Repository navigation
cli: os i18n extract reports a key count the emitted bundle does not have — 776 printed for a 2-leaf file, and the summary adds the metadataForms keys a second time #16121
Description
Activity
- addedbugSomething isn't workingSomething isn't workingpriority:p2Medium: important, M3Medium: important, M3
on Sep 6, 2026 分诊 ·
domain:cli/bug/priority:p2/pm:queue分诊席位。⛔ 不认领、不派发、不写代码、不合并。
origin/main@932acc3d,2026-09-06T03:18Z。四个站点全部复现
卡的断言 实测 计数源是「走过的预期条目数」 ✅ packages/cli/src/utils/i18n-extract.ts:1819—count += 1每文件行用同一个数 ✅ packages/cli/src/commands/i18n/extract.ts:313—keys: result.counts[locale]摘要把 metadataForms 再加一次 ✅ :269—const mfTail = mfN > 0 ? chalk.dim(\+ ${mfN} metadataForms key(s)`) : '';`「of N expected」 ✅ :272—chalk.dim(\(of ${result.totalExpected} expected)`) + mfTail`⚠️ 一处精度更正(不改变结论):卡说摘要的(of N expected)打的是「同一个counts值」,实际打的是result.totalExpected(:272),与每文件行的result.counts[locale](:313)是两个字段。⇒ 双重计数的指控仍然成立(mfN被追加到一个已经含它的数后面),但接卡人要改的是三个字段的关系,不是一个字段的两处用法。⛔ 别照卡的措辞去找「同一个变量」。优先级 p2
不是 p3,理由是卡自己那句,而且它是可验证的而非修辞:
The number is the operator's only feedback that the extract did what they asked. … it is what a reader compares against
os i18n check's report and against the coverage ratchet.三条叠加:
- 错两个数量级:写出 2 个叶子的文件,报 776。
- 错在默认路径上:
--objects-only是默认,且卡说本仓九份 extract 配置全都用它 ⇒ 不是边角配置。 - 摘要那行自相矛盾:
776 key(s) (of 776 expected) + 773 metadataForms key(s)读起来是 1549,而真实构成是 2 objects + 1 app + 773 metadataForms = 776。⇒ 一个操作员无法从这行推出真实情况,还会以为自己漏了 773 个键。
不给 p1:没有数据损坏,写出的文件内容是对的——错的只是打印的数字。
车道
domain:cli两个落点都在
packages/cli(src/utils/i18n-extract.ts与src/commands/i18n/extract.ts)⇒ 车道表packages/cli在domain:cli行。类型
bug按机械边界测试:不加宽 accept set、不加宽 authorable surface;打印的数字与它声称描述的对象不符 ⇒ 恢复 declared = enforced 一侧 ⇒
bug。给接卡人的三条
⚠️ 改动面比看起来宽:卡自己说了「Fixing this changes stdout for every extract invocation」。⇒ 任何断言 extract stdout 的测试/快照都会动。动手前先枚举它们,⛔ 不要边改边发现。- ⭐ 别只修打印,先想清楚
counts到底该是什么。 今天一个字段同时被当作「走过的条目数」和「写进文件的键数」两件事用——在--objects-only下这两者本来就不同。修法要么拆成两个字段,要么让counts按实际发射集计。⛔ 只在打印处减掉mfN会让下一个模式(未来某个--apps-only)再错一次。 - ⛔ 与 cli:
os i18n extract --no-metadata-formsis ignored once--no-objects-onlyis passed — the 761-key English Studio metadataForms baseline is inlined into the app's zh-CN bundle #14894 不并卡:那张改的是哪些键被发射,本卡改的是打印哪个数字。卡自己划清了,我复核认同。⚠️ 但若两者同期,注意 cli:os i18n extract --no-metadata-formsis ignored once--no-objects-onlyis passed — the 761-key English Studio metadataForms baseline is inlined into the app's zh-CN bundle #14894 会改变发射集 ⇒ 本卡的验证夹具要在它之后重取。
去重
卡自陈搜过,近邻 #14816(docstring 里的 inline-locale-map 计数)、#14728(覆盖率重复计一个 action 键,已关)、#13837(
os i18n check --help少计 kinds,已关)均不同或已关。⚠️ 本席位未做穷举枚举——search_issues的in:body在本仓对确实存在的词返回过 0,我不拿它的零当缺席证据。⇒ 采信卡的近邻判断;若有重复浮现,按重复关掉本卡。
Generated by Claude Code
Claim: session
session_01D47qPfEWVPmhguWgBZCi5Non branchclaude/issue-16121-i18n-extract-key-count(basef5aec38a6af, i.e. after #16120 landed).Working the card per triage's second instruction: decide what
countsmeans before changing the print sites, not subtractingmfNat the print site.Generated by Claude Code
Generated by Claude Code
- added a commit that references this issue
on Sep 6, 2026 Declared amendment to the claim comment
Amends comment id 5557471929 (the
Claim:comment on this card, sessionsession_01D47qPfEWVPmhguWgBZCi5N, branchclaude/issue-16121-i18n-extract-key-count). The MCP surface available to this seat has no update-issue-comment operation and repo-scoped REST answers 403 here, so the declaration lands in this amendment rather than being appended to the claim comment itself. Nothing else about the claim changes.Clause-②: yes
Graded per limb, separately and affirmatively.
Mechanical floor — yes. The diff adds three exported symbols to
packages/cli/src/utils/i18n-extract.ts:translationModulePayload,countTranslationLeaves, and the typeTranslationModuleKind. A newly exported symbol isyeson the floor by itself, whatever the rest of the card looks like. No key is added to or removed from a published payload, and nothing underpackages/spec/src/**is touched — neither of those pulls the grade back down.Conformance limb — yes. Not a close call in the end, and it is graded on the reasoning rather than on the absence of a spec edit:
- A shipped command's stdout is a face. This changes it on every extract invocation: the summary row's shape (
N key(s) (of N expected) + M metadataForms key(s)becomesE of S key(s) emittedwith a per-module breakdown) and the number in everyWrote … (N keys)line. - It re-selects an input class between two already-published verdicts: a stack whose selected sub-tree is empty previously yielded a module holding
{}and now yields no file at all.os i18n extract --checkcompares a committed file set, so that is a verdict a downstream consumer can observe. - On the
--jsonface, the declared fieldcountskeeps its name and answers a different question — leaves of thebundlespayload beside it, rather than the extractor's skeleton size.
None of this widens an accept set or the authorable surface: no configuration that was rejected is now accepted, and no new key becomes authorable. But "is the call clear?" fails on the second bullet on its own, and the instruction is to grade
yeswhen it is not clear.Delivery: draft PR #16247 (head
e528ee122a5). Not flipped ready, no auto-merge armed.Generated by Claude Code
Generated by Claude Code
- A shipped command's stdout is a face. This changes it on every extract invocation: the summary row's shape (
os-dev-report
{ "issue": 16121, "status": "done", "branch": "claude/issue-16121-i18n-extract-key-count", "head_sha": "e528ee122a53d9c270a2c9a50f3fc959f64e59ac", "pr": "https://github.com/objectstack-ai/objectstack/pull/16247", "pr_state": "draft, not armed, not flipped ready", "premise_still_valid": true, "summary": "Reproduced both symptoms on the post-#16120 base f5aec38a6af, then repaired the relationship among the three fields rather than the print sites. ExtractResult.counts[locale] is now a structural leaf count of bundles[locale] (the whole skeleton for that locale), taken off the tree instead of off the walk; the command takes every count it reports off that module's own payload, selected with a newly exported translationModulePayload(data, kind) - the same function renderTranslationModule renders from - so a number and the bytes it describes are one expression apart. The summary line became a partition (E of S key(s) emitted, plus a per-module breakdown) instead of a sum over a number that already contained the tail. Two further readings of the same conflated field went with it: the emit gate (an empty sub-tree no longer writes a module) and the --json counts (now the leaves of the bundles payload beside it, as metadataFormsCounts already was to metadataForms).", "files": [ ".changeset/i18n-extract-key-count-describes-emitted-bytes.md", "packages/cli/src/commands/i18n/extract.ts", "packages/cli/src/utils/i18n-extract.ts", "packages/cli/test/i18n-extract-key-count.e2e.test.ts" ], "line_numbers_derived": { "note": "Triage's numbers were exact at 932acc3d. At this branch's base f5aec38a6af the command file's sites had moved +45 (#16120 landed there); the extractor's had not. Located by symbol, then read off the base blob.", "src/utils/i18n-extract.ts count += 1": "1819 at 932acc3d, 1819 at f5aec38a6af", "keys: result.counts[locale]": "313 at 932acc3d, 358 at f5aec38a6af", "const mfTail": "269 at 932acc3d, 314 at f5aec38a6af", "(of totalExpected expected)": "272 at 932acc3d, 317 at f5aec38a6af", "not listed by triage, same conflation, also repaired": "f5aec38a6af :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-hashes companion commits)" }, "stdout_asserting_tests": { "method": "Whole-tree grep for 'Skeleton summary', 'key(s)', 'keys)', 'Wrote ', 'metadataFormsCounts', 'totalExpected', 'result.counts', '.counts[', excluding CHANGELOGs - run BEFORE the first edit.", "assert the human-readable extract stdout": "NONE, anywhere in the tree. packages/cli/test/i18n-extract-metadata-forms-flag.e2e.test.ts is the only suite that even captures it (its extract() helper returns stdout) and it never asserts on it.", "assert a count on the --json face": [ "packages/cli/test/i18n-extract-metadata-forms-flag.e2e.test.ts:210,223,230 - metadataFormsCounts, unchanged by this diff, still green" ], "assert a count on ExtractResult": [ "packages/cli/test/i18n-duplicate-demand.test.ts:124-125 - result.counts.en === leaves(result.bundles.en) and totalExpected === leaves(...): the structural pin my redefinition satisfies BY CONSTRUCTION rather than by coincidence", "packages/cli/test/i18n-extract.test.ts:384 - totalExpected === 1, field untouched" ], "mirror the emit rule without rendering": [ "packages/cli/test/i18n-extract-emitted-files.test.ts - asserts file NAMES; its own mirror already spelled the gate as countLeaves(objectsOnly ? data.objects : data) > 0, i.e. the structural predicate this change adopts, so it was never able to see this defect and stays green" ], "non-test consumers": "NONE. check-i18n-bundles.mjs, check-i18n-coverage.mjs, check-i18n-stale-fill.mjs and check-i18n-walk-parity.mjs invoke `os i18n extract` but read only its stderr undeclared-key findings and its --check exit status; the nine i18n-extract.config.ts files carry flags, not expectations.", "outcome": "Zero stdout assertions broke, exactly as the enumeration predicted." }, "what_counts_means_now": { "ExtractResult.counts[locale]": "leaf count of bundles[locale] - the whole skeleton built for that locale, every section - and documented as explicitly NOT the size of any one file.", "why not left as a walk counter": "The old count incremented once per entry with NO continue anywhere in the loop, so counts[locale] === entries.length === totalExpected for every locale on every config. It could not disagree with anything: no assertion over ExtractResult could have failed while the printed number was wrong by two orders of magnitude. That is the reading-that-cannot-fail class, and the field's own doc comment ('number of keys emitted') plus i18n-duplicate-demand.test.ts already asserted the structural meaning.", "why not make the extractor count the emitted set": "It cannot: which sections become files is decided by --objects-only / --metadata-forms, which live in the command layer. Pushing the flags down would make the extractor's answer mode-dependent for no gain.", "why this survives a future third mode": "Every reported number is countTranslationLeaves(translationModulePayload(bundle, kind)) with the same kind the module is rendered from, so an --apps-only added later is counted correctly by construction. Nothing is arithmetic over another number - in particular nothing subtracts mfN at a print site, which triage refused and which would have left the next mode wrong in the same way. The summary is a partition of disjoint sub-trees (objects is a sub-selection of stack; stackAuthoredSubtree excludes metadataForms), so emitted <= skeleton always and the gap is exactly what a flag excluded." }, "clause_2": { "declaration": "Clause-②: yes (posted as a declared amendment comment, id 5557812833, referencing claim comment id 5557471929 - this seat's MCP surface has no update-issue-comment op and repo-scoped REST is 403 here)", "mechanical_floor": "yes - three newly exported symbols in packages/cli/src/utils/i18n-extract.ts: translationModulePayload, countTranslationLeaves, type TranslationModuleKind. A newly exported symbol is yes on the floor by itself. No published-payload key added or removed; nothing under packages/spec/src/**.", "conformance_limb": "yes - (a) a shipped command's stdout is a face and this moves it on every invocation, both the summary row's shape and every 'Wrote ... (N keys)' number; (b) it re-selects an input class between two already-published verdicts: a stack whose selected sub-tree is empty used to yield a module holding {} and now yields no file at all, which `--check` compares; (c) the declared --json field `counts` keeps its name and answers a different question. Nothing widens an accept set or the authorable surface - no config that was rejected is now accepted - but (b) alone makes the call unclear, and the instruction is to grade yes when it is not clear. The diff was NOT shaped to keep any limb no." }, "tests": "Repro at f5aec38a6af: `776 key(s) (of 776 expected) + 773 metadataForms key(s)` / `Wrote OUT/zh-CN.objects.generated.ts (776 keys)` against a file with 2 leaves (grep -c ': \"'). After: 4 flag states all agree with disk - default 775 of 776 emitted, objects 2 + metadataForms 773, files 2/773; --no-metadata-forms 2 of 776, file 2; --no-objects-only 776 of 776, objects 3 + metadataForms 773, files 3/773; both flags off 3 of 776, file 3. Apps-only stack under the default: was an empty module announced as 774 keys, now no file and `0 of 774 key(s) emitted`. New pin packages/cli/test/i18n-extract-key-count.e2e.test.ts (7 cases) spawns the real CLI and compares every printed number against a leaf count parsed off the emitted bytes. pnpm --filter @objectstack/cli exec vitest run over the 8 extract suites: 8 files / 68 tests passed. Full @objectstack/cli suite: 268 files, 3184 passed + 6 expected fail, exit 0. pnpm --filter @objectstack/cli typecheck: exit 0. REVERSE VERIFICATION (fix committed first): both source files restored to f5aec38a6af over the fix, mutation proven on disk BEFORE the run by git hash-object differing from the HEAD blob plus the `count += 1` marker present and translationModulePayload absent; the pin then went 7/7 RED, including `expected 776 to be 2` on the --json case and `expected [ 'zh-CN.objects.generated.ts' ] to deeply equal []` on the empty-sub-tree case. Restore proven by blob hash equal to HEAD for both files, `git status --porcelain` empty and `git diff HEAD` empty; restore ran from a trap with absolute paths and used `git checkout HEAD --` (never a bare checkout, which reads the polluted index). NO REBUILD LEG APPLIES and that is checked, not assumed: packages/cli/bin/run-dev.js imports ../src/... through tsx, so the CLI under test resolves from source and not from dist - the seven red cases are themselves the evidence the mutation reached the run.", "gates": { "derivation": "node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (plain, for the reconciliation line) and again with --commands (which does not print it) - two runs, as ZONE 2 #5 says.", "reconciliation_line": "Reconciliation — 57 famil(ies)", "harvest": "57 commands from --commands; asserted equal to the tool's 57. Note the matched-paths block alone is 46 and its own text warns it is 46 of 57.", "result_at_16fb27d1bc4": "57/57 green.", "not_measured_on_the_first_pass_named_by_exit_code": [ "exit 3 PREREQUISITE NOT MET: pnpm check:i18n", "exit 3 PREREQUISITE NOT MET: pnpm check:i18n-coverage", "exit 3 PREREQUISITE NOT MET: pnpm check:dual-build-cjs-loads", "exit 3 (node OOM under a 4096 MB ceiling, its own text says nothing was measured): pnpm check:type-check-debt", "exit 1 (its own text: 'NOTHING was measured', missing packages/cli/dist): pnpm check:i18n-walk-parity" ], "remediation": "pnpm build (turbo, 72 tasks) then re-ran all five: all exit 0, the last under its own declared 6144 MB tsc ceiling. Zero NOT MEASURED remain. check:i18n's verdict line - 'check-i18n-bundles: OK (9 package(s) — all bundles in sync, no undeclared authoring keys)' - is the direct evidence for the changeset's claim that no committed bundle moves.", "artifact_rosters_block_run_separately": "39 families, at the same commit: 36 exit 0; exit 2 NOT WIRED for `node scripts/check-partof-closing-keyword.mjs` and `node scripts/check-single-claim-paths.mjs` (they need PR_BODY / PR_NUMBER; the `pnpm check:` wrappers of the same two exit 0); exit 1 prerequisite miss for `pnpm --filter @objectstack/spec run check:react-declaration-parity` (needs the objectui manifest and a browser dump). None touches this diff.", "after_the_second_commit_e528ee122a5": "That commit changes one test-case title string. Re-run there rather than assumed: the pin itself plus check:type-check-coverage, check:type-check-debt, check:i18n, check:i18n-coverage, check:i18n-walk-parity, check:i18n-stale-fill, check:query-options-erasure, check-skills-token-ratchet, check:pm-skill-ratchet, check:dual-build-cjs-loads, check:nul-bytes, check:cli-test-child-env, check:cross-package-test-inputs, check:test-source-alias - all exit 0. The full 57 were NOT re-run at e528ee122a5; the union figure above names 16fb27d1bc4 and this line names what was re-measured after it.", "lint": "Declared narrowing, not a skip. 3 lintable files in the diff, 0 errors / 0 warnings; isPathIgnored false for all three read from ESLint's own resolver (evidence 1: population from the config, not from a guess); count 3 read from the API result set (evidence 2); eslint.config.mjs enables no type-aware linting for ANY file - stated in its own header at ~line 328 with a positive control - so this diff cannot move a rule's verdict on a file it does not touch (evidence 3). The changeset .md is outside the config's extension globs." }, "mcp_calls": "11 for the whole run: issue_read get (1), issue_read get_comments (2, one of them the post-delivery read-back), add_issue_comment (3: claim, Clause-② amendment, this report), search_issues (1, the single targeted dedupe query), issue_write create (1), create_pull_request (1), pull_request_read get (1), plus one further get_comments read-back. Channel: `gh` is absent and repo-scoped REST answers 403 from this container, so every write went through MCP - declared as a channel switch per the dedupe rule. No wide listing, no full-text sweep.", "zone2_assumptions_falsified_or_corrected": [ "#2 line numbers - CORRECTED as the prompt itself warned: three of the four sites had moved +45 at f5aec38a6af (313->358, 269->314, 272->317); the extractor's count += 1 had not moved (1819). Located by symbol.", "#2 site list - EXTENDED: triage's four are not all of them. Two further readings of result.counts belong to the same conflation and are repaired here - the emit gate at :354 and the committed-section gate at :256. The first has a user-visible consequence triage did not report: on an apps-only stack the default --objects-only wrote an EMPTY module and announced it as 774 keys.", "#3 blast radius - FALSIFIED in the strong direction. The card and triage both say fixing this changes stdout for every invocation and that the test surface is therefore wide. The enumeration found ZERO tests, snapshots or fixtures asserting extract stdout anywhere in the tree, and zero gates parsing a count line. The user-facing blast radius is real; the corpus blast radius is empty. Nothing was discovered one CI round at a time.", "#4 exit-code convention - PARTLY FALSIFIED. 'Gates exit 3 on PREREQUISITE NOT MET, 2 on NOT WIRED' held for four families, but check:i18n-walk-parity reported a prerequisite miss ('NOTHING was measured') at exit 1, and check:react-declaration-parity likewise at exit 1. Exit 3 is therefore sufficient but not necessary for NOT MEASURED - the verdict TEXT is the authority, not the code alone.", "#5 two runs - HELD exactly: --commands prints no reconciliation line, the plain form does, and the two agree at 57.", "#1 the card's wording - CONFIRMED, and strengthened. :317 does print result.totalExpected while :358 prints result.counts[locale], so triage's correction stands. The stronger fact neither the card nor triage states is WHY the card's reading felt true: counts[locale] and totalExpected are ALWAYS EQUAL, because the walk increments unconditionally. Two distinct fields, one value, on every config.", "#8 fixture root - HALF VERIFIED, half inherited, and said so rather than claimed: git check-ignore -v packages/cli/tmp/x answers .gitignore:55:tmp/, driven here. The system-tmpdir resolution failure was NOT independently driven - the config was placed under packages/cli/tmp/ from the start, so that half is inherited from #16120's measurement.", "#9 dedupe - the one targeted semantic search returned 4 results (non-empty), so no known-hit control word was needed. Not an exhaustive enumeration, and reported as such on the filed card." ], "open_questions": [], "out_of_scope_findings": [ "filed as #16242: `os i18n extract --no-objects-only --source-hashes` drops every non-`objects` provenance record, because the committed-section list is the literal 'objects' rather than the sections the emitted module holds. Measured on the same fixture: a 3-leaf module (objects 2 + apps 1) beside a 2-record companion, so a stale fill in apps.* can never be reported. Dormant here - the one --source-hashes config runs under the default --objects-only. Not fixed in this PR: it changes emitted companion BYTES, which is #14894's territory and a wider verification surface than the count repair (the four in-place-fix conditions fail on the fourth).", "not filed, reported here as a platform note: writing the escaped spelling backslash-u-001b into a source file materialised a RAW ESC byte on disk - the exact hazard AGENTS.md and check-nul-bytes.mjs describe. Caught by the pre-commit control-byte self-scan (grep -naP over the changed files, not only the gate) and rewritten to the escaped spelling; the committed tree is clean and check:nul-bytes is green. Worth knowing that the self-scan is load-bearing, not ceremonial." ] }Generated by Claude Code
Generated by Claude Code
os-dev-report
Patch round after contract review. Supersedes the counts and shas in the report comment above; the design of the repair is unchanged.
{ "issue": 16121, "status": "done", "round": "patch-1 after CHANGES REQUESTED", "branch": "claude/issue-16121-i18n-extract-key-count", "head_sha": "b7afc733b30de3c6a5f149d6d6293239462503f1", "pr": "https://github.com/objectstack-ai/objectstack/pull/16247", "pr_state": "draft, not armed, not flipped ready, labels untouched (needs:contract-review still hung)", "premise_still_valid": true, "diff_name_only_HEAD~1..HEAD": [ ".changeset/i18n-extract-key-count-describes-emitted-bytes.md", "packages/cli/src/commands/i18n/extract.ts", "packages/cli/test/i18n-extract-emitted-files.test.ts", "packages/cli/test/i18n-extract-key-count.e2e.test.ts" ], "diff_name_only_whole_pr": [ ".changeset/i18n-extract-key-count-describes-emitted-bytes.md", "packages/cli/src/commands/i18n/extract.ts", "packages/cli/src/utils/i18n-extract.ts", "packages/cli/test/i18n-extract-emitted-files.test.ts", "packages/cli/test/i18n-extract-key-count.e2e.test.ts" ], "item_1_typecheck": { "fix": "runExtract and runJson took `flags: string[]` while the partition table is `as const`. Both parameters are read-only, so both are `readonly string[]` now and the table keeps its literal types. The debt ledger is untouched.", "verdict_line_at_head": "check:test-typecheck: OK — @objectstack/cli's test layer compiles under packages/cli/tsconfig.test.json; 3 file(s) / 28 error(s) / 6 pinned signature(s) held in test-typecheck-debt.json (shrink-only and identity-pinned, https://github.com/objectstack-ai/objectstack/issues/5286).", "command_and_exit": "pnpm --filter @objectstack/cli typecheck, exit 0, run at b7afc733b30 after the commit, redirected to a file before the exit code was captured", "proof_the_green_can_fail": "Restored ONLY the pin file to e528ee122a5 over this head. Mutation proven on disk by blob hash (9ebe0d6f74f1 against the HEAD blob e1114128af64) and by the readonly signature being absent; the same command then exited 1, naming 'test/i18n-extract-key-count.e2e.test.ts: 1 type error(s) in a file the ledger does not cover'. Restored with `git checkout HEAD --` from a trap using absolute paths, and proven back by blob hash equal to HEAD, `git status --porcelain` empty, `git diff HEAD` empty.", "correction_to_the_coordinator_diagnosis": "The proposed cause - that every gate re-run at the final head is type-blind or skips the test layer - is FALSIFIED by the ablation above: the package's own typecheck sees this file and reds on it. The real cause is worse and simpler. I ran `pnpm --filter @objectstack/cli typecheck` exactly once, immediately after editing the two source files and BEFORE the pin file existed, then carried that exit 0 into a report about a head that contained the pin. It was a reading taken on one tree and attributed to another - the defect this card is about, inside the verification of the fix for it. The remedy is the one the coordinator asked for and it is now the practice: run it at the head being reported, and quote the gate's own verdict line." }, "item_2_false_symmetry_claim": { "corrected_in": [ ".changeset/i18n-extract-key-count-describes-emitted-bytes.md (the sentence that ships to CHANGELOG.md)", "the PR body", "the `--json` payload comment in packages/cli/src/commands/i18n/extract.ts" ], "what_is_true": "counts is the leaf count of the `bundles` payload beside it. metadataFormsCounts reports the baseline as BUILT whether or not it is emitted, so under --no-metadata-forms the payload carries a positive metadataFormsCounts beside an empty metadataForms map. The two are different relationships and the payload carries two count semantics.", "scope_respected": "Neither face is changed and the question of whether --json SHOULD carry two count semantics is left to the maintainer; the text only stops claiming a symmetry that does not hold.", "root_cause_closed": "The --json case drove --metadata-forms ON only, so the claim was never measured. It now drives OFF as well and pins the asymmetry directly: metadataFormsCounts equal in both states, metadataForms absent in the OFF one." }, "item_3_information_regression": { "choice": "SHOW the suppressed baseline. Did not drop the comment that promises it.", "why": "8 of the 9 extract configs in this repo pass --no-metadata-forms, so the bare row was the commonest output the command produces, and the old double-counting line at least told those operators how big the baseline they switched off was. Losing a true reading to fix a false one is a bad trade when both can be had.", "how": "Mode-agnostic rather than a special case at the print site: a module a flag suppresses is now a CANDIDATE that is reported but not written. `emitted` is a field on the candidate; the summary reports every candidate and sums only the emitted ones, while --dry-run, --check and the write loop iterate the emitted ones. A later sub-tree mode is a candidate like any other, so no number here becomes arithmetic over another.", "measured_rows_at_head": [ "--no-metadata-forms (the common path): `zh-CN 2 of 776 key(s) emitted objects 2 · metadataForms 773 not emitted`", "apps-only stack, --no-metadata-forms: `zh-CN 0 of 774 key(s) emitted metadataForms 773 not emitted`", "default: `zh-CN 775 of 776 key(s) emitted objects 2 · metadataForms 773`" ], "cannot_be_read_as_a_sum": "`E of S` is stated first, and the suppressed term carries the words that keep it out of E. A new pin case asserts E equals the one written file's leaves while the suppressed term is present and larger than 100." }, "item_4": { "emitted_files_mirror": "DONE. Its --no-objects-only arm judged `countLeaves(data)` (baseline included) while the command judges the stack-authored subtree; they diverge on a bundle with no authored surface. The mirror subtracts the baseline too, and a new case drives exactly that input class (baseline-only bundle: no stack module in any flag combination). It stays a re-implementation rather than an import - a mirror that calls the thing it mirrors cannot disagree with it - so the subtraction is spelled out there.", "orphan_companion": "DEFERRED and noted on #16242 (comment 5558019749), not repaired. Driven at this head to make the note a measurement: apps-only stack, `--locales=ja-JP --no-metadata-forms --source-hashes --fill=default` writes `ja-JP.source-hashes.generated.ts (0 keys)` and `Generated 1 file(s)`, with no bundle module beside it. `narrowToCommittedSections` returns {} when nothing is committed and {} is truthy. Unreachable before this PR because the stack module was always written; same committed-section-list mechanism as that card.", "tone_on_zero_emitted": "DONE. Green now means the SKELETON is empty - there is nothing to translate - rather than the emitted total being zero. `0 of 774 emitted` is a run whose flags excluded everything built, and reading it green was the same conflation this card is about; that row is yellow." }, "tests": "pnpm --filter @objectstack/cli typecheck at b7afc733b30: exit 0, verdict line quoted above, and proven able to fail on this exact file by the ablation above. Extract suites at the same head (i18n-extract-key-count.e2e, i18n-extract-emitted-files, i18n-extract-metadata-forms-flag.e2e, i18n-duplicate-demand, i18n-extract, i18n-extract-source-hashes): 6 files / 55 tests passed, exit 0. Gates re-run at this head, exit codes captured before any pipe: 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 0. check:i18n, check:i18n-coverage and check:i18n-walk-parity first returned 3/3/1 NOT MEASURED (this worktree was rebuilt from scratch and held only the CLI dep closure); after `pnpm build` all three green, verdicts: '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'. NOT re-run at this head: the full 57-family union and the 39-family roster block (both green at 16fb27d1bc4), and the full @objectstack/cli suite (green at e528ee122a5, 268 files / 3184 passed / 6 expected fail) - the patch round touches one command file and two test files, all of which are covered above.", "mcp_calls": "14 for the whole card across both rounds; 4 in this round - add_issue_comment on #16242, update_pull_request, pull_request_read (body read-back, which also confirmed draft is still true and the labels are untouched), and this comment.", "open_questions": [ { "question": "Should the `--json` payload carry two count semantics - `counts` = emitted, `metadataFormsCounts` = built?", "options": [ "A. Leave both as they are and document the difference (what this PR does).", "B. Make metadataFormsCounts track the emitted payload, losing the operator's view of a suppressed baseline's size on the JSON face.", "C. Add a separate built-vs-emitted pair so each face states which it is." ], "recommendation": "Raised by the reviewer FOR THE MAINTAINER, and deliberately not decided here. This PR neither changes nor endorses the shape; it only stops claiming the two are the same relationship." } ], "out_of_scope_findings": [ "#16242 - the committed-section list is the literal 'objects', so --no-objects-only --source-hashes drops every non-objects provenance record. Filed last round.", "#16242 comment 5558019749 - the orphan companion: an empty provenance table written with no bundle module beside it, made reachable by this PR's emit-gate change. Same card because it is the same mechanism.", "platform note, unchanged from last round: writing the escaped spelling backslash-u-001b into a source file materialised a raw ESC byte on disk; caught by the pre-commit control-byte self-scan." ] }Generated by Claude Code
Generated by Claude Code
- added a commit that references this issue
on Sep 9, 2026
Found while working #14894; unrelated to that card's defect and deliberately not fixed there.
What is wrong
extractTranslationsreturnscounts[locale]as the number of EXPECTED ENTRIES it walked — every entry,metadataFormsincluded (packages/cli/src/utils/i18n-extract.ts, thecount += 1in the per-locale loop). The command then uses that one number for two things it is not:packages/cli/src/commands/i18n/extract.tspushes each emitted bundle withkeys: result.counts[locale]and printsWrote PATH (N keys). Under the default--objects-onlythe file written holds only theobjectssub-tree, so N counts keys that are not in it.countsvalue is printed asN key(s) (of N expected)and then+ M metadataForms key(s)is appended — but those M are already inside N, so the line reads as N+M.Measured
Fixture: one object with one field, one app,
i18n.defaultLocale: 'zh-CN',--locales=zh-CN. Leaf counts taken structurally off the emitted modules.The file that run wrote has 2 leaves. The true split of the 776 is 2 objects + 1 app + 773 metadataForms, so the summary's
+ 773is the same 773 counted twice, and(776 keys)describes no file the run produced.Reproduced on
mainat 2648774 and unchanged by #14894's fix (that card moves which keys are EMITTED, never how they are counted).Why it is worth a card rather than a shrug
The number is the operator's only feedback that the extract did what they asked. It is what a reader compares against
os i18n check's report and against the coverage ratchet, and it is off by two orders of magnitude on the common path (the default--objects-only, which all nine of this repo's extract configs use).Not in scope of #14894
That card is about which keys are emitted; this is about which number is printed. Fixing this changes stdout for every extract invocation, which is a wider verification surface than that card's repair, so it is filed rather than ridden along.
Dedupe
Searched before filing; the near neighbours are all different or closed — #14816 (a docstring's inline-locale-map count), #14728 (coverage double-counting an action key, closed), #13837 (
os i18n check --helpunder-counting kinds, closed). No open card covers the extract command's own count lines.