diff --git a/.github/workflows/half-state-patrol.yml b/.github/workflows/half-state-patrol.yml
index f56a2a88510..4928204c6fc 100644
--- a/.github/workflows/half-state-patrol.yml
+++ b/.github/workflows/half-state-patrol.yml
@@ -161,6 +161,11 @@ on:
# as the row above, one file along: a step whose script can change without
# this trigger firing is a step whose PR-time proof is a coincidence.
- 'scripts/pm/sweep-closed-cards.mjs'
+ # The citation census this workflow also calls (#18224). Same reasoning as
+ # the two rows above, one file along: a step whose script can change
+ # without this trigger firing is a step whose PR-time proof is a
+ # coincidence.
+ - 'scripts/check-issue-citations.mjs'
# The patrol's body. A glob, not the one file: anything added to that
# directory is part of what runs here.
- '.github/actions/half-state-patrol/**'
@@ -170,9 +175,21 @@ on:
# plus the labels and comment the closed-card sweep is ruled to write. `issues:
# write` is the narrowest scope GitHub offers for either edit; the sweeper the
# patrol calls is read-only against the API by construction.
+#
+# `pull-requests: read` is READ-ONLY and buys one thing, for the citation
+# census step only — MEASURED on run 35495222460, this workflow's own
+# pull_request run WITHOUT this row: the census reported 3,628 unresolvable
+# citation sites where a full-scope read of the same tree reported 2,168, and
+# the difference is 1,460 — EXACTLY the `resolves-as-pull-request` tally.
+# `GET /repos/{owner}/{repo}/issues` answers with the pull requests omitted
+# unless this scope is held, so every citation naming a PR number was reported
+# as a number the board never had. A report-only reading that is wrong by 67%
+# is still a machine-readable surface telling a lie. ⛔ Do not drop this row
+# as tidying, and ⛔ do not widen it to `write`: nothing here writes a PR.
permissions:
contents: read
issues: write
+ pull-requests: read
# One patrol at a time. A scheduled run overlapping a manual dispatch would have
# two runs racing to rewrite the same body, and the loser's findings would vanish
@@ -351,3 +368,121 @@ jobs:
elif [ "$code" != "0" ]; then
echo "::warning::closed-card sweep exited $code — at least one card was left UNJUDGED. An unjudged card is not a clean card; see this run's summary."
fi
+
+ - name: Census the repo's issue citations
+ # #17512's gate, wired here by #18224 — the REPORT-ONLY half, and the
+ # posture is a ruling, not a preference. `--census` judges every
+ # citation in the gate's declared surfaces (the published release pages
+ # and package source docblocks), which is ~2,785 unresolvable sites on a
+ # tree nobody touched, and its verdict is NOT a function of this tree:
+ # #16783, #16786 and #16787 were measured RESOLVING on 2026-09-10 and
+ # 404 on 2026-09-14 with no change to this repository. That is exactly
+ # the shape this workflow exists for — a fact about a live shared board,
+ # not about whichever change happens to run CI next — so it belongs on
+ # the patrol lane and ⛔ NEVER on a blocking one. The DIFF-scoped half of
+ # the same gate is the blocking one and lives in `lint.yml`; the two
+ # postures are opposite on purpose and ⛔ neither moves to the other's
+ # lane.
+ #
+ # ⛔ NOT in the composite action this job `uses:` above — it is a step of
+ # the CALLER, for the same reason the closed-card sweep one step up
+ # states, plus two that are specific to a census of a TREE. All three
+ # are measurable on this repo rather than argued:
+ #
+ # - It is not repo-agnostic. `scripts/check-issue-citations.mjs` is
+ # objectstack-only, so the action's own "Locate the patrol sources"
+ # step would have to either name it — and then REFUSE to run in
+ # every sibling that adopted the action, which is the one thing that
+ # action exists to make possible — or not name it, and leave a step
+ # inside the action failing on a missing file in that sibling. The
+ # repo-name gate below is what answers both, and it belongs where
+ # the repository is the caller's own.
+ # - The action runs its scripts from `steps.sources.outputs.root`, the
+ # tree the ACTION ships from, while the board's checkout is
+ # `github.workspace`; that split is deliberate and the action's own
+ # comments carry it. This census's subject is the WORKSPACE tree's
+ # declared surfaces, so from inside the action a sibling's run would
+ # census objectstack's own release pages and report the count under
+ # the sibling's name — a machine-readable surface telling a lie, the
+ # failure this step's `permissions:` note above is about. In the
+ # caller the two directories are the same one, on every install.
+ # - `pull-requests: read` is granted by the `permissions:` block of
+ # this file. A composite action declares no permissions and the
+ # action's input documentation names only `issues: write` and
+ # `contents: read`, so a census inside it would silently depend on a
+ # scope no adopting caller was told to grant — measured above as a
+ # reading wrong by 67%. A scope and its one consumer stay in one
+ # file.
+ #
+ # WHERE THE REPORT GOES: this run's step summary and job log, plus one
+ # `::warning::` carrying the site count. ⛔ NOT the anchor issue — that
+ # body is owned end-to-end by `check-half-states.mjs`'s generator, and a
+ # second writer is how half of a generated body goes stale.
+ # HOW OFTEN: on this workflow's schedule — four times a day, six hours
+ # apart — plus any `workflow_dispatch`, plus the `pull_request` runs the
+ # paths filter above admits.
+ # WHAT IT COSTS AND WHO PAYS: the census enumerates the whole board once
+ # (159 requests on this repo at the time of writing, cursor-paginated —
+ # the alternative is one request per distinct number). It is paid by
+ # THIS repository's own `secrets.GITHUB_TOKEN` core quota, the same
+ # 5,000/hour this job already draws the live sweep from: ~636
+ # requests/day at four runs, under half a percent of a single hour's
+ # allowance. ⛔ No PAT, no cross-repo credential — the file's own rule.
+ #
+ # ⛔ Gated on the repository NAME, for the reason the closed-card sweep
+ # above states: this script is objectstack-only until a sibling has a
+ # copy, and a verbatim copy of this workflow elsewhere must SKIP rather
+ # than fail on a missing file.
+ #
+ # ⛔ `!cancelled()` rather than an implicit `success()`, and this
+ # placement has to SPELL that rather than inherit it. While the patrol's
+ # steps were written inline in this file, this step sat ABOVE the one
+ # step that fails the job, so it ran whatever the sweep returned. The
+ # patrol is now a single `uses:` step that goes red itself when the
+ # sweep could not read the board, so a default `success()` here would
+ # skip the census on exactly the runs where the patrol is down. This
+ # step reads neither the sweep's exit code nor its files: a patrol that
+ # could not reach the board says nothing about whether the citations in
+ # this tree resolve.
+ #
+ # ⛔ This step never fails the job, whatever the census returns —
+ # findings are not a failure condition here, and neither is a census
+ # that could not read the board: that is an alarm (`::error::`), not a
+ # gate. LAST on purpose, after the patrol's own anchor write and after
+ # the closed-card sweep: the anchor is this patrol's product, this job
+ # has a 15-minute timeout, and a report-only reading must never be able
+ # to starve the thing the workflow exists to deliver.
+ if: ${{ !cancelled() && github.repository == 'objectstack-ai/objectstack' }}
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ set +e
+ node scripts/check-issue-citations.mjs --census \
+ > "$RUNNER_TEMP/issue-citations.md" 2> "$RUNNER_TEMP/issue-citations.err"
+ code=$?
+ set -e
+ # Captured with NO pipe in between, for the reason the two steps above
+ # state at length: piped, `$?` is the pipe's status and a red run and a
+ # green one read the same.
+ {
+ echo "### Issue-citation census — exit $code (report-only)"
+ echo
+ echo '```'
+ cat "$RUNNER_TEMP/issue-citations.md" 2>/dev/null || echo '(no report produced)'
+ echo '```'
+ echo
+ echo 'stderr
'
+ echo
+ echo '```'
+ cat "$RUNNER_TEMP/issue-citations.err" 2>/dev/null || true
+ echo '```'
+ echo
+ echo ' '
+ } >> "$GITHUB_STEP_SUMMARY"
+ cat "$RUNNER_TEMP/issue-citations.err" >&2 || true
+ if [ "$code" != "0" ]; then
+ echo "::error::issue-citation census exited $code — the board was NOT read, so this run says nothing about whether unresolvable citations are accumulating. A census that could not run is not a clean census. See this run's summary."
+ else
+ sites=$(sed -n 's/^.*census: \([0-9][0-9]*\) unresolvable citation site(s).*$/\1/p' "$RUNNER_TEMP/issue-citations.md" | tail -1)
+ echo "::warning::issue-citation census: ${sites:-unknown} unresolvable citation site(s) in the declared surfaces. Report-only — the blocking half judges only what a change ADDS."
+ fi
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index 2d8f686110c..08842a67b79 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -184,6 +184,27 @@ jobs:
# `scripts/report-unmeasured-gate-tail.mjs`. Nothing else in this job
# reads it, and no step writes anything anywhere.
actions: read
+ # ⭐ Added by #18224, for the issue-citation step far below: it resolves
+ # the numbers THIS change cites against the board. Read-only and one
+ # scope wide — no issue, comment, label or assignee is ever written. It
+ # is spelled even though this board is PUBLIC, because an explicit
+ # `permissions:` block sets every unnamed scope to `none`, and a BLOCKING
+ # gate whose transport depends on repository visibility is a gate that
+ # goes red on a settings change no file here can assert.
+ issues: read
+ # ⭐ AND `pull-requests`, which is NOT redundant with the row above —
+ # MEASURED, on run 35495222460 of `half-state-patrol.yml`, whose token
+ # carries `issues` and NOT this scope: its census reported 3,628
+ # unresolvable citation sites where a full-scope read of the same tree
+ # reported 2,168, and the difference is 1,460 — EXACTLY the
+ # `resolves-as-pull-request` tally. `GET /repos/{owner}/{repo}/issues`
+ # answers with the pull requests omitted, so every citation naming a PR
+ # number reads as a number the board never had. On THIS blocking step
+ # that is a false RED on a correct citation, and the frontier probe
+ # (`?per_page=1&sort=created&direction=desc`) is read from the same
+ # response — so an understated frontier turns later numbers into
+ # `never-issued` too. ⛔ Do not drop this row as tidying.
+ pull-requests: read
steps:
- name: Checkout repository
@@ -4638,6 +4659,105 @@ jobs:
- name: workspace manifest dependency graph has no cycle
run: pnpm check:workspace-manifest-cycles
+ # Merged-result budget probe, wired by the card that wired the citation
+ # gate below (#18224). `node scripts/check-merged-result.mjs --self-test`
+ # IS the whole gate and is exactly what the root manifest registers as
+ # `check:merged-result`; the probe landed in #18338 with the file surface
+ # of its own card excluding `.github/workflows/**`, so until this step no
+ # workflow named it and its self-test ran nowhere. Offline: no build, no
+ # network, no credential, sub-second. Its defect class is a MATCHING RULE,
+ # which a clean tree cannot tell from a rule that stopped matching — the
+ # self-test is the only instrument that can, and an instrument nobody runs
+ # is silence.
+ - name: Merged-result probe self-test
+ run: pnpm check:merged-result
+
+ # Issue citations (#17512, wired by #18224). TWO commands, and the split
+ # is the point:
+ #
+ # `pnpm check:issue-citations` the checker's own
+ # `--self-test` — offline,
+ # no board, no credential.
+ # `node scripts/check-issue-citations.mjs` the DIFF-SCOPED verdict —
+ # the live run, judging only
+ # the citations THIS change
+ # adds.
+ #
+ # ⛔ The manifest alias is NOT the verdict. It runs `--self-test` and
+ # nothing else — the shape every credential-needing gate in this manifest
+ # uses (`check:pm-half-states`, `check:pm-closed-card-sweep`), because a
+ # live mode needs a board and a credential. Wiring the alias ALONE would
+ # run the self-test twice and scan nothing. Both spellings are here on
+ # purpose, self-test first: `check-self-test-wired` requires CI to run the
+ # self-test of every script CI runs, and a verdict from a checker whose
+ # own cases were never exercised is a verdict about nothing.
+ #
+ # ⭐ WHY DIFF-SCOPED AND NOT TREE-WIDE, which is the whole ruling. The
+ # standing population is ~2,785 unresolvable citation sites, and the
+ # predicate is NOT a function of this tree: #16783, #16786 and #16787 were
+ # measured RESOLVING on 2026-09-10 and 404 on 2026-09-14, with no change
+ # to this repository. A tree-wide blocking verdict therefore reds a
+ # MOTIONLESS tree because somebody else deleted an issue, and fails a PR
+ # for a change its author did not make and cannot repair. The diff-scoped
+ # half is the part an author owns, it is small, and it is the only thing
+ # that stops 2,785 becoming 3,000. The tree-wide reading is a `--census`
+ # and it runs REPORT-ONLY in `half-state-patrol.yml`, four times a day —
+ # never here, and never blocking. ⛔ Do not "upgrade" this step to
+ # `--census`.
+ #
+ # THE DIFF BASE, per event, and the merge_group row is the whole of
+ # #19259's ejection:
+ #
+ # pull_request `git merge-base origin/main HEAD`. CORRECT and left
+ # alone: the checked-out merge ref already CONTAINS the
+ # main it was computed against, so the merge base IS that
+ # main and nothing newer can leak into the added set.
+ # push (main) the merge base IS the head, so the added set is empty,
+ # the run makes ZERO requests and exits 0 before it asks
+ # the board anything.
+ # merge_group ⭐ the group's own `base_sha`, declared below. A queue
+ # entry is built on the GROUP's base, which carries the
+ # entries AHEAD of it in the queue and has NOT landed on
+ # `main` yet, so the published `origin/main` this runner
+ # fetched is BEHIND that base and the ref guess lands at
+ # the published tip — attributing every entry ahead in the
+ # queue to THIS change.
+ #
+ # MEASURED, on this step's own first queue build (entry `a7109d1f08`,
+ # 2026-09-20): the ref guess resolved to the published main of 14:30Z and
+ # judged 15 file(s) / 16 citations, 3 unresolvable — `#6361` twice from
+ # `ada701220` (#19364) and `#18003` from `8271c81425` (#19363), the two
+ # entries ahead in the queue, both of which landed on `main` only at
+ # 14:36:53Z and 14:47:59Z. The step exited 2 (its findings code) and the
+ # queue ejected the PR for citations its author never wrote. Against
+ # `base_sha` the same tree judges 0 file(s).
+ #
+ # ⛔ Do NOT `if:`-skip this step on `merge_group` instead: this file's
+ # header asserts that every gate step here runs on merge_group, and a
+ # blocking gate turned off on the one event that guards what is about to
+ # reach `main` is a gate that is not one. Both halves of the repair are
+ # this: the base above, and the gate's own refusal below.
+ #
+ # `fetch-depth: 0` is what makes either spelling resolvable (the checkout
+ # step above says so for its own reason).
+ #
+ # ⛔ A failed read is never a pass: an unreadable board — and now an
+ # unresolvable base, which used to throw an unclassified exit code —
+ # exits PREREQUISITE NOT MET rather than reporting a clean tree.
+ - name: Issue citations this change adds resolve on the board
+ env:
+ # The board read is one GET per distinct number the change cites
+ # (none at all when it cites none), against a PUBLIC board. Spelled
+ # anyway: the gate uses the token when it finds one, and an
+ # unauthenticated runner shares one IP's 60 requests/hour.
+ GITHUB_TOKEN: ${{ github.token }}
+ # Renders the group's base on `merge_group` and EMPTY on every other
+ # event, where the gate keeps the `origin/main` / `main` guesses. Same
+ # name, same expression, same fact as the gate-family selector above
+ # — one fact, one spelling.
+ OS_GATE_MERGE_GROUP_BASE_SHA: ${{ github.event.merge_group.base_sha }}
+ run: pnpm check:issue-citations && node scripts/check-issue-citations.mjs
+
# #15149. Grep-level guard for the defect this very repo just shipped: an
# unquoted `- name:` step name containing ` #` is silently truncated by
# YAML at that point (a space + hash starts a comment inside a plain
diff --git a/scripts/check-issue-citations.mjs b/scripts/check-issue-citations.mjs
index e7a3516c550..90324bb885d 100644
--- a/scripts/check-issue-citations.mjs
+++ b/scripts/check-issue-citations.mjs
@@ -7,6 +7,10 @@
*
* node scripts/check-issue-citations.mjs # judge what this change ADDS
* node scripts/check-issue-citations.mjs --base [ # ...relative to ][
+ *
+ * A runner declares the base instead, through `OS_GATE_MERGE_GROUP_BASE_SHA`
+ * -- see `baseSpellings` below for the `merge_group` reading that makes it the
+ * only correct answer there.
* node scripts/check-issue-citations.mjs --census # the whole declared surface
* node scripts/check-issue-citations.mjs --list # extraction only, no network
* node scripts/check-issue-citations.mjs --json # machine-readable
@@ -138,8 +142,10 @@
* report-only and scheduled -- the right posture for a
* reading whose verdict a third party can change.
*
- * Neither is installed here. That is a DECLARED gap, recorded in the PR body and
- * the report, not an oversight.
+ * Both are installed (#18224): the diff-scoped verdict in `lint.yml`'s
+ * `Lint & Repo Gates` job, the `--census` in `half-state-patrol.yml`. The
+ * verdict step also declares the base -- see `baseSpellings` below for why a
+ * `merge_group` build cannot be left to guess one.
*
* ## The local route -- why this gate re-execs itself
*
@@ -601,14 +607,91 @@ export function addedLines(base, root) {
return out;
}
-/** The merge-base this repo's PRs are judged against. */
-export function defaultBase(root) {
- for (const ref of ['origin/main', 'main']) {
- try {
- return execFileSync('git', ['merge-base', ref, 'HEAD'], { cwd: root, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
- } catch { /* try the next spelling */ }
+/**
+ * Every diff-base spelling a run may use, in order, each carrying the reason it
+ * is on the list -- the refusal below prints them, so a reader who acts on it
+ * looks where the run actually looked.
+ *
+ * ⭐ A runner that DECLARES a base replaces the ref guesses entirely, and on a
+ * `merge_group` build that is not a convenience, it is the only correct answer.
+ * A queue entry is built on the GROUP's base, which carries the entries AHEAD
+ * of it in the queue and has NOT landed on `main` yet; the published
+ * `origin/main` the runner fetched is therefore BEHIND that base, and
+ * `merge-base origin/main HEAD` lands at the published tip. Everything between
+ * the two -- other people's PRs -- then reads as "added by this change".
+ *
+ * MEASURED on the ejection this spelling exists for: on queue entry
+ * `a7109d1f08` the ref guess resolved to the published main of 14:30Z and
+ * judged 15 file(s) / 16 citations, 3 of them unresolvable and every one of
+ * those written by the two entries ahead in the queue -- `#6361` twice from
+ * `ada701220`, `#18003` from `8271c81425`, both landing on `main` AFTER that
+ * checkout (14:36:53Z and 14:47:59Z). The declared base judged 0 file(s). So
+ * the ref guess failed a PR for citations its author did not write, which is
+ * the same defect class this gate's own diff scoping exists to prevent.
+ *
+ * ⛔ `pull_request` and `push` keep the ref guesses, deliberately: there the
+ * checked-out merge ref already CONTAINS the main it was computed against, so
+ * the merge base IS that main and nothing newer can leak into the added set.
+ *
+ * The variable is the name `lint.yml` already uses for this fact
+ * (`scripts/ci/select-gate-families.sh`) -- one fact, one spelling. It is
+ * deliberately NOT `PROXY_REARM_GUARD`'s own-name case: a re-exec guard is this
+ * PROCESS's state, which a sibling instrument must never answer for, while a
+ * group's `base_sha` is a fact about the build that every reader of it shares.
+ */
+export function baseSpellings({ base = null, env = process.env } = {}) {
+ if (base) return [{ ref: base, verbatim: true, why: 'the `--base` argument' }];
+ const declared = (env.OS_GATE_MERGE_GROUP_BASE_SHA ?? '').trim();
+ if (declared) return [{ ref: declared, verbatim: true, why: 'OS_GATE_MERGE_GROUP_BASE_SHA -- `github.event.merge_group.base_sha`' }];
+ return [
+ { ref: 'origin/main', verbatim: false, why: 'the remote-tracking main a CI checkout fetches' },
+ { ref: 'main', verbatim: false, why: 'a local main, for a clone that tracks no remote' },
+ ];
+}
+
+/** @returns {string|null} what git printed, or null when the command refused. */
+function gitLine(root, args) {
+ try {
+ return execFileSync('git', args, { cwd: root, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim() || null;
+ } catch { return null; }
+}
+
+/**
+ * The base a diff-scoped run is judged against, plus every spelling tried.
+ *
+ * ⛔ It never returns a base it did not VERIFY resolves to a commit in this
+ * checkout. An unverified base reaches `git diff` as-is, which answers
+ * `fatal: bad revision` and THROWS -- an uncaught exception whose exit code is
+ * none of this gate's three answers, so a failed read arrives wearing an
+ * unclassified number. "could not resolve" and "resolves" are not the same
+ * answer, so an unresolvable spelling answers `{ base: null }` here and the
+ * caller refuses loudly with `EXIT_PREREQUISITE_NOT_MET`.
+ */
+export function resolveDiffBase({ root = process.cwd(), base = null, env = process.env } = {}) {
+ const tried = baseSpellings({ base, env });
+ for (const spelling of tried) {
+ /* A declared base is taken VERBATIM -- it already IS the fork point. A ref
+ * guess goes through `merge-base`, because a branch TIP is not one. */
+ const sha = spelling.verbatim
+ ? gitLine(root, ['rev-parse', '--verify', '--quiet', `${spelling.ref}^{commit}`])
+ : gitLine(root, ['merge-base', spelling.ref, 'HEAD']);
+ if (sha) return { base: sha, used: spelling, tried };
}
- return null;
+ return { base: null, used: null, tried };
+}
+
+/**
+ * The refusal text for a base that does not resolve. It names every spelling
+ * TRIED and what to pass instead: "no merge-base with `origin/main`" is a true
+ * sentence about a run that never looked at `origin/main` at all, and a reader
+ * who acts on it looks in the wrong place.
+ */
+export function unresolvedBaseMessage({ tried }) {
+ const spellings = tried.map((s) => `\`${s.ref}\` (${s.why})`).join(', ');
+ return `no diff base resolves to a commit in this checkout — tried ${spellings}. `
+ + 'A diff-scoped run has no baseline to judge against. Pass one with `--base ][`, '
+ + 'or set OS_GATE_MERGE_GROUP_BASE_SHA to a commit this checkout has, or fetch `main` '
+ + '(a shallow clone carries no merge base).';
}
/**
@@ -709,9 +792,11 @@ function prerequisiteRefusal(message) {
}
export async function run({ root = process.cwd(), scope = 'diff', base = null, json = false, probeCause = false, ownerRepo = 'objectstack-ai/objectstack', strategy = 'auto' } = {}) {
- const resolvedBase = scope === 'diff' ? (base ?? defaultBase(root)) : null;
- if (scope === 'diff' && !resolvedBase) {
- prerequisiteRefusal('no merge-base with `origin/main` or `main` — a diff-scoped run has no baseline to judge against.');
+ let resolvedBase = null;
+ if (scope === 'diff') {
+ const attempt = resolveDiffBase({ root, base });
+ if (!attempt.base) prerequisiteRefusal(unresolvedBaseMessage(attempt));
+ resolvedBase = attempt.base;
}
const { rows, files } = collectCitations({ root, scope, base: resolvedBase });
@@ -810,7 +895,7 @@ const SELF_TEST_BATTERIES = Object.freeze({
causes: 12,
transport: 7,
'scope-contract': 12,
- 'diff-scope': 6,
+ 'diff-scope': 13,
'live-corpus': 3,
'proxy-rearm': 10,
});
@@ -995,6 +1080,27 @@ export async function selfTest() {
const board = boardFromSets({ numbers: [100], pulls: [], frontier: 1000, source: 'stub' });
check(classifyCitation(after.rows[0], board, { ownerRepo: 'o/r' }).cause === CAUSE.ALLOCATED_BUT_ABSENT,
'the added citation must classify as unresolvable against a board that lacks it');
+
+ /* ⭐ THE BASE, both directions. The base decides WHICH diff is judged, so
+ * a wrong one does not fail loudly -- it judges somebody else's change
+ * and reports the answer as this one's. */
+ const absent = '0'.repeat(40);
+ check(resolveDiffBase({ root: tmp, base }).base === base,
+ 'a resolvable explicit base must be used verbatim -- it already IS the fork point');
+ check(resolveDiffBase({ root: tmp, base: absent }).base === null,
+ '⛔ a `--base` naming no commit here must answer null, NEVER be handed to `git diff` -- unverified, it throws, and an uncaught throw is a failed read wearing an unclassified exit code');
+ const refusal = unresolvedBaseMessage(resolveDiffBase({ root: tmp, base: absent }));
+ check(refusal.includes(absent) && refusal.includes('--base ][') && refusal.includes('OS_GATE_MERGE_GROUP_BASE_SHA'),
+ '...and the refusal must name the spelling that was TRIED and what to pass instead');
+ check(resolveDiffBase({ root: tmp, env: { OS_GATE_MERGE_GROUP_BASE_SHA: base } }).base === base,
+ 'a runner-DECLARED merge-group base must become the base, so a queue build judges its own diff and not the entries ahead of it in the queue');
+ check(resolveDiffBase({ root: tmp, env: { OS_GATE_MERGE_GROUP_BASE_SHA: absent } }).base === null,
+ '⛔ ...and a declared base this checkout does not have must REFUSE, never fall back to a ref guess that would silently judge a different diff');
+ check(baseSpellings({ env: {} }).map((s) => s.ref).join() === 'origin/main,main'
+ && baseSpellings({ env: { OS_GATE_MERGE_GROUP_BASE_SHA: '' } }).map((s) => s.ref).join() === 'origin/main,main',
+ 'with nothing declared -- and on the events where that variable renders EMPTY -- the ref guesses are unchanged, so `pull_request` and `push` keep the base they already had');
+ check(/if \(!attempt\.base\) prerequisiteRefusal\(unresolvedBaseMessage\(attempt\)\);/.test(readFileSync(SELF_PATH, 'utf8')),
+ 'structural: the null answer must reach `prerequisiteRefusal` -- exit 3, never 0 and never an uncaught throw');
} finally {
rmSync(tmp, { recursive: true, force: true });
}
]