Skip to content

Commit dd0e782

Browse files
committed
fix(lint): walk stack.translations as the locale-keyed bundle it is
`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
1 parent b863f01 commit dd0e782

3 files changed

Lines changed: 171 additions & 6 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/lint": patch
3+
---
4+
5+
**Fix:** `lintLivenessProperties` walks `stack.translations` as the locale-keyed bundle it is, so the `translation` liveness ledger finally reaches the author (#11288).
6+
7+
`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.
8+
9+
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'`).
10+
11+
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.
12+
13+
Advisory-only as before: the finding is a warning, and `os lint` exits on errors, never on warnings.
14+
15+
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.

‎packages/lint/src/lint-liveness-properties.test.ts‎

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -379,7 +379,9 @@ describe('lintLivenessProperties', () => {
379379
schedule: { type: 'cron', expression: '0 0 * * *' },
380380
handler: 'syncAll',
381381
}],
382-
translations: [{ name: 'zh_cn', locale: 'zh-CN', messages: { 'common.save': '保存' } }],
382+
// Bundle-shaped since #11288: `stack.translations` is locale-keyed, so the
383+
// item shape this fixture used to carry was never what the collection holds.
384+
translations: [{ 'zh-CN': { messages: { 'common.save': '保存' } } }],
383385
apps: [{ name: 'crm', label: 'CRM', navigation: [] }],
384386
});
385387
expect(findings).toEqual([]);
@@ -651,6 +653,108 @@ describe('lintLivenessProperties', () => {
651653
expect(findings).toEqual([]);
652654
});
653655
});
656+
657+
// ── #11288: `stack.translations` is a locale-keyed BUNDLE, not an item ──────
658+
//
659+
// Every other collection in `TYPE_COLLECTIONS` is a flat array of items whose
660+
// TOP-LEVEL keys are the ledger's props. `translations` is not: an item of
661+
// `stack.translations` is a `TranslationBundle` — `z.record(LocaleSchema,
662+
// TranslationDataSchema)` (`packages/spec/src/stack.zod.ts:275`) — so every
663+
// warned group sits one level down, under a locale code. Walked flat, the
664+
// lookup read `bundle['flows']`, which a bundle has at no depth reachable
665+
// that way, and the ENTIRE translation ledger warned nobody for file-authored
666+
// bundles — the only way apps author translations today. Measured on a real
667+
// app as a zero-delta ablation: an injected `flows:` section produced 0 new
668+
// findings (91 issues before and after).
669+
//
670+
// ⚠️ These fixtures are locale-keyed ON PURPOSE, and the anti-fixture at the
671+
// bottom of the block is why. A `TranslationItem`-shaped fixture warns on the
672+
// BROKEN walk, so it would have been green from the day the bug shipped and
673+
// pinned nothing.
674+
describe('translation bundles are locale-keyed (#11288)', () => {
675+
/** `flows` is the one `authorWarn` row on the shipped `translation` ledger. */
676+
const flowsGroup = { lead_conversion: { screens: { screen_1: { title: '转化详情' } } } };
677+
678+
it('warns on a warned group authored under a locale entry', () => {
679+
const findings = lintLivenessProperties({
680+
translations: [{ 'zh-CN': { flows: flowsGroup } }],
681+
});
682+
expect(paths(findings).some((m) => m.includes('`flows`'))).toBe(true);
683+
expect(findings.map((f) => f.where)).toEqual(["translation bundle #0 · locale 'zh-CN'"]);
684+
expect(findings[0]?.hint).toContain('screen-flow runner');
685+
});
686+
687+
// The walk has two levels and both can stop early. Authored on the SECOND
688+
// locale of the SECOND bundle, so neither an outer nor an inner walk that
689+
// visits only index 0 can pass this.
690+
it('reaches every locale of every bundle, not just the first of each', () => {
691+
const findings = lintLivenessProperties({
692+
translations: [
693+
{ en: { messages: { 'common.save': 'Save' } } },
694+
{
695+
ja: { messages: { 'common.save': '保存' } },
696+
'zh-CN': { flows: flowsGroup },
697+
},
698+
],
699+
});
700+
expect(findings.map((f) => f.where)).toEqual(["translation bundle #1 · locale 'zh-CN'"]);
701+
});
702+
703+
it('stays silent on a bundle that authors only live groups', () => {
704+
const findings = lintLivenessProperties({
705+
translations: [{
706+
'zh-CN': {
707+
objects: { crm_lead: { label: '线索' } },
708+
messages: { 'common.save': '保存' },
709+
},
710+
}],
711+
});
712+
expect(findings).toEqual([]);
713+
});
714+
715+
// Anti-vacuity guard for the silence pin above — the shape the dashboard and
716+
// navigation blocks use. `lintLivenessProperties` returns [] both when a walk
717+
// is broken and when it cannot resolve the shipped ledgers at all, so the
718+
// silence needs a witness that the ledgers ARE loaded in the same call. Pairs
719+
// the clean bundle with `object.externalSharingModel`, still `authorWarn` in
720+
// tree: same process, same ledger load, one warning and not two.
721+
it('the translation silence is a real verdict, not a lint that stopped loading ledgers', () => {
722+
const findings = lintLivenessProperties({
723+
objects: [{ name: 'widget', externalSharingModel: 'read' }],
724+
translations: [{ 'zh-CN': { objects: { crm_lead: { label: '线索' } } } }],
725+
});
726+
const messages = findings.map((f) => f.message);
727+
expect(messages.some((m) => m.includes('externalSharingModel'))).toBe(true);
728+
expect(messages.some((m) => m.includes('flows'))).toBe(false);
729+
});
730+
731+
// THE ANTI-FIXTURE, and the reason every pin above is bundle-shaped. This is
732+
// the shape the broken walk assumed: a `TranslationItem` — `locale` plus the
733+
// same groups at the TOP level — which is the RUNTIME metadata door
734+
// (`packages/spec/src/kernel/metadata-type-schemas.ts:158`), not this
735+
// collection. It warns on the broken walk and must not here:
736+
// `stack.translations` is `z.record(LocaleSchema, TranslationDataSchema)`, so
737+
// this object would have to mean a locale named `flows` whose value is
738+
// `TranslationData` — a parse error two tiers before this advisory ever runs.
739+
// Runtime-authored items reach this lint through no door at all: no stack
740+
// collection carries them, and the rule is `surfaces: CLI_ONLY`, so it does
741+
// not run at the runtime publish gate either.
742+
it('does not treat a runtime `TranslationItem` shape as a bundle', () => {
743+
const findings = lintLivenessProperties({
744+
translations: [{ name: 'zh_cn', locale: 'zh-CN', flows: flowsGroup }],
745+
});
746+
expect(findings).toEqual([]);
747+
});
748+
749+
// "Advisory only — returns findings, never throws" is the function's own
750+
// contract, and a bundle walk adds two levels that can be malformed.
751+
it('never throws on a malformed bundle, and keeps walking past it', () => {
752+
const findings = lintLivenessProperties({
753+
translations: [null, { 'zh-CN': null }, { en: { flows: flowsGroup } }],
754+
});
755+
expect(findings.map((f) => f.where)).toEqual(["translation bundle #2 · locale 'en'"]);
756+
});
757+
});
654758
});
655759

656760
// ── #10262: the array fan-out, tested at the WALKER's own level ──────────────

‎packages/lint/src/lint-liveness-properties.ts‎

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ export interface LedgerEntry {
4747
/** Flattened, warn-only view of a type's ledger: propPath → entry (incl. `a.b` children). */
4848
type WarnMap = Map<string, LedgerEntry>;
4949

50+
function isRecord(v: unknown): v is AnyRec {
51+
return !!v && typeof v === 'object' && !Array.isArray(v);
52+
}
53+
5054
function asArray(v: unknown): AnyRec[] {
5155
if (Array.isArray(v)) return v as AnyRec[];
5256
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(
215219

216220
/**
217221
* The compiled-stack collection each governed metadata type lives in.
218-
* `object`/`field` keep their bespoke walk (fields nest under objects);
219-
* everything else is a flat top-level array on the stack definition.
222+
* `object`/`field` and `translation` keep their bespoke walks (fields nest
223+
* under objects; translation bundles nest under locale codes); everything else
224+
* is a flat top-level array of items whose TOP-LEVEL keys are the ledger's
225+
* props, which is what this loop's `checkItem(type, item, …)` assumes.
220226
*/
221227
const TYPE_COLLECTIONS: Array<{ type: string; key: string }> = [
222228
{ type: 'flow', key: 'flows' },
@@ -244,7 +250,13 @@ const TYPE_COLLECTIONS: Array<{ type: string; key: string }> = [
244250
{ type: 'job', key: 'jobs' },
245251
{ type: 'email_template', key: 'emailTemplates' },
246252
{ type: 'mapping', key: 'mappings' },
247-
{ type: 'translation', key: 'translations' },
253+
// `translation` is NOT here — see the bespoke bundle walk in
254+
// `lintLivenessProperties`. It was listed here until #11288, and being listed
255+
// is precisely what made it silent: an item of `stack.translations` is a
256+
// locale-keyed `TranslationBundle`, not a `TranslationItem`, so this loop's
257+
// flat `checkItem` read `bundle['flows']` and every warned lookup missed. Do
258+
// not re-add the row — registering the collection is only half the contract;
259+
// the walk has to match the collection's SHAPE.
248260
// #4956 — dashboard joins the list the moment its ledger first warns on
249261
// anything, which is exactly the rule the comment above states. Drilling
250262
// `widgets` produced five warned keys (`colorVariant`, `actionUrl`,
@@ -267,8 +279,9 @@ const TYPE_COLLECTIONS: Array<{ type: string; key: string }> = [
267279
* Lint the compiled stack for authored properties the liveness ledger flags as
268280
* misleading. Advisory only — returns findings, never throws. Covers every
269281
* governed metadata type: objects (incl. `enable.*`) and their fields walk
270-
* bespoke nesting; the remaining types are flat stack collections. Container
271-
* properties fan out over arrays (each flow node, each dataset measure). The
282+
* bespoke nesting, and translation bundles walk their locale entries (#11288);
283+
* the remaining types are flat stack collections. Container properties fan out
284+
* over arrays (each flow node, each dataset measure). The
272285
* mechanism stays ledger-driven — coverage grows by marking more entries
273286
* `authorWarn` rather than touching this code.
274287
*/
@@ -291,6 +304,39 @@ export function lintLivenessProperties(stack: AnyRec): LivenessLintFinding[] {
291304
}
292305
}
293306

307+
// `translation` walks one level deeper than every other collection. An item of
308+
// `stack.translations` is a `TranslationBundle` — `z.record(LocaleSchema,
309+
// TranslationDataSchema)` (`packages/spec/src/stack.zod.ts:275`) — so the
310+
// ledger's groups (`flows`, `objects`, `messages`, …) live under each locale
311+
// code, not on the item. Walked flat (as it was until #11288) every warned
312+
// lookup read `bundle['flows']`, which a bundle has at no depth reachable that
313+
// way, and the whole translation ledger warned nobody for file-authored
314+
// bundles — the only way apps author translations today. Measured on a real
315+
// app as a zero-delta ablation: an injected `flows:` section produced no new
316+
// findings at all.
317+
//
318+
// The ledger's own subject is `TranslationItemSchema`, the RUNTIME metadata
319+
// door, which does carry the groups at its top level. That door is reached by
320+
// no walk here and cannot be: no stack collection carries those items, and
321+
// this rule is `surfaces: CLI_ONLY` (`authoring-rules.ts`), so it never runs
322+
// at the runtime publish gate either. The two doors share the group
323+
// vocabulary, not the container; only the file-authored one is lintable.
324+
const translationWarn = loadWarnMap(dir, 'translation');
325+
if (translationWarn.size > 0) {
326+
const bundles = asArray(stack.translations);
327+
for (let i = 0; i < bundles.length; i++) {
328+
const bundle = bundles[i];
329+
if (!isRecord(bundle)) continue;
330+
for (const [locale, data] of Object.entries(bundle)) {
331+
// Only a locale entry holds `TranslationData`. Anything else is either a
332+
// malformed bundle or the `name` key `asArray` injects for a map-shaped
333+
// collection — skipping both keeps the "never throws" contract.
334+
if (!isRecord(data)) continue;
335+
checkItem('translation', data, `translation bundle #${i} · locale '${locale}'`, translationWarn, findings);
336+
}
337+
}
338+
}
339+
294340
for (const { type, key } of TYPE_COLLECTIONS) {
295341
const warnMap = loadWarnMap(dir, type);
296342
if (warnMap.size === 0) continue;

0 commit comments

Comments
 (0)