diff --git a/.changeset/19276-liveness-ledger-unreadable.md b/.changeset/19276-liveness-ledger-unreadable.md new file mode 100644 index 00000000000..81450073ad3 --- /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 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` 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 7827e7efaf7..e0711f2085f 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -850,6 +850,18 @@ 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 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'; 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 4bcc22ec2ca..a73816c6948 100644 --- a/packages/lint/src/lint-liveness-properties.test.ts +++ b/packages/lint/src/lint-liveness-properties.test.ts @@ -1,9 +1,15 @@ // 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, + 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. @@ -12,6 +18,13 @@ 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/#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'; /** @@ -1328,9 +1341,244 @@ 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 ─────────── +// +// 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 +// `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 +// 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, +// 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 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 = { + 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([]); + }); +}); + +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 1bb124b9061..59129472ae6 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 { @@ -58,8 +68,16 @@ 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'); @@ -70,18 +88,60 @@ 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)) { @@ -90,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). */ @@ -290,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 @@ -302,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. */ @@ -467,23 +534,20 @@ 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 +580,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 +597,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 +612,106 @@ export function lintLivenessProperties(stack: AnyRec): LivenessLintFinding[] { 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 / #19276). Package-internal: NOT part of the published + * 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 + * bundled by tsup from those two entries), so no consumer can reach either + * 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. + * + * 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 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. + * - `#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. + * + * 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 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. + */ +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, fault } = loadWarnMap(dir, type); + maps.set(type, map); + if (fault) faults.set(type, fault); + return map; + }; + 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]; +} + +/** + * 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. + * + * 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(); + if (!dir) return []; + return lintLivenessPropertiesFromLedgerDir(dir, stack); +} 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';