From fd3dfa151c7494b369ede3f8f46f9a9e5a118093 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 01:34:32 +0000 Subject: [PATCH 1/5] feat(lint): give the liveness walk a subject no ledger verdict can move MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `lintLivenessProperties` puts the entire object/field walk behind `if (fieldWarn.size > 0)`. `field.relatedListFilter` was the only `authorWarn` row on `field.json` at any depth, so flipping it `live` (#19187, landed by PR #19265) emptied `loadWarnMap(dir, 'field')` and the field loop stopped executing altogether — taking #11385's `if (!isRecord(field)) continue` guard out of reach of the public function, with no second warned field row anywhere to re-hang it on. Split the walk from ledger RESOLUTION and export the pair as a package-internal seam (`resolveLivenessDir`, `lintLivenessPropertiesFromLedgerDir`): module exports only, neither re-exported by `src/index.ts`, and the package's `exports` map still publishes just `.` and `./runtime`, so the published surface is unchanged. The new tests drive the real rule against a copy of the shipped ledger directory carrying one synthetic `field.json`, which makes #11385's guard provable again and cannot be emptied by a future flip — the third time a correct flip deleted coverage in this file. Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude --- .../lint/src/lint-liveness-properties.test.ts | 150 +++++++++++++++++- packages/lint/src/lint-liveness-properties.ts | 99 +++++++++--- 2 files changed, 230 insertions(+), 19 deletions(-) diff --git a/packages/lint/src/lint-liveness-properties.test.ts b/packages/lint/src/lint-liveness-properties.test.ts index 4bcc22ec2ca..9e2fb0db7da 100644 --- a/packages/lint/src/lint-liveness-properties.test.ts +++ b/packages/lint/src/lint-liveness-properties.test.ts @@ -1,9 +1,13 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { describe, it, expect } from 'vitest'; +import { afterAll, describe, it, expect } from 'vitest'; +import { cpSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { authorWarnedProperties, lintLivenessProperties, + LIVENESS_DEAD_PROPERTY, // #10262 test seam — package-internal (not re-exported by `src/index.ts`, not // in the package's `exports` map). See the block below `getNested` in the // source for why this ONE property is tested off the ledger. @@ -12,6 +16,12 @@ 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, + // #19268 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 ledger directory the + // test controls, and the resolver that finds the real one to copy. + lintLivenessPropertiesFromLedgerDir, + resolveLivenessDir, } from './lint-liveness-properties.js'; /** @@ -1334,3 +1344,141 @@ describe('authorWarnedProperties', () => { expect([...authorWarnedProperties('no-such-metadata-type')]).toEqual([]); }); }); + +// ── #19268 / #19276: the walk seam, and the ledger reads it makes ─────────── +// +// #19268 — `lintLivenessProperties` puts the whole field loop behind +// `if (fieldWarn.size > 0)`. `field.relatedListFilter` was the ONE +// `authorWarn` row on `field.json` at any depth, so when #19187 correctly +// flipped it `live` the loop stopped executing and #11385's +// `if (!isRecord(field)) continue` guard became unreachable through the +// public function — with no second warned field row anywhere to re-hang it +// on, because the field walk reads `field.json` and nothing else. +// - #19276 — `loadWarnMap` returned the same empty map for "this ledger +// classifies nothing as warn-worthy" and for "there is no ledger", so +// losing or corrupting one file under the shipped `liveness/` directory +// switched every author warning for that type off in silence. One frame up +// the directory-level failure is loud by construction (the rule returns +// `[]` and its dependants go red): loud by directory, silent by file. +// +// Both blocks below drive the REAL rule against a COPY of the shipped ledger +// directory with one file changed. That is deliberately the same trade the +// #10262 block above makes and states: assertions made through this seam say +// nothing about what the shipped ledgers classify — every other block in this +// file is still a contract test against the real ones — and in exchange no +// future ledger flip can empty them. The subject is the walker and the loader, +// and neither is a verdict that can move. +const tempLedgerDirs: string[] = []; + +afterAll(() => { + for (const dir of tempLedgerDirs) rmSync(dir, { recursive: true, force: true }); +}); + +/** The directory the rule itself reads — resolved its way, never re-derived. */ +function shippedLedgerDir(): string { + const dir = resolveLivenessDir(); + if (!dir) { + throw new Error( + 'the shipped liveness directory did not resolve; every assertion in this file depends on it', + ); + } + return dir; +} + +/** A throwaway copy of the shipped ledger directory, with `mutate` applied to it. */ +function ledgerDirWith(mutate: (dir: string) => void): string { + const dir = mkdtempSync(join(tmpdir(), 'os-liveness-ledgers-')); + tempLedgerDirs.push(dir); + cpSync(shippedLedgerDir(), dir, { recursive: true }); + mutate(dir); + return dir; +} + +const writeLedger = (dir: string, type: string, body: unknown) => + writeFileSync(join(dir, `${type}.json`), typeof body === 'string' ? body : JSON.stringify(body)); + +/** + * A `field.json` whose only row is synthetic. `status: 'dead'` is explicit for + * the same reason the #10262 block says it is: `describe()` throws on a status + * it does not recognise, and this fixture asserts nothing about verdicts. + */ +const SYNTHETIC_FIELD_LEDGER = { + props: { + synthWarnedSlot: { + status: 'dead', + authorWarn: true, + authorHint: 'synthetic (#19268) — this row exists only in a test ledger directory', + }, + }, +}; + +const fieldLedgerDir = () => ledgerDirWith((dir) => writeLedger(dir, 'field', SYNTHETIC_FIELD_LEDGER)); + +describe('the object/field walk, against a synthetic ledger directory (#19268)', () => { + // The state that makes this block necessary, asserted rather than recalled. + // If `field.json` ever warns again these two flip, and the block above + // ("field walk: a malformed `fields` array …") can take its real subject back + // — but this block keeps working either way, which is the point. + it('the SHIPPED field ledger warns on nothing today — which is why the walk needs a subject of its own', () => { + expect([...authorWarnedProperties('field')]).toEqual([]); + expect( + lintLivenessProperties({ + objects: [{ name: 'widget', fields: [{ name: 'a', synthWarnedSlot: true }] }], + }), + ).toEqual([]); + }); + + it('runs the field walk and reports the authored field, where the public function reports nothing', () => { + const findings = lintLivenessPropertiesFromLedgerDir(fieldLedgerDir(), { + objects: [{ name: 'widget', fields: [{ name: 'a', synthWarnedSlot: true }] }], + }); + expect(findings.map((f) => f.where)).toEqual(["object 'widget' · field 'a'"]); + expect(findings[0].rule).toBe(LIVENESS_DEAD_PROPERTY); + expect(findings[0].message).toContain('sets `synthWarnedSlot`'); + expect(findings[0].hint).toBe('synthetic (#19268) — this row exists only in a test ledger directory'); + }); + + it('reaches every field of every object, not just the first of each', () => { + const findings = lintLivenessPropertiesFromLedgerDir(fieldLedgerDir(), { + objects: [ + { name: 'widget', fields: [{ name: 'a', synthWarnedSlot: true }, { name: 'b', synthWarnedSlot: true }] }, + { name: 'gadget', fields: [{ name: 'c', synthWarnedSlot: true }] }, + ], + }); + expect(findings.map((f) => f.where)).toEqual([ + "object 'widget' · field 'a'", + "object 'widget' · field 'b'", + "object 'gadget' · field 'c'", + ]); + }); + + // #11385, provable again. This is the assertion the card says was lost: with + // the field loop gated off, a `null` field could not even be reached, so the + // guard that skips it evaluated never. Driven from the seam the walk runs, + // the malformed element is skipped AND the walk keeps going past it — the + // two halves #11385 pairs on purpose, because a walk that aborts silently + // passes a no-throw assertion just as well as one that recovers. + it('#11385: skips a null element in `fields` and keeps walking past it', () => { + const findings = lintLivenessPropertiesFromLedgerDir(fieldLedgerDir(), { + objects: [{ + name: 'widget', + fields: [null, { name: 'after_the_null', synthWarnedSlot: true }], + }], + }); + expect(findings.map((f) => f.where)).toEqual(["object 'widget' · field 'after_the_null'"]); + }); + + it('#11385: a null OBJECT does not stop the field walk on the objects after it', () => { + const findings = lintLivenessPropertiesFromLedgerDir(fieldLedgerDir(), { + objects: [null, { name: 'widget', fields: [{ name: 'a', synthWarnedSlot: true }] }], + }); + expect(findings.map((f) => f.where)).toEqual(["object 'widget' · field 'a'"]); + }); + + it('is silent on fields that author no warned key — the walk running is not the walk warning', () => { + const findings = lintLivenessPropertiesFromLedgerDir(fieldLedgerDir(), { + objects: [{ name: 'widget', fields: [{ name: 'a', type: 'text', label: 'A' }] }], + }); + expect(findings).toEqual([]); + }); +}); diff --git a/packages/lint/src/lint-liveness-properties.ts b/packages/lint/src/lint-liveness-properties.ts index 1bb124b9061..4a18e7ec3a9 100644 --- a/packages/lint/src/lint-liveness-properties.ts +++ b/packages/lint/src/lint-liveness-properties.ts @@ -58,8 +58,17 @@ function isRecord(v: unknown): v is AnyRec { return !!v && typeof v === 'object' && !Array.isArray(v); } -/** Locate `@objectstack/spec`'s shipped `liveness/` dir (workspace src or published files). */ -function resolveLivenessDir(): string | null { +/** + * Locate `@objectstack/spec`'s shipped `liveness/` dir (workspace src or + * published files). + * + * Exported as part of the package-internal walk seam (`#19268`, see the block + * below `walkStack`): a test that drives the rule against a MODIFIED copy of + * the shipped ledgers has to start from the same directory the rule itself + * reads, resolved the same way, or it is testing a directory nothing uses. + */ +export function resolveLivenessDir(): string | null { + try { const require = createRequire(import.meta.url); const pkgJson = require.resolve('@objectstack/spec/package.json'); @@ -467,23 +476,21 @@ 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, 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. + * The walk itself: every governed metadata type checked against whatever warn + * map `warnMapOf` answers with, findings appended in walk order. + * + * Split out from ledger RESOLUTION so the walk can be driven from ledgers the + * caller controls (`#19268` — see the seam block below). Every governed type is + * asked for exactly once per call whatever the stack holds, which is also what + * lets the seam report a fault for a type whose collection the stack never + * carries. */ -export function lintLivenessProperties(stack: AnyRec): LivenessLintFinding[] { - const dir = resolveLivenessDir(); - if (!dir) return []; - +function walkStack(stack: AnyRec, warnMapOf: (type: string) => WarnMap): LivenessLintFinding[] { const findings: LivenessLintFinding[] = []; - const objectWarn = loadWarnMap(dir, 'object'); - const fieldWarn = loadWarnMap(dir, 'field'); + const objectWarn = warnMapOf('object'); + const fieldWarn = warnMapOf('field'); + for (const obj of recordsOf(stack.objects)) { // Malformed collection item — same "never throws" contract as the flat // TYPE_COLLECTIONS loop and the translation bundle walk below (#11385). @@ -516,7 +523,7 @@ export function lintLivenessProperties(stack: AnyRec): LivenessLintFinding[] { // 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'); + const translationWarn = warnMapOf('translation'); if (translationWarn.size > 0) { const bundles = recordsOf(stack.translations); for (let i = 0; i < bundles.length; i++) { @@ -533,7 +540,7 @@ export function lintLivenessProperties(stack: AnyRec): LivenessLintFinding[] { } for (const { type, key } of TYPE_COLLECTIONS) { - const warnMap = loadWarnMap(dir, type); + const warnMap = warnMapOf(type); if (warnMap.size === 0) continue; for (const item of recordsOf(stack[key])) { // Malformed collection item — "never throws" contract (#11385). @@ -548,3 +555,59 @@ export function lintLivenessProperties(stack: AnyRec): LivenessLintFinding[] { return findings; } + +/** + * ── Walk seam (#19268). Package-internal: NOT part of the published surface, + * the same posture as the #10262 seam below `getNested` — exported from the + * MODULE only. `src/index.ts` re-exports neither this nor + * `resolveLivenessDir`, and this package's `exports` map publishes exactly + * two subpaths (`.` → `dist/index.js`, `./runtime` → `dist/runtime.js`, both + * bundled by tsup from those two entries), so no consumer can reach either + * symbol and the built `.d.ts` surface is unchanged. ──────────────────── + * + * The whole rule, against a ledger directory the CALLER supplies. + * + * WHY THE SEAM IS A DIRECTORY AND NOT A READY-MADE WARN MAP. The field walk + * sits behind `if (fieldWarn.size > 0)`, so when `field.json`'s last + * `authorWarn` row correctly flipped `live` (#19187) the loop stopped + * executing and the `if (!isRecord(field)) continue` guard #11385 was filed for + * became unreachable THROUGH THE PUBLIC FUNCTION — with nothing to re-subject + * it to, because the field walk reads `field.json` and nothing else. That is + * the third time a correct ledger flip deleted this file's coverage (#7079, + * the #10262 block above, now this), so the cure is that block's: give the walk + * a subject no verdict can move. `checkItemAgainstWarnMap` cannot be that + * subject — it takes one ITEM, and what #11385 guards is the walk that finds + * the items. A synthetic ledger file can be all of it: copy the shipped ledger + * directory, change ONE file in the copy, run the real rule against it. + * + * The cost is honest and bounded, exactly as for the #10262 block: assertions + * made through this seam say nothing about what the SHIPPED ledgers classify — + * that stays the job of every ledger-driven assertion in the test file. + */ +export function lintLivenessPropertiesFromLedgerDir(dir: string, stack: AnyRec): LivenessLintFinding[] { + const maps = new Map(); + const warnMapOf = (type: string): WarnMap => { + const cached = maps.get(type); + if (cached) return cached; + const map = loadWarnMap(dir, type); + maps.set(type, map); + return map; + }; + return walkStack(stack, warnMapOf); +} + +/** + * 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, 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. + */ +export function lintLivenessProperties(stack: AnyRec): LivenessLintFinding[] { + const dir = resolveLivenessDir(); + if (!dir) return []; + return lintLivenessPropertiesFromLedgerDir(dir, stack); +} From e6a61abcd86c13d56b1d76b0c159e51e66c0f93e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 01:34:48 +0000 Subject: [PATCH 2/5] feat(lint): report a liveness ledger that could not be read, instead of going silent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `loadWarnMap` returned the same empty map for "this type's ledger classifies nothing as warn-worthy" and for "there is no ledger" — a missing file and broken JSON both `return map` without a log, a throw or any other signal. Losing or corrupting one file under the shipped `liveness/` directory therefore switched every author warning for that metadata type off in silence, indistinguishable from that type having no warnings. One frame up the directory-level failure is loud by construction (`resolveLivenessDir()` returning null makes the whole rule return `[]` and its dependants go red): loud by directory, silent by file, and that asymmetry is the defect. `loadWarnMap` now returns the map plus an optional `fault`, and `lintLivenessProperties` raises one `liveness-ledger-unreadable` finding per faulted type — once per run, never once per item, and ahead of the walk's own findings because it says why the rest may be short. A third failed-read shape is covered by the same branch: a document that parses but is not a ledger, including a `null` whose `.props` read was a TypeError against a rule that promises never to throw. `authorWarnedProperties` keeps answering the empty set (a decision procedure returning a set cannot report a failed read) and `os lint` runs both halves in one pass, so the run says it once rather than never. Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude --- packages/lint/src/index.ts | 7 + .../lint/src/lint-liveness-properties.test.ts | 116 +++++++++++- packages/lint/src/lint-liveness-properties.ts | 165 ++++++++++++++---- .../validate-retired-permission-residue.ts | 6 +- 4 files changed, 252 insertions(+), 42 deletions(-) diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index 7827e7efaf7..26bc355b668 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -850,6 +850,13 @@ export { // HERE by measurement but genuinely enforced in a sibling repo, so it must // never share the `dead` id: the two ask the author for opposite actions. LIVENESS_LIVE_ELSEWHERE_PROPERTY, + // #19276 — not a verdict: the rule reporting that a per-type ledger could not + // be read at all, so every author warning for that type is switched off. + // Published for the same reason as the four above — `f.rule` is what `--json` + // consumers and `suppressWarnings` compare against — and load-bearing here, + // because this is the one finding whose presence means the OTHER four cannot + // be trusted for that type. + LIVENESS_LEDGER_UNREADABLE, } from './lint-liveness-properties.js'; export { validateRetiredPermissionResidue } from './validate-retired-permission-residue.js'; diff --git a/packages/lint/src/lint-liveness-properties.test.ts b/packages/lint/src/lint-liveness-properties.test.ts index 9e2fb0db7da..08e6b8c8450 100644 --- a/packages/lint/src/lint-liveness-properties.test.ts +++ b/packages/lint/src/lint-liveness-properties.test.ts @@ -8,6 +8,8 @@ import { authorWarnedProperties, lintLivenessProperties, LIVENESS_DEAD_PROPERTY, + LIVENESS_EXPERIMENTAL_PROPERTY, + LIVENESS_LEDGER_UNREADABLE, // #10262 test seam — package-internal (not re-exported by `src/index.ts`, not // in the package's `exports` map). See the block below `getNested` in the // source for why this ONE property is tested off the ledger. @@ -16,10 +18,11 @@ 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, - // #19268 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 ledger directory the - // test controls, and the resolver that finds the real one to copy. + // #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 + // ledger directory the test controls, and the resolver that finds the real + // one to copy. lintLivenessPropertiesFromLedgerDir, resolveLivenessDir, } from './lint-liveness-properties.js'; @@ -1338,16 +1341,26 @@ describe('authorWarnedProperties', () => { }); it('returns the empty set for a type with no ledger, rather than throwing', () => { - // The fail-quiet path both halves share: no ledger ⇒ this rule warns on - // nothing and the CLI gates nothing. They go silent together; the state - // that must never happen is one of them speaking alone. + // The fail-quiet path both halves share for a type nothing governs: this + // rule never asks about it and the CLI gates nothing. They go silent + // together; the state that must never happen is one of them speaking alone. + // + // #19276 narrowed where that silence is acceptable, and only there: for one + // of the types the rule DOES walk, a ledger that cannot be read is now + // reported once by `lintLivenessProperties` (see the block below), because + // silence about a governed type is indistinguishable from a clean bill of + // health. This set still answers empty — a decision procedure returning a + // set has no way to report a failed read — and `os lint` runs both halves + // in one pass, so the run says it once rather than never. expect([...authorWarnedProperties('no-such-metadata-type')]).toEqual([]); }); }); // ── #19268 / #19276: the walk seam, and the ledger reads it makes ─────────── // -// #19268 — `lintLivenessProperties` puts the whole field loop behind +// Two findings, one file, one shape: an empty warn map silently kills a walk. +// +// - #19268 — `lintLivenessProperties` puts the whole field loop behind // `if (fieldWarn.size > 0)`. `field.relatedListFilter` was the ONE // `authorWarn` row on `field.json` at any depth, so when #19187 correctly // flipped it `live` the loop stopped executing and #11385's @@ -1482,3 +1495,90 @@ describe('the object/field walk, against a synthetic ledger directory (#19268)', expect(findings).toEqual([]); }); }); + +describe('a per-type ledger that could not be READ is reported once (#19276)', () => { + // The load-bearing discrimination, both directions in one test: a ledger that + // warns on nothing is a READING (silence is the right answer), and the same + // type with no ledger at all is a FAULT (silence would be a lie). Before + // #19276 these two produced byte-identical output. + it('tells "this ledger warns on nothing" apart from "there is no ledger"', () => { + const readsClean = ledgerDirWith((dir) => + writeLedger(dir, 'object', { props: { somethingLive: { status: 'live' } } }), + ); + expect(lintLivenessPropertiesFromLedgerDir(readsClean, {})).toEqual([]); + + const missing = ledgerDirWith((dir) => rmSync(join(dir, 'object.json'))); + const findings = lintLivenessPropertiesFromLedgerDir(missing, {}); + expect(findings.map((f) => f.where)).toEqual(["liveness ledger 'object'"]); + expect(findings[0].rule).toBe(LIVENESS_LEDGER_UNREADABLE); + }); + + it('a MISSING ledger names the type, the file and the remedy — on an empty stack, too', () => { + const findings = lintLivenessPropertiesFromLedgerDir( + ledgerDirWith((dir) => rmSync(join(dir, 'translation.json'))), + {}, + ); + expect(findings).toHaveLength(1); + expect(findings[0].message).toContain('no ledger file was found for it'); + expect(findings[0].message).toContain('`translation.json`'); + expect(findings[0].message).toContain('every author warning for `translation` metadata is switched off'); + expect(findings[0].hint).toContain('@objectstack/spec'); + }); + + it('an UNPARSEABLE ledger is reported too, and says so in different words', () => { + const findings = lintLivenessPropertiesFromLedgerDir( + ledgerDirWith((dir) => writeLedger(dir, 'translation', '{ "props": { broken')), + {}, + ); + expect(findings.map((f) => f.where)).toEqual(["liveness ledger 'translation'"]); + expect(findings[0].message).toContain('does not parse as a ledger'); + expect(findings[0].message).not.toContain('no ledger file was found'); + }); + + // A document that PARSES but is not a ledger is the same failed read, and the + // `null` case is also a throw the old code could take: `ledger.props` off a + // parsed `null` is a TypeError, through the one input an author cannot + // influence — our own shipped data — against a rule that promises never to + // throw. + it('a ledger that parses to something that is not a ledger is a fault, never a throw', () => { + for (const document of ['null', '[]', '"a string"', '42', '{}']) { + const findings = lintLivenessPropertiesFromLedgerDir( + ledgerDirWith((dir) => writeLedger(dir, 'view', document)), + {}, + ); + expect(findings.map((f) => f.where)).toEqual(["liveness ledger 'view'"]); + } + }); + + it('reports the fault ONCE per run, not once per authored item', () => { + const findings = lintLivenessPropertiesFromLedgerDir( + ledgerDirWith((dir) => rmSync(join(dir, 'agent.json'))), + { agents: [{ name: 'ag1' }, { name: 'ag2' }, { name: 'ag3' }] }, + ); + expect(findings.filter((f) => f.rule === LIVENESS_LEDGER_UNREADABLE)).toHaveLength(1); + }); + + it('keeps walking the types whose ledgers ARE readable, and puts the fault first', () => { + // agent.memory is a real `experimental` row, so this proves the fault does + // not abort the pass: one type is dark, the rest still enforce, and the + // line that explains the darkness is the one a reader meets first. + const findings = lintLivenessPropertiesFromLedgerDir( + ledgerDirWith((dir) => rmSync(join(dir, 'object.json'))), + { agents: [{ name: 'ag1', memory: { kind: 'buffer' } }] }, + ); + expect(findings.map((f) => f.rule)).toEqual([LIVENESS_LEDGER_UNREADABLE, LIVENESS_EXPERIMENTAL_PROPERTY]); + expect(findings[0].where).toBe("liveness ledger 'object'"); + }); + + // Anti-vacuity control for every assertion above: on an INTACT copy of the + // shipped directory the fault channel is silent, so a test that expects one + // fault is reading the file it removed and not a permanent noise floor. The + // second half says the same of the real directory through the public + // function, which is what ships. + it('an intact ledger directory raises no fault at all — the control', () => { + expect(lintLivenessPropertiesFromLedgerDir(ledgerDirWith(() => {}), {})).toEqual([]); + expect( + lintLivenessProperties({}).filter((f) => f.rule === LIVENESS_LEDGER_UNREADABLE), + ).toEqual([]); + }); +}); diff --git a/packages/lint/src/lint-liveness-properties.ts b/packages/lint/src/lint-liveness-properties.ts index 4a18e7ec3a9..da8cf30bc8f 100644 --- a/packages/lint/src/lint-liveness-properties.ts +++ b/packages/lint/src/lint-liveness-properties.ts @@ -41,6 +41,16 @@ export const LIVENESS_EXPERIMENTAL_PROPERTY = 'liveness-experimental-property'; export const LIVENESS_PLANNED_PROPERTY = 'liveness-planned-property'; export const LIVENESS_LIVE_ELSEWHERE_PROPERTY = 'liveness-live-elsewhere-property'; +/** + * `#19276`. The one finding this rule emits about ITSELF rather than about an + * authored property: a per-type ledger could not be READ, so every author + * warning for that metadata type is switched off and no other finding about + * that type means anything. It is deliberately not a fifth verdict — the four + * above grade a property the ledger DID classify; this one says the + * classification never arrived. + */ +export const LIVENESS_LEDGER_UNREADABLE = 'liveness-ledger-unreadable'; + type AnyRec = Record; export interface LedgerEntry { @@ -68,7 +78,6 @@ function isRecord(v: unknown): v is AnyRec { * reads, resolved the same way, or it is testing a directory nothing uses. */ export function resolveLivenessDir(): string | null { - try { const require = createRequire(import.meta.url); const pkgJson = require.resolve('@objectstack/spec/package.json'); @@ -79,18 +88,60 @@ export function resolveLivenessDir(): string | null { } } +/** + * Why a type's warn map came back empty, when the reason is NOT the ordinary + * one ("this type's ledger warns on nothing") — `#19276`. + * + * - `missing` — no `.json` under the resolved liveness directory at all. + * - `unreadable` — the file is there but does not parse as a ledger: broken + * JSON, or a document whose top level is not a record carrying a `props` + * record. + */ +export type LedgerFault = 'missing' | 'unreadable'; + +/** + * One type's warn map, plus the reason it is empty when that reason is a + * FAILED READ rather than a reading (`#19276`). + * + * The distinction is the whole point. An empty map is the most consequential + * value in this module — it silences every author warning for that metadata + * type — and it used to be returned identically for "the ledger classifies + * nothing as warn-worthy" and for "there was no ledger to classify from". So + * losing or corrupting ONE file under the shipped `liveness/` directory + * switched a whole type's author-side enforcement off with nothing anywhere + * saying so, indistinguishable from that type simply having no warnings. + * + * The contrast that makes it a defect rather than a design is one frame up: + * the DIRECTORY-level failure (`resolveLivenessDir()` returning `null`) is + * loud by construction — the whole rule returns `[]` and every test that + * depends on it goes red. Loud by directory, silent by file. `fault` is what + * closes that asymmetry; {@link lintLivenessProperties} reports it once. + */ +interface WarnMapLoad { + map: WarnMap; + /** Absent means the map is a READING; present means the ledger never arrived. */ + fault?: LedgerFault; +} + /** Build the warn-only lookup for one type, flattening one level of `children`. */ -function loadWarnMap(dir: string, type: string): WarnMap { +function loadWarnMap(dir: string, type: string): WarnMapLoad { const map: WarnMap = new Map(); const file = join(dir, `${type}.json`); - if (!existsSync(file)) return map; - let ledger: { props?: Record }; + if (!existsSync(file)) return { map, fault: 'missing' }; + let ledger: unknown; try { ledger = JSON.parse(readFileSync(file, 'utf8')); } catch { - return map; + return { map, fault: 'unreadable' }; } - const props = ledger.props || {}; + // A document that PARSES but is not a ledger is the same failed read, and + // reading `.props` off a parsed `null` would throw — breaking this rule's + // "never throws" contract through the one input an author cannot influence: + // our own shipped data. Measured across all 39 shipped ledgers, every one + // 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)) { @@ -99,7 +150,7 @@ function loadWarnMap(dir: string, type: string): WarnMap { } if (shouldWarn(entry)) map.set(key, entry); } - return map; + return { map }; } /** An entry warns when explicitly opted in, OR when it's experimental (a declared-but-unenforced guarantee). */ @@ -299,9 +350,16 @@ export function shippedLedgerStatuses(): ReadonlySet { * * Keys are the ledger's own property paths, `children` flattened one level as * `parent.child` — the shape `checkItem` resolves. Unreadable or absent ledger - * ⇒ the empty set, which is also the state in which `lintLivenessProperties` - * warns on nothing: the two sides go quiet together rather than one of them - * going quiet alone. + * ⇒ 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. + * + * What changed with `#19276` is that the quiet is no longer unannounced. A + * decision procedure returning a set cannot report a failed read, so this one + * still answers "nothing warns" — but `lintLivenessProperties` now raises a + * `liveness-ledger-unreadable` finding for the same ledger, and `os lint` runs + * both in one pass, so the run says once that the ledger never arrived instead + * of both halves agreeing in silence. * * Deliberately NOT memoized, for the same reason `lintLivenessProperties` * re-reads on every call: a cached verdict outlives the ledger edit that @@ -311,7 +369,7 @@ export function shippedLedgerStatuses(): ReadonlySet { export function authorWarnedProperties(type: string): ReadonlySet { const dir = resolveLivenessDir(); if (!dir) return new Set(); - return new Set(loadWarnMap(dir, type).keys()); + return new Set(loadWarnMap(dir, type).map.keys()); } /** Check one metadata item's set properties against its type's warn-map. */ @@ -490,7 +548,6 @@ function walkStack(stack: AnyRec, warnMapOf: (type: string) => WarnMap): Livenes const objectWarn = warnMapOf('object'); const fieldWarn = warnMapOf('field'); - for (const obj of recordsOf(stack.objects)) { // Malformed collection item — same "never throws" contract as the flat // TYPE_COLLECTIONS loop and the translation bundle walk below (#11385). @@ -556,10 +613,35 @@ function walkStack(stack: AnyRec, warnMapOf: (type: string) => WarnMap): Livenes return findings; } +/** The cause half of a ledger-fault message, per {@link LedgerFault}. */ +const LEDGER_FAULT_CAUSE: Record = { + missing: 'no ledger file was found for it', + unreadable: 'its ledger file does not parse as a ledger', +}; + +/** + * One finding per type whose ledger never arrived (`#19276`) — reported ONCE + * per run, not once per authored item: the subject is the ledger, and an + * unreadable ledger is one fact however many objects the stack carries. + */ +function ledgerFaultFindings(faults: ReadonlyMap): LivenessLintFinding[] { + return [...faults].map(([type, fault]) => ({ + where: `liveness ledger '${type}'`, + message: + `every author warning for \`${type}\` metadata is switched off: ${LEDGER_FAULT_CAUSE[fault]} ` + + `(expected \`${type}.json\` beside the other liveness ledgers \`@objectstack/spec\` ships).`, + hint: + 'This is a packaging fault in the platform, not an authoring error: nothing in the metadata ' + + 'being linted caused it, and this rule’s silence about this type means nothing until it is ' + + 'fixed. Reinstall or repair `@objectstack/spec` so its `liveness/` directory ships intact.', + rule: LIVENESS_LEDGER_UNREADABLE, + })); +} + /** - * ── Walk seam (#19268). Package-internal: NOT part of the published surface, - * the same posture as the #10262 seam below `getNested` — exported from the - * MODULE only. `src/index.ts` re-exports neither this nor + * ── Walk seam (#19268 / #19276). Package-internal: NOT part of the published + * surface, the same posture as the #10262 seam below `getNested` — exported + * from the MODULE only. `src/index.ts` re-exports neither this nor * `resolveLivenessDir`, and this package's `exports` map publishes exactly * two subpaths (`.` → `dist/index.js`, `./runtime` → `dist/runtime.js`, both * bundled by tsup from those two entries), so no consumer can reach either @@ -567,33 +649,47 @@ function walkStack(stack: AnyRec, warnMapOf: (type: string) => WarnMap): Livenes * * The whole rule, against a ledger directory the CALLER supplies. * - * WHY THE SEAM IS A DIRECTORY AND NOT A READY-MADE WARN MAP. The field walk - * sits behind `if (fieldWarn.size > 0)`, so when `field.json`'s last - * `authorWarn` row correctly flipped `live` (#19187) the loop stopped - * executing and the `if (!isRecord(field)) continue` guard #11385 was filed for - * became unreachable THROUGH THE PUBLIC FUNCTION — with nothing to re-subject - * it to, because the field walk reads `field.json` and nothing else. That is - * the third time a correct ledger flip deleted this file's coverage (#7079, - * the #10262 block above, now this), so the cure is that block's: give the walk - * a subject no verdict can move. `checkItemAgainstWarnMap` cannot be that - * subject — it takes one ITEM, and what #11385 guards is the walk that finds - * the items. A synthetic ledger file can be all of it: copy the shipped ledger - * directory, change ONE file in the copy, run the real rule against it. + * WHY THE SEAM IS A DIRECTORY AND NOT A READY-MADE WARN MAP. Two defects in + * this file share one shape — an empty warn map silently kills a walk — and + * they need fixtures a warn-map seam cannot both give: + * + * - `#19268`: the field walk sits behind `if (fieldWarn.size > 0)`, so when + * `field.json`'s last `authorWarn` row correctly flipped `live` (#19187) + * the loop stopped executing altogether and the `if (!isRecord(field)) + * continue` guard #11385 was filed for became unreachable THROUGH THE + * PUBLIC FUNCTION — with nothing to re-subject it to, because the field + * walk reads `field.json` and nothing else. That is the third time a + * correct ledger flip deleted this file's coverage (#7079, the #10262 + * block above, now this), so the cure is that block's: give the walk a + * subject no verdict can move. `checkItemAgainstWarnMap` cannot be that + * subject here — it takes one ITEM, and what #11385 guards is the walk + * that finds the items. + * - `#19276`: a missing or corrupt per-type ledger must produce one loud + * report. That fault is born inside `loadWarnMap`, so a seam accepting + * ready-made warn maps would bypass the code under test. A directory is + * what a ledger file can be missing FROM. * - * The cost is honest and bounded, exactly as for the #10262 block: assertions - * made through this seam say nothing about what the SHIPPED ledgers classify — - * that stays the job of every ledger-driven assertion in the test file. + * Both are driven the same way: copy the shipped ledger directory, change ONE + * file in the copy, run the real rule against it. The cost is honest and + * bounded, exactly as for the #10262 block: assertions made through this seam + * say nothing about what the SHIPPED ledgers classify — that stays the job of + * every ledger-driven assertion in the test file. */ export function lintLivenessPropertiesFromLedgerDir(dir: string, stack: AnyRec): LivenessLintFinding[] { const maps = new Map(); + const faults = new Map(); const warnMapOf = (type: string): WarnMap => { const cached = maps.get(type); if (cached) return cached; - const map = loadWarnMap(dir, type); + const { map, fault } = loadWarnMap(dir, type); maps.set(type, map); + if (fault) faults.set(type, fault); return map; }; - return walkStack(stack, warnMapOf); + const findings = walkStack(stack, warnMapOf); + // Faults first. They state why the rest of the list may be short, so a reader + // who stops after one line has read the load-bearing one. + return [...ledgerFaultFindings(faults), ...findings]; } /** @@ -605,6 +701,11 @@ export function lintLivenessPropertiesFromLedgerDir(dir: string, stack: AnyRec): * 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. + * + * One finding it raises is not about the metadata at all: if a per-type ledger + * is missing or unparseable, that type's warnings are all switched off, and + * `liveness-ledger-unreadable` says so once (`#19276`) instead of leaving the + * silence to look like a clean bill of health. */ export function lintLivenessProperties(stack: AnyRec): LivenessLintFinding[] { const dir = resolveLivenessDir(); diff --git a/packages/lint/src/validate-retired-permission-residue.ts b/packages/lint/src/validate-retired-permission-residue.ts index 7e87df2ffa2..8708494b0fa 100644 --- a/packages/lint/src/validate-retired-permission-residue.ts +++ b/packages/lint/src/validate-retired-permission-residue.ts @@ -53,8 +53,10 @@ * parse-time one the same author sees through the other door, so the hint is * resolved from `ObjectPermissionSchema`'s own shape at call time. An * unresolvable prescription yields NO finding rather than a hint this module - * invented — the same posture `lintLivenessProperties` takes to an unreadable - * ledger, and the reason this module's test carries an anti-vacuity guard. + * invented — the same refusal to invent that `lintLivenessProperties` applies + * to an unreadable ledger (which since #19276 it also REPORTS once, rather than + * warning from a map it never read), and the reason this module's test carries + * an anti-vacuity guard. */ import { ObjectPermissionSchema } from '@objectstack/spec/security'; From 61caacfcbe8f5dc305a965884fd367dda7f88237 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 01:36:39 +0000 Subject: [PATCH 3/5] chore(changeset): minor for the liveness ledger-unreadable finding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The published half of this branch is the new `LIVENESS_LEDGER_UNREADABLE` rule id and the finding `lintLivenessProperties` raises with it. The walk seam in the commit before it publishes nothing — module exports only, re-exported by no barrel — so one changeset covers the pair. Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude --- .changeset/19276-liveness-ledger-unreadable.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .changeset/19276-liveness-ledger-unreadable.md diff --git a/.changeset/19276-liveness-ledger-unreadable.md b/.changeset/19276-liveness-ledger-unreadable.md new file mode 100644 index 00000000000..e7069698746 --- /dev/null +++ b/.changeset/19276-liveness-ledger-unreadable.md @@ -0,0 +1,17 @@ +--- +'@objectstack/lint': minor +--- + +`lintLivenessProperties` now reports a liveness ledger it could not read, instead of going silent. + +`loadWarnMap` returned the same empty map for two different facts: "this metadata type's ledger classifies nothing as warn-worthy" and "there is no ledger". A missing `.json` and a file whose JSON is broken both returned an empty map with no log, no throw and no other signal, so losing or corrupting ONE file under the `liveness/` directory `@objectstack/spec` ships switched every author warning for that type off in silence — indistinguishable from that type simply having no warnings. + +The contrast that makes it a defect rather than a design sits one frame up: the DIRECTORY-level failure is loud by construction (the rule returns `[]` and everything depending on it goes red). Loud by directory, silent by file. + +What changes for consumers: + +- A new rule id, `LIVENESS_LEDGER_UNREADABLE` (`'liveness-ledger-unreadable'`), exported from the package root beside the four verdict ids. It is not a fifth verdict: the other four grade a property the ledger DID classify, this one says the classification never arrived, so a finding carrying it means no other finding about that metadata type can be trusted. Compare `f.rule` against the constant, and `suppressWarnings` accepts the slug like any other. +- `lintLivenessProperties` raises exactly one such finding per unreadable type, per run — never one per authored item — ahead of the walk's own findings, and keeps walking every type whose ledger IS readable. On an intact installation nothing changes: no ledger is missing, so no finding is added. +- A ledger that parses but is not a ledger (a bare `null`, an array, a scalar, or a document with no `props` record) is the same reported fault. Reading `.props` off a parsed `null` used to be a `TypeError` — a throw from a rule whose contract is that it never throws, through the one input an author cannot influence. + +`authorWarnedProperties` is unchanged and still answers the empty set for a ledger it cannot read: a decision procedure returning a set has no way to report a failed read. `os lint` runs both halves in one pass, so the run now states it once rather than never. From f2587e1379f887840c324ab6054fde96589829ca Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 02:43:15 +0000 Subject: [PATCH 4/5] docs(lint): correct the changeset's suppression claim, and scope two comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contract review on PR #19480 came back FAIL on one ground, re-measured first-hand here before changing anything rather than taken on trust. REQUIRED. The changeset promised a suppression path that does not exist: "`suppressWarnings` accepts the slug like any other". Measured in this tree: `suppressWarnings` is declared once, on the dashboard WIDGET (`packages/spec/src/ui/dashboard.zod.ts:1081`, "Build diagnostic rule ids suppressed on this widget"); its only non-test consumer is `validate-widget-bindings.ts:751`, reading `w.suppressWarnings` off a widget; `lint-liveness-properties.ts` has 0 hits against a control of 12 matching lines in `validate-widget-bindings.ts`; and the CLI has no per-rule suppression at all — `packages/cli/src/utils/i18n-extract.ts` states it in-tree at line 1054 ("the CLI has no per-rule suppression, only `--skip-i18n`"), while `commands/lint.ts` carries one `suppress` hit, a comment about stdout, against a control of 41 lines mentioning `rule`. This finding's subject is a ledger rather than an authored item, so there is no surface to carry the key even if one existed. The text now says that and points at the remedy its own hint names. The wording follows the house shape already used for the same fact in `validate-chart-bindings.ts` and in this package's shipped CHANGELOG. Three advisory corrections ride along, all prose: - The changeset called `authorWarnedProperties` "unchanged" for a broken ledger. Lenient in one input: at base `const props = ledger.props || {}` sat OUTSIDE the try, so a document parsing to `null` threw a TypeError out of it; it now returns the empty set like the other two legs. - The seam docblock's "the built `.d.ts` surface is unchanged" is true of the two seam symbols it is scoped to and false of the change as a whole, which adds one published rule id. Scoped, and the addition named. - The `suppressWarnings` half of the house phrase in this change's own `src/index.ts` comment. The pre-existing instance on `PERMISSION_RETIRED_LIFECYCLE_RESIDUE` is left alone: this change does not make it false, and it belongs to another rule. Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude --- .changeset/19276-liveness-ledger-unreadable.md | 4 ++-- packages/lint/src/index.ts | 11 ++++++++--- packages/lint/src/lint-liveness-properties.ts | 5 ++++- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/.changeset/19276-liveness-ledger-unreadable.md b/.changeset/19276-liveness-ledger-unreadable.md index e7069698746..81450073ad3 100644 --- a/.changeset/19276-liveness-ledger-unreadable.md +++ b/.changeset/19276-liveness-ledger-unreadable.md @@ -10,8 +10,8 @@ The contrast that makes it a defect rather than a design sits one frame up: the What changes for consumers: -- A new rule id, `LIVENESS_LEDGER_UNREADABLE` (`'liveness-ledger-unreadable'`), exported from the package root beside the four verdict ids. It is not a fifth verdict: the other four grade a property the ledger DID classify, this one says the classification never arrived, so a finding carrying it means no other finding about that metadata type can be trusted. Compare `f.rule` against the constant, and `suppressWarnings` accepts the slug like any other. +- A new rule id, `LIVENESS_LEDGER_UNREADABLE` (`'liveness-ledger-unreadable'`), exported from the package root beside the four verdict ids. It is not a fifth verdict: the other four grade a property the ledger DID classify, this one says the classification never arrived, so a finding carrying it means no other finding about that metadata type can be trusted. Compare `f.rule` against the constant rather than retyping the slug. It cannot be silenced per finding: the CLI has no per-rule suppression, and `suppressWarnings` is a dashboard-widget key (`spec/src/ui/dashboard.zod.ts`) while this finding's subject is a ledger rather than an authored item, so there is nothing to carry it. The remedy is the one the finding's own hint names — repair or reinstall `@objectstack/spec`. - `lintLivenessProperties` raises exactly one such finding per unreadable type, per run — never one per authored item — ahead of the walk's own findings, and keeps walking every type whose ledger IS readable. On an intact installation nothing changes: no ledger is missing, so no finding is added. - A ledger that parses but is not a ledger (a bare `null`, an array, a scalar, or a document with no `props` record) is the same reported fault. Reading `.props` off a parsed `null` used to be a `TypeError` — a throw from a rule whose contract is that it never throws, through the one input an author cannot influence. -`authorWarnedProperties` is unchanged and still answers the empty set for a ledger it cannot read: a decision procedure returning a set has no way to report a failed read. `os lint` runs both halves in one pass, so the run now states it once rather than never. +`authorWarnedProperties` still answers the empty set for a ledger it cannot read — a decision procedure returning a set has no way to report a failed read — and that is unchanged for a missing file and for broken JSON. One input does move: a ledger document that parses to `null` used to make it THROW, and it now returns the empty set like the other two. `os lint` runs both halves in one pass, so the run states the fault once rather than never. diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index 26bc355b668..e0711f2085f 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -853,9 +853,14 @@ export { // #19276 — not a verdict: the rule reporting that a per-type ledger could not // be read at all, so every author warning for that type is switched off. // Published for the same reason as the four above — `f.rule` is what `--json` - // consumers and `suppressWarnings` compare against — and load-bearing here, - // because this is the one finding whose presence means the OTHER four cannot - // be trusted for that type. + // consumers compare against — and load-bearing here, because this is the one + // finding whose presence means the OTHER four cannot be trusted for that + // type. ⛔ Not suppressible per finding, and the hint says so rather than + // naming a key that does not exist: the CLI has no per-rule suppression, and + // `suppressWarnings` is declared on the dashboard WIDGET only + // (`spec/src/ui/dashboard.zod.ts`) while this finding's subject is a ledger + // — the same shape `validate-chart-bindings.ts` states for its three + // surfaces. LIVENESS_LEDGER_UNREADABLE, } from './lint-liveness-properties.js'; diff --git a/packages/lint/src/lint-liveness-properties.ts b/packages/lint/src/lint-liveness-properties.ts index da8cf30bc8f..d98b3359daa 100644 --- a/packages/lint/src/lint-liveness-properties.ts +++ b/packages/lint/src/lint-liveness-properties.ts @@ -645,7 +645,10 @@ function ledgerFaultFindings(faults: ReadonlyMap): Liveness * `resolveLivenessDir`, and this package's `exports` map publishes exactly * two subpaths (`.` → `dist/index.js`, `./runtime` → `dist/runtime.js`, both * bundled by tsup from those two entries), so no consumer can reach either - * symbol and the built `.d.ts` surface is unchanged. ──────────────────── + * symbol and neither appears in the built `.d.ts`. That is scoped to these + * two symbols on purpose: this change DOES add one published name, the + * `LIVENESS_LEDGER_UNREADABLE` rule id, which the barrel re-exports + * deliberately (#5648 — a rule id no barrel carries is unreachable). * * The whole rule, against a ledger directory the CALLER supplies. * From dc126ffe81af4dc595664cadc77746922daa3a75 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 03:22:07 +0000 Subject: [PATCH 5/5] docs(lint): drop the five dangling citations this branch added, keeping every pointer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Lint & Repo Gates` went red on step 180, `Issue citations this change adds resolve on the board`. Reproduced locally with the gate's own second invocation — the half that reads the board and that a local `pnpm check:issue-citations` alone does not perform: citations judged: 23 across 3 file(s) 20 resolves · 3 allocated-but-absent --probe-cause: 3 deleted — minted, gone from the board, and the web endpoint 404s too All three were citations THIS branch added, in the new walk-seam docblock. Two more of the same number were added in the test file. The chain-head card recorded that same 404 with a lit control and deliberately declined to invoke the number; the at-tier review flagged it and called it not a blocker, which was wrong on the mechanics — a required gate enforces it. Every one of the five named a block that lives in this file or its test, and each of those blocks still carries the number in its own header on the base. So the pointer stays and the citation goes: "the test seam below `getNested`", "the array fan-out seam above". The sentences read the same and now point at something a reader can find. ⛔ No replacement number is guessed — the gate's own refusal text forbids it ("guessing an upstream is exactly how a dangling reference becomes a wrong one") and no number was verified to be the intended one. ⛔ The pre-existing citations on the base are left exactly as found: the gate judges only what a change adds, and sweeping them would widen this PR past both its cards. The question of which number was meant stays escalated to the maintainer, where the review left it. Both halves now read exit 0: 20 citations judged, 20 resolve. Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude --- packages/lint/src/lint-liveness-properties.test.ts | 4 ++-- packages/lint/src/lint-liveness-properties.ts | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/lint/src/lint-liveness-properties.test.ts b/packages/lint/src/lint-liveness-properties.test.ts index 08e6b8c8450..a73816c6948 100644 --- a/packages/lint/src/lint-liveness-properties.test.ts +++ b/packages/lint/src/lint-liveness-properties.test.ts @@ -1376,7 +1376,7 @@ describe('authorWarnedProperties', () => { // // Both blocks below drive the REAL rule against a COPY of the shipped ledger // directory with one file changed. That is deliberately the same trade the -// #10262 block above makes and states: assertions made through this seam say +// array fan-out block above makes and states: assertions made through this seam say // nothing about what the shipped ledgers classify — every other block in this // file is still a contract test against the real ones — and in exchange no // future ledger flip can empty them. The subject is the walker and the loader, @@ -1412,7 +1412,7 @@ const writeLedger = (dir: string, type: string, body: unknown) => /** * A `field.json` whose only row is synthetic. `status: 'dead'` is explicit for - * the same reason the #10262 block says it is: `describe()` throws on a status + * the same reason the array fan-out block above says it is: `describe()` throws on a status * it does not recognise, and this fixture asserts nothing about verdicts. */ const SYNTHETIC_FIELD_LEDGER = { diff --git a/packages/lint/src/lint-liveness-properties.ts b/packages/lint/src/lint-liveness-properties.ts index d98b3359daa..59129472ae6 100644 --- a/packages/lint/src/lint-liveness-properties.ts +++ b/packages/lint/src/lint-liveness-properties.ts @@ -640,7 +640,7 @@ function ledgerFaultFindings(faults: ReadonlyMap): Liveness /** * ── Walk seam (#19268 / #19276). Package-internal: NOT part of the published - * surface, the same posture as the #10262 seam below `getNested` — exported + * surface, the same posture as the test seam below `getNested` — exported * from the MODULE only. `src/index.ts` re-exports neither this nor * `resolveLivenessDir`, and this package's `exports` map publishes exactly * two subpaths (`.` → `dist/index.js`, `./runtime` → `dist/runtime.js`, both @@ -662,8 +662,8 @@ function ledgerFaultFindings(faults: ReadonlyMap): Liveness * continue` guard #11385 was filed for became unreachable THROUGH THE * PUBLIC FUNCTION — with nothing to re-subject it to, because the field * walk reads `field.json` and nothing else. That is the third time a - * correct ledger flip deleted this file's coverage (#7079, the #10262 - * block above, now this), so the cure is that block's: give the walk a + * correct ledger flip deleted this file's coverage (#7079, the array + * fan-out seam above, now this), so the cure is that seam's: give the walk a * subject no verdict can move. `checkItemAgainstWarnMap` cannot be that * subject here — it takes one ITEM, and what #11385 guards is the walk * that finds the items. @@ -674,7 +674,7 @@ function ledgerFaultFindings(faults: ReadonlyMap): Liveness * * Both are driven the same way: copy the shipped ledger directory, change ONE * file in the copy, run the real rule against it. The cost is honest and - * bounded, exactly as for the #10262 block: assertions made through this seam + * bounded, exactly as for the array fan-out seam above: assertions made through it * say nothing about what the SHIPPED ledgers classify — that stays the job of * every ledger-driven assertion in the test file. */