Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion packages/spec/scripts/liveness/build-state-counts.mts
Original file line numberDiff line numberDiff line change
Expand Up@@ -61,8 +61,10 @@ import { fileURLToPath } from 'node:url';
import {
STATE_COUNTS_FILE,
STATE_COUNTS_PATH,
STATE_COUNTS_TOTALS_GUIDANCE,
foldStateCounts,
parseStateTable,
reconcileStateCountTotals,
renderStateCounts,
} from './readme-table.mts';

Expand All@@ -85,7 +87,10 @@ const run = spawnSync(process.execPath, [tsxCli, gate, '--json'], {

// A crash is fatal; a red verdict is not. See the header — the gate is red
// precisely when this artifact needs rewriting.
let report: { types?: Record<string, { byStatus?: Record<string, number> }>; readmeMissingRows?: string[] };
let report: {
types?: Record<string, { byStatus?: Record<string, number>; classified?: number }>;
readmeMissingRows?: string[];
};
try {
report = JSON.parse(run.stdout || '');
} catch {
Expand All@@ -104,6 +109,28 @@ const rows = foldStateCounts(Object.keys(types), Object.fromEntries(
Object.entries(types).map(([t, v]) => [t, v.byStatus ?? {}]),
));

// ── refuse to publish a total the fold under-counted (#13083) ──
// The header's rule is that a RED gate is not fatal here — the gate is red
// precisely when this artifact needs rewriting. This failure is the exception,
// and it is the same exception the unparseable report above already carves out:
// there is nothing to rewrite. The fold that produced `rows` reads four status
// names and drops everything else, so writing now would publish an understated
// `classified` — and the gate's freshness leg would then compare those bytes
// against a re-render of the SAME understated fold and call it current. A stale
// artifact is the safer state; a fresh wrong one is unfalsifiable.
const totalErrors = reconcileStateCountTotals({
governed: Object.keys(types),
byStatus: Object.fromEntries(Object.entries(types).map(([t, v]) => [t, v.byStatus ?? {}])),
classified: Object.fromEntries(Object.entries(types).map(([t, v]) => [t, v.classified])),
});
if (totalErrors.length) {
console.error(`✗ refusing to write ${STATE_COUNTS_FILE} — the fold does not preserve the walk's total:\n`);
totalErrors.forEach((s) => console.error(` ${s}`));
console.error('');
STATE_COUNTS_TOTALS_GUIDANCE.forEach((line) => console.error(line ? ` ${line}` : ''));
process.exit(1);
}

const rendered = renderStateCounts(rows);
writeFileSync(join(ledgerRoot, STATE_COUNTS_FILE), rendered);

Expand Down
95 changes: 94 additions & 1 deletion packages/spec/scripts/liveness/check-liveness.mts
Original file line numberDiff line numberDiff line change
Expand Up@@ -186,10 +186,12 @@ import {
STATE_COUNTS_FILE,
STATE_COUNTS_GUIDANCE,
STATE_COUNTS_PATH,
STATE_COUNTS_TOTALS_GUIDANCE,
STATUS_COLUMNS,
foldStateCounts,
parseStateTable,
reconcileReadmeTable,
reconcileStateCountTotals,
reconcileStateCounts,
renderStateCounts,
} from './readme-table.mts';
Expand DownExpand Up@@ -393,6 +395,36 @@ for (const s of STATUS_COLUMNS) {
}
}

// ── THE SAME PARTITION, ASKED OF THE DATA (#13083) ──
//
// The loop above holds the CODE to the published vocabulary. Nothing held the
// LEDGERS to it. `classify()` accepts any truthy string and counts it, so a row
// written `"status": "planed"` is classified (the forward pass is satisfied, no
// UNCLASSIFIED finding), counted into a `byStatus` bucket named after the typo,
// and then dropped by `foldStateCounts` — which reads four names and nothing
// else. The artifact publishes a `classified` total short by exactly the typo'd
// population, and every reconciliation in this gate compares that number against
// itself, so it stays green.
//
// After #13041 the same unvalidated string carries a second consequence: a
// status in neither evidence-scan set has its `evidence` pointer counted by the
// census and read by no check. A typo lands in neither set BY CONSTRUCTION,
// which is the defect that loop exists to prevent — reachable through the data
// instead of through the code.
//
// So the vocabulary is read from `STATUS_COLUMNS` rather than written out again:
// the guard and the fold that drops the value must not be able to disagree about
// what the four names are. That is the same reason `EVIDENCE_SCANNED_LABEL`
// below is derived from its set rather than restated.
//
// Population measured before switching this on, across all 31 ledgers on this
// commit: live 819, planned 10, dead 80, experimental 5 — 914 classified, no
// fifth value. So it starts GREEN and only a NEW typo can red it, which is the
// zero-census argument the orphan-proof and key-mention flips were switched on
// under. A check that starts at zero can be red; that is why the census came
// first.
const KNOWN_STATUSES = new Set<string>(STATUS_COLUMNS);

/**
* The scanned population, rendered for the gate's own output. Derived from the
* set rather than written out again, so the numbers and the population they
Expand DownExpand Up@@ -523,6 +555,8 @@ const report: any = {
countsArtifactErrors: [] as string[], // state-counts.md is missing, or its bytes are not what the gate measures (#7377)
countsRowSetErrors: [] as string[], // the README's row set and the artifact's disagree
countsHandEdited: [] as string[], // a count column is back in the README — a hand-maintained number in the merge path
countsTotalErrors: [] as string[], // the four columns and the walk's own `classified` disagree — the fold dropped a status (#13083)
unknownStatus: [] as string[], // a ledger `status` outside STATUS_COLUMNS — counted by the walk, dropped by the fold (#13083)
verification: null as VerificationReport | null, // `verifiedAt` ages — the re-verification worklist
producers: null as ProducerReport | null, // `producer` / `evidenceScope` — the #4837 / #4895 worklists
producerMissing: [] as string[], // a `producer` pointer into thin air — FAILS, like a rotted `evidence`
Expand DownExpand Up@@ -635,6 +669,13 @@ function classify(type: string, path: string, status: string, led: any, cat: any
cat.classified++;
cat.byStatus[status] = (cat.byStatus[status] || 0) + 1;
report.totals.byStatus[status] = (report.totals.byStatus[status] || 0) + 1;
// #13083 — an unrecognized value is still COUNTED here, deliberately. Dropping
// it would keep `cat.classified` and the `byStatus` buckets in agreement and
// hide the row from the totals reconciliation downstream, which is the very
// silence this names. It is counted, and it is reported.
if (!KNOWN_STATUSES.has(status)) {
report.unknownStatus.push(`${type}/${path} → "${status}"`);
}
// Framework-auto entries (`led === null`) have no ledger row to date-stamp.
if (led !== null) {
verificationEntries.push({ key: `${type}/${path}`, status, verifiedAt: led?.verifiedAt });
Expand DownExpand Up@@ -897,6 +938,19 @@ if (!existsSync(readmeFile)) {
report.countsHandEdited = counts.handCountErrors;
}

// ── the fold's arithmetic (#13083) ──
// Outside the README block above on purpose: the three legs there all read the
// README or the artifact, and every one of them is satisfied by a fold that
// silently dropped a status. This one reads the WALK — `types.<type>.classified`,
// counted by its own `++` and never through `byStatus` — so it is the only
// comparison here whose two sides are not the same measurement twice. It must
// therefore run even when the README is gone, which is why it is not nested.
report.countsTotalErrors = reconcileStateCountTotals({
governed: GOVERNED,
byStatus: Object.fromEntries(Object.entries<any>(report.types).map(([t, v]) => [t, v.byStatus])),
classified: Object.fromEntries(Object.entries<any>(report.types).map(([t, v]) => [t, v.classified])),
});

// ── verifiedAt: how old is each claim? ──
// Age never fails the gate — re-verification is a worklist, not a merge gate.
// A MALFORMED value does fail: it silently disables the staleness check for
Expand DownExpand Up@@ -981,7 +1035,14 @@ const failed =
report.readmeMalformedRows.length > 0 ||
report.countsArtifactErrors.length > 0 ||
report.countsRowSetErrors.length > 0 ||
report.countsHandEdited.length > 0;
report.countsHandEdited.length > 0 ||
// A ledger `status` outside the published vocabulary, and the arithmetic that
// proves the artifact under-counted because of it (#13083). Red rather than ⚠
// on the zero-census argument stated at KNOWN_STATUSES: measured across all 31
// ledgers on the commit that switched this on, every value was one of the
// four, so the gate starts green and only a NEW typo can red it.
report.unknownStatus.length > 0 ||
report.countsTotalErrors.length > 0;
if (asJson) {
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
} else {
Expand DownExpand Up@@ -1169,6 +1230,29 @@ if (asJson) {
console.log(`\n✗ ${totalUnclassified} UNCLASSIFIED — classify in packages/spec/liveness/<type>.json:`);
report.unclassified.forEach((s: string) => console.log(` ${s}`));
}
if (report.unknownStatus.length) {
console.log(
`\n✗ ${report.unknownStatus.length} ledger row(s) whose \`status\` is not one of ` +
`${STATUS_COLUMNS.join(' / ')}:`,
);
report.unknownStatus.forEach((s: string) => console.log(` ${s}`));
console.log(
'\n This is the shape UNCLASSIFIED above cannot catch, and it is worse than\n' +
' UNCLASSIFIED because it looks DONE: the row has a verdict, the forward pass is\n' +
` satisfied, the walk counts it — and then ${STATE_COUNTS_FILE} drops it, because\n` +
` the fold reads ${STATUS_COLUMNS.join(' / ')} and nothing else. The published\n` +
' total comes out short by exactly these rows, and every other check in this gate\n' +
' compares that total against itself and agrees (#13083).\n\n' +
' Since #13041 the same value costs a second check: the evidence scan reads a\n' +
' declared population, and a status in neither the scanned nor the unscanned set\n' +
" has its `evidence` pointer counted by the census and READ BY NOTHING. A typo is\n" +
' in neither set by construction.\n\n' +
' Fix the VALUE in packages/spec/liveness/<type>.json — it is almost always a\n' +
" misspelling of the verdict the author meant. ⛔ Never widen STATUS_COLUMNS to\n" +
' accept it: that vocabulary is what the generated artifact publishes as columns,\n' +
' and a fifth name there changes the artifact (see the totals failure below).',
);
}
if (report.ungoverned.length) {
console.log(`\n✗ ${report.ungoverned.length} REGISTERED metadata type(s) governed by nothing:`);
report.ungoverned.forEach((t: string) => console.log(` ${t}`));
Expand DownExpand Up@@ -1288,6 +1372,15 @@ if (asJson) {
' cell; the Notes prose is what this table is for.',
);
}
if (report.countsTotalErrors.length) {
console.log(
`\n✗ ${report.countsTotalErrors.length} governed type(s) where ${STATE_COUNTS_FILE}'s columns ` +
"do not add up to the walk's own count:",
);
report.countsTotalErrors.forEach((s: string) => console.log(` ${s}`));
console.log('');
STATE_COUNTS_TOTALS_GUIDANCE.forEach((line) => console.log(line ? ` ${line}` : ''));
}
// ── re-verification clock ──
// Annotated at the boundary: `report` is deliberately `any` (see its
// declaration), so without this every `v.*` below is `any` too — which is how
Expand Down
96 changes: 96 additions & 0 deletions packages/spec/scripts/liveness/check-liveness.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -702,3 +702,99 @@ describe('check:liveness — the manifest is inside the governed universe (#1072
expect(src).toContain("const schema = SPEC_ONLY_SCHEMAS[type] ?? getMetadataTypeSchema(type);");
});
});

// A ledger `status` was free text: any truthy string was classified and counted,
// then dropped by `foldStateCounts`, which reads four names and nothing else. The
// gate stayed GREEN over an understated total, because `state-counts.md` computes
// its `classified` column as the sum of those four columns and the freshness leg
// compares it against a re-render of the same fold — every reconciliation in the
// gate comparing that number against itself.
//
// Measured across all 31 ledgers on the commit that switched this on: live 819,
// planned 10, dead 80, experimental 5 — 914 classified, no fifth value. So the
// population is ZERO and a green `pnpm check:liveness` proves nothing about
// whether either guard can fire. `--ledger-root` is what answers that, for the
// #5623 reason every block above states: the REAL gate, a COPY with one status
// misspelled, and a real exit code.
describe('check:liveness — an unrecognized ledger `status` (#13083)', () => {
let tmp: string;

beforeAll(() => {
tmp = mkdtempSync(path.join(tmpdir(), 'os-liveness-status-'));
});
afterAll(() => rmSync(tmp, { recursive: true, force: true }));

/** Rewrite one property's `status` in a copied ledger. */
function setStatus(root: string, type: string, prop: string, status: string): void {
const file = path.join(root, `${type}.json`);
const ledger = JSON.parse(readFileSync(file, 'utf8'));
ledger.props[prop].status = status;
writeFileSync(file, `${JSON.stringify(ledger, null, 2)}\n`);
}

/** A copy of the real ledger root with `field.useGrouping` misspelled. */
function typodRoot(name: string): string {
const root = path.join(tmp, name);
cpSync(LEDGERS, root, { recursive: true });
// `field.useGrouping` is `planned` and carries no evidence, so the misspelling
// is the only thing in the copy that can move a verdict — no evidence-scan
// finding can be confused for it.
setStatus(root, 'field', 'useGrouping', 'planed');
return root;
}

// DISPOSITION 1. The row is named, with its coordinate and the offending value.
it("FAILS and names the row when a ledger `status` is misspelled", () => {
const { status, output } = runGate(typodRoot('d1-names-the-row'));
expect(status, output).toBe(1);
expect(output).toContain('whose `status` is not one of live / experimental / dead / planned');
expect(output).toContain('field/useGrouping → "planed"');
});

// The misspelled row is still COUNTED, deliberately. Dropping it would keep
// `classified` and the `byStatus` buckets in agreement and hide the row from
// the arithmetic below — silencing the second guard with the first.
it('still counts the misspelled row, under its own bucket name', () => {
const { output } = runGate(typodRoot('d1-still-counted'));
expect(output).toMatch(/^ {2}field {2,}\d+ classified \(.*\bplaned 1\b/m);
});

// DISPOSITION 2, through the real gate. The walk counted the row; the four
// columns did not; the artifact would have published the smaller number. This
// is the leg that fires even if the vocabulary itself grows — see
// readme-table.test.ts for that case, which no ledger typo can produce.
it('FAILS the totals arithmetic, because the fold cannot name that bucket', () => {
const { status, output } = runGate(typodRoot('d2-arithmetic'));
expect(status, output).toBe(1);
expect(output).toContain("do not add up to the walk's own count");
expect(output).toContain('1 in `planed`');
expect(output).toContain('is not the repair');
});

// The two are not one check reported twice: disposition 1 is the only one that
// can say WHICH row, and disposition 2 is the only one that reads a number the
// artifact actually publishes. A repair that satisfied one and not the other
// would leave the class open, so the split is pinned rather than assumed.
it('reports the two failures separately — one names the row, one names the number', () => {
const { output } = runGate(typodRoot('d1-d2-separate'));
const rowLine = output.split('\n').find((l) => l.includes('field/useGrouping → "planed"'));
const sumLine = output.split('\n').find((l) => l.includes("publishes") && l.includes('the walk counted'));
expect(rowLine, output).toBeTruthy();
expect(sumLine, output).toBeTruthy();
// The arithmetic is per TYPE — it cannot name the property, which is exactly
// why disposition 1 is not redundant with it.
expect(sumLine).not.toContain('useGrouping');
expect(sumLine).toContain('field');
});

// The quiet half, and the reason the whole thing could be switched on: the four
// real statuses are the entire population today, so an unmutated run must be
// green AND must show neither heading. "Exits 0" alone would also be satisfied
// by a guard wired to nothing.
it('stays GREEN on the real ledgers, where every status is one of the four', () => {
const { status, output } = runGate();
expect(status, output).toBe(0);
expect(output).not.toContain('whose `status` is not one of');
expect(output).not.toContain("do not add up to the walk's own count");
});
});
Loading
Loading