From f596a65170e7a31efeb98ff408e3f31f365a2e31 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 06:26:10 +0000 Subject: [PATCH] =?UTF-8?q?feat(pm):=20ci-failure=20=E2=80=94=20a=20red=20?= =?UTF-8?q?check's=20assertion=20in=20one=20command=20(#9777)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `scripts/pm/ci-failure.mjs` walks a sha's check-runs, fetches each failing check's annotations, and prints the failing assertion — replacing the manual log-zip sequence #9777 measured. The log archive that card sketched is not reachable from an agent container: both Actions log endpoints redirect to storage hosts the session's egress policy answers 403 to on CONNECT (re-measured 2026-08-19 on two runs; /root/.ccr/README.md classifies that as an organization policy denial and says to report it rather than route around it). So this builds on the surfaces that do answer, and states out loud when they carry no assertion instead of printing an empty result that reads green. Three answers, never two: RETRIEVED (a file-anchored annotation), NO FILE-ANCHORED ASSERTION but N annotations with content (a gate sentence is often the whole answer), and NONE (only `Process completed with exit code 1.`). Only the last is "could not be retrieved", and it comes with the failing step name resolved offline against .github/workflows/ to the command that reproduces it locally. Transport: node 22's fetch ignores HTTPS_PROXY, so an unproxied run hands GitHub the proxy's placeholder token and every read answers 401 — a transport fault wearing a credential fault's face. A live run re-execs itself once with --use-env-proxy, reusing check-governed-merges.mjs's `proxyRearmPlan` rather than re-deriving it. That import exposed a latent defect in the imported file: its `--self-test` trigger had no `invokedDirectly` guard, so importing it ran its 77 assertions inside the importer's own self-test and put an unrelated file's failures on the importer's exit code. Guarded the same way line 810 of that file already is. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XqDQYVU5smx29ts9pAErja --- scripts/pm/check-governed-merges.mjs | 9 +- scripts/pm/ci-failure.mjs | 1267 ++++++++++++++++++++++++++ 2 files changed, 1275 insertions(+), 1 deletion(-) create mode 100644 scripts/pm/ci-failure.mjs diff --git a/scripts/pm/check-governed-merges.mjs b/scripts/pm/check-governed-merges.mjs index fc1ccd0bee..fb9fbaf8ca 100644 --- a/scripts/pm/check-governed-merges.mjs +++ b/scripts/pm/check-governed-merges.mjs @@ -1006,6 +1006,13 @@ function runTestModeExitFor(paths) { return testVerdict(paths).governed ? EXIT_TEST_GOVERNED : EXIT_TEST_NOT_GOVERNED; } -if (process.argv.includes('--self-test')) { +// `invokedDirectly` for the same reason line 810 carries it: this module is +// imported for its exported predicates (`proxyRearmPlan` — see +// scripts/pm/ci-failure.mjs), and an unguarded trigger ran THIS file's 77 +// assertions inside the importer's own `--self-test`, printing a second +// summary and putting an unrelated file's failures on the importer's exit +// code. A self-test is a mode of the file that is being RUN, never a side +// effect of importing it. +if (invokedDirectly && process.argv.includes('--self-test')) { selfTest(); } diff --git a/scripts/pm/ci-failure.mjs b/scripts/pm/ci-failure.mjs new file mode 100644 index 0000000000..5500de41cd --- /dev/null +++ b/scripts/pm/ci-failure.mjs @@ -0,0 +1,1267 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ci-failure (#9777) — one command from "a check is red" to "here is the + * failing assertion", built ONLY on what an agent container can actually reach. + * + * node scripts/pm/ci-failure.mjs # HEAD of the current worktree + * node scripts/pm/ci-failure.mjs --pr 9774 + * node scripts/pm/ci-failure.mjs --sha 40740da3 + * node scripts/pm/ci-failure.mjs --run 32204206019 + * node scripts/pm/ci-failure.mjs --json # the same walk, machine-readable + * node scripts/pm/ci-failure.mjs --self-test # offline: no network, no token + * + * Behind an agent container's HTTPS proxy a live run RE-EXECS itself once with + * `--use-env-proxy`. Without that flag node's fetch bypasses the proxy, GitHub + * receives the proxy's placeholder token, and every read answers 401 — see + * "The transport" below before reading an exit 3 as a credential problem. + * + * ## Why this is not the log-zip script the card sketched + * + * #9777 proposed wrapping the run-log archive dance. That shape does not work + * from here, and the measurement is the design. Taken on 2026-08-19 in an agent + * container, against a real red run (32204206019): + * + * GET /repos/{o}/{r}/actions/jobs/{job}/logs -> 302 to + * productionresultssa8.blob.core.windows.net -> CONNECT 403 + * GET /repos/{o}/{r}/actions/runs/{run}/logs -> 302 to + * results-receiver.actions.githubusercontent.com -> CONNECT 403 + * + * Both hosts are denied by this session's egress policy (the proxy records + * `connect_rejected … gateway answered 403 to CONNECT` for each). Raw logs are + * therefore NOT a foundation any seat-facing tool can stand on, and routing + * around a policy denial is forbidden. So this walks the surfaces that DO + * answer, and — this is the load-bearing half — says so out loud when they do + * not carry the assertion, instead of printing an empty result that reads green. + * + * ## The two false-conclusion generators this file is built around + * + * 1. `output.summary` / `output.text` on a check-run are NULL even when the + * check has annotations. Measured: all 18 check-runs on 40740da3 carried + * `summary: null, text: null`; the failing one carried + * `annotations_count: 1`. The content is at `annotations_url` — a SEPARATE + * request. A tool that reads only `output` concludes "nothing recorded". + * + * 2. A job's `steps` array has a NUMBERING GAP that is not truncation. Measured + * on six failing jobs: `Lint & Repo Gates` returned 72 steps numbered + * 1..68 then 134..137; `Test Core (1/3)` returned 20 numbered 1..16 then + * 30..33. The tail block is always `Post ` steps plus `Complete + * job` — GitHub reserves a numbering block per action for its post phase and + * only emits the ones that ran. So `max(number) !== steps.length` is the + * NORMAL shape and is not evidence of a truncated list; reading it as + * truncation ("129 steps, only 64 came back") throws away a complete answer. + * `stepsIntegrity` below draws the distinction mechanically: a single gap at + * the pre/post boundary is COMPLETE, any interior gap is POSSIBLY-TRUNCATED + * and is reported as such. Absence is never silently read as evidence. + * + * ## What each check family actually surfaces (measured, 36 failing jobs) + * + * vitest shard jobs — `Test Core (n/3)`, `Dogfood Regression Gate (n/3)`: + * vitest auto-enables its `github-actions` reporter under CI, so the + * assertion arrives as a FILE-ANCHORED annotation (path + line + message). + * Retrieved in 9 of 14 sampled shard failures. The other 5 carried only + * `command () … exited (1)` — a nested package runner died without the + * reporter emitting anything. That inconsistency is exactly why the verdict + * below distinguishes "no assertion" from "no failure". + * + * roster/aggregate jobs — `Test Core`, `Dogfood Regression Gate` (no shard + * suffix): their annotations are the attestation gate's own sentences. They + * carry NO assertion, but they DO name the failing leg (`test-1-of-3`). + * They are ranked below informative jobs and labelled, the same call + * `.github/workflows/merge-queue-triage.yml` makes for the same reason. + * + * repo gates and tsc — `Lint & Repo Gates`, `TypeScript Type Check`: + * exactly ONE annotation, `Process completed with exit code 1.` The + * assertion exists only in the blocked log. What IS reachable is the failing + * STEP NAME, and every step in this repo's workflows is a named `run:` + * block — so the tool resolves the step name against `.github/workflows/*` + * offline and prints the command that reproduces the assertion locally + * (`Engine test-double contract gate` -> `pnpm check:engine-double-contract`). + * That is a substitute for the assertion, and it is reported as a substitute. + * + * ## Exit codes — "I could not retrieve it" is a VERDICT, never a green + * + * This is the #9747 shape stated as an exit table. A gate that says `clean` when + * it means `I saw nothing I understood` is the mechanism behind every false + * green in that card's census; the same trap is one `catch {}` away here. + * + * 0 GREEN every check-run on the sha completed, none failed. + * 1 RED failing checks, and the assertion text was retrieved for + * EVERY one of them. The output is the answer. + * 2 UNDETERMINED the walk completed but cannot answer: a failing check whose + * assertion is not retrievable from here, checks still + * running, or zero check-runs on the sha. Zero is not a clean + * repo, it is a broken scan. + * 3 PREREQUISITE NOT MET — no usable transport (no token, exhausted quota, + * unreachable api.github.com). Classified by + * `check-half-states.mjs`'s probe, which is imported rather + * than re-implemented: same numbers, same wording, one + * instrument. Its header states the rule this file inherits — + * a non-zero exit of this kind classifies the ENVIRONMENT, + * not the tree. + * + * Piping hides all of it (`… | tail` reports the PIPE's status). Read `$?`. + * + * ## The transport — node's fetch does not read HTTPS_PROXY, and the failure + * wears a credential fault's face + * + * In an agent container `GITHUB_TOKEN` is a placeholder that the session proxy + * swaps for a real credential on the way out. Node 22's global fetch ignores + * `HTTPS_PROXY`, so an unproxied run hands GitHub the placeholder itself and + * GitHub answers 401. Measured here 2026-08-19, one URL, one environment: + * + * curl (reads HTTPS_PROXY) -> 200, x-ratelimit-remaining 14951 + * node fetch, no flag, env token -> 401 + * node fetch, NODE_OPTIONS=--use-env-proxy -> 200, x-ratelimit-remaining 14951 + * + * Without the flag this file's own prerequisite probe reads that 401 correctly + * and concludes "no usable credential in this container" — right about what it + * saw, wrong about the world, and unfalsifiable to anyone who then checks with + * `curl` and sees 200. `check-governed-merges.mjs` and + * `check-required-contexts.mjs` each paid a round for this exact reading + * (#9642). The flag must be set at process START — assigning + * `process.env.NODE_USE_ENV_PROXY` from inside is too late — so a live run + * re-execs itself once. The DECISION is `check-governed-merges.mjs`'s + * `proxyRearmPlan`, imported rather than re-derived: one instrument, one set of + * measured branches, and a node too old for the flag gets a printed hint + * instead of a crash loop. `--self-test` and `--help` never re-exec — they + * open no socket. + * + * ## History — this file reads none, so the shallow clone cannot mislead it + * + * Agent containers start from a 63-commit shallow clone (#9878), which answers + * silently wrong for anything that walks history. Nothing here walks any: the + * only git call is `git rev-parse HEAD` (a shallow clone has HEAD), and every + * other input is a REST response or a `.github/workflows/*` file read off disk. + * No deepening is required. An edit that adds a `git log`, `git merge-base` or + * tag read retires this paragraph with it. + * + * ## Cost, and why plain REST + * + * A typical single-failure PR is THREE requests: check-runs for the sha, the + * failing run's jobs, and one annotations call per failing check. Plain REST is + * 15,000/h and near-idle here; the MCP GitHub tools spend the 5,000/h GraphQL + * budget, which this lane exhausts repeatedly. No GraphQL is used. + */ + +import { execFileSync, spawnSync } from 'node:child_process'; +import { readFileSync, readdirSync, existsSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join, resolve } from 'node:path'; +import process from 'node:process'; + +import { + EXIT_PREREQUISITE_NOT_MET, + classifyTransportProbe, + describeProbe, + parseRemaining, +} from './check-half-states.mjs'; +import { PROXY_FLAG, PROXY_REARM_GUARD, proxyRearmPlan } from './check-governed-merges.mjs'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(HERE, '..', '..'); +const OWNER_REPO = process.env.PM_SWEEP_REPO ?? 'objectstack-ai/objectstack'; +const API = 'https://api.github.com'; +const TOKEN = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN ?? ''; + +export const EXIT_GREEN = 0; +export const EXIT_RED = 1; +export const EXIT_UNDETERMINED = 2; + +/** + * Where an Actions log download redirects, and where this container's egress + * stops. Measured twice on 2026-08-19 against two different red runs: the + * job-log redirect named `productionresultssa8` at 03:0x and + * `productionresultssa1` at 06:14 — the storage-account NUMERAL varies per run, + * so this is a pattern plus its specimens rather than a closed list. A reader + * who matched a literal host would conclude the policy had changed on the next + * numeral. + * + * Both answered `CONNECT tunnel failed, response 403`. `/root/.ccr/README.md` + * classifies that as an organization egress-policy denial and instructs + * "do not retry or route around it — report the blocked host", which is why + * this tool is built on annotations instead of on the log archive #9777 + * sketched. Named so the claim stays falsifiable: if the policy opens, these + * are the hosts to retry. + */ +export const LOG_BLOB_HOSTS = [ + 'productionresultssa{N}.blob.core.windows.net (measured 2026-08-19: sa8, sa1)', + 'results-receiver.actions.githubusercontent.com', +]; + +// --------------------------------------------------------------------------- +// Pure layer — every judgment lives here so `--self-test` can drive it with the +// real measured shapes and the live walk stays a thin fetch loop. +// --------------------------------------------------------------------------- + +/** + * Whether THIS invocation has to re-exec through the session proxy before it + * can reach GitHub at all — see "The transport" in the header. + * + * The proxy decision itself is `proxyRearmPlan`, imported. What is added here + * is the one thing that function cannot know: which of this tool's modes open a + * socket. `--self-test` and `--help` do not, and re-execing them would spend a + * process, print an experimental-agent warning, and make the offline mode + * depend on a proxy it never uses. + * + * `flagSupported` is injected so the self-test can drive the old-node branch; + * live callers let it default to what this node actually accepts. + */ +export function proxyRearmFor(argv, { env = process.env, execArgv = process.execArgv, flagSupported } = {}) { + const offline = (argv ?? []).some((a) => a === '--self-test' || a === '--help' || a === '-h'); + if (offline) return { rearm: false, hint: false, reason: 'offline mode — this run opens no socket' }; + return proxyRearmPlan({ + env, + execArgv, + flagSupported: flagSupported ?? process.allowedNodeEnvironmentFlags.has(PROXY_FLAG), + }); +} + +/** + * The header's title and usage block, for `--help`. + * + * Read structurally — the leading doc comment up to its first `##` section — + * rather than by line numbers. The line-slice this replaces was already one + * line short of the usage list, and any header edit silently moved what it + * printed: a help text that drifts from the file it documents is the same + * silent-wrongness this tool exists to refuse, in miniature. + */ +export function usageText(source) { + const doc = []; + for (const line of String(source ?? '').split('\n')) { + const comment = /^ \*(?: (.*))?$/.exec(line); + if (comment) { + doc.push(comment[1] ?? ''); + continue; + } + if (doc.length > 0) break; // the doc comment ended + } + const section = doc.findIndex((l, i) => i > 0 && /^## /.test(l)); + return doc + .slice(0, section === -1 ? doc.length : section) + .join('\n') + .replace(/\n+$/, ''); +} + +/** + * The check-runs that actually describe THIS attempt: grouped by name, newest + * kept. + * + * GitHub leaves superseded check-runs on the sha. A re-run, or a merge-queue + * eviction, leaves a `cancelled` (or older `failure`) row behind under the same + * name, and a reader that scans the flat list sees a red PR that is green. The + * newest is decided by `started_at` and, when two share a timestamp, by id — + * ids are monotonic per repo, so the tie-break is not arbitrary. + * + * The dropped rows are returned rather than discarded: "this name had 2 older + * runs" is a fact a reader chasing a flaky re-run wants, and swallowing it here + * would be the same silent-narrowing move this file exists to avoid. + */ +export function latestPerName(checkRuns) { + const byName = new Map(); + for (const run of checkRuns ?? []) { + const key = run?.name ?? ''; + const prev = byName.get(key); + if (!prev || isNewer(run, prev)) byName.set(key, run); + } + const kept = [...byName.values()]; + const keptIds = new Set(kept.map((r) => r.id)); + const superseded = (checkRuns ?? []).filter((r) => !keptIds.has(r.id)); + return { kept, superseded }; +} + +function isNewer(a, b) { + const at = Date.parse(a?.started_at ?? '') || 0; + const bt = Date.parse(b?.started_at ?? '') || 0; + if (at !== bt) return at > bt; + return Number(a?.id ?? 0) > Number(b?.id ?? 0); +} + +/** + * What ONE annotation is, by the shapes this repo's CI actually emits. + * + * The classifier is deliberately total: anything unmatched lands in `other` and + * is still PRINTED. A recognizer narrower than reality must report the + * shortfall, not drop it (#9747) — an annotation this function has never seen + * is far more likely to be the answer than to be noise. + * + * assertion a file-anchored annotation. vitest's github-actions + * reporter writes these; `path` is a repo file, and `message` + * is the failure text. THE thing this tool exists to find. + * package-pointer `command () exited (N)` — pnpm/turbo reporting a + * child that died. Names the failing PACKAGE, not the + * assertion. A lead, never an answer. + * roster-pointer the shard-attestation gate's own sentence, naming the leg + * that published no positive attestation. Points at another + * job in the same run. + * exit-status `Process completed with exit code N.` — the Actions runner's + * generic marker. Carries nothing. It is what a `check:*` gate + * failure leaves behind, and mistaking its presence for + * content is how a red gate reads as "no details recorded". + * + * `.github` is how the runner spells "no file" in `path`, so it is not a file + * anchor; that check is what keeps `exit-status` out of the assertion bucket. + */ +export function classifyAnnotation(annotation) { + const message = String(annotation?.message ?? ''); + const path = String(annotation?.path ?? ''); + const anchored = path !== '' && path !== '.github'; + if (anchored) return { kind: 'assertion', path, line: annotation?.start_line ?? null, message }; + + const command = /^command \((?.+?)\)\s+(?.*?)\s+exited \((?\d+)\)\s*$/.exec(message); + if (command) { + return { kind: 'package-pointer', dir: command.groups.dir, code: Number(command.groups.code), message }; + } + + const leg = /published no positive attestation \((?[^)]+)\)/.exec(message) + ?? /leg (?[A-Za-z0-9_-]+) reported result/.exec(message); + if (leg) return { kind: 'roster-pointer', leg: leg.groups.leg, message }; + + if (/^Process completed with exit code \d+\.?\s*$/.test(message)) return { kind: 'exit-status', message }; + + return { kind: 'other', message }; +} + +/** + * What this failing check can tell a reader about WHY it failed — three + * answers, never two. + * + * "No assertion" and "nothing at all" are different facts and the difference + * changes what the reader does next. Measured on the same morning: + * + * `Dogfood Regression Gate (1/3)` — a file-anchored vitest annotation. The + * assertion itself: RETRIEVED, and the reader is done. + * `Check Changeset` — no file anchor, but a full sentence from the gate + * ("This PR adds no changeset…"), which IS the answer even though it is not + * an assertion. Reporting that as "not retrievable" would send a reader to + * open the log for something already printed two lines above. + * `Lint & Repo Gates` — one `Process completed with exit code 1.` and + * nothing else. The step's stdout is the answer and it is in the blocked + * blob; only here is "could not be retrieved" the whole truth. + * + * `exit-status` is content-free by construction, so a check carrying only + * those carries nothing — counting it as "an annotation was returned" is how a + * red gate comes to read as "details recorded elsewhere". + */ +export function assertionStatus(annotations) { + const list = annotations ?? []; + const assertions = list.filter((a) => a.kind === 'assertion'); + if (assertions.length > 0) return { kind: 'retrieved', assertions, others: [] }; + const others = list.filter((a) => a.kind !== 'exit-status'); + if (others.length > 0) return { kind: 'no-anchor', assertions: [], others }; + return { kind: 'none', assertions: [], others: [] }; +} + +/** + * Whether a job's `steps` array is the whole list — see trap 2 in the header. + * + * The rule is structural, not a magic number: real steps are numbered from 1 + * and are contiguous; the runner then reserves a numbering block per action for + * its post phase and emits only the post steps that ran, which lands as ONE gap + * immediately before the trailing `Post …` / `Complete job` block. Exactly that + * shape is COMPLETE. Any other gap — an interior one, or a list that does not + * start at 1 — is `possibly-truncated`, and the caller must not read absence + * from it. + * + * The failure direction is chosen: an unrecognised shape is reported as + * possibly-truncated (recall loss, announced) rather than complete (a confident + * wrong answer). + */ +export function stepsIntegrity(steps) { + const list = Array.isArray(steps) ? steps : []; + if (list.length === 0) { + return { verdict: 'unknown', gaps: [], realCount: 0, postCount: 0, note: 'the job returned no steps at all' }; + } + const numbers = list.map((s) => Number(s?.number ?? 0)); + const gaps = []; + for (let i = 0; i < numbers.length - 1; i++) { + if (numbers[i + 1] !== numbers[i] + 1) gaps.push([numbers[i], numbers[i + 1]]); + } + const firstPost = list.findIndex((s) => /^Post /.test(String(s?.name ?? ''))); + const postCount = firstPost === -1 ? 0 : list.length - firstPost; + const realCount = firstPost === -1 ? list.length : firstPost; + + if (numbers[0] !== 1) { + return { verdict: 'possibly-truncated', gaps, realCount, postCount, note: `the list starts at step #${numbers[0]}, not #1` }; + } + if (gaps.length === 0) { + return { verdict: 'complete', gaps, realCount, postCount, note: 'contiguous from #1' }; + } + if (gaps.length === 1 && firstPost > 0 && numbers[firstPost - 1] === gaps[0][0] && numbers[firstPost] === gaps[0][1]) { + return { + verdict: 'complete', + gaps, + realCount, + postCount, + note: `the #${gaps[0][0]} to #${gaps[0][1]} jump is the runner's reserved post-step block, not a truncation`, + }; + } + return { + verdict: 'possibly-truncated', + gaps, + realCount, + postCount, + note: `${gaps.length} numbering gap(s), at least one of them interior`, + }; +} + +/** + * A failing job that is only a roster verdict about OTHER jobs. + * + * `Test Core` and `Dogfood Regression Gate` fail with a `Verify … results` step + * when a shard did not attest; the assertion is in the shard job, which is a + * separate check-run on the same sha and is therefore already in this walk. + * Ranking these last (rather than dropping them) is the same call + * merge-queue-triage.yml makes — they are real failures, they just are not + * where the answer is. + */ +export function isRosterJob(failedStepNames) { + const names = failedStepNames ?? []; + return names.length > 0 && names.every((n) => /^Verify .* results$/.test(String(n))); +} + +/** + * The `run:` (or `uses:`) body of every workflow step with this exact name. + * + * This is the reachable substitute for a blocked log on the gate families: every + * step in this repo's workflows is a named shell block, so the step name the + * jobs API DOES return resolves offline to the command that reproduces the + * assertion locally. `Engine test-double contract gate` resolves to + * `pnpm check:engine-double-contract`, and running that prints the very text + * the log blob is withholding. + * + * A hand-rolled scanner rather than the `yaml` dependency, for the reason + * `dispatch-gates.mjs` gives for the same choice: this must run from a bare + * checkout during triage, before `pnpm install`, and it needs the RAW text + * anyway (a parsed tree loses the block scalar's exact spelling, which is what a + * reader is going to paste). + * + * Names are matched exactly and ALL matches are returned. Step names are not + * unique across workflows, and picking one would be a silent guess about which + * job the reader meant; the caller prints the file beside each. + */ +export function stepBodies(workflowText, stepName) { + const lines = String(workflowText ?? '').split('\n'); + const found = []; + for (let i = 0; i < lines.length; i++) { + const head = /^(\s*)-\s+name:\s*(.*?)\s*$/.exec(lines[i]); + if (!head) continue; + if (unquote(head[2]) !== stepName) continue; + const itemIndent = head[1].length; + const body = []; + for (let j = i + 1; j < lines.length; j++) { + if (/^\s*$/.test(lines[j])) { body.push(lines[j]); continue; } + if (lines[j].match(/^\s*/)[0].length <= itemIndent) break; + body.push(lines[j]); + } + const command = commandOf(body); + if (command) found.push(command); + } + return found; +} + +function unquote(raw) { + const s = String(raw ?? '').trim(); + if (s.length >= 2 && ((s[0] === '"' && s.endsWith('"')) || (s[0] === "'" && s.endsWith("'")))) { + return s.slice(1, -1).replace(/''/g, "'"); + } + return s; +} + +/** + * The one command a step runs, as text. `run:` wins; a step with only `uses:` + * reports the action reference, which is not runnable locally and says so by + * its `kind` rather than by being omitted — a step silently producing nothing + * would read to the caller as "this step has no command", which is false. + */ +function commandOf(bodyLines) { + for (let i = 0; i < bodyLines.length; i++) { + const run = /^(\s*)run:\s*(.*?)\s*$/.exec(bodyLines[i]); + if (run) { + const inline = run[2]; + if (inline && !/^[|>][+-]?$/.test(inline)) return { kind: 'run', text: inline }; + const indent = run[1].length; + const block = []; + for (let j = i + 1; j < bodyLines.length; j++) { + if (/^\s*$/.test(bodyLines[j])) { block.push(''); continue; } + if (bodyLines[j].match(/^\s*/)[0].length <= indent) break; + block.push(bodyLines[j]); + } + return { kind: 'run', text: dedent(block).join('\n').replace(/\n+$/, '') }; + } + } + for (const line of bodyLines) { + const uses = /^\s*uses:\s*(.*?)\s*$/.exec(line); + if (uses) return { kind: 'uses', text: unquote(uses[1]) }; + } + return null; +} + +function dedent(block) { + const widths = block.filter((l) => l !== '').map((l) => l.match(/^\s*/)[0].length); + const cut = widths.length ? Math.min(...widths) : 0; + return block.map((l) => l.slice(cut)); +} + +/** Every `.github/workflows/*.yml` as `{ file, text }`, read once per run. */ +export function readWorkflows(root = ROOT) { + const dir = join(root, '.github', 'workflows'); + if (!existsSync(dir)) return []; + return readdirSync(dir) + .filter((f) => f.endsWith('.yml') || f.endsWith('.yaml')) + .sort() + .map((f) => ({ file: `.github/workflows/${f}`, text: readFileSync(join(dir, f), 'utf8') })); +} + +/** Where a failing step name resolves, across the whole workflow tree. */ +export function resolveStep(workflows, stepName) { + const hits = []; + for (const wf of workflows ?? []) { + for (const command of stepBodies(wf.text, stepName)) hits.push({ file: wf.file, ...command }); + } + return hits; +} + +/** + * The run id a check-run belongs to, taken from the URL the check-run already + * carries. Free — it saves a request per failing check, and the alternative + * (listing the run) is the request this walk is trying to spend elsewhere. + * Returns null for a check-run that is not an Actions job (an external app), + * which the caller reports rather than assumes away. + */ +export function runIdOf(checkRun) { + const url = String(checkRun?.details_url ?? checkRun?.html_url ?? ''); + const m = /\/actions\/runs\/(\d+)\//.exec(url); + return m ? m[1] : null; +} + +/** + * The whole walk's verdict, from the per-check findings. Pure, so the exit + * table in the header is pinned by the self-test rather than asserted in prose. + * + * The ordering is the point: an unretrievable assertion OUTRANKS a retrieved + * one, and "still running" outranks "all green". Both are cases where a naive + * reducer returns the cheerful answer, and both are the #9747 shape. + */ +export function verdictOf({ failing, pending, total }) { + if ((total ?? 0) === 0) { + return { verdict: 'UNDETERMINED', exit: EXIT_UNDETERMINED, why: 'no check-runs on this sha — zero is not a clean tree, it is a scan that found nothing' }; + } + const withAssertion = failing.filter((f) => f.assertions.length > 0).length; + if (failing.length === 0) { + if (pending > 0) { + return { verdict: 'UNDETERMINED', exit: EXIT_UNDETERMINED, why: `${pending} check(s) still running — not-red-yet is not green` }; + } + return { verdict: 'GREEN', exit: EXIT_GREEN, why: `all ${total} check-run(s) completed, none failed` }; + } + if (withAssertion === failing.length) { + return { verdict: 'RED', exit: EXIT_RED, why: `assertion text retrieved for all ${failing.length} failing check(s)` }; + } + return { + verdict: 'UNDETERMINED', + exit: EXIT_UNDETERMINED, + why: + `assertion text retrieved for ${withAssertion} of ${failing.length} failing check(s) — ` + + 'the rest are not retrievable from this container', + }; +} + +// --------------------------------------------------------------------------- +// Transport — the prerequisite, then the walk. +// --------------------------------------------------------------------------- + +/** + * `/rate_limit`, reduced to the observations `classifyTransportProbe` reads. + * The classifier itself is IMPORTED, not copied: it already encodes the + * measured traps (a 200 carrying `x-ratelimit-remaining: 0` is not usable; an + * absent header is unknown, not exhausted; a `proxy-…` placeholder is not a + * GitHub token), and a second copy of that knowledge would be a second thing to + * keep true. + */ +async function probeRateLimit(token) { + try { + const res = await fetch(`${API}/rate_limit`, { + headers: { accept: 'application/vnd.github+json', ...(token ? { authorization: `Bearer ${token}` } : {}) }, + }); + return { status: res.status, rateLimitRemaining: parseRemaining(res.headers.get('x-ratelimit-remaining')) }; + } catch (error) { + return { networkError: error?.code ?? error?.message ?? 'unknown' }; + } +} + +async function probeTransport() { + const authed = await probeRateLimit(TOKEN); + const usable = !TOKEN || (authed.status === 200 && authed.rateLimitRemaining !== 0); + const anon = usable ? (TOKEN ? null : authed) : await probeRateLimit(''); + // The raw readings ride along: `classifyTransportProbe` returns null for a + // shape it cannot name, and a caller that kept only the verdict would have + // nothing to report about the container it just failed to classify. + return { verdict: classifyTransportProbe({ token: TOKEN, authed, anon }), authed, anon }; +} + +async function rest(path) { + const res = await fetch(`${API}${path}`, { + headers: { accept: 'application/vnd.github+json', ...(TOKEN ? { authorization: `Bearer ${TOKEN}` } : {}) }, + }); + if (!res.ok) { + const error = new Error(`GET ${path} -> HTTP ${res.status}`); + error.status = res.status; + throw error; + } + return res.json(); +} + +/** Every check-run on a sha, paginated. */ +async function checkRunsFor(sha) { + const out = []; + for (let page = 1; page <= 10; page++) { + const body = await rest(`/repos/${OWNER_REPO}/commits/${sha}/check-runs?per_page=100&page=${page}`); + const batch = body?.check_runs ?? []; + out.push(...batch); + if (batch.length < 100) break; + } + return out; +} + +/** + * The annotations for one check-run. + * + * Always fetched, never inferred from `annotations_count` and never skipped + * because `output.summary` looked empty — trap 1 in the header is precisely + * that the `output` fields are null while the content sits behind this call. A + * failure here is reported as a retrieval failure, not as an absence. + */ +async function annotationsFor(checkRunId) { + return rest(`/repos/${OWNER_REPO}/check-runs/${checkRunId}/annotations?per_page=100`); +} + +async function jobsFor(runId) { + const body = await rest(`/repos/${OWNER_REPO}/actions/runs/${runId}/jobs?per_page=100&filter=latest`); + return body?.jobs ?? []; +} + +// --------------------------------------------------------------------------- +// Target resolution — what sha are we asking about? +// --------------------------------------------------------------------------- + +function headSha() { + try { + return execFileSync('git', ['rev-parse', 'HEAD'], { cwd: ROOT, encoding: 'utf8' }).trim(); + } catch { + return null; + } +} + +async function resolveTarget(argv) { + const value = (flag) => { + const i = argv.indexOf(flag); + return i === -1 ? null : argv[i + 1] ?? null; + }; + const pr = value('--pr'); + if (pr) { + const body = await rest(`/repos/${OWNER_REPO}/pulls/${encodeURIComponent(pr)}`); + return { sha: body?.head?.sha, from: `PR #${pr} (head ${String(body?.head?.sha).slice(0, 8)})` }; + } + const run = value('--run'); + if (run) { + const body = await rest(`/repos/${OWNER_REPO}/actions/runs/${encodeURIComponent(run)}`); + return { sha: body?.head_sha, from: `run ${run} (head ${String(body?.head_sha).slice(0, 8)})` }; + } + const sha = value('--sha'); + if (sha) return { sha, from: `--sha ${sha}` }; + const head = headSha(); + if (!head) return { sha: null, from: 'git HEAD (unreadable)' }; + return { sha: head, from: `git HEAD (${head.slice(0, 8)})` }; +} + +// --------------------------------------------------------------------------- +// The walk +// --------------------------------------------------------------------------- + +/** + * Three request classes, in order: check-runs for the sha, jobs for each run + * that owns a failing check, annotations for each failing check. Everything + * else — which step failed, whether the step list is whole, what command + * reproduces it — is derived from what those already returned, or read off + * disk. + */ +async function walk(sha) { + const all = await checkRunsFor(sha); + const { kept, superseded } = latestPerName(all); + const failingRuns = kept.filter((c) => c.conclusion === 'failure' || c.conclusion === 'timed_out'); + const pending = kept.filter((c) => c.status !== 'completed').length; + + const jobsByRun = new Map(); + for (const check of failingRuns) { + const runId = runIdOf(check); + if (!runId || jobsByRun.has(runId)) continue; + try { + jobsByRun.set(runId, await jobsFor(runId)); + } catch (error) { + jobsByRun.set(runId, { error: error.message }); + } + } + + const workflows = readWorkflows(); + const failing = []; + for (const check of failingRuns) { + const runId = runIdOf(check); + const jobs = jobsByRun.get(runId); + const job = Array.isArray(jobs) ? jobs.find((j) => j.id === check.id) : null; + const steps = job?.steps ?? []; + const failedSteps = steps.filter((s) => s.conclusion === 'failure' || s.conclusion === 'timed_out'); + + let annotations = []; + let retrievalError = null; + try { + annotations = (await annotationsFor(check.id)).map(classifyAnnotation); + } catch (error) { + retrievalError = error.message; + } + + failing.push({ + name: check.name, + checkRunId: check.id, + runId, + url: check.html_url ?? check.details_url ?? null, + jobsError: Array.isArray(jobs) ? null : (jobs?.error ?? "the run's jobs were not fetched"), + steps: stepsIntegrity(steps), + failedSteps: failedSteps.map((s) => ({ + number: s.number, + name: s.name, + repro: resolveStep(workflows, s.name), + })), + roster: isRosterJob(failedSteps.map((s) => s.name)), + annotations, + assertions: annotations.filter((a) => a.kind === 'assertion'), + retrievalError, + }); + } + + failing.sort((a, b) => Number(a.roster) - Number(b.roster)); + return { sha, total: kept.length, pending, failing, superseded, checkRunCount: all.length }; +} + +// --------------------------------------------------------------------------- +// Report +// --------------------------------------------------------------------------- + +function render(result, target) { + const out = []; + const say = (line = '') => out.push(line); + const verdict = verdictOf(result); + + say(`ci-failure — ${OWNER_REPO} @ ${String(result.sha).slice(0, 8)} (from ${target.from})`); + say(''); + say(`VERDICT: ${verdict.verdict} — ${verdict.why}`); + say(` ${result.total} check-run(s) after grouping by name (${result.checkRunCount} rows on the sha, ` + + `${result.superseded.length} superseded)`); + if (result.superseded.length > 0) { + say(' superseded rows are NOT read: a lingering cancelled run under a name whose latest is green'); + say(' is the shape that makes a green PR look red.'); + } + say(''); + + for (const check of result.failing) { + say(`✗ ${check.name} (check-run ${check.checkRunId}${check.runId ? ` · run ${check.runId}` : ''})`); + if (check.url) say(` ${check.url}`); + if (check.roster) { + say(" ROSTER JOB — it verifies other jobs' attestations and holds no assertion of its own."); + say(' The answer is in the shard job listed above; this row is kept so the count is honest.'); + } + if (check.jobsError) say(` steps unavailable: ${check.jobsError}`); + else { + say(` steps ${check.steps.realCount} real + ${check.steps.postCount} post — ` + + `${check.steps.verdict.toUpperCase()} (${check.steps.note})`); + if (check.steps.verdict !== 'complete') { + say(' so a step ABSENT from this list is not evidence that it did not run.'); + } + } + for (const step of check.failedSteps) { + say(` failed step #${step.number} ${step.name}`); + } + if (check.failedSteps.length === 0 && !check.jobsError) { + say(' failed step none reported — with a complete step list that means the job died outside a step'); + } + + if (check.retrievalError) { + say(` annotations RETRIEVAL FAILED: ${check.retrievalError}`); + say(' that is not the same as "the check recorded nothing".'); + } else { + say(` annotations ${check.annotations.length} fetched from /check-runs/${check.checkRunId}/annotations`); + say(' (output.summary and output.text on a check-run are null even when this is not)'); + for (const a of check.annotations) { + const head = a.kind === 'assertion' ? `${a.path}${a.line ? `:${a.line}` : ''}` : a.kind; + say(` · ${head}`); + for (const line of String(a.message).split('\n').slice(0, 20)) say(` ${line}`); + } + } + + const status = assertionStatus(check.annotations); + if (status.kind === 'retrieved') { + say(' assertion RETRIEVED (above, the file-anchored annotation(s))'); + } else { + if (status.kind === 'no-anchor') { + say(` assertion NO FILE-ANCHORED ASSERTION — but this check carried ${status.others.length} annotation(s)`); + say(' with content, printed above. Read those first: a gate sentence is often the'); + say(" whole answer. What is missing is the failing step's own stdout."); + } else { + say(' assertion NONE — the check carried no annotation with any content in it.'); + say(' (An `exit-status` annotation is the runner saying a command exited'); + say(' non-zero; it is not a record of what failed.)'); + } + say(" That stdout exists only in the Actions log blob, and both log"); + say(" endpoints redirect to hosts this session's egress policy denies:"); + for (const host of LOG_BLOB_HOSTS) say(` ${host} (403 on CONNECT)`); + const repros = check.failedSteps.flatMap((s) => s.repro.map((r) => ({ step: s.name, ...r }))); + if (repros.length > 0) { + say(' substitute run the failing step locally — it prints the same assertion:'); + for (const r of repros) { + say(` from ${r.file}, step "${r.step}" (${r.kind}:)`); + for (const line of String(r.text).split('\n').slice(0, 12)) say(` ${line}`); + } + } else { + say(' substitute none — the failing step name resolves to no `run:` block in .github/workflows/,'); + say(' so there is no offline reproduction to offer. Open the job URL above.'); + } + } + say(''); + } + + if (result.failing.length === 0 && result.pending > 0) { + say(`${result.pending} check-run(s) have not finished. Re-run this when they have.`); + say(''); + } + + say(`(Exit ${verdict.exit}: 0 green · 1 red with the assertion in hand · 2 undetermined · ` + + `${EXIT_PREREQUISITE_NOT_MET} prerequisite not met.`); + say(" Piping reports the PIPE's status, so `... | tail` reads green either way. Use `echo 'EXIT=$?'`.)"); + return { text: out.join('\n'), exit: verdict.exit, verdict }; +} + +// --------------------------------------------------------------------------- +// Self-test — offline. No network, no token, no repo state beyond +// .github/workflows/, which is read once to pin the step-name resolver against +// the real tree rather than against a fixture of it. +// --------------------------------------------------------------------------- + +function selfTest() { + const failures = []; + const t = (label, actual, expected = true) => { + const ok = JSON.stringify(actual) === JSON.stringify(expected); + if (!ok) failures.push(`${label}\n expected ${JSON.stringify(expected)}\n actual ${JSON.stringify(actual)}`); + }; + + // -- latestPerName: the superseded-run trap ------------------------------- + const dupes = [ + { id: 1, name: 'CI', conclusion: 'cancelled', started_at: '2026-08-19T01:00:00Z' }, + { id: 2, name: 'CI', conclusion: 'success', started_at: '2026-08-19T02:00:00Z' }, + { id: 3, name: 'Lint', conclusion: 'failure', started_at: '2026-08-19T01:00:00Z' }, + ]; + t('latestPerName keeps the newest per name', latestPerName(dupes).kept.map((c) => c.id), [2, 3]); + t('latestPerName reports what it dropped', latestPerName(dupes).superseded.map((c) => c.id), [1]); + t( + 'latestPerName breaks a started_at tie by id, not by list order', + latestPerName([ + { id: 9, name: 'CI', started_at: '2026-08-19T01:00:00Z' }, + { id: 4, name: 'CI', started_at: '2026-08-19T01:00:00Z' }, + ]).kept.map((c) => c.id), + [9], + ); + t('latestPerName on an empty list yields nothing, not a throw', latestPerName([]).kept.length, 0); + + // -- classifyAnnotation: the four measured shapes ------------------------- + t( + 'a file-anchored annotation is the assertion', + classifyAnnotation({ + path: 'packages/spec/scripts/gen-sdui-manifest-collision.test.ts', + start_line: 167, + message: 'Error: Command failed: bash /tmp/harness.sh', + }).kind, + 'assertion', + ); + t( + 'the assertion keeps its file and line', + (() => { + const a = classifyAnnotation({ path: 'packages/runtime/src/x.test.ts', start_line: 12, message: 'boom' }); + return [a.path, a.line]; + })(), + ['packages/runtime/src/x.test.ts', 12], + ); + t( + '`.github` in path is the runner spelling "no file", not a file anchor', + classifyAnnotation({ path: '.github', start_line: 23, message: 'Process completed with exit code 1.' }).kind, + 'exit-status', + ); + t( + 'a pnpm child-process death is a package pointer, not an assertion', + classifyAnnotation({ + path: '.github', + message: 'command (/home/runner/work/objectstack/objectstack/packages/spec) /opt/hostedtoolcache/node/22.23.2/x64/bin/pnpm run test exited (1)', + }).kind, + 'package-pointer', + ); + t( + '...and it names the directory, which is the lead it carries', + classifyAnnotation({ + path: '.github', + message: 'command (/home/runner/work/objectstack/objectstack/examples/app-showcase) /x/pnpm run test exited (1)', + }).dir, + '/home/runner/work/objectstack/objectstack/examples/app-showcase', + ); + t( + 'the shard-attestation sentence is a pointer at another job', + classifyAnnotation({ + path: '.github', + message: 'Test Core: 1 of 3 declared shard(s) of test published no positive attestation (test-1-of-3). A shard that never ran cannot be counted as passing — see #6082.', + }).leg, + 'test-1-of-3', + ); + t( + 'the leg-result sentence is a pointer too', + classifyAnnotation({ + path: '.github', + message: "Test Core: leg test reported result 'failure' — a declared negative is never overridden by a full roster.", + }).kind, + 'roster-pointer', + ); + t( + 'an unrecognised annotation is `other`, never dropped', + classifyAnnotation({ path: '.github', message: 'something this classifier has never seen' }).kind, + 'other', + ); + t('a missing message does not throw', classifyAnnotation({}).kind, 'other'); + + // -- stepsIntegrity: the pre/post numbering gap is NOT truncation --------- + const testCoreShard = [ + ...Array.from({ length: 16 }, (_, i) => ({ number: i + 1, name: `step ${i + 1}`, conclusion: 'success' })), + { number: 30, name: 'Post Setup pnpm cache', conclusion: 'skipped' }, + { number: 31, name: 'Post Setup Node.js', conclusion: 'skipped' }, + { number: 32, name: 'Post Checkout repository', conclusion: 'success' }, + { number: 33, name: 'Complete job', conclusion: 'success' }, + ]; + t('the measured 16-then-30 shape is COMPLETE', stepsIntegrity(testCoreShard).verdict, 'complete'); + t('...and it counts real steps separately from post steps', [stepsIntegrity(testCoreShard).realCount, stepsIntegrity(testCoreShard).postCount], [16, 4]); + const lintGates = [ + ...Array.from({ length: 68 }, (_, i) => ({ number: i + 1, name: `gate ${i + 1}`, conclusion: 'success' })), + { number: 134, name: 'Post Setup pnpm cache', conclusion: 'success' }, + { number: 135, name: 'Post Setup Node.js', conclusion: 'success' }, + { number: 136, name: 'Post Checkout repository', conclusion: 'success' }, + { number: 137, name: 'Complete job', conclusion: 'success' }, + ]; + t('the measured 68-then-134 shape is COMPLETE as well', stepsIntegrity(lintGates).verdict, 'complete'); + t( + 'an INTERIOR gap is possibly-truncated — absence proves nothing there', + stepsIntegrity([ + { number: 1, name: 'a', conclusion: 'success' }, + { number: 5, name: 'b', conclusion: 'success' }, + { number: 6, name: 'Post a', conclusion: 'success' }, + ]).verdict, + 'possibly-truncated', + ); + t( + 'a list that does not start at #1 is possibly-truncated', + stepsIntegrity([{ number: 7, name: 'a', conclusion: 'success' }]).verdict, + 'possibly-truncated', + ); + t( + 'a contiguous list with no post block is complete', + stepsIntegrity([{ number: 1, name: 'a' }, { number: 2, name: 'b' }]).verdict, + 'complete', + ); + t('no steps at all is `unknown`, never `complete`', stepsIntegrity([]).verdict, 'unknown'); + t('a non-array is `unknown`, never a throw', stepsIntegrity(undefined).verdict, 'unknown'); + + // -- assertionStatus: three answers, not two ------------------------------ + t( + 'a file-anchored annotation means the assertion was retrieved', + assertionStatus([{ kind: 'exit-status' }, { kind: 'assertion', path: 'a.test.ts' }]).kind, + 'retrieved', + ); + t( + 'a gate sentence with no file anchor is NOT "nothing" — it is the answer, unanchored', + assertionStatus([{ kind: 'exit-status' }, { kind: 'other', message: 'This PR adds no changeset.' }]).kind, + 'no-anchor', + ); + t( + 'a package pointer counts as content too — it names where to look', + assertionStatus([{ kind: 'package-pointer', dir: '/x' }]).kind, + 'no-anchor', + ); + t( + 'only exit-status is nothing at all — the one case where "could not retrieve" is the whole truth', + assertionStatus([{ kind: 'exit-status' }]).kind, + 'none', + ); + t('no annotations at all is `none`, never a throw', assertionStatus([]).kind, 'none'); + t('an undefined annotation list is `none`, never a throw', assertionStatus(undefined).kind, 'none'); + t( + 'the unanchored case carries its annotations out, so the caller can count them', + assertionStatus([{ kind: 'other', message: 'x' }, { kind: 'exit-status' }]).others.length, + 1, + ); + + // -- isRosterJob ---------------------------------------------------------- + t('the aggregate roster job is recognised', isRosterJob(['Verify test shard results']), true); + t('...and the dogfood one', isRosterJob(['Verify dogfood shard results']), true); + t('a shard job is not a roster job', isRosterJob(["Run this shard's tests"]), false); + t('a mixed job is not a roster job', isRosterJob(['Verify test shard results', 'Upload reports']), false); + t('no failed steps is not a roster job', isRosterJob([]), false); + + // -- runIdOf -------------------------------------------------------------- + t( + 'the run id rides along on details_url, so it costs no request', + runIdOf({ details_url: 'https://github.com/o/r/actions/runs/32204206019/job/95924070749' }), + '32204206019', + ); + t('a non-Actions check-run yields null rather than a guess', runIdOf({ details_url: 'https://example.test/x' }), null); + t('a check-run with no urls yields null', runIdOf({}), null); + + // -- stepBodies / resolveStep -------------------------------------------- + const wf = [ + 'jobs:', + ' gates:', + ' steps:', + ' - name: Checkout', + ' uses: actions/checkout@v5', + '', + ' # a comment between steps', + ' - name: Engine test-double contract gate', + ' run: pnpm check:engine-double-contract', + '', + " - name: Run this shard's tests", + ' env:', + ' NODE_OPTIONS: --report-on-signal', + ' run: |', + ' if [ ! -s "$RUNNER_TEMP/shard-packages.txt" ]; then', + ' echo "nothing"', + ' fi', + '', + ' - name: Next step', + ' run: echo done', + ].join('\n'); + t('an inline `run:` resolves', stepBodies(wf, 'Engine test-double contract gate'), [{ kind: 'run', text: 'pnpm check:engine-double-contract' }]); + t( + 'a block `run:` resolves, dedented, and stops at the next step', + stepBodies(wf, "Run this shard's tests")[0].text, + 'if [ ! -s "$RUNNER_TEMP/shard-packages.txt" ]; then\n echo "nothing"\nfi', + ); + t('a `uses:` step reports the action rather than nothing', stepBodies(wf, 'Checkout'), [{ kind: 'uses', text: 'actions/checkout@v5' }]); + t('an unknown step name resolves to nothing', stepBodies(wf, 'No such step'), []); + t( + 'a quoted step name matches its unquoted form', + stepBodies([' - name: "Quoted step"', ' run: echo hi'].join('\n'), 'Quoted step'), + [{ kind: 'run', text: 'echo hi' }], + ); + t( + 'resolveStep names the file each hit came from', + resolveStep([{ file: '.github/workflows/lint.yml', text: wf }], 'Engine test-double contract gate'), + [{ file: '.github/workflows/lint.yml', kind: 'run', text: 'pnpm check:engine-double-contract' }], + ); + t( + 'a step name in two workflows returns BOTH — picking one would be a silent guess', + resolveStep( + [ + { file: 'a.yml', text: ' - name: Shared\n run: one' }, + { file: 'b.yml', text: ' - name: Shared\n run: two' }, + ], + 'Shared', + ).map((h) => h.text), + ['one', 'two'], + ); + + // The live wiring: the gate family this tool exists to serve must still be + // resolvable against the REAL workflow tree. A fixture cannot pin that — the + // failure this guards is the workflows being reshaped so no step name + // resolves any more, which would silently turn every gate failure from + // "here is the command" into "no substitute available". + const live = resolveStep(readWorkflows(), 'Engine test-double contract gate'); + t('the real workflow tree still resolves a known gate step to a runnable command', live.length > 0 && live[0].kind === 'run', true); + + // -- proxyRearmFor: the transport trap ------------------------------------ + const proxied = { HTTPS_PROXY: 'http://127.0.0.1:38113' }; + t( + 'a --self-test run never re-execs — it opens no socket', + proxyRearmFor(['--self-test'], { env: proxied, execArgv: [], flagSupported: true }).rearm, + false, + ); + t( + '--help never re-execs either', + proxyRearmFor(['--help'], { env: proxied, execArgv: [], flagSupported: true }).rearm, + false, + ); + t( + 'a live run behind a proxy re-execs with the flag — without it every read is a 401', + proxyRearmFor(['--pr', '9774'], { env: proxied, execArgv: [], flagSupported: true }).flag, + PROXY_FLAG, + ); + t( + 'a live run with no proxy configured stays in process', + proxyRearmFor(['--pr', '9774'], { env: {}, execArgv: [], flagSupported: true }).rearm, + false, + ); + t( + 'the flag already in execArgv stops a second re-exec', + proxyRearmFor(['--pr', '9774'], { env: proxied, execArgv: [PROXY_FLAG], flagSupported: true }).rearm, + false, + ); + t( + 'the guard env stops an infinite re-exec loop', + proxyRearmFor(['--pr', '9774'], { env: { ...proxied, [PROXY_REARM_GUARD]: '1' }, execArgv: [], flagSupported: true }).rearm, + false, + ); + t( + 'a node that will not accept the flag gets the hint, not a crash loop', + (() => { + const plan = proxyRearmFor(['--pr', '9774'], { env: proxied, execArgv: [], flagSupported: false }); + return [plan.rearm, plan.hint]; + })(), + [false, true], + ); + + // -- usageText: --help tracks the header instead of a line number --------- + const fakeHeader = [ + '#!/usr/bin/env node', + '// Copyright', + '', + '/**', + ' * tool — one line about it.', + ' *', + ' * node tool.mjs --flag # what it does', + ' *', + ' * ## A section that is not usage', + ' *', + ' * prose', + ' */', + '', + "import x from 'y';", + ].join('\n'); + t( + 'usage stops at the first ## section, so --help is the usage block and nothing else', + usageText(fakeHeader), + 'tool — one line about it.\n\n node tool.mjs --flag # what it does', + ); + t('usage of a file with no doc comment is empty, not a throw', usageText('const a = 1;'), ''); + // The live wiring: --help must print THIS file's real usage, including every + // documented invocation. A line-slice could not assert that, which is how the + // one it replaces came to omit its own last usage line. + const liveUsage = usageText(readFileSync(fileURLToPath(import.meta.url), 'utf8')); + t( + 'every documented invocation of this file survives into --help', + ['--pr', '--sha', '--run', '--json', '--self-test'].every((flag) => liveUsage.includes(flag)), + true, + ); + t('...and --help stops before the header sections', liveUsage.includes('## '), false); + + // -- verdictOf: the exit table -------------------------------------------- + const withAssertion = { assertions: [{ kind: 'assertion' }] }; + const without = { assertions: [] }; + t('no checks at all is UNDETERMINED, never GREEN', verdictOf({ failing: [], pending: 0, total: 0 }).exit, EXIT_UNDETERMINED); + t('all completed and none failed is GREEN', verdictOf({ failing: [], pending: 0, total: 18 }).exit, EXIT_GREEN); + t('still-running is UNDETERMINED, not GREEN', verdictOf({ failing: [], pending: 3, total: 18 }).exit, EXIT_UNDETERMINED); + t('red with every assertion in hand is RED', verdictOf({ failing: [withAssertion], pending: 0, total: 18 }).exit, EXIT_RED); + t( + 'red with an unretrievable assertion is UNDETERMINED — the whole point of this file', + verdictOf({ failing: [without], pending: 0, total: 18 }).exit, + EXIT_UNDETERMINED, + ); + t( + 'one retrieved and one not is UNDETERMINED — the shortfall outranks the answer', + verdictOf({ failing: [withAssertion, without], pending: 0, total: 18 }).exit, + EXIT_UNDETERMINED, + ); + t( + '...and it says how many, so the reader is not left guessing which half is missing', + verdictOf({ failing: [withAssertion, without], pending: 0, total: 18 }).why.includes('1 of 2'), + true, + ); + + if (failures.length > 0) { + console.error(`✗ ci-failure --self-test (${failures.length} failure(s)):\n`); + for (const f of failures) console.error(` • ${f}`); + console.error(''); + process.exit(1); + } + console.log( + 'OK self-test: supersession keeps the newest per name and reports the drops; the four measured\n' + + ' annotation shapes classify, with the unrecognised one kept rather than dropped; a check that\n' + + ' carried a gate sentence is told apart from one that carried only an exit status and from one\n' + + ' that carried a real assertion; the pre/post step-numbering jump reads COMPLETE while an\n' + + ' interior gap reads possibly-truncated; roster jobs are told from shard jobs; step names\n' + + ' resolve to their run: blocks in fixtures and in the real workflow tree; a live run behind an\n' + + ' HTTPS proxy re-arms itself with --use-env-proxy while the offline modes never do; --help\n' + + ' still carries every documented invocation of this file; and the exit table holds — zero\n' + + ' checks, still-running, and a red check whose assertion could not be retrieved all land on\n' + + ' UNDETERMINED rather than on GREEN.', + ); +} + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +const invokedDirectly = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url); + +if (!invokedDirectly) { + // Imported (a sibling's self-test, or a measurement helper). Running the walk + // as an import side effect would make this file impossible to reuse without + // also spending someone else's rate limit. +} else if (process.argv.includes('--self-test')) { + selfTest(); +} else if (process.argv.includes('--help') || process.argv.includes('-h')) { + console.log(usageText(readFileSync(fileURLToPath(import.meta.url), 'utf8'))); +} else { + // Transport before credentials: behind the session proxy an unproxied fetch + // answers 401 on every endpoint, and the probe below would classify that as a + // dead credential. The flag only takes effect at process start. + const rearm = proxyRearmFor(process.argv.slice(2)); + if (rearm.rearm) { + console.error(`ℹ️ re-exec with ${rearm.flag}: ${rearm.reason}.`); + console.error(" Without it GitHub sees the proxy's placeholder token and answers 401 to everything,"); + console.error(' which this file would otherwise report as PREREQUISITE NOT MET.'); + // The proxy agent announces itself as experimental once per run; the reader + // cannot act on that, so silence it where this node can. + const quiet = process.allowedNodeEnvironmentFlags.has('--disable-warning') ? ['--disable-warning=UNDICI-EHPA'] : []; + const child = spawnSync(process.execPath, [rearm.flag, ...quiet, fileURLToPath(import.meta.url), ...process.argv.slice(2)], { + stdio: 'inherit', + env: { ...process.env, [PROXY_REARM_GUARD]: '1' }, + }); + if (typeof child.status === 'number') process.exit(child.status); + console.error(`⚠️ could not re-exec with ${rearm.flag} (${child.error?.message ?? 'no exit status'}); continuing in` + + '-process — every read may answer 401 for transport reasons.'); + } else if (rearm.hint) { + console.error(`⚠️ ${rearm.reason}`); + console.error(' A 401/403 below is then a TRANSPORT reading, not a verdict about the credential.'); + } + + const { verdict: probe, authed, anon } = await probeTransport(); + if (probe === null) { + console.error('ci-failure: the transport probe returned a result its classifier does not recognise.'); + console.error(` GET /rate_limit with the env token -> ${describeProbe(authed)}`); + console.error(` GET /rate_limit anonymously -> ${describeProbe(anon)}`); + console.error(' That is a gap in the classifier, not a verdict about the tree — report it with both readings.'); + process.exit(EXIT_UNDETERMINED); + } + if (probe.kind !== 'reachable') { + console.error(`ci-failure: PREREQUISITE NOT MET — ${probe.headline}`); + for (const line of probe.detail ?? []) console.error(` ${line}`); + for (const line of probe.fix ?? []) console.error(` fix: ${line}`); + console.error(` (Exit ${EXIT_PREREQUISITE_NOT_MET}. This classifies the ENVIRONMENT, not the tree.)`); + process.exit(EXIT_PREREQUISITE_NOT_MET); + } + const target = await resolveTarget(process.argv.slice(2)); + if (!target.sha) { + console.error(`ci-failure: could not resolve a commit to ask about (${target.from}).`); + console.error(` (Exit ${EXIT_PREREQUISITE_NOT_MET}. This classifies the ENVIRONMENT, not the tree.)`); + process.exit(EXIT_PREREQUISITE_NOT_MET); + } + const result = await walk(target.sha); + const rendered = render(result, target); + if (process.argv.includes('--json')) { + console.log(JSON.stringify({ ...result, target: target.from, verdict: rendered.verdict }, null, 2)); + } else { + console.log(rendered.text); + } + process.exit(rendered.exit); +}