src/translations/en.ts can drift from the source labels and no gate catches it - #69
Conversation
`src/translations/en.ts` restates the English labels that already exist inline in the object, view, app, dashboard and dataset definitions, so the two locale files diff line for line. Nothing checked that the restatement was still true: rename a label in an object file and the bundle keeps serving the old string to every English user, because a bundle entry wins over the source label at runtime. Measured before this change, on this tree: `pnpm validate`, `pnpm lint --i18n-strict` and `pnpm typecheck` are all green with a drifted entry, and green with a missing `en` entry too — `os i18n check` reports `en` at 100 % (expected 1265 / translated 1265) because the source label counts as the translation for the default locale. Coverage asks whether a key exists, not whether it still says what the metadata says. `scripts/check-i18n-source.mjs` asks the second question. It reads both sides out of `dist/objectstack.json` and fails on a drifted value, a key whose metadata is gone, and a source label the bundle never restates. The source side is collected twice on purpose: `os i18n extract --json` is the platform's own single definition of what is translatable, so a surface the platform adds later arrives here for free; a local resolver covers the view-nested text that walk does not address (a view's default `list`, its `listViews` and its `formViews`). Together they resolve all 507 keys in `en.ts` — every key is compared, none is skipped, and a key shape neither collector knows fails as an orphan rather than passing quietly. `pnpm lint` chains it, so CI and a laptop run it without a fourth command. It runs its own `--self-test` first, on every run: seven assertions that the comparator still reports a mismatch, an orphan and a gap, so the gate proves in CI that it can fail. The real run refuses to report success when either side came back empty, or when the artifact is older than `src/`. Zero findings on this tree today: 507 bundle keys, 507 source labels, 507 compared, 769 platform `metadataForms.*` keys excluded. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PbJ5Cy9KDAzeQHo8bsMadG
The end-to-end CI proof, as promised in the bodyBoth runs are in. Same workflow, same
Step-by-step on the red run — the gate is what stopped it, and What the runner printed, from the job log (timestamps and colour codes stripped): Byte-identical to the local failing run in the body, from a clean checkout on a runner that never saw my machine — same key, same two strings. The self-test line above it is the second half of the proof: it ran in CI, on that runner, before the real check. The throwaway PR is #70, now closed with its branch deleted. Nothing of it is in this PR's history. Nothing about the gate is left unproven, with one honest caveat: what CI executed is Generated by Claude Code |
|
Correction to the comment above: the throwaway PR #70 is closed, but its branch Pushing commits works from here; deleting a ref does not, and there is no delete-branch tool on the MCP surface either. Someone with write access can remove it — it carries one commit ( Generated by Claude Code |
Review — I tried to break it four ways, including the self-test, and it heldThis is the highest-leverage change in the repository so far, so it got the hardest review. Everything below is my own run in a clean worktree at First: does the gate actually add anything?The strongest possible evidence that a new gate earns its place is the same tree passing the old one and failing the new one. Deleted That settles the The tautology risk, which is the way this check could have been worthlessA source-parity check is vacuous if the "source" side is derived from the bundle. I read both collectors for this specifically: collector 1 is Four mutations, each proven to land on disk before I read any result
B is the one I added. Your CI proof covered collector 1 only; collector 2 is the bespoke half — 25 keys the platform's walk does not address — and a bespoke resolver that never fires is precisely how a gate reads green forever. It fires, and it names the authoring site so a maintainer knows which file to open. C and D together also show the coverage counter is live rather than a printed constant: bundle keys stayed 507 (one deleted, one added) while compared moved to 506. The self-test — I broke the comparator to see whether the guard on the guard works
That is the scenario in full: a crippled comparator passes a broken tree silently, and the self-test is the only thing that says so. It says so, before the real check runs, on every CI invocation. This is the part of the PR I would keep if I could keep only one. The interaction with
|
Fixes #63
Route 1 of the two the card offers: the check exists,
en.tsstays. #62 kept the file so a translator can diff the two locales line for line, and #55 is in flight adding a field that will want an entry there.What changed
scripts/check-i18n-source.mjssrc/translations/en.tsagainst the label its metadata declares, out ofdist/objectstack.json.package.jsoncheck:i18n-source= build, self-test, check.lintchains it, so the gate rides the command a contributor already runs..github/workflows/ci.ymlpnpm lintruns it.README.md·CONTRIBUTING.md·AGENTS.mdsrc/translations/en.tsNo metadata was changed: the check found zero drift on
main.Why it belongs in
pnpm lintand not in a fourth commandA contributor runs
pnpm validate && pnpm lint && pnpm typecheck, and CI runs the same three. Chaining keeps that list at three and needs no new CI step.pnpm check:i18n-sourcestill exists as its own name for running only this.The shape question: what is in
en.ts, and is all of it resolvableEnumerated from the file, not guessed — every key shape, its count, and which collector resolves it:
All of it is resolvable. Coverage is 507 of 507, and the remainder is empty. That is a measurement, not a hope: the source side is collected twice, deliberately.
os i18n extract --json— 482 keys. That command is built oncollectExpectedEntries, which the CLI's own header calls the single definition of what is translatable at all (os lint's coverage gate consumes the same walk). Using it means a surface the platform adds later arrives here for free rather than waiting for someone to notice this script is behind.objects.OBJ._views.NAME.*only for view documents.en.tsalso restates the text that lives inside a view document: its defaultlist(9), itslistViews(14), and oneformViewsheading and description (2, where the form view spells the headingtitleand the bundle key face spells itlabel). Without collector 2 those 25 keys would be compared against nothing.Excluded and named: 769
metadataForms.*keys, the Studio metadata-form copy the platform packages own and ship — the bucketpnpm lintreports asplatform built-ins: 773 i18n issue(s) hidden(#60). 773 is the number of addresses the walk records; 769 of them carry a string. Neither number is this app's, and neither is compared.There is no skip path in the script. Any key shape neither collector knows would land in
orphanedwith its own count, loudly, instead of passing quietly — which is why the coverage number above cannot silently rot.Keys with no source, and sources with no key — both are in scope
The card asked me to decide and justify, so:
en.ts, no metadata declaring text there — a deleted or renamed field leaving its entry behind): in scope, fails. It is dead weight in a file whose only job is to be diffed againstzh-CN, and it is the exact residue a rename leaves.en.tsnever restates it): in scope, fails — and nothing else covers it. Measured onmainbefore this PR: deleteobjects.ats_skill.fields.aliases.helpfromen.tsandpnpm lint --i18n-strictexits 0.os i18n checkreportsenat 100 % (expected 1265 / translated 1265) because for the default locale the source label is the translation.--i18n-strictgates the non-default locales; it structurally cannot speak foren. That is the half of the drift that would have bitten Time-to-offer on the employer dashboard: stamp the duration onats_application, seed offers for hired applications, and correct §04's wording #55: a new field, azh-CNentry written for it, and noenentry, with every gate green.A document that was already false
CONTRIBUTING.mdsaid "pnpm lintfails when a translatable key is missing in either file". Measured above: it does not, foren. The card required these documents to end up true, so the Translations section now separates the two gates — coverage (--i18n-strict,zh-CN) from source parity (this check,en) — and says what each can and cannot see.Proof it bites — the deliberate mismatch
The rename the card names, on the real source file. The diff first, because a regex that matches nothing leaves the file unmodified and the run then reads as "the gate does nothing":
pnpm linton that tree — exit code captured before any pipe:Restored, proven by hash rather than by an exit code:
All three finding kinds, in one run
Three mutations to
en.ts— rename a value, add a key for a field nobody declares, delete a key the metadata does declare:The middle mutation is the one that was invisible before this PR: on
main, that same deletion leavespnpm lintat exit 0.Proof it bites in CI
Two independent pieces, because "it runs on my laptop" is not a gate:
pnpm check:i18n-sourceruns--self-testbefore the real check, in the same script, so CI executes seven assertions on synthetic input on every push: a drifted value is reported with both strings, an orphan is reported, a gap is reported, a clean pair reports nothing, the view-nested resolver addresses list / listViews / formViews, two source strings on one key are an ambiguity rather than a coin flip, and a non-string leaf is refused rather than skipped. A future edit that makes the comparator vacuous turns CI red immediately instead of turning it quietly green.job.object.tsrename, opened as a draft PR purely so CI would execute it, then closed and deleted. It is kept out of this PR's history on purpose.The real run also refuses to pass on nothing: an empty bundle, an empty source side, zero shared keys, or an artifact older than
src/are each a hard failure rather than a green tick.Gates
Exit codes captured before any pipe, on the committed tree:
validateandtypecheckare green with their usual output;validatestill prints the three pre-existingapproval-approvers-may-resolve-emptysuggestions frommain, untouched by this PR.Generated by Claude Code