diff --git a/packages/spec/scripts/liveness/build-state-counts.mts b/packages/spec/scripts/liveness/build-state-counts.mts index b6487b1bfa..99f7ac2bf4 100644 --- a/packages/spec/scripts/liveness/build-state-counts.mts +++ b/packages/spec/scripts/liveness/build-state-counts.mts @@ -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'; @@ -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 }>; readmeMissingRows?: string[] }; +let report: { + types?: Record; classified?: number }>; + readmeMissingRows?: string[]; +}; try { report = JSON.parse(run.stdout || ''); } catch { @@ -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); diff --git a/packages/spec/scripts/liveness/check-liveness.mts b/packages/spec/scripts/liveness/check-liveness.mts index 28bacc5bb9..aec5fa17ec 100644 --- a/packages/spec/scripts/liveness/check-liveness.mts +++ b/packages/spec/scripts/liveness/check-liveness.mts @@ -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'; @@ -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(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 @@ -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` @@ -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 }); @@ -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..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(report.types).map(([t, v]) => [t, v.byStatus])), + classified: Object.fromEntries(Object.entries(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 @@ -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 { @@ -1169,6 +1230,29 @@ if (asJson) { console.log(`\n✗ ${totalUnclassified} UNCLASSIFIED — classify in packages/spec/liveness/.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/.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}`)); @@ -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 diff --git a/packages/spec/scripts/liveness/check-liveness.test.ts b/packages/spec/scripts/liveness/check-liveness.test.ts index 55d0a660e7..c74b79ebc4 100644 --- a/packages/spec/scripts/liveness/check-liveness.test.ts +++ b/packages/spec/scripts/liveness/check-liveness.test.ts @@ -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"); + }); +}); diff --git a/packages/spec/scripts/liveness/readme-table.mts b/packages/spec/scripts/liveness/readme-table.mts index 01f99d849c..6b091ebeea 100644 --- a/packages/spec/scripts/liveness/readme-table.mts +++ b/packages/spec/scripts/liveness/readme-table.mts @@ -304,6 +304,99 @@ export function foldStateCounts( }); } +/** + * The fold's own blind spot, made ARITHMETIC (#13083). + * + * `foldStateCounts` above reads four names and nothing else, so a `byStatus` + * bucket it cannot name — a ledger row written `"status": "planed"` — is dropped + * on the floor. Every check downstream then agrees with every other, because + * they are all reading the same understated fold: `renderStateCounts` computes + * the `classified` column as the SUM OF THE FOUR COLUMNS BESIDE IT, the + * freshness leg compares those bytes against a re-render of the same fold, and + * the README agrees with that. The published total is smaller than the ledger by + * exactly the typo'd population and nothing in the gate can say so — a reading + * that cannot come back wrong because the thing it reads is invisible to it. + * + * The fix is a SECOND, INDEPENDENT source for that number. `cat.classified` is + * counted by its own `++` in the walk, one per classified property, never + * through the `byStatus` map — so binding the artifact's total to it is the one + * comparison in this file whose two sides cannot be the same measurement twice. + * + * Deliberately NOT an "other" column. That would change what the artifact + * PUBLISHES — a fifth column, new bytes, a re-render of every row — and the + * defect here is that the gate cannot SEE a dropped status, not that the table + * should carry one. This leg leaves `renderStateCounts` byte-identical and adds + * a reading; the file's idiom for "a population the artifact must not hide" is a + * `reconcile*` returning named errors (see `reconcileStateCounts`, and the + * heading rule its interface states), not a wider table. + * + * Two failures reach here and only one of them is a typo: + * • a bucket outside `STATUS_COLUMNS` — the #13083 case, named row by row; + * • a status ADDED to `STATUS_COLUMNS` without a matching field on + * `StateCountsRow` — accepted by the data-side guard in check-liveness.mts + * precisely because it IS published vocabulary, and dropped here anyway. + * Nothing else in either file catches that one. + * + * A `byStatus` key that is not a governed TYPE is not this leg's population: the + * row set is reconciled against `GOVERNED` separately (#7257), and both callers + * build `governed` from the same report they build `byStatus` from, so the case + * cannot arise here without arising there first. One population per heading. + */ +export function reconcileStateCountTotals({ + governed, + byStatus, + classified, +}: { + /** The governed type list the fold renders, in its order. */ + governed: readonly string[]; + /** The gate's `types..byStatus`, exactly as the fold reads it. */ + byStatus: Readonly> | undefined>>; + /** The gate's `types..classified` — the walk's own counter, not derived from `byStatus`. */ + classified: Readonly>; +}): string[] { + const named = new Set(STATUS_COLUMNS); + const errors: string[] = []; + + for (const row of foldStateCounts(governed, byStatus)) { + const columnSum = STATUS_COLUMNS.reduce((a, c) => a + row[c], 0); + const walked = classified[row.type] ?? 0; + if (columnSum === walked) continue; + + const unnamed = Object.entries(byStatus[row.type] ?? {}).filter(([s]) => !named.has(s)); + errors.push( + `${row.type} — ${STATE_COUNTS_FILE} publishes ${columnSum} classified, the walk counted ${walked}` + + (unnamed.length + ? `; ${unnamed.map(([s, n]) => `${n} in \`${s}\``).join(', ')} — not one of ${STATUS_COLUMNS.join(' / ')}` + : '; no unnamed status accounts for the gap — the fold and the walk have come apart for another reason'), + ); + } + + return errors; +} + +/** The prescription printed under a total that does not reconcile. */ +export const STATE_COUNTS_TOTALS_GUIDANCE = [ + 'The count columns are a FOLD of four status names, and this is the arithmetic that', + 'says the fold dropped something (#13083). It is NOT a stale-artifact failure, and', + `\`${STATE_COUNTS_GEN_COMMAND}\` is not the repair: the generator folds through`, + 'exactly the same four names, so it would only re-publish the same understated total.', + '', + 'Read the buckets named above:', + '', + ` • a MISSPELLED status in a ledger — the ordinary case. Fix the value in`, + ' packages/spec/liveness/.json; the unrecognized-status failure above names', + ' the offending row. Never add the misspelling to STATUS_COLUMNS to get green.', + '', + ' • a status DELIBERATELY added to STATUS_COLUMNS — then the vocabulary grew and', + ' the fold did not. `StateCountsRow`, `foldStateCounts` and `renderStateCounts`', + ' all name the four columns by hand, and a fifth one publishes as a COLUMN, which', + ' changes what the artifact contains. That is an artifact-shape decision (#7377):', + ' make it deliberately, move all four sites together, and regenerate.', + '', + '⛔ Never satisfy this by editing the artifact. The number it publishes is not the', + 'one in dispute — the population behind it is.', +]; + /** * Render the whole artifact. The generator writes this; the gate renders it again * and compares BYTES. diff --git a/packages/spec/scripts/liveness/readme-table.test.ts b/packages/spec/scripts/liveness/readme-table.test.ts index ce41aeeea9..b9e96daceb 100644 --- a/packages/spec/scripts/liveness/readme-table.test.ts +++ b/packages/spec/scripts/liveness/readme-table.test.ts @@ -17,9 +17,12 @@ import { STATE_COUNTS_GEN_COMMAND, STATE_COUNTS_GUIDANCE, STATE_COUNTS_PATH, + STATE_COUNTS_TOTALS_GUIDANCE, + STATUS_COLUMNS, foldStateCounts, parseStateTable, reconcileReadmeTable, + reconcileStateCountTotals, reconcileStateCounts, renderStateCounts, } from './readme-table.mts'; @@ -362,3 +365,128 @@ describe('the counts prescription', () => { expect(text).toContain('Notes cell'); }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// The fold's own blind spot (#13083) +// +// Every leg above reads the README or the artifact, and a fold that dropped a +// status satisfies all of them: `renderStateCounts` computes `classified` as the +// sum of the four columns beside it, the freshness leg re-renders the same fold +// and compares bytes, and the README agrees with that. So the population these +// cases describe is invisible to every test above this line, and the real gate +// is GREEN on it — which is why the proof has to be here and in +// check-liveness.test.ts, and cannot come from `pnpm check:liveness` passing. +// ───────────────────────────────────────────────────────────────────────────── +describe('reconcileStateCountTotals — the fold must not lose a property', () => { + /** `byStatus` and `classified` as the gate reports them, always in agreement. */ + const HONEST = { + governed: ['object', 'field'], + byStatus: { object: { live: 49, planned: 1 }, field: { live: 66 } }, + classified: { object: 50, field: 66 }, + }; + + it('stays quiet when every property sits in one of the four published columns', () => { + expect(reconcileStateCountTotals(HONEST)).toEqual([]); + }); + + // A type the report does not carry is zero and zero — "measured as nothing" + // must not read as a gap, or the leg fires on every green tree and stops being + // read. Same rule `foldStateCounts` states for its own zero row. + it('stays quiet on a governed type the report does not carry at all', () => { + expect( + reconcileStateCountTotals({ governed: ['object', 'ghost'], byStatus: { object: { live: 49 } }, classified: { object: 49 } }), + ).toEqual([]); + }); + + // THE CARD'S OWN INSTANCE. One row written `"status": "planed"`: the walk + // classified 50, the four columns carry 49, and before this leg existed the + // artifact published 49 with nothing able to say otherwise. + it('FAILS when a status outside the four columns is counted by the walk', () => { + const errors = reconcileStateCountTotals({ + governed: ['object'], + byStatus: { object: { live: 49, planed: 1 } }, + classified: { object: 50 }, + }); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('object'); + expect(errors[0]).toContain('publishes 49 classified, the walk counted 50'); + expect(errors[0]).toContain('1 in `planed`'); + }); + + it('names every unnamed bucket, so one run repairs all of them', () => { + const errors = reconcileStateCountTotals({ + governed: ['object'], + byStatus: { object: { live: 49, planed: 1, experimentl: 2 } }, + classified: { object: 52 }, + }); + expect(errors[0]).toContain('1 in `planed`'); + expect(errors[0]).toContain('2 in `experimentl`'); + }); + + it('reports each offending type separately rather than one grand total', () => { + const errors = reconcileStateCountTotals({ + governed: ['object', 'field', 'api'], + byStatus: { object: { live: 1, planed: 1 }, field: { live: 66 }, api: { dead: 2, ded: 3 } }, + classified: { object: 2, field: 66, api: 5 }, + }); + expect(errors).toHaveLength(2); + expect(errors[0]).toContain('object'); + expect(errors[1]).toContain('api'); + }); + + // The OTHER way the fold loses a property, and the reason this leg is + // arithmetic rather than a spell-check on bucket names: a status added to + // STATUS_COLUMNS is published vocabulary, so the data-side guard in + // check-liveness.mts accepts it — and `StateCountsRow` still names four fields + // by hand, so the fold drops it anyway. Nothing else in either file catches + // this, and the message must not claim a typo when there is none. + it('FAILS, without blaming a typo, when the columns simply do not add up', () => { + const errors = reconcileStateCountTotals({ + governed: ['object'], + byStatus: { object: { live: 49 } }, + classified: { object: 51 }, + }); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('no unnamed status accounts for the gap'); + }); + + // The vocabulary is read from STATUS_COLUMNS, never restated: the guard and + // the fold that drops the value must not be able to disagree about the four + // names. Pinned because a second copy is exactly how the two come apart. + it('measures against the published vocabulary, not a second copy of it', () => { + expect([...STATUS_COLUMNS]).toEqual(['live', 'experimental', 'dead', 'planned']); + const errors = reconcileStateCountTotals({ + governed: ['object'], + byStatus: { object: { live: 1, planed: 1 } }, + classified: { object: 2 }, + }); + expect(errors[0]).toContain(STATUS_COLUMNS.join(' / ')); + }); +}); + +describe('the totals prescription', () => { + // The failure a reader is most likely to "fix" the wrong way: it looks like + // staleness, and regenerating re-publishes the same understated number, + // because the generator folds through the same four names. + it('says regeneration is not the repair, and names why', () => { + const text = STATE_COUNTS_TOTALS_GUIDANCE.join('\n'); + expect(text).toContain(STATE_COUNTS_GEN_COMMAND); + expect(text).toContain('is not the repair'); + expect(text).toContain('the same four names'); + }); + + it('forbids widening the vocabulary to get green, and forbids editing the artifact', () => { + const text = STATE_COUNTS_TOTALS_GUIDANCE.join('\n'); + expect(text).toContain('Never add the misspelling to STATUS_COLUMNS'); + expect(text).toContain('Never satisfy this by editing the artifact'); + }); + + // The deliberate case has to be reachable from the message, or the only + // documented reading is "somebody typo'd" and a real vocabulary change gets + // hammered into the shape that makes the error go away. + it('describes the deliberate fifth status as an artifact-shape decision', () => { + const text = STATE_COUNTS_TOTALS_GUIDANCE.join('\n'); + expect(text).toContain('artifact-shape decision'); + expect(text).toContain('StateCountsRow'); + }); +});