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
13 changes: 13 additions & 0 deletions .changeset/26146-liveness-every-depth.md
Original file line number Diff line number Diff line change
@@ -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.
124 changes: 111 additions & 13 deletions packages/lint/src/lint-liveness-properties.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string, unknown> };

/** 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<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v);
for (const [key, entry] of Object.entries((ledger.props ?? {}) as Record<string, unknown>)) {
const isRow = (v: unknown): v is Record<string, unknown> => !!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<string, unknown>)) {
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<string, unknown> => {
const [head, child] = path.split('.');
return child === undefined ? { name: 'probe', [head]: 'x' } : { name: 'probe', [head]: { [child]: 'x' } };
const nested = path.split('.').reduceRight<unknown>((value, seg) => ({ [seg]: value }), 'x');
return { name: 'probe', ...(nested as Record<string, unknown>) };
};

const hintFor = (row: ShippedRow) => {
Expand Down Expand Up @@ -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<string, unknown>) => ({
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);
});
});
69 changes: 49 additions & 20 deletions packages/lint/src/lint-liveness-properties.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,35 @@ export interface LedgerEntry {
children?: Record<string, LedgerEntry>;
}

/** 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<string, LedgerEntry>;

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<string, LedgerEntry>)) {
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).
Expand Down Expand Up @@ -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`);
Expand All @@ -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<string, LedgerEntry>;
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 };
}
Expand Down Expand Up @@ -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
Expand All @@ -328,8 +348,19 @@ function describe(entry: LedgerEntry): { kind: string; rule: string; defaultHint
* carries an anti-vacuity guard.
*/
export function shippedLedgerStatuses(): ReadonlySet<string> {
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<string> {
const statuses = new Set<string>();
const dir = resolveLivenessDir();
if (!dir) return statuses;
let files: string[];
try {
Expand All @@ -345,11 +376,8 @@ export function shippedLedgerStatuses(): ReadonlySet<string> {
} 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;
Expand All @@ -371,8 +399,9 @@ export function shippedLedgerStatuses(): ReadonlySet<string> {
* 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.
Expand Down
Loading