Skip to content

src/translations/en.ts can drift from the source labels and no gate catches it - #69

Merged
os-sam merged 1 commit into
mainfrom
claude/issue-63-en-drift-gate
Sep 7, 2026
Merged

src/translations/en.ts can drift from the source labels and no gate catches it#69
os-sam merged 1 commit into
mainfrom
claude/issue-63-en-drift-gate

Conversation

@os-sam

@os-sam os-sam commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

Fixes #63

Route 1 of the two the card offers: the check exists, en.ts stays. #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

File Why
scripts/check-i18n-source.mjs New. Compares every key in src/translations/en.ts against the label its metadata declares, out of dist/objectstack.json.
package.json check:i18n-source = build, self-test, check. lint chains it, so the gate rides the command a contributor already runs.
.github/workflows/ci.yml Step renamed and the header comment says what the lint step now carries. No new step: pnpm lint runs it.
README.md · CONTRIBUTING.md · AGENTS.md The documented gate list is true again — see "a document that was already false" below.
src/translations/en.ts Header only. It said "no gate catches it"; now it names the gate.

No metadata was changed: the check found zero drift on main.

Why it belongs in pnpm lint and not in a fourth command

A 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-source still exists as its own name for running only this.

The shape question: what is in en.ts, and is all of it resolvable

Enumerated from the file, not guessed — every key shape, its count, and which collector resolves it:

key shape                                            count  resolved by
apps.APP.description                                     1  os i18n extract
apps.APP.label                                           1  os i18n extract
apps.APP.navigation.ITEM.label                          32  os i18n extract
dashboards.D.description                                 3  os i18n extract
dashboards.D.label                                       3  os i18n extract
dashboards.D.widgets.W.description                      14  os i18n extract
dashboards.D.widgets.W.title                            15  os i18n extract
datasets.DS.description                                  5  os i18n extract
datasets.DS.dimensions.N.label                          16  os i18n extract
datasets.DS.label                                        5  os i18n extract
datasets.DS.measures.N.label                             5  os i18n extract
objects.OBJ._actions.N.confirmText                       1  os i18n extract
objects.OBJ._actions.N.description                       2  os i18n extract
objects.OBJ._actions.N.label                             4  os i18n extract
objects.OBJ._actions.N.successMessage                    3  os i18n extract
objects.OBJ._sections.N.label                           12  os i18n extract
objects.OBJ._validations.N.message                      10  os i18n extract
objects.OBJ._views.N.description                         1  view-nested resolver
objects.OBJ._views.N.label                              34  10 extract + 24 view-nested
objects.OBJ.description                                 12  os i18n extract
objects.OBJ.fields.F.help                               35  os i18n extract
objects.OBJ.fields.F.label                             146  os i18n extract
objects.OBJ.fields.F.options.VALUE                     123  os i18n extract
objects.OBJ.label                                       12  os i18n extract
objects.OBJ.pluralLabel                                 12  os i18n extract
TOTAL                                                  507

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.

  1. os i18n extract --json — 482 keys. That command is built on collectExpectedEntries, 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.
  2. A local resolver — 25 keys. The platform walk addresses objects.OBJ._views.NAME.* only for view documents. en.ts also restates the text that lives inside a view document: its default list (9), its listViews (14), and one formViews heading and description (2, where the form view spells the heading title and the bundle key face spells it label). 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 bucket pnpm lint reports as platform 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 orphaned with 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:

  • orphan (a key in 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 against zh-CN, and it is the exact residue a rename leaves.
  • missing (metadata declares text, en.ts never restates it): in scope, fails — and nothing else covers it. Measured on main before this PR: delete objects.ats_skill.fields.aliases.help from en.ts and pnpm lint --i18n-strict exits 0. os i18n check reports en at 100 % (expected 1265 / translated 1265) because for the default locale the source label is the translation. --i18n-strict gates the non-default locales; it structurally cannot speak for en. That is the half of the drift that would have bitten Time-to-offer on the employer dashboard: stamp the duration on ats_application, seed offers for hired applications, and correct §04's wording #55: a new field, a zh-CN entry written for it, and no en entry, with every gate green.

A document that was already false

CONTRIBUTING.md said "pnpm lint fails when a translatable key is missing in either file". Measured above: it does not, for en. 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":

before:  label: 'Status',        x1   label: 'Review status',  x0
after:   label: 'Status',        x0   label: 'Review status',  x1
blob:    worktree 0e76273b6c38309647802814a372f123fdc53475  vs  HEAD e3136ea10c25c53f079f5d10bff409de4e80ab40
diff --git a/src/objects/job.object.ts b/src/objects/job.object.ts
index e3136ea..0e76273 100644
--- a/src/objects/job.object.ts
+++ b/src/objects/job.object.ts
@@ -129,7 +129,7 @@ export const Job = ObjectSchema.create({
 
     // ── Lifecycle ───────────────────────────────────────────────────────
     status: Field.select({
-      label: 'Status',
+      label: 'Review status',
       required: true,
       defaultValue: 'draft',
       options: [

pnpm lint on that tree — exit code captured before any pipe:

EXIT (captured before any pipe) = 1

◆ i18n source parity
────────────────────────────────────────
  bundle keys (src/translations/en.ts)    507
  source labels collected                   507   (482 os i18n extract + 25 view-nested)
  compared, key present on both sides       507
  excluded: platform built-in keys          769   (metadataForms.* — Studio copy the platform packages ship)

  drifted — the bundle overrides the source label (1)
    objects.ats_job.fields.status.label
      bundle (en.ts)  "Status"
      source          "Review status"   [os i18n extract]

  1 i18n source-parity issue(s)
  src/translations/en.ts restates the source labels; a stale entry there wins at runtime.
  Fix the bundle to match the metadata, or fix the metadata — whichever is the mistake.

Restored, proven by hash rather than by an exit code:

git diff HEAD is empty above => restored
worktree blob e3136ea10c25c53f079f5d10bff409de4e80ab40  vs  HEAD e3136ea10c25c53f079f5d10bff409de4e80ab40
label: 'Status',       x1

EXIT (captured before any pipe) = 0
  ✓ every bundle key restates the label its metadata declares

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:

@@ -976,7 +976,10 @@ export const en: TranslationData = {
       fields: {
         name: {
-          label: 'Skill',
+          label: 'Skill name',
+        },
+        deleted_field: {
+          label: 'A field nobody declares any more',
         },
@@ -990,7 +993,6 @@ export const en: TranslationData = {
         aliases: {
           label: 'Aliases',
-          help: 'Comma-separated synonyms, so a search for one spelling finds the tag.',
         },
EXIT (captured before any pipe) = 1

  compared, key present on both sides       506

  drifted — the bundle overrides the source label (1)
    objects.ats_skill.fields.name.label
      bundle (en.ts)  "Skill name"
      source          "Skill"   [os i18n extract]

  orphaned — no metadata declares text at this key (1)
    objects.ats_skill.fields.deleted_field.label
      bundle (en.ts)  "A field nobody declares any more"

  missing — the metadata declares text en.ts never restates (1)
    objects.ats_skill.fields.aliases.help
      source  "Comma-separated synonyms, so a search for one spelling finds the tag."   [os i18n extract]

  3 i18n source-parity issue(s)

The middle mutation is the one that was invisible before this PR: on main, that same deletion leaves pnpm lint at exit 0.

Proof it bites in CI

Two independent pieces, because "it runs on my laptop" is not a gate:

  1. Every CI run proves the comparator can fail. pnpm check:i18n-source runs --self-test before 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.
  2. The end-to-end red run is linked in a comment below: a throwaway branch carrying this PR's commit plus the job.object.ts rename, 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:

validate=0 lint=0 typecheck=0
◆ i18n source parity — self-test
────────────────────────────────────────
  ✓ 7 assertions — the comparator still reports drift, orphans and gaps

◆ i18n source parity
────────────────────────────────────────
  bundle keys (src/translations/en.ts)    507
  source labels collected                   507   (482 os i18n extract + 25 view-nested)
  compared, key present on both sides       507
  excluded: platform built-in keys          769   (metadataForms.* — Studio copy the platform packages ship)

  ✓ every bundle key restates the label its metadata declares

validate and typecheck are green with their usual output; validate still prints the three pre-existing approval-approvers-may-resolve-empty suggestions from main, untouched by this PR.


Generated by Claude Code

`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

os-sam commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator Author

The end-to-end CI proof, as promised in the body

Both runs are in. Same workflow, same pnpm lint step, two trees that differ by one line.

Branch Tree Run lint step
claude/issue-63-en-drift-gate (this PR) as committed run 7 success
claude/issue-63-ci-proof (throwaway, now closed) this PR's commit + ats_job.fields.status renamed StatusReview status run 8 failure

Step-by-step on the red run — the gate is what stopped it, and typecheck never got to run:

JOB validate · lint · typecheck  completed  failure
    7 validate — protocol schema, CEL predicates, widget bindings   -> success
    8 lint — data-model conventions + zh-CN coverage + en.ts source parity -> failure
    9 typecheck                                                     -> skipped

What the runner printed, from the job log (timestamps and colour codes stripped):

◆ i18n source parity — self-test
────────────────────────────────────────
  ✓ 7 assertions — the comparator still reports drift, orphans and gaps

◆ i18n source parity
────────────────────────────────────────
  bundle keys (src/translations/en.ts)    507
  source labels collected                   507   (482 os i18n extract + 25 view-nested)
  compared, key present on both sides       507
  excluded: platform built-in keys          769   (metadataForms.* — Studio copy the platform packages ship)

  drifted — the bundle overrides the source label (1)
    objects.ats_job.fields.status.label
      bundle (en.ts)  "Status"
      source          "Review status"   [os i18n extract]

  1 i18n source-parity issue(s)
  src/translations/en.ts restates the source labels; a stale entry there wins at runtime.
  Fix the bundle to match the metadata, or fix the metadata — whichever is the mistake.

 ELIFECYCLE  Command failed with exit code 1.
##[error]Process completed with exit code 1.

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 pnpm lint, which chains pnpm check:i18n-source. There is no separate CI step to point at, by design — the card asked for the wiring that keeps a contributor's command list at three.


Generated by Claude Code

os-sam commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator Author

Correction to the comment above: the throwaway PR #70 is closed, but its branch claude/issue-63-ci-proof is still on the remote — the delete push is refused by this session's git credential:

$ git push origin :refs/heads/claude/issue-63-ci-proof
error: RPC failed; HTTP 403 curl 22 The requested URL returned error: 403
send-pack: unexpected disconnect while reading sideband packet

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 (328c54b, a deliberate label rename) on top of this PR's, and nothing depends on it. The local worktree and branch are gone; this PR's history is unaffected either way.


Generated by Claude Code

os-sam commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator Author

Review — I tried to break it four ways, including the self-test, and it held

This 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 c91cc1a, not a reading of the PR body.

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 objects.ats_skill.fields.name.label from en.ts (diff pasted below, edit confirmed on disk before reading any result):

objectstack lint --i18n-strict   EXIT=0      ← the gate we had is blind to it
pnpm lint (with the new chain)   EXIT=1      ← missing — the metadata declares text en.ts never restates (1)
                                                 objects.ats_skill.fields.name.label
                                                   source  "Skill"   [os i18n extract]

That settles the missing category, which the card did not even ask for. CONTRIBUTING.md's old claim that lint "fails when a translatable key is missing in either file" was already false; this PR both fixes the sentence and makes the half that was false true.

The tautology risk, which is the way this check could have been worthless

A source-parity check is vacuous if the "source" side is derived from the bundle. I read both collectors for this specifically: collector 1 is os i18n extract --json (the platform's own walk over inline label: declarations) and collector 2 reads view.list / view.listViews / view.formViews off the built view documents. Neither reads translations[]. Confirmed behaviourally too — a rename in a source file moves the source column while the bundle column stays put, which a tautology cannot do.

Four mutations, each proven to land on disk before I read any result

# mutation collector result
A ats_job.fields.status "Status" → "Review status" (job.object.ts) 1 exit 1 — the CI proof in #70, log read
B listViews.pipeline.label "Pipeline" → "Pipeline board" (application.view.ts) 2 exit 1 — objects.ats_application._views.pipeline.label, bundle "Pipeline" vs source "Pipeline board", origin views.ats_application.listViews.pipeline.label
C delete objects.ats_skill.fields.name.label from en.ts 1 exit 1 — missing, compared count drops 507 → 506
D add objects.ats_skill._views.ghost_view_that_does_not_exist.label to en.ts exit 1 — orphaned

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

--self-test claims 7 assertions on every run. A self-test that cannot fail is just more green. So I disabled drift detection in compare() (if (false && sourceValue !== bundleValue)) and ran both halves against a genuinely drifted tree:

node scripts/check-i18n-source.mjs --self-test
  ✗ a drifted value is reported as a mismatch, carrying both strings:
      {"mismatches":[],"orphans":[],"missing":[],"compared":1}
  1 self-test failure(s) — the gate cannot be trusted to bite; fix it before trusting a green run.
  EXIT=1

node scripts/check-i18n-source.mjs          (on the drifted tree)
  EXIT=0                                    ← the drift went completely unseen

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 main, which had moved under this branch

#69 was written against 62e496d; main is now e249926 (#68), which added five keys to en.ts — a field label and help, a dataset measure label, a widget title and description. Key shapes the collectors had never been exercised against. An orphan there would have turned main red on the merge, so I did not take the mergeability flag as an answer: I merged origin/main into the branch locally and ran the whole chain.

bundle keys (src/translations/en.ts)    512
source labels collected                 512   (487 os i18n extract + 25 view-nested)
compared, key present on both sides     512
✓ every bundle key restates the label its metadata declares

validate=0  lint=0  typecheck=0

All five of #68's new keys resolve through collector 1. No conflict, no orphan. Test merge discarded; the branch is untouched.

Two notes, neither blocking

  • pnpm lint now runs objectstack build as a side effect, so linting writes dist/. It is ~800 ms and the check genuinely needs the artifact, so the trade is right — but it is worth knowing that "lint" is no longer read-only.
  • claude/issue-63-ci-proof could not be deleted (403 from the session credential). It is one commit, PR DO NOT MERGE — CI proof for #63: deliberate label drift #70 is closed, nothing depends on it, and the run it exists to preserve is the evidence. Leaving it; I have recorded on DO NOT MERGE — CI proof for #63: deliberate label drift #70 that its red CI is a deliverable and must never be "fixed".

Merging.


Generated by Claude Code

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

src/translations/en.ts can drift from the source labels and no gate catches it

2 participants