feat(platform-objects,cli): record which source revision a generated translation leaf was filled from - #12557
Conversation
…translation leaf was filled from Extends the #8765 Option B source-hash mechanism to the generated bundles, per maintainer ruling #12069 Option A, and corrects the false "this hole cannot occur there" note that kept them out of it. `os i18n extract --source-hashes` emits <locale>.source-hashes.generated.ts — per leaf, the digest of the source revision that leaf is still a byte copy of. withSourceFallback takes it as a fourth argument and judges objects / metadataForms alongside the hand-authored sections. Records are written only where a leaf is currently a copy of the CURRENT source, so day-one stale count is 0 by construction: 9030 translated leaves measured, 1543 recorded, 7487 left legacy-trusted. No committed bundle moved a byte. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o
…8n-stale-leaf-drift # Conflicts: # packages/cli/src/utils/i18n-extract.ts
…property the ruling turns on The suite pinned per-locale RECOVERY for the hand-authored half but had no analogue for the generated half's per-locale DETECTION, which is the whole of what maintainer ruling #12069 Option A buys over the gate that shipped first. `check:i18n-stale-fill` infers provenance from two locales holding byte-identical text, so a leaf stranded in exactly one locale has no second witness and is invisible to it by construction. A recorded hash needs no witness. Measured on this tree: 18 generated leaves are recorded in exactly one locale (zh-CN 2, ja-JP 3, es-ES 13) — English-looking terms one locale left as a fill while the others translated them. Verified falsifiable: deleting the third conjunct of findStaleFills turns these 3 tests into 1 failure among 3 total, predicted in writing before the run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o
📓 Docs Drift CheckThis PR changes 2 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 — 24 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 b196afc5a2b186099cc03dc83606fa9d0e0763d1 && git checkout b196afc5a2b186099cc03dc83606fa9d0e0763d1
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 52a98238876fd5183ff087ce6cca1adb195a9636 43f2f81f54e4cecf543382983ded449cd4d5c514 && git checkout -B drift-repro 52a98238876fd5183ff087ce6cca1adb195a9636 && git merge --no-ff 43f2f81f54e4cecf543382983ded449cd4d5c514
node scripts/docs-audit/affected-docs.mjs --json 52a98238876fd5183ff087ce6cca1adb195a9636
|
PM review — accepted, flipped ready, auto-merge armedRe-measured independently of the report, because a resumed branch carrying another seat's unreviewed commit is exactly where inherited numbers go unchecked. The load-bearing claim, recounted from the artefactsLeg D is what justifies Option A over the already-shipped cross-locale gate, and its population claim is the part that has to be true. Counted straight out of the three committed companions rather than from the PR body: Every figure matches to the unit. So the argument holds on measured ground: The ruling's three constraints, checked one at a timeRuling #12069 Option A (os-trump, 2026-08-25, maintainer accepted verbatim 「同意你的建议」) carried three:
On the third: Other claims spot-checked
On the resumed-branch provenanceThe recovered commit was kept in full, and the report says every number was re-derived after merging 46 commits of Recorded, not held against the PR
One scope boundary the reader must not misread
CI is the remaining gate. Generated by Claude Code |
Fixes #11671
Implements maintainer ruling #12069 Option A: extend the existing #8765 source-hash mechanism to the generated bundles, correct the false "cannot occur" note that kept them out of it, no second mechanism, no change to
check:i18n's key-set comparison.The defect
check:i18ncompares bundles by key. A leaf filled with the source string under--fill=defaultis still present after the source is revised, so the gate reports OK forever while the locale serves a superseded draft — and the drift is sticky, because--fill=defaultfills gaps only and never revisits a non-empty leaf. Measured on #11659 atbbe0b17: three locales serving a 602-char draft of a 411-char help string under 31 green checks.Once the source has moved, that stale fill is indistinguishable by value from a real translation, which is why the "at minimum" fallback in the original triage was unimplementable: 2648 of 3010 leaves differ from
en, so "untranslated AND differing from the source" names an empty set.What this does
os i18n extract --source-hasheswrites oneLOCALE.source-hashes.generated.tscompanion per locale, beside each generated bundle: per leaf, the digest of the source revision that leaf is still a byte copy of.withSourceFallbacktakes that table as a fourth argument and now judgesobjects/metadataFormsalongside the hand-authored sections, so a leaf whose source moved underneath it serves the current source string instead of a superseded draft.The generated half needs one conjunct the hand-authored half does not — the leaf must still hold the recorded bytes. Its hash table is itself generated, so a translator cannot be asked to refresh a digest by hand; without that conjunct, re-translating a stale leaf would report the fresh translation as stale forever. With it, editing the value clears the flag by itself.
Provenance of this branch — kept / re-derived / discarded
This card was resumed after a dev seat stalled. One commit (
00078b736b) existed only on container-local disk and was pushed by the PM so it would survive; it carried no PR, no gate run, no test run, and no ablation.00078b736b. I read it against the ruling and re-derived its claims rather than trusting them; every number quoted below is one I measured on this tree, not one carried over from its commit message.git resetwas on the table and I did not need it: the shape is the ruled one (extends the existing module, one hash function, one table shape).origin/main(46 commits, one real conflict inpackages/cli/src/utils/i18n-extract.ts, an import line resolved as a union); the gate union, derived from the real changeset rather than recalled; and the day-one staleness claim, which I re-measured after the merge because 46 commits of source-string movement could have invalidated it (it holds:TOTAL_STALE=0).43f2f81f54). The suite pinned per-locale recovery for the hand-authored half but had no analogue for the generated half's per-locale detection, which is the entire reason Option A was ruled over the status quo. Named here because it is the one thing in this PR that is not in the recovered commit.Why a change that only records provenance would be half the card
It does not only record.
withSourceFallbackreads the records at serving time and substitutes the current source, andfindStaleFillsreports them. Note this is deliberately a serving rule, not a red build: making translation lag fail CI is Option C, which the #8765 ruling rejected for putting a four-locale translation task in front of every one-word source edit.Evidence
Every ablation below had its direction and exact count written down before the run, proved its mutation on disk with anchored
grep -cFcounts before any result was read, restored undertrap … EXIT INT TERM, and had the restore verified by an emptygit diff. No build was involved: these tests import the module relatively, sosrcis what runs and nodistcan go stale underneath them.Baseline (merged tree):
TOTAL_STALE=0— zh-CN 336 records / ja-JP 389 / es-ES 398, over 1397 generated leaves each. A zero is not a reading until a positive control fires, hence:enat a leaf recorded in all three localesenat a leaf recorded in no localefindStaleFillsenat a leaf recorded in es-ES onlycheck:i18n-stale-fillexit 0, "0 stale-fill leaves"⭐ Leg D is the measurement that justifies the ruling. Same tree, same drift:
check:i18n-stale-fillinfers provenance from two locales holding byte-identical text, so a leaf stranded in exactly one locale has no second witness and is invisible to it by construction. A recorded hash needs no witness. That population is real, not hypothetical — 18 generated leaves are recorded in exactly one locale today (zh-CN 2, ja-JP 3, es-ES 13): English-looking terms one locale left as a fill while the others translated them (Variables (JSON),Reply-To,Checksum).Gates. Union derived at the merge head with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(repo assertion held; 16 changed paths, three-dot semantics) → 33 families. All 33 green, each exit code captured before any pipe. Two first reportedCOULD NOT MEASURE—check:i18n-coverageandcheck:type-check-debt, both naming an unbuilt workspace package in this worktree, both refusing to measure rather than measuring a different world. Read as not measured, not red, and both green once the named packages were built. Quoting each gate's own verdict line:check-i18n-bundles: OK (9 package(s) — all bundles in sync, no undeclared authoring keys).⭐ platform-objects reports 11 bundles here, up from 8 — the three provenance companions are inside the gate's byte-for-byte compare set, so they cannot diverge from what a real extract writes. They still match after 46 commits ofmain.check-i18n-stale-fill: OK (10 bundle set(s) — no new stale fills, 0 baselined).check-i18n-coverage: OK (12 config(s), 602 baselined untranslated string(s), none new).check-type-check-coverage --re-measure: OK — 32 ledger entr(ies) re-measured in 415.7s, 1843 raw tsc error(s) total, none above its recorded number.check:entry-guard: 170 scripts/ file(s) … 126 export bindings, 124 of them inert on importRatchet families re-run at the final commit
43f2f81f54, after the test addition, together with@objectstack/platform-objects typecheck(rc=0) and 15 other affected families — all green.Tests at
43f2f81f54:platform-objectstranslations 85 passed (7 files);clii18n extract source-hashes 6 passed.Lint.
eslint --no-inline-config --format jsonover the diff: 14 files linted, 0 errors, 0 warnings. This is a measurement, not a narrowing — the 14 are 100% of the diff's lint-governed surface (0 came backFile ignored; the other 2 changed paths arepackage.jsonand the changeset.md), andeslint.config.mjsstates it "never enables type-aware linting (noparserOptions.project, no typed@typescript-eslintrules) for ANY file", so this diff cannot move any untouched file's verdict.Changeset
.changeset/i18n-generated-leaf-source-provenance.md— minor for@objectstack/platform-objectsand@objectstack/cli. Defended: both changes are additive and backward compatible — a new CLI flag that is off by default, a new optional fourth parameter onwithSourceFallback, and new exports. No existing call site changes behaviour, and no committed bundle moved a byte. Notpatch, because it adds a published capability and a new emitted file format; notmajor, because nothing existing breaks.Scope
--source-hashesis off by default and@objectstack/platform-objectsis the one bundle set that opts in, by documenting the flag in its extract config. The other eight sets keep exactly today's behaviour — a set with no companion is entirely legacy-trusted, never reported and never wrong about. Behaviour on the day this lands is unchanged for every leaf: records are written only where a leaf is currently a byte copy of the current source, so day-one staleness is 0 by construction, which I re-measured after the merge rather than inheriting.docs/adr/**,.claude/**,skills/**,AGENTS.md,CLAUDE.mdandcontent/docs/releases/are untouched.Generated by Claude Code