From b2072b3d4940600e6a04072ed62c27c0783cbc79 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 16:12:16 +0000 Subject: [PATCH 1/2] fix(pm): governed-merges audit covers all four governed repos, attributes through the channel it has, and answers the pre-arm question MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three deliverables in the one audit script: 1. Multi-repo scope (#9619). One invocation sweeps objectstack, objectui, cloud and objectos, resolved from the script's own root rather than the working directory, so the answer no longer depends on where it is run. An absent or wrong-origin checkout reports as UNAUDITED and exits 2 — never as a clean repo (#4690). 2. Attribution through a channel the session actually has (#9619). Channels are tried in order (env token, then anonymous REST) and the transport trap is fixed with them: node's fetch does not read HTTPS_PROXY, so a proxied run read 401/403 on every channel and looked like a credential problem. Sweep mode re-execs once with --use-env-proxy. When every channel fails the reason is stated ONCE per repo as a named line instead of a per-entry UNRESOLVED that buried the list. 3. The --test pre-arm predicate (#9550). `--test ` answers "would a PR touching these be governed?" from GOVERNED_SURFACES: exit 3 governed, exit 0 not governed, exit 1 on an empty path list. The incident it comes from was recall in place of derivation; derivation is now one command. The header gains #9550's measured timeline and the reading that merged_by names an account, not a principal — the maintainer also operates the seat accounts, so a resolved column prompts recognition and never settles it. Self-test: 25 -> 77 assertions. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WswfK2yNYT9hNnMH6TzAwL --- scripts/pm/check-governed-merges.mjs | 781 +++++++++++++++++++++++---- 1 file changed, 675 insertions(+), 106 deletions(-) diff --git a/scripts/pm/check-governed-merges.mjs b/scripts/pm/check-governed-merges.mjs index 01adcfd9c4..15ad014725 100644 --- a/scripts/pm/check-governed-merges.mjs +++ b/scripts/pm/check-governed-merges.mjs @@ -3,19 +3,46 @@ /** * check-governed-merges — report-only post-merge audit of the governed - * surfaces (#9495). Enumerates the PRs that MERGED into `main` since a given - * date/ref whose diff touched a governed surface, with merge attribution, for - * the PM round report and the report-only patrol family (the - * `check-half-states.mjs` precedent: a completed sweep exits 0 whether it - * found 0 or 40 entries; non-zero exits classify the ENVIRONMENT, not the - * tree). + * surfaces (#9495), across the four governed repos (#9619), plus the + * pre-arm `--test` predicate every seat runs before flipping ready (#9550). + * Enumerates the PRs that MERGED into `main` since a given date/ref whose diff + * touched a governed surface, with merge attribution, for the PM round report + * and the report-only patrol family (the `check-half-states.mjs` precedent: a + * completed sweep exits 0 whether it found 0 or 40 entries; non-zero exits + * classify the ENVIRONMENT, not the tree). * - * node scripts/pm/check-governed-merges.mjs # sweep, last 24h + * node scripts/pm/check-governed-merges.mjs # sweep, last 24h, all four repos * node scripts/pm/check-governed-merges.mjs --since 7d # or 36h, or ISO date * node scripts/pm/check-governed-merges.mjs --since-ref v5.0.0-rc.3 + * node scripts/pm/check-governed-merges.mjs --repos objectstack,objectui + * node scripts/pm/check-governed-merges.mjs --repo-root cloud=/srv/cloud * node scripts/pm/check-governed-merges.mjs --json # for round reports + * node scripts/pm/check-governed-merges.mjs --test AGENTS.md src/x.ts # pre-arm predicate * node scripts/pm/check-governed-merges.mjs --self-test # offline, no network * + * ## Exit codes — the refusal to read as clean, in one table + * + * Sweep mode (default): + * 0 swept COMPLETELY — every governed repo audited, every entry attributed. + * Zero entries and forty entries both exit 0; the list is the product. + * 1 could not sweep at all — bad args, unreadable `--since-ref`. + * 2 swept, but INCOMPLETE — at least one repo unaudited (no checkout, wrong + * origin, unreadable `origin/main`) and/or at least one entry's + * attribution unresolved on every channel. Incomplete must never read as + * clean (#4690): an unaudited repo is not a repo with nothing to report, + * and a list whose whole point is "does the maintainer recognise every + * entry" is incomplete without the who-merged-it column. + * + * `--test` mode is a PREDICATE, so it answers on its own codes and shares only + * the failure code with the sweep: + * 0 the given paths are NOT governed — ordinary queue landing applies. + * 3 the given paths ARE governed — human merge only. Deliberately NOT 1 or + * 2: a governed verdict must be impossible to confuse with the sweep's + * "could not sweep" / "incomplete", so `if cmd; then` and `$?` readings + * cannot silently turn a governed answer into an environment complaint. + * 1 bad args — no paths given. ⛔ Silence never reads as "not governed": + * `--test` with an empty path list is a failure, never a green light. + * * ## The regime this audit belongs to (maintainer ruling, 2026-08-18) * * A human merge IS the review record for a governed PR. The seat put it as @@ -53,9 +80,28 @@ * `CLAUDE.md`。混合 diff 照现行规则一条命中即整 PR 分叉」. The two file * entries are the REPO-ROOT instruction files exactly — not * `examples/AGENTS.md` (an example-tree file) and not the - * `create-objectstack` template copy (product content); widening to those, or - * to the sibling repos (objectui / cloud carry the same convention in their - * own AGENTS.md), is a separate decision this script does not take. + * `create-objectstack` template copy (product content). + * + * ## The governed REPOS (maintainer 「同意」 2026-08-18, wired here by #9619) + * + * The same day, asked whether the rule reaches the sibling repos — 「任何对 + * agents.md 等文件的修改…包括 objectui cloud仓库」 — the maintainer answered + * 「同意」. So the surface register above is REPO-AGNOSTIC: the same five + * globs are governed in `objectstack`, `objectui`, `cloud` and `objectos`, + * and this sweep covers all four in one invocation regardless of the working + * directory it is run from. + * + * ⚠️ Until #9619 that was not true, and the gap was not theoretical: run from + * the objectui checkout the sweep still enumerated objectstack PRs, so a + * governed merge in objectstack showed up and the identical merge in objectui + * did not. objectui PR #5188 (`AGENTS.md` + `skills/**`) landed on + * 2026-08-18 and appeared in no audit output at all — a seat happened to + * catch it reading `git log` by hand. + * + * Sibling repos are not always checked out. An absent checkout reports as + * **UNAUDITED** and exits 2 — never as a clean repo (#4690). "Nothing was + * found" and "nothing was looked at" are different facts and this sweep is + * required to keep them apart. * * ## Institutional memory — why governed surfaces are guarded at all * @@ -95,41 +141,127 @@ * LATER dispatch reads, so a bad landing there propagates into work nobody * has started yet — that is why the governed surface covers the agent * instruction tree, and (2026-08-18) the repo-root instruction files too. + * - 2026-08-18, #9550 — the first datapoint on the bet this regime made, and + * it arrived NINETEEN MINUTES in. Measured timeline, one file in the diff + * (`AGENTS.md`, PR #9527): 07:32:55Z #9495 lands (86ea8df7d) and the + * repo-root instruction files become governed → 07:51:53Z a PM seat flips + * the PR `ready_for_review` (+19 min) → 07:52:06Z the same seat enqueues it + * (+13 s) → 08:08:35Z `github-merge-queue[bot]` removes it, on a merge + * CONFLICT against 86ea8df7d, the very commit that had made the file + * governed → 08:52:08Z another seat converts it back to draft. Both the + * flip and the enqueue are forbidden on a governed PR. ⭐ The conflict, not + * the discipline, is what stopped it: had #9495 touched a different part of + * `AGENTS.md`, this would be a governed merge nobody authorised. The seat's + * own root cause, verbatim from its own report: at the moment of arming it + * reasoned 「`AGENTS.md` is none of those. So arming is fine」 — RECALL of a + * register that had changed 19 minutes earlier, not DERIVATION from it, by + * the same seat that had written "re-read it rather than recalling it" into + * a dispatch prompt hours before. ⇒ That is the whole reason `--test` + * exists: derivation is now ONE command, so a seat cannot be wrong about a + * list it never re-read. Detection existed; this is the missing prevention + * primitive, and it costs one process spawn. * * ## Attribution readings, measured not assumed * - * `merged_by` on this repo attributes to the human account for BOTH merge - * flows (measured 2026-08-18: a queue-flow landing and a direct merge both - * read `merged_by: hotlong`). If a future reading ever shows a bot login - * here, report it verbatim and extend the audit to read the enqueue actor - * from the issue timeline (`added_to_merge_queue`) — never remap silently. - * A mainline commit whose subject names NO PR is listed as its own loud - * entry (a direct push to `main` is more anomalous than any PR merge, not - * less). + * ⚠️ Read this before treating a resolved `merged_by` as an answer. + * `merged_by` names an ACCOUNT, not a PRINCIPAL, and the two do not + * correspond: the maintainer also operates the seat accounts. Measured + * 2026-08-18 — objectui PR #5188, a governed-surface merge, read + * `merged_by: os-steve` (a seat account) and was filed as a possible seat + * violation; asked directly, the maintainer answered 「5188 是我合并的」. So a + * token that resolves the column would NOT have answered the question; it + * would have returned a seat login and left the audit exactly as uncertain, + * while looking authoritative. The column is a PROMPT for the maintainer's + * recognition, never a substitute for it — which is why the report prints + * that caveat on every sweep that resolves anything, and why the fix for the + * 401 wall (below) is a channel chain, not a claim of authority. + * This is the same shared-identity trap the claim protocol documents, where + * the workaround is the session ID inside the claim comment; a merge carries + * no equivalent discriminator, and inventing one is a maintainer decision + * this script does not take (#9619 records the three options). + * On this repo `merged_by` has read as the human account for BOTH merge flows + * (measured 2026-08-18: a queue-flow landing and a direct merge both read + * `merged_by: hotlong`). If a future reading shows a bot login, report it + * verbatim and extend the audit to read the enqueue actor from the issue + * timeline (`added_to_merge_queue`) — never remap silently. A mainline commit + * whose subject names NO PR is listed as its own loud entry (a direct push to + * `main` is more anomalous than any PR merge, not less). + * + * ### The attribution channel chain (#9619, measured on the PM container) + * + * The PM session container exports no usable `GITHUB_TOKEN`/`GH_TOKEN` — + * GitHub reaches it through the MCP server, not the raw API — so the old + * single-channel read answered HTTP 401 for EVERY entry and the sweep's + * default outcome was "incomplete". An audit that always degrades to + * incomplete is one a reader learns to skim, which is how a real violation + * gets waved through. Channels are therefore tried in order and the first + * success wins: + * + * 1. env token — `GITHUB_TOKEN` / `GH_TOKEN`, when one is exported. + * 2. anonymous REST — no `authorization` header at all. Measured working + * 2026-08-18 for the public repos (`objectstack`, `objectui` and + * `objectos` all answered 200 with `merged_by` populated); `cloud` + * answered 403 at the session proxy, which is exactly the case the named + * fallback line below is for. + * + * ⚠️ NEITHER channel reaches GitHub at all unless node's fetch is pointed at + * the session proxy, and this is the trap that made the original 401 reading + * look like a token problem when it was a TRANSPORT problem. Measured + * 2026-08-18 in the PM container, all four readings on the same URL: + * + * curl (reads HTTPS_PROXY) → 200, merged_by present + * node fetch, no flag, anonymous → 403 + * node fetch, no flag, with the env token → 401 + * node fetch, NODE_OPTIONS=--use-env-proxy → 200, merged_by present + * + * Node's global fetch does NOT read `HTTPS_PROXY` on its own (Node 22), so a + * curl probe proves nothing about what this script will see — and the env + * token in an agent container is the literal string `proxy-injected`, a + * placeholder the PROXY swaps for a real credential. Bypass the proxy and it + * is a bad token (401); go through the proxy and both channels answer 200. + * `scripts/check-required-contexts.mjs` hit the identical trap (#9642) and + * names this file as sharing it. `--use-env-proxy` must be set at process + * start — assigning `process.env.NODE_USE_ENV_PROXY` from inside the script + * is too late (measured: still 403) — so sweep mode RE-EXECS itself once with + * the flag when a proxy is configured and the flag is absent, guarded by + * `process.allowedNodeEnvironmentFlags` so an older node gets the printed + * hint instead of a bad-option crash. `--test` and `--self-test` never + * re-exec: they touch no network. + * + * When every channel fails for an entry, the entry still prints (marked + * UNAVAILABLE, never silently blank) and the reason is stated ONCE per + * repo+reason group as a NAMED line that says which channels were tried and + * what each answered — instead of the per-entry error string that used to + * bury the list it was attached to. The sweep still exits 2. * * ## Cost discipline * - * Enumeration and diff-path reading are pure LOCAL git over `origin/main` — - * zero API calls; the sweep header prints the `origin/main` tip and its date - * so a stale local fetch is visible rather than silently under-reporting - * (run `git fetch origin main` first). The GitHub API is consulted only for + * Enumeration and diff-path reading are pure LOCAL git over each repo's + * `origin/main` — zero API calls; the sweep header prints every audited + * repo's tip and date so a stale local fetch is visible rather than silently + * under-reporting (run `git fetch origin main` in each first). A repo with no + * mainline commit in the window prints a note naming its tip date: local git + * cannot distinguish "quiet repo" from "stale mirror", so the note is + * informational and does not change the exit code — read the tip date, and + * fetch if it predates your last one. The GitHub API is consulted only for * ATTRIBUTION, one `GET /pulls/{n}` per governed entry — on the ordinary day - * with no governed merges the sweep costs ZERO lookups. With entries present - * but no usable token the sweep still prints the list, marks attribution - * UNRESOLVED, and exits 2 (environment) — a list whose whole point is "does - * the maintainer recognise every entry" is incomplete without the - * who-merged-it column, and incomplete must not read as clean (#4690). - * - * Exit codes: 0 = sweep complete (with or without entries); 1 = could not - * sweep (bad args, git failure); 2 = swept, but attribution could not be - * resolved for at least one entry (missing token / HTTP failure). + * with no governed merges the sweep costs ZERO lookups. `--test` never + * touches the network or git at all: it reads the register in this file. */ -import { execFileSync } from 'node:child_process'; -import { dirname, resolve } from 'node:path'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -const scriptDir = dirname(fileURLToPath(import.meta.url)); +const scriptPath = fileURLToPath(import.meta.url); +const scriptDir = dirname(scriptPath); + +/** The exit contract, named so the table above is machine-checkable. */ +export const EXIT_SWEPT = 0; +export const EXIT_CANNOT_SWEEP = 1; +export const EXIT_INCOMPLETE = 2; +export const EXIT_TEST_GOVERNED = 3; +export const EXIT_TEST_NOT_GOVERNED = 0; /** * The governed surfaces, in report order — the 2026-08-18 unified definition @@ -137,6 +269,7 @@ const scriptDir = dirname(fileURLToPath(import.meta.url)); * one repo-relative path byte-for-byte (the repo-ROOT instruction files, not * `examples/AGENTS.md`, not template copies). One path hit governs a whole * PR — 「混合 diff 一条命中即整 PR 分叉」; proportion is never a question. + * The register is repo-agnostic: it applies in all of `GOVERNED_REPOS`. */ export const GOVERNED_SURFACES = Object.freeze([ Object.freeze({ id: 'adr', prefix: 'docs/adr/', glob: 'docs/adr/**', what: 'architecture decision records' }), @@ -146,6 +279,22 @@ 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 four repos the 2026-08-18 cross-repo extension governs. `id` doubles as + * the sibling directory name beside this checkout — the layout every session + * container uses — and `--repo-root =` overrides it for any other + * layout. A checkout whose `origin` remote is not `slug` is treated as ABSENT + * (unaudited), never audited under the wrong name. + */ +export const GOVERNED_REPOS = Object.freeze([ + Object.freeze({ id: 'objectstack', slug: 'objectstack-ai/objectstack', what: 'the framework repo (this script lives here)' }), + Object.freeze({ id: 'objectui', slug: 'objectstack-ai/objectui', what: 'the UI repo (live skills/** tree)' }), + Object.freeze({ id: 'cloud', slug: 'objectstack-ai/cloud', what: 'the cloud repo' }), + Object.freeze({ id: 'objectos', slug: 'objectstack-ai/objectos', what: 'the objectos repo' }), +]); + +export const SELF_REPO_ID = 'objectstack'; + /** * The governed slice of a path list, grouped by surface. Surfaces with no hit * are absent — `matched.length === 0` IS the clean path. @@ -160,6 +309,33 @@ export function governedPathsIn(paths) { })).filter((surface) => surface.files.length > 0); } +/** `owner/name` out of any git remote spelling, or null. Pure. */ +export function slugFromRemote(url) { + const m = /(?:github\.com[:/])([\w.-]+\/[\w.-]+?)(?:\.git)?\/*\s*$/.exec(String(url ?? '')); + return m ? m[1] : null; +} + +/** + * Where each governed repo's checkout is, and whether it can be audited at + * all. Pure: `probe(path)` answers `{ exists, slug }`, so the whole + * absent/wrong-origin/present fork is offline-testable. An unresolvable repo + * is `status: 'unaudited'` with a stated reason — the #4690 rule in code: + * absence must be loud, and must never render as a clean repo. + */ +export function resolveRepoCheckouts({ repos = GOVERNED_REPOS, selfId = SELF_REPO_ID, selfRoot, siblingDir, overrides = {}, probe }) { + return repos.map((repo) => { + const candidate = overrides[repo.id] ?? (repo.id === selfId ? selfRoot : join(siblingDir, repo.id)); + const seen = probe(candidate) ?? { exists: false, slug: null }; + if (!seen.exists) { + return { ...repo, path: candidate, status: 'unaudited', reason: `no git checkout at ${candidate}` }; + } + if (seen.slug && seen.slug !== repo.slug) { + return { ...repo, path: candidate, status: 'unaudited', reason: `the checkout at ${candidate} has origin ${seen.slug}, not ${repo.slug}` }; + } + return { ...repo, path: candidate, status: 'audited', reason: null }; + }); +} + /** * The PR number a mainline commit subject names, in either spelling GitHub * writes: a merge commit's `Merge pull request #N from ...` or a squash @@ -193,12 +369,67 @@ export function parseSince(arg, now = new Date()) { /** * One sweep entry from one mainline commit. Pure: the caller supplies the - * commit row and its changed paths; attribution is stitched on later. + * commit row, its changed paths, and (multi-repo) which repo it came from; + * attribution is stitched on later. */ -export function classifyCommit({ sha, date, subject }, changedPaths) { +export function classifyCommit({ sha, date, subject }, changedPaths, repo = null) { const surfaces = governedPathsIn(changedPaths); if (surfaces.length === 0) return null; - return { sha, date, subject, pr: pullNumberFromSubject(subject), surfaces }; + return { + repoId: repo?.id ?? null, + repoSlug: repo?.slug ?? null, + sha, + date, + subject, + pr: pullNumberFromSubject(subject), + surfaces, + }; +} + +// ── the --test predicate (#9550): "would a PR touching these be governed?" ─── + +/** + * The pre-arm answer, as data. Pure and repo-agnostic — the register is the + * same in all four governed repos, so a seat can run this from anywhere with + * the file list of any PR in any of them. + */ +export function testVerdict(paths) { + const list = (Array.isArray(paths) ? paths : []).filter((p) => typeof p === 'string' && p !== ''); + const matched = governedPathsIn(list); + const hit = new Set(matched.flatMap((s) => s.files)); + return { + governed: matched.length > 0, + checked: list.length, + surfacesChecked: GOVERNED_SURFACES.length, + matched, + hitPaths: [...hit], + clearPaths: list.filter((p) => !hit.has(p)), + }; +} + +/** The words a seat reads before flipping ready. Pure, so --self-test pins them. */ +export function renderTestVerdict(verdict) { + const head = `governed-surface predicate: ${verdict.hitPaths.length} of ${verdict.checked} path(s) hit the register (${verdict.surfacesChecked} surfaces, repo-agnostic).`; + if (!verdict.governed) { + return ( + `${head}\n` + + ` ✅ NOT governed — ordinary queue landing applies to a PR with exactly this file list.\n` + + ` Derived from GOVERNED_SURFACES, not recalled. Re-run on the FINAL file list: the register\n` + + ` has grown several times in two days, and a reading taken earlier in the session is recall.` + ); + } + const lines = verdict.matched.map((s) => { + const files = s.files.slice(0, 8).map((f) => ` - ${f}`).join('\n'); + return ` ${s.glob} ×${s.files.length} — ${s.what}\n${files}`; + }); + const clear = verdict.clearPaths.length > 0 ? `\n paths not on the register: ${verdict.clearPaths.slice(0, 8).join(', ')}` : ''; + return ( + `${head}\n` + + ` ⛔ GOVERNED — a human merge is the review record for this PR (#9495 regime).\n` + + ` No seat flips it ready, enqueues it, or arms auto-merge (AGENTS.md Prime Directive #14).\n` + + ` One hit governs the whole PR — 「混合 diff 一条命中即整 PR 分叉」; proportion is not a question.\n` + + `${lines.join('\n')}${clear}` + ); } // ── local git (enumeration + diff paths; zero API) ────────────────────────── @@ -225,135 +456,355 @@ export function commitPaths(root, sha) { return out.split('\n').filter((p) => p !== ''); } +/** Is `path` a git checkout, and of what? The real `probe` for resolveRepoCheckouts. */ +function probeCheckout(path) { + try { + git(path, ['rev-parse', '--git-dir']); + } catch { + return { exists: false, slug: null }; + } + try { + return { exists: true, slug: slugFromRemote(git(path, ['remote', 'get-url', 'origin']).trim()) }; + } catch { + return { exists: true, slug: null }; + } +} + // ── attribution (the only API surface) ────────────────────────────────────── function apiContext(env) { - return { - apiUrl: (env.GITHUB_API_URL ?? 'https://api.github.com').replace(/\/+$/, ''), - repo: env.GITHUB_REPOSITORY ?? 'objectstack-ai/objectstack', - token: env.GITHUB_TOKEN || env.GH_TOKEN || null, - }; + return { apiUrl: (env.GITHUB_API_URL ?? 'https://api.github.com').replace(/\/+$/, '') }; } -/** One PR read, for `merged_by` / `merged_at`. Throws on any failure. */ -async function fetchPullAttribution({ apiUrl, repo, token }, pull) { - const url = `${apiUrl}/repos/${repo}/pulls/${pull}`; - let res; - try { - res = await fetch(url, { - headers: { - accept: 'application/vnd.github+json', - 'x-github-api-version': '2022-11-28', - ...(token ? { authorization: `Bearer ${token}` } : {}), - }, - }); - } catch (error) { - throw new Error(`GET ${url} failed: ${error?.message ?? error}`); +/** + * The channel chain, in try order. Anonymous is ALWAYS present and ALWAYS + * last — it is what makes the PM container (no usable token) resolvable at + * all for public repos, and it costs nothing when the token works. + */ +export function attributionChannels(env) { + const token = env.GITHUB_TOKEN || env.GH_TOKEN || null; + const channels = []; + if (token) channels.push({ id: 'env-token', name: 'env token (GITHUB_TOKEN/GH_TOKEN)', headers: { authorization: `Bearer ${token}` } }); + channels.push({ id: 'anonymous', name: 'anonymous REST', headers: {} }); + return channels; +} + +/** The node flag that points fetch at the session proxy, and the re-exec guard. */ +export const PROXY_FLAG = '--use-env-proxy'; +export const PROXY_REARM_GUARD = 'OS_GOVERNED_MERGES_PROXY_REARMED'; + +/** + * Does this run need to be re-executed with `PROXY_FLAG` before it can reach + * GitHub at all? Pure, so every branch is offline-testable — and the branches + * are the whole point: a proxied run without the flag reads 401/403 on every + * channel and looks exactly like a credential problem (#9642). + */ +export function proxyRearmPlan({ env = {}, execArgv = [], flagSupported = true }) { + const proxy = env.HTTPS_PROXY || env.https_proxy || null; + if (!proxy) return { rearm: false, hint: false, reason: 'no HTTPS_PROXY in the environment — fetch reaches GitHub directly' }; + if (execArgv.includes(PROXY_FLAG) || (env.NODE_OPTIONS ?? '').includes(PROXY_FLAG)) { + return { rearm: false, hint: false, reason: `already running with ${PROXY_FLAG}` }; + } + if (env[PROXY_REARM_GUARD] === '1') return { rearm: false, hint: false, reason: 'already re-armed once this run' }; + if (!flagSupported) { + return { rearm: false, hint: true, reason: `this node does not accept ${PROXY_FLAG}; fetch will bypass ${proxy}` }; } - if (!res.ok) throw new Error(`GET ${url} answered HTTP ${res.status}`); - const body = await res.json(); - return { mergedBy: body?.merged_by?.login ?? null, mergedAt: body?.merged_at ?? null, title: body?.title ?? null }; + return { rearm: true, hint: false, flag: PROXY_FLAG, reason: `HTTPS_PROXY is set (${proxy}) and node's fetch does not read it` }; +} + +/** One PR read for `merged_by` / `merged_at`, over every channel in turn. */ +async function fetchPullAttribution({ apiUrl }, slug, pull, channels) { + const url = `${apiUrl}/repos/${slug}/pulls/${pull}`; + const failures = []; + for (const channel of channels) { + let res; + try { + res = await fetch(url, { + headers: { accept: 'application/vnd.github+json', 'x-github-api-version': '2022-11-28', ...channel.headers }, + }); + } catch (error) { + failures.push(`${channel.name}: request failed (${error?.message ?? error})`); + continue; + } + if (!res.ok) { + failures.push(`${channel.name}: HTTP ${res.status}`); + continue; + } + const body = await res.json(); + return { + attribution: { mergedBy: body?.merged_by?.login ?? null, mergedAt: body?.merged_at ?? null, title: body?.title ?? null }, + channel: channel.id, + }; + } + return { attribution: null, channel: null, failure: failures.join('; ') }; +} + +/** + * The per-run NAMED fallback lines (#9619): one line per repo+reason group + * naming which channels were tried and what each answered — replacing the + * per-entry error string that buried the list. Pure. + */ +export function summariseAttributionFailures(entries) { + const groups = new Map(); + for (const entry of entries) { + if (!entry.attributionError) continue; + const key = JSON.stringify([entry.repoSlug ?? '(repo)', entry.attributionError]); + const group = groups.get(key) ?? { slug: entry.repoSlug ?? '(repo)', reason: entry.attributionError, prs: [] }; + group.prs.push(entry.pr); + groups.set(key, group); + } + return [...groups.values()].map( + (g) => + `⚠️ attribution unavailable for ${g.slug} — ${g.prs.length} entr${g.prs.length === 1 ? 'y' : 'ies'} ` + + `(PR ${g.prs.map((n) => `#${n}`).join(', ')}); channels tried — ${g.reason}.`, + ); } // ── rendering ─────────────────────────────────────────────────────────────── /** The whole report as text — pure, so --self-test asserts on the words. */ -export function renderReport({ sinceIso, tip, scanned, entries, lookups }) { +export function renderReport({ sinceIso, repos, scanned, entries, lookups }) { + const audited = repos.filter((r) => r.status === 'audited'); + const unaudited = repos.filter((r) => r.status !== 'audited'); const head = - `governed-merges sweep: ${entries.length} governed merge(s) since ${sinceIso}\n` + - ` scanned ${scanned} mainline commit(s) on origin/main (tip ${tip.sha.slice(0, 9)} @ ${tip.date}); ${lookups} API lookup(s).\n` + - ` Every entry below should correspond to a merge the maintainer performed or ordered in person.\n` + - ` An entry the maintainer does not recognise is the violation signal — file it as an incident (#9495 regime).`; - if (entries.length === 0) return `${head}\n ✅ clean window — no governed surface was merged.`; + `governed-merges sweep: ${entries.length} governed merge(s) since ${sinceIso} ` + + `across ${audited.length}/${repos.length} governed repo(s)\n` + + ` scanned ${scanned} mainline commit(s); ${lookups} API lookup(s).`; + const auditedLines = audited.map( + (r) => ` ✓ audited ${r.slug} — tip ${r.tip ? `${r.tip.sha.slice(0, 9)} @ ${r.tip.date}` : '(unknown)'}; ${r.scanned ?? 0} mainline commit(s) in window${r.quiet ? ' — none in window; if that tip predates your last fetch, run `git fetch origin main` there' : ''}`, + ); + const unauditedLines = unaudited.map((r) => ` ⚠️ UNAUDITED ${r.slug} — ${r.reason}`); + const unauditedNote = + unaudited.length > 0 + ? [ + ` ⛔ ${unaudited.length} governed repo(s) were NOT audited. An unaudited repo is not a clean repo (#4690):`, + ` nothing was found there because nothing was looked at. Check the repo out (or pass`, + ` \`--repo-root =\`) and re-run before reading this sweep as clean.`, + ] + : []; + const contract = [ + ` Every entry below should correspond to a merge the maintainer performed or ordered in person.`, + ` An entry the maintainer does not recognise is the violation signal — file it as an incident (#9495 regime).`, + ]; + const preamble = [head, ...auditedLines, ...unauditedLines, ...unauditedNote, ...contract].join('\n'); + + if (entries.length === 0) { + return unaudited.length > 0 + ? `${preamble}\n no governed surface was merged in the audited repo(s) — NOT a clean window; see UNAUDITED above.` + : `${preamble}\n ✅ clean window — no governed surface was merged in any governed repo.`; + } + const lines = entries.map((e) => { const surfaces = e.surfaces.map((s) => `${s.glob} ×${s.files.length}`).join(', '); const who = e.attribution - ? `merged_by ${e.attribution.mergedBy ?? '(none)'} @ ${e.attribution.mergedAt ?? '(unknown)'}` - : `merged_by UNRESOLVED${e.attributionError ? ` (${e.attributionError})` : ''}`; + ? `merged_by ${e.attribution.mergedBy ?? '(none)'} @ ${e.attribution.mergedAt ?? '(unknown)'} (via ${e.attributionChannel ?? 'unknown channel'})` + : `merged_by UNAVAILABLE — every channel failed; see the attribution note below`; const prName = e.pr != null ? `PR #${e.pr}` : '⚠️ NO PR NUMBER IN SUBJECT — direct push to main? investigate'; const files = e.surfaces.flatMap((s) => s.files.slice(0, 6)).slice(0, 8); - return ` • ${prName} — ${e.subject}\n commit ${e.sha.slice(0, 9)} @ ${e.date}; ${who}\n surfaces: ${surfaces}\n${files.map((f) => ` - ${f}`).join('\n')}`; + return ` • ${e.repoSlug ? `${e.repoSlug} ` : ''}${prName} — ${e.subject}\n commit ${e.sha.slice(0, 9)} @ ${e.date}; ${who}\n surfaces: ${surfaces}\n${files.map((f) => ` - ${f}`).join('\n')}`; }); - return `${head}\n${lines.join('\n')}`; + + const notes = summariseAttributionFailures(entries).map((l) => ` ${l}`); + const caveat = entries.some((e) => e.attribution) + ? [ + ` ℹ️ merged_by names an ACCOUNT, not a principal — the maintainer also operates the seat accounts`, + ` (measured 2026-08-18: objectui PR #5188 read merged_by os-steve; asked directly, the maintainer`, + ` answered 「5188 是我合并的」). The column PROMPTS recognition; it never settles it.`, + ] + : []; + return [preamble, ...lines, ...notes, ...caveat].join('\n'); } // ── CLI ───────────────────────────────────────────────────────────────────── const invokedDirectly = process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url)); +function runTestMode(args) { + const i = args.indexOf('--test'); + const paths = args.slice(i + 1).filter((a) => !a.startsWith('--')); + if (paths.length === 0) { + console.error( + `❌ --test wants the PR's changed paths, and got none. An empty path list is a failure, never\n` + + ` a "not governed" answer. Derive the list rather than typing it, e.g.\n` + + ` node scripts/pm/check-governed-merges.mjs --test $(gh pr diff --name-only )`, + ); + return EXIT_CANNOT_SWEEP; + } + const verdict = testVerdict(paths); + if (args.includes('--json')) console.log(JSON.stringify(verdict, null, 2)); + else console.log(renderTestVerdict(verdict)); + return verdict.governed ? EXIT_TEST_GOVERNED : EXIT_TEST_NOT_GOVERNED; +} + async function main() { const args = process.argv.slice(2); + if (args.includes('--test')) return runTestMode(args); + const argOf = (name) => { const i = args.indexOf(name); return i > -1 ? args[i + 1] : null; }; - const root = resolve(argOf('--root') ?? resolve(scriptDir, '..', '..')); + const argsOf = (name) => args.map((a, i) => (a === name ? args[i + 1] : null)).filter((v) => v != null); + + const selfRoot = resolve(argOf('--root') ?? resolve(scriptDir, '..', '..')); const ref = 'origin/main'; + const overrides = {}; + for (const pair of argsOf('--repo-root')) { + const eq = pair.indexOf('='); + if (eq < 1) { + console.error(`❌ --repo-root wants =; got '${pair}'.`); + return EXIT_CANNOT_SWEEP; + } + overrides[pair.slice(0, eq)] = resolve(pair.slice(eq + 1)); + } + + const only = (argOf('--repos') ?? '').split(',').map((s) => s.trim()).filter((s) => s !== ''); + const known = new Set(GOVERNED_REPOS.map((r) => r.id)); + const unknown = only.filter((id) => !known.has(id)); + if (unknown.length > 0) { + console.error(`❌ --repos names no governed repo: ${unknown.join(', ')}. Known: ${[...known].join(', ')}.`); + return EXIT_CANNOT_SWEEP; + } + const repoSet = only.length > 0 ? GOVERNED_REPOS.filter((r) => only.includes(r.id)) : GOVERNED_REPOS; + let sinceIso; const sinceRef = argOf('--since-ref'); if (sinceRef) { try { - sinceIso = git(root, ['log', '-1', '--format=%cI', `${sinceRef}^{commit}`]).trim(); + sinceIso = git(selfRoot, ['log', '-1', '--format=%cI', `${sinceRef}^{commit}`]).trim(); } catch { console.error(`❌ --since-ref '${sinceRef}' does not resolve to a commit.`); - return 1; + return EXIT_CANNOT_SWEEP; } } else { sinceIso = parseSince(argOf('--since') ?? '24h'); if (!sinceIso) { console.error(`❌ --since wants d, h, or an ISO date; got '${argOf('--since')}'.`); - return 1; + return EXIT_CANNOT_SWEEP; } } - let tip; - let commits; - try { - const [sha, date] = git(root, ['log', '-1', '--format=%H%x09%cI', ref]).trim().split('\t'); - tip = { sha, date }; - commits = mainlineCommits(root, ref, sinceIso); - } catch (error) { - console.error(`❌ cannot read ${ref}: ${error.message}\n Run \`git fetch origin main\` and re-run — a sweep over an unreadable ref is a failure, never a clean window.`); - return 1; + // Transport before credentials, and only once every argument has validated + // (a bad-arg run must not pay for a child process): a proxied run whose fetch + // bypasses the proxy answers 401/403 on every channel and reads as a token + // problem (#9642). The flag has to be set at process start, so re-exec. + const rearm = proxyRearmPlan({ + env: process.env, + execArgv: process.execArgv, + flagSupported: process.allowedNodeEnvironmentFlags.has(PROXY_FLAG), + }); + if (rearm.rearm) { + console.error(`ℹ️ re-exec with ${rearm.flag}: ${rearm.reason}. Attribution would otherwise fail on every channel.`); + // The proxy agent is experimental and says so once per run; the operator + // cannot act on that notice, so keep it out of the report where the node + // in use can silence it by code. + const quiet = process.allowedNodeEnvironmentFlags.has('--disable-warning') ? ['--disable-warning=UNDICI-EHPA'] : []; + const child = spawnSync(process.execPath, [rearm.flag, ...quiet, scriptPath, ...args], { + stdio: 'inherit', + env: { ...process.env, [PROXY_REARM_GUARD]: '1' }, + }); + if (typeof child.status === 'number') return child.status; + console.error(`⚠️ could not re-exec with ${rearm.flag} (${child.error?.message ?? 'no exit status'}); continuing in-process — attribution may fail.`); } + const repos = resolveRepoCheckouts({ + repos: repoSet, + selfRoot, + siblingDir: dirname(selfRoot), + overrides, + probe: probeCheckout, + }); + const entries = []; - for (const commit of commits) { - const entry = classifyCommit(commit, commitPaths(root, commit.sha)); - if (entry) entries.push(entry); + let scanned = 0; + for (const repo of repos) { + if (repo.status !== 'audited') continue; + let commits; + try { + const [sha, date] = git(repo.path, ['log', '-1', '--format=%H%x09%cI', ref]).trim().split('\t'); + repo.tip = { sha, date }; + commits = mainlineCommits(repo.path, ref, sinceIso); + } catch (error) { + repo.status = 'unaudited'; + repo.reason = `cannot read ${ref} in ${repo.path}: ${String(error.message ?? error).split('\n')[0]} — run \`git fetch origin main\` there`; + continue; + } + repo.scanned = commits.length; + repo.quiet = commits.length === 0; + scanned += commits.length; + for (const commit of commits) { + const entry = classifyCommit(commit, commitPaths(repo.path, commit.sha), repo); + if (entry) entries.push(entry); + } + } + + if (repos.every((r) => r.status !== 'audited')) { + console.error( + `❌ no governed repo could be audited — not one checkout resolved. This is a failed sweep, not a\n` + + ` clean window.\n${repos.map((r) => ` • ${r.slug}: ${r.reason}`).join('\n')}`, + ); + return EXIT_CANNOT_SWEEP; } // Attribution — the only API surface, and only when there is something to - // attribute. Failures are per-entry-loud and classify the sweep incomplete. + // attribute. Failures fall back through the channel chain, then group into + // named per-run lines; the sweep is classified incomplete either way. const ctx = apiContext(process.env); + const channels = attributionChannels(process.env); let lookups = 0; let attributionFailed = false; for (const entry of entries) { if (entry.pr == null) continue; // its own loud entry; nothing to look up - try { - lookups += 1; - entry.attribution = await fetchPullAttribution(ctx, entry.pr); - } catch (error) { + lookups += 1; + const got = await fetchPullAttribution(ctx, entry.repoSlug ?? GOVERNED_REPOS[0].slug, entry.pr, channels); + if (got.attribution) { + entry.attribution = got.attribution; + entry.attributionChannel = got.channel; + } else { attributionFailed = true; - entry.attributionError = error.message; + entry.attributionError = got.failure; } } + const unaudited = repos.filter((r) => r.status !== 'audited'); + const complete = !attributionFailed && unaudited.length === 0; + if (args.includes('--json')) { - console.log(JSON.stringify({ since: sinceIso, tip, scanned: commits.length, complete: !attributionFailed, entries }, null, 2)); + console.log( + JSON.stringify( + { + since: sinceIso, + repos: repos.map((r) => ({ id: r.id, slug: r.slug, path: r.path, status: r.status, reason: r.reason, tip: r.tip ?? null, scanned: r.scanned ?? 0 })), + scanned, + complete, + channelsTried: channels.map((c) => c.id), + entries, + }, + null, + 2, + ), + ); } else { - console.log(renderReport({ sinceIso, tip, scanned: commits.length, entries, lookups })); + console.log(renderReport({ sinceIso, repos, scanned, entries, lookups })); } - if (attributionFailed) { + + if (!complete) { + const why = []; + if (unaudited.length > 0) why.push(`${unaudited.length} governed repo(s) unaudited (${unaudited.map((r) => r.slug).join(', ')})`); + if (attributionFailed) why.push('at least one entry has no merged_by reading on any channel'); console.error( - '\n⚠️ attribution incomplete — at least one entry has no merged_by reading (see above). The list is\n' + - ' printed, but "does the maintainer recognise every entry" cannot be answered without the who-merged-it\n' + - ' column, and incomplete must not read as clean (#4690). Provide GITHUB_TOKEN / GH_TOKEN and re-run.', + `\n⚠️ sweep INCOMPLETE — ${why.join('; ')}. The list above is printed, but it must not read as\n` + + ` clean (#4690): "does the maintainer recognise every entry" cannot be answered over repos that\n` + + ` were never looked at or entries with no who-merged-it column.` + + (attributionFailed && rearm.hint + ? `\n ⚠️ ${rearm.reason} — node's fetch is bypassing the session proxy, which answers 401/403 here.\n` + + ` Re-run as NODE_OPTIONS=${PROXY_FLAG} before concluding anything about credentials (#9642).` + : ''), ); - return 2; + return EXIT_INCOMPLETE; } - return 0; + return EXIT_SWEPT; } if (invokedDirectly && !process.argv.includes('--self-test')) { @@ -417,24 +868,142 @@ function selfTest() { const entry = classifyCommit({ sha: 'b'.repeat(40), date: '2026-08-18T00:00:00Z', subject: replay.subject }, replay.files); assert(`replay-lists: ${replay.name}`, entry !== null && entry.pr === replay.pr, JSON.stringify(entry)); } + const uiEntry = classifyCommit({ sha: 'f'.repeat(40), date: '2026-08-18T00:00:00Z', subject: 'docs: seat protocol (#5188)' }, ['AGENTS.md', 'skills/x/SKILL.md'], GOVERNED_REPOS[1]); + assert('an-entry-carries-the-repo-it-came-from', uiEntry.repoSlug === 'objectstack-ai/objectui' && uiEntry.pr === 5188, JSON.stringify(uiEntry)); + + // ── the exit contract, as a table (#9550 picked 3 so it cannot be read as + // the sweep's failure/incomplete codes) ───────────────────────────────── + assert('exit-swept-is-0', EXIT_SWEPT === 0); + assert('exit-cannot-sweep-is-1', EXIT_CANNOT_SWEEP === 1); + assert('exit-incomplete-is-2', EXIT_INCOMPLETE === 2); + assert('exit-test-governed-is-3-and-collides-with-no-sweep-code', EXIT_TEST_GOVERNED === 3 && ![EXIT_SWEPT, EXIT_CANNOT_SWEEP, EXIT_INCOMPLETE].includes(EXIT_TEST_GOVERNED)); + assert('exit-test-not-governed-is-0', EXIT_TEST_NOT_GOVERNED === 0); + + // ── multi-repo scope (#9619) ────────────────────────────────────────────── + assert('four-governed-repos-declared', GOVERNED_REPOS.map((r) => r.id).join(',') === 'objectstack,objectui,cloud,objectos', GOVERNED_REPOS.map((r) => r.id).join(',')); + assert('slug-from-https-remote', slugFromRemote('https://github.com/objectstack-ai/objectui') === 'objectstack-ai/objectui'); + assert('slug-from-ssh-remote-with-suffix', slugFromRemote('git@github.com:objectstack-ai/cloud.git') === 'objectstack-ai/cloud'); + assert('slug-from-nonsense-is-null', slugFromRemote('/some/local/path') === null); + + const layout = { + '/w/objectstack': { exists: true, slug: 'objectstack-ai/objectstack' }, + '/w/objectui': { exists: true, slug: 'objectstack-ai/objectui' }, + '/w/objectos': { exists: true, slug: 'objectstack-ai/objectos' }, + // /w/cloud deliberately absent — the live case measured on the PM + // container 2026-08-18 (no checkout, and the API 403s for it too). + }; + const resolved = resolveRepoCheckouts({ selfRoot: '/w/objectstack', siblingDir: '/w', probe: (p) => layout[p] ?? { exists: false, slug: null } }); + const byId = Object.fromEntries(resolved.map((r) => [r.id, r])); + assert('all-four-repos-are-resolved-not-just-the-self-repo', resolved.length === 4); + assert('the-self-repo-is-audited-from-the-scripts-own-root-not-cwd', byId.objectstack.status === 'audited' && byId.objectstack.path === '/w/objectstack'); + assert('a-sibling-checkout-beside-it-is-audited', byId.objectui.status === 'audited' && byId.objectos.status === 'audited'); + assert('an-absent-checkout-is-UNAUDITED-never-clean', byId.cloud.status === 'unaudited' && /no git checkout at \/w\/cloud/.test(byId.cloud.reason), JSON.stringify(byId.cloud)); + const wrongOrigin = resolveRepoCheckouts({ + repos: [GOVERNED_REPOS[1]], + selfRoot: '/w/objectstack', + siblingDir: '/w', + probe: () => ({ exists: true, slug: 'someone-else/objectui' }), + })[0]; + assert('a-checkout-with-the-wrong-origin-is-UNAUDITED-not-audited-under-the-wrong-name', wrongOrigin.status === 'unaudited' && wrongOrigin.reason.includes('someone-else/objectui'), JSON.stringify(wrongOrigin)); + const overridden = resolveRepoCheckouts({ repos: [GOVERNED_REPOS[2]], selfRoot: '/w/objectstack', siblingDir: '/w', overrides: { cloud: '/srv/cloud' }, probe: (p) => (p === '/srv/cloud' ? { exists: true, slug: 'objectstack-ai/cloud' } : { exists: false, slug: null }) })[0]; + assert('--repo-root-relocates-a-checkout', overridden.status === 'audited' && overridden.path === '/srv/cloud'); // ── the report words an operator reads ──────────────────────────────────── - const tip = { sha: 'c'.repeat(40), date: '2026-08-18T00:00:00Z' }; - const clean = renderReport({ sinceIso: '2026-08-17T00:00:00Z', tip, scanned: 12, entries: [], lookups: 0 }); + const allAudited = resolved.map((r) => ({ ...r, status: 'audited', reason: null, tip: { sha: 'c'.repeat(40), date: '2026-08-18T00:00:00Z' }, scanned: 3 })); + const clean = renderReport({ sinceIso: '2026-08-17T00:00:00Z', repos: allAudited, scanned: 12, entries: [], lookups: 0 }); assert('clean-window-says-clean-and-costs-zero-lookups', clean.includes('clean window') && clean.includes('0 API lookup(s)'), clean); - const noPr = classifyCommit({ sha: 'd'.repeat(40), date: '2026-08-18T00:00:00Z', subject: 'chore: direct push' }, ['AGENTS.md']); - const loud = renderReport({ sinceIso: '2026-08-17T00:00:00Z', tip, scanned: 3, entries: [noPr], lookups: 0 }); + assert('a-clean-sweep-names-every-repo-it-audited', GOVERNED_REPOS.every((r) => clean.includes(r.slug)), clean); + const withAbsent = renderReport({ sinceIso: '2026-08-17T00:00:00Z', repos: resolved.map((r) => ({ ...r, tip: r.status === 'audited' ? { sha: 'c'.repeat(40), date: '2026-08-18T00:00:00Z' } : undefined, scanned: 3 })), scanned: 9, entries: [], lookups: 0 }); + // The ✅ marker, not the words: "NOT a clean window" contains "clean window", + // so a substring test on the phrase alone would pass while the green tick + // still printed. Assert on the tick and on the refusal sentence together. + assert('an-unaudited-repo-never-renders-as-a-clean-window', !withAbsent.includes('✅') && withAbsent.includes('UNAUDITED') && withAbsent.includes('NOT a clean window'), withAbsent); + assert('and-the-clean-case-does-print-the-tick', clean.includes('✅'), clean); + assert('the-unaudited-line-names-the-repo-and-the-reason', withAbsent.includes('objectstack-ai/cloud') && withAbsent.includes('no git checkout'), withAbsent); + const noPr = classifyCommit({ sha: 'd'.repeat(40), date: '2026-08-18T00:00:00Z', subject: 'chore: direct push' }, ['AGENTS.md'], GOVERNED_REPOS[0]); + const loud = renderReport({ sinceIso: '2026-08-17T00:00:00Z', repos: allAudited, scanned: 3, entries: [noPr], lookups: 0 }); assert('a-pr-less-mainline-commit-is-its-own-loud-entry', loud.includes('NO PR NUMBER IN SUBJECT'), loud); - const unresolved = renderReport({ sinceIso: '2026-08-17T00:00:00Z', tip, scanned: 3, entries: [{ ...classifyCommit({ sha: 'e'.repeat(40), date: '2026-08-18T00:00:00Z', subject: 'docs: x (#9501)' }, ['AGENTS.md']), attributionError: 'no token' }], lookups: 0 }); - assert('unresolved-attribution-is-printed-not-hidden', unresolved.includes('UNRESOLVED') && unresolved.includes('no token'), unresolved); assert('the-violation-contract-is-stated-on-every-sweep', clean.includes('violation signal') && loud.includes('violation signal')); + // ── attribution: the channel chain and its named fallback (#9619) ───────── + const anonOnly = attributionChannels({}); + assert('with-no-token-anonymous-REST-is-still-tried', anonOnly.length === 1 && anonOnly[0].id === 'anonymous', JSON.stringify(anonOnly.map((c) => c.id))); + const both = attributionChannels({ GITHUB_TOKEN: 'x' }); + assert('with-a-token-the-token-goes-first-and-anonymous-remains-the-fallback', both.map((c) => c.id).join(',') === 'env-token,anonymous', both.map((c) => c.id).join(',')); + assert('GH_TOKEN-is-honoured-too', attributionChannels({ GH_TOKEN: 'x' }).map((c) => c.id).join(',') === 'env-token,anonymous'); + assert('the-token-channel-sends-an-authorization-header-and-anonymous-sends-none', both[0].headers.authorization === 'Bearer x' && Object.keys(both[1].headers).length === 0); + + // The transport branch (#9642's trap, measured again here): every channel + // reads 401/403 when a proxied run's fetch bypasses the proxy, so the plan + // is pinned in all five directions — no proxy, flag present in either + // spelling, guard set, unsupported flag, and the one case that re-execs. + assert('no-proxy-means-no-rearm', proxyRearmPlan({ env: {} }).rearm === false); + const proxied = proxyRearmPlan({ env: { HTTPS_PROXY: 'http://127.0.0.1:1' } }); + assert('a-proxied-run-without-the-flag-re-arms', proxied.rearm === true && proxied.flag === PROXY_FLAG, JSON.stringify(proxied)); + assert('the-flag-in-execArgv-stops-the-rearm', proxyRearmPlan({ env: { HTTPS_PROXY: 'http://x' }, execArgv: [PROXY_FLAG] }).rearm === false); + assert('the-flag-in-NODE_OPTIONS-stops-the-rearm-too', proxyRearmPlan({ env: { HTTPS_PROXY: 'http://x', NODE_OPTIONS: `--enable-source-maps ${PROXY_FLAG}` } }).rearm === false); + assert('the-guard-env-stops-an-infinite-rearm-loop', proxyRearmPlan({ env: { HTTPS_PROXY: 'http://x', [PROXY_REARM_GUARD]: '1' } }).rearm === false); + const unsupported = proxyRearmPlan({ env: { https_proxy: 'http://x' }, flagSupported: false }); + assert('an-older-node-gets-the-printed-hint-not-a-bad-option-crash', unsupported.rearm === false && unsupported.hint === true, JSON.stringify(unsupported)); + assert('only-the-unsupported-branch-asks-for-the-hint', proxied.hint === false && proxyRearmPlan({ env: {} }).hint === false); + + const failedEntries = [ + { ...classifyCommit({ sha: 'e'.repeat(40), date: '2026-08-18T00:00:00Z', subject: 'docs: a (#101)' }, ['AGENTS.md'], GOVERNED_REPOS[2]), attributionError: 'env token (GITHUB_TOKEN/GH_TOKEN): HTTP 401; anonymous REST: HTTP 403' }, + { ...classifyCommit({ sha: 'e'.repeat(40), date: '2026-08-18T00:00:00Z', subject: 'docs: b (#102)' }, ['AGENTS.md'], GOVERNED_REPOS[2]), attributionError: 'env token (GITHUB_TOKEN/GH_TOKEN): HTTP 401; anonymous REST: HTTP 403' }, + ]; + const notes = summariseAttributionFailures(failedEntries); + assert('two-failures-with-one-cause-collapse-to-ONE-named-line-not-per-entry-spam', notes.length === 1, JSON.stringify(notes)); + assert('the-named-line-names-repo-entries-and-every-channel-tried', notes[0].includes('objectstack-ai/cloud') && notes[0].includes('#101, #102') && notes[0].includes('HTTP 401') && notes[0].includes('anonymous REST'), notes[0]); + assert('two-different-causes-do-not-collapse', summariseAttributionFailures([failedEntries[0], { ...failedEntries[1], attributionError: 'anonymous REST: request failed (ENOTFOUND)' }]).length === 2); + const unresolvedReport = renderReport({ sinceIso: '2026-08-17T00:00:00Z', repos: allAudited, scanned: 3, entries: failedEntries, lookups: 2 }); + assert('an-unattributed-entry-is-marked-UNAVAILABLE-never-silently-blank', unresolvedReport.includes('merged_by UNAVAILABLE'), unresolvedReport); + assert('the-reason-appears-once-below-the-list-not-inside-every-entry', unresolvedReport.split('HTTP 401').length - 1 === 1, unresolvedReport); + const resolvedReport = renderReport({ + sinceIso: '2026-08-17T00:00:00Z', + repos: allAudited, + scanned: 3, + entries: [{ ...classifyCommit({ sha: 'e'.repeat(40), date: '2026-08-18T00:00:00Z', subject: 'docs: a (#5188)' }, ['AGENTS.md'], GOVERNED_REPOS[1]), attribution: { mergedBy: 'os-steve', mergedAt: '2026-08-18T09:00:00Z' }, attributionChannel: 'anonymous' }], + lookups: 1, + }); + assert('a-resolved-entry-names-the-channel-it-came-from', resolvedReport.includes('merged_by os-steve') && resolvedReport.includes('(via anonymous)'), resolvedReport); + assert('a-resolved-column-carries-the-account-is-not-a-principal-caveat', resolvedReport.includes('names an ACCOUNT, not a principal'), resolvedReport); + assert('the-caveat-is-absent-when-nothing-resolved', !unresolvedReport.includes('names an ACCOUNT, not a principal')); + + // ── the --test pre-arm predicate (#9550) ────────────────────────────────── + const governedCase = testVerdict(['AGENTS.md']); + assert('--test-on-the-#9527-file-list-answers-GOVERNED', governedCase.governed === true && governedCase.hitPaths.join() === 'AGENTS.md', JSON.stringify(governedCase)); + assert('--test-governed-renders-the-no-flip-no-enqueue-no-arm-instruction', renderTestVerdict(governedCase).includes('GOVERNED') && renderTestVerdict(governedCase).includes('arms auto-merge'), renderTestVerdict(governedCase)); + const mixedCase = testVerdict(['packages/spec/src/index.ts', '.claude/agents/os-dev.md', 'README.md']); + assert('--test-on-a-mixed-diff-answers-GOVERNED-on-one-hit', mixedCase.governed === true && mixedCase.hitPaths.join() === '.claude/agents/os-dev.md', JSON.stringify(mixedCase.hitPaths)); + assert('--test-lists-the-paths-that-are-NOT-on-the-register-too', mixedCase.clearPaths.join() === 'packages/spec/src/index.ts,README.md', JSON.stringify(mixedCase.clearPaths)); + const clearCase = testVerdict(['packages/spec/src/index.ts', 'scripts/pm/check-governed-merges.mjs']); + assert('--test-on-an-ordinary-diff-answers-NOT-governed', clearCase.governed === false && clearCase.hitPaths.length === 0, JSON.stringify(clearCase)); + assert('this-very-file-is-not-itself-a-governed-surface', testVerdict(['scripts/pm/check-governed-merges.mjs']).governed === false); + assert('--test-not-governed-renders-the-re-run-on-the-final-list-warning', renderTestVerdict(clearCase).includes('NOT governed') && renderTestVerdict(clearCase).includes('recall'), renderTestVerdict(clearCase)); + // Near misses on the predicate, the class the incident turns on: the seat + // reasoned about a name, not a register. + const nearMiss = testVerdict(['examples/AGENTS.md', 'docs/adrs/x.md', '.claude-x/y', 'skillsx/a.md', 'apps/CLAUDE.md.bak', 'packages/create-objectstack/src/templates/AGENTS.md']); + assert('--test-near-misses-answer-NOT-governed', nearMiss.governed === false && nearMiss.clearPaths.length === 6, JSON.stringify(nearMiss.hitPaths)); + assert('--test-with-an-empty-path-list-is-never-a-not-governed-answer', testVerdict([]).checked === 0 && testVerdict([]).governed === false && runTestModeExitFor([]) === EXIT_CANNOT_SWEEP); + // Every governed surface answers 3 through its own glob — an uninvoked + // surface is the phantom shape this self-test exists to refuse. + for (const surface of GOVERNED_SURFACES) { + const sample = surface.prefix ? `${surface.prefix}sample.md` : surface.exact; + assert(`--test-answers-governed-for-${surface.id}`, testVerdict([sample]).governed === true, sample); + } + if (failures.length > 0) { console.error(`✗ check-governed-merges --self-test — ${failures.length} failure(s)\n`); for (const failure of failures) console.error(` • ${failure}`); process.exit(1); } - console.log(`✓ check-governed-merges --self-test: ${checked} assertions (the unified governed predicate + near misses, subject→PR spellings, window parsing, the replay fixtures, and the report wording pins).`); + console.log(`✓ check-governed-merges --self-test: ${checked} assertions (the unified governed predicate + near misses, subject→PR spellings, window parsing, the replay fixtures, the four-repo resolution incl. absent/wrong-origin/relocated checkouts, the attribution channel chain + its proxy-transport re-arm plan and its one named fallback line, the --test pre-arm predicate, the exit table, and the report wording pins).`); +} + +/** The exit code `--test` would return for a path list — pinned without spawning. */ +function runTestModeExitFor(paths) { + if (paths.length === 0) return EXIT_CANNOT_SWEEP; + return testVerdict(paths).governed ? EXIT_TEST_GOVERNED : EXIT_TEST_NOT_GOVERNED; } if (process.argv.includes('--self-test')) { From 31818e0ea9866bafb3e6fd78b364b8ec19182b45 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 16:13:26 +0000 Subject: [PATCH 2/2] fix(pm): governed-merges self-test reports a missing attribution channel as a named failure The channel-shape assertion indexed both[1] directly, so an ablation that removes the anonymous channel crashed the self-test with a TypeError instead of naming which pin broke. Red either way; only one of them tells the reader what is wrong. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WswfK2yNYT9hNnMH6TzAwL --- scripts/pm/check-governed-merges.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/pm/check-governed-merges.mjs b/scripts/pm/check-governed-merges.mjs index 15ad014725..fc1ccd0bee 100644 --- a/scripts/pm/check-governed-merges.mjs +++ b/scripts/pm/check-governed-merges.mjs @@ -931,7 +931,7 @@ function selfTest() { const both = attributionChannels({ GITHUB_TOKEN: 'x' }); assert('with-a-token-the-token-goes-first-and-anonymous-remains-the-fallback', both.map((c) => c.id).join(',') === 'env-token,anonymous', both.map((c) => c.id).join(',')); assert('GH_TOKEN-is-honoured-too', attributionChannels({ GH_TOKEN: 'x' }).map((c) => c.id).join(',') === 'env-token,anonymous'); - assert('the-token-channel-sends-an-authorization-header-and-anonymous-sends-none', both[0].headers.authorization === 'Bearer x' && Object.keys(both[1].headers).length === 0); + assert('the-token-channel-sends-an-authorization-header-and-anonymous-sends-none', both[0]?.headers?.authorization === 'Bearer x' && Object.keys(both[1]?.headers ?? { unset: 1 }).length === 0, JSON.stringify(both.map((c) => c.id))); // The transport branch (#9642's trap, measured again here): every channel // reads 401/403 when a proxied run's fetch bypasses the proxy, so the plan