Skip to content
Merged
17 changes: 17 additions & 0 deletions .changeset/19276-liveness-ledger-unreadable.md
Original file line number Diff line number Diff line change
@@ -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 `<type>.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.
12 changes: 12 additions & 0 deletions packages/lint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
256 changes: 252 additions & 4 deletions packages/lint/src/lint-liveness-properties.test.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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';

/**
Expand Down Expand Up @@ -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([]);
});
});
Loading
Loading