Skip to content

Commit ce54b21

Browse files
hyperpolymathclaude
andcommitted
docs(campaigns): add 2026-05-26 driver scripts + README
Bundles the driver scripts used to run the campaign so it's reproducible. The output JSONs/triage plan are still ephemeral (under /tmp during the run) but the scripts themselves are tiny and worth keeping in-repo. Notable: file-ffi-pr-v2.sh is the corrected version of file-ffi-pr.sh (the v1 chained-OR jq prefix filter was broken under operator precedence; v2 uses --argjson + any()). Refs #32. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 85c99d4 commit ce54b21

5 files changed

Lines changed: 522 additions & 0 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
#!/usr/bin/env bash
2+
# Per-repo assail loop with timeout. No single repo can stall the campaign.
3+
set -uo pipefail
4+
5+
ESTATE="${ESTATE:-/home/hyperpolymath/developer/repos}"
6+
CAMP="${CAMP:-/tmp/panic-attack-campaign-2026-05-26}"
7+
BIN="${BIN:-$ESTATE/panic-attack/target/release/panic-attack}"
8+
TIMEOUT="${TIMEOUT:-90}"
9+
PER_REPO="$CAMP/per-repo"
10+
LOG="$CAMP/00-per-repo.log"
11+
DONE_FILE="$CAMP/00-per-repo-done.txt"
12+
SKIPPED_FILE="$CAMP/00-per-repo-skipped.txt"
13+
14+
mkdir -p "$PER_REPO"
15+
: > "$LOG"
16+
: > "$DONE_FILE"
17+
: > "$SKIPPED_FILE"
18+
19+
echo "=== per-repo assail $(date -u --iso=seconds) timeout=${TIMEOUT}s ===" | tee -a "$LOG"
20+
21+
# Build repo list: top-level dirs with .git/ as a directory
22+
REPOS=()
23+
for d in "$ESTATE"/*; do
24+
[ -d "$d/.git" ] || continue
25+
REPOS+=("$d")
26+
done
27+
TOTAL=${#REPOS[@]}
28+
echo "estate: $TOTAL repos" | tee -a "$LOG"
29+
30+
I=0
31+
for repo in "${REPOS[@]}"; do
32+
I=$((I + 1))
33+
name=$(basename "$repo")
34+
# Skip the canonical tool repo (we don't audit ourselves here)
35+
if [ "$name" = "panic-attack" ]; then
36+
echo "[$I/$TOTAL] $name — SKIP (canonical tool)" | tee -a "$LOG"
37+
continue
38+
fi
39+
out="$PER_REPO/${name}.json"
40+
start=$SECONDS
41+
if timeout "${TIMEOUT}s" "$BIN" assail "$repo" --headless --output "$out" >/dev/null 2>&1; then
42+
dur=$((SECONDS - start))
43+
findings=$(jq '.weak_points | length' "$out" 2>/dev/null || echo "?")
44+
crit=$(jq '[.weak_points[] | select(.severity == "Critical")] | length' "$out" 2>/dev/null || echo "?")
45+
high=$(jq '[.weak_points[] | select(.severity == "High")] | length' "$out" 2>/dev/null || echo "?")
46+
echo "[$I/$TOTAL] $name${dur}s findings=$findings crit=$crit high=$high" | tee -a "$LOG"
47+
echo "$name" >> "$DONE_FILE"
48+
else
49+
dur=$((SECONDS - start))
50+
echo "[$I/$TOTAL] $name ✗ TIMEOUT(${dur}s)" | tee -a "$LOG"
51+
echo "$name" >> "$SKIPPED_FILE"
52+
rm -f "$out"
53+
fi
54+
done
55+
56+
echo "=== pass complete $(date -u --iso=seconds) ===" | tee -a "$LOG"
57+
echo "done: $(wc -l < "$DONE_FILE")" | tee -a "$LOG"
58+
echo "skipped: $(wc -l < "$SKIPPED_FILE")" | tee -a "$LOG"
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
#!/usr/bin/env bash
2+
# Pass 1b: per-repo assail loop for NESTED sub-repos and peer locations
3+
# Picks up everything the top-level walker missed.
4+
set -uo pipefail
5+
6+
CAMP="${CAMP:-/tmp/panic-attack-campaign-2026-05-26}"
7+
BIN="${BIN:-/home/hyperpolymath/developer/repos/panic-attack/target/release/panic-attack}"
8+
TIMEOUT="${TIMEOUT:-90}"
9+
PER_REPO="$CAMP/per-repo"
10+
LOG="$CAMP/00b-nested.log"
11+
DONE_FILE="$CAMP/00b-nested-done.txt"
12+
SKIPPED_FILE="$CAMP/00b-nested-skipped.txt"
13+
14+
mkdir -p "$PER_REPO"
15+
: > "$LOG"
16+
: > "$DONE_FILE"
17+
: > "$SKIPPED_FILE"
18+
19+
echo "=== nested-repo assail $(date -u --iso=seconds) timeout=${TIMEOUT}s ===" | tee -a "$LOG"
20+
21+
# Container dirs: scan their direct children that have .git/
22+
CONTAINERS=(
23+
/home/hyperpolymath/developer/repos/a2ml
24+
/home/hyperpolymath/developer/repos/awesome-projects
25+
/home/hyperpolymath/developer/repos/idaptik
26+
/home/hyperpolymath/developer/repos/isers
27+
/home/hyperpolymath/developer/repos/julia-libraries
28+
/home/hyperpolymath/developer/repos/k9
29+
)
30+
# Peer locations: scan top-level + 1 level deep
31+
PEERS=(
32+
/home/hyperpolymath/typed-wasm-final
33+
/home/hyperpolymath/ephapax-fix
34+
)
35+
36+
REPOS=()
37+
for parent in "${CONTAINERS[@]}"; do
38+
for d in "$parent"/*; do
39+
[ -d "$d/.git" ] && REPOS+=("$d")
40+
done
41+
done
42+
for peer in "${PEERS[@]}"; do
43+
[ -d "$peer/.git" ] && REPOS+=("$peer")
44+
if [ -d "$peer" ]; then
45+
for d in "$peer"/*; do
46+
[ -d "$d/.git" ] && REPOS+=("$d")
47+
done
48+
fi
49+
done
50+
51+
TOTAL=${#REPOS[@]}
52+
echo "nested + peers: $TOTAL repos" | tee -a "$LOG"
53+
54+
I=0
55+
for repo in "${REPOS[@]}"; do
56+
I=$((I + 1))
57+
# Use parent/child slug to avoid name collisions
58+
parent=$(basename "$(dirname "$repo")")
59+
child=$(basename "$repo")
60+
name="${parent}__${child}"
61+
out="$PER_REPO/${name}.json"
62+
start=$SECONDS
63+
if timeout "${TIMEOUT}s" "$BIN" assail "$repo" --headless --output "$out" >/dev/null 2>&1; then
64+
dur=$((SECONDS - start))
65+
findings=$(jq '.weak_points | length' "$out" 2>/dev/null || echo "?")
66+
crit=$(jq '[.weak_points[] | select(.severity == "Critical")] | length' "$out" 2>/dev/null || echo "?")
67+
high=$(jq '[.weak_points[] | select(.severity == "High")] | length' "$out" 2>/dev/null || echo "?")
68+
echo "[$I/$TOTAL] $name${dur}s findings=$findings crit=$crit high=$high" | tee -a "$LOG"
69+
echo "$name" >> "$DONE_FILE"
70+
else
71+
dur=$((SECONDS - start))
72+
echo "[$I/$TOTAL] $name ✗ TIMEOUT/EARLY-EXIT(${dur}s)" | tee -a "$LOG"
73+
echo "$name" >> "$SKIPPED_FILE"
74+
rm -f "$out"
75+
fi
76+
done
77+
78+
echo "=== nested pass complete $(date -u --iso=seconds) ===" | tee -a "$LOG"
79+
echo "done: $(wc -l < "$DONE_FILE")" | tee -a "$LOG"
80+
echo "skipped: $(wc -l < "$SKIPPED_FILE")" | tee -a "$LOG"
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
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();
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<!--
2+
SPDX-License-Identifier: MPL-2.0
3+
Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
4+
-->
5+
6+
# Campaign 2026-05-26 — driver scripts
7+
8+
These are the driver scripts used to run the 2026-05-26 estate sweep. They're filed alongside the human + machine campaign reports so the campaign is reproducible. See [`../2026-05-26.md`](../2026-05-26.md) for the report.
9+
10+
## Scripts
11+
12+
| Script | Purpose |
13+
|---|---|
14+
| `00-per-repo.sh` | Iterates top-level dirs with `.git/` and runs `panic-attack assail --headless` against each with a 90s timeout. Writes per-repo JSON to `/tmp/panic-attack-campaign-<date>/per-repo/<repo>.json`. Pivot away from `assemblyline` so no single slow repo can stall the whole batch. |
15+
| `00b-nested.sh` | Same scan loop but for nested-repo containers (`a2ml`, `awesome-projects`, `idaptik`, `isers`, `julia-libraries`, `k9`). Output filenames use `parent__child.json` to avoid collisions. |
16+
| `01-triage.ts` | Deno script that reads per-repo JSONs and classifies into autofix / issue / proof-draft / skip buckets. Writes `02-plan.json`. |
17+
| `file-ffi-pr-v2.sh` | Per-repo classification PR generator. Accepts `REPO_NAME`, `PREFIX_JSON` (JSON array of path prefixes), `SHORT_RATIONALE`, optional `CLASSIFICATION` (default `legitimate-ffi`). Builds the `audits/assail-classifications.a2ml` + audit doc, commits with the GPG override flags, pushes, opens a PR. **v2** uses `--argjson` + `any()` for the prefix filter (the v1 chained-OR form was broken under jq operator precedence). |
18+
19+
## Known gotchas
20+
21+
1. `file-ffi-pr-v2.sh` does `cat > audits/assail-classifications.a2ml` without checking whether the file already exists on `origin/main`. If it does, the existing entries get overwritten. **Always `git show origin/main:audits/assail-classifications.a2ml` before running the script**, and if entries exist, edit the script to preserve them.
22+
2. Some repos are forks on GitHub with issues disabled (`linguist`, `rescript`, `HOL`) — Track A PRs land, but Track C tracking issues can't be filed.
23+
3. Some repos are archived (`polystack`) or deleted (`hyperpolymath-archive`); skip them.
24+
4. valence-shell-style local-only commits on `main` need branching from `origin/main` (not local `main`) to preserve them.
25+
26+
## Re-running
27+
28+
```sh
29+
# Phase 1: per-repo scan (~10 min)
30+
bash 00-per-repo.sh && bash 00b-nested.sh
31+
32+
# Phase 1b: triage
33+
deno run --allow-read --allow-write 01-triage.ts
34+
35+
# Phase 2..N: per-repo PRs (one invocation per repo)
36+
BRANCH=panic-fix/PA001-PA007-ffi-legitimate \
37+
bash file-ffi-pr-v2.sh \
38+
<repo-name> \
39+
'["src/<prefix>/", "ffi/<prefix>/"]' \
40+
"Rationale text..." \
41+
"legitimate-ffi"
42+
```
43+
44+
The output JSONs and triage plan are NOT committed to the repo (they're ephemeral, scan-time-sensitive). See [`../2026-05-26.md`](../2026-05-26.md) for the persistent campaign record.

0 commit comments

Comments
 (0)