From 83a51548019b73f6a93a92652c195871ba7d7e3e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 00:45:32 +0000 Subject: [PATCH 1/2] fix(lint): the liveness warn map and status census walk every depth of ledger children Claude-Session: https://claude.ai/code/session_01WGufzhxSxc27ChPLkDSKEa Co-authored-by: Claude --- .../lint/src/lint-liveness-properties.test.ts | 124 ++++++++++++++++-- packages/lint/src/lint-liveness-properties.ts | 69 +++++++--- 2 files changed, 160 insertions(+), 33 deletions(-) diff --git a/packages/lint/src/lint-liveness-properties.test.ts b/packages/lint/src/lint-liveness-properties.test.ts index ec3aeb9ecbe..3eac4ef5cd7 100644 --- a/packages/lint/src/lint-liveness-properties.test.ts +++ b/packages/lint/src/lint-liveness-properties.test.ts @@ -21,6 +21,8 @@ import { // #14057 coverage seam — the statuses the shipped ledgers actually carry, so // the coverage pin below is derived from the ledgers rather than hand-listed. shippedLedgerStatuses, + // #26146 — the same census against a ledger directory the test controls. + ledgerStatusesIn, // #19268/#19276 walk seam — package-internal, same posture as the two above // (module exports; `src/index.ts` re-exports neither, and the package's // `exports` map publishes only `.` and `./runtime`). The rule against a @@ -1928,29 +1930,29 @@ describe('the dead and live-elsewhere verdicts warn on their own (#16094)', () = describe('the hint a warned row shows an author (#16094, #21096)', () => { type ShippedRow = { type: string; path: string; entry: Record }; - /** Every shipped row at the depth the warn map reads (props + one level of `children`). */ + /** Every shipped row at the depth the warn map reads: every level of `children` (#26146). */ function shippedRows(): ShippedRow[] { const rows: ShippedRow[] = []; const dir = shippedLedgerDir(); - for (const file of readdirSync(dir).filter((f) => f.endsWith('.json')).sort()) { - const type = file.replace(/\.json$/, ''); - const ledger = JSON.parse(readFileSync(join(dir, file), 'utf8')); - const isRow = (v: unknown): v is Record => !!v && typeof v === 'object' && !Array.isArray(v); - for (const [key, entry] of Object.entries((ledger.props ?? {}) as Record)) { + const isRow = (v: unknown): v is Record => !!v && typeof v === 'object' && !Array.isArray(v); + const walk = (type: string, entries: unknown, prefix: string) => { + for (const [key, entry] of Object.entries(isRow(entries) ? entries : {})) { if (!isRow(entry)) continue; - for (const [ck, centry] of Object.entries((entry.children ?? {}) as Record)) { - if (isRow(centry)) rows.push({ type, path: `${key}.${ck}`, entry: centry }); - } - rows.push({ type, path: key, entry }); + const path = prefix ? `${prefix}.${key}` : key; + walk(type, entry.children, path); + rows.push({ type, path, entry }); } + }; + for (const file of readdirSync(dir).filter((f) => f.endsWith('.json')).sort()) { + walk(file.replace(/\.json$/, ''), JSON.parse(readFileSync(join(dir, file), 'utf8')).props, ''); } return rows; } - /** An item that authors exactly `path` (one level of nesting at most). */ + /** An item that authors exactly `path`, at any depth. */ const itemAuthoring = (path: string): Record => { - const [head, child] = path.split('.'); - return child === undefined ? { name: 'probe', [head]: 'x' } : { name: 'probe', [head]: { [child]: 'x' } }; + const nested = path.split('.').reduceRight((value, seg) => ({ [seg]: value }), 'x'); + return { name: 'probe', ...(nested as Record) }; }; const hintFor = (row: ShippedRow) => { @@ -2150,3 +2152,99 @@ describe('[#22161] one-line fix — liveness-ledger-unreadable', () => { } }); }); + +// ── #26146: the warn map and the status census read every depth of `children` ── +// +// Both walks used to stop one `children` level down, while the ledgers had grown +// rows two and three levels down (the dashboard's `widgets.chartConfig.*`, the +// app's `navigation.dataSource.read.*`). None of them reached the warn map or +// the vocabulary census. The shipped deep rows carry no `authorWarn`, and every +// status they carry also appears at a shallower depth, so the opt-in and the +// census are each held through a planted copy of the ledger directory; the +// verdict-triggered half is held against the real dashboard ledger. +describe('#26146 — ledger rows below the child level are graded and counted', () => { + const board = (widget: Record) => ({ + dashboards: [{ + name: 'sales_overview', + label: 'Sales', + widgets: [ + { id: 'first', type: 'metric', dataset: 'orders', values: ['total'] }, + { id: 'second', type: 'bar', dataset: 'orders', values: ['total'], ...widget }, + ], + }], + }); + + it('REAL LEDGER: the dead `widgets.chartConfig.*` rows, two levels down, are graded dead on an unparsed stack', () => { + const warned = authorWarnedProperties('dashboard'); + const findings = lintLivenessProperties(board({ + chartConfig: { type: 'bar', xAxis: 'region', yAxis: 'total', series: ['total'], title: 'Pipeline' }, + })); + for (const key of ['type', 'xAxis', 'yAxis', 'series']) { + const path = `widgets.chartConfig.${key}`; + expect(warned.has(path), path).toBe(true); + // The second widget is the one authoring it, so the array fan-out is in the path too. + expect(ruleOf(findings, path), path).toBe(LIVENESS_DEAD_PROPERTY); + } + // Control: a live sibling on the same carrier stays silent in the same call. + expect(warned.has('widgets.chartConfig.title')).toBe(false); + expect(ruleOf(findings, 'widgets.chartConfig.title')).toBeUndefined(); + }); + + /** The shipped `dashboard.json`, with a synthetic subtree planted under `widgets`. */ + const plantedDashboardLedgerDir = () => ledgerDirWith((dir) => { + const ledger = JSON.parse(readFileSync(join(dir, 'dashboard.json'), 'utf8')); + ledger.props.widgets.children.synthNest = { + status: 'live', + children: { + // Depth 2: the card's case, a `dead` row with `authorWarn`. + deadOptIn: { status: 'dead', authorWarn: true }, + deeper: { + status: 'live', + children: { + // Depth 3: `planned` warns ONLY through the opt-in, so this pair + // shows the opt-in itself reaches this deep, not just the verdict. + plannedOptIn: { status: 'planned', authorWarn: true }, + plannedQuiet: { status: 'planned' }, + }, + }, + }, + }; + writeLedger(dir, 'dashboard', ledger); + }); + + it('PLANTED: a dead row with authorWarn two levels down warns, and an opted-in planned row three levels down warns too', () => { + const findings = lintLivenessPropertiesFromLedgerDir(plantedDashboardLedgerDir(), board({ + synthNest: { deadOptIn: 'x', deeper: { plannedOptIn: 'x', plannedQuiet: 'x' } }, + })); + expect(findings.filter((f) => f.rule === LIVENESS_LEDGER_UNREADABLE)).toEqual([]); + expect(ruleOf(findings, 'widgets.synthNest.deadOptIn')).toBe(LIVENESS_DEAD_PROPERTY); + expect(ruleOf(findings, 'widgets.synthNest.deeper.plannedOptIn')).toBe('liveness-planned-property'); + expect(ruleOf(findings, 'widgets.synthNest.deeper.plannedQuiet')).toBeUndefined(); + // The live containers on the way down warn on nothing. + expect(ruleOf(findings, 'widgets.synthNest')).toBeUndefined(); + expect(ruleOf(findings, 'widgets.synthNest.deeper')).toBeUndefined(); + }); + + it('PLANTED: a status word used only two or three levels down reaches the vocabulary census', () => { + const dir = ledgerDirWith((d) => { + const ledger = JSON.parse(readFileSync(join(d, 'dashboard.json'), 'utf8')); + ledger.props.widgets.children.synthNest = { + status: 'live', + children: { + atDepthTwo: { status: 'synth-depth-two' }, + deeper: { status: 'live', children: { atDepthThree: { status: 'synth-depth-three' } } }, + }, + }; + writeLedger(d, 'dashboard', ledger); + }); + const planted = ledgerStatusesIn(dir); + expect(planted.has('synth-depth-two')).toBe(true); + expect(planted.has('synth-depth-three')).toBe(true); + // Control: the shipped census is this same walk over the shipped directory, + // and neither planted word is in it. + const shipped = shippedLedgerStatuses(); + expect([...ledgerStatusesIn(shippedLedgerDir())].sort()).toEqual([...shipped].sort()); + expect(shipped.has('synth-depth-two')).toBe(false); + expect(shipped.has('synth-depth-three')).toBe(false); + }); +}); diff --git a/packages/lint/src/lint-liveness-properties.ts b/packages/lint/src/lint-liveness-properties.ts index 08a2bc23d7f..60014c1e09e 100644 --- a/packages/lint/src/lint-liveness-properties.ts +++ b/packages/lint/src/lint-liveness-properties.ts @@ -64,13 +64,35 @@ export interface LedgerEntry { children?: Record; } -/** Flattened, warn-only view of a type's ledger: propPath → entry (incl. `a.b` children). */ +/** Flattened, warn-only view of a type's ledger: propPath → entry (`a.b`, `a.b.c`, … for nested children). */ type WarnMap = Map; function isRecord(v: unknown): v is AnyRec { return !!v && typeof v === 'object' && !Array.isArray(v); } +/** + * Every row of a ledger's `props`, at EVERY depth of `children`, as + * `[dotted path, entry]` — each row's descendants before the row itself, the + * order the warn map has always been filled in. + * + * `#26146`. Both readers of a ledger's rows — {@link loadWarnMap} and the + * vocabulary census {@link ledgerStatusesIn} — used to hand-roll the same + * walk, and both stopped one `children` level down: a ledger documented as + * that deep had since grown rows two and three levels down (the dashboard's + * `widgets.chartConfig.*`, the app's `navigation.dataSource.read.*`), and none + * of them reached the warn map or the census. One walk, shared, is what keeps + * the two from disagreeing again about how deep a ledger goes. + */ +function* ledgerRows(rows: unknown, prefix = ''): Generator<[string, LedgerEntry]> { + if (!isRecord(rows)) return; + for (const [key, entry] of Object.entries(rows as Record)) { + const path = prefix ? `${prefix}.${key}` : key; + if (isRecord(entry)) yield* ledgerRows(entry.children, path); + yield [path, entry]; + } +} + /** * Locate `@objectstack/spec`'s shipped `liveness/` dir (workspace src or * published files). @@ -126,7 +148,7 @@ interface WarnMapLoad { fault?: LedgerFault; } -/** Build the warn-only lookup for one type, flattening one level of `children`. */ +/** Build the warn-only lookup for one type, flattening every level of `children` ({@link ledgerRows}). */ function loadWarnMap(dir: string, type: string): WarnMapLoad { const map: WarnMap = new Map(); const file = join(dir, `${type}.json`); @@ -144,14 +166,8 @@ function loadWarnMap(dir: string, type: string): WarnMapLoad { // carries a `props` record, so this branch describes a corrupt file and // never a legitimately empty one (`{"props": {}}` is a reading, not a fault). if (!isRecord(ledger) || !isRecord(ledger.props)) return { map, fault: 'unreadable' }; - const props = ledger.props as Record; - for (const [key, entry] of Object.entries(props)) { - if (entry?.children) { - for (const [ck, centry] of Object.entries(entry.children)) { - if (shouldWarn(centry)) map.set(`${key}.${ck}`, centry); - } - } - if (shouldWarn(entry)) map.set(key, entry); + for (const [path, entry] of ledgerRows(ledger.props)) { + if (shouldWarn(entry)) map.set(path, entry); } return { map }; } @@ -301,9 +317,13 @@ function describe(entry: LedgerEntry): { kind: string; rule: string; defaultHint * publishes just `.` and `./runtime`). ──────────────────────────────────── * * Every distinct `status` string the SHIPPED ledgers actually carry, walked the - * way `loadWarnMap` walks them (top-level props plus one level of `children` — - * measured as the ledgers' full depth: 423 top-level and 285 child rows carry a - * status, zero grandchildren do). + * way `loadWarnMap` walks them: {@link ledgerRows}, every depth of `children` + * (`#26146`). Measured on `origin/main` `6390fe52`, the rows carrying a status + * are 528 top-level, 419 child, 41 grandchild and 2 great-grandchild (the app's + * `navigation.dataSource.read.url` / `.method`). Until `#26146` both walks + * stopped at the child level and a docblock here called that the ledgers' full + * depth; it had stopped being one, which is why the depth is no longer a + * number this code assumes. * * WHY THIS EXISTS AND WHY IT DERIVES FROM ROWS. #14057 is #11384 repeating * itself through a different door: `describe()` fell behind the vocabulary when @@ -328,8 +348,19 @@ function describe(entry: LedgerEntry): { kind: string; rule: string; defaultHint * carries an anti-vacuity guard. */ export function shippedLedgerStatuses(): ReadonlySet { + return ledgerStatusesIn(resolveLivenessDir()); +} + +/** + * The census of {@link shippedLedgerStatuses}, against a ledger directory the + * CALLER supplies — the same seam posture as + * {@link lintLivenessPropertiesFromLedgerDir} (module export only). The + * shipped ledgers carry no status at depth 2 or deeper that a shallower row + * does not also carry, so only a planted copy can show that the census reaches + * that depth at all (`#26146`). + */ +export function ledgerStatusesIn(dir: string | null): ReadonlySet { const statuses = new Set(); - const dir = resolveLivenessDir(); if (!dir) return statuses; let files: string[]; try { @@ -345,11 +376,8 @@ export function shippedLedgerStatuses(): ReadonlySet { } catch { continue; } - for (const entry of Object.values(ledger?.props ?? {})) { + for (const [, entry] of ledgerRows(ledger?.props)) { if (typeof entry?.status === 'string') statuses.add(entry.status); - for (const child of Object.values(entry?.children ?? {})) { - if (typeof child?.status === 'string') statuses.add(child.status); - } } } return statuses; @@ -371,8 +399,9 @@ export function shippedLedgerStatuses(): ReadonlySet { * returns the very map `checkItem` iterates rather than a parallel reading of * the same JSON. * - * Keys are the ledger's own property paths, `children` flattened one level as - * `parent.child` — the shape `checkItem` resolves. Unreadable or absent ledger + * Keys are the ledger's own property paths, `children` flattened at every depth + * as `parent.child`, `parent.child.grandchild`, … — the shape `checkItem` + * resolves. Unreadable or absent ledger * ⇒ the empty set, which is also the map `lintLivenessProperties` walks with: * the two sides still go quiet TOGETHER rather than one of them going quiet * alone, which is the invariant this export exists for. From bec9f0584657401fbc1542f4bfab1b0ea34b3475 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 00:48:54 +0000 Subject: [PATCH 2/2] chore(changeset): lint liveness rows at every depth Claude-Session: https://claude.ai/code/session_01WGufzhxSxc27ChPLkDSKEa Co-authored-by: Claude --- .changeset/26146-liveness-every-depth.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .changeset/26146-liveness-every-depth.md diff --git a/.changeset/26146-liveness-every-depth.md b/.changeset/26146-liveness-every-depth.md new file mode 100644 index 00000000000..a328055eda6 --- /dev/null +++ b/.changeset/26146-liveness-every-depth.md @@ -0,0 +1,13 @@ +--- +'@objectstack/lint': patch +--- + +fix(lint): the liveness author warnings and their status census read ledger rows at every depth of `children` + +Clause-②: no +Metadata-protocol: none + +The liveness rule built its warn map, and its census of the status words the ledgers use, from the top-level rows and one level of `children`. The ledgers have rows two and three levels down, and those were never read. On the tree this was measured on, 41 rows two levels down and 2 rows three levels down carry a status. + +- **Warnings.** A row at any depth now warns under the same rules as a shallower one. Its verdict warns on its own (`dead`, `live-elsewhere`, `experimental`), or the row opts in with `authorWarn`. The only rows this reaches today are the dashboard's `widgets[].chartConfig.type`, `.xAxis`, `.yAxis`, `.series` and `.aria`, all `dead`. Each of them is already a retired key that the schema refuses, so `os lint` handed an unparsed stack now also reports `liveness-dead-property` for them. No row at depth 2 or deeper carries `authorWarn`, so no other warning appears. +- **Status census.** A status word used only at depth 2 or deeper now reaches the vocabulary check that makes sure every status has an author-facing branch.