Files
octopus-workflow/core/skills/review-code/scripts/precondition-gate.ts
T

511 lines
20 KiB
TypeScript
Raw Normal View History

// Mechanical-green precondition gate for review-code (#2598).
//
// Runs the CI-aligned checks BEFORE any reviewer is dispatched — at Phase A
// entry and at every Phase D re-entry — so an LLM review round is never spent
// on a diff a deterministic check would reject:
// 1. oxlint — `bun oxlint --deny-warnings` (repo root)
// 2. typecheck — `bun turbo typecheck` (repo root)
// 3. test:changed — `cd packages/octopus && CI=true TEST_SHARDS=3 bun run
// test:changed` (mirrors .gitea/workflows/ci.yml exactly)
//
// `test:parallel` is deliberately NOT a gate check — the full suite belongs
// to verify (#2598 check-tiering).
//
// Known-failure waivers (#4380): a main-preexisting red can reach this gate
// through test:changed's transitive import closure with zero causal link to
// the diff under review (instance: #4296 N-01, TD-930 red via the `Config`
// edge). Waivers are declared in `.octopus/known-failures.json` (tracked —
// they ride PRs like code) as:
//
// {
// "schema_version": 1,
// "entries": [
// {
// "test": "packages/octopus/test/foo.test.ts::widget > broken",
// "fingerprint": "<sha256 below>",
// "evidence": "https://…/issues/4296#issuecomment-…", // REQUIRED
// "reason": "one-line attribution",
// "added_ts": "2026-09-07T00:00:00.000Z"
// }
// ]
// }
//
// - fingerprint = `--fingerprint` helper output = sha256("<file>\0<name>"),
// where <file> is the repo-relative test file and <name> is the bun test
// name exactly as printed in the `(fail)` line (describe chain joined
// with " > ", timing suffix stripped).
// - ALL failures of a `test:changed` run matched by fingerprint downgrade
// that check to WARN: the gate passes (`blocked: false`) and the record
// carries `waivers_applied` with the evidence URL. oxlint/typecheck
// failures, timeouts, and unparsable runs are NEVER waivable (fail-closed).
// - Invalidation is automatic — 指纹消失即失效: once the waived test no
// longer fails while its file still ran (fix landed), the entry matches
// nothing and the gate emits a `waiver-stale` warning + record entry;
// physical removal rides the fix PR (the gate never rewrites repo files).
//
// Result cache: each run is keyed on a tree fingerprint (HEAD sha + tracked
// diff + untracked file contents + the check-set). If the newest record for
// this slug with the same fingerprint is green, the mechanical run is
// skipped (cache hit) — the Developer pre-handoff gate (Phase C step 4) and
// the Phase A/D dispatch gate collapse into ONE execution per tree state.
// verify Phase 2.0 consumes the same green record as its mechanical-evidence
// reuse source (eff-gate-cache: run this script; a cache-hit GREEN transfers
// to verify's typecheck/lint DoD without a local re-run). Only GREEN results are ever reused; a red result always re-runs. The
// fingerprint does NOT cover the environment (bun version, node_modules
// state) — pass --no-cache to force a full re-run after an env change. A
// waived green is cacheable like any other green: the waiver file is part of
// the tree (tracked diff or untracked contents), so editing it changes the
// fingerprint and forces a re-run.
//
// Usage (from the workflow worktree root):
// bun .octopus/skills/review-code/scripts/precondition-gate.ts <slug> [--round N] [--only oxlint|typecheck|test:changed] [--no-cache]
// bun .octopus/skills/review-code/scripts/precondition-gate.ts --fingerprint <repo-relative-test-file> "<bun test name>"
//
// Harness mode (tests only, GATE_* env precedent — see GATE_ARCHIVE_DIR):
// GATE_CHECKS_JSON=<path> replace the check-set with a JSON array of
// { name, command, cwd, timeoutMs?, env? }
// GATE_WAIVER_FILE=<path> replace the default waiver file location
// (<repoRoot>/.octopus/known-failures.json)
//
// Exit codes: 0 = green (dispatch reviewers); 1 = RED — a PRECONDITION-BLOCK
// record has been appended to .octopus/runs/<slug>/reviews/code/precondition-gate.jsonl
// (Tier 1); do NOT dispatch reviewers, hand the output to the Developer
// (Phase C) and re-run after the fix. 2 = usage error.
//
// Every invocation (green, red, or cache hit) appends a record — per-check
// durations feed the gate-latency telemetry (#2598 DoD).
import { spawnSync } from "node:child_process"
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs"
import { createHash } from "node:crypto"
import { join } from "node:path"
// ---- Waiver types & helpers (#4380) ----
interface WaiverEntry {
test: string // "<repo-relative file>::<bun test name>"
fingerprint: string // sha256("<file>\0<name>")
evidence: string // attribution URL (issue comment)
reason?: string
added_ts?: string
}
interface WaiverLoad {
entries: WaiverEntry[]
warnings: string[]
}
function testFingerprint(file: string, name: string): string {
return createHash("sha256").update(`${file}\0${name}`).digest("hex")
}
// Fail-closed loader: any malformation drops the affected entries (or the
// whole file) with a warning — never grants an exemption it cannot verify.
function loadWaivers(waiverPath: string): WaiverLoad {
const warnings: string[] = []
if (!existsSync(waiverPath)) return { entries: [], warnings }
let parsed: unknown
try {
parsed = JSON.parse(readFileSync(waiverPath, "utf8"))
} catch (e) {
return {
entries: [],
warnings: [
`malformed known-failures file (unparsable JSON) at ${waiverPath}: ${(e as Error).message} — treating as no waivers`,
],
}
}
const wf = parsed as { schema_version?: unknown; entries?: unknown }
if (typeof wf.schema_version !== "number" || wf.schema_version !== 1) {
return {
entries: [],
warnings: [`malformed known-failures file at ${waiverPath}: schema_version must be 1 — treating as no waivers`],
}
}
if (!Array.isArray(wf.entries)) {
return {
entries: [],
warnings: [`malformed known-failures file at ${waiverPath}: entries must be an array — treating as no waivers`],
}
}
const entries: WaiverEntry[] = []
for (const raw of wf.entries) {
const e = raw as Partial<WaiverEntry>
const desc = typeof e.test === "string" ? e.test : JSON.stringify(raw).slice(0, 120)
if (typeof e.test !== "string" || !e.test.includes("::")) {
warnings.push(`waiver entry ignored (test must be "<file>::<name>"): ${desc}`)
continue
}
if (typeof e.fingerprint !== "string" || !/^[0-9a-f]{64}$/.test(e.fingerprint)) {
warnings.push(`waiver entry ignored (fingerprint must be 64-hex — run --fingerprint): ${desc}`)
continue
}
if (typeof e.evidence !== "string" || !/^https?:\/\//.test(e.evidence)) {
warnings.push(`waiver entry ignored (evidence must be an issue/comment URL): ${desc}`)
continue
}
entries.push({
test: e.test,
fingerprint: e.fingerprint,
evidence: e.evidence,
...(typeof e.reason === "string" ? { reason: e.reason } : {}),
...(typeof e.added_ts === "string" ? { added_ts: e.added_ts } : {}),
})
}
return { entries, warnings }
}
// ---- bun test output parsing (formats verified against bun 1.3.14 non-TTY) ----
//
// stdout carries the test:changed selection list BEFORE execution:
// test:changed: N test file(s) selected from M changed file(s):
// packages/octopus/test/foo.test.ts
// stderr carries per-file headers + failure lines (passing-only files and
// (pass) lines leave no trace):
// test/foo.test.ts:
// (fail) widget > known broken thing [0.08ms]
interface ParsedFailure {
file: string // repo-relative
name: string
fingerprint: string
}
function parseSelection(stdoutText: string): string[] {
const lines = stdoutText.split(/\r?\n/)
const selected: string[] = []
let inList = false
for (const line of lines) {
if (!inList) {
if (/^test:changed: \d+ test file\(s\) selected from /.test(line)) inList = true
continue
}
const m = line.match(/^ (\S+\.(?:test|spec)\.(?:ts|tsx))(?: \(isolated\))?$/)
if (!m) break // the list ends at the first non-entry line
selected.push(m[1]!)
}
return selected
}
// bun prints file headers exactly as the path was passed on the CLI;
// test:changed passes package-relative paths, so resolve against the
// repo-relative selection list by unique suffix. Ambiguous/unresolvable
// headers stay raw — a properly-authored waiver then misses, which is the
// fail-closed direction.
function resolveRepoRelative(header: string, selection: string[]): string {
if (selection.includes(header)) return header
const candidates = selection.filter((s) => s.endsWith(`/${header}`))
return candidates.length === 1 ? candidates[0]! : header
}
function parseFailures(outputText: string, selection: string[]): ParsedFailure[] {
const byFp = new Map<string, ParsedFailure>()
let header: string | null = null
for (const line of outputText.split(/\r?\n/)) {
const h = line.match(/^(\S+\.(?:test|spec)\.(?:ts|tsx)):\s*$/)
if (h) {
header = h[1]!
continue
}
const f = line.match(/^\(fail\) (.+) \[[0-9.]+ms\]$/)
if (f && header !== null) {
const file = resolveRepoRelative(header, selection)
const name = f[1]!
byFp.set(testFingerprint(file, name), { file, name, fingerprint: testFingerprint(file, name) })
}
}
return [...byFp.values()]
}
// ---- CLI ----
const args = process.argv.slice(2)
const fpIdx = args.indexOf("--fingerprint")
if (fpIdx >= 0) {
const file = args[fpIdx + 1]
const name = args[fpIdx + 2]
if (!file || !name) {
console.error(
'usage: bun .octopus/skills/review-code/scripts/precondition-gate.ts --fingerprint <repo-relative-test-file> "<bun test name>"',
)
process.exit(2)
}
console.log(testFingerprint(file, name))
process.exit(0)
}
let slug = ""
let round = 1
let only: string | null = null
let noCache = false
for (let i = 0; i < args.length; i++) {
if (args[i] === "--round") round = Number(args[++i] ?? 1)
else if (args[i] === "--no-cache") noCache = true
else if (args[i] === "--only") only = args[++i] ?? ""
else slug = args[i]
}
if (!slug || !/^[a-zA-Z0-9._-]+$/.test(slug) || !Number.isFinite(round) || round < 1) {
console.error(
"usage: bun .octopus/skills/review-code/scripts/precondition-gate.ts <slug> [--round N] [--only oxlint|typecheck|test:changed] [--no-cache]",
)
console.error(
' bun .octopus/skills/review-code/scripts/precondition-gate.ts --fingerprint <repo-relative-test-file> "<bun test name>"',
)
process.exit(2)
}
const root = spawnSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8" })
if (root.status !== 0 || !root.stdout) {
console.error("precondition-gate: not inside a git worktree")
process.exit(2)
}
const repoRoot = root.stdout.trim()
type Check = { name: string; command: string; cwd: string; env?: Record<string, string>; timeoutMs: number }
const defaultChecks: Check[] = [
{ name: "oxlint", command: "bun oxlint --deny-warnings", cwd: repoRoot, timeoutMs: 5 * 60_000 },
{ name: "typecheck", command: "bun turbo typecheck", cwd: repoRoot, timeoutMs: 10 * 60_000 },
{
name: "test:changed",
command: "bun run test:changed",
cwd: join(repoRoot, "packages", "octopus"),
env: { CI: "true", TEST_SHARDS: "3" }, // mirror ci.yml Test step
timeoutMs: 20 * 60_000,
},
]
// Harness mode (#4380): GATE_CHECKS_JSON replaces the production check-set
// with fixture commands (GATE_* env precedent). Unset in production.
let allChecks: Check[] = defaultChecks
if (process.env.GATE_CHECKS_JSON) {
try {
const parsed = JSON.parse(readFileSync(process.env.GATE_CHECKS_JSON, "utf8")) as unknown
if (!Array.isArray(parsed) || parsed.length === 0) throw new Error("must be a non-empty JSON array")
for (const c of parsed) {
const chk = c as Partial<Check>
if (typeof chk.name !== "string" || typeof chk.command !== "string" || typeof chk.cwd !== "string") {
throw new Error("each check needs string name/command/cwd")
}
if (chk.timeoutMs !== undefined && typeof chk.timeoutMs !== "number")
throw new Error("timeoutMs must be a number")
if (chk.env !== undefined && typeof chk.env !== "object") throw new Error("env must be an object")
}
allChecks = parsed as Check[]
} catch (e) {
console.error(
`precondition-gate: invalid GATE_CHECKS_JSON (${process.env.GATE_CHECKS_JSON}): ${(e as Error).message}`,
)
process.exit(2)
}
}
const checks = only ? allChecks.filter((c) => c.name === only) : allChecks
if (checks.length === 0) {
console.error(`--only must be one of: ${allChecks.map((c) => c.name).join(", ")}`)
process.exit(2)
}
const runsDir = join(repoRoot, ".octopus", "runs", slug, "reviews", "code")
const gateLog = join(runsDir, "precondition-gate.jsonl")
const gitOut = (gitArgs: string[]): string => {
const r = spawnSync("git", gitArgs, { encoding: "utf8", cwd: repoRoot })
return r.status === 0 ? r.stdout : ""
}
// Fingerprint = check-set + HEAD + tracked diff (staged+unstaged) + untracked
// contents. Above the untracked cap the fingerprint is made unique so this
// run can never cache-hit (fail-safe: always re-run).
const UNTRACKED_CACHE_CAP = 500
function treeFingerprint(): string {
const h = createHash("sha256")
h.update("gate-fingerprint-v1\n")
h.update(checks.map((c) => c.name).join(",") + "\n")
h.update(gitOut(["rev-parse", "HEAD"]))
h.update("\0")
h.update(gitOut(["diff", "HEAD"]))
h.update("\0")
const untracked = gitOut(["ls-files", "--others", "--exclude-standard"]).split(/\r?\n/).filter(Boolean)
if (untracked.length > UNTRACKED_CACHE_CAP) return `nocache-${Date.now()}-${Math.random()}`
const hashes = spawnSync("git", ["hash-object", "--stdin-paths"], {
input: untracked.join("\n"),
encoding: "utf8",
cwd: repoRoot,
})
h.update(hashes.status === 0 ? hashes.stdout : "")
return h.digest("hex")
}
// Only a GREEN record with the identical fingerprint may skip a re-run; red
// always re-runs (an environment-caused red must not wedge the gate until
// the tree changes).
function lastGreenHit(fp: string): { round: number; ts: string } | null {
if (!existsSync(gateLog)) return null
const lines = readFileSync(gateLog, "utf8")
.split(/\r?\n/)
.filter((l) => l.trim() !== "")
for (let i = lines.length - 1; i >= 0; i--) {
try {
const r = JSON.parse(lines[i]!) as { fingerprint?: string; blocked?: boolean; round?: number; ts?: string }
if (r.fingerprint === fp && r.blocked === false && typeof r.ts === "string") {
return { round: r.round ?? 0, ts: r.ts }
}
} catch {
// tolerate a malformed/truncated line — keep scanning
}
}
return null
}
const fingerprint = treeFingerprint()
const hit = noCache ? null : lastGreenHit(fingerprint)
const waiverPath = process.env.GATE_WAIVER_FILE ?? join(repoRoot, ".octopus", "known-failures.json")
const waivers = loadWaivers(waiverPath)
const record: Record<string, unknown> = {
schema_version: 3,
gate: "precondition-gate",
ts: new Date().toISOString(),
slug,
round,
fingerprint,
cache_hit: hit !== null,
checks: [] as Array<Record<string, unknown>>,
blocked: false,
}
if (hit) {
record.checks = checks.map((c) => ({ name: c.name, skipped: true }))
console.log(`PRECONDITION-GATE GREEN (cache hit — slug=${slug} round=${round})`)
console.log(`Reusing green result from ${hit.ts} (round ${hit.round}) — identical tree fingerprint.`)
} else {
const tail = (s: string | null | undefined): string[] =>
(s ?? "")
.split(/\r?\n/)
.filter((l) => l.trim() !== "")
.slice(-15)
for (const w of waivers.warnings) console.log(`[waiver] WARN ${w}`)
let blocked = false
for (const c of checks) {
const started = Date.now()
const r = spawnSync(c.command, {
shell: true,
cwd: c.cwd,
encoding: "buffer",
env: { ...process.env, ...c.env },
timeout: c.timeoutMs,
})
const timedOut =
r.error?.name === "TimeoutError" || (r.error as NodeJS.ErrnoException | undefined)?.code === "ETIMEDOUT"
const exitCode = timedOut ? 124 : (r.status ?? 1)
const failed = exitCode !== 0
const outText = r.stdout?.toString() ?? ""
const errText = r.stderr?.toString() ?? ""
// ---- waiver pass (#4380): test:changed only ----
// A red is waivable only when every parsed failure matches a waiver
// fingerprint. Timeouts and zero-failure reds (crash, harness gap, output
// drift) stay blocking — no fingerprint to vouch for, fail closed.
let waived = false
let applied: WaiverEntry[] = []
let failures: ParsedFailure[] = []
let stale: WaiverEntry[] = []
if (c.name === "test:changed") {
const selection = parseSelection(outText)
failures = parseFailures(`${outText}\n${errText}`, selection)
if (failed && !timedOut && failures.length > 0 && waivers.entries.length > 0) {
const fpSet = new Set(failures.map((f) => f.fingerprint))
applied = waivers.entries.filter((e) => fpSet.has(e.fingerprint))
waived = applied.length === failures.length
}
// 指纹消失即失效: the entry's file ran in this selection yet its
// fingerprint is absent from the failures — the waiver is inert now.
// Report only; removal rides the fix PR (the gate never rewrites
// repo-tracked state).
if (waivers.entries.length > 0 && selection.length > 0) {
const failFps = new Set(failures.map((f) => f.fingerprint))
stale = waivers.entries.filter((e) => {
const file = e.test.split("::")[0]!
return selection.includes(file) && !failFps.has(e.fingerprint)
})
}
}
if (failed && !waived) blocked = true
const errorLines = failed ? tail(errText || outText) : []
;(record.checks as Array<Record<string, unknown>>).push({
name: c.name,
command: c.command,
cwd: c.cwd.replace(/\\/g, "/"),
exit_code: exitCode,
timed_out: timedOut,
...(failures.length > 0 ? { failures } : {}),
...(waived ? { waived: true } : {}),
duration_ms: Date.now() - started,
...(errorLines.length > 0 ? { error_tail: errorLines } : {}),
})
if (applied.length > 0) {
record.waivers_applied = [
...((record.waivers_applied as WaiverEntry[]) ?? []),
...applied.map((e) => ({ test: e.test, fingerprint: e.fingerprint, evidence: e.evidence })),
]
}
if (stale.length > 0) {
record.waivers_stale = [
...((record.waivers_stale as WaiverEntry[]) ?? []),
...stale.map((e) => ({ test: e.test, fingerprint: e.fingerprint })),
]
}
if (waived) {
console.log(
`[${c.name}] WARN (waived — ${applied.length} known failure(s) exempted) (${((Date.now() - started) / 1000).toFixed(1)}s)`,
)
for (const e of applied) console.log(` [waiver] ${e.test} — evidence: ${e.evidence}`)
} else if (failed && applied.length > 0) {
console.log(
`[${c.name}] FAIL (${applied.length}/${failures.length} failure(s) waived — unmatched failures block the gate) (${((Date.now() - started) / 1000).toFixed(1)}s)`,
)
for (const e of applied) console.log(` [waiver] ${e.test} — evidence: ${e.evidence}`)
} else {
console.log(`[${c.name}] ${failed ? "FAIL" : "ok"} (${((Date.now() - started) / 1000).toFixed(1)}s)`)
}
for (const e of stale) {
console.log(
` [waiver-stale] ${e.test} — fingerprint absent from this run; entry is inert, remove it in the fix PR`,
)
}
if (failed) for (const l of errorLines.slice(0, 8)) console.log(` ${l}`)
}
record.blocked = blocked
}
try {
mkdirSync(runsDir, { recursive: true })
appendFileSync(gateLog, JSON.stringify(record) + "\n")
} catch (e) {
// The gate verdict must not depend on Tier 1 logging succeeding; surface but proceed.
console.error(`[warn] could not append gate record: ${(e as Error).message}`)
}
console.log()
if (record.blocked) {
console.log("PRECONDITION-BLOCK — mechanically red. Do NOT dispatch reviewers.")
console.log("Route the failing check output above to the Developer (Phase C), fix, then re-run this gate.")
process.exit(1)
}
const appliedCount = ((record.waivers_applied as WaiverEntry[]) ?? []).length
if (hit) {
// cache-hit path already printed above
} else if (appliedCount > 0) {
console.log(
`PRECONDITION-GATE GREEN (slug=${slug} round=${round}) — ${appliedCount} known failure(s) waived; evidence in the gate record. Dispatch reviewers.`,
)
} else {
console.log(`PRECONDITION-GATE GREEN (slug=${slug} round=${round}) — dispatch reviewers.`)
}