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 }); }