|
| 1 | +#!/usr/bin/env node |
| 2 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 3 | + |
| 4 | +/** |
| 5 | + * check-lockstep-package-count -- holds every PROSE restatement of "how many |
| 6 | + * packages sit in the lockstep Changesets `fixed` group" to the group itself. |
| 7 | + * |
| 8 | + * node scripts/check-lockstep-package-count.mjs # verify |
| 9 | + * node scripts/check-lockstep-package-count.mjs --fix # regenerate |
| 10 | + * node scripts/check-lockstep-package-count.mjs --self-test # verify the checker itself |
| 11 | + * |
| 12 | + * ## The defect (#17039) |
| 13 | + * |
| 14 | + * `.changeset/config.json`'s `fixed` array is the one true count: Changesets |
| 15 | + * applies the highest bump found anywhere in the group to EVERY name in it, so |
| 16 | + * the array's length IS the number of packages one stray `major` would |
| 17 | + * promote. `check-changeset-fixed.mjs` already gates that array against the |
| 18 | + * real workspace on every release. Nothing gated the THREE places that repeat |
| 19 | + * its length back to a reader in prose: |
| 20 | + * |
| 21 | + * - content/docs/protocol/backward-compatibility.mdx, twice ("...would |
| 22 | + * promote all 69 published packages", "all **69** packages published |
| 23 | + * from this repository...") |
| 24 | + * - scripts/publish-smoke-pack.mjs's own header comment ("enumerates the |
| 25 | + * same 69 names") |
| 26 | + * |
| 27 | + * All three were written when the group had 69 members and went silently |
| 28 | + * wrong the day a 70th package joined it -- nothing reds when `fixed[0]` |
| 29 | + * grows. This is the second confirmed instance of the class #16919 was filed |
| 30 | + * for (content/docs/permissions/system-context.mdx's hand-maintained counts, |
| 31 | + * generated by check-system-context-census.mjs): same discipline -- generate |
| 32 | + * the fact, verify the anchor still resolves, fail loud on drift -- but |
| 33 | + * deliberately NOT the same machinery. That script's job is an open-ended |
| 34 | + * CENSUS over the whole repo (call sites it has to go find); this job's |
| 35 | + * source of truth is a one-line read |
| 36 | + * (`require('.changeset/config.json').fixed[0].length`), and the population |
| 37 | + * of sentences that repeat it is small, closed and enumerated by hand once, |
| 38 | + * in OCCURRENCES below. Building a second census-shaped script for a |
| 39 | + * one-line fact would be the same fragility in a nicer coat. |
| 40 | + * |
| 41 | + * ## Why one script for three occurrences, not three patches |
| 42 | + * |
| 43 | + * All three sentences restate the SAME fact from the SAME source. Hand-fixing |
| 44 | + * the digit in each would leave three places to remember next time the group |
| 45 | + * grows -- exactly the failure this card exists to close. OCCURRENCES is the |
| 46 | + * one shared mechanism's only extension point: a fourth prose restatement |
| 47 | + * elsewhere gets a fourth row, not a fourth script. |
| 48 | + * |
| 49 | + * ## What "check" verifies, and the failure mode #17041 named |
| 50 | + * |
| 51 | + * A gate that recomputes the truth but can never disagree with what is on |
| 52 | + * disk is not a gate. So every occurrence's ANCHOR must resolve to EXACTLY |
| 53 | + * ONE match in its file before the number it captured is compared to the |
| 54 | + * truth: zero matches (the sentence was reworded) or more than one both |
| 55 | + * report ROTTED_ANCHOR rather than being silently skipped as agreement -- |
| 56 | + * silence on a stale number is the bug this exists to fix, and silence on a |
| 57 | + * rotted anchor is the same bug one level up. |
| 58 | + * |
| 59 | + * `--fix` rewrites ONLY the digits inside a matched anchor -- never anything |
| 60 | + * else in the file -- and only for occurrences that disagree; an occurrence |
| 61 | + * already correct is left byte-identical, so `--fix` on an already-clean tree |
| 62 | + * is a no-op (checked by --self-test). The narrowness follows AGENTS.md's |
| 63 | + * `packages/spec` `--fix` precedent: regenerating what nobody proved stale |
| 64 | + * lets a real, unrelated edit hide inside a mechanical-looking diff. |
| 65 | + */ |
| 66 | + |
| 67 | +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; |
| 68 | +import * as os from 'node:os'; |
| 69 | +import { dirname, join, resolve } from 'node:path'; |
| 70 | +import { fileURLToPath } from 'node:url'; |
| 71 | + |
| 72 | +const __dirname = dirname(fileURLToPath(import.meta.url)); |
| 73 | +const repoRoot = resolve(__dirname, '..'); |
| 74 | + |
| 75 | +/** Reads the one true count: the length of the lockstep `fixed` group. */ |
| 76 | +function trueCount(root) { |
| 77 | + const configPath = resolve(root, '.changeset/config.json'); |
| 78 | + const config = JSON.parse(readFileSync(configPath, 'utf8')); |
| 79 | + if (!Array.isArray(config.fixed) || !Array.isArray(config.fixed[0])) { |
| 80 | + throw new Error(`${configPath}: "fixed" is not an array containing a group array`); |
| 81 | + } |
| 82 | + return config.fixed[0].length; |
| 83 | +} |
| 84 | + |
| 85 | +// Every prose restatement of the lockstep package count, as of #17039. Each |
| 86 | +// pattern brackets a run of digits with enough literal context on both sides |
| 87 | +// to be unambiguous; the digits are the ONLY thing --fix ever rewrites. |
| 88 | +const OCCURRENCES = [ |
| 89 | + { |
| 90 | + file: 'content/docs/protocol/backward-compatibility.mdx', |
| 91 | + label: 'Breaking Change Process, step 4', |
| 92 | + pattern: /(would promote all )(\d+)( published packages\.)/, |
| 93 | + }, |
| 94 | + { |
| 95 | + file: 'content/docs/protocol/backward-compatibility.mdx', |
| 96 | + label: '"Which surfaces this covers" section', |
| 97 | + pattern: /(all \*\*)(\d+)(\*\* packages published from this repository)/, |
| 98 | + }, |
| 99 | + { |
| 100 | + file: 'scripts/publish-smoke-pack.mjs', |
| 101 | + label: 'header comment, "Source of truth" paragraph', |
| 102 | + pattern: /(enumerates the same )(\d+)( names)/, |
| 103 | + }, |
| 104 | +]; |
| 105 | + |
| 106 | +function globalOf(pattern) { |
| 107 | + return new RegExp(pattern.source, pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g`); |
| 108 | +} |
| 109 | + |
| 110 | +function readOccurrence(root, occurrence) { |
| 111 | + const path = resolve(root, occurrence.file); |
| 112 | + const text = readFileSync(path, 'utf8'); |
| 113 | + const matches = [...text.matchAll(globalOf(occurrence.pattern))]; |
| 114 | + if (matches.length !== 1) { |
| 115 | + return { path, text, rotted: true, matchCount: matches.length }; |
| 116 | + } |
| 117 | + return { path, text, rotted: false, current: Number(matches[0][2]) }; |
| 118 | +} |
| 119 | + |
| 120 | +/** |
| 121 | + * Checks (and optionally fixes) every occurrence against the true count. |
| 122 | + * Pure with respect to `fix`: false never touches disk; `fix: true` rewrites |
| 123 | + * only the occurrences it finds stale, byte-for-byte identical to the |
| 124 | + * original elsewhere in the file. |
| 125 | + */ |
| 126 | +function run(root, { fix = false } = {}) { |
| 127 | + const truth = trueCount(root); |
| 128 | + const results = []; |
| 129 | + |
| 130 | + for (const occurrence of OCCURRENCES) { |
| 131 | + const read = readOccurrence(root, occurrence); |
| 132 | + if (read.rotted) { |
| 133 | + results.push({ ...occurrence, status: 'ROTTED_ANCHOR', matchCount: read.matchCount }); |
| 134 | + continue; |
| 135 | + } |
| 136 | + if (read.current === truth) { |
| 137 | + results.push({ ...occurrence, status: 'OK', current: read.current }); |
| 138 | + continue; |
| 139 | + } |
| 140 | + results.push({ ...occurrence, status: 'STALE', current: read.current, truth }); |
| 141 | + if (fix) { |
| 142 | + const next = read.text.replace(occurrence.pattern, `$1${truth}$3`); |
| 143 | + writeFileSync(read.path, next); |
| 144 | + } |
| 145 | + } |
| 146 | + |
| 147 | + const dirty = results.some((r) => r.status !== 'OK'); |
| 148 | + return { truth, results, dirty }; |
| 149 | +} |
| 150 | + |
| 151 | +function formatReport({ truth, results }) { |
| 152 | + const lines = [ |
| 153 | + `lockstep package count (source: .changeset/config.json fixed[0].length) = ${truth}`, |
| 154 | + ]; |
| 155 | + for (const r of results) { |
| 156 | + if (r.status === 'OK') { |
| 157 | + lines.push(` OK ${r.file} -- ${r.label} (reads ${r.current})`); |
| 158 | + } else if (r.status === 'STALE') { |
| 159 | + lines.push(` STALE ${r.file} -- ${r.label}: reads ${r.current}, truth is ${r.truth}`); |
| 160 | + } else { |
| 161 | + lines.push( |
| 162 | + ` ROTTED ${r.file} -- ${r.label}: anchor matched ${r.matchCount} time(s), expected exactly 1`, |
| 163 | + ); |
| 164 | + } |
| 165 | + } |
| 166 | + return lines.join('\n'); |
| 167 | +} |
| 168 | + |
| 169 | +// --------------------------------------------------------------------------- |
| 170 | +// --self-test: fixture-based, touches no real repo file. Builds a throwaway |
| 171 | +// root with the same relative paths OCCURRENCES names, so the real anchors |
| 172 | +// are exercised against synthetic content. |
| 173 | +// --------------------------------------------------------------------------- |
| 174 | + |
| 175 | +function selfTest() { |
| 176 | + const write = writeFileSync; |
| 177 | + |
| 178 | + const failures = []; |
| 179 | + const t = (name, ok) => { |
| 180 | + if (!ok) failures.push(name); |
| 181 | + }; |
| 182 | + |
| 183 | + const tmp = mkdtempSync(join(os.tmpdir(), 'check-lockstep-package-count-')); |
| 184 | + try { |
| 185 | + mkdirSync(join(tmp, 'content/docs/protocol'), { recursive: true }); |
| 186 | + mkdirSync(join(tmp, 'scripts'), { recursive: true }); |
| 187 | + |
| 188 | + const configOf = (n) => |
| 189 | + JSON.stringify({ fixed: [Array.from({ length: n }, (_, i) => `@objectstack/pkg-${i}`)] }); |
| 190 | + |
| 191 | + const docOf = (n) => |
| 192 | + [ |
| 193 | + '4. **Release** -- ... because under lockstep one `major` would promote all ' + |
| 194 | + `${n} published packages.`, |
| 195 | + '', |
| 196 | + `**All of them.** ... all **${n}** packages published from this repository ...`, |
| 197 | + ].join('\n'); |
| 198 | + |
| 199 | + const scriptCommentOf = (n) => ` * .changeset/config.json enumerates the same ${n} names, but ...`; |
| 200 | + |
| 201 | + const writeFixture = (n) => { |
| 202 | + write(join(tmp, '.changeset', 'config.json'), configOf(n)); |
| 203 | + }; |
| 204 | + mkdirSync(join(tmp, '.changeset'), { recursive: true }); |
| 205 | + |
| 206 | + // Case 1: everything stale (69 written everywhere, truth is 70) -- check |
| 207 | + // must red and NAME both numbers, never silently pass. |
| 208 | + writeFixture(70); |
| 209 | + write(join(tmp, 'content/docs/protocol/backward-compatibility.mdx'), docOf(69)); |
| 210 | + write(join(tmp, 'scripts/publish-smoke-pack.mjs'), scriptCommentOf(69)); |
| 211 | + |
| 212 | + const before = run(tmp, { fix: false }); |
| 213 | + t('stale fixture: truth read as 70', before.truth === 70); |
| 214 | + t('stale fixture: reports dirty', before.dirty === true); |
| 215 | + t( |
| 216 | + 'stale fixture: every occurrence reported STALE with both numbers named', |
| 217 | + before.results.every((r) => r.status === 'STALE' && r.current === 69 && r.truth === 70), |
| 218 | + ); |
| 219 | + t( |
| 220 | + 'stale fixture: check-only (no fix) touched no file', |
| 221 | + readFileSync(join(tmp, 'content/docs/protocol/backward-compatibility.mdx'), 'utf8') === |
| 222 | + docOf(69), |
| 223 | + ); |
| 224 | + |
| 225 | + // Case 2: --fix converges, and a second run is idempotent (byte-identical). |
| 226 | + const fixed = run(tmp, { fix: true }); |
| 227 | + t('fix run: no longer dirty after fix', fixed.dirty === false || run(tmp, { fix: false }).dirty === false); |
| 228 | + const afterFirstFix = { |
| 229 | + doc: readFileSync(join(tmp, 'content/docs/protocol/backward-compatibility.mdx'), 'utf8'), |
| 230 | + script: readFileSync(join(tmp, 'scripts/publish-smoke-pack.mjs'), 'utf8'), |
| 231 | + }; |
| 232 | + t('fix run: doc now reads the true count', afterFirstFix.doc === docOf(70)); |
| 233 | + t('fix run: script comment now reads the true count', afterFirstFix.script === scriptCommentOf(70)); |
| 234 | + |
| 235 | + run(tmp, { fix: true }); // second fix pass |
| 236 | + const afterSecondFix = { |
| 237 | + doc: readFileSync(join(tmp, 'content/docs/protocol/backward-compatibility.mdx'), 'utf8'), |
| 238 | + script: readFileSync(join(tmp, 'scripts/publish-smoke-pack.mjs'), 'utf8'), |
| 239 | + }; |
| 240 | + t('idempotence: doc byte-identical after a second --fix pass', afterSecondFix.doc === afterFirstFix.doc); |
| 241 | + t( |
| 242 | + 'idempotence: script comment byte-identical after a second --fix pass', |
| 243 | + afterSecondFix.script === afterFirstFix.script, |
| 244 | + ); |
| 245 | + |
| 246 | + // Case 3: a reworded sentence rots the anchor -- must report ROTTED, never OK. |
| 247 | + write( |
| 248 | + join(tmp, 'content/docs/protocol/backward-compatibility.mdx'), |
| 249 | + 'This sentence no longer mentions any package count at all.', |
| 250 | + ); |
| 251 | + const rotted = run(tmp, { fix: false }); |
| 252 | + const rottedRows = rotted.results.filter( |
| 253 | + (r) => r.file === 'content/docs/protocol/backward-compatibility.mdx', |
| 254 | + ); |
| 255 | + t('rotted anchor: reported as ROTTED_ANCHOR, not OK', rottedRows.every((r) => r.status === 'ROTTED_ANCHOR')); |
| 256 | + t('rotted anchor: reports dirty (never a silent pass)', rotted.dirty === true); |
| 257 | + |
| 258 | + // Case 4: a duplicated anchor (two matches) is ALSO rotted, not "pick the first". |
| 259 | + write( |
| 260 | + join(tmp, 'content/docs/protocol/backward-compatibility.mdx'), |
| 261 | + `${docOf(70)}\n${docOf(70)}`, |
| 262 | + ); |
| 263 | + const duped = run(tmp, { fix: false }); |
| 264 | + const dupedRows = duped.results.filter( |
| 265 | + (r) => r.file === 'content/docs/protocol/backward-compatibility.mdx', |
| 266 | + ); |
| 267 | + t( |
| 268 | + 'duplicated anchor: reported as ROTTED_ANCHOR with matchCount 2', |
| 269 | + dupedRows.every((r) => r.status === 'ROTTED_ANCHOR' && r.matchCount === 2), |
| 270 | + ); |
| 271 | + } finally { |
| 272 | + rmSync(tmp, { recursive: true, force: true }); |
| 273 | + } |
| 274 | + |
| 275 | + if (failures.length > 0) { |
| 276 | + console.error(`✗ check:lockstep-package-count --self-test -- ${failures.length} failure(s):`); |
| 277 | + for (const f of failures) console.error(` - ${f}`); |
| 278 | + process.exit(1); |
| 279 | + } |
| 280 | + console.log(`✓ check:lockstep-package-count --self-test -- all cases passed.`); |
| 281 | +} |
| 282 | + |
| 283 | +function main() { |
| 284 | + const args = process.argv.slice(2); |
| 285 | + |
| 286 | + if (args.includes('--self-test')) { |
| 287 | + selfTest(); |
| 288 | + return; |
| 289 | + } |
| 290 | + |
| 291 | + const fix = args.includes('--fix'); |
| 292 | + const outcome = run(repoRoot, { fix }); |
| 293 | + console.log(formatReport(outcome)); |
| 294 | + |
| 295 | + if (fix) { |
| 296 | + const after = run(repoRoot, { fix: false }); |
| 297 | + if (after.dirty) { |
| 298 | + console.error( |
| 299 | + `\n✗ check:lockstep-package-count --fix did not converge -- rerun and inspect ROTTED_ANCHOR rows by hand.`, |
| 300 | + ); |
| 301 | + process.exit(1); |
| 302 | + } |
| 303 | + console.log(`\n✓ check:lockstep-package-count --fix -- all occurrences now read ${outcome.truth}.`); |
| 304 | + return; |
| 305 | + } |
| 306 | + |
| 307 | + if (outcome.dirty) { |
| 308 | + const staleCount = outcome.results.filter((r) => r.status !== 'OK').length; |
| 309 | + console.error( |
| 310 | + `\n✗ check:lockstep-package-count -- ${staleCount} occurrence(s) disagree with the true count (${outcome.truth}). Run \`node scripts/check-lockstep-package-count.mjs --fix\`.`, |
| 311 | + ); |
| 312 | + process.exit(1); |
| 313 | + } |
| 314 | + console.log(`\n✓ check:lockstep-package-count -- all ${OCCURRENCES.length} occurrence(s) agree with the true count (${outcome.truth}).`); |
| 315 | +} |
| 316 | + |
| 317 | +main(); |
0 commit comments