diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index bb0c37ed2f..022f50a8e5 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1081,9 +1081,12 @@ jobs: # — one card per silent exit — and each fix lands as more unexercised # code. This step is the exercise: the shipped script is extracted from # the YAML with a real parser (never retyped) and run under doubles the - # way actions/github-script runs it, as one AsyncFunction body. Ten + # way actions/github-script runs it, as one AsyncFunction body. Its # scenarios pin the target parse and the outcome of EVERY exit — which of # setFailed / warning / job summary fires, and which API calls were made. + # How many of them there are is deliberately NOT written here (#9917): + # the check names the total on its own OK line, and `--list` prints the + # whole table. This comment used to say "ten" against a real eighteen. # # Assertion 0 is the compile, and it is not theoretical: this job failed # twice on 2026-08-02 with `SyntaxError: Identifier 'octokit' has already @@ -1091,14 +1094,26 @@ jobs: # workflow whose red nothing else in CI can see. # # Its --self-test runs first and is the half that stops the battery - # rotting into decoration: it mutates the shipped script seven ways — - # downgrade the verdict to a warning, stop collecting failed keys, break - # out of the loop instead of isolating, drop the same-repo skip, narrow - # the keyword set, drop the already-closed skip, downgrade the notice - # path's verdict — and requires the battery to go RED for each, naming the - # scenario that catches it. A mutation whose anchor no longer exists is a - # failure too, so a rewrite of the workflow cannot leave the mutations - # silently matching nothing. + # rotting into decoration: it mutates the shipped script along every + # CLASS the contract is made of — a verdict downgraded (red becomes a + # warning, or an info line nothing annotates), the loop's bookkeeping + # deleted (which keys failed, which half was lost, which target was + # refused), isolation abandoned mid-loop, the target parse narrowed (the + # keyword set, the optional colon, the same-repo skip), a guard removed + # (already-closed, triage, pull-request), idempotency eroded (the + # backlink marker dropped, or a blind post where a skip belongs) — and + # requires the battery to go RED for each, naming the scenario that + # catches it. A mutation whose anchor no longer exists is a failure too, + # so a rewrite of the workflow cannot leave the mutations silently + # matching nothing. + # + # Classes rather than a count, and rather than the enumeration that used + # to sit here, because BOTH hand-maintained numbers in this comment had + # gone stale unnoticed — measured 2026-08-20 from the check's own output, + # eighteen scenarios and fifteen mutations against a comment claiming ten + # and seven (#9917). A stale enumeration reads exactly as authoritative + # as a fresh one, and this comment is what the next reader checks the + # harness against. Prose classes survive an M16; a count does not. # # Invoked as `node` rather than through a `pnpm check:*` alias: that alias # belongs in root package.json, declared territory of the @changesets/cli diff --git a/scripts/check-cross-repo-closer-outcome.mjs b/scripts/check-cross-repo-closer-outcome.mjs index 2429d31740..826d61b3f1 100644 --- a/scripts/check-cross-repo-closer-outcome.mjs +++ b/scripts/check-cross-repo-closer-outcome.mjs @@ -89,15 +89,26 @@ // // A battery of assertions over a script that is already correct is green on day // one and green forever, including the day someone deletes the thing it -// guards. `--self-test` mutates the extracted source -- drop the `setFailed`, -// stop collecting failed keys, `break` out of the loop instead of isolating, -// disable the same-repo skip, narrow the keyword set, collapse the -// already-closed branch, strip the backlink marker, drop the triage guard, -// post blind when the comment listing is refused, stop recording which half -// was lost -- and requires the battery to go RED for each, naming the scenario -// it expects. Each mutation also asserts its own anchor was PRESENT before -// substituting: a mutation that silently matched nothing would leave the -// battery green and read exactly like a passing self-test. +// guards. `--self-test` mutates the extracted source and requires the battery +// to go RED for each mutation, naming the scenario it expects. +// +// What the mutations cover is written here as CLASSES, and their number is not +// written here at all (#9917). The list this paragraph used to carry named ten +// of them and was never updated again; the same habit in lint.yml's step +// comment was counting seven when there were fifteen. A prose class survives +// the next mutation being added, a hand-count does not, and `--self-test` +// prints its own total on every run. The classes: a verdict DOWNGRADED (red +// becomes a warning, or an info line nothing annotates), the loop's +// BOOKKEEPING deleted (which keys failed, which half was lost, which target +// was refused -- each one lets a run report green about work it did not do), +// ISOLATION abandoned mid-loop, the TARGET PARSE narrowed (the keyword set, +// the optional colon, the same-repo skip), a GUARD removed (already-closed, +// triage, pull-request), and IDEMPOTENCY eroded (the backlink marker dropped, +// or a blind post where a skip belongs). +// +// Each mutation also asserts its own anchor was PRESENT before substituting: a +// mutation that silently matched nothing would leave the battery green and +// read exactly like a passing self-test. // // One scenario (L11) is driven TWICE, the second run's world built out of the // first run's calls. Re-run idempotency is a property of the PAIR, and a @@ -200,9 +211,14 @@ function makeDoubles({ body, token, issues = {}, prCommentError = null, summaryE calls.get.push(`${owner}/${repo}#${n}`); const t = target(owner, repo, n); if (t.getError) throw t.getError; - // `state_reason` is nullable on a real closed issue -- objectui#4478 - // answers `null` from this very endpoint -- so the default models that - // rather than inventing a value the API does not promise. + // `state_reason` is nullable BY CONTRACT on this endpoint -- the API + // states a reason when it has one and promises nothing otherwise -- so + // the default models `null` rather than inventing a value the API does + // not promise. Modelled, not sampled, and that distinction is load + // bearing here: the citation that stood on this line named a foreign + // number as a closed issue answering `null` and it was a pull request + // (#9917). L9 carries the reason a sampled specimen is the wrong kind of + // evidence for THIS default in particular. // // `pull_request` is how the same endpoint says the number is a PULL // REQUEST, and it is modelled as ABSENCE rather than as `undefined` @@ -667,9 +683,25 @@ export const SCENARIOS = [ scenario: () => ({ body: MIXED_BODY, token: 'pat', - // Measured, not invented: objectstack-ai/objectui#4478 is closed and - // answers `state_reason: null` from `issues.get`. A fix that keys on the - // reason has to say what null means, and this pins the answer. + // MODELLED FROM THE CONTRACT, deliberately not sampled. `issues.get` + // declares `state_reason` nullable and promises no reason on a closed + // issue, so a fix that keys on the reason has to say what null means, and + // this pins the answer -- the shipped script's own reading of it, "no + // objection recorded", is stated on the already-closed branch of + // .github/workflows/cross-repo-issue-closer.yml, which is the subject + // this scenario drives. + // + // A SAMPLED citation stood here and was withdrawn (#9917): it named + // objectui#4478 as a closed issue answering `null`, and that number is a + // PULL REQUEST. Read that as the trap it is rather than as one bad + // lookup -- the objects that most dependably answer `state: 'closed'` + // with `state_reason: null` ARE pull requests, so hunting a specimen for + // this fixture walks straight into L13's subject, and the file then + // offers ONE observation as evidence for two opposite scenarios. L9 and + // L13 are kept on separate footings on purpose: L9 models the contract + // and cites nothing, L13 cites a measured pull request + // (objectstack-ai/objectstack#9143). Do not "improve" this by finding a + // real issue to name. issues: { [CLOSED_TARGET]: { state: 'closed', stateReason: null, comments: [] } }, }), check: (r, t) => [ @@ -845,10 +877,13 @@ export const SCENARIOS = [ token: 'pat', // A merged pull request answers `state: 'closed'` with // `state_reason: null` from the issues endpoint -- measured on - // objectstack-ai/objectstack#9143. That is the very fixture L9 uses to - // prove a closed ISSUE gets its backlink, which makes this scenario the - // ORDERING test: a `pull_request` guard placed after the state branch - // would sail past it and comment on somebody else's pull request. + // objectstack-ai/objectstack#9143. That is the very fixture SHAPE L9 uses + // to prove a closed ISSUE gets its backlink -- the shape, not the + // observation: L9 models its null from the contract and cites no object, + // precisely so that this measured pull request stays evidence for THIS + // scenario only (#9917). The shared shape is what makes this the ORDERING + // test: a `pull_request` guard placed after the state branch would sail + // past it and comment on somebody else's pull request. issues: { [PR_TARGET]: { state: 'closed',