diff --git a/scripts/check-stale-todo-markers.mjs b/scripts/check-stale-todo-markers.mjs index 860082d9..bdf58335 100755 --- a/scripts/check-stale-todo-markers.mjs +++ b/scripts/check-stale-todo-markers.mjs @@ -14,10 +14,50 @@ // Design notes: // - No new dependencies: it shells out to the `gh` CLI (already a dev tool) // via execFileSync (argv array, no shell — no injection surface). -// - Fail-soft: if gh is missing or unauthenticated it warns and exits 0, -// so a contributor without gh isn't blocked. There is no CI equivalent — -// the TODO file lives under .claude/ (gitignored, not checked in), so -// this script is intentionally local-only (see CLAUDE.md). +// - Fail-soft & advisory: this is a LOCAL-only check (the TODO file lives +// under .claude/, gitignored, absent from CI checkouts), so it never hard- +// blocks a contributor. A missing/unauthenticated/rate-limited `gh` warns +// and exits 0. +// - Tests split by seam: pure transforms (`parseInflight`, `parseEntry`, +// `summarize`) are pinned with literal in-memory fixtures; the `ghRunner`- +// injected exports (`resolveEntry`, `scanTodo`, `runScan`) are pinned with +// a fake `ghRunner`; `classifyGhError` is pinned with fake error objects +// and `ghExec` with a fake `exec` (a different seam, not the `ghRunner`). +// `main()` is a thin fs + console + process.exit wrapper behind the +// import-guard at the bottom. +// +// Verdict correctness (three false-verdict paths this file deliberately avoids): +// (a) REUSED head-branch name — a branch name may be reused after its first +// PR merged. `resolveEntry` prefers an OPEN PR for the branch, so +// still-in-flight work is not flagged stale WHILE that OPEN PR exists. +// Residual gap: a reused branch whose new work has not yet opened a PR +// still resolves via the old MERGED PR and reads STALE — durable PR +// identity on the marker (not just a branch name) is the real fix, +// tracked in the TODO backlog. +// (b) NUMBER-ONLY match — PR numbers are reused across repo generations, so a +// match resting solely on a `(#N)` / `(PR #N)` number is REPORT-ONLY +// (a warning), never a hard STALE verdict. A conforming in-flight entry +// names a `(branch: X)` (the marker `check-todo-hygiene.mjs` lints for) +// and resolves via the branch path, which still exits 1 when stale. +// (c) TRANSIENT gh failure — a one-off 404 / network blip skips only THAT +// entry (recorded for a partial-scan warning) and keeps scanning; it does +// NOT bail the whole loop. See the error taxonomy below. +// +// Error taxonomy (see `classifyGhError`): +// - GhUnavailable — `gh` is unusable for the REST of this run: not installed +// (ENOENT), not authenticated (auth failure / HTTP 401 bad credentials), or +// rate-limited. All three persist for the run, so re-trying every remaining +// entry is futile; the scan stops but +// still RETURNS what it already found (a stale marker confirmed before a +// mid-scan auth expiry must not be silently discarded). +// - GhTransient — a genuinely per-entry failure (single 404, one-off network +// blip, unparseable JSON): skip that entry, continue. +// Any OTHER error is a real bug and propagates (never swallowed), surfacing as +// exit 2 — kept distinct from the stale exit 1 so a crash can't read as +// "stale found". +// +// Exit codes: 0 = clean or partial scan (fail-soft), 1 = a genuine stale +// marker, 2 = TODO unreadable or an internal error. // // Usage: // node scripts/check-stale-todo-markers.mjs [path-to-todo.md] @@ -25,34 +65,61 @@ import { execFileSync } from "node:child_process"; import { readFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +export const DEFAULT_TODO = ".claude/docs/TODO.md"; -const DEFAULT_TODO = ".claude/docs/TODO.md"; -const todoPath = process.argv[2] ?? DEFAULT_TODO; +// `gh` is unusable for the rest of this run (ENOENT / auth / rate limit). +export class GhUnavailable extends Error {} +// A single-entry `gh` failure (one 404, a network blip, unparseable JSON). +export class GhTransient extends Error {} -let text; -try { - text = readFileSync(todoPath, "utf8"); -} catch (err) { - console.error(`check-stale-todo-markers: cannot read ${todoPath}: ${err.message}`); - process.exit(2); +// Map a raw execFile/JSON error to the taxonomy. Pure — fake error objects in, +// the right class out — so the classification is unit-testable without a real +// `gh`. ENOENT (not installed), auth failures, and rate limits all persist for +// the run → GhUnavailable; everything else is treated as per-entry transient. +export function classifyGhError(err) { + const msg = `${err?.stderr ?? ""}${err?.message ?? ""}`; + if ( + err?.code === "ENOENT" || + /not logged|authentication|gh auth login|GH_TOKEN|rate limit|bad credentials|HTTP 401/i.test( + msg + ) + ) { + return new GhUnavailable(msg.trim() || String(err)); + } + return new GhTransient(msg.trim() || String(err)); +} + +// Run `gh` and return parsed JSON. `exec` is injectable so tests can exercise +// the JSON.parse → GhTransient path and the happy path without a real binary. +// Throws GhUnavailable / GhTransient per classifyGhError; a JSON parse failure +// is transient (one bad response for one entry). +export function ghExec(args, exec = execFileSync) { + let out; + try { + out = exec("gh", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); + } catch (err) { + throw classifyGhError(err); + } + try { + return JSON.parse(out); + } catch (err) { + throw new GhTransient(`unparseable gh output: ${err.message}`); + } } // An ACTIVE in-flight entry is an unchecked task line carrying the 🚧 glyph. // Checked (`- [x]`) lines are done and never in-flight, so they are skipped. -const inflight = text - .split("\n") - .filter((line) => /^\s*-\s*\[ \]/.test(line) && line.includes("🚧")); - -if (inflight.length === 0) { - console.log("check-stale-todo-markers: no 🚧 in-flight entries — OK"); - process.exit(0); +export function parseInflight(text) { + return text.split("\n").filter((line) => /^\s*-\s*\[ \]/.test(line) && line.includes("🚧")); } // Extract the branch and/or PR number from one entry line. Both the modern // `(branch: X)` form and the legacy `` HTML marker are // recognized (the same two forms /merge-pr's sync understands). PR numbers // appear as `(PR #123)` or a bare `(#123)`. -function parseEntry(line) { +export function parseEntry(line) { const branch = line.match(/\(branch:\s*([^)]+?)\s*\)/)?.[1] ?? line.match(//)?.[1] ?? @@ -63,102 +130,206 @@ function parseEntry(line) { return { branch, pr, title }; } -// Run gh and return parsed JSON, or null if gh is unavailable/unauthenticated -// so the caller can decide to warn-and-pass rather than hard-fail. -function gh(args) { - try { - const out = execFileSync("gh", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); - return JSON.parse(out); - } catch (err) { - const msg = `${err.stderr ?? ""}${err.message ?? ""}`; - if ( - err.code === "ENOENT" || // gh not installed - /not logged|authentication|gh auth login|GH_TOKEN/i.test(msg) - ) { - throw new GhUnavailable(msg.trim()); - } - // Other failures (e.g. transient API error) — treat as unavailable too, - // since a guard that hard-fails on a flaky network would be worse than - // one that warns. CI re-runs catch genuinely stale markers. - throw new GhUnavailable(msg.trim() || String(err)); - } -} - -class GhUnavailable extends Error {} - -// Resolve whether an entry's PR has already merged. Prefer the branch lookup -// (covers entries with only a branch); fall back to the PR number. -function resolveMerged({ branch, pr }) { +// Resolve one entry against GitHub via the injected `ghRunner`. +// Returns { status: "clean" | "stale" | "warn", merged?: { number, title } }. +// - branch path (authoritative): fix (a) — an OPEN PR for the head branch +// means the work is in flight, so it reads clean WHILE that OPEN PR +// exists, even if an OLDER same-named PR merged. Only "no OPEN + a +// MERGED" → stale. (A reused branch with no new PR yet still resolves +// via the old MERGED PR — see the header note.) +// - number-only path: fix (b) — a match resting solely on a PR number is a +// WARNING, never stale (PR numbers are reused across repo generations). +export function resolveEntry({ branch, pr }, ghRunner) { if (branch) { - const prs = gh([ + // fix (a): an OPEN PR for the head branch means the work is in flight, so + // the entry reads clean while that OPEN PR exists — even if an older + // same-named PR merged. Query OPEN on its own (not a truncated `--state + // all` slice) so a busy reused branch can't hide the in-flight PR behind + // a result cap. (If no new PR has been opened yet for a reused branch, + // this still falls through to the MERGED check below and reads stale — + // a fundamental branch-name-identity limitation, not a bug here.) + const open = ghRunner([ + "pr", + "list", + "--head", + branch, + "--state", + "open", + "--json", + "number", + "--limit", + "1", + ]); + if (open.length > 0) { + return { status: "clean" }; // in flight — clean while this OPEN PR exists (fix (a)) + } + const merged = ghRunner([ "pr", "list", "--head", branch, "--state", - "all", + "merged", "--json", - "number,state,title,mergedAt", + "number,title,mergedAt", "--limit", - "5", + "1", ]); - const merged = prs.find((p) => p.state === "MERGED"); - if (merged) { - return { number: merged.number, title: merged.title }; + if (merged.length > 0) { + return { status: "stale", merged: { number: merged[0].number, title: merged[0].title } }; } - return null; + return { status: "clean" }; } if (pr) { - const view = gh(["pr", "view", pr, "--json", "state,title,mergedAt"]); + const view = ghRunner(["pr", "view", pr, "--json", "state,title,mergedAt"]); if (view.state === "MERGED") { - return { number: Number(pr), title: view.title }; + // Report-only: a number can collide across repo generations (fix (b)). + return { status: "warn", merged: { number: Number(pr), title: view.title } }; } - return null; + return { status: "clean" }; } - return null; + return { status: "clean" }; } -const stale = []; -try { +// Walk every in-flight entry, resolving each against `ghRunner`. +// Returns { inflightCount, stale, warnings, skipped, aborted }. +// - GhTransient → record in `skipped`, keep scanning (fix (c)). +// - GhUnavailable → record `aborted` and STOP, but still return everything +// found so far (never discard a stale marker confirmed before the abort). +// - any other error → rethrow (a real bug must not be swallowed into a +// silent clean/continue). +export function scanTodo(text, ghRunner = ghExec) { + const inflight = parseInflight(text); + const stale = []; + const warnings = []; + const skipped = []; + let aborted = null; + for (const line of inflight) { const entry = parseEntry(line); if (!entry.branch && !entry.pr) { continue; // nothing to resolve against } - const merged = resolveMerged(entry); - if (merged) { - stale.push({ entry, merged }); + let result; + try { + result = resolveEntry(entry, ghRunner); + } catch (err) { + if (err instanceof GhTransient) { + skipped.push({ entry, reason: err.message }); + continue; + } + if (err instanceof GhUnavailable) { + aborted = { reason: err.message }; + break; + } + throw err; // taxonomy-external → a real bug, surface it (never swallowed) } + if (result.status === "stale") { + stale.push({ entry, merged: result.merged }); + } else if (result.status === "warn") { + warnings.push({ entry, merged: result.merged }); + } + } + + return { inflightCount: inflight.length, stale, warnings, skipped, aborted }; +} + +// Pure: turn a scan result into an exit code + message lines (no I/O, no +// throw). Exit 1 iff a genuine stale marker exists; otherwise 0 (fail-soft). +// The summary line always says "partial scan" when any entry was skipped or +// the scan aborted, so exit 0 never reads as "all verified clean". +export function summarize(scan) { + const out = []; + const err = []; + const { inflightCount, stale, warnings, skipped, aborted } = scan; + + if (inflightCount === 0) { + out.push("check-stale-todo-markers: no 🚧 in-flight entries — OK"); + return { exitCode: 0, out, err }; } -} catch (err) { - if (err instanceof GhUnavailable) { - console.warn( - `check-stale-todo-markers: gh unavailable/unauthenticated — skipping check.\n (${err.message})` + + const partial = skipped.length > 0 || Boolean(aborted); + + for (const { entry, merged } of warnings) { + err.push(` ⚠ ${entry.title}`); + err.push( + ` PR #${merged.number} looks MERGED (number-only match — verify): ${merged.title}` + ); + } + for (const { entry, reason } of skipped) { + err.push(` … skipped (transient gh error): ${entry.title}\n (${reason})`); + } + if (aborted) { + err.push( + ` … scan stopped early — gh became unavailable; remaining entries unchecked.\n (${aborted.reason})` ); - process.exit(0); } - throw err; -} -if (stale.length === 0) { - console.log( - `check-stale-todo-markers: ${inflight.length} 🚧 in-flight ${ - inflight.length === 1 ? "entry" : "entries" - } checked, none stale — OK` + if (stale.length === 0) { + const scope = partial ? "partial scan" : "checked"; + const noun = inflightCount === 1 ? "entry" : "entries"; + out.push( + `check-stale-todo-markers: ${inflightCount} 🚧 in-flight ${noun} (${scope}), none stale — OK` + + (warnings.length ? ` (${warnings.length} number-only warning(s) above)` : "") + ); + return { exitCode: 0, out, err }; + } + + err.unshift( + `check-stale-todo-markers: ${stale.length} STALE 🚧 marker(s)` + + (partial ? " (partial scan — some entries unchecked)" : "") + + "\n" + ); + for (const { entry, merged } of stale) { + const via = entry.branch ? `branch: ${entry.branch}` : `PR #${merged.number}`; + err.push(` ✗ ${entry.title}`); + err.push(` ${via} — PR #${merged.number} is MERGED: ${merged.title}`); + } + err.push( + "\nRemediation: the PR(s) above merged outside /merge-pr, so the 🚧 entry was\n" + + "never archived. Move each stale entry from the TODO file into TODO-archive.md\n" + + "(collapse to a one-line ✅ entry), then re-run this check. Going forward, merge\n" + + "through /merge-pr so its post-merge sync handles this automatically." ); - process.exit(0); + return { exitCode: 1, out, err }; +} + +// Wrap summarize(scanTodo(...)) so a taxonomy-external error (a real bug) maps +// to exit 2 — distinct from stale's exit 1 — instead of crashing with Node's +// default exit 1 (which would read as "stale found"). The testable exit-2 seam. +export function runScan(text, ghRunner = ghExec) { + try { + return summarize(scanTodo(text, ghRunner)); + } catch (e) { + return { + exitCode: 2, + out: [], + err: [`check-stale-todo-markers: internal error — ${e?.message ?? String(e)}`], + }; + } +} + +function main() { + const todoPath = process.argv[2] ?? DEFAULT_TODO; + let text; + try { + text = readFileSync(todoPath, "utf8"); + } catch (err) { + console.error(`check-stale-todo-markers: cannot read ${todoPath}: ${err.message}`); + process.exit(2); + } + + const { exitCode, out, err } = runScan(text); + for (const line of out) { + console.log(line); + } + for (const line of err) { + console.error(line); + } + process.exit(exitCode); } -console.error(`check-stale-todo-markers: ${stale.length} STALE 🚧 marker(s) in ${todoPath}\n`); -for (const { entry, merged } of stale) { - const branch = entry.branch ? `branch: ${entry.branch}` : `PR #${merged.number}`; - console.error(` ✗ ${entry.title}`); - console.error(` ${branch} — PR #${merged.number} is MERGED: ${merged.title}`); +// Only run main when invoked directly (not when imported by the test suite). +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main(); } -console.error( - "\nRemediation: the PR(s) above merged outside /merge-pr, so the 🚧 entry was\n" + - "never archived. Move each stale entry from the TODO file into TODO-archive.md\n" + - "(collapse to a one-line ✅ entry), then re-run this check. Going forward, merge\n" + - "through /merge-pr so its post-merge sync handles this automatically." -); -process.exit(1); diff --git a/test/build/stale-todo-markers.test.ts b/test/build/stale-todo-markers.test.ts new file mode 100644 index 00000000..3a9787d7 --- /dev/null +++ b/test/build/stale-todo-markers.test.ts @@ -0,0 +1,325 @@ +// Non-vacuity pins for scripts/check-stale-todo-markers.mjs. +// +// The script is the backstop that catches stale in-flight (🚧) TODO markers +// whose PR merged outside /merge-pr. It shells out to `gh`. These tests feed +// the PURE / dependency-injected exports in-memory fixtures — a fake `ghRunner` +// (and a fake `exec` for `ghExec`) — so no real `gh` binary, network, or auth +// is needed, and the three historical false-verdict paths stay pinned: +// (a) a REUSED head-branch name whose OLD PR merged must not flag still-open +// work as stale — an OPEN PR for the branch wins. +// (b) a bare `(#N)` / number-only match must downgrade to a WARNING, never a +// hard STALE verdict (PR numbers are reused across repo generations). +// (c) one transient gh failure must skip only THAT entry and keep scanning — +// not bail the whole loop — and the exit must reflect a partial scan. +// +// Why in-memory (never the real .claude/docs/TODO.md): that file is git-ignored +// and absent from CI checkouts, so reading it here would fail in CI — same rule +// as test/build/todo-hygiene.test.ts. +// +// @ts-nocheck — importing a plain .mjs with no bundled types; vitest runs this +// transpile-only and tsc does not include test/build/ in `pnpm compile`. +import { describe, expect, it } from "vitest"; + +import { + classifyGhError, + GhTransient, + GhUnavailable, + ghExec, + parseEntry, + parseInflight, + resolveEntry, + runScan, + scanTodo, + summarize, +} from "../../scripts/check-stale-todo-markers.mjs"; + +// A fake `ghRunner`, keyed on the argv array. `resolveEntry` issues the branch +// lookup as two independent `--state`-scoped queries (open, then merged), so +// `routes.list[headBranch]` is itself keyed by state: `{ open: [...], merged: +// [...] }`. `routes.view[prNumber]` keeps its single form. Any leaf may be a +// canned value or a 0-arg function (a function lets a route throw +// GhTransient/GhUnavailable/TypeError to simulate failures). +function makeGh(routes = {}) { + return (args) => { + if (args[0] === "pr" && args[1] === "list") { + const head = args[args.indexOf("--head") + 1]; + const state = args[args.indexOf("--state") + 1]; + const r = routes.list?.[head]?.[state]; + return typeof r === "function" ? r() : (r ?? []); + } + if (args[0] === "pr" && args[1] === "view") { + const r = routes.view?.[args[2]]; + return typeof r === "function" ? r() : (r ?? { state: "OPEN" }); + } + return []; + }; +} + +// A one-entry / two-entry in-flight TODO fixture. Each entry carries the 🚧 +// glyph, a `(branch: X)` (or `(#N)`) marker, and a **bold** title. +function todo(...entries) { + return ["# TODO", "", "## ▶️ Active queue", "", ...entries, ""].join("\n"); +} +const branchEntry = (branch, title) => + `- [ ] 🚧 🐛 [MED] **${title}** (branch: ${branch})\n - Done when: shipped.`; +const numberEntry = (n, title) => `- [ ] 🚧 🐛 [MED] **${title}** (#${n})\n - Done when: shipped.`; + +describe("resolveEntry — fix (a): reused branch prefers an OPEN PR", () => { + it("returns clean when an OPEN PR exists even though an older same-branch PR merged", () => { + const gh = makeGh({ + list: { + "feat/x": { + open: [{ state: "OPEN", number: 99 }], + merged: [{ number: 12, title: "old shipped" }], + }, + }, + }); + expect(resolveEntry({ branch: "feat/x", pr: null }, gh).status).toBe("clean"); + }); + + it("returns clean via the OPEN query without ever consulting merged (short-circuit)", () => { + const gh = makeGh({ + list: { + "feat/x": { + open: [{ number: 99 }], + merged: () => { + throw new Error("merged query must not run when an OPEN PR exists"); + }, + }, + }, + }); + expect(resolveEntry({ branch: "feat/x", pr: null }, gh).status).toBe("clean"); + }); + + it("returns stale when there is NO open PR and a merged one exists (exit-1 contract)", () => { + const gh = makeGh({ + list: { "feat/x": { open: [], merged: [{ number: 42, title: "shipped" }] } }, + }); + const r = resolveEntry({ branch: "feat/x", pr: null }, gh); + expect(r.status).toBe("stale"); + expect(r.merged.number).toBe(42); + }); + + it("returns clean for the no-OPEN/no-MERGED edge (empty, DRAFT, or CLOSED only)", () => { + // A CLOSED-only branch surfaces as neither an open nor a merged hit. + const gh = makeGh({ list: { "feat/x": { open: [], merged: [] } } }); + expect(resolveEntry({ branch: "feat/x", pr: null }, gh).status).toBe("clean"); + }); +}); + +describe("resolveEntry — fix (b): number-only resolution downgrades to a warning", () => { + it("returns warn (not stale) when a bare-number entry's PR is merged", () => { + const gh = makeGh({ view: { "7": { state: "MERGED", title: "shipped elsewhere" } } }); + const r = resolveEntry({ branch: null, pr: "7" }, gh); + expect(r.status).toBe("warn"); + expect(r.merged.number).toBe(7); + }); + + it("returns clean when a number-only entry's PR is still open", () => { + const gh = makeGh({ view: { "7": { state: "OPEN", title: "in flight" } } }); + expect(resolveEntry({ branch: null, pr: "7" }, gh).status).toBe("clean"); + }); +}); + +describe("scanTodo — fix (c): a transient error skips one entry, not the whole loop", () => { + it("skips the transient entry and still finds the stale one that follows", () => { + const text = todo( + branchEntry("feat/flaky", "Flaky lookup"), + branchEntry("feat/done", "Already shipped") + ); + const gh = makeGh({ + list: { + "feat/flaky": { + open: () => { + throw new GhTransient("HTTP 404"); + }, + }, + "feat/done": { open: [], merged: [{ number: 5, title: "Already shipped" }] }, + }, + }); + const scan = scanTodo(text, gh); + expect(scan.skipped.map((s) => s.entry.branch)).toEqual(["feat/flaky"]); + expect(scan.stale.map((s) => s.entry.branch)).toEqual(["feat/done"]); + expect(scan.aborted).toBeNull(); + }); +}); + +describe("scanTodo — GhUnavailable stops the loop but preserves already-found stale", () => { + it("keeps a stale entry found before a mid-scan GhUnavailable and does not throw", () => { + const text = todo( + branchEntry("feat/done", "Already shipped"), + branchEntry("feat/auth", "Auth expired here") + ); + const gh = makeGh({ + list: { + "feat/done": { open: [], merged: [{ number: 5, title: "Already shipped" }] }, + "feat/auth": { + open: () => { + throw new GhUnavailable("gh auth login"); + }, + }, + }, + }); + let scan = null; + expect(() => { + scan = scanTodo(text, gh); + }).not.toThrow(); + expect(scan.stale.map((s) => s.entry.branch)).toEqual(["feat/done"]); + expect(scan.aborted).toBeTruthy(); + const { exitCode, err } = summarize(scan); + expect(exitCode).toBe(1); + expect(err.join("\n")).toMatch(/partial|unchecked|unavailable/i); + }); +}); + +describe("scanTodo — a number-only merged entry warns, never becomes stale", () => { + it("lands the entry in warnings, keeps stale empty, exit 0", () => { + const text = todo(numberEntry(7, "Number only")); + const gh = makeGh({ view: { "7": { state: "MERGED", title: "shipped" } } }); + const scan = scanTodo(text, gh); + expect(scan.inflightCount).toBe(1); + expect(scan.stale).toHaveLength(0); + expect(scan.warnings).toHaveLength(1); + expect(summarize(scan).exitCode).toBe(0); + }); +}); + +describe("scanTodo — a taxonomy-external bug is rethrown, never swallowed", () => { + it("propagates a raw TypeError out of the loop instead of reporting clean", () => { + const text = todo(branchEntry("feat/x", "Boom")); + const gh = () => { + throw new TypeError("boom"); + }; + expect(() => scanTodo(text, gh)).toThrow(TypeError); + }); +}); + +describe("runScan — a bug becomes exit 2, never colliding with stale's exit 1", () => { + it("maps a taxonomy-external error to exitCode 2 with an internal-error message", () => { + const text = todo(branchEntry("feat/x", "Boom")); + const { exitCode, err } = runScan(text, () => { + throw new TypeError("boom"); + }); + expect(exitCode).toBe(2); + expect(err.join("\n")).toMatch(/internal error/i); + }); + + it("still returns exitCode 1 for a genuinely stale marker (2 and 1 do not collide)", () => { + const text = todo(branchEntry("feat/done", "Shipped")); + const gh = makeGh({ + list: { "feat/done": { open: [], merged: [{ number: 5, title: "Shipped" }] } }, + }); + expect(runScan(text, gh).exitCode).toBe(1); + }); +}); + +describe("summarize — honest partial-scan wording and exit codes", () => { + it("says 'partial' and exits 0 when entries were skipped but none are stale", () => { + const scan = { + inflightCount: 2, + stale: [], + warnings: [], + skipped: [{ entry: { branch: "feat/a" }, reason: "404" }], + aborted: null, + }; + const { exitCode, out, err } = summarize(scan); + expect(exitCode).toBe(0); + expect([...out, ...err].join("\n")).toMatch(/partial/i); + }); + + it("does not say 'partial' on a fully clean scan", () => { + const scan = { inflightCount: 1, stale: [], warnings: [], skipped: [], aborted: null }; + const { exitCode, out, err } = summarize(scan); + expect(exitCode).toBe(0); + expect([...out, ...err].join("\n")).not.toMatch(/partial/i); + }); + + it("exits 1 whenever a stale entry is present", () => { + const scan = { + inflightCount: 1, + stale: [ + { + entry: { branch: "feat/done", title: "Shipped" }, + merged: { number: 5, title: "Shipped" }, + }, + ], + warnings: [], + skipped: [], + aborted: null, + }; + expect(summarize(scan).exitCode).toBe(1); + }); +}); + +describe("classifyGhError — ENOENT / auth / rate-limit → GhUnavailable, else GhTransient", () => { + it("classifies ENOENT and auth failures and rate limits as GhUnavailable", () => { + expect(classifyGhError(Object.assign(new Error("spawn"), { code: "ENOENT" }))).toBeInstanceOf( + GhUnavailable + ); + expect( + classifyGhError({ stderr: "gh: Not logged into any GitHub hosts. Run gh auth login" }) + ).toBeInstanceOf(GhUnavailable); + expect(classifyGhError({ stderr: "API rate limit exceeded for user" })).toBeInstanceOf( + GhUnavailable + ); + expect(classifyGhError({ stderr: "HTTP 401: Bad credentials" })).toBeInstanceOf(GhUnavailable); + }); + + it("classifies a bare HTTP 401 (no 'bad credentials' text) as GhUnavailable", () => { + expect(classifyGhError({ stderr: "gh: HTTP 401 Unauthorized" })).toBeInstanceOf(GhUnavailable); + }); + + it("classifies a 'Bad credentials' message (no HTTP status) as GhUnavailable", () => { + expect(classifyGhError({ stderr: "error: Bad credentials" })).toBeInstanceOf(GhUnavailable); + }); + + it("classifies a one-off HTTP error as GhTransient", () => { + expect(classifyGhError({ stderr: "HTTP 404: Not Found" })).toBeInstanceOf(GhTransient); + }); +}); + +describe("ghExec — injectable exec exercises JSON.parse and the classification paths", () => { + it("throws GhTransient on unparseable stdout", () => { + expect(() => ghExec(["pr", "view", "1"], () => "not json")).toThrow(GhTransient); + }); + + it("returns parsed JSON on success", () => { + expect(ghExec(["x"], () => '{"state":"MERGED"}')).toEqual({ state: "MERGED" }); + }); + + it("throws GhUnavailable when the underlying exec reports ENOENT", () => { + expect(() => + ghExec(["x"], () => { + throw Object.assign(new Error("spawn"), { code: "ENOENT" }); + }) + ).toThrow(GhUnavailable); + }); +}); + +describe("parseEntry — extracts branch / pr / title from every marker form", () => { + it("reads the modern (branch: X) form", () => { + expect(parseEntry("- [ ] 🚧 **Feature** (branch: feat/foo)").branch).toBe("feat/foo"); + }); + it("reads the legacy form", () => { + expect(parseEntry("- [ ] 🚧 **Feature** ").branch).toBe("feat/foo"); + }); + it("reads (PR #12) and bare (#12) as a pr number", () => { + expect(parseEntry("- [ ] 🚧 **Feature** (PR #12)").pr).toBe("12"); + expect(parseEntry("- [ ] 🚧 **Feature** (#12)").pr).toBe("12"); + }); + it("captures the **bold** title", () => { + expect(parseEntry("- [ ] 🚧 **The title** (branch: b)").title).toBe("The title"); + }); +}); + +describe("parseInflight — selects only unchecked 🚧 lines, excludes the rest", () => { + it("selects an unchecked 🚧 line", () => { + expect(parseInflight("- [ ] 🚧 **A** (branch: x)")).toHaveLength(1); + }); + it("excludes a CHECKED 🚧 line (done work is never in-flight)", () => { + expect(parseInflight("- [x] 🚧 **A** (branch: x)")).toHaveLength(0); + }); + it("excludes an unchecked line WITHOUT the 🚧 glyph", () => { + expect(parseInflight("- [ ] **A** (branch: x)")).toHaveLength(0); + }); +});