Skip to content

Commit 2ae580c

Browse files
authored
Merge branch 'main' into claude/issue-8615-single-param-filter-discovery
2 parents 3a6b5ec + 247c55a commit 2ae580c

2 files changed

Lines changed: 238 additions & 63 deletions

File tree

scripts/check-adr-0087-registration.mjs

Lines changed: 164 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -278,9 +278,16 @@
278278
// instead. The direction is deliberate (projection is a SUBSET of source): a
279279
// new entry not yet regenerated is `check:spec-changes`'s red, not this one's,
280280
// so the two gates never double-report the same fact.
281-
// * CONVENTION ROT -- at least one changeset in the current stock must match the
282-
// breaking detector. If `**BREAKING**` / `major` / `feat!:` are ever reworded
283-
// wholesale, this gate would match nothing and pass everything in silence.
281+
// * CONVENTION ROT -- a fixed set of synthetic control texts, one per spelling
282+
// `breakingDeclaration()` is specified to match plus shapes it must NOT
283+
// match, is driven through the detector on every invocation. If
284+
// `**BREAKING**` / `major` / `feat!:` are ever reworded wholesale, this gate
285+
// would match nothing and pass everything in silence; the controls surface
286+
// that as detector rot the moment convention and detector diverge. They
287+
// deliberately never read the live stock: "at least one breaking changeset
288+
// in stock" asserted repo PHASE -- false on every PR in the post-release
289+
// window, once `version packages` has consumed the breaking population --
290+
// not detector health (#8658).
284291
// * VOCABULARY DRIFT (#8299) -- the categories `CATEGORIES` accepts and the ones
285292
// ADR-0087 documents must be the SAME SET, checked both ways. A category this
286293
// file accepts that the ADR never described is an exemption an author cannot
@@ -1367,11 +1374,27 @@ export function mergeBase(base, head, cwd) {
13671374
try { return git(['merge-base', base, head], cwd).trim() || null; } catch { return null; }
13681375
}
13691376

1377+
/**
1378+
* Everything under `.changeset/` at a rev, split into "any entry at all" and
1379+
* "actual changesets", or `null` when the tree itself cannot be listed.
1380+
*
1381+
* The split is what lets `assertInputs` tell an EMPTY STOCK (zero pending
1382+
* changesets — the legitimate state of main right after a release cut, #8658)
1383+
* apart from a MISSING DIRECTORY (not even the tracked README.md/config.json
1384+
* found — unreadable input, still a #4690 refusal).
1385+
*
1386+
* @returns {{ entries: string[], changesets: string[] } | null}
1387+
*/
1388+
function changesetDirAt(rev, cwd) {
1389+
let out;
1390+
try { out = git(['ls-tree', '-r', '--name-only', rev, '--', '.changeset'], cwd); } catch { return null; }
1391+
const entries = out.split('\n').map((s) => s.trim()).filter(Boolean);
1392+
return { entries, changesets: entries.filter(isChangesetFile) };
1393+
}
1394+
13701395
/** Every changeset path present at a rev. */
13711396
function changesetsAt(rev, cwd) {
1372-
let out;
1373-
try { out = git(['ls-tree', '-r', '--name-only', rev, '--', '.changeset'], cwd); } catch { return []; }
1374-
return out.split('\n').map((s) => s.trim()).filter((p) => p && isChangesetFile(p));
1397+
return changesetDirAt(rev, cwd)?.changesets ?? [];
13751398
}
13761399

13771400
/**
@@ -1406,13 +1429,24 @@ export function ledgerAt(rev, cwd) {
14061429
export function assertInputs({ cwd, head }) {
14071430
const problems = [];
14081431

1409-
// (1) the subject matter exists at all
1410-
const stock = changesetsAt(head, cwd);
1411-
if (stock.length === 0) {
1432+
// (1) the subject matter's DIRECTORY is readable at all. Its POPULATION is
1433+
// repo phase, not an input problem (#8658): right after a release cut,
1434+
// `version packages` has consumed the whole stock, and this gate's
1435+
// subject is the DIFF — a PR that introduces no changeset over an empty
1436+
// stock has nothing to judge and must be clean, not refused. What stays
1437+
// a refusal is the directory itself being missing or unlistable at HEAD
1438+
// (the #4690 posture, kept deliberately): `.changeset/README.md` and
1439+
// `config.json` are tracked, so a rev where `ls-tree` finds NOTHING
1440+
// under `.changeset/` is a rev this gate cannot trust — the directory
1441+
// moved, or the read failed.
1442+
const csDir = changesetDirAt(head, cwd);
1443+
if (csDir === null || csDir.entries.length === 0) {
14121444
problems.push(
1413-
'no changesets found at HEAD (`.changeset/*.md` is empty or absent).\n' +
1414-
' This gate judges changesets; with none to read it would report success while checking\n' +
1415-
' nothing (#4690). If the changeset directory genuinely moved, this gate moves with it.',
1445+
'`.changeset/` is missing or unlistable at HEAD — not even its tracked README.md/config.json\n' +
1446+
' were found. This gate judges changesets; a rev where their directory cannot be read would\n' +
1447+
' report success while checking nothing (#4690). If the changeset directory genuinely moved,\n' +
1448+
' this gate moves with it. (An EMPTY stock is NOT this failure: zero pending changesets is\n' +
1449+
' the legitimate state of main right after a release cut — #8658.)',
14161450
);
14171451
}
14181452

@@ -1478,18 +1512,52 @@ export function assertInputs({ cwd, head }) {
14781512

14791513
// (4) CONVENTION ROT -- if `major` / `**BREAKING` / `feat!:` are ever reworded
14801514
// wholesale, this gate matches nothing and passes everything in silence.
1481-
let breakingInStock = 0;
1482-
for (const text of showManyOrNull(head, stock, cwd).values()) {
1483-
if (breakingDeclaration(parseChangeset(text)).breaking) breakingInStock++;
1515+
// Guarded by SYNTHETIC controls, never by the live stock (#8658): "at
1516+
// least one breaking changeset in the real stock" asserted repo PHASE,
1517+
// not detector health -- a release's `version packages` consumes exactly
1518+
// that population, so the old spelling went red on every post-cut PR
1519+
// while proving nothing about this checker. These fixtures pin each
1520+
// spelling `breakingDeclaration()` is specified to match (the three
1521+
// signals its doc comment names, including the #7004 comment/quoting
1522+
// dialects), plus shapes that must NOT match, and they run on EVERY
1523+
// invocation regardless of what the release train last did to
1524+
// `.changeset/`. A wholesale rewording of the convention now surfaces at
1525+
// the moment convention and detector are changed apart: whoever rewords
1526+
// `breakingDeclaration()` must reword these fixtures in the same edit,
1527+
// or this refusal names the divergence.
1528+
const MUST_MATCH_BREAKING = [
1529+
['a `major` frontmatter bump', "---\n'@objectstack/spec': major\n---\n\nan ordinary summary\n"],
1530+
['a quoted `major` bump with a trailing YAML comment (#7004)', '---\n"@objectstack/spec": "major" # keep\n---\n\nan ordinary summary\n'],
1531+
['a `**BREAKING**` body marker', "---\n'@objectstack/spec': minor\n---\n\na summary\n\n**BREAKING**: something changed\n"],
1532+
['a `BREAKING CHANGE:` body line', "---\n'@objectstack/spec': minor\n---\n\na summary\n\nBREAKING CHANGE: something changed\n"],
1533+
['a conventional-commit `!` summary', "---\n'@objectstack/spec': patch\n---\n\nfeat(spec)!: drop a key\n"],
1534+
];
1535+
const MUST_NOT_MATCH_BREAKING = [
1536+
['a plain `patch` changeset', "---\n'@objectstack/spec': patch\n---\n\nfix a typo\n"],
1537+
['a `minor` changeset whose prose merely contains the word breaking', "---\n'@objectstack/spec': minor\n---\n\nnothing groundbreaking here\n"],
1538+
];
1539+
for (const [label, text] of MUST_MATCH_BREAKING) {
1540+
if (!breakingDeclaration(parseChangeset(text)).breaking) {
1541+
problems.push(
1542+
`CONVENTION ROT: ${label} no longer matches the breaking-change detector.\n` +
1543+
' This gate fires on declared-breaking changesets; a detector that misses a documented\n' +
1544+
' spelling is a no-op reporting success on exactly those changesets (#4690). If the\n' +
1545+
' breaking-change convention was deliberately reworded, this gate\'s contract changed with\n' +
1546+
' it -- update breakingDeclaration() and these control fixtures together, rather than\n' +
1547+
' leaving a green gate that checks nothing.',
1548+
);
1549+
}
14841550
}
1485-
if (stock.length > 0 && breakingInStock === 0) {
1486-
problems.push(
1487-
`not one of ${stock.length} changeset(s) in stock matches the breaking-change detector.\n` +
1488-
' This gate fires on declared-breaking changesets; with zero detectable it is a no-op\n' +
1489-
' reporting success (#4690). If the breaking-change convention was deliberately reworded,\n' +
1490-
' this gate\'s contract changed with it -- update breakingDeclaration() and this assertion\n' +
1491-
' together, rather than leaving a green gate that checks nothing.',
1492-
);
1551+
for (const [label, text] of MUST_NOT_MATCH_BREAKING) {
1552+
if (breakingDeclaration(parseChangeset(text)).breaking) {
1553+
problems.push(
1554+
`CONVENTION ROT (inverted): ${label} now matches the breaking-change detector.\n` +
1555+
' An over-matching detector demands an ADR-0087 disposition of authors who declared\n' +
1556+
' nothing breaking, which teaches them to write markers by rote -- the allow-list decay\n' +
1557+
' this gate exists to avoid. Narrow breakingDeclaration() and these control fixtures\n' +
1558+
' together.',
1559+
);
1560+
}
14931561
}
14941562

14951563
// (5) VOCABULARY DRIFT (#8299) -- the categories this gate accepts and the ones
@@ -2376,7 +2444,7 @@ function selfTest() {
23762444

23772445
/**
23782446
* Build a two-commit repo: base carries the ledger + a breaking changeset in
2379-
* stock (so the convention assertion is satisfied), head adds `files`.
2447+
* stock (realistic mid-cycle stock), head adds `files`.
23802448
*
23812449
* `baseFiles` puts extra files on the BASE commit, and a `null` in `files`
23822450
* deletes one at head. Together they are how a RENAME is expressed (#7045):
@@ -2405,7 +2473,9 @@ function selfTest() {
24052473
w(LEDGER_SOURCES[1], CONV(['a-conversion']));
24062474
w(SPEC_CHANGES, SPEC_CHANGES_JSON(baseIds));
24072475
w(ADR_0087, ADR_DOC());
2408-
// stock: one declared-breaking changeset so the convention-rot assertion holds
2476+
// stock: one declared-breaking changeset -- realistic mid-cycle stock for
2477+
// the inherited-changeset cases. (No longer needed to satisfy assertInputs:
2478+
// since #8658 its convention-rot control is synthetic and phase-independent.)
24092479
w('.changeset/stock-breaking.md', CS({ body: 'stock\n\n**BREAKING** something\n' }));
24102480
for (const [name, p] of Object.entries(pkgs ?? { '@objectstack/spec': { dir: 'packages/spec', private: false } })) {
24112481
w(`${p.dir}/package.json`, JSON.stringify({ name, version: '1.0.0', ...(p.private ? { private: true } : {}) }));
@@ -2984,7 +3054,13 @@ function selfTest() {
29843054
assert(probs.some((p) => /parser drift/.test(p) && /invisible-to-the-parser/.test(p)), `I1: parser rot must be RED, got: ${probs.join('|')}`);
29853055
}
29863056
{
2987-
// convention rot: stock exists but nothing in it reads as breaking
3057+
// FLIPPED by #8658: a stock with nothing breaking in it is the legitimate
3058+
// shape of main between a release cut and the next breaking changeset
3059+
// landing -- repo PHASE, not an input problem, so it is GREEN now. The
3060+
// detector-rot guarantee the old red carried moved onto the synthetic
3061+
// controls inside assertInputs, which run here too (and on every other
3062+
// invocation in this self-test): rot breakingDeclaration() and every one
3063+
// of these fixtures refuses, this one included.
29883064
const dir = mkdtempSync(join(tmpdir(), 'adr0087-conv-'));
29893065
cleanup.push(dir);
29903066
const w = (rel, text) => { mkdirSync(dirname(join(dir, rel)), { recursive: true }); writeFileSync(join(dir, rel), text); };
@@ -2994,10 +3070,71 @@ function selfTest() {
29943070
w(LEDGER_SOURCES[0], REG(['seen-one']));
29953071
w(LEDGER_SOURCES[1], CONV(['a-conversion']));
29963072
w(SPEC_CHANGES, SPEC_CHANGES_JSON(['seen-one']));
3073+
w(ADR_0087, ADR_DOC());
29973074
w('.changeset/quiet.md', CS({ bumps: [['@objectstack/spec', 'patch']], body: 'nothing breaking here\n' }));
29983075
git(['add', '-A'], dir); git(['commit', '-qm', 'base'], dir);
29993076
const probs = assertInputs({ cwd: dir, head: 'HEAD' });
3000-
assert(probs.some((p) => /breaking-change detector/.test(p)), `I2: convention rot must be RED, got: ${probs.join('|')}`);
3077+
assert(probs.length === 0, `I2 (#8658): a stock with no breaking changeset is repo phase, never an input problem -- got: ${probs.join('|')}`);
3078+
}
3079+
{
3080+
// I2b (#8658): the post-cut window proper -- ZERO changesets in stock, only
3081+
// the tracked README/config. `version packages` produces exactly this state
3082+
// on main, so it must be judged (and, with a diff introducing nothing,
3083+
// found clean), never refused.
3084+
const dir = mkdtempSync(join(tmpdir(), 'adr0087-postcut-'));
3085+
cleanup.push(dir);
3086+
const w = (rel, text) => { mkdirSync(dirname(join(dir, rel)), { recursive: true }); writeFileSync(join(dir, rel), text); };
3087+
git(['init', '-q', '-b', 'main'], dir);
3088+
git(['config', 'user.email', 't@t'], dir);
3089+
git(['config', 'user.name', 't'], dir);
3090+
w(LEDGER_SOURCES[0], REG(['seen-one']));
3091+
w(LEDGER_SOURCES[1], CONV(['a-conversion']));
3092+
w(SPEC_CHANGES, SPEC_CHANGES_JSON(['seen-one']));
3093+
w(ADR_0087, ADR_DOC());
3094+
w('.changeset/README.md', '# Changesets\n\ndocumentation only\n');
3095+
w('.changeset/config.json', '{}\n');
3096+
git(['add', '-A'], dir); git(['commit', '-qm', 'post-cut base'], dir);
3097+
const probs = assertInputs({ cwd: dir, head: 'HEAD' });
3098+
assert(probs.length === 0, `I2b (#8658): an EMPTY stock right after a release cut is a legal repo state -- got: ${probs.join('|')}`);
3099+
const base = git(['rev-parse', 'HEAD'], dir).trim();
3100+
const res = scan({ cwd: dir, base, head: 'HEAD' });
3101+
assert(
3102+
res.problems.length === 0 && res.judged.length === 0,
3103+
`I2b (#8658): ... and a diff introducing no changeset over it scans clean -- got ${JSON.stringify(res.problems)}`,
3104+
);
3105+
}
3106+
{
3107+
// I2c: the #4690 posture SURVIVES the #8658 repair -- a rev with no
3108+
// `.changeset/` at all (not even README/config) is unreadable input, and
3109+
// unreadable input is a refusal, never a pass.
3110+
const dir = mkdtempSync(join(tmpdir(), 'adr0087-nodir-'));
3111+
cleanup.push(dir);
3112+
const w = (rel, text) => { mkdirSync(dirname(join(dir, rel)), { recursive: true }); writeFileSync(join(dir, rel), text); };
3113+
git(['init', '-q', '-b', 'main'], dir);
3114+
git(['config', 'user.email', 't@t'], dir);
3115+
git(['config', 'user.name', 't'], dir);
3116+
w(LEDGER_SOURCES[0], REG(['seen-one']));
3117+
w(LEDGER_SOURCES[1], CONV(['a-conversion']));
3118+
w(SPEC_CHANGES, SPEC_CHANGES_JSON(['seen-one']));
3119+
w(ADR_0087, ADR_DOC());
3120+
git(['add', '-A'], dir); git(['commit', '-qm', 'no changeset dir'], dir);
3121+
const probs = assertInputs({ cwd: dir, head: 'HEAD' });
3122+
assert(
3123+
probs.some((p) => /`\.changeset\/` is missing or unlistable/.test(p)),
3124+
`I2c: a missing .changeset directory must still be RED (#4690) -- got: ${probs.join('|')}`,
3125+
);
3126+
}
3127+
{
3128+
// I2d: the synthetic convention-rot controls run on a repo whose stock DOES
3129+
// contain a breaking changeset too -- they are phase-independent in both
3130+
// directions, so a mid-cycle repo gains no problems from them either.
3131+
// (The rot direction itself -- a broken breakingDeclaration() making the
3132+
// controls refuse -- cannot be staged from here without mutating this
3133+
// module; it is verified by ablation: see the reverse-verification record
3134+
// on the PR that introduced the controls, #8658.)
3135+
const r = mk({ files: {} });
3136+
const probs = assertInputs({ cwd: r.dir, head: 'HEAD' });
3137+
assert(probs.length === 0, `I2d: the synthetic breaking-detector controls add no problems on a healthy detector -- got: ${probs.join('|')}`);
30013138
}
30023139
{
30033140
// a missing ledger is a red, never a skip

0 commit comments

Comments
 (0)