Repository navigation
fix(cli,lint): gate the i18n flow coverage bucket on the liveness ledger - #12442
Conversation
`os lint` computes i18n coverage and runs the authoring-rule registry in one
pass over the same stack, and for the `flows` translation group the two halves
pointed opposite ways: omitting the keys drew `i18n/missing-flow`, authoring
them drew `liveness-planned-property` ("sets `flows` but this translation
property is planned"). Measured on one stack: 4 demands vs 2 warnings, and no
third move — `os lint` has no per-rule suppression, only `--skip-i18n`, which
silences the whole `i18n/missing-*` family.
The warning is true and is unchanged: no shipped screen-flow runner reads the
group. The demand is the premature half.
`collectExpectedEntries` — the single definition of what is translatable at
all, shared by the coverage gate and the `os i18n extract` skeleton — now
leaves out any translation group the liveness ledger warns authors for
authoring, reading that set from `@objectstack/lint`'s new
`authorWarnedProperties(type)`: the very warn map `lintLivenessProperties`
iterates, so the demand side and the warn side read ONE verdict.
Reading the ledger rather than switching on `flows` by name means the bucket
turns itself back on the day the row flips to `live`, and any future warned
group is covered on the day it is marked.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd
`check:i18n-coverage` counts untranslated declared strings per example config against a shrink-only baseline. Gating the `flow` bucket removes the `flows.*` demands, so the count improves on exactly the three example apps that declare a `type: 'screen'` flow and on no other config: examples/app-crm 93 -> 89 examples/app-showcase 424 -> 393 examples/app-todo 140 -> 120 The other 9 baselined configs are unchanged — `examples/embed-objectql` and the 8 platform/plugin extract configs declare no screen flow. Regenerated with `node scripts/check-i18n-coverage.mjs --update`, the repair the gate itself prescribes for a ratchet DOWN. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd
…8n-flow-bucket-liveness-gate
📓 Docs Drift Check7 anchor(s) derived from 2 changed package(s); no hand-written page names any of them. What this run could not see
Coarse fallback — 26 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 d5fc40ec65fcf4c9a46553438443b180f48b441a && git checkout d5fc40ec65fcf4c9a46553438443b180f48b441a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5150053a495797de0524e5bbca56eb0232f2ce2d 52991eb9e7137e356862d673a6ce2c8289075c9d && git checkout -B drift-repro 5150053a495797de0524e5bbca56eb0232f2ce2d && git merge --no-ff 52991eb9e7137e356862d673a6ce2c8289075c9d
node scripts/docs-audit/affected-docs.mjs --json 5150053a495797de0524e5bbca56eb0232f2ce2d |
Fixes #11624
The collision, reproduced before it was fixed
os lintcomputes i18n coverage and runs the authoring-rule registry in a single pass over the same stack —Lint.runcallslintConfig(→runAuthoringRules('lint'), which carrieslintLivenessProperties) andcomputeI18nCoverageone after the other. For theflowstranslation group the two halves pointed opposite ways. Measured on one stack, one run, atcdbd9204b:i18n/missing-flowliveness-planned-propertyflows.*from the bundleos i18n extractscaffolds it)The warn side's exact text:
sets `flows` but this translation property is planned — declared, and a consumer is being built against it (not read YET).On the "no third option" claim — the card's central assertion, verified and refined rather than falsified.
os linthas no per-rule or per-bucket suppression: the flags are--skip-i18n,--include-platform,--i18n-strict,--default-locale, and nothing else; the authoring-rule registry has no opt-out field. So a third option exists only in the coarse sense —--skip-i18nsilences the entirei18n/missing-*family, i.e. exactly the signal #11485 was filed to restore. At the granularity of theflows.*keys themselves the card is right: there is no move that satisfies both rules. Under--i18n-strictthe demand side becomes an error, so a project could be forced to author keys it is then warned for.The warn side is live, not inert — confirmed.
lintLivenessPropertieswalks locale-keyed bundles (packages/lint/src/lint-liveness-properties.ts:391,asArray(stack.translations)then one level into each locale), which is #11288's fix, and it fired on both locales of a real authored bundle in the measurement above.⛔ The warning is true and is not softened here. No shipped screen-flow runner reads the group, so a translated wizard string is stored and never shown — the failure mode
validationMessageswas removed in 17.0.0 for. Its rule id isliveness-planned-property, i.e. keep the key, not remove it. The premature half is the demand, and that is what this PR gates.The fix
collectExpectedEntriesinpackages/cli/src/utils/i18n-extract.ts— the file's own docstring calls it "the single definition of what is translatable at all", consumed by both the coverage gate and theos i18n extractskeleton — now drops any entry whose top-level translation group the liveness ledger warns authors for authoring.It gets that set from a new
authorWarnedProperties(type)exported by@objectstack/lint, which returns the very warn maplintLivenessPropertiesiterates. That is the load-bearing choice: a demand side and a warn side can only be kept from contradicting each other by reading one verdict, and the first thing a second reader of the same JSON would do is drift from it.Three properties follow from reading the ledger instead of switching on
flowsby name:live(dropping itsauthorWarn, which exists precisely because nothing reads the group), both the coverage report and the extract skeleton pick the group up again. No flag, no follow-up edit.lintLivenessPropertieswarns on nothing. The state that must never arise is one of them speaking alone.Gating
os i18n extracttoo is deliberate, not incidental. The card names extract as the other door into the same trap ("adds it, e.g. fromos i18n extract"), andi18n-coverage.tscarries an explicit invariant — the walker is shared "so the gated surface and the scaffolded surface can never disagree". Gating only the coverage bucket would have satisfied the letter of the suggested shape while moving the collision from lint-vs-lint to extract-vs-lint, and broken that invariant on the way.Join depth. The match is on the group (
path[0]) and stops there, because that is the only depth at which the warn side can fire for file-authored bundles:getNestedfans out over arrays, and a translation group is a record keyed by target name, so a warned child row (flows.label) would resolvedata.flows.label— a path no real bundle has — and warn nobody. Matching deeper would suppress keys nothing warns about.Measurements the card asked for
Every line number re-measured on
origin/main; ⛔ not reconciled with the card's 2026-08-24 figures.cdbd9204blint-liveness-properties.ts:387—asArray(stack.translations)authoring-rules.ts:1081— registrationname:), call at :1090;commands: ALL,surfaces: CLI_ONLY,tier: 'advisory', severitywarning— all as describedpackages/spec/liveness/translation.json:63— theflowsrowstatus: planned,authorWarn: true, childrenlabel/screensbothplannedwithoutauthorWarni18n-extract.ts—walkScreenFlowsi18n-coverage.ts—COVERAGE_SOURCE/flowbucketThe⚠️ census —
flowsis the only affected group. Of the 14 top-level groups intranslation.json, 13 arelive;flowsalone isplanned+authorWarn. The two otherauthorWarnrows in the wholeliveness/tree arefield.json'srelatedListFilterandobject.json'sexternalSharingModel— object/field properties, which the i18n walker never harvests into a translation bundle, so they cannot collide this way. Pinned as an equality (toEqual(['flows'])), not atoContain, so a second warned group goes red and is considered before it ships.Instrument discipline. Every zero-hit reading here is paired with a positive control from the same instrument on a term independently known present and never a substring of the term under test — e.g.
flowsscores 0 in the committed example bundles whileobjectsscores 4 in the same file;embed-objectqlscores 0 screen-flow files while scoring 2 on the control term. The committed test carries the same pairing: theflows-is-warned assertion supplies the positive, and the six live groups supply the negatives.Tests
Fixture triage of the existing #11485 pins, stated because "green" and "green by emptiness" are different things.
i18n-flow-screen-coverage.test.ts's 13 pins are all about the bucket itself — which keys the walker harvests, how the report attributes them, what the skeleton looks like — and none of that changed. What changed is when it runs. Retiring them would have left the flip with no proof the bucket still works; leaving them to run gated would have turned four of them into assertions over an empty walk. So the file now mocksauthorWarnedPropertiesto the empty set — the post-flip ledger — and keeps all 13 verbatim. The gated half is pinned next door in the newi18n-flow-liveness-gate.test.ts, against the real shipped ledger, so it goes red the day the row moves and someone must look at both halves together.Reverse verification — two ablations, each with its restore leg proven on disk. Predicted direction first, both times "the gate test goes red, the as-if-live file does not":
collectExpectedEntries:i18n-flow-liveness-gate.test.ts5 failed / 16 passed;i18n-flow-screen-coverage.test.tsstayed fully green, as predicted (it is as-if-live either way). The three gate pins that survived are the ones that do not route through the filter (the census, the negative control, thewarnedGroups: new Set()self-activation pin).i18n-flow-screen-coverage.test.ts: 9 failed / 4 passed, proving those pins really do assert over a walked surface. The 4 survivors are the silent-either-way ones — and one of them,never emits the keys the schema refuses by name(.toEqual([])), is green-by-emptiness without the mock, which is exactly why the mock is there rather than the pins being retired.Mutation was confirmed on disk each time by counting the removed text and the injected marker separately (
before=1 / after=0 / marker=1) plus agit hash-objectdiffering from theHEADblob — never by an editor's exit code. Each script installed atraponEXIT INT TERMcalling a named restore function, which seedsREPO_ROOTfromgit rev-parse --show-topleveland restores the absolute path"$REPO_ROOT/$REL"withgit checkout HEAD -- "$REPO_ROOT/$REL"— pinned toHEAD, never the bare two-argument form, which restores from the index and would have put the mutation straight back on disk at exit 0. Restoration was proven bygit diff HEADbeing empty and the blob hash returning to theHEADvalue, not by the trap having run. No rebuild was needed for either leg: both suites import the CLI's TypeScript source (../src/utils/i18n-extract.js), so nodist/sits between the mutation and the reading.Suites and gates, all at the final commit
52991eb9e(re-run after theorigin/mainmerge, not carried over from the pre-merge tree):pnpm --filter @objectstack/cli test— 184 files / 2102 passed, after one unrelated flake was isolated (see below)pnpm --filter @objectstack/lint test— 81 files / 2298 passedpnpm --filter @objectstack/cli typecheck,pnpm --filter @objectstack/lint typecheck— cleanpnpm check:type-check-debt—--re-measure: OK — 32 ledger entr(ies) re-measured in 214.8s, 1843 raw tsc error(s) total, none above its recorded number, withsurplus: none — every entry sits exactly at its measurement, so any new error is red. This is what typechecks the new test files: both packages hide their tests from their owntscprogram (packages/lint/tsconfig.jsonexcludes**/*.test.ts;packages/cli/tsconfig.jsonincludes onlysrc), so the packagetypecheckscripts say nothing about them — stated rather than left to read as coverage they do not have.pnpm check:i18n—OK (9 package(s) — all bundles in sync, no undeclared authoring keys)pnpm check:i18n-coverage—OK (12 config(s), 602 baselined untranslated string(s), none new)(see the ratchet below)check:nul-bytes,check:cross-package-test-inputs,check:test-source-alias,check:engine-double-contract,check:where-matcher,check:query-options-erasure,check:type-check-coverage,check:cli-test-child-env,check:published-files,check:page-declaration-shape,check:slot-lookup,check:type-source-resolution,check:cli-command-ids,check:entry-guard,check:parse-guard,check:pnpm-filter-targets,check:agent-test-spelling,check:bash32-floor,check:react-page-adapter-contract,check:role-word,check:published-readme-links,check:doc-anchors,check:doc-authoring,check:docs-single-h1,check:docs-redirects,check:docs-audit-scope,check:objectui-changeset,check:changeset-gate-self-tests, spec'scheck:docs/check:liveness/check:strictness-ledger/check:empty-state/check:variant-docs/check:skill-examples, lint'scheck:doc-formula-expressions/check:doc-security-posture, and the script-formcheck-doc-frontmatter,check-doc-route-spelling,check-docs-section-name,check-section-landing-index,check-empty-changeset,check-changeset-no-major,check-adr-0087-registration,check-ci-filter-parity,check-plugin-teardown-shape,check-comment-mask-adoption,docs-audit/check-affected-docs,docs-audit/check-drift-comment,pm/release-rehearsal-clone --self-test.pnpm lintwas narrowed, and the narrowing is measured rather than asserted. ① The examined population is read from ESLint's own config, not guessed: run over all 9 changed paths, ESLint itself reportscontent/docs/ui/translations.mdx,scripts/i18n-coverage-baseline.jsonand the changeset.mdas "File ignored because no matching configuration was supplied" — outside the linted population by config. ② The count comes from--format json: 5 in-scope.tsfiles, 0 errors, 0 findings. ③ Invariance for untouched files:eslint.config.mjsis the repo's single config and never enables type-aware linting for any file — noparserOptions.project, no typed@typescript-eslintrules, a claim the config itself records as positive-control-measured at line 328. Each file's verdict is therefore a pure function of that file plus the shared config, and this diff touches no config, so it cannot move the verdict of a file it does not contain. CI runs the full farm regardless.One unrelated flake, isolated and filed, not swept. The first full CLI run came back 1 failed / 2101 passed on
test/serve-node-env-production-default.e2e.test.tswith✗ Port 49402 is already in use— a port bind race with a parallel agent in this shared container, not an assertion failure and nothing this diff can reach. Re-run in isolation: 3 passed. The mechanism (a blindMath.random()port draw with no bind probe, under a docblock claiming it "never contends") is filed as #12441, unassigned, and ⛔ not fixed here.The i18n coverage ratchet moved down, and only where it should
check:i18n-coveragecounts untranslated declared strings per example config against a shrink-only baseline. Removing theflows.*demands improved it on exactly the three example apps that declare atype: 'screen'flow, and on no other config:The other 9 baselined configs are byte-unchanged —
examples/embed-objectqland the 8 platform/plugin extract configs declare no screen flow, censused with the paired positive control described above. Regenerated withnode scripts/check-i18n-coverage.mjs --update, the repair the gate itself prescribes for a ratchet DOWN, and confirmed independently:os lint --jsononapp-crmafter the change reportsi18n/missing-flow: 0while every other bucket is intact (missing-object8,missing-option34,missing-field22,missing-action2,missing-navigation6,missing-dashboard2,missing-widget12,missing-page3) — total 89 warnings, matching the new baseline exactly.scripts/i18n-coverage-baseline.jsonis a shared hot file; a sibling agent was observed runningcheck:i18n-coveragein the same container during this work. If it conflicts at merge, the resolution is to re-run--update, never to hand-merge the numbers.Scope, and the two boundaries declared rather than crossed
packages/specis untouched (ruling ②). Theflowsrow keepsstatus: plannedandauthorWarn: true; the ledger is read, never written. ⛔ No row was flipped, reshaped, or dropped.authorWarnwas not dropped from the row (ruling ①). The warning is true and stays.Clause-② holds as determined at claim time. The declared accept set is unchanged: no schema, no key, no validity boundary moves.
TranslationDataSchemastill accepts exactly the sameflows.*keys it accepted before — an author who writes them still parses clean, and still gets the (true) liveness warning. What changed is which keys a reporting tool asks for. This isdeclared = enforcedrestoration on the tooling side.File-surface delta, stated because it exceeds the claim comment's literal list by one file. The claim comment (5419223296) named
i18n-extract.ts,lint-liveness-properties.ts,authoring-rules.ts, their sibling tests, andtranslations.mdx. Two departures, both upward-visible:packages/lint/src/index.ts— one added re-export line.@objectstack/lintpublishes exactly two subpaths (.and./runtime), so a symbol added tolint-liveness-properties.ts(which is in the surface) is unreachable without its barrel line. The only alternative was to re-derive the ledger predicate insidepackages/cli— precisely the drift this card exists to close. No API-surface artifact exists for this package, so the delta is the one line.scripts/i18n-coverage-baseline.json— a generated ratchet artifact this change necessarily moves, regenerated by the command the gate's own failure text prescribes.And one file the retriage ruling named that turned out not to need editing:
packages/cli/src/utils/i18n-coverage.ts. The ruling (5415947100) placed the remedy onCOVERAGE_SOURCE; measurement showed the shared walker is the single definition, so gating there gives the coverage gate and the extract skeleton one consistent answer with zero edits to that file. Worth flagging because the claim comment's file surface omitted it while the ruling named it — the discrepancy is real, and it resolved in the direction of a smaller diff.packages/lint/src/authoring-rules.tswas read (to confirmcommands: ALL/surfaces: CLI_ONLY) and not edited.Docs
content/docs/ui/translations.mdx"Current boundaries" told an author the group is declared-not-applied and that the compile lint warns — but not what the tooling would do. It now carries the other half:os lintdoes not reportflows.*as missing andos i18n extractdoes not scaffold them, both reading the sameplannedrow; authoring the copy anyway yields the liveness warning and nothing else, which is telling the truth rather than asking for a deletion; and the day the row flips, both surfaces pick the group up with no flag to turn on.Generated by Claude Code