|
| 1 | +#!/usr/bin/env tsx |
| 2 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 3 | + |
| 4 | +/** |
| 5 | + * Writes `packages/spec/liveness/state-counts.md` — every number the liveness |
| 6 | + * ledger's "Current state" table used to publish by hand (#7377). |
| 7 | + * |
| 8 | + * The table's Notes prose merged cleanly for a year; its NUMBERS drifted from the |
| 9 | + * gate on 9 of 30 rows and nothing could see it, because the count columns were |
| 10 | + * hand-maintained and hand-maintained counts merge in the one way that hides. Two |
| 11 | + * PRs each move a different row by their own correct delta, the rows do not |
| 12 | + * overlap, git composes them without complaint, and the table publishes a state |
| 13 | + * nobody measured. #5107 met the identical shape in the strictness ledger and |
| 14 | + * counted seven cases in a single day; its resolution — the numbers become a |
| 15 | + * generated artifact carrying `merge=os-regen`, the prose stays hand-written — is |
| 16 | + * the one adopted here. |
| 17 | + * |
| 18 | + * ## Where the numbers come from, and why this script does not compute them |
| 19 | + * |
| 20 | + * From `check-liveness.mts --json`, run as a child process. That is the counting |
| 21 | + * method the README has declared since #4488 ("the gate's own report"), and it is |
| 22 | + * deliberately not re-implemented here: a second walker would be a second |
| 23 | + * definition of "what is classified", and when two definitions disagree the one |
| 24 | + * that wins is whichever the artifact happens to be rendered from. The gate is |
| 25 | + * what CI enforces, so the gate is what gets published. |
| 26 | + * |
| 27 | + * Its exit code is ignored ON PURPOSE. The gate exits 1 while this very artifact |
| 28 | + * is stale — which is the state a regeneration is run FROM — so honouring it would |
| 29 | + * make the fix unreachable from the failure. The JSON is emitted before the |
| 30 | + * verdict, so a non-zero exit still carries a complete report. What is never |
| 31 | + * ignored is a crash: no parseable JSON means no write, because an artifact |
| 32 | + * written from a half-measurement is worse than a stale one. |
| 33 | + * |
| 34 | + * ## The skeleton rows |
| 35 | + * |
| 36 | + * #7257 gave the old python snippet one behaviour a plain regenerate does not |
| 37 | + * have: it read the README back and printed a SKELETON row for any governed type |
| 38 | + * that had none, so the omission was visible at regeneration time and not only at |
| 39 | + * CI time. `api` and `capability` were governed, ledgered and counted for days |
| 40 | + * with no row, because the only reader who could have noticed was a human |
| 41 | + * comparing two lists by eye. That behaviour moves here, unchanged in substance: |
| 42 | + * the skeleton stops at the type name and prints a marker where the Notes cell |
| 43 | + * goes, never a guess at what belongs there. |
| 44 | + * |
| 45 | + * Regeneration is WHOLESALE — this script never patches a number in place, and |
| 46 | + * neither should you. |
| 47 | + * |
| 48 | + * Usage: |
| 49 | + * tsx build-state-counts.mts # rewrite the artifact |
| 50 | + * |
| 51 | + * Freshness is proved by `check:liveness`, which renders the same model and |
| 52 | + * compares bytes — deliberately not a second parser. |
| 53 | + */ |
| 54 | + |
| 55 | +import { spawnSync } from 'node:child_process'; |
| 56 | +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; |
| 57 | +import { createRequire } from 'node:module'; |
| 58 | +import { dirname, join, resolve } from 'node:path'; |
| 59 | +import { fileURLToPath } from 'node:url'; |
| 60 | + |
| 61 | +import { |
| 62 | + STATE_COUNTS_FILE, |
| 63 | + STATE_COUNTS_PATH, |
| 64 | + foldStateCounts, |
| 65 | + parseStateTable, |
| 66 | + renderStateCounts, |
| 67 | +} from './readme-table.mts'; |
| 68 | + |
| 69 | +const here = dirname(fileURLToPath(import.meta.url)); |
| 70 | +const specRoot = resolve(here, '../..'); // packages/spec |
| 71 | +const ledgerRoot = join(specRoot, 'liveness'); |
| 72 | +const gate = join(here, 'check-liveness.mts'); |
| 73 | + |
| 74 | +// `tsx/cli` rather than the `.bin/tsx` shim: the shim is a shell script, so it is |
| 75 | +// not spawnable by `process.execPath` and its resolution depends on which |
| 76 | +// node_modules/.bin happens to be on PATH. The module export is the same CLI and |
| 77 | +// resolves through the package graph. |
| 78 | +const tsxCli = createRequire(import.meta.url).resolve('tsx/cli'); |
| 79 | + |
| 80 | +const run = spawnSync(process.execPath, [tsxCli, gate, '--json'], { |
| 81 | + cwd: specRoot, |
| 82 | + encoding: 'utf8', |
| 83 | + maxBuffer: 64 * 1024 * 1024, |
| 84 | +}); |
| 85 | + |
| 86 | +// A crash is fatal; a red verdict is not. See the header — the gate is red |
| 87 | +// precisely when this artifact needs rewriting. |
| 88 | +let report: { types?: Record<string, { byStatus?: Record<string, number> }>; readmeMissingRows?: string[] }; |
| 89 | +try { |
| 90 | + report = JSON.parse(run.stdout || ''); |
| 91 | +} catch { |
| 92 | + console.error(`✗ ${gate} --json produced no parseable report — refusing to write ${STATE_COUNTS_FILE}.`); |
| 93 | + console.error(' Nothing is written from a half-measurement; a stale artifact is the safer state.\n'); |
| 94 | + if (run.error) console.error(` ${run.error.message}`); |
| 95 | + if (run.stderr) console.error(run.stderr); |
| 96 | + process.exit(1); |
| 97 | +} |
| 98 | + |
| 99 | +const types = report.types ?? {}; |
| 100 | +// The gate reports one entry per GOVERNED type, in GOVERNED order, so the report's |
| 101 | +// own key order IS the artifact's row order. Reading it back from the report keeps |
| 102 | +// this script from carrying a second copy of the governed list. |
| 103 | +const rows = foldStateCounts(Object.keys(types), Object.fromEntries( |
| 104 | + Object.entries(types).map(([t, v]) => [t, v.byStatus ?? {}]), |
| 105 | +)); |
| 106 | + |
| 107 | +const rendered = renderStateCounts(rows); |
| 108 | +writeFileSync(join(ledgerRoot, STATE_COUNTS_FILE), rendered); |
| 109 | + |
| 110 | +const total = rows.reduce((a, r) => a + r.live + r.experimental + r.dead + r.planned, 0); |
| 111 | +console.log(`✓ wrote ${STATE_COUNTS_PATH}`); |
| 112 | +console.log(` ${rows.length} governed type(s), ${total} classified propert(ies).`); |
| 113 | + |
| 114 | +// ── the #7257 skeleton, preserved ── |
| 115 | +// Prefer the gate's own reconciliation when the report carries it; fall back to a |
| 116 | +// direct read only if an older report shape is being parsed, so the two can never |
| 117 | +// answer differently on a report that has the field. |
| 118 | +const readmeFile = join(ledgerRoot, 'README.md'); |
| 119 | +const missing = report.readmeMissingRows |
| 120 | + ?? (existsSync(readmeFile) |
| 121 | + ? (() => { |
| 122 | + const have = new Set(parseStateTable(readFileSync(readmeFile, 'utf8')).rows.map((r) => r.type)); |
| 123 | + return rows.map((r) => r.type).filter((t) => !have.has(t)); |
| 124 | + })() |
| 125 | + : []); |
| 126 | + |
| 127 | +if (missing.length) { |
| 128 | + console.log( |
| 129 | + `\n⚠ ${missing.length} governed type(s) counted above have NO row in README.md's ` + |
| 130 | + '"Current state" table.\n' + |
| 131 | + ' Paste the skeleton(s) below into the table and write the Notes cell BY\n' + |
| 132 | + ' MEASUREMENT — the seeding PR, what it measured, which keys are dead and why.\n' + |
| 133 | + ' Never infer one from the counts or from the type\'s name.\n', |
| 134 | + ); |
| 135 | + for (const t of missing) { |
| 136 | + console.log( |
| 137 | + `| ${t} | **NO ROW YET (#7257) — write this Notes cell from the seeding PR measurement, never from a guess** |`, |
| 138 | + ); |
| 139 | + } |
| 140 | + console.log('\n check:liveness will fail until every governed type has a row.'); |
| 141 | +} |
0 commit comments