From ab5f3bfbac9c0ad6e135b51992c1140eb3fb9ae7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 14:20:33 +0000 Subject: [PATCH 1/3] ci(#9678): give --verify-required-set a standing scheduled caller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--verify-required-set` (#9642) shipped with no caller: the only thing CI ran was its offline self-test, so the registry-vs-live diff existed and measured nothing. Ruled A+B on the card. A. `.github/workflows/required-set-patrol.yml` — scheduled twice daily (`23 4,16 * * *`), plus workflow_dispatch and a pull_request run scoped to the patrol and the script it calls, following half-state-patrol.yml's posture: least privilege (`contents: read` — the read's price is `metadata=read`), findings never fail anything, and the job goes red ONLY when the sweep could not run or its report could not be delivered. Exit code captured with no pipe in between, so the 0-swept / 2-environment split survives. The report lands in the run log and step summary and nowhere else: a new tracker is refused, and the existing anchor (#9857) is owned end-to-end by half-state-patrol's generator. A completed sweep that finds drift exits 0 and would show a green tick, so it emits a `::warning::` annotation keyed on the ABSENCE of the report's own clean mark — which fails toward a false alarm, never a false all-clear. B. Header note in `scripts/check-required-contexts.mjs`: the live half is not runnable from a dev seat. Re-measured 2026-08-19 — bare run answers HTTP 401 (the #7412 proxy trap), and with the documented `--use-env-proxy` remedy it answers HTTP 403, the seat's own egress policy. The proxy hint correctly goes silent once the flag is set, so a dev sees a bare 403 with no explanation of its seat class. NOT VERIFIED there is the environment, not the tree. The self-test's "live mode stays OFF the required path" block gains the presence half it lacked: an absence cannot tell "deliberately off the required path" from "wired nowhere at all". It now sweeps all of .github/workflows for callers (not just the two files `sources` carries), pins that the only one is the patrol, that the patrol declares no `merge_group:` trigger (the mechanical proxy for "can never be validly required-ized" — assertion 6), that no REQUIRED_CONTEXTS row names it, and that it still keys its annotation on the clean mark this file renders. 115 -> 119 assertions. Part of #9678 --- .github/workflows/required-set-patrol.yml | 207 ++++++++++++++++++++++ scripts/check-required-contexts.mjs | 106 ++++++++++- 2 files changed, 308 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/required-set-patrol.yml diff --git a/.github/workflows/required-set-patrol.yml b/.github/workflows/required-set-patrol.yml new file mode 100644 index 0000000000..789b90cf6a --- /dev/null +++ b/.github/workflows/required-set-patrol.yml @@ -0,0 +1,207 @@ +name: Required-Set Patrol + +# The standing caller for +# `node scripts/check-required-contexts.mjs --verify-required-set` (#9678). +# +# ## Why a workflow, and not "a seat should run it" +# +# `--verify-required-set` (#9642) diffs this repo's REQUIRED_CONTEXTS registry +# against the LIVE ruleset's required set, in both directions: +# +# A. a registry row whose context is absent from the live set — the gate +# family that row names is advisory RIGHT NOW, with no signal anywhere. +# #5617 verbatim: PR #5584 merged with its gate-carrying job red for 19 +# minutes because that job was not in the required set at all. +# B. a live required context with no registry row — renaming that job detaches +# its gate silently, which is the defect check-required-contexts exists for. +# +# It shipped with no standing caller, so the only thing CI ran was its OFFLINE +# self-test: the mechanism existed and measured nothing. A sweep whose value is +# realized only when something runs it on a schedule, with nothing on a +# schedule, is the `check-half-states.mjs` shape verbatim — an alarm added to a +# script nobody runs is still silence, and half-state-patrol.yml exists because +# that cost a shift's worth of unnoticed findings. +# +# "Some seat should run it" also keeps not happening for a MEASURED reason +# rather than a discipline one. Re-measured 2026-08-19 from a dev session +# container, both spellings the script documents: +# +# node scripts/check-required-contexts.mjs --verify-required-set +# -> NOT VERIFIED, GET /repos/... answered HTTP 401 (exit 2) +# NODE_OPTIONS=--use-env-proxy node ... --verify-required-set +# -> NOT VERIFIED, GET /repos/... answered HTTP 403 (exit 2) +# +# i.e. the documented proxy remedy is not enough: the seat's egress policy +# refuses api.github.com outright. The seats that would notice drift are exactly +# the ones that cannot measure it, so the caller had to move somewhere the +# transport prerequisite is actually met. A GitHub Actions runner reaches the +# API directly with the workflow's own GITHUB_TOKEN — no proxy env, no +# `--use-env-proxy`; the read's whole price is `metadata=read`, which every +# token carries and none can give up. +# +# ## Where the report lands, and why nowhere else +# +# The run log and the run's STEP SUMMARY, and nothing else. Deliberately not an +# issue: +# +# - a NEW tracker is refused outright — this repo has one board, and a per-run +# comment stream or a fresh anchor is a second one that nobody prunes; +# - the EXISTING anchor (#9857) is owned end-to-end by half-state-patrol.yml, +# whose generator rewrites that body whole every run. A second writer would +# make each patrol silently discard the other's findings. +# +# What that costs is honestly stated: a completed sweep that FINDS drift exits 0 +# and shows a green check in the Actions list, so the drift is one click deep. +# The `::warning::` annotation below is what keeps it from being invisible — +# annotations render on the run list itself. Routing findings to a durable +# surface (the PM round report, a rotating anchor) is a governed-surface change +# and is not this workflow's to make. +# +# ## Report-only, and the one thing that is NOT report-only +# +# ⛔ This workflow is NEVER a required context and must never become one. +# That is structural, not squeamish: the settings half of any required-set +# change is maintainer-only and lands AFTER the merge (the #9325 two-step), so a +# blocking version would be red on precisely the PR carrying the repo half and +# could not go green before merging — it would deadlock the sitting it claims to +# protect. The script's own header argues this at length, and +# check-required-contexts' self-test pins it from this side too: it asserts that +# this is the ONLY workflow running the flag, that this file declares no +# `merge_group:` trigger (a workflow without one deadlocks the queue the moment +# it is required-ized — assertion 6 of the pin), and that no REQUIRED_CONTEXTS +# row names this file. +# +# So FINDINGS never fail anything: a completed sweep exits 0 whether it found 0 +# or 40 disagreements, and this job never writes a label, an issue or a state. +# +# The job DOES fail when the sweep could not RUN, or when its report could not +# be DELIVERED. That is not a gate on the repo; it is the patrol reporting its +# own death. `NOT VERIFIED` is not a pass and not a failure of the tree (#4690): +# exit 2 classifies the ENVIRONMENT, and a patrol that quietly stops reading +# would leave a run history of green checks that reads exactly like a clean +# required set. Failing costs nobody a PR — this workflow gates no branch and +# blocks no queue. + +on: + schedule: + # Twice a day, twelve hours apart, at :23 past the hour. + # + # The live required set is changed by hand, by the maintainer, a few times a + # month at most — detection latency of half a day is already two orders of + # magnitude better than the status quo (never), and the #5617 incident it + # guards against sat undetected for days. The minute is offset off the top + # of the hour on purpose: scheduled workflows queue behind everyone else's + # :00 cron, and this sweep shares the core API quota with the loop's hot + # path. + - cron: '23 4,16 * * *' + workflow_dispatch: {} + # Changes to the patrol or to the sweep it calls get exercised before they + # merge — the same posture as half-state-patrol.yml. The transport, the flags + # and the rendering are proven on a real runner rather than argued about; this + # job publishes no required context, so a red here blocks nothing. + pull_request: + paths: + - 'scripts/check-required-contexts.mjs' + - '.github/workflows/required-set-patrol.yml' + +# Least privilege. The sweep is read-only against the API by construction and +# this job writes no issue, no label and no comment, so `contents: read` is the +# whole grant — the ruleset read's documented price is `metadata=read`, which is +# below anything a workflow can toggle. +permissions: + contents: read + +# One patrol at a time; a scheduled run overlapping a manual dispatch would burn +# two readings of the same set for one report. +concurrency: + group: required-set-patrol-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: false + +jobs: + sweep: + # ⛔ This name is NOT a required context and must never be added to the + # ruleset — see the header. It is deliberately not registered in + # REQUIRED_CONTEXTS, and check-required-contexts' self-test asserts so. + name: Live required-set sweep + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: '22' + + # No `pnpm install`: the live mode imports nothing outside node: builtins + # and global fetch. Installing the workspace would buy nothing and would + # give a scheduled patrol a lockfile it could fail on. + # + # No NODE_OPTIONS=--use-env-proxy either, and that is not an omission: a + # runner reaches api.github.com directly. The flag exists for agent + # session containers, where it is necessary but (measured, #9678) still + # not sufficient — that is why this caller lives here and not there. + - name: Run the live required-set sweep + id: sweep + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set +e + node scripts/check-required-contexts.mjs --verify-required-set \ + > "$RUNNER_TEMP/required-set.md" 2> "$RUNNER_TEMP/required-set.err" + code=$? + set -e + # Captured with NO pipe in between. `cmd | tail` would report the + # PIPE's status — `tail` essentially never fails, so a swept run and + # an unreadable one both read as 0, and the exit split (0 swept / 2 + # environment) is the entire classification this patrol delivers. + echo "exit_code=$code" >> "$GITHUB_OUTPUT" + echo "check-required-contexts --verify-required-set exited $code" + cat "$RUNNER_TEMP/required-set.err" >&2 || true + + - name: Publish the sweep to the run summary + # Always: this IS the delivery, on every trigger. A run whose summary is + # empty because an earlier step died is itself the signal. + if: always() + run: | + { + echo "### Required-set patrol — sweep exit ${{ steps.sweep.outputs.exit_code }}" + echo + echo '```' + cat "$RUNNER_TEMP/required-set.md" 2>/dev/null || echo '(no report produced)' + cat "$RUNNER_TEMP/required-set.err" 2>/dev/null || true + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + - name: Annotate a completed sweep that found drift + # A finding is NOT a failure (see the header) — but a green check that + # hides one is the #4690 shape with a tick on it, so a completed sweep + # that did not render its clean mark gets an annotation on the run list. + # + # The marker is the script's own `✅ the live required set and this + # registry agree in both directions.` line, and its ABSENCE is what is + # read: every non-agreeing reading the script can produce (direction A, + # direction B, a dry-run ruleset, no enforcing ruleset at all) withholds + # that line, and check-required-contexts' self-test pins both directions + # of that rendering — including this workflow's dependence on it by + # name. Erring this way is deliberate: a rendering change costs a FALSE + # ALARM, never a false all-clear. + if: steps.sweep.outputs.exit_code == '0' + run: | + if grep -qF '✅' "$RUNNER_TEMP/required-set.md"; then + echo "::notice::required-set sweep: the live required set and REQUIRED_CONTEXTS agree in both directions." + else + echo "::warning::required-set sweep found a registry-vs-live DISAGREEMENT. Nothing is blocked and nothing is judged — read this run's summary. Direction A (a registry row that is not live) is the maintainer's to resolve; direction B (a live context with no row) is a dev's." + fi + + - name: Fail the run if the sweep could not run + # LAST, on purpose: the summary carries the classified output BEFORE the + # job goes red, so the run that raised the alarm also holds the reason. + # + # Findings are NOT a failure condition and never reach here: exit 0 with + # 40 disagreements is a successful patrol. + if: steps.sweep.outputs.exit_code != '0' + run: | + echo "::error::check-required-contexts --verify-required-set exited ${{ steps.sweep.outputs.exit_code }} — the standing patrol did not read the live required set. NOT VERIFIED is not a clean reading (#4690); see this run's summary." + exit 1 diff --git a/scripts/check-required-contexts.mjs b/scripts/check-required-contexts.mjs index 789caa888d..e8b8dbca88 100644 --- a/scripts/check-required-contexts.mjs +++ b/scripts/check-required-contexts.mjs @@ -9,8 +9,8 @@ * node scripts/check-required-contexts.mjs --self-test # verify the checker itself * node scripts/check-required-contexts.mjs --verify-required-set * # report-only live ruleset diff (#9642) - * NODE_OPTIONS=--use-env-proxy node scripts/check-required-contexts.mjs --verify-required-set - * # the same, from an agent container + * # ⛔ NOT runnable from a dev seat — see + * # "Where the live half can run" below * * ## The defect * @@ -97,7 +97,10 @@ * the same path plus /{id}, answer HTTP 200 to an ordinary agent seat. * Their price is `X-Accepted-GitHub-Permissions: metadata=read` — the * baseline every token carries, and not one of the 17 a workflow can toggle - * because it cannot be given up. The required SET is readable. + * because it cannot be given up. The required SET is readable — by a token + * that reaches api.github.com at all, which a DEV SEAT does not (see + * "Where the live half can run" at `--verify-required-set` below; that is a + * transport fact about the container, not a permission this repo grants). * * Measured 2026-08-18 (#9642): one ruleset, `main`, id 12119582, enforcement * `active`, repository-sourced — `includes_parents=true` returns that one and @@ -173,7 +176,7 @@ * below, with the decision in judgeInstructionSurfaces. */ -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -975,6 +978,33 @@ export async function scanInstructionSurfaces( * non-zero exit classifies the ENVIRONMENT, not the tree. Unreachable prints * NOT VERIFIED and exits 2; it is never a pass (#4690). * + * ## Where the live half can run — NOT from a dev seat (#9678) + * + * ⛔ A `NOT VERIFIED` here on an agent session container is the ENVIRONMENT, + * never the tree and never this registry. Do not chase it, do not "fix" it, and + * do not conclude from it that the ruleset is unreadable — that inference, from + * a different cause, is exactly what #9642 had to retire. Re-measured + * 2026-08-19 from a dev seat, both documented spellings: + * + * node scripts/check-required-contexts.mjs --verify-required-set + * -> NOT VERIFIED — GET /repos/objectstack-ai/objectstack answered 401 + * NODE_OPTIONS=--use-env-proxy node ... --verify-required-set + * -> NOT VERIFIED — GET /repos/objectstack-ai/objectstack answered 403 + * + * The 401 is the #7412 proxy trap (Node's global fetch does not read + * HTTPS_PROXY on its own), and `renderRequiredSetUnverified` says so. The 403 + * is the finding: with the documented remedy applied, the seat's own egress + * policy refuses api.github.com. The proxy hint correctly goes SILENT once the + * flag is set, so what a dev actually sees is a bare 403 with no explanation of + * its seat class — hence this paragraph. Two seat classes, both dead ends; + * there is no third spelling that works from here. + * + * The standing caller is therefore `.github/workflows/required-set-patrol.yml` + * (#9678), where a runner reaches the API directly with the workflow's own + * GITHUB_TOKEN — no proxy env and no `--use-env-proxy` needed. It is scheduled, + * report-only and ⛔ never a required context; the "live mode stays OFF the + * required path" block in `--self-test` pins that from this side. + * * ## Both directions are reported, because they are different defects * * A. a REGISTRY ROW whose context is absent from the live required set — the @@ -1124,7 +1154,7 @@ export function renderRequiredSetReport(verdict) { } if (verdict.advisory.length === 0 && verdict.unpinned.length === 0 && !verdict.noEnforcingRuleset && !verdict.noRequiredChecksRule) { - lines.push('', ' ✅ the live required set and this registry agree in both directions.'); + lines.push('', ` ${REQUIRED_SET_CLEAN_MARK} the live required set and this registry agree in both directions.`); } return lines.join('\n'); } @@ -1162,6 +1192,25 @@ export const EXIT_SWEPT = 0; /** Could not read the live set — classifies the environment, never the tree. */ export const EXIT_ENVIRONMENT = 2; +/** + * The standing caller for the live mode (#9678) — scheduled, report-only, and + * ⛔ never a required context. Named here rather than only in a comment so the + * self-test can assert the caller EXISTS and that nothing else runs the flag. + * Repo-root-relative to `.github/workflows/`. + */ +export const PATROL_WORKFLOW = 'required-set-patrol.yml'; + +/** + * The clean mark a COMPLETED sweep renders when the two lists agree in both + * directions, and the exact character the patrol keys its drift annotation on. + * One constant rather than two copies: the workflow reads this out of the + * rendered report, so a silent divergence between "what the report prints" and + * "what the caller looks for" would turn every drift finding into a green tick. + * Every non-agreeing reading withholds it, so the patrol annotates on ABSENCE — + * which fails toward a false alarm, never a false all-clear. + */ +export const REQUIRED_SET_CLEAN_MARK = '✅'; + function requiredSetApiContext(env) { return { apiUrl: (env.GITHUB_API_URL ?? 'https://api.github.com').replace(/\/+$/, ''), @@ -2029,6 +2078,53 @@ async function selfTest() { !Object.values(pkgJson.scripts ?? {}).some((s) => typeof s === 'string' && s.includes('--verify-required-set')), 'wiring: no package script runs the live read — a `check:*` script is how a thing reaches the required job (#9642)', ); + + // ── …and the standing caller it DOES have (#9678) ───────────────────── + // + // The two assertions above are absences, and an absence cannot tell "kept + // deliberately off the required path" apart from "wired nowhere at all" — + // which is what this mode actually was for its whole first life: a sweep + // whose only scheduled caller was its own offline self-test. So the caller + // is pinned as a PRESENCE too, in the same block, and the whole + // .github/workflows tree is swept rather than the two files `sources` + // carries: a second caller appearing in some third workflow is exactly the + // thing the absences above are guarding against, and they cannot see it. + const workflowDir = join(root, '.github', 'workflows'); + const callers = readdirSync(workflowDir) + .filter((f) => f.endsWith('.yml') || f.endsWith('.yaml')) + .filter((f) => /--verify-required-set/.test(uncommentedYaml(readFileSync(join(workflowDir, f), 'utf8')))); + assert( + callers.join(',') === PATROL_WORKFLOW, + `wiring: exactly one workflow runs the live read and it is ${PATROL_WORKFLOW} — found [${callers.join(', ')}] (#9678)`, + ); + + const patrol = readFileSync(join(workflowDir, PATROL_WORKFLOW), 'utf8'); + const patrolYaml = uncommentedYaml(patrol); + // The mechanical proxy for "never required". Assertion 6 of this pin is + // that every required context's workflow carries `merge_group:` — without + // one, the queue build never produces the context and the whole queue + // stalls waiting for it. A patrol that declares no merge_group trigger + // therefore CANNOT be validly required-ized, and required-izing it anyway + // wedges the queue rather than deadlocking a rename two-step quietly. + assert( + !/^\s{0,4}merge_group\s*:/m.test(patrolYaml), + `wiring: ${PATROL_WORKFLOW} must declare no merge_group trigger — a workflow without one deadlocks the queue if it is ever required-ized (#9678)`, + ); + assert( + !REQUIRED_CONTEXTS.some((entry) => entry.workflow === PATROL_WORKFLOW), + `wiring: no REQUIRED_CONTEXTS row may name ${PATROL_WORKFLOW} — the patrol is report-only by construction (#9678)`, + ); + // The patrol reads its drift signal out of the CLEAN MARK this file + // renders, so the coupling is pinned from the side that owns the string. + // The rendering itself is pinned in both directions above ('agreement in + // both directions renders as the clean case' / 'the unprotected reading + // never renders as the clean case'); this asserts the caller still reads + // the same character. A rendering change costs the patrol a FALSE ALARM, + // never a false all-clear — it annotates on the mark's ABSENCE. + assert( + patrolYaml.includes(REQUIRED_SET_CLEAN_MARK), + `wiring: ${PATROL_WORKFLOW} must key its drift annotation on the clean mark ${REQUIRED_SET_CLEAN_MARK} this file renders (#9678)`, + ); } { From 95c175de2af9a3b9327137c2ebf1ce9eb975f388 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 14:27:21 +0000 Subject: [PATCH 2/3] chore(#9979): five more gates declare the repo-root file they really read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #9964 established the per-gate escape hatch for a population that is a repo-root FILE: dispatch-gates' extractor requires a path separator, so a bare `AGENTS.md` literal is a WORD and contributes no watch hint — the gate reads the file while naming nothing that can match it. The remedy is to declare the subtree spelling (`/**`), which `extractWatchHints` already accepts and `collapseHint` reduces back to that one path. Measured under #9979, nine of those (family, root file) pairs are genuine and all but the ratchet's were still invisible. Each gate now declares its own, in its own idiom, pinned by its own self-test: check:pm-skill-id-lint AGENTS.md (EXTRA_FILES) check:pm-governed-merges AGENTS.md, CLAUDE.md (the two `exact` rows) check:pm-governed-prose AGENTS.md (PROSE_SURFACES) check:docs-audit-scope AGENTS.md (AGENTS_REL) check:required-contexts AGENTS.md (INSTRUCTION_SURFACES) check:doc-anchors README.md, ARCHITECTURE.md (EXTRA_SOURCES) Every declaration is PROVENANCE, never a lookup key — each gate opens its real constant, and the glob spelling appearing there would send it reading a path that does not exist (silently, in doc-anchors' case, since `existsSync` filters the extra sources out). Both halves are pinned per file: the declaration covers the real population, and nothing declared is a value the gate looks up. Zero changes to `extractWatchHints` or `hintCovers` — the bare-`*.md` widening stays refused, and the new cases pin that these declarations bought no part of it (`examples/AGENTS.md` and the cross-root pairs stay unreached). Derivation, measured before -> after: AGENTS.md 1 -> 7 families (6 in lint.yml + the #9678 patrol) README.md 0 -> 1 ARCHITECTURE.md 0 -> 1 CLAUDE.md 0 -> 1 LICENSE 0 -> 0 (control) examples/AGENTS.md 0 -> 0 (control) Self-tests: dispatch-gates 314 -> 332, skill-id-lint 10 -> 14, governed-merges 77 -> 81, governed-prose 24 -> 28, check-audit-scope 22 -> 24, required-contexts 119 -> 123, doc-anchors 17 -> 22 assertions. check:doc-anchors' "by judgment" docblock now points at its declaration; its COVERAGE is unchanged (251 fragment links across 399 sources, same as before). Part of #9979 --- scripts/check-doc-anchors.mjs | 64 ++++++++++++++++++++++-- scripts/check-required-contexts.mjs | 49 ++++++++++++++++++ scripts/docs-audit/check-audit-scope.mjs | 29 +++++++++++ scripts/pm/check-governed-merges.mjs | 44 ++++++++++++++++ scripts/pm/check-governed-prose.mjs | 37 ++++++++++++++ scripts/pm/check-skill-id-lint.mjs | 32 ++++++++++++ scripts/pm/dispatch-gates.mjs | 39 +++++++++++++++ 7 files changed, 289 insertions(+), 5 deletions(-) diff --git a/scripts/check-doc-anchors.mjs b/scripts/check-doc-anchors.mjs index 60c80f2ab1..b6021f1a56 100644 --- a/scripts/check-doc-anchors.mjs +++ b/scripts/check-doc-anchors.mjs @@ -146,11 +146,12 @@ import { stripCodeSpans, stripFencedBlocks } from './check-adr-links.mjs'; * substring of it rather than a second constant, so the two cannot drift apart * — the failure that would otherwise replace a silent gate with a lying one. * - * What this does NOT reach: `EXTRA_SOURCES` below. A top-level FILE name - * carries no separator either, and teaching the scanner to accept one would - * admit every `package.json` basename in the tree. A card editing only - * `README.md` or `ARCHITECTURE.md` still has to reach this gate by judgment; - * that residue is recorded in `hintCovers`' docblock as a decided loss. + * What this constant does NOT reach: `EXTRA_SOURCES` below. A top-level FILE + * name carries no separator either, and teaching the scanner to accept one + * would admit every `package.json` basename in the tree — that widening stays + * refused, and `hintCovers`' docblock carries the measurement. Those two files + * are declared instead, one line down, in the subtree spelling the extractor + * already accepts; they no longer depend on a dispatching seat's judgment. */ const CONTENT_GLOB = 'content/**'; @@ -161,6 +162,33 @@ const CONTENT_ROOT = CONTENT_GLOB.slice(0, CONTENT_GLOB.indexOf('/')); /** Link sources outside `content/`, matching the lychee globs. */ const EXTRA_SOURCES = ['README.md', 'ARCHITECTURE.md']; +/** + * `EXTRA_SOURCES` above, declared for `scripts/pm/dispatch-gates.mjs` (#9979, + * applying #9964's pattern). + * + * `CONTENT_GLOB` closed the `content/**` half of this gap; these two files were + * the remainder, and they are the ones it costs most to miss. This gate is + * REQUIRED in lint.yml and is the repo's ONLY fragment coverage + * (`check-links.yml` sets `include_fragments = "none"` and says so — a link to + * a heading that does not exist is reported `[200] OK` there). Until this + * declaration, a card editing only `README.md` or `ARCHITECTURE.md` derived + * ZERO gates and met that coverage as red CI instead of as a local command. + * + * `/**` is the form that reaches a repo-root file: the extractor requires + * a separator, and `collapseHint` reduces the hint back to that one path before + * comparing. Nothing in this tree lives under `README.md/` or + * `ARCHITECTURE.md/`, so neither claims a directory, and a same-named file + * inside one (a package's own `README.md`) is not reached — which is correct, + * because this gate reads the repo-root pair and nothing else. + * + * ⚠️ Provenance, NOT a lookup key. `listSources` joins each `EXTRA_SOURCES` + * entry with the repo root and stats it; the glob spelling appearing there + * would make every extra source vanish from the sweep — silently, since + * `existsSync` filters them out, which is the "checked nothing, reported green" + * disease this file's own header opens with. The self-test pins both halves. + */ +const ROOT_FILE_WATCH_HINTS = ['README.md/**', 'ARCHITECTURE.md/**']; + const PAGE_EXTENSIONS = ['.mdx', '.md']; /* --------------------------------------------------------------- heading ids */ @@ -542,6 +570,32 @@ function selfTest() { const live = sweep(); assert(live.checked > 0, 'the real corpus yielded zero fragment links — the extractor is over-stripping'); + // 8. The dispatch-gates declaration (#9979). Enforcement cannot hold any of + // these: the declaration is read by another tool entirely, so a wrong or + // missing entry runs perfectly green here and shows up only as a dev + // dispatched on a README.md / ARCHITECTURE.md card who is not told that + // this REQUIRED gate — the repo's only fragment coverage — reads it. + assert( + EXTRA_SOURCES.every((f) => ROOT_FILE_WATCH_HINTS.includes(`${f}/**`)), + `every extra source declares a root-file watch hint: ${EXTRA_SOURCES.join(', ')} vs ${ROOT_FILE_WATCH_HINTS.join(', ')}`, + ); + assert( + ROOT_FILE_WATCH_HINTS.every((h) => EXTRA_SOURCES.includes(h.replace(/\/\*+$/, ''))), + `the declaration names no file this gate does not read: ${ROOT_FILE_WATCH_HINTS.join(', ')}`, + ); + // Provenance, never a lookup key: `listSources` joins each EXTRA_SOURCES + // entry with the repo root, so the glob form there would drop both from the + // sweep — and `existsSync` would drop them SILENTLY. + assert( + !EXTRA_SOURCES.some((f) => ROOT_FILE_WATCH_HINTS.includes(f)), + 'the declared form is NOT an EXTRA_SOURCES entry — it would silently empty the extra-source half of the sweep', + ); + // The population the declaration claims is the one the gate really reads. + assert( + ROOT_FILE_WATCH_HINTS.every((h) => listSources(process.cwd()).includes(h.replace(/\/\*+$/, ''))), + 'the declared root files are in the live source population this gate sweeps', + ); + console.log( `✅ check-doc-anchors --self-test: slug parity, custom ids, duplicate counters, extraction discrimination and both finding classes verified (${live.checked} live fragment links)`, ); diff --git a/scripts/check-required-contexts.mjs b/scripts/check-required-contexts.mjs index e8b8dbca88..49d6f0dd83 100644 --- a/scripts/check-required-contexts.mjs +++ b/scripts/check-required-contexts.mjs @@ -463,6 +463,29 @@ export const INSTRUCTION_SURFACES = [ { file: 'docs/releases-maintenance.md', mustName: [] }, ]; +/** + * The repo-ROOT surface above, declared for `scripts/pm/dispatch-gates.mjs` + * (#9979, applying #9964's pattern). + * + * That tool derives a card's gate list from the path literals in each gate's + * own source, and "looks like a path" there means "carries a separator". Four + * of the five surfaces have one; `AGENTS.md` does not, because a repo-root FILE + * has no separator to be found by — so an AGENTS.md card derived this gate not + * at all, while that surface is the one carrying `mustName` for all six + * required contexts (widened 2→6 by the #9677 ruling). Editing the merge-queue + * paragraph there is precisely how this gate goes red, and it was reachable + * only by judgment. + * + * `/**` is the form that reaches a root file: the extractor accepts it, + * and `collapseHint` reduces it back to that one path and to nothing else. + * + * ⚠️ Provenance, NOT a lookup key. `scanInstructionSurfaces` opens every + * surface `file`; the glob spelling appearing there would send this gate + * reading a path that does not exist — and a surface it cannot read is a hard + * failure here by design (#4690). The self-test pins both halves. + */ +export const ROOT_FILE_WATCH_HINTS = ['AGENTS.md/**']; + /** * Former required-context names — permanent history, append-only. A row is * added BY the rename PR (the scan is red on the old literal until it is) and @@ -1870,6 +1893,32 @@ async function selfTest() { judgeSurfaces({ surfaces: [] }).problems.some((p) => p.includes('scan set is empty')), 'an empty scan set ⇒ red, never a silent tick (#4690)', ); + + // ── the dispatch-gates declaration (#9979) ─────────────────────────────── + // + // Enforcement cannot hold any of these: the declaration is read by another + // tool entirely, so a wrong or missing entry runs perfectly green here and + // shows up only as a dev dispatched on an AGENTS.md card with this gate + // absent from the brief — on the surface that carries `mustName` for all six + // required contexts. + assert( + INSTRUCTION_SURFACES.map((s) => s.file) + .filter((f) => !f.includes('/')) + .every((f) => ROOT_FILE_WATCH_HINTS.includes(`${f}/**`)), + 'every separator-less instruction surface declares a root-file watch hint (#9979)', + ); + assert( + ROOT_FILE_WATCH_HINTS.every((h) => INSTRUCTION_SURFACES.some((s) => s.file === h.replace(/\/\*+$/, ''))), + 'and the declaration names no file this gate does not scan (#9979)', + ); + assert(ROOT_FILE_WATCH_HINTS.join(',') === 'AGENTS.md/**', 'AGENTS.md is the root surface it declares (#9979)'); + // Provenance, never a lookup key: `scanInstructionSurfaces` opens every + // surface `file`, so the glob form appearing there would read a missing file + // — a hard failure here by design. + assert( + !INSTRUCTION_SURFACES.some((s) => ROOT_FILE_WATCH_HINTS.includes(s.file)), + 'the declared form is NOT an INSTRUCTION_SURFACES file (#9979)', + ); assert( judgeSurfaces({ files: new Map() }).problems.filter((p) => p.includes('was never read')).length === INSTRUCTION_SURFACES.length, diff --git a/scripts/docs-audit/check-audit-scope.mjs b/scripts/docs-audit/check-audit-scope.mjs index 55a8ba50f0..80d95eb743 100644 --- a/scripts/docs-audit/check-audit-scope.mjs +++ b/scripts/docs-audit/check-audit-scope.mjs @@ -95,6 +95,26 @@ const REPO_ROOT = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: H const WORKFLOW_REL = '.claude/workflows/docs-accuracy-audit.js'; const AGENTS_REL = 'AGENTS.md'; + +/** + * `AGENTS_REL` above, declared for `scripts/pm/dispatch-gates.mjs` (#9979, + * applying #9964's pattern). + * + * That tool derives a card's gate list from the path literals in each gate's + * own source, and "looks like a path" there means "carries a separator". + * `WORKFLOW_REL` has one and reaches it; `AGENTS_REL` does not, because a + * repo-root FILE has no separator to be found by — so an AGENTS.md card + * derived this gate not at all, while `assertGuardrailAnchored` reddens on + * exactly the edit such a card is most likely to make (moving or rewording the + * RELEASE-OWNED guardrail row). `/**` is the form that reaches a root + * file: the extractor accepts it, and `collapseHint` reduces it back to that + * one path. + * + * ⚠️ Provenance, NOT a lookup key. `assertGuardrailAnchored` opens + * `AGENTS_REL`; the glob spelling appearing there would send this gate reading + * a file that does not exist. The self-test pins both halves. + */ +export const ROOT_FILE_WATCH_HINTS = ['AGENTS.md/**']; const BEGIN = '// '; const END = '// '; @@ -692,6 +712,15 @@ async function selfTest() { null, findGuardrailRow('| `content/docs/releases/` | generated | see the release process |'), ); + // The dispatch-gates declaration (#9979). Enforcement cannot hold either of + // these: the declaration is read by another tool entirely, so a wrong or + // missing entry runs perfectly green here and shows up only as a dev + // dispatched on an AGENTS.md card with this gate absent from the brief. + check('the repo-root file this gate reads is declared for dispatch-gates', [`${AGENTS_REL}/**`], ROOT_FILE_WATCH_HINTS); + // Provenance, never a lookup key: `assertGuardrailAnchored` opens + // `AGENTS_REL`, so the glob form appearing there would read a missing file. + check('…and the declared form is not the path the gate opens', false, ROOT_FILE_WATCH_HINTS.includes(AGENTS_REL)); + throws( 'a workflow without the prefix constant throws', () => parseReleaseOwnedPrefix('const ALL_HANDWRITTEN = []'), diff --git a/scripts/pm/check-governed-merges.mjs b/scripts/pm/check-governed-merges.mjs index fb9fbaf8ca..4da69b1b3f 100644 --- a/scripts/pm/check-governed-merges.mjs +++ b/scripts/pm/check-governed-merges.mjs @@ -279,6 +279,34 @@ export const GOVERNED_SURFACES = Object.freeze([ Object.freeze({ id: 'claude-md', exact: 'CLAUDE.md', glob: 'CLAUDE.md', what: 'the repo-root Claude instruction file' }), ]); +/** + * The register's repo-ROOT rows, declared for `scripts/pm/dispatch-gates.mjs` + * (#9979, applying #9964's pattern). + * + * That tool derives a card's gate list from the path literals in each gate's + * own source, and "looks like a path" there means "carries a separator". The + * three `prefix` rows above have one and reach dispatch-gates already — the + * `skills/**` row is one of the three specimens that motivated reading a hint + * AS WRITTEN. The two `exact` rows do not: a repo-root FILE carries no + * separator, so an `AGENTS.md` or `CLAUDE.md` card derived this gate not at all + * while the same card is GOVERNED by it (draft-only PR, maintainer merge) — + * the loudest possible thing to learn late. + * + * `/**` is the form that reaches one: the extractor accepts it, and + * `collapseHint` reduces it back to that single path. `examples/AGENTS.md` and + * the `create-objectstack` template copy stay out, exactly as the `exact` rows + * intend. + * + * ⚠️ Provenance, NOT a matcher. `governedSlice` compares against `exact`, and + * `glob` is the spelling the instruction files must carry verbatim + * (`check-governed-prose.mjs` asserts prose containment against it, and both + * files spell these two as bare filenames). Rewriting either field into the + * glob form would silently change what this register GOVERNS and what the + * prose gate demands; this list is read by neither. The self-test pins both + * halves. + */ +export const ROOT_FILE_WATCH_HINTS = ['AGENTS.md/**', 'CLAUDE.md/**']; + /** * The four repos the 2026-08-18 cross-repo extension governs. `id` doubles as * the sibling directory name beside this checkout — the layout every session @@ -849,6 +877,22 @@ function selfTest() { assert('near-misses-stay-out', ids(['docs/adrs/z.md', '.claude-x/y.md', 'skillsx/a.md', 'examples/AGENTS.md', 'packages/create-objectstack/src/templates/AGENTS.md', 'apps/CLAUDE.md.bak']).length === 0, JSON.stringify(ids(['examples/AGENTS.md']))); assert('a-mixed-diff-groups-by-surface', ids(['docs/adr/0001.md', 'AGENTS.md', 'package.json']).join() === 'adr,agents-md'); + // ── the dispatch-gates declaration (#9979) ─────────────────────────────── + // + // Enforcement cannot hold any of these: the declaration is read by another + // tool entirely, so a wrong or missing entry runs perfectly green here and + // shows up only as a dev dispatched on a root-file card who is not told that + // the card is GOVERNED. + const rootExacts = GOVERNED_SURFACES.filter((s) => s.exact).map((s) => s.exact); + assert('every-exact-root-row-declares-a-watch-hint', rootExacts.every((f) => ROOT_FILE_WATCH_HINTS.includes(`${f}/**`)), JSON.stringify(rootExacts)); + assert('the-declaration-names-no-file-this-register-does-not-govern', ROOT_FILE_WATCH_HINTS.every((h) => rootExacts.includes(h.replace(/\/\*+$/, ''))), JSON.stringify(ROOT_FILE_WATCH_HINTS)); + assert('both-root-instruction-files-are-declared', ROOT_FILE_WATCH_HINTS.join(',') === 'AGENTS.md/**,CLAUDE.md/**', ROOT_FILE_WATCH_HINTS.join(',')); + // Provenance, never a matcher: `governedSlice` compares against `exact` and + // `check-governed-prose.mjs` demands `glob` verbatim in the instruction + // files. The glob spelling appearing in either field would change what this + // register governs, and what that gate requires the prose to say. + assert('the-declared-form-is-neither-an-exact-nor-a-glob-value', !GOVERNED_SURFACES.some((s) => ROOT_FILE_WATCH_HINTS.includes(s.exact) || ROOT_FILE_WATCH_HINTS.includes(s.glob))); + // ── subject → PR (both GitHub spellings; the trailing parenthetical wins) ─ assert('squash-subject', pullNumberFromSubject('fix(api): envelope the error paths (#9456)') === 9456); assert('merge-subject', pullNumberFromSubject('Merge pull request #123 from x/y') === 123); diff --git a/scripts/pm/check-governed-prose.mjs b/scripts/pm/check-governed-prose.mjs index 0e0e987287..380f71047d 100644 --- a/scripts/pm/check-governed-prose.mjs +++ b/scripts/pm/check-governed-prose.mjs @@ -112,6 +112,29 @@ export const PROSE_SURFACES = Object.freeze([ }), ]); +/** + * The repo-ROOT surface above, declared for `scripts/pm/dispatch-gates.mjs` + * (#9979, applying #9964's pattern). + * + * That tool derives a card's gate list from the path literals in each gate's + * own source, and "looks like a path" there means "carries a separator". The + * skill surface has one; `AGENTS.md` does not, so this gate's two regions + * yielded one hint and an AGENTS.md card derived it not at all — on a file + * where this gate is one of the strictest things that can go red, and where the + * remedy is famously NOT the obvious edit (the enumeration sits inside a + * verbatim, untranslated maintainer quotation that must never be rewritten to + * satisfy a gate). + * + * `/**` is the form that reaches a root file: the extractor accepts it + * and `collapseHint` reduces it back to that one path. + * + * ⚠️ Provenance, NOT a lookup key. Every `path` in `PROSE_SURFACES` is opened + * by the run; the glob spelling appearing there would send this gate looking + * for a file that does not exist — red on a missing input, which this file + * treats as a hard failure by design. The self-test pins both halves. + */ +export const ROOT_FILE_WATCH_HINTS = ['AGENTS.md/**']; + /** * The region between two anchors. Pure. Returns `{ ok: false, reason }` rather * than throwing, so the caller decides the exit code and the self-test can @@ -286,6 +309,20 @@ function selfTest() { if (region.ok) assert(`${surface.path}: region is clean`, verdict(region.text, globs), { missing: [], unknown: [] }); } + // --- the dispatch-gates declaration (#9979) ---------------------------- + // + // Enforcement cannot hold any of these: the declaration is read by another + // tool entirely, so a wrong or missing entry runs perfectly green here and + // shows up only as a dev dispatched on an AGENTS.md card with this gate + // absent from the brief. + const rootSurfaces = PROSE_SURFACES.map((s) => s.path).filter((p) => !p.includes('/')); + assert('every separator-less prose surface declares a root-file watch hint', rootSurfaces.every((p) => ROOT_FILE_WATCH_HINTS.includes(`${p}/**`)), true); + assert('the declaration names no file this gate does not read', ROOT_FILE_WATCH_HINTS.every((h) => PROSE_SURFACES.some((s) => s.path === h.replace(/\/\*+$/, ''))), true); + assert('AGENTS.md is the root surface it declares', ROOT_FILE_WATCH_HINTS, ['AGENTS.md/**']); + // Provenance, never a lookup key: the run opens every `path`, so the glob + // form appearing there would make this gate read a path that does not exist. + assert('the declared form is NOT a PROSE_SURFACES path', PROSE_SURFACES.some((s) => ROOT_FILE_WATCH_HINTS.includes(s.path)), false); + const failed = cases.filter((c) => !c.ok); for (const c of failed) { console.error(` ✗ ${c.name}\n expected ${JSON.stringify(c.expected)}\n actual ${JSON.stringify(c.actual)}`); diff --git a/scripts/pm/check-skill-id-lint.mjs b/scripts/pm/check-skill-id-lint.mjs index 930bf46a88..1044b5356a 100644 --- a/scripts/pm/check-skill-id-lint.mjs +++ b/scripts/pm/check-skill-id-lint.mjs @@ -54,6 +54,27 @@ export const SCAN_ROOT = '.claude/skills/pm-dispatch'; export const EXTRA_FILES = ['.claude/agents/os-dev.md', 'AGENTS.md']; export const ID_PATTERN = /#[0-9]{3,}/g; +/** + * The repo-ROOT files above, declared for `scripts/pm/dispatch-gates.mjs` + * (#9979, applying #9964's pattern). + * + * That tool derives a card's gate list from the path literals in each gate's + * own source, and "looks like a path" there means "carries a separator". Both + * of the constants above satisfy that except `AGENTS.md` — a repo-root FILE has + * no separator to be found by — so this gate contributed hints for its skill + * tree and its os-dev.md twin, and an AGENTS.md card derived it not at all. + * `/**` is the form that reaches one: the extractor accepts it, and + * `collapseHint` reduces it back to `AGENTS.md` and to nothing else. Nothing in + * the tree lives under `AGENTS.md/`, so it claims no directory and no + * same-named file inside one (`examples/AGENTS.md` stays out). + * + * ⚠️ Provenance, NOT a lookup key. `run` opens every path in `EXTRA_FILES`; + * the glob spelling appearing there would send this gate looking for a file + * that does not exist — red under the cannot-read rule, for a file that is + * fine. The self-test pins both halves. + */ +export const ROOT_FILE_WATCH_HINTS = ['AGENTS.md/**']; + // Exact-count legacy waiver (see header): pass iff count === legacy || count === 0. export const LEGACY_EXACT = new Map([]); @@ -156,6 +177,17 @@ function selfTest() { ['red message names the standard', verdictFor('x.md', 2).msg.includes('self-contained'), true], ['placeholders stay legal', ('# #N epic:# #12 #34'.match(ID_PATTERN) ?? []).length, 0], ['real IDs match', ('see #4650 and #12345'.match(ID_PATTERN) ?? []).length, 2], + // The dispatch-gates declaration (#9979). Enforcement cannot hold any of + // these: the declaration is read by another tool entirely, so a wrong or + // missing entry runs perfectly green here and shows up only as a dev + // dispatched on an AGENTS.md card with this gate absent from the brief. + ['every separator-less scanned file declares a root-file watch hint', EXTRA_FILES.filter((f) => !f.includes('/')).every((f) => ROOT_FILE_WATCH_HINTS.includes(`${f}/**`)), true], + ['and the declaration names no file this gate does not scan', ROOT_FILE_WATCH_HINTS.every((h) => EXTRA_FILES.includes(h.replace(/\/\*+$/, ''))), true], + ['AGENTS.md is the root file it declares', ROOT_FILE_WATCH_HINTS.includes('AGENTS.md/**'), true], + // Provenance, never a lookup key: `run` opens every EXTRA_FILES entry, so + // the glob form appearing there would make this gate read a path that does + // not exist — red on a missing input, for a file that is fine. + ['the declared form is NOT an EXTRA_FILES entry', EXTRA_FILES.some((f) => ROOT_FILE_WATCH_HINTS.includes(f)), false], ]; let failed = 0; for (const [name, actual, expected] of cases) { diff --git a/scripts/pm/dispatch-gates.mjs b/scripts/pm/dispatch-gates.mjs index f85bd0ef00..e9ee31c607 100644 --- a/scripts/pm/dispatch-gates.mjs +++ b/scripts/pm/dispatch-gates.mjs @@ -2680,6 +2680,45 @@ function selfTest() { t('nor a same-named file inside a directory', !lineRatchetHints.some((h) => hintCovers(h, 'examples/AGENTS.md'))); t('a bare top-level file literal is still no hint at all', extractWatchHints("const F = 'README.md';").length === 0); + // The rest of that class (#9979). The ratchet above was one of SIX families + // whose population genuinely includes a repo-root instruction file; the other + // five were measured still invisible, so an AGENTS.md card derived ONE gate + // out of six and a README.md / ARCHITECTURE.md card derived NONE at all — + // while `check:doc-anchors` is REQUIRED in lint.yml and is this repo's only + // fragment coverage. Each declares the subtree spelling in its own source. + // + // Read from the real gates, not fixtures: what is pinned is that the tree + // still HAS the declarations. If one of these gates stops reading its root + // file, delete its case with the declaration — never keep a case green by + // re-pointing it at a gate that never read the file. + const rootFileDeclarations = [ + ['the pm skill-id lint', 'scripts/pm/check-skill-id-lint.mjs', 'AGENTS.md'], + ['the governed-merge register', 'scripts/pm/check-governed-merges.mjs', 'AGENTS.md'], + ['the governed-merge register (CLAUDE.md half)', 'scripts/pm/check-governed-merges.mjs', 'CLAUDE.md'], + ['the governed-prose gate', 'scripts/pm/check-governed-prose.mjs', 'AGENTS.md'], + ['the docs-audit scope gate', 'scripts/docs-audit/check-audit-scope.mjs', 'AGENTS.md'], + ['the required-context pin', 'scripts/check-required-contexts.mjs', 'AGENTS.md'], + ['the doc-anchors gate', 'scripts/check-doc-anchors.mjs', 'README.md'], + ['the doc-anchors gate (ARCHITECTURE.md half)', 'scripts/check-doc-anchors.mjs', 'ARCHITECTURE.md'], + ]; + for (const [what, gate, rootFile] of rootFileDeclarations) { + const gateHints = extractWatchHints(readFileSync(join(ROOT, gate), 'utf8')); + t(`${what} reaches the repo-root file it declares (${rootFile})`, gateHints.some((h) => hintCovers(h, rootFile))); + // The negative half, and the reason each of these is a DECLARATION rather + // than an extractor change: a declaration must buy its own file and NOT the + // bare-`*.md` class the extractor still refuses. `examples/AGENTS.md` is + // the live specimen — a real tracked file, same basename, not read by any + // of these gates (check-governed-merges' own near-miss case names it). + t(`${what} claims no same-named file inside a directory`, !gateHints.some((h) => hintCovers(h, `examples/${rootFile}`))); + } + // …and the root files stay separated from each other: the governed-merge + // register is the only one of the six that declares two, and nothing here may + // reach a root file its gate does not read. + const proseHints = extractWatchHints(readFileSync(join(ROOT, 'scripts/pm/check-governed-prose.mjs'), 'utf8')); + t('a one-root declaration does not reach the other root file', !proseHints.some((h) => hintCovers(h, 'CLAUDE.md'))); + const anchorRootHints = extractWatchHints(readFileSync(join(ROOT, 'scripts/check-doc-anchors.mjs'), 'utf8')); + t('and the doc-anchors pair claims neither instruction file', !anchorRootHints.some((h) => hintCovers(h, 'AGENTS.md') || hintCovers(h, 'CLAUDE.md'))); + // ── A trailing sentence period is not part of the path (#8534, half two) ── // // Coupled to the rule above: the raw-prefix comparison reached the real file From 9b479c512d2427607e158bd2a84b82e8aa17220d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 14:29:49 +0000 Subject: [PATCH 3/3] fix(#9678): the patrol wiring pins report their own verdict when it is missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by reverse verification of the block added in this branch: deleting required-set-patrol.yml made the self-test die on an uncaught ENOENT instead of reporting. Two consequences, both the #4690 family: - the `exactly one workflow runs the live read` failure was recorded and never printed, and every assertion after it never ran — a stack trace where the gate's own authored verdict belongs; - `!/merge_group/` on an unread file is vacuously TRUE, so the assertion that the patrol can never be validly required-ized would have passed about a workflow that does not exist. The file is now read through `existsSync`, its absence is its own named assertion, and each downstream case carries `patrolPresent` so none of them can go green on a file nobody read. Re-measured under the same ablation: 4 named failures, no throw. 123 -> 124 assertions. Part of #9678 --- scripts/check-required-contexts.mjs | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/scripts/check-required-contexts.mjs b/scripts/check-required-contexts.mjs index 49d6f0dd83..dce4b60373 100644 --- a/scripts/check-required-contexts.mjs +++ b/scripts/check-required-contexts.mjs @@ -2147,8 +2147,18 @@ async function selfTest() { `wiring: exactly one workflow runs the live read and it is ${PATROL_WORKFLOW} — found [${callers.join(', ')}] (#9678)`, ); - const patrol = readFileSync(join(workflowDir, PATROL_WORKFLOW), 'utf8'); - const patrolYaml = uncommentedYaml(patrol); + // Read defensively and turn "missing" into a NAMED assertion rather than an + // uncaught ENOENT. Measured under reverse verification: deleting the patrol + // made this block throw mid-self-test, so the `callers` failure above was + // recorded and never printed and every later assertion never ran — a stack + // trace where the gate's own authored verdict belongs (#4690's family). The + // downstream cases each carry `patrolPresent` for the same reason: on an + // absent file `!/merge_group/` is vacuously true, which is a false green + // about a workflow that does not exist. + const patrolPath = join(workflowDir, PATROL_WORKFLOW); + const patrolPresent = existsSync(patrolPath); + assert(patrolPresent, `wiring: the standing caller .github/workflows/${PATROL_WORKFLOW} is missing — the live mode is wired nowhere again (#9678)`); + const patrolYaml = patrolPresent ? uncommentedYaml(readFileSync(patrolPath, 'utf8')) : ''; // The mechanical proxy for "never required". Assertion 6 of this pin is // that every required context's workflow carries `merge_group:` — without // one, the queue build never produces the context and the whole queue @@ -2156,7 +2166,7 @@ async function selfTest() { // therefore CANNOT be validly required-ized, and required-izing it anyway // wedges the queue rather than deadlocking a rename two-step quietly. assert( - !/^\s{0,4}merge_group\s*:/m.test(patrolYaml), + patrolPresent && !/^\s{0,4}merge_group\s*:/m.test(patrolYaml), `wiring: ${PATROL_WORKFLOW} must declare no merge_group trigger — a workflow without one deadlocks the queue if it is ever required-ized (#9678)`, ); assert( @@ -2171,7 +2181,7 @@ async function selfTest() { // the same character. A rendering change costs the patrol a FALSE ALARM, // never a false all-clear — it annotates on the mark's ABSENCE. assert( - patrolYaml.includes(REQUIRED_SET_CLEAN_MARK), + patrolPresent && patrolYaml.includes(REQUIRED_SET_CLEAN_MARK), `wiring: ${PATROL_WORKFLOW} must key its drift annotation on the clean mark ${REQUIRED_SET_CLEAN_MARK} this file renders (#9678)`, ); }