From a50c016dc558492dc0ee005f61a8ee4141cbc4a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 11:15:05 +0000 Subject: [PATCH] fix(gates): move post-cut-fragile live-repo assertions onto synthetic controls (#8654, #8658) The self-test of check-changeset-no-major.mjs and the input assertions of check-adr-0087-registration.mjs each asserted properties of the LIVE repo (major-declaring stock present, pre.json present, breaking-declaring stock present) that a release cut legitimately falsifies, conflating 'the checker has not rotted' with 'the repo is mid-cycle'. Per the maintainer ruling on the two cards, every such assertion now runs on synthetic temp-repo/fixture controls that run on every invocation regardless of repo phase; assertions of repo phase are removed deliberately. The #4690 posture (unreadable input is a refusal, never a pass) and the #7005 anti-vacuity guarantee are both preserved on the synthetic controls. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01E5tUwGM3LQoqErTfkvRW7W --- scripts/check-adr-0087-registration.mjs | 191 ++++++++++++++++++++---- scripts/check-changeset-no-major.mjs | 110 +++++++++----- 2 files changed, 238 insertions(+), 63 deletions(-) diff --git a/scripts/check-adr-0087-registration.mjs b/scripts/check-adr-0087-registration.mjs index c8423d398c..630917f745 100644 --- a/scripts/check-adr-0087-registration.mjs +++ b/scripts/check-adr-0087-registration.mjs @@ -278,9 +278,16 @@ // instead. The direction is deliberate (projection is a SUBSET of source): a // new entry not yet regenerated is `check:spec-changes`'s red, not this one's, // so the two gates never double-report the same fact. -// * CONVENTION ROT -- at least one changeset in the current stock must match the -// breaking detector. If `**BREAKING**` / `major` / `feat!:` are ever reworded -// wholesale, this gate would match nothing and pass everything in silence. +// * CONVENTION ROT -- a fixed set of synthetic control texts, one per spelling +// `breakingDeclaration()` is specified to match plus shapes it must NOT +// match, is driven through the detector on every invocation. If +// `**BREAKING**` / `major` / `feat!:` are ever reworded wholesale, this gate +// would match nothing and pass everything in silence; the controls surface +// that as detector rot the moment convention and detector diverge. They +// deliberately never read the live stock: "at least one breaking changeset +// in stock" asserted repo PHASE -- false on every PR in the post-release +// window, once `version packages` has consumed the breaking population -- +// not detector health (#8658). // * VOCABULARY DRIFT (#8299) -- the categories `CATEGORIES` accepts and the ones // ADR-0087 documents must be the SAME SET, checked both ways. A category this // file accepts that the ADR never described is an exemption an author cannot @@ -1367,11 +1374,27 @@ export function mergeBase(base, head, cwd) { try { return git(['merge-base', base, head], cwd).trim() || null; } catch { return null; } } +/** + * Everything under `.changeset/` at a rev, split into "any entry at all" and + * "actual changesets", or `null` when the tree itself cannot be listed. + * + * The split is what lets `assertInputs` tell an EMPTY STOCK (zero pending + * changesets — the legitimate state of main right after a release cut, #8658) + * apart from a MISSING DIRECTORY (not even the tracked README.md/config.json + * found — unreadable input, still a #4690 refusal). + * + * @returns {{ entries: string[], changesets: string[] } | null} + */ +function changesetDirAt(rev, cwd) { + let out; + try { out = git(['ls-tree', '-r', '--name-only', rev, '--', '.changeset'], cwd); } catch { return null; } + const entries = out.split('\n').map((s) => s.trim()).filter(Boolean); + return { entries, changesets: entries.filter(isChangesetFile) }; +} + /** Every changeset path present at a rev. */ function changesetsAt(rev, cwd) { - let out; - try { out = git(['ls-tree', '-r', '--name-only', rev, '--', '.changeset'], cwd); } catch { return []; } - return out.split('\n').map((s) => s.trim()).filter((p) => p && isChangesetFile(p)); + return changesetDirAt(rev, cwd)?.changesets ?? []; } /** @@ -1406,13 +1429,24 @@ export function ledgerAt(rev, cwd) { export function assertInputs({ cwd, head }) { const problems = []; - // (1) the subject matter exists at all - const stock = changesetsAt(head, cwd); - if (stock.length === 0) { + // (1) the subject matter's DIRECTORY is readable at all. Its POPULATION is + // repo phase, not an input problem (#8658): right after a release cut, + // `version packages` has consumed the whole stock, and this gate's + // subject is the DIFF — a PR that introduces no changeset over an empty + // stock has nothing to judge and must be clean, not refused. What stays + // a refusal is the directory itself being missing or unlistable at HEAD + // (the #4690 posture, kept deliberately): `.changeset/README.md` and + // `config.json` are tracked, so a rev where `ls-tree` finds NOTHING + // under `.changeset/` is a rev this gate cannot trust — the directory + // moved, or the read failed. + const csDir = changesetDirAt(head, cwd); + if (csDir === null || csDir.entries.length === 0) { problems.push( - 'no changesets found at HEAD (`.changeset/*.md` is empty or absent).\n' + - ' This gate judges changesets; with none to read it would report success while checking\n' + - ' nothing (#4690). If the changeset directory genuinely moved, this gate moves with it.', + '`.changeset/` is missing or unlistable at HEAD — not even its tracked README.md/config.json\n' + + ' were found. This gate judges changesets; a rev where their directory cannot be read would\n' + + ' report success while checking nothing (#4690). If the changeset directory genuinely moved,\n' + + ' this gate moves with it. (An EMPTY stock is NOT this failure: zero pending changesets is\n' + + ' the legitimate state of main right after a release cut — #8658.)', ); } @@ -1478,18 +1512,52 @@ export function assertInputs({ cwd, head }) { // (4) CONVENTION ROT -- if `major` / `**BREAKING` / `feat!:` are ever reworded // wholesale, this gate matches nothing and passes everything in silence. - let breakingInStock = 0; - for (const text of showManyOrNull(head, stock, cwd).values()) { - if (breakingDeclaration(parseChangeset(text)).breaking) breakingInStock++; + // Guarded by SYNTHETIC controls, never by the live stock (#8658): "at + // least one breaking changeset in the real stock" asserted repo PHASE, + // not detector health -- a release's `version packages` consumes exactly + // that population, so the old spelling went red on every post-cut PR + // while proving nothing about this checker. These fixtures pin each + // spelling `breakingDeclaration()` is specified to match (the three + // signals its doc comment names, including the #7004 comment/quoting + // dialects), plus shapes that must NOT match, and they run on EVERY + // invocation regardless of what the release train last did to + // `.changeset/`. A wholesale rewording of the convention now surfaces at + // the moment convention and detector are changed apart: whoever rewords + // `breakingDeclaration()` must reword these fixtures in the same edit, + // or this refusal names the divergence. + const MUST_MATCH_BREAKING = [ + ['a `major` frontmatter bump', "---\n'@objectstack/spec': major\n---\n\nan ordinary summary\n"], + ['a quoted `major` bump with a trailing YAML comment (#7004)', '---\n"@objectstack/spec": "major" # keep\n---\n\nan ordinary summary\n'], + ['a `**BREAKING**` body marker', "---\n'@objectstack/spec': minor\n---\n\na summary\n\n**BREAKING**: something changed\n"], + ['a `BREAKING CHANGE:` body line', "---\n'@objectstack/spec': minor\n---\n\na summary\n\nBREAKING CHANGE: something changed\n"], + ['a conventional-commit `!` summary', "---\n'@objectstack/spec': patch\n---\n\nfeat(spec)!: drop a key\n"], + ]; + const MUST_NOT_MATCH_BREAKING = [ + ['a plain `patch` changeset', "---\n'@objectstack/spec': patch\n---\n\nfix a typo\n"], + ['a `minor` changeset whose prose merely contains the word breaking', "---\n'@objectstack/spec': minor\n---\n\nnothing groundbreaking here\n"], + ]; + for (const [label, text] of MUST_MATCH_BREAKING) { + if (!breakingDeclaration(parseChangeset(text)).breaking) { + problems.push( + `CONVENTION ROT: ${label} no longer matches the breaking-change detector.\n` + + ' This gate fires on declared-breaking changesets; a detector that misses a documented\n' + + ' spelling is a no-op reporting success on exactly those changesets (#4690). If the\n' + + ' breaking-change convention was deliberately reworded, this gate\'s contract changed with\n' + + ' it -- update breakingDeclaration() and these control fixtures together, rather than\n' + + ' leaving a green gate that checks nothing.', + ); + } } - if (stock.length > 0 && breakingInStock === 0) { - problems.push( - `not one of ${stock.length} changeset(s) in stock matches the breaking-change detector.\n` + - ' This gate fires on declared-breaking changesets; with zero detectable it is a no-op\n' + - ' reporting success (#4690). If the breaking-change convention was deliberately reworded,\n' + - ' this gate\'s contract changed with it -- update breakingDeclaration() and this assertion\n' + - ' together, rather than leaving a green gate that checks nothing.', - ); + for (const [label, text] of MUST_NOT_MATCH_BREAKING) { + if (breakingDeclaration(parseChangeset(text)).breaking) { + problems.push( + `CONVENTION ROT (inverted): ${label} now matches the breaking-change detector.\n` + + ' An over-matching detector demands an ADR-0087 disposition of authors who declared\n' + + ' nothing breaking, which teaches them to write markers by rote -- the allow-list decay\n' + + ' this gate exists to avoid. Narrow breakingDeclaration() and these control fixtures\n' + + ' together.', + ); + } } // (5) VOCABULARY DRIFT (#8299) -- the categories this gate accepts and the ones @@ -2376,7 +2444,7 @@ function selfTest() { /** * Build a two-commit repo: base carries the ledger + a breaking changeset in - * stock (so the convention assertion is satisfied), head adds `files`. + * stock (realistic mid-cycle stock), head adds `files`. * * `baseFiles` puts extra files on the BASE commit, and a `null` in `files` * deletes one at head. Together they are how a RENAME is expressed (#7045): @@ -2405,7 +2473,9 @@ function selfTest() { w(LEDGER_SOURCES[1], CONV(['a-conversion'])); w(SPEC_CHANGES, SPEC_CHANGES_JSON(baseIds)); w(ADR_0087, ADR_DOC()); - // stock: one declared-breaking changeset so the convention-rot assertion holds + // stock: one declared-breaking changeset -- realistic mid-cycle stock for + // the inherited-changeset cases. (No longer needed to satisfy assertInputs: + // since #8658 its convention-rot control is synthetic and phase-independent.) w('.changeset/stock-breaking.md', CS({ body: 'stock\n\n**BREAKING** something\n' })); for (const [name, p] of Object.entries(pkgs ?? { '@objectstack/spec': { dir: 'packages/spec', private: false } })) { w(`${p.dir}/package.json`, JSON.stringify({ name, version: '1.0.0', ...(p.private ? { private: true } : {}) })); @@ -2984,7 +3054,13 @@ function selfTest() { assert(probs.some((p) => /parser drift/.test(p) && /invisible-to-the-parser/.test(p)), `I1: parser rot must be RED, got: ${probs.join('|')}`); } { - // convention rot: stock exists but nothing in it reads as breaking + // FLIPPED by #8658: a stock with nothing breaking in it is the legitimate + // shape of main between a release cut and the next breaking changeset + // landing -- repo PHASE, not an input problem, so it is GREEN now. The + // detector-rot guarantee the old red carried moved onto the synthetic + // controls inside assertInputs, which run here too (and on every other + // invocation in this self-test): rot breakingDeclaration() and every one + // of these fixtures refuses, this one included. const dir = mkdtempSync(join(tmpdir(), 'adr0087-conv-')); cleanup.push(dir); const w = (rel, text) => { mkdirSync(dirname(join(dir, rel)), { recursive: true }); writeFileSync(join(dir, rel), text); }; @@ -2994,10 +3070,71 @@ function selfTest() { w(LEDGER_SOURCES[0], REG(['seen-one'])); w(LEDGER_SOURCES[1], CONV(['a-conversion'])); w(SPEC_CHANGES, SPEC_CHANGES_JSON(['seen-one'])); + w(ADR_0087, ADR_DOC()); w('.changeset/quiet.md', CS({ bumps: [['@objectstack/spec', 'patch']], body: 'nothing breaking here\n' })); git(['add', '-A'], dir); git(['commit', '-qm', 'base'], dir); const probs = assertInputs({ cwd: dir, head: 'HEAD' }); - assert(probs.some((p) => /breaking-change detector/.test(p)), `I2: convention rot must be RED, got: ${probs.join('|')}`); + assert(probs.length === 0, `I2 (#8658): a stock with no breaking changeset is repo phase, never an input problem -- got: ${probs.join('|')}`); + } + { + // I2b (#8658): the post-cut window proper -- ZERO changesets in stock, only + // the tracked README/config. `version packages` produces exactly this state + // on main, so it must be judged (and, with a diff introducing nothing, + // found clean), never refused. + const dir = mkdtempSync(join(tmpdir(), 'adr0087-postcut-')); + cleanup.push(dir); + const w = (rel, text) => { mkdirSync(dirname(join(dir, rel)), { recursive: true }); writeFileSync(join(dir, rel), text); }; + git(['init', '-q', '-b', 'main'], dir); + git(['config', 'user.email', 't@t'], dir); + git(['config', 'user.name', 't'], dir); + w(LEDGER_SOURCES[0], REG(['seen-one'])); + w(LEDGER_SOURCES[1], CONV(['a-conversion'])); + w(SPEC_CHANGES, SPEC_CHANGES_JSON(['seen-one'])); + w(ADR_0087, ADR_DOC()); + w('.changeset/README.md', '# Changesets\n\ndocumentation only\n'); + w('.changeset/config.json', '{}\n'); + git(['add', '-A'], dir); git(['commit', '-qm', 'post-cut base'], dir); + const probs = assertInputs({ cwd: dir, head: 'HEAD' }); + assert(probs.length === 0, `I2b (#8658): an EMPTY stock right after a release cut is a legal repo state -- got: ${probs.join('|')}`); + const base = git(['rev-parse', 'HEAD'], dir).trim(); + const res = scan({ cwd: dir, base, head: 'HEAD' }); + assert( + res.problems.length === 0 && res.judged.length === 0, + `I2b (#8658): ... and a diff introducing no changeset over it scans clean -- got ${JSON.stringify(res.problems)}`, + ); + } + { + // I2c: the #4690 posture SURVIVES the #8658 repair -- a rev with no + // `.changeset/` at all (not even README/config) is unreadable input, and + // unreadable input is a refusal, never a pass. + const dir = mkdtempSync(join(tmpdir(), 'adr0087-nodir-')); + cleanup.push(dir); + const w = (rel, text) => { mkdirSync(dirname(join(dir, rel)), { recursive: true }); writeFileSync(join(dir, rel), text); }; + git(['init', '-q', '-b', 'main'], dir); + git(['config', 'user.email', 't@t'], dir); + git(['config', 'user.name', 't'], dir); + w(LEDGER_SOURCES[0], REG(['seen-one'])); + w(LEDGER_SOURCES[1], CONV(['a-conversion'])); + w(SPEC_CHANGES, SPEC_CHANGES_JSON(['seen-one'])); + w(ADR_0087, ADR_DOC()); + git(['add', '-A'], dir); git(['commit', '-qm', 'no changeset dir'], dir); + const probs = assertInputs({ cwd: dir, head: 'HEAD' }); + assert( + probs.some((p) => /`\.changeset\/` is missing or unlistable/.test(p)), + `I2c: a missing .changeset directory must still be RED (#4690) -- got: ${probs.join('|')}`, + ); + } + { + // I2d: the synthetic convention-rot controls run on a repo whose stock DOES + // contain a breaking changeset too -- they are phase-independent in both + // directions, so a mid-cycle repo gains no problems from them either. + // (The rot direction itself -- a broken breakingDeclaration() making the + // controls refuse -- cannot be staged from here without mutating this + // module; it is verified by ablation: see the reverse-verification record + // on the PR that introduced the controls, #8658.) + const r = mk({ files: {} }); + const probs = assertInputs({ cwd: r.dir, head: 'HEAD' }); + assert(probs.length === 0, `I2d: the synthetic breaking-detector controls add no problems on a healthy detector -- got: ${probs.join('|')}`); } { // a missing ledger is a red, never a skip diff --git a/scripts/check-changeset-no-major.mjs b/scripts/check-changeset-no-major.mjs index 230e18b204..b7fa4b5939 100644 --- a/scripts/check-changeset-no-major.mjs +++ b/scripts/check-changeset-no-major.mjs @@ -882,41 +882,24 @@ function selfTest() { ); assert(judge({ introduced: [], pre: { mode: 'exit' } }).verdict === 'clean', 'a diff introducing no major, outside pre-mode ⇒ clean'); - // ── #7005 ITSELF, stated as one assertion against the REAL stock ────────── + // ── #7005 against a stock of majors: MOVED to the temp-repo STOCK block ─── // - // The card's acceptance criterion, driven on the actual pending directory - // rather than on a synthetic stand-in for it: with N major-declaring - // changesets really on disk and pre-mode really exited, a PR that introduces - // none of them is CLEAN. Before #7005 this exact input was `enforce` and every - // unlabelled PR in the repo went red listing all N. - // - // The control is what keeps it from being vacuous — if the stock ever stops - // containing a major, the assertion below would pass for the wrong reason, so - // the count is asserted non-zero first and named in the message. - { - const realStock = readChangesets(REPO_ROOT); - assert(realStock instanceof Map, 'reader: the real .changeset directory is reachable from this script (it is what `--list` audits)'); - const stockMajors = [...(realStock ?? new Map()).entries()].filter(([, text]) => majorPackagesIn(text).length > 0); - assert( - stockMajors.length > 0, - 'control (#7005): the real .changeset stock must actually contain major-declaring changesets, or the assertion below is green for the wrong reason', - ); - assert( - judge({ introduced: [], pre: { mode: 'exit' } }).verdict === 'clean', - `#7005: ${stockMajors.length} major-declaring changeset(s) are pending on disk and pre-mode is exited, and a PR that introduces none of them is still CLEAN — stock-scoped, this was \`enforce\` for every unlabelled PR in the repo`, - ); - // And the other direction, so the pair cannot both be satisfied by a gate - // that simply stopped enforcing: one introduced major, same stock, is red. - assert( - judge({ introduced: [{ file: '.changeset/mine.md', majors: ['@objectstack/spec'] }], pre: { mode: 'exit' } }).verdict === 'enforce', - '#7005 control: the same exited pre-mode with ONE introduced major is still `enforce` — the fix narrows the gate, it does not disarm it', - ); - const only = render(judge({ introduced: [{ file: '.changeset/mine.md', majors: ['@objectstack/spec'] }], pre: { mode: 'exit' } })); - assert( - only.stderr.includes(' .changeset/mine.md') && !only.stderr.some((l) => stockMajors.some(([name]) => l.includes(name))), - '#7005: the report names ONLY the changeset this diff introduced, never one of the pending stock files', - ); - } + // This spot used to state #7005's acceptance criterion against the REAL + // `.changeset` directory, anchored by a control requiring the real stock to + // actually contain major-declaring changesets. That anchor was an assertion + // of repo PHASE, not of checker health: a release's post-exit + // `changeset version` consumes exactly that population, so the self-test + // went red on every PR in the post-cut window while saying nothing about + // this checker (#8654). `judge()` takes only `{ introduced, pre }` — it + // never reads the stock — so the one guarantee that genuinely needed a + // major-declaring stock was the report-scope negative ("the report names + // ONLY what this diff introduced, never a pending stock file"). That + // negative now lives in the temp-repo STOCK block below, where the stock is + // synthetic, really on disk at the branch point, read by the real scan, and + // therefore present on EVERY run, whatever the release train last did. The + // repo-phase halves (majors pending NOW, pre.json present NOW) are removed + // deliberately — asserting the repo is mid-cycle was never this control's + // contract. // ── Missing input is a failure, never a pass (#4690 / #7006) ────────────── const unreadable = judge({ introduced: null, pre: { mode: 'exit' } }); @@ -934,12 +917,38 @@ function selfTest() { // stay because `--list` is now the only stock view a curator has. { const real = readChangesets(REPO_ROOT); - assert(real !== null && real.size > 0, `reader: the real .changeset directory is non-empty — got ${real === null ? 'null' : real.size} entries`); + // Reachability only, never SIZE (#8654): `.changeset/` itself is tracked + // (README.md + config.json), so it exists in every repo phase — but its + // `.md` population is exactly what a release's `version packages` + // consumes, and "non-empty" here was an assertion of repo phase that went + // red on every PR in the post-cut window. What the reader must do with a + // POPULATED directory is asserted on the synthetic one below, which this + // test populates itself and is therefore populated on every run. + assert(real instanceof Map, 'reader: the real .changeset directory is reachable from this script (it is what `--list` audits)'); assert(real !== null && !real.has('README.md'), 'reader: .changeset/README.md is documentation, never a changeset'); assert(existsSync(join(REPO_ROOT, '.changeset', 'README.md')), 'reader: control — that README really exists, so the exclusion above is exercised rather than vacuous'); assert(real !== null && [...real.keys()].every((k) => k.endsWith('.md')), 'reader: only .md files are read (pre.json and config.json are not changesets)'); + // No third state for the real pre.json (#8654). PRESENT-and-parsing (a + // pre-release window) and ABSENT (post-GA — `changeset pre exit` + + // `version packages` legitimately removes it; absent ⇒ null ⇒ no + // exemption, pinned in the temp-dir block below) are both legal terminal + // states of the repo. The one shape that must fail here is + // PRESENT-but-unparsable: `readPre` collapses it to null, silently + // dropping an exemption nobody decided to drop — the #4690 direction + // pointed at the release train. Requiring presence itself was an + // assertion of repo phase, red on every post-cut PR, and is removed. const realPre = readPre(REPO_ROOT); - assert(realPre !== null && typeof realPre === 'object', 'reader: the real .changeset/pre.json is readable and parses'); + if (existsSync(join(REPO_ROOT, '.changeset', 'pre.json'))) { + assert( + realPre !== null && typeof realPre === 'object', + 'reader: the real .changeset/pre.json is PRESENT but does not parse — readPre collapses that to "no exemption" silently, a state someone must notice (#8654)', + ); + } else { + assert( + realPre === null, + 'reader: an absent real pre.json reads as null (⇒ no exemption) — absence is a legal post-GA state, never a failure (#8654)', + ); + } } const empty = mkdtempSync(join(tmpdir(), 'changeset-no-major-')); @@ -952,6 +961,17 @@ function selfTest() { assert(readPre(empty) === null, 'reader: a malformed pre.json reads as null (⇒ no exemption), never as a partial object'); writeFileSync(join(empty, '.changeset', 'pre.json'), '{"mode":"pre","tag":"rc"}'); assert(readPre(empty)?.mode === 'pre', 'reader: control — a well-formed pre.json DOES parse, so the two nulls above are about the input, not a broken reader'); + // The populated-directory control (#8654): what "reader: the real + // .changeset directory is non-empty" used to prove, on a directory this + // test populates itself so the proof survives every repo phase. + writeFileSync(join(empty, '.changeset', 'stocked.md'), MAJOR); + writeFileSync(join(empty, '.changeset', 'README.md'), MAJOR); + const stocked = readChangesets(empty); + assert( + stocked instanceof Map && stocked.size === 1 && stocked.get('stocked.md') === MAJOR, + `reader: a changeset on disk is read back verbatim, keyed by file name, with pre.json and README.md beside it not counted — got ${stocked === null ? 'null' : [...(stocked ?? new Map()).keys()].join(', ')} (#8654: replaces the phase-dependent "real directory is non-empty" assertion)`, + ); + assert(!stocked?.has('README.md'), 'reader: README.md is excluded even when it is shaped exactly like a major-declaring changeset'); } finally { rmSync(empty, { recursive: true, force: true }); } @@ -1038,6 +1058,24 @@ function selfTest() { ); assert(judge({ introduced, pre: { mode: 'exit' } }).verdict === 'enforce', '#7005: ... and with pre-mode exited that PR is RED'); assert(judge({ introduced, pre: { mode: 'pre', tag: 'rc' } }).verdict === 'exempt', '#7005: ... and inside the RC window it is still exempt'); + + // The report-scope negative, rebuilt HERE from the real-stock block this + // file used to carry (#8654): the enforce report names ONLY the changeset + // this diff introduced, never one of the pending stock files. The control + // right below keeps it non-vacuous — the synthetic stock really declares + // majors, really sits on disk at the branch point, and the real scan just + // read past it — without asserting anything about the phase of the real + // repository. + assert( + [STOCK['.changeset/stock-1.md'], STOCK['.changeset/stock-2.md']].every((t) => majorPackagesIn(t).length > 0), + 'control (#7005/#8654): the synthetic stock must actually contain major-declaring changesets, or the report-scope negative below is green for the wrong reason', + ); + const rendered = render(judge({ introduced, pre: { mode: 'exit' } })); + assert( + rendered.stderr.includes(' .changeset/mine.md') && + !rendered.stderr.some((l) => l.includes('stock-1.md') || l.includes('stock-2.md') || l.includes('stock-3.md')), + '#7005: the report names ONLY the changeset this diff introduced, never one of the pending stock files', + ); } // Row 2: majored in place. `--diff-filter=A` alone would see nothing here.