From bb53d97b4f9a2e85ac29109dc017cbc21ca2b17a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 07:48:24 +0000 Subject: [PATCH] fix(spec): refuse an unrecognized liveness `status`, and make the fold preserve the walk's total MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A ledger `status` was free text. `classify()` accepted any truthy string and counted it; `foldStateCounts` then read four names and nothing else, so a row written `"status": "planed"` was classified, counted, and dropped — and `state-counts.md` published a `classified` total short by exactly that population while the gate stayed green, because the artifact computes that column as the sum of the four columns beside it and the freshness leg compares it against a re-render of the same understated fold. Disposition 1 — the data-side half of the #13041 partition. `KNOWN_STATUSES` is read from `STATUS_COLUMNS`, never restated, so the guard and the fold that drops the value cannot disagree about the four names. An unrecognized value is still COUNTED, deliberately: dropping it would keep `classified` and the `byStatus` buckets in agreement and hide the row from the arithmetic below. Disposition 2 — `reconcileStateCountTotals` binds the artifact's total to `cat.classified`, which the walk counts with its own `++` and never through `byStatus`. That is the one comparison here whose two sides are not the same measurement twice. Not an "other" column: that would change what the artifact publishes, and the defect is that the gate cannot SEE a dropped status. The generator refuses to write rather than publish an understated total. Population measured across all 31 ledgers on this commit: live 819, planned 10, dead 80, experimental 5 — 914 classified, no fifth value. Both guards start green and only a new typo can red them. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LpRNHxWZgSUgVnFT9mQQo4 --- .../scripts/liveness/build-state-counts.mts | 29 +++- .../spec/scripts/liveness/check-liveness.mts | 95 ++++++++++++- .../scripts/liveness/check-liveness.test.ts | 96 +++++++++++++ .../spec/scripts/liveness/readme-table.mts | 93 +++++++++++++ .../scripts/liveness/readme-table.test.ts | 128 ++++++++++++++++++ 5 files changed, 439 insertions(+), 2 deletions(-) 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'); + }); +});