From e07a1f8d35b4bd7a3045605716ef23c2a3dc640b Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Sat, 30 May 2026 16:18:03 +0100 Subject: [PATCH] =?UTF-8?q?docs:=20TS=E2=86=92AS=20migration=20of=2001-tri?= =?UTF-8?q?age=20=E2=80=94=20preserve=20algorithm=20spec=20as=20.md,=20def?= =?UTF-8?q?er=20port?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of standards#239 (TS→AffineScript estate migration), STEP 2 candidate per standards#241. The original 01-triage.ts (183 lines, Deno TS) is a historical campaign snapshot from 2026-05-26. An attempted AffineScript port surfaced 10 distinct stdlib gaps (catalogued in the replacement .md) that require filling in STEP 3 (standards#242) before a clean .affine port can land. Rather than ship a syntactically-iffy partial port (no `Set`, no `Map`, no async generators, no typed JSON decode, no ISO8601 date formatter, no Object.fromEntries equivalent), this commit preserves the algorithmic spec as a structured Markdown document and removes the .ts file. The .md serves as the canonical reference for the future implementation once the stdlib gaps close. Net delta: - docs/campaigns/2026-05-26/01-triage.ts: -183 lines (removed) - docs/campaigns/2026-05-26/01-triage.md: +196 lines (added) The .md preserves all algorithmic content needed to re-implement: - Domain types (Severity, WeakPoint, AssailReport, PrCandidate) - Static policy tables (proof exts, autofix-OK, issue-only, parked debts) - Category alias mapping (13 entries) - 8-step classification algorithm - PR grouping by (repo, file_dir, category) - Output summary JSON shape - Side-channel stderr log shape Stdlib gaps surfaced (feeds STEP 3 standards#242): 1. Set membership (workaround: linear-scan list) 2. Map group-by (workaround: assoc list) 3. Async generators (collapse to eager list) 4. Typed JSON decoder (json.affine v0.3 pending) 5. Regex object construction (regexMatch is the limit) 6. Date.toISOString (no dateNowIso extern) 7. Object.fromEntries (no jsonObjectFromPairs builder) 8. Optional chaining (explicit Option/match — fine) 9. async/await on sync FS (Deno.affine all sync — fine) 10. Spread / rest destructuring (explicit helpers needed) Refs: - standards#239 — TS→AS estate migration umbrella - standards#241 — STEP 2 candidate list - standards#242 — STEP 3 stdlib fill (consumes this PR's gap list) - project_estate_ts_to_affinescript_2026_05_28.md — memory tracker Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/campaigns/2026-05-26/01-triage.md | 196 +++++++++++++++++++++++++ docs/campaigns/2026-05-26/01-triage.ts | 183 ----------------------- 2 files changed, 196 insertions(+), 183 deletions(-) create mode 100644 docs/campaigns/2026-05-26/01-triage.md delete mode 100755 docs/campaigns/2026-05-26/01-triage.ts 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();