fix(cli): os i18n check counts the coverage an app actually owns, so --strict / --threshold can gate an app package - #17223
Conversation
`collectExpectedEntries` walks the Studio metadata-form registries unconditionally, so every stack's expected set carries ~773 `metadataForms.*` keys that `@objectstack/platform-objects` translates and the runtime serves. `os lint` hides them and says so; `os i18n extract` has `--no-metadata-forms`; `os i18n check` — the one command that publishes a PERCENTAGE — carried them in its denominator, so an application with its own surface fully translated read 38.9% and `--strict` / `--threshold` could not gate it. Ownership is now OBSERVED rather than assumed: the baseline counts when the stack itself ships those translations, and does not when it does not. That keeps the package which owns the family gated with no flag, which an unconditional exclusion would not. `--include-platform` (`os lint`'s own spelling) and `--no-include-platform` force either way. `os lint` is unchanged: the shared seam still defaults to counting the baseline, because lint folds it away one seam later and counts what it folded for its own hint line. Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8 Co-authored-by: Claude <noreply@anthropic.com>
…is taught `content/docs/ui/translations.mdx` recommends `os i18n check --strict --threshold` as the CI gate; that advice is now reachable for an app package, and the page has to say what is and is not in the number it produces — including why shipping your own `metadataForms` bundle to move it is the wrong repair. The published i18n skill listed metadata forms among the surfaces `check` always reports on. That sentence is false as of this change, so it is corrected in place rather than left to read as a scope statement. `content/docs/protocol/kernel/i18n-standard.mdx` names `metadataForms` only in its ORPHAN-key paragraph (the reverse direction, which `os validate` / `os lint` / `os compile` answer) — untouched by this change and left alone. Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8 Co-authored-by: Claude <noreply@anthropic.com>
📓 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:
What this run could not see
Coarse fallback — 23 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 c754029632e9ce2aa713a655b81cde5ba714106f && git checkout c754029632e9ce2aa713a655b81cde5ba714106f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin bccf311100cad7affccd6cbfcddbd81fe734d97d 03ca4f4eba6c0400edaecc47fb12d31cd70eb4fc && git checkout -B drift-repro bccf311100cad7affccd6cbfcddbd81fe734d97d && git merge --no-ff 03ca4f4eba6c0400edaecc47fb12d31cd70eb4fc
node scripts/docs-audit/affected-docs.mjs --json bccf311100cad7affccd6cbfcddbd81fe734d97d
|
Contract review at
|
|
Clear-and-hold provenance — director seat, summon #18 segment 6 ( Generated by Claude Code |
Fixes #16681
Clause-②: yes
os i18n checkis the one of three i18n-aware commands that publishes a percentage, and it carried the platform'smetadataForms.*baseline in its denominator. An application with every string it owns translated read 38.9%, so--strictand--threshold— the two flags whose entire purpose is CI gating — could not gate an app package at all.mainThe card's half ② (
collectExpectedEntriesemits object action keys twice) was fixed before the card was filed.dedupeByPathlanded in095df7fee3— "fix(cli): count one translation key once in the i18n coverage population (#15368)", authored 2026-09-04, three days before the card (2026-09-07) and four before triage (2026-09-08).git merge-base --is-ancestor 095df7fee3 BASEexits 0.Triage located
pushEntrycorrectly and read it correctly — it still does not de-duplicate. What it did not check is the return statement ofcollectExpectedEntries, which is where the collapse now lives. Measured on this branch's base, for all three carrier shapes, zero repeated paths.That does not make this card a no-op, and it does not relax the "⛔ 不要拆卡" ruling — it satisfies it. Triage's ordering condition is 「② 先于 ① 落地」; ② landed five days earlier, so ① is landing on a correct count, which is the state the ruling exists to guarantee. What ② still owed is its acceptance: conditions 2 and 3 ask for assertions that did not exist. They exist now, and the ablation below shows them catching exactly the 492-vs-482 defect the card reports.
验收备注
Triage's six conditions (
5578569640), transcribed verbatim per 「承接 PR 请照抄进## 验收备注」, each with its answer.Met, by a stronger route than asked. ② is not landed in this PR — it landed in #15368 on 2026-09-04 and is in this branch's merge base. So the gate ① opens is opened onto a count that has been correct for five days, never onto a wrong one. Verified rather than assumed: the equality pin and the ablation below both run against this tree.
Met.
packages/cli/test/i18n-walk-output-parity.test.tsasserts set equality of the key paths, from both published faces, for all three carrier shapes — not merely "no duplicates", and stronger than count equality (the count assertion is there too, because the count is what an operator reads and what the percentage divides by). Neither side is read from the shared walker: extract is read through the extractor's ownstackAuthoredSubtree, andcheckthrough the finding list it publishes for a declared locale that ships no bundle, where the expected set and the missing set coincide by construction.Met. Both shapes are fixtures —
actionOnObject()andactionTopLevel()— plus the third shape the normalizer actually produces (actionOnBothCarriers(), one object by reference on both carriers). Each is asserted to reach the complete five-key action set, and one further case asserts the three shapes reach the same key set, so "where you declared it changes nothing about what you are asked to translate" is a single pinned fact. A repair by deletion turns the equality assertions green and reddens those.Met. No flag is required on either side. The default is
lint's principle rather thanlint's mechanism, because the outputs differ:lintfolds at the report seam and prints a hint;checkpublishes a percentage, so the same principle has to move the denominator. The flag name and meaning arelint's (--include-platform), and the console prints the same shape of hint under the coverage table.Met, and it is what forbids the unconditional implementation. Ownership is observed, from the one place it is already written down: the stack's own
translationsbundles.platform-objects' extract config pinsmetadataFormsinto every locale bundle it declares, so it stays gated with no flag. A non-empty string leaf is the test, so an--fill=emptyscaffold is not mistaken for a claim of ownership. Asserted at the seam (i18n-platform-bucket.test.ts) and end to end (--threshold=100on a platform-shaped config must exit 1). Ablation A below installs exactly the forbidden implementation and reddens exactly this case.Met — the table is below, and it is also carried in
check.ts's module note so the next reader does not have to find this PR.os lint--include-platformos i18n extractmetadataFormsCountsreports its size either way)--no-metadata-formsos i18n check--include-platform/--no-include-platformcheckpublishes a percentage, which is also why it is the one that can answer without a flag at all.What changed
computeI18nCoveragegainsplatformMetadataForms?: 'include' | 'exclude' | 'auto', defaulting to'include'— deliberately, becauseos lintis the other caller and folds the family one seam later offCoverageIssue['source'], counting what it folded for its hint line. Flipping that default would zero lint's hint silently; a pin holds the coupling.stackAuthorsMetadataForms(config)is the observed-ownership predicate, exported and pinned separately.CoverageReportgainsplatformMetadataForms: { mode, excludedKeys }; the console hint renders from those same two numbers, so--jsonand the console cannot disagree.os i18n checkgains--include-platformwithallowNo, so absence is a third state rather than afalse.content/docs/ui/translations.mdxgains "What counts as your coverage" — the page that teaches--strict --thresholdas the CI gate now says what is in the number, and that shipping your ownmetadataFormsbundle to move it is the wrong repair.skills/objectstack-i18n/SKILL.mdlisted metadata forms among the surfacescheckalways reports on; that sentence is now false, so it is corrected in place.Evidence
Ablation — directions predicted in the script header before any leg ran; every leg proved the mutation reached disk (anchor occurrence count and a blob hash off the HEAD blob) and proved the restore clean (
git checkout HEAD -- ABSPATH, blob equal to HEAD's, emptygit diff HEAD), whole script undertrap ... EXIT INT TERMwith absolute paths. Finalgit status --porcelainobserved empty.'auto'arm returns'excluded'unconditionally — the forbidden fixc254407…on both sidescollectExpectedEntriesreturnswalked, notdedupeByPath(walked)(pre-#15368)158aceff…on both sides'auto'arm alone, and the explicit'include'/'exclude'requests route around it. The direction held; the count did not.B1's failure text is the card's own defect at fixture scale:
expected 18 to be 13, the five surplus entries being exactly theobjects.inquiry._actions.ats_convert_inquiry.*keys — the same mechanism as the reported 492-against-482 over ten keys.Tests (exit codes captured by redirect-then-
$?, never through a pipe):pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2→ exit 0, 192 files / 2658 tests passed.pnpm --filter @objectstack/cli typecheck→ exit 0 (tsc --noEmit+check:test-typecheck, ledger held at 3 files / 28 errors / 6 pinned signatures).pnpm --workspace-concurrency=2 --filter @objectstack/cli build→ exit 0.i18n-check-platform-bucket.e2e.test.tscarries the*.e2e.test.*name, so it is a nightly-tier file and the queue does not run it. Driven here underOS_TEST_TIERS=nightly ... --project integration→ exit 0, 5/5. The three states are additionally proven per-PR, in-process, through oclif's ownParseragainstI18nCheck.flags.Gates — derived on this head with
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, reconciled with--ran:All 95 exited 0. One needs naming:
pnpm check:i18n-coveragefirst exited 3 — PREREQUISITE-NOT-MET, an unbuilt@objectstack/connector-mcp, ⛔ not a pass. The closure it named was built and it was re-run:check-i18n-coverage: OK (13 config(s), 621 baselined untranslated string(s), none new). That number not moving is the regression reading that matters most here — it isos lint's ratchet over 13 example configs, and this change must not touch it.pnpm check:i18n→ exit 0.pnpm check:i18n-walk-parity→ exit 0.Lint — the whole repo, not a narrowed run:
pnpm exec eslint --no-inline-config --format json .over 6443 files → exit 0, 0 errors, 0 warnings, at9f9b579.Published skill readings (
skills/**is touched, so both are owed), measured with the gate's owncountTokens:skills/objectstack-i18n/SKILL.md, linesskills/objectstack-i18n/SKILL.md, tokensSKILL.md, linesSKILL.md, tokenscheck-skills-token-ratchetreads the file at 4708 against a ceiling of 6338 (headroom 1630) and exits 0. The edit is a correction of a sentence this change makes false, not an expansion: one clause replaced by two, one line longer.Single-writer, measured from the open PR list (21 PRs), each PR's own files against its own merge base via
/pulls/N/files: no PR touches any face of this card. The Version Packages PR was paged to completeness (207 files, not the 100-row first page). Positive control: the same predicate reports 7 hits against this branch's own diff, and the coarserpackages/cli/counter fires non-zero on #17073 (src/commands/db/clean.ts) and #17076 (CHANGELOG.md,package.json) — so the zeros are readings, not a silent instrument.Docs-drift round on this head. Anchor sweep over every symbol the diff touches, plus a prose-class sweep for pages naming none of them (firing control:
i18nhits 63 pages undercontent/docs/). Rows judged and not edited, each for a stated reason:content/docs/protocol/kernel/i18n-standard.mdxnamesmetadataFormsonly in its orphan-key paragraph — the reverse direction, answered byos validate/os lint/os compile— which this change does not touch, and which in fact states the same ownership fact;content/docs/deployment/cli.mdxdocumentsos lintand neveros i18n check, and no sentence on it moves;content/docs/deployment/validating-metadata.mdxnames "i18n coverage" as one of lint's rules, and lint is unchanged. ⛔content/docs/releases/**was not edited.维护者速读(草稿)
改了什么。
os i18n check现在只统计这个应用自己拥有的翻译。平台 Studio 表单那 ~773 条(metadataForms.*)由@objectstack/platform-objects翻译并由运行时直接下发,不再算进应用的覆盖率分母。归属不是拍脑袋定的,而是从配置自己的 translations 里读出来的:你自己发这套翻译,就还是要你补完;不发,就不是你的。三个命令的口径列成一张表,同时写进了check.ts的模块注释。为什么改。 一个把自己声明的每一个字符串都翻完的应用,报出来是 38.9%,于是
--strict/--threshold这两个专为 CI 闸门而生的旗标对应用包完全不可用。而唯一能把数字做上去的办法——自带一份metadataForms翻译——会覆盖平台那份并在下次升级时变陈旧,规避方案本身有害。三个命令里已经有两个知道这件事,check是唯一的例外。风险与代价(含回滚)。 这是放宽接受集:今天被拒绝的一次调用明天会被接受,而且默认的覆盖率数字会变。风险集中在一处——如果归属判定读错,平台侧的闸门就会被悄悄删掉;这正是分诊点名的阴性对照,已经用消融把那个错误实现装进去验证过它会变红。
os lint一行未动,它的棘轮读数(13 个示例配置、621 条,无新增)没有移动。回滚是单一 commit revert,无数据迁移、无生成物、无存量形状变更。判断留给复审的一条:归属判定读的是「有没有发这套翻译」,一个只翻了三条的应用会因此被要求补完剩下的 770 条——这是不是过重,写在open_questions里。席位意见。 (留空,待契约复审档填写)
你要做的。 这是 clause-② 卡,按
SKILL.md:640禁止入队,PR 保持 draft、挂needs:contract-review、未挂 auto-merge。它等的是一次席内契约复审档 PASS,不是等 CI。另外这个 diff 触到了skills/**(受管面),所以按 Prime Directive #14 它的落地本来也需要你亲自确认。Generated by Claude Code