Skip to content

fix(cli): the i18n provenance companion accompanies a module, and names its sections from their payloads - #16871

Merged
os-project-manager merged 1 commit into
mainfrom
claude/issue-16242-companion-section-list
Sep 8, 2026
Merged

fix(cli): the i18n provenance companion accompanies a module, and names its sections from their payloads#16871
os-project-manager merged 1 commit into
mainfrom
claude/issue-16242-companion-section-list

Conversation

@claude

@claude claude Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Fixes #16242

Clause-②: no

os i18n extract --source-hashes narrows the provenance table to "the sections this run commits" before writing LOCALE.source-hashes.generated.ts. The half that decided WHICH modules were emitted already read emittedModules(locale); the half that NAMED them pushed the literals 'objects' and 'metadataForms'.

Both literals are gone. translationModuleSections(bundle, kind) now sits beside translationModulePayload and is switched on the same kind, so what a module holds and which sections it commits are one decision; and the emit gate returns undefined for an empty section set instead of a truthy {}.

⚠️ Premise correction — symptom 1 does not occur, and the fix it prescribed would have regressed the live path

Two things the card asked for turned out to be false at source. Both were measured on this branch's base, origin/main 41cbc54fc5, not inherited.

1. No apps.* provenance record is being filtered out, because none is ever built. The provenance table comes from collectFilledFromHashes, which walks collectGeneratedLeaves, which walks GENERATED_SECTIONS['objects', 'metadataForms'] in packages/platform-objects/src/apps/translations/source-hash.ts. apps / dashboards / pages are HAND_AUTHORED_SECTIONS, judged by a different predicate (findStaleLeaves) against a different, hand-maintained file. Measured on the card's own fixture, reading the table BEFORE narrowing:

UNNARROWED record count: 775
UNNARROWED sections    : ["metadataForms","objects"]
has apps.kpi.label     : false
objects.* records      : 2
metadataForms.* records: 773

So the card's 3 leaves / 2 records reading reproduces exactly — and its 2 records are the correct output. The 773 records the narrowing dropped are the metadataForms baseline, dropped because --no-metadata-forms was passed, which is the narrowing working as designed. The card's number is consistent with both its own explanation and this one; only the unnarrowed table separates them.

Acceptance item 1 as written ("the companion must contain the apps.* record") is not reachable from this file at all, and is not delivered. See the residual finding below.

2. The prescribed expression — "the top-level keys of translationModulePayload(bundle, kind)" — is wrong for two of the three kinds, and implementing it literally would have been a serious regression. For kind: 'objects' the payload is data.objects, whose top-level keys are OBJECT NAMES (kpi_metric), not the section objects. Deriving the section list from those keys would commit ['kpi_metric'], and narrowToCommittedSections — which matches a leaf path's first dotted segment — would then drop every objects.* record. That is the one path this repository's single --source-hashes config (packages/platform-objects/scripts/i18n-extract.config.ts) is on.

Only kind: 'stack' selects a TranslationData-shaped subtree whose top-level keys ARE sections. translationModuleSections therefore switches on the kind, exhaustively: 'stack' yields the subtree's keys (so a group added later needs no edit here), 'objects' / 'metadataForms' yield the selector's own name, and a further AGGREGATE kind fails to compile at that one site rather than silently committing its own name as a section. Two unit cases pin exactly this, including the wrong derivation as the named falsifier.

What the derived list does buy is that the caller's statement stops being true by coincidence. 'objects' is the right name for both stack sub-tree modes only because the table can hold nothing else; a third generated section would have broken the literal silently.

Both fixtures, re-run on this branch

Base 41cbc54fc5. Fixture configs are the card's: one object with one field plus one app, and apps-only, both i18n.defaultLocale: 'zh-CN'.

Fixture A — symptom 1 (--locales=ja-JP --no-objects-only --no-metadata-forms --source-hashes --fill=default):

before:  Wrote OUT/zh-CN.objects.generated.ts (3 keys)
         Wrote OUT/ja-JP.objects.generated.ts (3 keys)
         Wrote OUT/ja-JP.source-hashes.generated.ts (2 keys)
after :  byte-identical (diff -r over both output trees reports no differences)

The module's 3 leaves are objects.kpi_metric.label, objects.kpi_metric.fields.name.label and apps.kpi.label; the companion's 2 records are the two objects.* ones. Unchanged, deliberately — per the correction above there is nothing here to restore.

Fixture B — symptom 2 (--locales=ja-JP --no-metadata-forms --source-hashes --fill=default, apps-only stack):

before:  Wrote OUT/ja-JP.source-hashes.generated.ts (0 keys)
         Generated 1 file(s)
after :  Generated 0 file(s)          [no companion written; the output dir is empty]

Reverse verification, from the committed fix, ablating ONLY if (committed.size === 0) return undefined; (mutation proven on disk by marker count 1 -> 0; tree restored, git diff HEAD empty): the pre-fix source writes the orphan, and --check with the companion deleted exits 1 with missing: OUT/ja-JP.source-hashes.generated.ts. That is the "demands that empty file forever" half, measured. The same ablation reddens exactly the two symptom-2 test cases and leaves the two measurement cases green.

--check semantics for an already-committed empty companion (acceptance item 4)

Decision: nothing is deleted, and --check tolerates the leftover — it neither demands it nor objects to it. Measured, not reasoned: with the empty companion sitting in the output directory, the fixed command's --check exits 0 (0 bundle(s) are in sync with the schema) and the file is still there afterwards.

The mechanism is compareCommitted, which iterates the EMITTED file list and reports missing (absent) and stale (bytes differ) over that set only. There is no orphan/extra-file detection anywhere in the command, so a file on disk the run does not write is in neither category. The leftover is also inert to the next extract: it is read back through parseSourceHashModule into an empty record set, which is indistinguishable from its absence (everything legacy-trusted).

⇒ Repositories carrying one can delete it at their convenience; nothing in the toolchain will ask them to, and nothing breaks either way. The alternatives were both rejected: deleting it makes the command remove files it does not own, and failing on it turns a repair into a breaking change for the very repositories the repair helps. This is pinned by a test case, deliberately seeded with STUB bytes rather than a faithful copy — the property is that the file is not in the compared set at all, and bytes the run would itself produce could not tell "not compared" apart from "compared and equal".

The emit gate is on the section set, not on the record count. A run that commits bundle modules but happens to have zero recordable leaves still writes its (empty) companion: there IS a module beside it, and the empty table is a true statement about it. Gating on the record count instead would make the file appear and disappear as translations change, which is worse than a stable empty file.

Tests

  • packages/cli/test/i18n-extract-companion-orphan.test.ts (new, 4 cases) drives the REAL CLI. The defect is about which files exist, decided apart from the modules they accompany; the mirror-shaped sibling i18n-extract-emitted-files.test.ts re-implements the emit rule and so agrees with it by construction — it would have written the orphan too.
  • Named without .e2e on purpose. The behavioural predicate puts it in the integration PROJECT; the .e2e NAME would additionally move it into the OS_TEST_TIERS=nightly POPULATION. The two cuts are independent and the name decides the RUN, so a --check semantics guard is named like i18n-extract-check-json.test.ts (queue) rather than i18n-extract-key-count.e2e.test.ts (nightly). Verified with vitest list --filesOnly --project integration.
  • packages/cli/test/i18n-extract-source-hashes.test.ts gains 3 unit cases for translationModuleSections.

Verification on 4481b402fb:

what result
vitest run --project unit (whole tier) 2547 passed, 6 expected fail, 11 skipped; 185/186 files
vitest run --project integration (new file) 4 passed
pnpm --filter @objectstack/cli typecheck exit 0
58 derived check:* gates (dispatch-gates.mjs --commands) all exit 0
eslint . --no-inline-config (whole repo, --format json) 6369 files, 0 errors, 0 warnings

The eslint reading is the full union, not a narrowed run. The config declares no type-aware linting (eslint.config.mjs line 328: no parserOptions.project, no typed rules).

Five of the 58 gates first came back PREREQUISITE NOT MET (check:i18n, check:i18n-coverage, check:i18n-walk-parity, check:dual-build-cjs-loads, check:type-check-debt) because only the CLI's dependency closure had been built. All five pass after a full pnpm build (73/73 tasks). Worth noting that check:i18n-walk-parity signals that prerequisite with exit 1, not exit 3 — the exit code alone reads as a finding, and only its printed verdict line distinguishes them.

验收备注

  • Residual finding, NOT filed, recommended as its own card. Under --no-objects-only the stack module writes apps.* leaves into a .generated.ts file, filled by --fill=default from the source — leaves with exactly the property the generated predicate exists for (a byte copy indistinguishable by value from a real translation) — and they get no provenance record from EITHER mechanism: findStaleFills skips them (not in GENERATED_SECTIONS) and the hand-authored LOCALE.source-hashes.ts has no entry for a generated leaf. So the card's harm statement is real; its attributed mechanism is not. Fixing it means changing GENERATED_SECTIONS in packages/platform-objects — a different package, outside this card's declared surface, and a semantics decision about which predicate judges a generated apps leaf. It is dormant today for the same reason the card was: the repo's one --source-hashes config runs under the default --objects-only.
  • Pre-existing flake observed, not filed. src/commands/datasource/envelope-unwrap.test.ts failed its beforeAll with Hook timed out in 10000ms (oclif Config.load) during the full unit-tier run on a contended box, and passes in isolation (11 passed). Untouched by this diff. The repo has a home for this class — Queue-flake anchor: src/email-service.queue-delivery.test.ts #16506, "Queue-flake anchor" — but one load-dependent observation is not enough to assert a queue flake, so it is recorded here rather than filed.
  • #16247 is not addressed here and is not at fault: it moved the emit gate to the module's own leaf count, correctly, and only made a previously unreachable class reachable. This is ⛔ not its regression.
  • No existing changeset file was edited; the new one takes a distinct name and does not collide with the ten .changeset/i18n-*.md files PR chore: version packages #15334 holds.
  • Base is 41cbc54fc5; origin/main moved during the run. main was deliberately not merged in, so CI validates the merge ref.

Docs drift advisory

Re-derived rather than read off the bot, on the tree the advisory names.

Provenance — the thing the bot's own run could not say. A fresh worktree was cut detached at c74b61bc550820a2165937eebc35fd5893e5e7fd (the merge of head 4481b402fb into base c930f859714de408ba0221f435ff957ed9e64759) and confirmed clean before the tool ran: git status --porcelain printed nothing. The tool's own provenance block then reported:

"computedOn": {
  "head": "c74b61bc550820a2165937eebc35fd5893e5e7fd",
  "headParents": ["c930f859714de408ba0221f435ff957ed9e64759",
                  "4481b402fb940f5a21d363bcc232809c2e053ae8"],
  "diffBase": "c930f859714de408ba0221f435ff957ed9e64759",
  "dirty": false
}

The counts reproduce exactly — this run did not under-report. 5 docs, 5 anchors (2 symbol, 0 route, 0 sdk, 2 literal, 1 command, 0 rule), 1 changed package, 3 of the 5 release-owned, 22 packageMentionDocs. Identical to the advisory's rows, so unlike the sibling PR's round there is no gap between the dirty run and the clean one here.

The metadataForms literal row — not a falsification

content/docs/protocol/kernel/i18n-standard.mdx is listed via the metadataForms literal, and this PR's headline claim is that both literals are gone, so this row got a real read rather than a glance. The page contains exactly one occurrence of the token, at line 873:

messages, settings, settingsCommon and metadataForms are not checked: their keys are owned by application code, plugins, and the platform's own metadata-type registry rather than by this stack's metadata, so there is no set of legal names to resolve against.

That is a statement about key resolution — which sections os i18n check will warn about an unresolvable key in — not about the committed-section list, the provenance companion, or anything translationModuleSections decides. The page does not enumerate the section list and does not describe it as hard-coded. Nothing translationModuleSections(bundle, kind) now derives differs from anything this page asserts, for any kind. Not falsified.

The neighbouring table at lines 845-855 enumerates resolvable key namespaces (objects.{object}, apps.{app}, dashboards.{dash}, globalActions.{action}), which is the check command's resolution surface and is untouched by this diff.

The emit gate — the change most likely to falsify prose

Checked specifically, in both hand-written pages: neither one mentions the companion at all. source-hashes, objects-only and companion return zero hits in either file, so neither says --source-hashes always writes LOCALE.source-hashes.generated.ts and neither documents a per-run file count. What each actually claims about the command:

  • i18n-standard.mdx — three mentions, all shape-level: two example invocations (--locales=zh-CN,ja-JP --out=... and --json) and one capability list entry. No flag this diff touches, no file-set claim.
  • translations.mdx — nine mentions, all about which keys get scaffolded and how --check is used as a CI gate. Its one load-bearing sentence is "--check writes nothing: it re-renders and diffs against --out, naming each stale file and printing the regenerate command." Still true: --check still writes nothing, still diffs against --out, still names each stale file. This diff changes only which files enter that compared set when no bundle module is committed.

Release-owned pages (read-only, read anyway)

All three read. No fact in them is falsified by this diff, so there is nothing to hand back for a docs-only PR or an issue. Each names os i18n extract once, and each names it for a reason this change does not touch:

  • releases/v15.mdx:534 — extract emits action-param keys. About which keys. Untouched.
  • releases/v16.mdx:790 — extract emits the resultDialog keys. About which keys. Untouched.
  • releases/v17.mdx:1084 — the translation metadata type speaks objects.{object}. About key shape. Untouched.
  • releases/v17.mdx:1650"os i18n extract --check fails instead of writing when bundles have drifted." Still true; this diff narrows the compared set in one input class, it does not change the verdict rule.

The provenance and companion hits in these three pages are unrelated senses of the words (record/package provenance badges, the __search companion column).

The advisory's own blind spot, swept by hand

The advisory states it cannot list a page that names a rule by its inputs, because such a page shares no identifier with the emitter. That is the real coverage question for an emitter-side diff like this one, so all of content/ was swept for the input-side vocabulary as well as the emitter-side identifiers:

token hits in content/ what they are
source-hashes 0
source-hashes.generated 0
--objects-only 0
--no-objects-only 0
generated.ts 0
GENERATED_SECTIONS 0
collectFilledFromHashes 0
narrowToCommittedSections 0
translationModuleSections 0
findStaleFills / stale-fill 0
metadataForms 6, in 3 files 5 are generated schema-reference rows for the TranslationData / TranslationItem field of that name (references/api/protocol.mdx, references/system/translation.mdx), documenting a spec shape this diff does not touch; the 6th is the i18n-standard.mdx line read above
provenance 150, in 62 files exactly one falls in an i18n/translation context — references/system/translation.mdx:398, the _provenance enum (package / org / env-forced) on a stored TranslationItem, which is metadata-item origin, a different concept from the source-hash companion. The other 149 are record, package and badge provenance

Nothing in content/ documents the provenance companion, the --source-hashes flag, the committed-section list, or the emit gate. No page states this rule by its inputs either, so the blind spot is closed by measurement rather than assumed away. Nothing is falsified, and no docs change is owed by this PR — neither here nor as a follow-up.


Generated by Claude Code

…es its sections from their payloads

`os i18n extract --source-hashes` narrowed the provenance table to "the
sections this run commits" and built that list from two literals. The half
deciding WHICH modules were emitted already read the emitted set; the half
naming them pushed 'objects' / 'metadataForms'.

Two consequences, one cause:

  - With no module emitted the list is empty, narrowToCommittedSections
    returns {}, and {} is truthy at the emit gate — so the run wrote a
    zero-record companion with no bundle module beside it. Because --check
    compares the companion by bytes like any other emitted file, that
    orphan once committed is a file the gate demands forever.
  - Under kind: 'stack' the module holds every group the stack authors
    while the caller named one of them — correct only for as long as no
    other section can reach the table.

translationModuleSections() now sits beside translationModulePayload and is
switched on the same kind, so what a module holds and which sections it
commits are one decision. The emit gate returns undefined for an empty set.

No provenance record moves in this repository: the tables only ever carry the
two sections collectFilledFromHashes walks, so 'objects' was the right name
for both stack sub-tree modes — correct by coincidence, now by construction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8
@github-actions github-actions Bot added the size/m label Sep 8, 2026
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 5 documentable anchor(s).

2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/protocol/kernel/i18n-standard.mdx (via metadataForms (literal, a string literal in I18nExtract; a string literal in translationModuleSections; a string literal on a changed line), os i18n extract (command, read off packages/cli/src/commands/i18n/extract.ts))
  • content/docs/ui/translations.mdx (via os i18n extract (command, read off packages/cli/src/commands/i18n/extract.ts))

3 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v15.mdx (via os i18n extract (command, read off packages/cli/src/commands/i18n/extract.ts))
  • content/docs/releases/v16.mdx (via os i18n extract (command, read off packages/cli/src/commands/i18n/extract.ts))
  • content/docs/releases/v17.mdx (via os i18n extract (command, read off packages/cli/src/commands/i18n/extract.ts))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 60 of 216 client-bound route-ledger rows — the other 156 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 156: 0 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; 100 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 — 22 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 c930f859714de408ba0221f435ff957ed9e64759packageMentionDocs.

Which tree this was computed on

This run read content/docs from c74b61bc550820a2165937eebc35fd5893e5e7fd — the merge of head 4481b402fb940f5a21d363bcc232809c2e053ae8 into base c930f859714de408ba0221f435ff957ed9e64759, 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 c74b61bc550820a2165937eebc35fd5893e5e7fd && git checkout c74b61bc550820a2165937eebc35fd5893e5e7fd
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c930f859714de408ba0221f435ff957ed9e64759 4481b402fb940f5a21d363bcc232809c2e053ae8 && git checkout -B drift-repro c930f859714de408ba0221f435ff957ed9e64759 && git merge --no-ff 4481b402fb940f5a21d363bcc232809c2e053ae8

node scripts/docs-audit/affected-docs.mjs --json c930f859714de408ba0221f435ff957ed9e64759

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs c930f859714de408ba0221f435ff957ed9e64759 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 8, 2026
@os-project-manager
os-project-manager marked this pull request as ready for review September 8, 2026 13:00
@os-project-manager
os-project-manager added this pull request to the merge queue Sep 8, 2026
Merged via the queue into main with commit f570c28 Sep 8, 2026
38 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-16242-companion-section-list branch September 8, 2026 13:27
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