From dd0e782b275ffbdd7ba02cc98e4e705e3d892827 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 14:52:26 +0000 Subject: [PATCH] fix(lint): walk stack.translations as the locale-keyed bundle it is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 Claude-Session: https://claude.ai/code/session_015ahemw8RcTgqtxrj15PEZx --- .../lint-liveness-translation-bundle-walk.md | 15 +++ .../lint/src/lint-liveness-properties.test.ts | 106 +++++++++++++++++- packages/lint/src/lint-liveness-properties.ts | 56 ++++++++- 3 files changed, 171 insertions(+), 6 deletions(-) create mode 100644 .changeset/lint-liveness-translation-bundle-walk.md diff --git a/.changeset/lint-liveness-translation-bundle-walk.md b/.changeset/lint-liveness-translation-bundle-walk.md new file mode 100644 index 0000000000..f0414afb71 --- /dev/null +++ b/.changeset/lint-liveness-translation-bundle-walk.md @@ -0,0 +1,15 @@ +--- +"@objectstack/lint": patch +--- + +**Fix:** `lintLivenessProperties` walks `stack.translations` as the locale-keyed bundle it is, so the `translation` liveness ledger finally reaches the author (#11288). + +`stack.translations` is `z.array(TranslationBundleSchema)` — each item is a `TranslationBundle`, i.e. `z.record(LocaleSchema, TranslationDataSchema)`, whose top-level keys are locale codes. The lint registered `{ type: 'translation', key: 'translations' }` in `TYPE_COLLECTIONS` and then walked those items flat, the way every other collection there is walked: `checkItem` read `bundle['flows']` for the ledger's one `authorWarn` row. A bundle has no `flows` key at any depth reachable that way — the groups live one level down, under each locale — so every warned lookup missed and the whole `translation` ledger was silent for file-authored bundles, the only way apps author translations today. + +That is the failure mode the comment above `TYPE_COLLECTIONS` names ("a newly governed type needs its collection registered or its ledger warns nobody"), reached from the other side: the collection *was* registered, and the shape underneath it was the mismatch. Registering a collection is only half the contract — the walk has to match the collection's shape — so the row is now a tombstone comment saying exactly that, and `translation` joins `object`/`field` as a bespoke walk: for each bundle, each locale entry's `TranslationData` is checked, with the finding subject naming the bundle index and the locale (`translation bundle #0 · locale 'zh-CN'`). + +Measured on a real app before the fix, as a guarded ablation: injecting a `flows:` section into a locale bundle and re-running `objectstack lint --json` produced **zero** delta — 91 issues before and after, 0 liveness findings naming `flows`. The author who reached for a `planned` translation group got silence, which is strictly worse than the ledger being absent, because the ledger's stated contract is that `authorWarn` is what tells them. + +Advisory-only as before: the finding is a warning, and `os lint` exits on errors, never on warnings. + +The regression test is pinned on the **bundle** shape, and a `TranslationItem`-shaped anti-fixture is pinned alongside it. That shape — `locale` plus the groups at the top level — is the runtime metadata door, and it *warned on the broken walk*, so a fixture written that way would have been green from the day the bug shipped and pinned nothing. Runtime-authored `translation` items are reached by this lint through no door at all: no stack collection carries them, and the rule is `surfaces: CLI_ONLY`, so it does not run at the runtime publish gate either. The two doors share the group vocabulary, not the container; only the file-authored one is lintable, and now it is linted. diff --git a/packages/lint/src/lint-liveness-properties.test.ts b/packages/lint/src/lint-liveness-properties.test.ts index b5f5e540aa..000d9f5056 100644 --- a/packages/lint/src/lint-liveness-properties.test.ts +++ b/packages/lint/src/lint-liveness-properties.test.ts @@ -379,7 +379,9 @@ describe('lintLivenessProperties', () => { schedule: { type: 'cron', expression: '0 0 * * *' }, handler: 'syncAll', }], - translations: [{ name: 'zh_cn', locale: 'zh-CN', messages: { 'common.save': '保存' } }], + // Bundle-shaped since #11288: `stack.translations` is locale-keyed, so the + // item shape this fixture used to carry was never what the collection holds. + translations: [{ 'zh-CN': { messages: { 'common.save': '保存' } } }], apps: [{ name: 'crm', label: 'CRM', navigation: [] }], }); expect(findings).toEqual([]); @@ -651,6 +653,108 @@ describe('lintLivenessProperties', () => { expect(findings).toEqual([]); }); }); + + // ── #11288: `stack.translations` is a locale-keyed BUNDLE, not an item ────── + // + // Every other collection in `TYPE_COLLECTIONS` is a flat array of items whose + // TOP-LEVEL keys are the ledger's props. `translations` is not: an item of + // `stack.translations` is a `TranslationBundle` — `z.record(LocaleSchema, + // TranslationDataSchema)` (`packages/spec/src/stack.zod.ts:275`) — so every + // warned group sits one level down, under a locale code. Walked flat, the + // lookup read `bundle['flows']`, which a bundle has at no depth reachable + // that way, and the ENTIRE translation ledger warned nobody for file-authored + // bundles — the only way apps author translations today. Measured on a real + // app as a zero-delta ablation: an injected `flows:` section produced 0 new + // findings (91 issues before and after). + // + // ⚠️ These fixtures are locale-keyed ON PURPOSE, and the anti-fixture at the + // bottom of the block is why. A `TranslationItem`-shaped fixture warns on the + // BROKEN walk, so it would have been green from the day the bug shipped and + // pinned nothing. + describe('translation bundles are locale-keyed (#11288)', () => { + /** `flows` is the one `authorWarn` row on the shipped `translation` ledger. */ + const flowsGroup = { lead_conversion: { screens: { screen_1: { title: '转化详情' } } } }; + + it('warns on a warned group authored under a locale entry', () => { + const findings = lintLivenessProperties({ + translations: [{ 'zh-CN': { flows: flowsGroup } }], + }); + expect(paths(findings).some((m) => m.includes('`flows`'))).toBe(true); + expect(findings.map((f) => f.where)).toEqual(["translation bundle #0 · locale 'zh-CN'"]); + expect(findings[0]?.hint).toContain('screen-flow runner'); + }); + + // The walk has two levels and both can stop early. Authored on the SECOND + // locale of the SECOND bundle, so neither an outer nor an inner walk that + // visits only index 0 can pass this. + it('reaches every locale of every bundle, not just the first of each', () => { + const findings = lintLivenessProperties({ + translations: [ + { en: { messages: { 'common.save': 'Save' } } }, + { + ja: { messages: { 'common.save': '保存' } }, + 'zh-CN': { flows: flowsGroup }, + }, + ], + }); + expect(findings.map((f) => f.where)).toEqual(["translation bundle #1 · locale 'zh-CN'"]); + }); + + it('stays silent on a bundle that authors only live groups', () => { + const findings = lintLivenessProperties({ + translations: [{ + 'zh-CN': { + objects: { crm_lead: { label: '线索' } }, + messages: { 'common.save': '保存' }, + }, + }], + }); + expect(findings).toEqual([]); + }); + + // Anti-vacuity guard for the silence pin above — the shape the dashboard and + // navigation blocks use. `lintLivenessProperties` returns [] both when a walk + // is broken and when it cannot resolve the shipped ledgers at all, so the + // silence needs a witness that the ledgers ARE loaded in the same call. Pairs + // the clean bundle with `object.externalSharingModel`, still `authorWarn` in + // tree: same process, same ledger load, one warning and not two. + it('the translation silence is a real verdict, not a lint that stopped loading ledgers', () => { + const findings = lintLivenessProperties({ + objects: [{ name: 'widget', externalSharingModel: 'read' }], + translations: [{ 'zh-CN': { objects: { crm_lead: { label: '线索' } } } }], + }); + const messages = findings.map((f) => f.message); + expect(messages.some((m) => m.includes('externalSharingModel'))).toBe(true); + expect(messages.some((m) => m.includes('flows'))).toBe(false); + }); + + // THE ANTI-FIXTURE, and the reason every pin above is bundle-shaped. This is + // the shape the broken walk assumed: a `TranslationItem` — `locale` plus the + // same groups at the TOP level — which is the RUNTIME metadata door + // (`packages/spec/src/kernel/metadata-type-schemas.ts:158`), not this + // collection. It warns on the broken walk and must not here: + // `stack.translations` is `z.record(LocaleSchema, TranslationDataSchema)`, so + // this object would have to mean a locale named `flows` whose value is + // `TranslationData` — a parse error two tiers before this advisory ever runs. + // Runtime-authored items reach this lint through no door at all: no stack + // collection carries them, and the rule is `surfaces: CLI_ONLY`, so it does + // not run at the runtime publish gate either. + it('does not treat a runtime `TranslationItem` shape as a bundle', () => { + const findings = lintLivenessProperties({ + translations: [{ name: 'zh_cn', locale: 'zh-CN', flows: flowsGroup }], + }); + expect(findings).toEqual([]); + }); + + // "Advisory only — returns findings, never throws" is the function's own + // contract, and a bundle walk adds two levels that can be malformed. + it('never throws on a malformed bundle, and keeps walking past it', () => { + const findings = lintLivenessProperties({ + translations: [null, { 'zh-CN': null }, { en: { flows: flowsGroup } }], + }); + expect(findings.map((f) => f.where)).toEqual(["translation bundle #2 · locale 'en'"]); + }); + }); }); // ── #10262: the array fan-out, tested at the WALKER's own level ────────────── diff --git a/packages/lint/src/lint-liveness-properties.ts b/packages/lint/src/lint-liveness-properties.ts index 2b1c560e17..93017b20f9 100644 --- a/packages/lint/src/lint-liveness-properties.ts +++ b/packages/lint/src/lint-liveness-properties.ts @@ -47,6 +47,10 @@ export interface LedgerEntry { /** Flattened, warn-only view of a type's ledger: propPath → entry (incl. `a.b` children). */ type WarnMap = Map; +function isRecord(v: unknown): v is AnyRec { + return !!v && typeof v === 'object' && !Array.isArray(v); +} + function asArray(v: unknown): AnyRec[] { if (Array.isArray(v)) return v as AnyRec[]; if (v && typeof v === 'object') return Object.entries(v as AnyRec).map(([name, def]) => ({ name, ...(def as AnyRec) })); @@ -215,8 +219,10 @@ export function checkItemAgainstWarnMap( /** * The compiled-stack collection each governed metadata type lives in. - * `object`/`field` keep their bespoke walk (fields nest under objects); - * everything else is a flat top-level array on the stack definition. + * `object`/`field` and `translation` keep their bespoke walks (fields nest + * under objects; translation bundles nest under locale codes); everything else + * is a flat top-level array of items whose TOP-LEVEL keys are the ledger's + * props, which is what this loop's `checkItem(type, item, …)` assumes. */ const TYPE_COLLECTIONS: Array<{ type: string; key: string }> = [ { type: 'flow', key: 'flows' }, @@ -244,7 +250,13 @@ const TYPE_COLLECTIONS: Array<{ type: string; key: string }> = [ { type: 'job', key: 'jobs' }, { type: 'email_template', key: 'emailTemplates' }, { type: 'mapping', key: 'mappings' }, - { type: 'translation', key: 'translations' }, + // `translation` is NOT here — see the bespoke bundle walk in + // `lintLivenessProperties`. It was listed here until #11288, and being listed + // is precisely what made it silent: an item of `stack.translations` is a + // locale-keyed `TranslationBundle`, not a `TranslationItem`, so this loop's + // flat `checkItem` read `bundle['flows']` and every warned lookup missed. Do + // not re-add the row — registering the collection is only half the contract; + // the walk has to match the collection's SHAPE. // #4956 — dashboard joins the list the moment its ledger first warns on // anything, which is exactly the rule the comment above states. Drilling // `widgets` produced five warned keys (`colorVariant`, `actionUrl`, @@ -267,8 +279,9 @@ const TYPE_COLLECTIONS: Array<{ type: string; key: string }> = [ * Lint the compiled stack for authored properties the liveness ledger flags as * misleading. Advisory only — returns findings, never throws. Covers every * governed metadata type: objects (incl. `enable.*`) and their fields walk - * bespoke nesting; the remaining types are flat stack collections. Container - * properties fan out over arrays (each flow node, each dataset measure). The + * bespoke nesting, and translation bundles walk their locale entries (#11288); + * the remaining types are flat stack collections. Container properties fan out + * over arrays (each flow node, each dataset measure). The * mechanism stays ledger-driven — coverage grows by marking more entries * `authorWarn` rather than touching this code. */ @@ -291,6 +304,39 @@ export function lintLivenessProperties(stack: AnyRec): LivenessLintFinding[] { } } + // `translation` walks one level deeper than every other collection. An item of + // `stack.translations` is a `TranslationBundle` — `z.record(LocaleSchema, + // TranslationDataSchema)` (`packages/spec/src/stack.zod.ts:275`) — so the + // ledger's groups (`flows`, `objects`, `messages`, …) live under each locale + // code, not on the item. Walked flat (as it was until #11288) every warned + // lookup read `bundle['flows']`, which a bundle has at no depth reachable that + // way, and the whole translation ledger warned nobody for file-authored + // bundles — the only way apps author translations today. Measured on a real + // app as a zero-delta ablation: an injected `flows:` section produced no new + // findings at all. + // + // The ledger's own subject is `TranslationItemSchema`, the RUNTIME metadata + // door, which does carry the groups at its top level. That door is reached by + // no walk here and cannot be: no stack collection carries those items, and + // this rule is `surfaces: CLI_ONLY` (`authoring-rules.ts`), so it never runs + // at the runtime publish gate either. The two doors share the group + // vocabulary, not the container; only the file-authored one is lintable. + const translationWarn = loadWarnMap(dir, 'translation'); + if (translationWarn.size > 0) { + const bundles = asArray(stack.translations); + for (let i = 0; i < bundles.length; i++) { + const bundle = bundles[i]; + if (!isRecord(bundle)) continue; + for (const [locale, data] of Object.entries(bundle)) { + // Only a locale entry holds `TranslationData`. Anything else is either a + // malformed bundle or the `name` key `asArray` injects for a map-shaped + // collection — skipping both keeps the "never throws" contract. + if (!isRecord(data)) continue; + checkItem('translation', data, `translation bundle #${i} · locale '${locale}'`, translationWarn, findings); + } + } + } + for (const { type, key } of TYPE_COLLECTIONS) { const warnMap = loadWarnMap(dir, type); if (warnMap.size === 0) continue;