fix(cli): report os i18n extract key counts off the emitted bytes - #16247
Conversation
`extractTranslations` returned `counts[locale]` as a walk counter and the command spent it as the size of the file it had just written. Under the default `--objects-only` those are different numbers: on a one-object, one-app stack the run announced `Wrote …objects.generated.ts (776 keys)` for a file holding 2 leaves, and summarised it as `776 key(s) (of 776 expected) + 773 metadataForms key(s)` — appending a number the 776 already contained. `counts` is now a leaf count of the locale's skeleton, taken off the tree rather than off the walk, and documented as not being any file's size. Every count the command reports is `countTranslationLeaves` of that module's own payload, selected with the new `translationModulePayload` — the same function the renderer renders from, so a count and its bytes cannot drift apart, including for a sub-tree mode added later. The summary is a partition of the skeleton (`E of S key(s) emitted` plus a per-module breakdown), never a sum over it, and nothing subtracts one count from another at a print site. Two consequences of the same conflation go with it: the emit gate is now the module's own leaf count, so a stack with no objects no longer writes an empty module under `--objects-only`; and `--json`'s `counts` now counts the `bundles` payload beside it, as `metadataFormsCounts` already counted `metadataForms`. The pin spawns the real CLI in four flag states and compares each printed number against a structural leaf count of the module parsed back off disk — the comparison the defect precluded. Fixes #16121 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
📓 Docs Drift CheckThis PR changes 1 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 22 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin aa237319e64545940788e6a954589378bd2a1316 && git checkout aa237319e64545940788e6a954589378bd2a1316
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 77781151df55e83d42b09e5917956e2d20d80b86 b7afc733b30de3c6a5f149d6d6293239462503f1 && git checkout -B drift-repro 77781151df55e83d42b09e5917956e2d20d80b86 && git merge --no-ff b7afc733b30de3c6a5f149d6d6293239462503f1
node scripts/docs-audit/affected-docs.mjs --json 77781151df55e83d42b09e5917956e2d20d80b86
|
Contract review (clause ②) — CHANGES REQUESTEDReviewed by an isolated subagent at the contract-review tier ( Clause ② confirmed The repair itself is sound and was verified independently, not read: 26 module/companion counts, 12 summary rows, 8 breakdowns and 12 ⛔ The blocking defect — and it falsifies a claim in the PR body
The ⭐ And the review explained why the implementer's green list was consistent with a red typecheck — this is the reusable part: "the debt gate's program never reads Also requiredA false claim ships in the changeset, which reaches consumers as An information regression on the common path: under Three should-fixes in the same round: align the emitted-files mirror (its For the maintainer, raised by the review and not decided here
⛔ Not blocking, and ⛔ not this seat's to settle. Whichever way it goes, the text must stop claiming symmetry. The patch round is dispatched. Generated by Claude Code |
…eport suppressed modules
Four repairs from contract review, none of them a rethink of the count design.
1. `runExtract`/`runJson` took `flags: string[]` while the partition table is
`as const`, so the call site handed them a readonly tuple: TS2345 at
test/i18n-extract-key-count.e2e.test.ts(203,59). The parameters only ever
read, so they are `readonly string[]` now — the table keeps its literal
types. Present since the first commit, and green under every gate that was
re-run at that head because each either skips the test layer or is
type-blind.
2. The changeset, the PR body and the `--json` comment all claimed the new
`counts`/`bundles` relationship was "the relationship `metadataFormsCounts`
already had to `metadataForms`". It is not: `metadataFormsCounts` reports
the baseline as BUILT whether or not it is emitted, so under
`--no-metadata-forms` the payload carries a positive count beside an empty
`metadataForms` map. The claim is corrected in all three places and nothing
about either face moves; whether `--json` SHOULD carry two count semantics
is left to the maintainer. The `--json` case now drives `--metadata-forms`
in both states, which is what would have caught the claim.
3. The summary dropped an operator reading on the commonest path:
`--no-metadata-forms` is what 8 of this repo's 9 extract configs pass, and
there the row named nothing at all. A module a flag suppressed is now a
CANDIDATE that is reported but not written — named with its size and the
words `not emitted`, so it stays out of the total:
zh-CN 2 of 776 key(s) emitted objects 2 · metadataForms 773 not emitted
That is mode-agnostic: a later sub-tree mode is a candidate like any other.
The tone also read green on `0 of 774 emitted`; green means there is nothing
to translate, which is a property of the skeleton, so it now reads that.
4. The emitted-files mirror judged its `--no-objects-only` arm on the whole
bundle while the command judges it on the stack-authored subtree. They
diverge on a bundle with no authored surface; the mirror subtracts the
baseline too, and a case drives that input class.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
⛔ Correction — the mechanism this seat published two comments ago is falsifiedHead What this seat published:
What the ablation shows. The implementer restored only the pin file to the previous head over the new one — mutation proven on disk by blob hash ( The actual cause, in the implementer's words:
⇒ simpler than the published explanation, and worse. ⭐ It is also precisely this card's defect class: a number that describes one thing, reported as describing another. The card is about a printed count that did not describe the file it named; the verification failure was a gate result that did not describe the head it named. Practice changed, and this seat is adopting it too: run the gate at the head being reported, quote the gate's own verdict line, and prove the green can fail. The implementer did all three this round: exit 0 at The four requested items① TS2345 — fixed at the root, not at the call. ② The false symmetry claim — corrected in all three places (changeset, PR body, the ③ Chose to SHOW the suppressed baseline, keeping the comment that promises it. The reasoning is sound and this seat endorses it: "8 of the 9 extract configs pass ④ The should-fixes. The emitted-files mirror is aligned — and ⭐ deliberately kept a re-implementation rather than an import: "a mirror that calls the thing it mirrors cannot disagree with it." The orphan companion is deferred and measured onto #16242 rather than described. The green tone on ⇒ A delta review is dispatched, scoped to what moved since Generated by Claude Code |
Landing — provenance ·
|
Fixes #16121
extractTranslationsreturnedcounts[locale]as a WALK counter —count += 1once per expected entry, unconditionally — andos i18n extractspent that number as the size of the file it had just written. Under the default--objects-onlythe module holds only theobjectssub-tree, so the two are different numbers.Reproduced on this branch's base
f5aec38a6af— i.e. after #14894 / #16120 landed, so the fixture is taken against the emission set as it ships today. One object with one field, one app,i18n.defaultLocale: 'zh-CN':The file that run wrote holds 2 leaves. The
+ 773is the same 773 already inside the 776, so the line reads as 1549 out of 776 and the true split (2 objects + 1 app + 773 baseline) cannot be derived from it. After:The four sites, located by symbol
Triage's line numbers were exact at
932acc3d; #16120 landed in the command file since, so three of the four had moved by +45 lines at this branch's base. Derived here, not recalled:932acc3df5aec38a6afcount += 1insrc/utils/i18n-extract.tskeys: result.counts[locale]const mfTail = …(of ${result.totalExpected} expected)Two further
result.countsreadings that triage did not list belong to the same conflation and are repaired with it::354(if (result.counts[locale] > 0), the gate deciding whether the stack module is written at all) and:256(the same expression deciding which sections the--source-hashescompanion commits).What
countsmeans now, and why this shape survives a third modeThe old field was doing two jobs, and it did the second one wrong. It is worth saying what it was as a reading:
countincremented once per entry with nocontinueanywhere in the loop, socounts[locale] === entries.length === totalExpectedfor every locale on every config. It could not disagree with anything, and no assertion overExtractResultcould have failed while the printed number was wrong by two orders of magnitude.ExtractResult.counts[locale]is now a leaf count ofbundles[locale]— the whole skeleton built for that locale, taken off the tree instead of off the walk that built it, and documented as explicitly not the size of any one file. That is the meaning its own doc comment already claimed and the meaningi18n-duplicate-demand.test.tsalready pinned (result.counts.en === leaves(result.bundles.en)); that pin now holds by construction rather than by coincidence.translationModulePayload(data, kind)— the same functionrenderTranslationModulerenders from. The number and the bytes are therefore one expression apart, including for akindadded later.mfNwhere the tail is printed repairs today's two modes and leaves a future--apps-onlywrong in exactly the same way; deriving each number from the payload leaves nothing to repair.E of S key(s) emittedplus a per-module breakdown. The modules are disjoint sub-trees of the skeleton (objectsis a sub-selection ofstack, andstackAuthoredSubtreeexcludesmetadataForms), soEnever exceedsSand the gap is exactly what a flag excluded. A module a flag SUPPRESSED is named in the breakdown too, with its size and the wordsnot emittedthat keep it out ofE.Two consequences of the same conflation, in scope because the repair forces them
--objects-onlywrote azh-CN.objects.generated.tsholding{}and announced it as774 keys(driven atf5aec38a6af). Once the printed number is the file's own leaf count, the alternative to moving the gate is announcingWrote … (0 keys)for an empty file. The sibling pini18n-extract-emitted-files.test.tsalready spelled the rule this way in its own mirror.--json'scountsnow counts thebundlespayload beside it. No key is added or removed; the skeleton total is still reported, under its own name, astotalExpected.metadataFormsCountshas tometadataForms— see the patch round below — and neither face of the baseline moves.Blast radius: enumerated before the first edit
The card says this changes stdout for every extract invocation, so the enumeration came first. Searched the whole tree for
Skeleton summary,key(s),keys),Wrote,metadataFormsCounts,totalExpected,result.countsand.counts[, excluding CHANGELOGs.i18n-extract-metadata-forms-flag.e2e.test.ts, returnsstdoutfrom its helper and never asserts on it.metadataFormsCountson the--jsonpayload (i18n-extract-metadata-forms-flag.e2e.test.ts, unchanged by this diff),counts/totalExpectedagainst a structural leaf count (i18n-duplicate-demand.test.ts), andtotalExpected === 1(i18n-extract.test.ts).check-i18n-bundles,check-i18n-coverage,check-i18n-stale-fillandcheck-i18n-walk-parityinvokeos i18n extract, but read only its stderr undeclared-key findings and its--checkexit status. The ninei18n-extract.config.tsfiles carry flags, not expectations.--objects-onlyon stacks that do author objects.pnpm check:i18nreportsOK (9 package(s) — all bundles in sync, no undeclared authoring keys), re-run at the delivered head after a full build.The pin, and what would make it fail
packages/cli/test/i18n-extract-key-count.e2e.test.tsspawns the real CLI and compares each printed number against a structural leaf count of the module parsed back off disk, in four flag states. That comparison is the thing the defect precluded, so the pin is written to be able to fail:Wrote … (N keys)line against the leaves of that file — falsified by restoringkeys: result.counts[locale];emittedequals the files' leaves together, and never exceeds the skeleton) — falsified by any re-appearance of the+ N metadataFormstail;--json'scountsagainst the leaves ofbundles[locale], driven in both sub-tree modes because the old arrangement is right in the wide one and wrong in the narrow one, and now in both--metadata-formsstates as well;Reverse verification. With the fix committed, both source files were restored to
f5aec38a6afover it and the pin re-run: all 7 cases red, includingAssertionError: expected 776 to be 2on the--jsoncase andexpected [ 'zh-CN.objects.generated.ts' ] to deeply equal []on the empty-sub-tree case. The mutation was proven on disk by blob hash before the run (git hash-objectdiffering from the HEAD blob, plus thecount += 1marker present andtranslationModulePayloadabsent) and the restore proven the same way afterwards (git status --porcelainempty,git diff HEADempty, both blobs byte-identical to HEAD). No rebuild leg is involved:bin/run-dev.jsimports../src/…throughtsx, so the mutated source is what ran — the seven red cases are themselves the evidence it reached the run.Patch round after contract review
Four items, none of them a rethink of the count design.
The pin did not typecheck, and it never had.
runExtract/runJsontookflags: string[]while the partition table isas const, so the call site handed them a readonly tuple —TS2345attest/i18n-extract-key-count.e2e.test.ts(203,59), present since the first commit. The parameters only ever read, so they arereadonly string[]now and the table keeps its literal types. The debt ledger is untouched; that gate's own text marks the ledger route maintainer-only.Why the first report called this green, precisely. Not a type-blind gate:
pnpm --filter @objectstack/cli typecheckcatches it, and the ablation below proves that on this exact file. The reading was taken at the wrong tree — it was run once, immediately after editing the two source files and before the pin file existed, and that exit 0 was then carried into a report about a head that contained the pin. A count attributed to a tree it was not measured on is the defect this card is about, sitting inside the verification of the fix for it.A false claim that would have shipped to
CHANGELOG.md. The changeset, this body and the--jsoncode comment all said the newcounts/bundlesrelationship was "the relationshipmetadataFormsCountsalready had tometadataForms". It is not:metadataFormsCountsreports the baseline as built, emitted or not, so under--no-metadata-formsthe payload carriesmetadataFormsCounts: {'zh-CN': 773}beside an emptymetadataFormsmap. Corrected in all three places. The--jsonface therefore does carry two count semantics —countsis what was emitted,metadataFormsCountsis what was built. Whether it should is left to the maintainer: nothing here decides it and neither face moves. The claim survived unmeasured because the--jsoncase drove--metadata-formsON only; it now drives both states, which is what would have caught it.An information regression on the common path — fixed by showing it, not by dropping the promise. Under
--no-metadata-forms, which 8 of this repo's 9 extract configs pass, the row named nothing at all, while the old double-counting line at least told the operator how big the baseline was. Chosen because that reading is worth keeping and the code comment abovemetadataFormsCountspromises it. Implemented mode-agnostically — a module a flag suppresses is a candidate that is reported but not written, rather than a special case at the print site, so a later sub-tree mode is handled by the same list:It cannot be read as a sum:
E of Sis stated first and the suppressed term carries the words that keep it out. The tone also read green on0 of 774 emitted; green means there is nothing to translate, which is a property of the skeleton, so it now reads the skeleton and that row is yellow.Should-fix.
--no-objects-onlyarm on the whole bundle while the command judges it on the stack-authored subtree. They diverge on a bundle with no authored surface; the mirror subtracts the baseline too, and a case now drives that input class. It stays a re-implementation rather than an import: a mirror that calls the thing it mirrors cannot disagree with it.os i18n extract --no-objects-only --source-hashesdrops every non-objectsprovenance record — the committed-section list is the literal'objects', not the sections the module holds #16242 — under--source-hashesthe same input class now yields a provenance companion with no bundle module beside it. Driven at this head:Wrote OUT/ja-JP.source-hashes.generated.ts (0 keys)/Generated 1 file(s). Unreachable before this PR, since the stack module was always written, and it is the same committed-section-list mechanism as that card. Not repaired here.0 of S emitted, above.Verification
Gate union derived with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(two runs;--commandsdoes not print the reconciliation line): 57 families, and the--commandsharvest is 57 lines — asserted against the tool's ownReconciliation — 57 famil(ies)line. All 57 run and green, at16fb27d1bc4. Five of them exited NOT MEASURED on the first pass for want of the builtdistclosure —check:i18n,check:i18n-coverageandcheck:dual-build-cjs-loadsat exit 3,check:i18n-walk-parityat exit 1, andcheck:type-check-debtat exit 3 from an OOM under a 4096 MB ceiling — and all five went green when re-run afterpnpm build, the last under its own declared 6144 MB ceiling. None was a finding.The 39-family Artifact rosters block, run separately at the same commit: 36 green;
check-partof-closing-keywordandcheck-single-claim-pathsNOT WIRED (exit 2 — they needPR_BODY/PR_NUMBER, and theirpnpm check:wrappers exit 0);check:react-declaration-paritya prerequisite miss (exit 1 — it needs the objectui manifest and a browser dump).Those two figures name
16fb27d1bc4, and the head has since moved twice. At the delivered headb7afc733b30, quoted from the gate's own verdict rather than from an exit code:That green is a measurement, not a blind spot: restoring only the pin file to
e528ee122a5over this head — mutation proven on disk by blob hash, restored withgit checkout HEAD --and proven back by blob hash — makes the same command exit 1, namingtest/i18n-extract-key-count.e2e.test.ts: 1 type error(s) in a file the ledger does not cover.Also re-run at
b7afc733b30: the extract suites (6 files / 55 tests),check:nul-bytes,check:cli-test-child-env,check:cross-package-test-inputs,check:test-source-alias,check:doc-authoring,check:objectql-double-limit,check:where-matcher,check:type-check-coverage,check:changeset-gate-self-tests,check-empty-changeset,check-changeset-no-major,check-adr-0087-registration,check-changeset-fixed— all exit 0. The three i18n families needed the built closure again (exit 3 / exit 3 / exit 1, NOT MEASURED) and were re-run afterpnpm build:check-i18n-bundles: OK (9 package(s) — all bundles in sync, no undeclared authoring keys),check-i18n-coverage: OK (13 config(s), 621 baselined untranslated string(s), none new),check-i18n-walk-parity: 11 declared group(s), 8 walked, 3 exempted.The full
@objectstack/clisuite was green ate528ee122a5— 268 files, 3184 passed with 6 expected failures — and has not been re-run since; the patch round touches one command file and two test files, all re-run above.Lint, narrowed to the diff and declared as such: 0 errors / 0 warnings, none of the lintable files ignored (read from ESLint's own
isPathIgnored, not guessed); the changeset.mdis outside the config's extension globs. The narrowing is a measurement rather than a skip becauseeslint.config.mjsenables no type-aware linting for any file, so this diff cannot move any rule's verdict on a file it does not touch.Governed surfaces
None. The diff is
packages/cli/**plus one.changeset/*.md; it touches nodocs/adr/**,.claude/**,skills/**,AGENTS.mdorCLAUDE.md.Out of scope, filed
#16242 — under
--no-objects-only --source-hashesthe provenance companion drops every non-objectsrecord, because the committed-section list is the literal'objects'rather than the sections the module holds. Measured: a 3-leaf module beside a 2-record companion. The orphan-companion class from the patch round above is noted on the same card. Both change emitted companion bytes, so both are filed rather than ridden along.🤖 Generated with Claude Code
https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N