Skip to content

fix(lint): walk stack.translations as the locale-keyed bundle it is - #11383

Merged
os-steve merged 1 commit into
mainfrom
claude/issue-11288-translation-liveness-walk
Aug 23, 2026
Merged

fix(lint): walk stack.translations as the locale-keyed bundle it is#11383
os-steve merged 1 commit into
mainfrom
claude/issue-11288-translation-liveness-walk

Conversation

@claude

@claudeclaudeBot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Fixes#11288

What was wrong

lintLivenessProperties registered { type: 'translation', key: 'translations' } in TYPE_COLLECTIONS and then walked those items the way every other row in that list is walked — flat, with checkItem reading item[path] for a dotless warned path. But an item of stack.translations is a TranslationBundle: z.record(LocaleSchema, TranslationDataSchema) (packages/spec/src/system/translation.zod.ts:904, wired at packages/spec/src/stack.zod.ts:275), whose top-level keys are locale codes. So the lookup was bundle['flows'], a bundle has no flows key at any depth reachable that way, and every warned property missed.

Verified against the shipped ledgers at b863f01: the translation warn map has exactly one entry, flows (status: planned, authorWarn: true), and the ledger has 8 warned rows in total across all shipped types. So the whole translation channel amounted to one row that reached nobody — for file-authored bundles, which is the only way apps author translations today.

The fix

translation joins object/field as a bespoke walk, and the registry row becomes a tombstone comment saying why it must not come back: registering the collection is only half the contract, the walk has to match the collection's shape. For each bundle, each locale entry's TranslationData is checked, with a subject naming both:

translation bundle #0 · locale 'zh-CN'

Non-record bundles and non-record locale values are skipped, which keeps the function's own "advisory only — returns findings, never throws" contract across the two new levels.

One deviation from the shape the card and triage endorsed, declared: the subject carries the bundle index as well as the locale (the card suggested translation bundle · locale 'zh-CN'). Bundles are an unnamed array and one locale can appear in more than one of them, so without the index the finding does not say which bundle to open. It is the same addressing the sibling rule validate-translation-references already reports (translations[0]["zh-CN"]), rendered in this file's prose-subject convention.

Non-vacuity: the new test fails on the pre-fix code

Ruling 3 of the dispatch. The test block was written and run before the source change, on an otherwise untouched tree (pnpm --filter @objectstack/lint test):

 Test Files 1 failed | 79 passed (80)
Tests 4 failed | 2248 passed | 5 skipped (2257)

The four failures are the four load-bearing assertions, and two of them fail in opposite directions — which is what makes this a discriminator rather than a fixture that happens to be red:

  • warns on a warned group authored under a locale entryAssertionError: expected false to be true. The locale-keyed bundle produced no finding on the broken walk.
  • does not treat a runtime TranslationItem shape as a bundleAssertionError: expected [ { …(4) } ] to deeply equal [], the received finding being sets \flows` but this translation property has no runtime effect (liveness: dead). The TranslationItem`-shaped fixture did warn on the broken walk. That is exactly why ruling 2 requires the pin to be bundle-shaped: an item-shaped fixture would have been green from the day the bug shipped and would have pinned nothing. It is kept in the file as an anti-fixture so nobody "repairs" the bundle fixtures into the shape that cannot fail.
  • reaches every locale of every bundle, not just the first of each — authored on the second locale of the second bundle, so neither level of the walk can stop at index 0.
  • never throws on a malformed bundleTypeError: Cannot read properties of null (reading 'name') on the pre-fix walk.

After the fix, on the same tree: Test Files 80 passed (80) · Tests 2257 passed (2257), with all six new cases named individually by --reporter=verbose.

Fixture triage: the pre-existing #4667 silence pin authored translations: [{ name: 'zh_cn', locale: 'zh-CN', messages: … }] — item-shaped. It stayed green in both directions (it asserts silence), so it pinned nothing about this collection either way; it is now bundle-shaped, which is what stack.translations actually holds.

The side question the card left to the dev: is the runtime door reached at all?

No — and it cannot be, for two independent reasons. Measured, not reasoned from the card:

  1. No stack collection carries those items.StackDefinitionSchema has exactly one translation-shaped key, translations: z.array(TranslationBundleSchema). TranslationItemSchema appears in the kernel's metadata-type registry (packages/spec/src/kernel/metadata-type-schemas.ts:158) and nowhere in the stack.
  2. The rule does not run where those items pass. In authoring-rules.ts it is declared surfaces: CLI_ONLY, so it is not among the rules the runtime publish gate selects (runtime-gate.ts filters on surfaces.includes('runtime-publish')).

There is also a third, softer reason the item shape cannot arrive through this door by accident: stack.translations parses as z.record(LocaleSchema, TranslationDataSchema), so an item-shaped entry would have to mean a locale named flows whose value is TranslationData — a parse error two tiers before the advisory ever runs.

So there is one door, not two, and the ledger's own subject is the other one: its _note describes TranslationItemSchema. The two doors share the group vocabulary and not the container; only the file-authored one is lintable from here, and now it is linted. Both facts are recorded in the source comment so the next reader does not have to re-derive them.

Can this redden a currently-green tree?

Checked before pushing, since a newly-firing advisory on unrelated PRs would change the landing plan.

  • os lint exits 1 only on errors.length > 0 (packages/cli/src/commands/lint.ts:634); warnings never move the exit code. The finding is tier: 'advisory', emitted as severity: 'warning'.
  • The one path in that command where a count of warnings can fail is os lint --eval, which exits 1 on !report.ok against a scored corpus. It is invoked by no workflow in .github/workflows/.
  • Blast radius is bounded by the warn map: the only property that can newly fire is translation.flows. No file in the tree authors a flows group inside a translation bundle (scanned examples/*/src/** translations plus a repo-wide grep). So the new walk produces zero new findings in-tree today, and PM's assumption holds.

Gates

Derived from the actual changed paths with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (change set read by the script from the merge base, not from a hand-built diff), then re-run in full against the final commit dd0e782 — 20 families, all green, plus the package suite, the package typecheck, and repo-wide pnpm lint (eslint . --no-inline-config, run whole rather than narrowed: 85s, clean).

Out of scope, filed rather than fixed: two findings on this same file are recorded on the issue thread — the planned status being reported with the liveness-dead-property vocabulary, and the flat-collection walk throwing on a null item.


Generated by Claude Code

`lintLivenessProperties` registered `{ type: 'translation', key:
'translations' }` in TYPE_COLLECTIONS and then walked those items flat, so
`checkItem` read `bundle['flows']` for the ledger's one `authorWarn` row. An
item of `stack.translations` is a `TranslationBundle` — `z.record(LocaleSchema,
TranslationDataSchema)` — whose top-level keys are locale codes, so every warned
lookup missed and the whole translation ledger was silent for file-authored
bundles, the only way apps author translations today.
`translation` now walks bespoke, the way `object`/`field` do: each bundle, each
locale entry's `TranslationData`, with the subject naming both. The registry row
is replaced by a tombstone comment saying why it must not come back —
registering a collection is only half the contract, the walk has to match the
collection's shape.
The regression test is pinned on the bundle shape with a `TranslationItem`-shaped
anti-fixture beside it: that shape warns on the BROKEN walk, so a fixture written
that way would have been green from the day the bug shipped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015ahemw8RcTgqtxrj15PEZx
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ✅

What this run could not see
  • the SDK route bridge reached 45 of 221 client-bound route-ledger rows — the other 176 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run: node scripts/docs-audit/affected-docs.mjs --bridge-coverage

Coarse fallback — 4 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 a872ce93ab967daeb017429220bcd5723c7682fapackageMentionDocs.

Which tree this was computed on

This run read content/docs from 0fe506c6a576a0e0a02e770d4af29ba95315c284 — the merge of head dd0e782b275ffbdd7ba02cc98e4e705e3d892827 into base a872ce93ab967daeb017429220bcd5723c7682fa, 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 0fe506c6a576a0e0a02e770d4af29ba95315c284 && git checkout 0fe506c6a576a0e0a02e770d4af29ba95315c284
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin a872ce93ab967daeb017429220bcd5723c7682fa dd0e782b275ffbdd7ba02cc98e4e705e3d892827 && git checkout -B drift-repro a872ce93ab967daeb017429220bcd5723c7682fa && git merge --no-ff dd0e782b275ffbdd7ba02cc98e4e705e3d892827
node scripts/docs-audit/affected-docs.mjs --json a872ce93ab967daeb017429220bcd5723c7682fa

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

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 23, 2026
@os-steve
os-steve marked this pull request as ready for review August 23, 2026 15:21
@os-steve
os-steve added this pull request to the merge queueAug 23, 2026
Merged via the queue into main with commit 365e334Aug 23, 2026
32 checks passed
@os-steve
os-steve deleted the claude/issue-11288-translation-liveness-walk branch August 23, 2026 15:36
os-warren pushed a commit that referenced this pull request Aug 24, 2026
…stead of throwing
The docblock's own contract — "Advisory only — returns findings, never
throws" — was not held by three walks: the flat TYPE_COLLECTIONS loop, the
object walk, and the field walk nested under it. Each read `item.name` (or
`item.object`) off every collection element with no record guard, so a null
element threw TypeError instead of being skipped. The translation bundle
walk already guarded its two levels this way (#11383); this adds the same
`isRecord()` guard to the three that did not.
Measured before the fix: all three walks throw on a null element, not just
the flat loop the card's suggested shape named. Three new regression tests
(one per guarded walk) reverse-verified red against the unguarded source.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-steve@claude