Skip to content

fix(cli,lint): gate the i18n flow coverage bucket on the liveness ledger - #12442

Merged
os-litant merged 3 commits into
mainfrom
claude/issue-11624-i18n-flow-bucket-liveness-gate
Aug 26, 2026
Merged

os-litant merged 3 commits into
mainfrom
claude/issue-11624-i18n-flow-bucket-liveness-gate

Conversation

@os-litant

@os-litant os-litant commented Aug 26, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #11624

The collision, reproduced before it was fixed

os lint computes i18n coverage and runs the authoring-rule registry in a single pass over the same stack — Lint.run calls lintConfig (→ runAuthoringRules('lint'), which carries lintLivenessProperties) and computeI18nCoverage one after the other. For the flows translation group the two halves pointed opposite ways. Measured on one stack, one run, at cdbd9204b:

the author does i18n/missing-flow liveness-planned-property
omits flows.* from the bundle 4 0
adds it (os i18n extract scaffolds it) 0 2 (one per locale bundle)

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 lint has 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-i18n silences the entire i18n/missing-* family, i.e. exactly the signal #11485 was filed to restore. At the granularity of the flows.* keys themselves the card is right: there is no move that satisfies both rules. Under --i18n-strict the 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. lintLivenessProperties walks 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 validationMessages was removed in 17.0.0 for. Its rule id is liveness-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

collectExpectedEntries in packages/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 the os i18n extract skeleton — 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 map lintLivenessProperties iterates. 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 flows by name:

  • It self-activates. The day an objectui screen-flow runner lands and the row flips to live (dropping its authorWarn, 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.
  • It answers the card's ⚠️ sweep generally. Any future group that acquires a warn is covered on the day it is marked, rather than re-opening this collision one group at a time.
  • Both halves fail quiet together. An unresolvable ledger yields the empty set, which is also the state in which lintLivenessProperties warns on nothing. The state that must never arise is one of them speaking alone.

Gating os i18n extract too is deliberate, not incidental. The card names extract as the other door into the same trap ("adds it, e.g. from os i18n extract"), and i18n-coverage.ts carries 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: getNested fans out over arrays, and a translation group is a record keyed by target name, so a warned child row (flows.label) would resolve data.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.

the card said measured at cdbd9204b
lint-liveness-properties.ts:387 — asArray(stack.translations) :391 (drifted +4)
authoring-rules.ts:1081 — registration :1082 (name:), call at :1090; commands: ALL, surfaces: CLI_ONLY, tier: 'advisory', severity warning — all as described
packages/spec/liveness/translation.json:63 — the flows row :63, exact; status: planned, authorWarn: true, children label/screens both planned without authorWarn
i18n-extract.ts — walkScreenFlows defined :963, called :889
i18n-coverage.ts — COVERAGE_SOURCE / flow bucket :192 / :216

The ⚠️ census — flows is the only affected group. Of the 14 top-level groups in translation.json, 13 are live; flows alone is planned + authorWarn. The two other authorWarn rows in the whole liveness/ tree are field.json's relatedListFilter and object.json's externalSharingModel — 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 a toContain, 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. flows scores 0 in the committed example bundles while objects scores 4 in the same file; embed-objectql scores 0 screen-flow files while scoring 2 on the control term. The committed test carries the same pairing: the flows-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 mocks authorWarnedProperties to the empty set — the post-flip ledger — and keeps all 13 verbatim. The gated half is pinned next door in the new i18n-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":

  • A — remove the gate filter from collectExpectedEntries: i18n-flow-liveness-gate.test.ts 5 failed / 16 passed; i18n-flow-screen-coverage.test.ts stayed 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, the warnedGroups: new Set() self-activation pin).
  • B — remove the as-if-live mock from 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 a git hash-object differing from the HEAD blob — never by an editor's exit code. Each script installed a trap on EXIT INT TERM calling a named restore function, which seeds REPO_ROOT from git rev-parse --show-toplevel and restores the absolute path "$REPO_ROOT/$REL" with git checkout HEAD -- "$REPO_ROOT/$REL" — pinned to HEAD, 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 by git diff HEAD being empty and the blob hash returning to the HEAD value, 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 no dist/ sits between the mutation and the reading.

Suites and gates, all at the final commit 52991eb9e (re-run after the origin/main merge, 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 passed
  • pnpm --filter @objectstack/cli typecheck, pnpm --filter @objectstack/lint typecheck — clean
  • pnpm 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, with surplus: 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 own tsc program (packages/lint/tsconfig.json excludes **/*.test.ts; packages/cli/tsconfig.json includes only src), so the package typecheck scripts 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)
  • Also green: 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's check:docs / check:liveness / check:strictness-ledger / check:empty-state / check:variant-docs / check:skill-examples, lint's check:doc-formula-expressions / check:doc-security-posture, and the script-form check-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 lint was 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 reports content/docs/ui/translations.mdx, scripts/i18n-coverage-baseline.json and the changeset .md as "File ignored because no matching configuration was supplied" — outside the linted population by config. ② The count comes from --format json: 5 in-scope .ts files, 0 errors, 0 findings. ③ Invariance for untouched files: eslint.config.mjs is the repo's single config and never enables type-aware linting for any file — no parserOptions.project, no typed @typescript-eslint rules, 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.ts with ✗ 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 blind Math.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-coverage counts untranslated declared strings per example config against a shrink-only baseline. Removing the flows.* demands improved it 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 byte-unchanged — examples/embed-objectql and the 8 platform/plugin extract configs declare no screen flow, censused with the paired positive control described above. Regenerated with node scripts/check-i18n-coverage.mjs --update, the repair the gate itself prescribes for a ratchet DOWN, and confirmed independently: os lint --json on app-crm after the change reports i18n/missing-flow: 0 while every other bucket is intact (missing-object 8, missing-option 34, missing-field 22, missing-action 2, missing-navigation 6, missing-dashboard 2, missing-widget 12, missing-page 3) — total 89 warnings, matching the new baseline exactly.

⚠️ scripts/i18n-coverage-baseline.json is a shared hot file; a sibling agent was observed running check:i18n-coverage in 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/spec is untouched (ruling ②). The flows row keeps status: planned and authorWarn: true; the ledger is read, never written. ⛔ No row was flipped, reshaped, or dropped.

authorWarn was 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. TranslationDataSchema still accepts exactly the same flows.* 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 is declared = enforced restoration 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, and translations.mdx. Two departures, both upward-visible:

  • packages/lint/src/index.ts — one added re-export line. @objectstack/lint publishes exactly two subpaths (. and ./runtime), so a symbol added to lint-liveness-properties.ts (which is in the surface) is unreachable without its barrel line. The only alternative was to re-derive the ledger predicate inside packages/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 on COVERAGE_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.ts was read (to confirm commands: 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 lint does not report flows.* as missing and os i18n extract does not scaffold them, both reading the same planned row; 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

claude added 3 commits August 26, 2026 01:43
`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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

7 anchor(s) derived from 2 changed package(s); no hand-written page names any of them. ⚠️ 1 changed file(s) yielded no anchor (packages/lint/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/lint/src/index.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 26 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 5150053a495797de0524e5bbca56eb0232f2ce2d → packageMentionDocs.

Which tree this was computed on

This run read content/docs from d5fc40ec65fcf4c9a46553438443b180f48b441a — the merge of head 52991eb9e7137e356862d673a6ce2c8289075c9d into base 5150053a495797de0524e5bbca56eb0232f2ce2d, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants