|
| 1 | +#!/usr/bin/env -S deno run --allow-read --allow-write |
| 2 | +// panic-attack estate sweep — triage |
| 3 | +// Reads per-repo AssailReport JSONs, classifies findings, emits a PR-candidate plan. |
| 4 | + |
| 5 | +import { walk } from "https://deno.land/std@0.224.0/fs/walk.ts"; |
| 6 | +import { dirname, basename, resolve } from "https://deno.land/std@0.224.0/path/mod.ts"; |
| 7 | + |
| 8 | +type Severity = "Low" | "Medium" | "High" | "Critical"; |
| 9 | +type WeakPoint = { |
| 10 | + category: string; // PA001..PA025 or enum name |
| 11 | + location?: string; |
| 12 | + file?: string; |
| 13 | + line?: number; |
| 14 | + severity: Severity; |
| 15 | + description: string; |
| 16 | + suppressed: boolean; |
| 17 | +}; |
| 18 | +type AssailReport = { |
| 19 | + schema_version: string; |
| 20 | + program_path: string; |
| 21 | + language: string; |
| 22 | + weak_points: WeakPoint[]; |
| 23 | + suppressed_count?: number; |
| 24 | +}; |
| 25 | + |
| 26 | +const PROOF_EXTS = new Set([ |
| 27 | + ".lean", ".agda", ".lagda", ".v", ".idr", ".idr2", ".fst", ".fsti", |
| 28 | + ".thy", ".spthy", ".smt2", ".tla", |
| 29 | +]); |
| 30 | + |
| 31 | +const PARKED_PROOF_DEBTS = [ |
| 32 | + { repo: "ephapax", path: "formal/Semantics.v", line: 3327, reason: "preservation, deferred per ephapax-preservation-closure-plan" }, |
| 33 | + { repo: "betlang", file_match: "substTop_preserves_typing", reason: "discharge recipe in PR#27 body" }, |
| 34 | +]; |
| 35 | + |
| 36 | +// PA-categories with reliable automated fixes (Critical/High only) |
| 37 | +const AUTOFIX_OK = new Set([ |
| 38 | + "PA001", "UnsafeCode", // unwrap → ?, mostly |
| 39 | + "PA006", "PanicPath", |
| 40 | + "PA022", "CryptoMisuse", // md5/sha1 → sha256 (limited) |
| 41 | +]); |
| 42 | + |
| 43 | +// PA-categories that need human judgement → file as issue |
| 44 | +const ISSUE_ONLY = new Set([ |
| 45 | + "PA023", "SupplyChain", // version pinning choices |
| 46 | + "PA024", "InputBoundary", // schema validation design |
| 47 | + "PA025", "MutationGap", // requires new test infra |
| 48 | + "PA021", "ProofDrift", // proof refactor, never blind-fix |
| 49 | +]); |
| 50 | + |
| 51 | +type Bucket = "autofix" | "issue" | "proof-draft" | "skip-known" | "skip-suppressed" | "skip-unknown-cat"; |
| 52 | + |
| 53 | +type PrCandidate = { |
| 54 | + repo: string; |
| 55 | + bucket: Bucket; |
| 56 | + category: string; |
| 57 | + severity: Severity; |
| 58 | + file: string; |
| 59 | + line?: number; |
| 60 | + description: string; |
| 61 | +}; |
| 62 | + |
| 63 | +function categoryCode(cat: string): string { |
| 64 | + // category may be either "PA001" or "UnsafeCode" or "{ category: "UnsafeCode" }" |
| 65 | + if (/^PA\d{3}/.test(cat)) return cat; |
| 66 | + const map: Record<string, string> = { |
| 67 | + UnsafeCode: "PA001", PanicPath: "PA006", |
| 68 | + CommandInjection: "PA003", UnsafeDeserialization: "PA004", |
| 69 | + AtomExhaustion: "PA005", UnsafeFFI: "PA007", |
| 70 | + PathTraversal: "PA008", HardcodedSecret: "PA009", |
| 71 | + ProofDrift: "PA021", CryptoMisuse: "PA022", |
| 72 | + SupplyChain: "PA023", InputBoundary: "PA024", |
| 73 | + MutationGap: "PA025", |
| 74 | + }; |
| 75 | + return map[cat] ?? cat; |
| 76 | +} |
| 77 | + |
| 78 | +function isProofFile(file: string): boolean { |
| 79 | + const dot = file.lastIndexOf("."); |
| 80 | + if (dot < 0) return false; |
| 81 | + return PROOF_EXTS.has(file.slice(dot).toLowerCase()); |
| 82 | +} |
| 83 | + |
| 84 | +function isParked(repo: string, wp: WeakPoint): boolean { |
| 85 | + for (const p of PARKED_PROOF_DEBTS) { |
| 86 | + if ((p as any).repo === repo) { |
| 87 | + if ((p as any).path && wp.file?.endsWith((p as any).path) && wp.line === (p as any).line) return true; |
| 88 | + if ((p as any).file_match && wp.description.includes((p as any).file_match)) return true; |
| 89 | + } |
| 90 | + } |
| 91 | + return false; |
| 92 | +} |
| 93 | + |
| 94 | +function classify(repo: string, wp: WeakPoint): Bucket { |
| 95 | + if (wp.suppressed) return "skip-suppressed"; |
| 96 | + if (wp.severity !== "Critical" && wp.severity !== "High") return "skip-unknown-cat"; // out-of-scope this wave |
| 97 | + if (isParked(repo, wp)) return "skip-known"; |
| 98 | + // Skip in-tree worktree-branch findings — main checkout state is the source of truth |
| 99 | + if (wp.file && (wp.file.includes(".claude/worktrees/") || wp.file.includes("/_wt-"))) return "skip-known"; |
| 100 | + const code = categoryCode(wp.category); |
| 101 | + if (wp.file && isProofFile(wp.file)) return "proof-draft"; |
| 102 | + if (ISSUE_ONLY.has(code) || ISSUE_ONLY.has(wp.category)) return "issue"; |
| 103 | + if (AUTOFIX_OK.has(code) || AUTOFIX_OK.has(wp.category)) return "autofix"; |
| 104 | + return "issue"; // default conservative: needs human eye |
| 105 | +} |
| 106 | + |
| 107 | +async function main() { |
| 108 | + const [perRepoDir, planPath] = Deno.args; |
| 109 | + if (!perRepoDir || !planPath) { |
| 110 | + console.error("Usage: 01-triage.ts <per-repo-dir> <plan.json>"); |
| 111 | + Deno.exit(2); |
| 112 | + } |
| 113 | + |
| 114 | + const candidates: PrCandidate[] = []; |
| 115 | + let scanned = 0; |
| 116 | + |
| 117 | + for await (const entry of walk(perRepoDir, { exts: [".json"], maxDepth: 1 })) { |
| 118 | + scanned++; |
| 119 | + const repo = basename(entry.path).replace(/\.json$/, ""); |
| 120 | + let raw: string; |
| 121 | + try { raw = await Deno.readTextFile(entry.path); } |
| 122 | + catch { continue; } |
| 123 | + let rpt: AssailReport; |
| 124 | + try { rpt = JSON.parse(raw); } |
| 125 | + catch { console.error(`bad json: ${entry.path}`); continue; } |
| 126 | + if (!rpt.weak_points) continue; |
| 127 | + |
| 128 | + for (const wp of rpt.weak_points) { |
| 129 | + const bucket = classify(repo, wp); |
| 130 | + candidates.push({ |
| 131 | + repo, |
| 132 | + bucket, |
| 133 | + category: categoryCode(wp.category), |
| 134 | + severity: wp.severity, |
| 135 | + file: wp.file ?? wp.location ?? "<unknown>", |
| 136 | + line: wp.line, |
| 137 | + description: wp.description, |
| 138 | + }); |
| 139 | + } |
| 140 | + } |
| 141 | + |
| 142 | + // Group by (repo, file, category) — that's the PR unit |
| 143 | + const groups = new Map<string, PrCandidate[]>(); |
| 144 | + for (const c of candidates) { |
| 145 | + if (c.bucket.startsWith("skip-")) continue; |
| 146 | + const key = `${c.repo}::${c.file.split("/").slice(0, -1).join("/")}::${c.category}`; |
| 147 | + if (!groups.has(key)) groups.set(key, []); |
| 148 | + groups.get(key)!.push(c); |
| 149 | + } |
| 150 | + |
| 151 | + const summary = { |
| 152 | + generated_at: new Date().toISOString(), |
| 153 | + per_repo_scanned: scanned, |
| 154 | + total_candidates: candidates.length, |
| 155 | + by_bucket: Object.fromEntries( |
| 156 | + ["autofix", "issue", "proof-draft", "skip-suppressed", "skip-known", "skip-unknown-cat"].map( |
| 157 | + b => [b, candidates.filter(c => c.bucket === b).length] |
| 158 | + ) |
| 159 | + ), |
| 160 | + by_repo: Object.fromEntries( |
| 161 | + [...new Set(candidates.map(c => c.repo))].sort().map(r => [ |
| 162 | + r, |
| 163 | + candidates.filter(c => c.repo === r && !c.bucket.startsWith("skip-")).length, |
| 164 | + ]).filter(([_, n]) => (n as number) > 0) |
| 165 | + ), |
| 166 | + pr_groups: [...groups.entries()].map(([k, members]) => ({ |
| 167 | + key: k, |
| 168 | + repo: members[0].repo, |
| 169 | + file_dir: k.split("::")[1], |
| 170 | + category: members[0].category, |
| 171 | + bucket: members[0].bucket, |
| 172 | + finding_count: members.length, |
| 173 | + severities: [...new Set(members.map(m => m.severity))], |
| 174 | + examples: members.slice(0, 3), |
| 175 | + })), |
| 176 | + }; |
| 177 | + |
| 178 | + await Deno.writeTextFile(planPath, JSON.stringify(summary, null, 2)); |
| 179 | + console.error(`triage complete: ${candidates.length} candidates, ${groups.size} PR groups → ${planPath}`); |
| 180 | + console.error(`buckets: ${JSON.stringify(summary.by_bucket)}`); |
| 181 | +} |
| 182 | + |
| 183 | +main(); |
0 commit comments