Skip to content

Commit 965e2dc

Browse files
hotlongclaude
andcommitted
fix(pm): stop dispatch-gates requiring a .changeset/*.md population it cannot own
The self-test's live-specimen block asserted `changesetPop.length >= 100` against the real working tree. That population is owned by the release cycle, not by this tool: a changesets version pass consumes all of it, so the control reds on the Version Packages PR — a required context, on the one PR whose merge IS the release — and then on `main` for the days it takes 100 fresh changesets to accumulate. The control was guarding something real: the two assertions under it pass vacuously over an empty population (`every` on nothing, `0 === 0`). That vacuity is now closed by construction instead of by a size claim — the corpus assertions run only where there is a corpus, and where there is not, the run says NOT MEASURED out loud, names why, and carries the tally on its verdict line. A silent skip would let the specimen rot, which is what the `>= 100` was reaching for. What a gate can require is the specimen's HOME: `.changeset/config.json`. While it is tracked, an empty population is this repo mid-cycle; when it goes, the specimen is rotted and somebody must pick a new one. That is the distinction the count could not draw. The second live-population site — the residue block's `hintReachesTree` — takes the same exit, so the red does not survive the repair at the site that was measured. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent a23603e commit 965e2dc

1 file changed

Lines changed: 159 additions & 10 deletions

File tree

scripts/pm/dispatch-gates.mjs

Lines changed: 159 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11160,6 +11160,57 @@ function selfTest() {
1116011160
console.log(` ${cond ? '✓' : '✗'} ${name}`);
1116111161
};
1116211162

11163+
// ── A subject this TREE cannot decide is not a passing case (#15255) ──────
11164+
//
11165+
// A handful of cases below read the REAL corpus rather than a fixture,
11166+
// because a fixture cannot show that a live specimen still reaches the tree
11167+
// it names. That is the right shape, and it carries one hazard a fixture does
11168+
// not: the specimen's population is a property of the tree, and a tree may
11169+
// legitimately hold none of it. `.changeset/*.md` is the measured instance —
11170+
// a changesets version pass consumes the whole population by design, so the
11171+
// Version Packages PR carries a tree the corpus assertions cannot be
11172+
// evaluated over, and `main` carries one for as long as it takes the next
11173+
// changesets to land.
11174+
//
11175+
// The control that guarded them asserted the population itself
11176+
// (`length >= 100`), which is a claim about the release cycle rather than
11177+
// about this tool, and it made a REQUIRED context red on the one PR whose
11178+
// merge IS the release. ⛔ The repair is not a quiet `if` around them either:
11179+
// a silent skip is exactly how a specimen rots unnoticed, which is the
11180+
// failure the population control was reaching for in the first place.
11181+
//
11182+
// So an undecidable subject is neither: it prints its own line, states WHY
11183+
// this tree cannot decide it in words a reader can check, and is counted
11184+
// apart from `cases` in the verdict. It is never pushed into `cases` — there
11185+
// it would be one more `✓`, indistinguishable from a case that ran.
11186+
const notMeasured = [];
11187+
const unmeasurable = (subject, why) => {
11188+
notMeasured.push([subject, why]);
11189+
console.log(` ⊘ NOT MEASURED — ${subject}`);
11190+
console.log(` ${why}`);
11191+
};
11192+
11193+
/**
11194+
* The verdict's NOT-MEASURED suffix — a pure renderer so the two properties
11195+
* that matter can be pinned on fixtures instead of on a run of this tree,
11196+
* which by construction skips nothing: it is EMPTY when nothing was skipped,
11197+
* so a fully-measured run's verdict line is byte-identical to the one this
11198+
* file printed before the tally existed; and it NAMES every skipped subject
11199+
* when there is one, so no skip can reach a reader as a bare pass count.
11200+
*/
11201+
const notMeasuredSuffix = (entries) =>
11202+
entries.length ? ` ⊘ ${entries.length} subject(s) NOT MEASURED on this tree — ${entries.map(([s]) => s).join(' · ')}.` : '';
11203+
11204+
/**
11205+
* The `.changeset/*.md` live specimen's population in a corpus, and the whole
11206+
* decision behind the block far below: with one member the corpus assertions
11207+
* are real, with none they are vacuous. Named and pure so both of its call
11208+
* sites ask the same question and so the boundary can be pinned at the sizes
11209+
* the release cycle really produces, rather than only at the one this tree
11210+
* happens to be at today.
11211+
*/
11212+
const changesetSpecimenPop = (corpus) => corpus.filter((f) => /^\.changeset\/[^/]+\.md$/.test(f));
11213+
1116311214
const wf = [
1116411215
'jobs:',
1116511216
' lint:',
@@ -13045,14 +13096,86 @@ function selfTest() {
1304513096
// `.changeset/*.md` into `.changeset/.md`, so the hint reached ZERO of 548
1304613097
// tracked changesets while reading as an ordinary literal, and the residue
1304713098
// then named a directory rename as the cause. Read from the REAL corpus: a
13048-
// fixture cannot show that the tree still holds the population the trap needs,
13049-
// and this one grows with every merged PR.
13099+
// fixture cannot show that the hint still reaches the population the trap was
13100+
// sprung on. What that corpus is NOT is something this file may require: it
13101+
// grows with every merged PR and a version pass takes all of it back (#15255,
13102+
// and the docblock under `changesetPop` below).
1305013103
const suffixCorpus = trackedFiles();
13051-
const changesetPop = suffixCorpus.filter((f) => /^\.changeset\/[^/]+\.md$/.test(f));
13052-
t('the tree really does hold a large `.changeset/*.md` population', changesetPop.length >= 100);
13053-
t('the live specimen reaches every changeset it names', changesetPop.every((f) => hintCovers('.changeset/*.md', f)));
13054-
t('and claims nothing else in the whole tree', suffixCorpus.filter((f) => hintCovers('.changeset/*.md', f)).length === changesetPop.length);
13055-
t('so it is nobody\'s dead literal any more', hintReachesTree('.changeset/*.md', suffixCorpus));
13104+
const changesetPop = changesetSpecimenPop(suffixCorpus);
13105+
// ⛔ NOT `changesetPop.length >= 100` (#15255). That control was reaching for
13106+
// something real — the two assertions under it pass VACUOUSLY over an empty
13107+
// population, `every` on nothing and `0 === 0` — but it bought the guard with
13108+
// a claim this tool has no standing to make. The size of that population is
13109+
// owned by the release cycle: a changesets version pass consumes all of it,
13110+
// so it is 1 on the Version Packages PR (measured on #11336's head
13111+
// b8573e843: `.changeset/` holds README.md and config.json and nothing else),
13112+
// it is whatever has landed since on `main` for the days after, and it is 865
13113+
// here. A required context that reds at 1 and at 3 blocks the release itself,
13114+
// and then blocks `main` behind it.
13115+
//
13116+
// What a gate CAN require is the specimen's HOME. `.changeset/config.json` is
13117+
// the changesets tool's own configuration: while it is tracked, an empty
13118+
// population is this repo mid-cycle and the specimen is merely resting; when
13119+
// it goes, the specimen has no population to come back to and somebody must
13120+
// pick a new one for this species. That distinction is the whole difference
13121+
// between "not measurable today" and "rotted", and it is the one the count
13122+
// could not draw.
13123+
//
13124+
// The vacuity the count was guarding is closed by construction instead: the
13125+
// three corpus assertions run only where there is a corpus, and where there
13126+
// is not, `unmeasurable` says so out loud rather than letting them green.
13127+
// Note the population is 1, not 0, on the real release PR — `README.md`
13128+
// survives a version pass — so a guard written at `=== 0` would have left the
13129+
// gate red on the very tree it was written for. It is written at "empty" and
13130+
// measured at 0, 1, 3 and 865.
13131+
t('the `.changeset/*.md` specimen still has a home in this tree', suffixCorpus.includes('.changeset/config.json'));
13132+
if (changesetPop.length === 0) {
13133+
unmeasurable(
13134+
'the `.changeset/*.md` live-specimen corpus assertions',
13135+
'this tree tracks no `.changeset/*.md` at all — that is what a changesets version pass produces, and the ' +
13136+
'population comes back as changesets land. The specimen still has its home (see the case above); the three ' +
13137+
'assertions that need a population are the only thing skipped, and every literal case in this block ran. ' +
13138+
"Check it yourself: git ls-files '.changeset/'",
13139+
);
13140+
} else {
13141+
t('the live specimen reaches every changeset it names', changesetPop.every((f) => hintCovers('.changeset/*.md', f)));
13142+
t('and claims nothing else in the whole tree', suffixCorpus.filter((f) => hintCovers('.changeset/*.md', f)).length === changesetPop.length);
13143+
t('so it is nobody\'s dead literal any more', hintReachesTree('.changeset/*.md', suffixCorpus));
13144+
}
13145+
// The branch above is a decision this tree can only exercise one way — it
13146+
// holds a population today and will hold one on almost every run — so the
13147+
// sizes the release cycle really produces are pinned on fixtures, at the four
13148+
// states named in the docblock. A boundary written at the wrong one is the
13149+
// defect that shipped: `>= 100` is green at 865 and red at every size a
13150+
// version pass leaves behind.
13151+
const csTree = (...names) => ['AGENTS.md', '.changeset/config.json', ...names];
13152+
t('at 865 the corpus assertions run, which is this tree and every ordinary day',
13153+
changesetSpecimenPop(csTree(...Array.from({ length: 865 }, (_, i) => `.changeset/c${i}.md`))).length === 865);
13154+
t('at 3 they still run — the state `main` is in for days after a release lands, and where `>= 100` was red',
13155+
changesetSpecimenPop(csTree('.changeset/a.md', '.changeset/b.md', '.changeset/c.md')).length === 3);
13156+
t('at 1 they still run, and 1 is what the Version Packages PR really carries — README.md survives a version pass',
13157+
changesetSpecimenPop(csTree('.changeset/README.md')).length === 1);
13158+
t('only an EMPTY population is undecidable, and that is the only state that skips',
13159+
changesetSpecimenPop(csTree()).length === 0);
13160+
// The home discriminator, both directions: it is what separates "resting" from
13161+
// "rotted", so it must not answer the same way for a tree that has retired
13162+
// changesets altogether.
13163+
t('a tree mid-cycle still has the specimen home, so an empty population reads as resting',
13164+
csTree().includes('.changeset/config.json'));
13165+
t('...while a tree that retired changesets has no home, and the case above reds instead of skipping',
13166+
!['AGENTS.md', 'package.json'].includes('.changeset/config.json'));
13167+
// The tally the skip is reported through. Pinned here rather than at the
13168+
// verdict because a green run of this file never reaches the non-empty branch
13169+
// of it, so nothing else in this program can show that a skip is visible.
13170+
t('a run that skipped nothing prints the verdict it always printed', notMeasuredSuffix([]) === '');
13171+
t('...and a run that skipped something names it, so no skip reaches a reader as a bare pass count',
13172+
notMeasuredSuffix([['a subject', 'a reason']]).includes('NOT MEASURED') &&
13173+
notMeasuredSuffix([['a subject', 'a reason']]).includes('a subject'));
13174+
t('...naming every one of them, never just a count',
13175+
notMeasuredSuffix([['first', 'x'], ['second', 'y']]).includes('first') &&
13176+
notMeasuredSuffix([['first', 'x'], ['second', 'y']]).includes('second'));
13177+
t('and a skipped subject is never a case, so the pass count cannot absorb one',
13178+
!cases.some(([name]) => name.includes('NOT MEASURED')));
1305613179
t('the extension the glob names is honoured', !hintCovers('.changeset/*.md', '.changeset/config.json'));
1305713180
t('a single `*` matches exactly one segment here too', !hintCovers('.changeset/*.md', '.changeset/pre/x.md'));
1305813181
t('a directory surface above it still derives the gate', hintCovers('.changeset/*.md', '.changeset'));
@@ -16739,7 +16862,25 @@ function selfTest() {
1673916862
t('...and its reason names the prefix that went missing', unreachableReason(patternPrefixGone).includes('packages/gone-away'));
1674016863
// The live half: the specimen is gone from the residue entirely, which is
1674116864
// what the card was filed for. A fixture cannot show that.
16742-
t('the live specimen is not a dead literal on this tree at all', hintReachesTree('.changeset/*.md', trackedFiles()));
16865+
//
16866+
// It reads the same live population as the `globCarriesLiteralSuffix` block
16867+
// above, so it takes the same exit when a version pass has consumed it
16868+
// (#15255) — the second site, and the reason `unmeasurable` is a primitive
16869+
// rather than one `if` written once. Left unguarded, this one case would have
16870+
// kept the release-blocking red after the block above stopped producing it,
16871+
// which is the shape a repair applied at only the site that was measured red
16872+
// always has.
16873+
const residueCorpus = trackedFiles();
16874+
if (residueCorpus.some((f) => /^\.changeset\/[^/]+\.md$/.test(f))) {
16875+
t('the live specimen is not a dead literal on this tree at all', hintReachesTree('.changeset/*.md', residueCorpus));
16876+
} else {
16877+
unmeasurable(
16878+
"the residue block's live `.changeset/*.md` specimen",
16879+
'a hint reaches the tree by matching a FILE, and a tree whose version pass has consumed the population has ' +
16880+
'no file for this one to match. The judgment this case guards — that a pattern-judged hint is not read as a ' +
16881+
'dead literal — is pinned on fixtures a few lines above and ran.',
16882+
);
16883+
}
1674316884

1674416885
// ── A slash is not proof of a path (#10097, option C) ─────────────────────
1674516886
//
@@ -19041,11 +19182,19 @@ function selfTest() {
1904119182
for (const [, cond] of cases) {
1904219183
if (!cond) failed++;
1904319184
}
19185+
// The NOT-MEASURED tally rides on BOTH verdicts (#15255). A reader who sees
19186+
// only the pass count cannot tell a run that measured everything from one
19187+
// that skipped a subject, and that indistinguishability is the whole failure
19188+
// mode a silent skip introduces — so the count is repeated here, next to the
19189+
// number it would otherwise be hiding inside. It never moves the exit code:
19190+
// "this tree cannot decide it" is not a defect in this tool, and a gate that
19191+
// reds for it is the release blocker this replaced.
19192+
const skipped = notMeasuredSuffix(notMeasured);
1904419193
if (failed) {
19045-
console.error(`✗ dispatch-gates self-test: ${failed} of ${cases.length} case(s) failed.`);
19194+
console.error(`✗ dispatch-gates self-test: ${failed} of ${cases.length} case(s) failed.${skipped}`);
1904619195
process.exit(1);
1904719196
}
19048-
console.log(`✓ dispatch-gates self-test: ${cases.length} cases pass.`);
19197+
console.log(`✓ dispatch-gates self-test: ${cases.length} cases pass.${skipped}`);
1904919198

1905019199
return SELF_TEST_VERDICT;
1905119200
}

0 commit comments

Comments
 (0)