Skip to content

Commit edf59e3

Browse files
Trumpclaude
andauthored
Generate the lockstep package count instead of hand-typing it (#17055)
The Changesets `fixed` group in .changeset/config.json is the one true count of packages one stray `major` bump would promote in lockstep. Three prose restatements of its length (two sentences in content/docs/protocol/backward-compatibility.mdx, one header comment in scripts/publish-smoke-pack.mjs) were hand-typed and had drifted silently from 69 to 70 as the group grew, with nothing to catch it. Adds scripts/check-lockstep-package-count.mjs as the one shared mechanism for all three occurrences: it reads the true count from .changeset/config.json, verifies each occurrence's anchor resolves to exactly one match before comparing its number (reporting a reworded or duplicated anchor as ROTTED_ANCHOR rather than silently agreeing), and supports --fix (idempotent, rewrites only the digits) and --self-test. Wired into lint.yml's "Lint & Repo Gates" job as an unconditional step so it runs on every PR. Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 Co-authored-by: Claude <noreply@anthropic.com>
1 parent a886fe2 commit edf59e3

5 files changed

Lines changed: 334 additions & 3 deletions

File tree

‎.github/workflows/lint.yml‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3029,6 +3029,19 @@ jobs:
30293029
- name: Changeset-family gate self-tests
30303030
run: pnpm check:changeset-gate-self-tests
30313031

3032+
# Lockstep package-count guard (#17039). `.changeset/config.json`'s
3033+
# `fixed` array is the one true count of packages one stray `major`
3034+
# promotes; check-changeset-fixed.mjs (release/RC workflows) already
3035+
# gates that array against the real workspace. Nothing gated the THREE
3036+
# prose restatements of the array's length — two sentences in
3037+
# content/docs/protocol/backward-compatibility.mdx and one header
3038+
# comment in scripts/publish-smoke-pack.mjs — so all three drifted
3039+
# silently (69 written, 70 true) the day a package joined the group.
3040+
# Full-repo state, not diff-shaped, so — unlike the self-tests above —
3041+
# this runs its real check every time, unconditionally.
3042+
- name: Lockstep package-count guard
3043+
run: pnpm check:lockstep-package-count
3044+
30323045
# Release-notes drift guard: the platform is one version-locked train, so
30333046
# every released @objectstack/spec major must have a curated, navigable
30343047
# release page at content/docs/releases/v<major>.mdx. Catches the gap that

‎content/docs/protocol/backward-compatibility.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ Read the **Breaking?** column, not the version number: during the launch window
111111
1. **RFC (Request for Comments)** — Breaking changes are proposed as GitHub issues with the `protocol:breaking` label.
112112
2. **Deprecation** — The old behavior is deprecated in a MINOR release (see timeline above).
113113
3. **Migration Guide** — A detailed migration guide is published before the removal lands, in the release notes for the version that carries it.
114-
4. **Release** — During the launch window the breaking change ships in the next **MINOR** version, carrying a changeset entry marked `**BREAKING**`. `scripts/check-changeset-no-major.mjs` fails any pull request that declares a `major` bump, because under lockstep one `major` would promote all 69 published packages.
114+
4. **Release** — During the launch window the breaking change ships in the next **MINOR** version, carrying a changeset entry marked `**BREAKING**`. `scripts/check-changeset-no-major.mjs` fails any pull request that declares a `major` bump, because under lockstep one `major` would promote all 70 published packages.
115115

116116
---
117117

@@ -189,7 +189,7 @@ The `@objectstack/spec` package provides additional stability guarantees:
189189

190190
### Which surfaces this covers
191191

192-
**All of them.** This is not scoped to an experimental corner or a pre-release channel: all **69** packages published from this repository belong to a single Changesets `fixed` group, so they share one version number and one policy. No published surface is exempt.
192+
**All of them.** This is not scoped to an experimental corner or a pre-release channel: all **70** packages published from this repository belong to a single Changesets `fixed` group, so they share one version number and one policy. No published surface is exempt.
193193

194194
The convention is enforced rather than informal — `scripts/check-changeset-no-major.mjs` fails any pull request that introduces a `major` bump, because under lockstep a single `major` on one package would promote the entire stack.
195195

‎package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@
127127
"check:empty-changeset": "node scripts/check-empty-changeset.mjs --self-test && node scripts/check-empty-changeset.mjs",
128128
"check:adr-0087-registration": "node scripts/check-adr-0087-registration.mjs --self-test && node scripts/check-adr-0087-registration.mjs",
129129
"check:changeset-gate-self-tests": "node scripts/check-empty-changeset.mjs --self-test && node scripts/check-adr-0087-registration.mjs --self-test && node scripts/check-changeset-no-major.mjs --self-test",
130+
"check:lockstep-package-count": "node scripts/check-lockstep-package-count.mjs --self-test && node scripts/check-lockstep-package-count.mjs",
130131
"check:override-consistency": "node scripts/check-override-consistency.mjs --self-test && node scripts/check-override-consistency.mjs",
131132
"check:vendor-export-contract": "node scripts/check-vendor-export-contract.mjs --self-test && node scripts/check-vendor-export-contract.mjs",
132133
"check:vendor-export-contract-resolve": "node scripts/check-vendor-export-contract.mjs --self-test && node scripts/check-vendor-export-contract.mjs --resolve",
Lines changed: 317 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,317 @@
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();

‎scripts/publish-smoke-pack.mjs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
* a future exclusion cannot re-open the hole silently.
3434
*
3535
* Source of truth: the workspace itself. The Changesets `fixed` group in
36-
* .changeset/config.json enumerates the same 69 names, but it is a DERIVED
36+
* .changeset/config.json enumerates the same 70 names, but it is a DERIVED
3737
* declaration validated against the workspace by scripts/check-changeset-fixed.mjs
3838
* (which reddens both when a public package is missing from the group and
3939
* when a group name no longer exists) — deriving from the group would mean

0 commit comments

Comments
 (0)