diff --git a/docs/campaigns/2026-05-26/01-triage.md b/docs/campaigns/2026-05-26/01-triage.md new file mode 100644 index 0000000..12f8187 --- /dev/null +++ b/docs/campaigns/2026-05-26/01-triage.md @@ -0,0 +1,196 @@ + + + +# 01-triage — algorithmic spec + +> Historical campaign snapshot from 2026-05-26. The original `01-triage.ts` +> (183 lines, Deno TypeScript) is removed per the standards#239 estate +> TypeScript → AffineScript migration. This document preserves the +> algorithmic spec so a future AffineScript port can land cleanly when the +> stdlib gaps surfaced below (feeding standards#242 STEP 3) close. + +## Purpose + +Reads per-repo `AssailReport` JSONs, classifies each weak-point finding into +one of six buckets, groups the actionable buckets by `(repo, file_dir, +category)`, and emits a JSON summary describing the PR-candidate plan. + +## Inputs + +- ``: directory of `.json` files (one per repo), each + an `AssailReport` produced by `panic-attack` assail. +- ``: output path for the summary. + +## Domain types + +```text +Severity := Low | Medium | High | Critical + +WeakPoint := + { category: PA-code or alias + , file: optional source path + , line: optional line number + , severity: Severity + , description: free text + , suppressed: bool + } + +AssailReport := + { schema_version: semver + , program_path: path + , language: identifier + , weak_points: [WeakPoint] + , suppressed_count?: int + } + +PrCandidate := WeakPoint + repo + bucket + canonical category code +``` + +## Static policy tables + +### Proof-file extensions +`.lean, .agda, .lagda, .v, .idr, .idr2, .fst, .fsti, .thy, .spthy, .smt2, .tla` + +### Categories with reliable automated fixes (Critical / High only) +`PA001/UnsafeCode, PA006/PanicPath, PA022/CryptoMisuse` + +### Categories requiring human judgement (always → issue, never auto-fix) +`PA023/SupplyChain, PA024/InputBoundary, PA025/MutationGap, PA021/ProofDrift` + +### Parked proof debts (skip wholesale) +- `ephapax / formal/Semantics.v:3327` (preservation, deferred per the + ephapax-preservation-closure-plan). +- `betlang / *` where description contains `substTop_preserves_typing` + (discharge recipe in PR#27 body). + +### Category alias → canonical PA-code mapping +Recognises both `PA\d{3}` codes (passed through) and the enum-name +aliases `UnsafeCode → PA001, PanicPath → PA006, CommandInjection → PA003, +UnsafeDeserialization → PA004, AtomExhaustion → PA005, UnsafeFFI → PA007, +PathTraversal → PA008, HardcodedSecret → PA009, ProofDrift → PA021, +CryptoMisuse → PA022, SupplyChain → PA023, InputBoundary → PA024, +MutationGap → PA025`. Unknown values pass through unchanged. + +## Classification algorithm + +For each `(repo, wp)`: + +1. `wp.suppressed` → `skip-suppressed`. +2. severity not in `{Critical, High}` → `skip-unknown-cat` (out of scope this + wave; the bucket name is historical and load-bearing in summaries). +3. `(repo, wp)` matches a parked-debt row → `skip-known`. +4. `wp.file` contains `.claude/worktrees/` or `/_wt-` → `skip-known` (main + checkout is the source of truth, not worktree branches). +5. `wp.file` extension in proof-file set → `proof-draft`. +6. Canonical category code in `issue_only` set → `issue`. +7. Canonical category code in `autofix_ok` set → `autofix`. +8. Otherwise → `issue` (default conservative — needs human eye). + +## PR grouping + +Group all non-skip candidates by the key +`::::`. `file_dir` is the parent directory of +`wp.file` (joined by `/`), so all findings under the same directory and +category collapse into one PR. + +## Output summary shape + +```jsonc +{ + "generated_at": "", + "per_repo_scanned": , + "total_candidates": , + "by_bucket": { "": , ... }, + "by_repo": { "": , ... }, // only repos with >0 + "pr_groups": [ + { + "key": "::::", + "repo": "", + "file_dir": "", + "category": "", + "bucket": "", + "finding_count": , + "severities": ["", ...], + "examples": [, ...] // up to 3 + } + ] +} +``` + +## Side-channel logs (stderr) + +``` +triage complete: candidates, PR groups → +buckets: { "": , ... } +``` + +## Stdlib gaps surfaced for the AffineScript port (feeds standards#242 STEP 3) + +This script depends on the following Deno / TS-stdlib surfaces that have no +direct equivalent in `stdlib/Deno.affine` or `stdlib/json.affine` today. +Each gap is a candidate fill-in for STEP 3 (stdlib enrichment) before the +port can land cleanly. + +1. **`Set` membership** — no native AS surface. Workaround: `[String]` + with linear-scan `containsString`. Acceptable for the ≤25-entry policy + lists this script uses; not a general-purpose Set. + +2. **`Map` group-by** — no native AS surface. Workaround: + `[(String, [PrCandidate])]` association list. O(n²) but n is small + here. A real Map binding would be a broader stdlib win. + +3. **Async generators (`async function* walk(...)`)** — no AS surface. + Workaround: collapse to eager `[String]` list collection via + `Deno::walkRecursive` (already in `Deno.affine`). + +4. **`JSON.parse` returning a typed sum** — `Deno::jsonParse` returns the + opaque `Deno.Json`, with field access via discrete `jsonGet*` externs. + Reading nested `AssailReport.weak_points[i].category` is many extern + calls. A typed-decoder generator (`json.affine` style) is the cleaner + path. The `json.affine` v0.3 work (mentioned in RSR-stack status memo) + would close this. + +5. **Regex object construction** — TS uses `new RegExp(pat).test(s)` and + inline `/^PA\d{3}/.test(cat)`. `Deno::regexMatch(s, pat)` covers the + call shape but does not expose a constructed regex value — fine for + this script. + +6. **`new Date().toISOString()`** — needed for `generated_at` and the + per-PrCandidate `lastProbe` analogue. `Deno.affine` has `dateNow` + (returns ms) but no ISO8601 formatter. Would need `dateNowIso` extern. + +7. **`Object.fromEntries(...)` for dynamic object construction** — needed + for the `by_bucket` / `by_repo` summary objects. `Deno.affine` exposes + `jsonNull` / `jsonStringify` but no `jsonObjectFromPairs` builder + extern. Workaround: build the JSON string manually. + +8. **Optional chaining `wp.file?.endsWith(...)`** — explicit Option/match + on the AS side (`wp.file != "" && Deno::endsWith(...)`). Working as + intended; just verbose. + +9. **`async / await` on synchronous-effect FS calls** — `Deno.affine` + externs are all sync (per the `Deno.affine` header comment); the port + loses the async machinery, which is the right call for the Deno-ESM + backend. + +10. **Spread / rest patterns in destructuring** — TS uses + `[...new Set(candidates.map(c => c.repo))].sort()` etc. Each call + site needs an explicit `unique_sort_string` helper in AS. + +## Reference: original implementation + +Removed in this PR — see git history for the full TS source. The original +referenced these external surfaces: + +- `https://deno.land/std@0.224.0/fs/walk.ts` +- `https://deno.land/std@0.224.0/path/mod.ts` +- `Deno.{args, readTextFile, writeTextFile, exit}` +- Built-ins: `Set`, `Map`, `Date`, `Object`, `JSON`, `RegExp`, `console` + +## Migration status + +- 🟡 Spec preserved (this document) — 2026-05-30. +- 🔴 `.affine` implementation pending stdlib fill-in per gaps 1–4, 6, 7 + above. Tracking: standards#242 STEP 3. +- 🟢 `01-triage.ts` removed (campaign artefact; logic deferred to future + panic-attack runs once the AffineScript port lands). diff --git a/docs/campaigns/2026-05-26/01-triage.ts b/docs/campaigns/2026-05-26/01-triage.ts deleted file mode 100755 index d083e80..0000000 --- a/docs/campaigns/2026-05-26/01-triage.ts +++ /dev/null @@ -1,183 +0,0 @@ -#!/usr/bin/env -S deno run --allow-read --allow-write -// panic-attack estate sweep — triage -// Reads per-repo AssailReport JSONs, classifies findings, emits a PR-candidate plan. - -import { walk } from "https://deno.land/std@0.224.0/fs/walk.ts"; -import { dirname, basename, resolve } from "https://deno.land/std@0.224.0/path/mod.ts"; - -type Severity = "Low" | "Medium" | "High" | "Critical"; -type WeakPoint = { - category: string; // PA001..PA025 or enum name - location?: string; - file?: string; - line?: number; - severity: Severity; - description: string; - suppressed: boolean; -}; -type AssailReport = { - schema_version: string; - program_path: string; - language: string; - weak_points: WeakPoint[]; - suppressed_count?: number; -}; - -const PROOF_EXTS = new Set([ - ".lean", ".agda", ".lagda", ".v", ".idr", ".idr2", ".fst", ".fsti", - ".thy", ".spthy", ".smt2", ".tla", -]); - -const PARKED_PROOF_DEBTS = [ - { repo: "ephapax", path: "formal/Semantics.v", line: 3327, reason: "preservation, deferred per ephapax-preservation-closure-plan" }, - { repo: "betlang", file_match: "substTop_preserves_typing", reason: "discharge recipe in PR#27 body" }, -]; - -// PA-categories with reliable automated fixes (Critical/High only) -const AUTOFIX_OK = new Set([ - "PA001", "UnsafeCode", // unwrap → ?, mostly - "PA006", "PanicPath", - "PA022", "CryptoMisuse", // md5/sha1 → sha256 (limited) -]); - -// PA-categories that need human judgement → file as issue -const ISSUE_ONLY = new Set([ - "PA023", "SupplyChain", // version pinning choices - "PA024", "InputBoundary", // schema validation design - "PA025", "MutationGap", // requires new test infra - "PA021", "ProofDrift", // proof refactor, never blind-fix -]); - -type Bucket = "autofix" | "issue" | "proof-draft" | "skip-known" | "skip-suppressed" | "skip-unknown-cat"; - -type PrCandidate = { - repo: string; - bucket: Bucket; - category: string; - severity: Severity; - file: string; - line?: number; - description: string; -}; - -function categoryCode(cat: string): string { - // category may be either "PA001" or "UnsafeCode" or "{ category: "UnsafeCode" }" - if (/^PA\d{3}/.test(cat)) return cat; - const map: Record = { - UnsafeCode: "PA001", PanicPath: "PA006", - CommandInjection: "PA003", UnsafeDeserialization: "PA004", - AtomExhaustion: "PA005", UnsafeFFI: "PA007", - PathTraversal: "PA008", HardcodedSecret: "PA009", - ProofDrift: "PA021", CryptoMisuse: "PA022", - SupplyChain: "PA023", InputBoundary: "PA024", - MutationGap: "PA025", - }; - return map[cat] ?? cat; -} - -function isProofFile(file: string): boolean { - const dot = file.lastIndexOf("."); - if (dot < 0) return false; - return PROOF_EXTS.has(file.slice(dot).toLowerCase()); -} - -function isParked(repo: string, wp: WeakPoint): boolean { - for (const p of PARKED_PROOF_DEBTS) { - if ((p as any).repo === repo) { - if ((p as any).path && wp.file?.endsWith((p as any).path) && wp.line === (p as any).line) return true; - if ((p as any).file_match && wp.description.includes((p as any).file_match)) return true; - } - } - return false; -} - -function classify(repo: string, wp: WeakPoint): Bucket { - if (wp.suppressed) return "skip-suppressed"; - if (wp.severity !== "Critical" && wp.severity !== "High") return "skip-unknown-cat"; // out-of-scope this wave - if (isParked(repo, wp)) return "skip-known"; - // Skip in-tree worktree-branch findings — main checkout state is the source of truth - if (wp.file && (wp.file.includes(".claude/worktrees/") || wp.file.includes("/_wt-"))) return "skip-known"; - const code = categoryCode(wp.category); - if (wp.file && isProofFile(wp.file)) return "proof-draft"; - if (ISSUE_ONLY.has(code) || ISSUE_ONLY.has(wp.category)) return "issue"; - if (AUTOFIX_OK.has(code) || AUTOFIX_OK.has(wp.category)) return "autofix"; - return "issue"; // default conservative: needs human eye -} - -async function main() { - const [perRepoDir, planPath] = Deno.args; - if (!perRepoDir || !planPath) { - console.error("Usage: 01-triage.ts "); - Deno.exit(2); - } - - const candidates: PrCandidate[] = []; - let scanned = 0; - - for await (const entry of walk(perRepoDir, { exts: [".json"], maxDepth: 1 })) { - scanned++; - const repo = basename(entry.path).replace(/\.json$/, ""); - let raw: string; - try { raw = await Deno.readTextFile(entry.path); } - catch { continue; } - let rpt: AssailReport; - try { rpt = JSON.parse(raw); } - catch { console.error(`bad json: ${entry.path}`); continue; } - if (!rpt.weak_points) continue; - - for (const wp of rpt.weak_points) { - const bucket = classify(repo, wp); - candidates.push({ - repo, - bucket, - category: categoryCode(wp.category), - severity: wp.severity, - file: wp.file ?? wp.location ?? "", - line: wp.line, - description: wp.description, - }); - } - } - - // Group by (repo, file, category) — that's the PR unit - const groups = new Map(); - for (const c of candidates) { - if (c.bucket.startsWith("skip-")) continue; - const key = `${c.repo}::${c.file.split("/").slice(0, -1).join("/")}::${c.category}`; - if (!groups.has(key)) groups.set(key, []); - groups.get(key)!.push(c); - } - - const summary = { - generated_at: new Date().toISOString(), - per_repo_scanned: scanned, - total_candidates: candidates.length, - by_bucket: Object.fromEntries( - ["autofix", "issue", "proof-draft", "skip-suppressed", "skip-known", "skip-unknown-cat"].map( - b => [b, candidates.filter(c => c.bucket === b).length] - ) - ), - by_repo: Object.fromEntries( - [...new Set(candidates.map(c => c.repo))].sort().map(r => [ - r, - candidates.filter(c => c.repo === r && !c.bucket.startsWith("skip-")).length, - ]).filter(([_, n]) => (n as number) > 0) - ), - pr_groups: [...groups.entries()].map(([k, members]) => ({ - key: k, - repo: members[0].repo, - file_dir: k.split("::")[1], - category: members[0].category, - bucket: members[0].bucket, - finding_count: members.length, - severities: [...new Set(members.map(m => m.severity))], - examples: members.slice(0, 3), - })), - }; - - await Deno.writeTextFile(planPath, JSON.stringify(summary, null, 2)); - console.error(`triage complete: ${candidates.length} candidates, ${groups.size} PR groups → ${planPath}`); - console.error(`buckets: ${JSON.stringify(summary.by_bucket)}`); -} - -main();