Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/lint-liveness-translation-bundle-walk.md
Original file line numberDiff line numberDiff line change
@@ -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.
106 changes: 105 additions & 1 deletion packages/lint/src/lint-liveness-properties.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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([]);
Expand DownExpand Up@@ -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 ──────────────
Expand Down
56 changes: 51 additions & 5 deletions packages/lint/src/lint-liveness-properties.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<string, LedgerEntry>;

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) }));
Expand DownExpand Up@@ -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' },
Expand DownExpand Up@@ -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`,
Expand All@@ -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.
*/
Expand All@@ -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;
Expand Down
Loading