Skip to content

Commit 13136f7

Browse files
fix(lint): liveness author warnings and the status census read ledger rows at every depth (#26379)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBXd6krbsYu1F6K7KQHMnPe9)_ Fixes #26146 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:liveness 作者告警(`os lint`)与状态词普查,原来只读台账顶层加一层 `children`,现在读到任意深度。 - 为什么改:台账已有 41 行在第二层、2 行在第三层带 status,从未被评级;一个深层 `dead` 行或新状态词会悄悄漏过。 - 风险与回滚:今天唯一新出现的告警是仪表盘 `widgets[].chartConfig` 的 5 个已退役键(schema 本就拒收);回滚即 revert 本 PR。 - 先看:`packages/lint/src/lint-liveness-properties.ts` 的 `ledgerRows`(两处遍历共用它),再看测试文件末尾的 #26146 块。 - 两分钟验证:`pnpm --filter @objectstack/lint exec vitest run src/lint-liveness-properties.test.ts`,预期 106 个用例全绿。 - 需要你做的:无,席位落地。 ## What changes - `packages/lint/src/lint-liveness-properties.ts`: one walk, `ledgerRows`, yields every ledger row at every depth of `children` as a dotted path, descendants before the row (the order the warn map was always filled in, so finding order at depth 0 and 1 is unchanged). `loadWarnMap` and the status census both read it, so they cannot disagree about depth again. - The census gains a directory seam, `ledgerStatusesIn(dir)` (module export only, the same posture as `lintLivenessPropertiesFromLedgerDir`); `shippedLedgerStatuses()` is that census over the shipped directory. - Docblocks re-measured. The `:303-306` count read "423 top-level and 285 child rows carry a status, zero grandchildren do". On `origin/main` `6390fe52` it is **528 top-level, 419 child, 41 grandchild, 2 great-grandchild**. The `WarnMap`, `loadWarnMap` and `authorWarnedProperties` docblocks now say every depth. - Changeset: `@objectstack/lint` patch. ## Tests - New block at the end of `lint-liveness-properties.test.ts`: - REAL LEDGER: `widgets.chartConfig.type` / `.xAxis` / `.yAxis` / `.series` (dead, two levels down) are in `authorWarnedProperties('dashboard')` and graded `liveness-dead-property` on an unparsed stack, through the array fan-out (the second widget authors them). The live `chartConfig.title` in the same call stays silent. - PLANTED: a `dead` + `authorWarn` row two levels down warns; a `planned` + `authorWarn` row three levels down warns, while its `planned` sibling without the opt-in stays silent (so the opt-in itself reaches depth 3, not only the verdict). - PLANTED: status words used only at depth 2 and depth 3 reach `ledgerStatusesIn`; the shipped census equals `ledgerStatusesIn(shippedLedgerDir())` and carries neither word. - The `#16094` hint pins' `shippedRows()` / `itemAuthoring` now walk every depth, so the newly warned deep rows are held to the same "never the note, no tracker id" rule. <details><summary>Test runs (head bec9f05)</summary> - `pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2 src/lint-liveness-properties.test.ts`: Test Files 1 passed, Tests 106 passed. - `pnpm --filter @objectstack/lint typecheck && pnpm --filter @objectstack/lint test`: exit 0, Test Files 165 passed, Tests 7383 passed, 5 skipped. - Ablation (`scripts/ablation-replace.mjs`, mutation landed: anchor 1 to 0, blob `60014c1e09ed` to `d39fa40b0098`): `ledgerRows` limited to one `children` level reds exactly the three new tests (3 failed, 103 passed). Restored: blob back to `60014c1e09ed`, equal to the `HEAD` blob, `git diff HEAD` empty. </details> ## Acceptance notes - Seat premise 6430781250 re-measured on `6390fe52`: status rows by depth 528 / 419 / 41 / 2; `authorWarn` at depth 2 or deeper: 0; the card's one-liner reads `properties` where the ledgers key `props`. - The verdict-triggered half does reach authors: `dead` warns without an opt-in (#16094), so the 5 dead rows two levels down now warn: `dashboard` `widgets.chartConfig.type`, `.xAxis`, `.yAxis`, `.series`, `.aria`. Each is a retired key the schema already refuses, so only an unparsed stack (`os lint`) reaches the warning. No other deep row warns. Per triage 6417914269, no ledger row is regraded here. - Serial with #26138 (draft): it edits this test file and drops rows from `app.json` / `dashboard.json`. Text-disjoint from the hunks here except that its new comment in the `dashboard silence` pin says the `chartConfig.*` rows "sit two down and were never graded", which this PR makes false; its assertions do not depend on that. Whoever lands second merges `main` and re-measures the counts above. - The CLI's i18n walker reads `authorWarnedProperties('translation')`; the translation ledger has no warned row below the child level, so that set is unchanged. ## Verification <details><summary>Dispatch gates (head bec9f05)</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 64 commands; 3 paths, 206 changed lines. - 63 exit 0. Three first answered exit 3 PREREQUISITE NOT MET (`check-plugin-teardown-shape --self-test` on the shallow clone; `check:docs-transcript-drift` and `check:lean-entry-closure` without built `lint` / `objectql`); after `git fetch --unshallow` and `pnpm --filter '@objectstack/lint...' --filter '@objectstack/objectql...' build` each re-ran exit 0. - NOT MEASURED: `check:dual-build-cjs-loads`, reason: it needs every package's `dist` (a full `pnpm build`, 88 packages absent); CI's `Lint & Repo Gates` runs it. - `--ran` reconciliation: exit 0, "64 derived famil(ies) accounted for — 63 run, 1 NOT-MEASURED". </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01WGufzhxSxc27ChPLkDSKEa)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3b480e0 commit 13136f7

3 files changed

Lines changed: 173 additions & 33 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/lint': patch
3+
---
4+
5+
fix(lint): the liveness author warnings and their status census read ledger rows at every depth of `children`
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
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.
11+
12+
- **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.
13+
- **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.

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

Lines changed: 111 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import {
2121
// #14057 coverage seam — the statuses the shipped ledgers actually carry, so
2222
// the coverage pin below is derived from the ledgers rather than hand-listed.
2323
shippedLedgerStatuses,
24+
// #26146 — the same census against a ledger directory the test controls.
25+
ledgerStatusesIn,
2426
// #19268/#19276 walk seam — package-internal, same posture as the two above
2527
// (module exports; `src/index.ts` re-exports neither, and the package's
2628
// `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)', () =
19281930
describe('the hint a warned row shows an author (#16094, #21096)', () => {
19291931
type ShippedRow = { type: string; path: string; entry: Record<string, unknown> };
19301932

1931-
/** Every shipped row at the depth the warn map reads (props + one level of `children`). */
1933+
/** Every shipped row at the depth the warn map reads: every level of `children` (#26146). */
19321934
function shippedRows(): ShippedRow[] {
19331935
const rows: ShippedRow[] = [];
19341936
const dir = shippedLedgerDir();
1935-
for (const file of readdirSync(dir).filter((f) => f.endsWith('.json')).sort()) {
1936-
const type = file.replace(/\.json$/, '');
1937-
const ledger = JSON.parse(readFileSync(join(dir, file), 'utf8'));
1938-
const isRow = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v);
1939-
for (const [key, entry] of Object.entries((ledger.props ?? {}) as Record<string, unknown>)) {
1937+
const isRow = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v);
1938+
const walk = (type: string, entries: unknown, prefix: string) => {
1939+
for (const [key, entry] of Object.entries(isRow(entries) ? entries : {})) {
19401940
if (!isRow(entry)) continue;
1941-
for (const [ck, centry] of Object.entries((entry.children ?? {}) as Record<string, unknown>)) {
1942-
if (isRow(centry)) rows.push({ type, path: `${key}.${ck}`, entry: centry });
1943-
}
1944-
rows.push({ type, path: key, entry });
1941+
const path = prefix ? `${prefix}.${key}` : key;
1942+
walk(type, entry.children, path);
1943+
rows.push({ type, path, entry });
19451944
}
1945+
};
1946+
for (const file of readdirSync(dir).filter((f) => f.endsWith('.json')).sort()) {
1947+
walk(file.replace(/\.json$/, ''), JSON.parse(readFileSync(join(dir, file), 'utf8')).props, '');
19461948
}
19471949
return rows;
19481950
}
19491951

1950-
/** An item that authors exactly `path` (one level of nesting at most). */
1952+
/** An item that authors exactly `path`, at any depth. */
19511953
const itemAuthoring = (path: string): Record<string, unknown> => {
1952-
const [head, child] = path.split('.');
1953-
return child === undefined ? { name: 'probe', [head]: 'x' } : { name: 'probe', [head]: { [child]: 'x' } };
1954+
const nested = path.split('.').reduceRight<unknown>((value, seg) => ({ [seg]: value }), 'x');
1955+
return { name: 'probe', ...(nested as Record<string, unknown>) };
19541956
};
19551957

19561958
const hintFor = (row: ShippedRow) => {
@@ -2150,3 +2152,99 @@ describe('[#22161] one-line fix — liveness-ledger-unreadable', () => {
21502152
}
21512153
});
21522154
});
2155+
2156+
// ── #26146: the warn map and the status census read every depth of `children` ──
2157+
//
2158+
// Both walks used to stop one `children` level down, while the ledgers had grown
2159+
// rows two and three levels down (the dashboard's `widgets.chartConfig.*`, the
2160+
// app's `navigation.dataSource.read.*`). None of them reached the warn map or
2161+
// the vocabulary census. The shipped deep rows carry no `authorWarn`, and every
2162+
// status they carry also appears at a shallower depth, so the opt-in and the
2163+
// census are each held through a planted copy of the ledger directory; the
2164+
// verdict-triggered half is held against the real dashboard ledger.
2165+
describe('#26146 — ledger rows below the child level are graded and counted', () => {
2166+
const board = (widget: Record<string, unknown>) => ({
2167+
dashboards: [{
2168+
name: 'sales_overview',
2169+
label: 'Sales',
2170+
widgets: [
2171+
{ id: 'first', type: 'metric', dataset: 'orders', values: ['total'] },
2172+
{ id: 'second', type: 'bar', dataset: 'orders', values: ['total'], ...widget },
2173+
],
2174+
}],
2175+
});
2176+
2177+
it('REAL LEDGER: the dead `widgets.chartConfig.*` rows, two levels down, are graded dead on an unparsed stack', () => {
2178+
const warned = authorWarnedProperties('dashboard');
2179+
const findings = lintLivenessProperties(board({
2180+
chartConfig: { type: 'bar', xAxis: 'region', yAxis: 'total', series: ['total'], title: 'Pipeline' },
2181+
}));
2182+
for (const key of ['type', 'xAxis', 'yAxis', 'series']) {
2183+
const path = `widgets.chartConfig.${key}`;
2184+
expect(warned.has(path), path).toBe(true);
2185+
// The second widget is the one authoring it, so the array fan-out is in the path too.
2186+
expect(ruleOf(findings, path), path).toBe(LIVENESS_DEAD_PROPERTY);
2187+
}
2188+
// Control: a live sibling on the same carrier stays silent in the same call.
2189+
expect(warned.has('widgets.chartConfig.title')).toBe(false);
2190+
expect(ruleOf(findings, 'widgets.chartConfig.title')).toBeUndefined();
2191+
});
2192+
2193+
/** The shipped `dashboard.json`, with a synthetic subtree planted under `widgets`. */
2194+
const plantedDashboardLedgerDir = () => ledgerDirWith((dir) => {
2195+
const ledger = JSON.parse(readFileSync(join(dir, 'dashboard.json'), 'utf8'));
2196+
ledger.props.widgets.children.synthNest = {
2197+
status: 'live',
2198+
children: {
2199+
// Depth 2: the card's case, a `dead` row with `authorWarn`.
2200+
deadOptIn: { status: 'dead', authorWarn: true },
2201+
deeper: {
2202+
status: 'live',
2203+
children: {
2204+
// Depth 3: `planned` warns ONLY through the opt-in, so this pair
2205+
// shows the opt-in itself reaches this deep, not just the verdict.
2206+
plannedOptIn: { status: 'planned', authorWarn: true },
2207+
plannedQuiet: { status: 'planned' },
2208+
},
2209+
},
2210+
},
2211+
};
2212+
writeLedger(dir, 'dashboard', ledger);
2213+
});
2214+
2215+
it('PLANTED: a dead row with authorWarn two levels down warns, and an opted-in planned row three levels down warns too', () => {
2216+
const findings = lintLivenessPropertiesFromLedgerDir(plantedDashboardLedgerDir(), board({
2217+
synthNest: { deadOptIn: 'x', deeper: { plannedOptIn: 'x', plannedQuiet: 'x' } },
2218+
}));
2219+
expect(findings.filter((f) => f.rule === LIVENESS_LEDGER_UNREADABLE)).toEqual([]);
2220+
expect(ruleOf(findings, 'widgets.synthNest.deadOptIn')).toBe(LIVENESS_DEAD_PROPERTY);
2221+
expect(ruleOf(findings, 'widgets.synthNest.deeper.plannedOptIn')).toBe('liveness-planned-property');
2222+
expect(ruleOf(findings, 'widgets.synthNest.deeper.plannedQuiet')).toBeUndefined();
2223+
// The live containers on the way down warn on nothing.
2224+
expect(ruleOf(findings, 'widgets.synthNest')).toBeUndefined();
2225+
expect(ruleOf(findings, 'widgets.synthNest.deeper')).toBeUndefined();
2226+
});
2227+
2228+
it('PLANTED: a status word used only two or three levels down reaches the vocabulary census', () => {
2229+
const dir = ledgerDirWith((d) => {
2230+
const ledger = JSON.parse(readFileSync(join(d, 'dashboard.json'), 'utf8'));
2231+
ledger.props.widgets.children.synthNest = {
2232+
status: 'live',
2233+
children: {
2234+
atDepthTwo: { status: 'synth-depth-two' },
2235+
deeper: { status: 'live', children: { atDepthThree: { status: 'synth-depth-three' } } },
2236+
},
2237+
};
2238+
writeLedger(d, 'dashboard', ledger);
2239+
});
2240+
const planted = ledgerStatusesIn(dir);
2241+
expect(planted.has('synth-depth-two')).toBe(true);
2242+
expect(planted.has('synth-depth-three')).toBe(true);
2243+
// Control: the shipped census is this same walk over the shipped directory,
2244+
// and neither planted word is in it.
2245+
const shipped = shippedLedgerStatuses();
2246+
expect([...ledgerStatusesIn(shippedLedgerDir())].sort()).toEqual([...shipped].sort());
2247+
expect(shipped.has('synth-depth-two')).toBe(false);
2248+
expect(shipped.has('synth-depth-three')).toBe(false);
2249+
});
2250+
});

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

Lines changed: 49 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -64,13 +64,35 @@ export interface LedgerEntry {
6464
children?: Record<string, LedgerEntry>;
6565
}
6666

67-
/** Flattened, warn-only view of a type's ledger: propPath → entry (incl. `a.b` children). */
67+
/** Flattened, warn-only view of a type's ledger: propPath → entry (`a.b`, `a.b.c`, … for nested children). */
6868
type WarnMap = Map<string, LedgerEntry>;
6969

7070
function isRecord(v: unknown): v is AnyRec {
7171
return !!v && typeof v === 'object' && !Array.isArray(v);
7272
}
7373

74+
/**
75+
* Every row of a ledger's `props`, at EVERY depth of `children`, as
76+
* `[dotted path, entry]` — each row's descendants before the row itself, the
77+
* order the warn map has always been filled in.
78+
*
79+
* `#26146`. Both readers of a ledger's rows — {@link loadWarnMap} and the
80+
* vocabulary census {@link ledgerStatusesIn} — used to hand-roll the same
81+
* walk, and both stopped one `children` level down: a ledger documented as
82+
* that deep had since grown rows two and three levels down (the dashboard's
83+
* `widgets.chartConfig.*`, the app's `navigation.dataSource.read.*`), and none
84+
* of them reached the warn map or the census. One walk, shared, is what keeps
85+
* the two from disagreeing again about how deep a ledger goes.
86+
*/
87+
function* ledgerRows(rows: unknown, prefix = ''): Generator<[string, LedgerEntry]> {
88+
if (!isRecord(rows)) return;
89+
for (const [key, entry] of Object.entries(rows as Record<string, LedgerEntry>)) {
90+
const path = prefix ? `${prefix}.${key}` : key;
91+
if (isRecord(entry)) yield* ledgerRows(entry.children, path);
92+
yield [path, entry];
93+
}
94+
}
95+
7496
/**
7597
* Locate `@objectstack/spec`'s shipped `liveness/` dir (workspace src or
7698
* published files).
@@ -126,7 +148,7 @@ interface WarnMapLoad {
126148
fault?: LedgerFault;
127149
}
128150

129-
/** Build the warn-only lookup for one type, flattening one level of `children`. */
151+
/** Build the warn-only lookup for one type, flattening every level of `children` ({@link ledgerRows}). */
130152
function loadWarnMap(dir: string, type: string): WarnMapLoad {
131153
const map: WarnMap = new Map();
132154
const file = join(dir, `${type}.json`);
@@ -144,14 +166,8 @@ function loadWarnMap(dir: string, type: string): WarnMapLoad {
144166
// carries a `props` record, so this branch describes a corrupt file and
145167
// never a legitimately empty one (`{"props": {}}` is a reading, not a fault).
146168
if (!isRecord(ledger) || !isRecord(ledger.props)) return { map, fault: 'unreadable' };
147-
const props = ledger.props as Record<string, LedgerEntry>;
148-
for (const [key, entry] of Object.entries(props)) {
149-
if (entry?.children) {
150-
for (const [ck, centry] of Object.entries(entry.children)) {
151-
if (shouldWarn(centry)) map.set(`${key}.${ck}`, centry);
152-
}
153-
}
154-
if (shouldWarn(entry)) map.set(key, entry);
169+
for (const [path, entry] of ledgerRows(ledger.props)) {
170+
if (shouldWarn(entry)) map.set(path, entry);
155171
}
156172
return { map };
157173
}
@@ -301,9 +317,13 @@ function describe(entry: LedgerEntry): { kind: string; rule: string; defaultHint
301317
* publishes just `.` and `./runtime`). ────────────────────────────────────
302318
*
303319
* Every distinct `status` string the SHIPPED ledgers actually carry, walked the
304-
* way `loadWarnMap` walks them (top-level props plus one level of `children` —
305-
* measured as the ledgers' full depth: 423 top-level and 285 child rows carry a
306-
* status, zero grandchildren do).
320+
* way `loadWarnMap` walks them: {@link ledgerRows}, every depth of `children`
321+
* (`#26146`). Measured on `origin/main` `6390fe52`, the rows carrying a status
322+
* are 528 top-level, 419 child, 41 grandchild and 2 great-grandchild (the app's
323+
* `navigation.dataSource.read.url` / `.method`). Until `#26146` both walks
324+
* stopped at the child level and a docblock here called that the ledgers' full
325+
* depth; it had stopped being one, which is why the depth is no longer a
326+
* number this code assumes.
307327
*
308328
* WHY THIS EXISTS AND WHY IT DERIVES FROM ROWS. #14057 is #11384 repeating
309329
* itself through a different door: `describe()` fell behind the vocabulary when
@@ -328,8 +348,19 @@ function describe(entry: LedgerEntry): { kind: string; rule: string; defaultHint
328348
* carries an anti-vacuity guard.
329349
*/
330350
export function shippedLedgerStatuses(): ReadonlySet<string> {
351+
return ledgerStatusesIn(resolveLivenessDir());
352+
}
353+
354+
/**
355+
* The census of {@link shippedLedgerStatuses}, against a ledger directory the
356+
* CALLER supplies — the same seam posture as
357+
* {@link lintLivenessPropertiesFromLedgerDir} (module export only). The
358+
* shipped ledgers carry no status at depth 2 or deeper that a shallower row
359+
* does not also carry, so only a planted copy can show that the census reaches
360+
* that depth at all (`#26146`).
361+
*/
362+
export function ledgerStatusesIn(dir: string | null): ReadonlySet<string> {
331363
const statuses = new Set<string>();
332-
const dir = resolveLivenessDir();
333364
if (!dir) return statuses;
334365
let files: string[];
335366
try {
@@ -345,11 +376,8 @@ export function shippedLedgerStatuses(): ReadonlySet<string> {
345376
} catch {
346377
continue;
347378
}
348-
for (const entry of Object.values(ledger?.props ?? {})) {
379+
for (const [, entry] of ledgerRows(ledger?.props)) {
349380
if (typeof entry?.status === 'string') statuses.add(entry.status);
350-
for (const child of Object.values(entry?.children ?? {})) {
351-
if (typeof child?.status === 'string') statuses.add(child.status);
352-
}
353381
}
354382
}
355383
return statuses;
@@ -371,8 +399,9 @@ export function shippedLedgerStatuses(): ReadonlySet<string> {
371399
* returns the very map `checkItem` iterates rather than a parallel reading of
372400
* the same JSON.
373401
*
374-
* Keys are the ledger's own property paths, `children` flattened one level as
375-
* `parent.child` — the shape `checkItem` resolves. Unreadable or absent ledger
402+
* Keys are the ledger's own property paths, `children` flattened at every depth
403+
* as `parent.child`, `parent.child.grandchild`, … — the shape `checkItem`
404+
* resolves. Unreadable or absent ledger
376405
* ⇒ the empty set, which is also the map `lintLivenessProperties` walks with:
377406
* the two sides still go quiet TOGETHER rather than one of them going quiet
378407
* alone, which is the invariant this export exists for.

0 commit comments

Comments
 (0)