fix(cli): the i18n provenance companion accompanies a module, and names its sections from their payloads - #16871
Conversation
…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
📓 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:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 22 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 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
|
Fixes #16242
Clause-②: no
os i18n extract --source-hashesnarrows the provenance table to "the sections this run commits" before writingLOCALE.source-hashes.generated.ts. The half that decided WHICH modules were emitted already reademittedModules(locale); the half that NAMED them pushed the literals'objects'and'metadataForms'.Both literals are gone.
translationModuleSections(bundle, kind)now sits besidetranslationModulePayloadand is switched on the samekind, so what a module holds and which sections it commits are one decision; and the emit gate returnsundefinedfor an empty section set instead of a truthy{}.Two things the card asked for turned out to be false at source. Both were measured on this branch's base,
origin/main41cbc54fc5, not inherited.1. No
apps.*provenance record is being filtered out, because none is ever built. The provenance table comes fromcollectFilledFromHashes, which walkscollectGeneratedLeaves, which walksGENERATED_SECTIONS—['objects', 'metadataForms']inpackages/platform-objects/src/apps/translations/source-hash.ts.apps/dashboards/pagesareHAND_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:So the card's
3 leaves / 2 recordsreading reproduces exactly — and its 2 records are the correct output. The 773 records the narrowing dropped are themetadataFormsbaseline, dropped because--no-metadata-formswas 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. Forkind: 'objects'the payload isdata.objects, whose top-level keys are OBJECT NAMES (kpi_metric), not the sectionobjects. Deriving the section list from those keys would commit['kpi_metric'], andnarrowToCommittedSections— which matches a leaf path's first dotted segment — would then drop everyobjects.*record. That is the one path this repository's single--source-hashesconfig (packages/platform-objects/scripts/i18n-extract.config.ts) is on.Only
kind: 'stack'selects aTranslationData-shaped subtree whose top-level keys ARE sections.translationModuleSectionstherefore 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, bothi18n.defaultLocale: 'zh-CN'.Fixture A — symptom 1 (
--locales=ja-JP --no-objects-only --no-metadata-forms --source-hashes --fill=default):The module's 3 leaves are
objects.kpi_metric.label,objects.kpi_metric.fields.name.labelandapps.kpi.label; the companion's 2 records are the twoobjects.*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):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 HEADempty): the pre-fix source writes the orphan, and--checkwith the companion deleted exits 1 withmissing: 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.--checksemantics for an already-committed empty companion (acceptance item 4)Decision: nothing is deleted, and
--checktolerates 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--checkexits 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 reportsmissing(absent) andstale(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 throughparseSourceHashModuleinto 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 siblingi18n-extract-emitted-files.test.tsre-implements the emit rule and so agrees with it by construction — it would have written the orphan too..e2eon purpose. The behavioural predicate puts it in theintegrationPROJECT; the.e2eNAME would additionally move it into theOS_TEST_TIERS=nightlyPOPULATION. The two cuts are independent and the name decides the RUN, so a--checksemantics guard is named likei18n-extract-check-json.test.ts(queue) rather thani18n-extract-key-count.e2e.test.ts(nightly). Verified withvitest list --filesOnly --project integration.packages/cli/test/i18n-extract-source-hashes.test.tsgains 3 unit cases fortranslationModuleSections.Verification on
4481b402fb:vitest run --project unit(whole tier)vitest run --project integration(new file)pnpm --filter @objectstack/cli typecheckcheck:*gates (dispatch-gates.mjs --commands)eslint . --no-inline-config(whole repo,--format json)The eslint reading is the full union, not a narrowed run. The config declares no type-aware linting (
eslint.config.mjsline 328: noparserOptions.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 fullpnpm build(73/73 tasks). Worth noting thatcheck:i18n-walk-paritysignals that prerequisite with exit 1, not exit 3 — the exit code alone reads as a finding, and only its printed verdict line distinguishes them.验收备注
--no-objects-onlythe stack module writesapps.*leaves into a.generated.tsfile, filled by--fill=defaultfrom 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:findStaleFillsskips them (not inGENERATED_SECTIONS) and the hand-authoredLOCALE.source-hashes.tshas no entry for a generated leaf. So the card's harm statement is real; its attributed mechanism is not. Fixing it means changingGENERATED_SECTIONSinpackages/platform-objects— a different package, outside this card's declared surface, and a semantics decision about which predicate judges a generatedappsleaf. It is dormant today for the same reason the card was: the repo's one--source-hashesconfig runs under the default--objects-only.src/commands/datasource/envelope-unwrap.test.tsfailed itsbeforeAllwithHook timed out in 10000ms(oclifConfig.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 hereand 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..changeset/i18n-*.mdfiles PR chore: version packages #15334 holds.41cbc54fc5;origin/mainmoved during the run.mainwas 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 head4481b402fbinto basec930f859714de408ba0221f435ff957ed9e64759) and confirmed clean before the tool ran:git status --porcelainprinted nothing. The tool's own provenance block then reported: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
metadataFormsliteral row — not a falsificationcontent/docs/protocol/kernel/i18n-standard.mdxis listed via themetadataFormsliteral, 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:That is a statement about key resolution — which sections
os i18n checkwill warn about an unresolvable key in — not about the committed-section list, the provenance companion, or anythingtranslationModuleSectionsdecides. The page does not enumerate the section list and does not describe it as hard-coded. NothingtranslationModuleSections(bundle, kind)now derives differs from anything this page asserts, for anykind. Not falsified.The neighbouring table at lines 845-855 enumerates resolvable key namespaces (
objects.{object},apps.{app},dashboards.{dash},globalActions.{action}), which is thecheckcommand'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-onlyandcompanionreturn zero hits in either file, so neither says--source-hashesalways writesLOCALE.source-hashes.generated.tsand 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--checkis used as a CI gate. Its one load-bearing sentence is "--checkwrites nothing: it re-renders and diffs against--out, naming each stale file and printing the regenerate command." Still true:--checkstill 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 extractonce, 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 theresultDialogkeys. About which keys. Untouched.releases/v17.mdx:1084— thetranslationmetadata type speaksobjects.{object}. About key shape. Untouched.releases/v17.mdx:1650— "os i18n extract --checkfails 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
provenanceandcompanionhits in these three pages are unrelated senses of the words (record/package provenance badges, the__searchcompanion 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:content/source-hashessource-hashes.generated--objects-only--no-objects-onlygenerated.tsGENERATED_SECTIONScollectFilledFromHashesnarrowToCommittedSectionstranslationModuleSectionsfindStaleFills/stale-fillmetadataFormsTranslationData/TranslationItemfield of that name (references/api/protocol.mdx,references/system/translation.mdx), documenting a spec shape this diff does not touch; the 6th is thei18n-standard.mdxline read aboveprovenancereferences/system/translation.mdx:398, the_provenanceenum (package / org / env-forced) on a storedTranslationItem, 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-hashesflag, 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