From cb2b5c2914a017f8f51ae6179242e4998b192f90 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Sat, 30 May 2026 20:23:08 +0100 Subject: [PATCH] feat(chapel): wire MassPanic CI + cross-rust contract checks (refs #33) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lands six strict CI gates on the OPTIONAL Chapel mass-panic harness and the Chapel↔Rust CLI contract, plus a handful of audit-surfaced bug fixes. The Rust binary stays standalone (USB-stick-portable, single-machine). chapel/ remains a detachable harness — removing it leaves Cargo green. CI gates (.github/workflows/chapel-ci.yml, no continue-on-error anywhere): 1. chapel-parse-check — chpl --parse-only on every module + smoke 2. chapel-build — just chapel-build-ci (toolbox-free .deb install) 3. chapel-smoke — RepoResult → SystemImage → JSON data-flow 4. chapel-e2e — mass-panic -nl 1 against synthetic 2-repo corpus 5. chapel-cli-contract — panic-attack describe-contract vs expected fixture 6. chapel-rust-diff — rayon assemblyline vs Chapel aggregate parity Rust: * New `panic-attack describe-contract` subcommand. Generic capability (useful to Chapel, Nextflow, Airflow, Slurm, shell scripts) — not a Chapel bridge. Uses clap's CommandFactory so flags auto-sync. Chapel bug fixes (audit-surfaced): * Imaging.chpl::writeNodeJson — emit path, high_count, error, category_breakdown. Four ImageNode fields were populated in memory but silently dropped from SystemImage JSON. JSON-escape helper added. * MassPanic.chpl::selectAndAnnounceScheduler — combining --scheduler=static with --resume now hard-fails (was: warning then silently ran without resume support). * MassPanic.chpl::buildCommandArgs — adjudicate mode passes --quiet for consistency with assail/assault/ambush (banner-bleed risk). * MassPanic.chpl::journalEscape — rename local out → buf (Chapel 2.8.0 parses `out` as intent keyword more strictly than older versions). * chapel/README.md — Chapel version pin aligned to 2.8.0+ (was drifted 2.3.0+), fNIRS terminology table cross-referenced, scheduler perf claim qualified as estimate pending Wave 2 benchmark, --resume hard-fail documented. Justfile: * chapel-build-ci (new): toolbox-free build of mass-panic + smoke * chapel-smoke / chapel-e2e / chapel-contract-check / chapel-rust-diff / chapel-parse-check — recipes wrapping the CI gates for local runs * chapel-scan: fixed pre-existing parse error (env() + str needed parens) Docs: * docs/adr/0001-chapel-distributed-scanner.md — decision record with Wave 2 deferred-items list * docs/adr/0001-chapel-issue-33-comment.md — draft cross-link for #33 Wave 2 follow-ups (tracked in the ADR): * Subprocess hang kill path (panic-attack subprocess can block Chapel) * True multi-locale CI (needs CHPL_COMM=gasnet, stock .deb is none) * SHA-pin the Chapel .deb download * BoJ-estate scheduler benchmark to back "~5–15% slower" claim * NFS journal lock semantics * buildCommandArgs callsite consolidation Local verification (Chapel 2.8.0, cargo 1.95.0): - cargo build --release: ok - just chapel-parse-check: ok - just chapel-build-ci: ok (mass-panic + smoke built) - just chapel-smoke: PASS (4 silent-loss fields + JSON-escape) - just chapel-e2e: PASS (mass-panic -nl 1 writes system-image JSON) - just chapel-contract-check: PASS (5 modes, 2 globals, 5 per-mode flags) - just chapel-rust-diff: PASS (rayon=3, chapel=3 total_weak_points) Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/chapel-ci.yml | 191 ++++++++++++++++++++ .gitignore | 1 + Justfile | 50 ++++- chapel/README.md | 47 ++++- chapel/smoke/two_repo_smoke.chpl | 128 +++++++++++++ chapel/src/Imaging.chpl | 35 +++- chapel/src/MassPanic.chpl | 25 ++- chapel/tests/contract_check.sh | 79 ++++++++ chapel/tests/expected_contract.json | 20 ++ chapel/tests/rayon_vs_chapel_diff.sh | 105 +++++++++++ docs/adr/0001-chapel-distributed-scanner.md | 146 +++++++++++++++ docs/adr/0001-chapel-issue-33-comment.md | 44 +++++ src/main.rs | 71 ++++++++ 13 files changed, 920 insertions(+), 22 deletions(-) create mode 100644 .github/workflows/chapel-ci.yml create mode 100644 chapel/smoke/two_repo_smoke.chpl create mode 100755 chapel/tests/contract_check.sh create mode 100644 chapel/tests/expected_contract.json create mode 100755 chapel/tests/rayon_vs_chapel_diff.sh create mode 100644 docs/adr/0001-chapel-distributed-scanner.md create mode 100644 docs/adr/0001-chapel-issue-33-comment.md diff --git a/.github/workflows/chapel-ci.yml b/.github/workflows/chapel-ci.yml new file mode 100644 index 0000000..5d8da2d --- /dev/null +++ b/.github/workflows/chapel-ci.yml @@ -0,0 +1,191 @@ +# SPDX-License-Identifier: MPL-2.0 +# +# chapel-ci — strict CI gates for the OPTIONAL Chapel mass-panic harness. +# +# The Rust binary stands alone (USB-stick-portable, single-machine). chapel/ is +# a detachable multi-machine harness on top. This workflow exercises the harness +# and the Rust↔Chapel contract surface. Path triggers are scoped so a pure-Rust +# PR that doesn't touch chapel/ or src/main.rs leaves these jobs unrun, and +# removing chapel/ entirely leaves the Rust CI path (rust-ci.yml) green. +# +# Six strict jobs (no continue-on-error): +# 1. chapel-parse-check — chpl --parse-only on every module +# 2. chapel-build — just chapel-build-ci (no toolbox) +# 3. chapel-smoke — chapel/smoke/two_repo_smoke (Chapel data flow) +# 4. chapel-e2e — mass-panic end-to-end (-nl 1) on a synthetic +# 2-repo manifest. True -nl 2 requires CHPL_COMM=gasnet +# which the stock .deb doesn't ship; tracked for Wave 2. +# 5. chapel-cli-contract — assert panic-attack describe-contract matches fixture +# 6. chapel-rust-diff — rayon assemblyline vs Chapel single-locale aggregates +# +# Wave 2 hardening tracker: SHA-pin the Chapel 2.8.0 .deb download. Today the +# workflow trusts the HTTPS endpoint at chapel-lang/chapel releases. Acceptable +# for the harness scaffold; harden before promoting Chapel to a production gate. + +name: chapel-ci + +on: + push: + branches: [main] + paths: + - 'chapel/**' + - 'Justfile' + - '.github/workflows/chapel-ci.yml' + - 'src/main.rs' + - 'src/types.rs' + - 'Cargo.toml' + - 'Cargo.lock' + pull_request: + paths: + - 'chapel/**' + - 'Justfile' + - '.github/workflows/chapel-ci.yml' + - 'src/main.rs' + - 'src/types.rs' + - 'Cargo.toml' + - 'Cargo.lock' + +permissions: + contents: read + +concurrency: + group: chapel-ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + CHAPEL_VERSION: "2.8.0" + CHAPEL_DEB_URL: "https://github.com/chapel-lang/chapel/releases/download/2.8.0/chapel-2.8.0-1.ubuntu22.amd64.deb" + +jobs: + chapel-parse-check: + name: chapel-parse-check + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Install just + run: sudo apt-get update -qq && sudo apt-get install -y just + - name: Install Chapel ${{ env.CHAPEL_VERSION }} + run: | + set -euo pipefail + curl -fsSL --retry 3 -o /tmp/chapel.deb "${{ env.CHAPEL_DEB_URL }}" + sudo apt-get install -y /tmp/chapel.deb + chpl --version + - name: Parse every Chapel module + run: just chapel-parse-check + + chapel-build: + name: chapel-build + needs: chapel-parse-check + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Install just + run: sudo apt-get update -qq && sudo apt-get install -y just + - name: Install Chapel ${{ env.CHAPEL_VERSION }} + run: | + set -euo pipefail + curl -fsSL --retry 3 -o /tmp/chapel.deb "${{ env.CHAPEL_DEB_URL }}" + sudo apt-get install -y /tmp/chapel.deb + chpl --version + - name: Build mass-panic + smoke (no toolbox) + run: just chapel-build-ci + - name: Upload Chapel artefacts + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: chapel-binaries + path: | + chapel/mass-panic + chapel/smoke/two_repo_smoke + retention-days: 1 + + chapel-smoke: + name: chapel-smoke + needs: chapel-build + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Install Chapel ${{ env.CHAPEL_VERSION }} + run: | + set -euo pipefail + curl -fsSL --retry 3 -o /tmp/chapel.deb "${{ env.CHAPEL_DEB_URL }}" + sudo apt-get install -y /tmp/chapel.deb + - name: Download Chapel artefacts + uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + with: + name: chapel-binaries + path: chapel/ + - name: Restore exec bits + run: chmod +x chapel/mass-panic chapel/smoke/two_repo_smoke + - name: Run two_repo_smoke + run: ./chapel/smoke/two_repo_smoke + + chapel-e2e: + name: chapel-e2e + needs: chapel-build + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Install just + Chapel ${{ env.CHAPEL_VERSION }} + run: | + set -euo pipefail + sudo apt-get update -qq && sudo apt-get install -y just + curl -fsSL --retry 3 -o /tmp/chapel.deb "${{ env.CHAPEL_DEB_URL }}" + sudo apt-get install -y /tmp/chapel.deb + - name: Download Chapel artefacts + uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + with: + name: chapel-binaries + path: chapel/ + - name: Restore exec bits + run: chmod +x chapel/mass-panic + - name: End-to-end -nl 1 exercise + run: just chapel-e2e + + chapel-cli-contract: + name: chapel-cli-contract + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: dtolnay/rust-toolchain@4be9e76fd7c4901c61fb841f559994984270fce7 # stable + - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-chapel-cli-contract-${{ hashFiles('Cargo.lock') }} + - name: Build panic-attack + run: cargo build --release --locked + - name: Run contract gate + run: ./chapel/tests/contract_check.sh + + chapel-rust-diff: + name: chapel-rust-diff + needs: chapel-build + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: dtolnay/rust-toolchain@4be9e76fd7c4901c61fb841f559994984270fce7 # stable + - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-chapel-rust-diff-${{ hashFiles('Cargo.lock') }} + - name: Install Chapel ${{ env.CHAPEL_VERSION }} + run: | + set -euo pipefail + curl -fsSL --retry 3 -o /tmp/chapel.deb "${{ env.CHAPEL_DEB_URL }}" + sudo apt-get install -y /tmp/chapel.deb + - name: Download Chapel artefacts + uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + with: + name: chapel-binaries + path: chapel/ + - name: Restore exec bits + run: chmod +x chapel/mass-panic + - name: Build panic-attack + run: cargo build --release --locked + - name: rayon vs Chapel single-locale aggregate parity + run: ./chapel/tests/rayon_vs_chapel_diff.sh diff --git a/.gitignore b/.gitignore index f9fc5a9..7faad10 100644 --- a/.gitignore +++ b/.gitignore @@ -106,6 +106,7 @@ htmlcov/ # asdf version manager .tool-versions chapel/mass-panic +chapel/smoke/two_repo_smoke target/ node_modules/ _build/ diff --git a/Justfile b/Justfile index affafd5..636495a 100644 --- a/Justfile +++ b/Justfile @@ -144,12 +144,58 @@ chapel-build: chapel-build-toolbox: toolbox run --container chapel-dev bash -c "cd $(pwd)/chapel && chpl src/MassPanic.chpl src/Protocol.chpl src/Imaging.chpl src/Temporal.chpl -o mass-panic" +# CI build path — explicitly toolbox-free. Used by .github/workflows/chapel-ci.yml +# on stock ubuntu-latest runners after installing the Chapel 2.8.0 .deb. Builds +# mass-panic and the smoke binary in one shot. +chapel-build-ci: + cd chapel && chpl src/MassPanic.chpl src/Protocol.chpl src/Imaging.chpl src/Temporal.chpl -o mass-panic + cd chapel && chpl smoke/two_repo_smoke.chpl src/Protocol.chpl src/Imaging.chpl -o smoke/two_repo_smoke + +# Chapel-side data-flow smoke test (<5s, single-locale, no Rust binary needed) +chapel-smoke: chapel-build-ci + ./chapel/smoke/two_repo_smoke + +# Verify the Rust↔Chapel CLI contract surface. Requires +# `cargo build --release` to have produced ./target/release/panic-attack. +chapel-contract-check: + ./chapel/tests/contract_check.sh + +# Aggregate-parity check: rayon assemblyline vs Chapel single-locale on a +# synthetic 2-repo corpus. Requires both binaries built. +chapel-rust-diff: + ./chapel/tests/rayon_vs_chapel_diff.sh + +# End-to-end single-locale exercise against a synthetic 2-repo manifest. +# +# True multi-locale (-nl 2 over real cluster nodes) requires Chapel built with +# CHPL_COMM=gasnet — the stock ubuntu .deb ships CHPL_COMM=none and rejects +# -nl >1. The v0 gate verifies the full mass-panic flow (discover → spawn +# panic-attack → write SystemImage JSON) on -nl 1; the cross-locale code path +# is staged for Wave 2 once the .deb story for multilocale Chapel is solved. +# Tracker: see chapel/README.md "Wave 2 follow-up". +chapel-e2e: + #!/usr/bin/env bash + set -euo pipefail + WORK=$(mktemp -d /tmp/chapel-e2e-XXXXXX) + trap 'rm -rf "$WORK"' EXIT + mkdir -p "$WORK/corpus/repo-alpha/src" "$WORK/corpus/repo-beta/src" + echo 'pub unsafe fn a() {}' > "$WORK/corpus/repo-alpha/src/lib.rs" + echo 'pub unsafe fn b() {}' > "$WORK/corpus/repo-beta/src/lib.rs" + for d in repo-alpha repo-beta; do (cd "$WORK/corpus/$d" && git init -q && git add -A && git -c user.email=ci@example.com -c user.name=ci commit -q -m init); done + ./chapel/mass-panic --repoDirectory="$WORK/corpus" --numLocales=1 --quiet --outputDir="$WORK/out" + ls "$WORK/out"/system-image-*.json >/dev/null && echo "chapel-e2e: PASS (-nl 1 produced system-image JSON)" + +# Parse-check every Chapel module (cheap canary; runs before chapel-build-ci) +chapel-parse-check: + cd chapel && chpl --parse-only src/MassPanic.chpl src/Protocol.chpl src/Imaging.chpl src/Temporal.chpl + cd chapel && chpl --parse-only smoke/two_repo_smoke.chpl src/Protocol.chpl src/Imaging.chpl + # Clean Chapel build artefacts chapel-clean: - rm -f chapel/mass-panic + rm -f chapel/mass-panic chapel/smoke/two_repo_smoke # Scan local repo tree with mass-panic (Chapel single-locale) -chapel-scan dir=env("HOME") + "/Documents/hyperpolymath-repos": +chapel-scan dir=(env("HOME") + "/Documents/hyperpolymath-repos"): ./chapel/mass-panic --repoDirectory={{dir}} # Diff the two most recent mass-panic temporal snapshots diff --git a/chapel/README.md b/chapel/README.md index 2e76e97..adab617 100644 --- a/chapel/README.md +++ b/chapel/README.md @@ -21,7 +21,7 @@ Locale 0 (coordinator) Locale 1..N (workers) ## Prerequisites -- [Chapel](https://chapel-lang.org/) 2.3.0+ +- [Chapel](https://chapel-lang.org/) 2.8.0+ (matches `chapel/Mason.toml`) - `panic-attack` binary on PATH (or specify via `--panicAttackBin`) ## Build @@ -70,7 +70,7 @@ chpl src/MassPanic.chpl src/Protocol.chpl src/Imaging.chpl src/Temporal.chpl -o | `--panicAttackBin` | `panic-attack` | Path to panic-attack binary | | `--mode` | `assail` | Operation mode (see above) | | `--scheduler` | `static` | `static` (fast, not resumable) or `queue` (resumable, ~5–15% slower) | -| `--resume` | `false` | Only with `--scheduler=queue`: skip repos already marked "done" in the journal | +| `--resume` | `false` | Requires `--scheduler=queue`; combining with `--scheduler=static` exits with an error (static mode has no journal). Skips repos already marked "done" in the journal | | `--journalDir` | `/journal` | Directory for queue-scheduler JSONL shards | | `--incremental` | `true` | Skip unchanged repos via BLAKE3 | | `--cacheFile` | | Fingerprint cache file path | @@ -128,10 +128,12 @@ previously-completed repos and the freshly-scanned ones. invocation with `--resume` reuses everything completed so far. A locale crash during a multi-day sweep loses only the currently-in-flight repo on that locale. -- **~5–15% slower** on clean runs. The dispatch overhead per task - (atomic fetch-add + one journal write) is per-repo instead of - being amortised across a `coforall` range. On a clean 10k-repo - sweep, expect queue mode to finish in ~1.10× the time of static. +- **~5–15% slower** on clean runs (estimate, not yet measured against a + full BoJ-estate corpus). The dispatch overhead per task (atomic + fetch-add + one journal write) is per-repo instead of being amortised + across a `coforall` range. On a clean 10k-repo sweep, expect queue + mode to finish in roughly ~1.10× the time of static. A defensible + empirical measurement is tracked as Wave 2 follow-up work. - **Right for:** long interactive sweeps (GitHub-account scale or larger), sweeps where at least one locale is on spot/preemptible infrastructure, or any run where you expect to want to pause @@ -193,7 +195,8 @@ The banner is suppressed under `--quiet`. ## Relationship to Rust assemblyline -The Chapel layer is **optional**. For single-machine scanning, use: +The Chapel layer is **optional** — a detachable harness on top of the +standalone Rust binary. For single-machine scanning, use: ```bash panic-attack assemblyline /path/to/repos # rayon parallel @@ -202,4 +205,32 @@ panic-attack image /path/to/repos # + imaging + temporal Chapel adds multi-machine distribution for scanning at GitHub-account or datacenter scale, where hundreds of machines each scan their partition of -repositories simultaneously. +repositories simultaneously. Removing `chapel/` entirely leaves the Rust +build green and the single-machine USB-stick experience intact. + +The Chapel↔Rust contract is exposed via `panic-attack describe-contract` +(introduced for the chapel-cli-contract CI gate). Any external orchestrator +— Chapel mass-panic, Nextflow, Airflow, Slurm, a hand-rolled shell script — +can call it to discover accepted flags per mode and the report +`schema_version` without coupling itself to panic-attack source. + +## Neuroscience analogy: fNIRS-inspired imaging + +panic-attack applies functional Near-Infrared Spectroscopy (fNIRS) concepts +to codebase health mapping. The canonical mapping lives in +[`src/Imaging.chpl`](src/Imaging.chpl) header (lines 4-27) and is mirrored +here so the metaphor doesn't drift: + +| fNIRS term | panic-attack equivalent | +|-----------------------|-------------------------------------------------------| +| Cortical region | Repository / directory / file | +| Blood oxygenation | Health score (inverse of risk) | +| Neural activation | Weak point density (findings per KLOC) | +| Hemodynamic response | Change velocity (how fast risk is changing) | +| Optode placement | Scanner coverage (which files were analysed) | +| Channel | Dependency / taint flow edge | +| Functional map | `SystemImage` | +| Time series | Temporal snapshot sequence in VeriSimDB | + +When a new health metric is added, update both `Imaging.chpl` and this +table; CI does not enforce the mapping but reviewers should. diff --git a/chapel/smoke/two_repo_smoke.chpl b/chapel/smoke/two_repo_smoke.chpl new file mode 100644 index 0000000..2d5aa8d --- /dev/null +++ b/chapel/smoke/two_repo_smoke.chpl @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: MPL-2.0 + +// +// two_repo_smoke — Chapel-side data-flow smoke test. +// +// Purpose: catch regressions in the RepoResult → SystemImage → JSON +// pipeline (Protocol.chpl + Imaging.chpl) WITHOUT requiring the +// panic-attack Rust binary or a real git workdir. Runs in single-locale +// mode in <5s on stock CI hardware. +// +// Scope: +// * Builds two synthetic RepoResult records (no scanning, no FFI). +// * Drives buildSystemImage() to compute aggregates + edges. +// * Writes the SystemImage JSON to a temp file via writeSystemImageJson(). +// * Asserts the four ImageNode fields that previously silently dropped +// out of writeNodeJson() (path, high_count, error, category_breakdown) +// are present and well-formed. +// +// Companion gates (in .github/workflows/chapel-ci.yml): +// * chapel-multilocale: end-to-end -nl 2 test against the real binary. +// * chapel-cli-contract: Rust↔Chapel argv-vs-describe-contract diff. +// * chapel-rust-diff: rayon vs Chapel single-locale aggregate parity. +// + +use FileSystem; +use IO; +use List; +use Protocol; +use Imaging; + +config const outputPath = "/tmp/two-repo-smoke.json"; +config const verbose = false; + +proc makeCategories(): list(CategoryCount) { + var cats: list(CategoryCount); + var c1: CategoryCount; + c1.name = "UnsafeCode"; c1.count = 3; c1.severity = "high"; + var c2: CategoryCount; + c2.name = "PanicPath"; c2.count = 2; c2.severity = "medium"; + cats.pushBack(c1); + cats.pushBack(c2); + return cats; +} + +proc makeRepoResult(name: string, path: string, wp: int, crit: int, hi: int, + lines: int, fpr: string, err: string = ""): RepoResult { + var r: RepoResult; + r.repoName = name; + r.repoPath = path; + r.weakPointCount = wp; + r.criticalCount = crit; + r.highCount = hi; + r.totalFiles = 5; + r.totalLines = lines; + r.fingerprint = fpr; + r.error = err; + r.skipped = false; + r.categories = makeCategories(); + return r; +} + +proc assertContains(haystack: string, needle: string, tag: string): bool { + if haystack.find(needle) == -1 { + writeln("smoke: FAIL [", tag, "] — '", needle, "' not found in output"); + return false; + } + if verbose then writeln("smoke: ok [", tag, "]"); + return true; +} + +proc main(): int { + var results: [0..#2] RepoResult; + results[0] = makeRepoResult("repo-alpha", "/tmp/smoke/repo-alpha", + 5, 1, 2, 1000, "f0a1b2c3"); + // The second repo includes an embedded quote in its error to exercise + // the new jsonEscape path that the silent-loss fix introduced. + results[1] = makeRepoResult("repo-beta", "/tmp/smoke/repo-beta", + 8, 2, 3, 2000, "deadbeef", + "parse error at \"line 3\""); + + var image = buildSystemImage(results, 2); + image.scanSurface = "two-repo-smoke"; + image.generatedAt = "2026-05-30T00:00:00Z"; + + { + var f = open(outputPath, ioMode.cw); + var w = f.writer(); + writeSystemImageJson(w, image); + w.close(); + f.close(); + } + + // Read it back and assert the new fields land in the JSON. + var blob: string; + { + var f = open(outputPath, ioMode.r); + var r = f.reader(); + var line: string; + while r.readLine(line) do blob += line; + r.close(); + f.close(); + } + + var ok = true; + ok &&= assertContains(blob, "\"repos_scanned\": 2", "repos_scanned"); + ok &&= assertContains(blob, "\"path\": \"/tmp/smoke/repo-alpha\"", "ImageNode.path emitted"); + ok &&= assertContains(blob, "\"high_count\": 2", "ImageNode.highCount emitted"); + ok &&= assertContains(blob, "\"high_count\": 3", "ImageNode.highCount emitted (repo-beta)"); + ok &&= assertContains(blob, "\"category_breakdown\":", "ImageNode.categoryBreakdown emitted"); + ok &&= assertContains(blob, "\"name\": \"UnsafeCode\"", "category UnsafeCode preserved"); + ok &&= assertContains(blob, "\"severity\": \"high\"", "category severity preserved"); + ok &&= assertContains(blob, "parse error at \\\"line 3\\\"", "ImageNode.error emitted with JSON-escaped quotes"); + // buildSystemImage excludes errored repos from aggregates, so totalWP/totalCrit + // count repo-alpha only. Per-node counts (high_count etc.) are still serialised + // for both — that's what the four-field silent-loss fix guarantees. + ok &&= assertContains(blob, "\"total_weak_points\": 5", "aggregate total_weak_points (alpha only; beta errored)"); + ok &&= assertContains(blob, "\"total_critical\": 1", "aggregate total_critical (alpha only)"); + + try { remove(outputPath); } catch { } + + if ok { + writeln("smoke: PASS (two-repo SystemImage round-trip + 4 silent-loss fields)"); + return 0; + } else { + writeln("smoke: FAIL — see assertions above"); + return 1; + } +} diff --git a/chapel/src/Imaging.chpl b/chapel/src/Imaging.chpl index dc9fb2a..f90c1ba 100644 --- a/chapel/src/Imaging.chpl +++ b/chapel/src/Imaging.chpl @@ -324,20 +324,49 @@ module Imaging { writer.writeln("}"); } + // Minimal JSON string escape — handles the characters most likely to + // appear in repo paths and panic-attack error messages. Other control + // characters are passed through; downstream readers must tolerate them. + // (Mirrors MassPanic.chpl::journalEscape, extended for the four ImageNode + // fields previously dropped silently by writeNodeJson.) + proc jsonEscape(s: string): string { + var buf: string; + for c in s { + if c == "\\" then buf += "\\\\"; + else if c == "\"" then buf += "\\\""; + else if c == "\n" then buf += "\\n"; + else if c == "\r" then buf += "\\r"; + else if c == "\t" then buf += "\\t"; + else buf += c; + } + return buf; + } + proc writeNodeJson(writer, node: ImageNode) throws { writer.write(" {"); - writer.write("\"id\": \"", node.id, "\", "); - writer.write("\"name\": \"", node.name, "\", "); + writer.write("\"id\": \"", jsonEscape(node.id), "\", "); + writer.write("\"path\": \"", jsonEscape(node.path), "\", "); + writer.write("\"name\": \"", jsonEscape(node.name), "\", "); writer.write("\"level\": \"", node.level, "\", "); writer.write("\"health_score\": ", node.healthScore, ", "); writer.write("\"risk_intensity\": ", node.riskIntensity, ", "); writer.write("\"weak_point_density\": ", node.weakPointDensity, ", "); writer.write("\"weak_point_count\": ", node.weakPointCount, ", "); writer.write("\"critical_count\": ", node.criticalCount, ", "); + writer.write("\"high_count\": ", node.highCount, ", "); writer.write("\"total_files\": ", node.totalFiles, ", "); writer.write("\"total_lines\": ", node.totalLines, ", "); writer.write("\"fingerprint\": \"", node.fingerprint, "\", "); - writer.write("\"skipped\": ", if node.skipped then "true" else "false"); + writer.write("\"skipped\": ", if node.skipped then "true" else "false", ", "); + writer.write("\"error\": \"", jsonEscape(node.error), "\", "); + writer.write("\"category_breakdown\": ["); + for (cat, idx) in zip(node.categoryBreakdown, 0..) { + if idx > 0 then writer.write(", "); + writer.write("{\"name\": \"", jsonEscape(cat.name), "\", "); + writer.write("\"count\": ", cat.count, ", "); + writer.write("\"severity\": \"", jsonEscape(cat.severity), "\"}"); + } + writer.write("]"); writer.write("}"); } diff --git a/chapel/src/MassPanic.chpl b/chapel/src/MassPanic.chpl index ea4f0b8..c6ccfe6 100644 --- a/chapel/src/MassPanic.chpl +++ b/chapel/src/MassPanic.chpl @@ -247,8 +247,10 @@ module MassPanic { "(~5-15% slower)."); } if resume { - writeln("mass-panic: WARNING: --resume ignored — ", - "requires --scheduler=queue"); + writeln("mass-panic: ERROR: --resume requires --scheduler=queue ", + "(static mode has no journal). Re-run with ", + "--scheduler=queue --resume."); + return false; } return true; } else if scheduler == "queue" { @@ -460,13 +462,13 @@ module MassPanic { // output — control chars are rare enough that a round-trip is // still readable if they appear; the loader uses a liberal parser). proc journalEscape(s: string): string { - var out: string; + var buf: string; for ch in s { - if ch == "\\" then out += "\\\\"; - else if ch == "\"" then out += "\\\""; - else out += ch; + if ch == "\\" then buf += "\\\\"; + else if ch == "\"" then buf += "\\\""; + else buf += ch; } - return out; + return buf; } // Write a {"state":"claim",…} journal entry on a single JSONL line. @@ -946,13 +948,18 @@ module MassPanic { args.pushBack("--intensity=" + intensity); } when "adjudicate" { - // Logic-based verdict (needs prior reports) + // Logic-based verdict (needs prior reports). + // --quiet matches assail/assault/ambush so stdout is pure JSON. + args.pushBack("--quiet"); args.pushBack("assail"); args.pushBack(repoPath); args.pushBack("--output-format=json"); } when "full" { - // Start with assail, then follow up with attack + adjudicate + // Start with assail (--quiet for pure JSON on stdout), then + // follow up with attack + adjudicate via runAttackPass and + // runAdjudicatePass. + args.pushBack("--quiet"); args.pushBack("assail"); args.pushBack(repoPath); args.pushBack("--output-format=json"); diff --git a/chapel/tests/contract_check.sh b/chapel/tests/contract_check.sh new file mode 100755 index 0000000..5003168 --- /dev/null +++ b/chapel/tests/contract_check.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# +# contract_check.sh — assert the panic-attack CLI contract surface that the +# Chapel mass-panic harness depends on remains stable. +# +# Reads chapel/tests/expected_contract.json (the fixture) and compares against +# the live `panic-attack describe-contract` JSON. Any divergence (missing mode, +# missing flag, drifted report_schema_version) fails the build. +# +# Usage: +# ./chapel/tests/contract_check.sh [path-to-panic-attack-binary] +# Default: ./target/release/panic-attack +# +# Exit codes: 0 ok, 1 contract drift, 2 fixture/tool problem. + +set -euo pipefail + +BIN="${1:-./target/release/panic-attack}" +FIXTURE="$(cd "$(dirname "$0")" && pwd)/expected_contract.json" + +if [[ ! -x "$BIN" ]]; then + echo "contract_check: ERROR: panic-attack binary not found or not executable: $BIN" >&2 + exit 2 +fi +if [[ ! -r "$FIXTURE" ]]; then + echo "contract_check: ERROR: fixture not readable: $FIXTURE" >&2 + exit 2 +fi +if ! command -v jq >/dev/null 2>&1; then + echo "contract_check: ERROR: jq is required" >&2 + exit 2 +fi + +CONTRACT="$("$BIN" describe-contract)" + +errors=0 + +# Schema-version pin +expected_schema=$(jq -r '.report_schema_version' "$FIXTURE") +actual_schema=$(jq -r '.report_schema_version' <<<"$CONTRACT") +if [[ "$expected_schema" != "$actual_schema" ]]; then + echo "contract_check: FAIL [schema] expected '$expected_schema', got '$actual_schema'" >&2 + errors=$((errors + 1)) +fi + +# Required modes +while IFS= read -r mode; do + if ! jq -e --arg m "$mode" '.modes[$m]' <<<"$CONTRACT" >/dev/null; then + echo "contract_check: FAIL [mode] required mode '$mode' missing from describe-contract output" >&2 + errors=$((errors + 1)) + fi +done < <(jq -r '.required_modes[]' "$FIXTURE") + +# Required global flags +while IFS= read -r flag; do + if ! jq -e --arg f "$flag" '.global_flags | index($f)' <<<"$CONTRACT" >/dev/null; then + echo "contract_check: FAIL [global] required global flag '$flag' missing" >&2 + errors=$((errors + 1)) + fi +done < <(jq -r '.required_global_flags[]' "$FIXTURE") + +# Required flags per mode +while IFS= read -r mode; do + while IFS= read -r flag; do + if ! jq -e --arg m "$mode" --arg f "$flag" '.modes[$m].flags | index($f)' <<<"$CONTRACT" >/dev/null; then + echo "contract_check: FAIL [flag] mode '$mode' missing required flag '$flag'" >&2 + errors=$((errors + 1)) + fi + done < <(jq -r --arg m "$mode" '.required_flags_per_mode[$m][]' "$FIXTURE") +done < <(jq -r '.required_flags_per_mode | keys[]' "$FIXTURE") + +if [[ $errors -eq 0 ]]; then + echo "contract_check: PASS — $(jq -r '.required_modes | length' "$FIXTURE") modes, $(jq -r '.required_global_flags | length' "$FIXTURE") global flags, $(jq -r '[.required_flags_per_mode[][]] | length' "$FIXTURE") per-mode flags verified." + exit 0 +fi + +echo "contract_check: $errors drift(s) detected. Run \`$BIN describe-contract\` and compare to $FIXTURE." >&2 +exit 1 diff --git a/chapel/tests/expected_contract.json b/chapel/tests/expected_contract.json new file mode 100644 index 0000000..4db2ba2 --- /dev/null +++ b/chapel/tests/expected_contract.json @@ -0,0 +1,20 @@ +{ + "_comment": "Fixture for the chapel-cli-contract CI gate. Lists the subset of the panic-attack CLI contract that the Chapel mass-panic harness depends on. The CI job runs `panic-attack describe-contract` and asserts every entry here is present in the live output. Adding a flag/mode to Chapel's argv builder requires adding it here; removing one from Rust without updating here is a drift the gate will catch.", + "report_schema_version": "2.5", + "required_modes": [ + "assail", + "assault", + "ambush", + "attack", + "adjudicate" + ], + "required_global_flags": [ + "quiet", + "output_format" + ], + "required_flags_per_mode": { + "assault": ["duration", "intensity"], + "ambush": ["duration", "intensity"], + "attack": ["duration"] + } +} diff --git a/chapel/tests/rayon_vs_chapel_diff.sh b/chapel/tests/rayon_vs_chapel_diff.sh new file mode 100755 index 0000000..c280736 --- /dev/null +++ b/chapel/tests/rayon_vs_chapel_diff.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# +# rayon_vs_chapel_diff.sh — verify that the Rust assemblyline (rayon) path +# and the Chapel mass-panic single-locale path produce identical aggregates +# on the same two-repo synthetic corpus. +# +# Compared fields (canonicalised — sorted, timestamps stripped): +# * total_weak_points +# * total_critical +# * repos_scanned +# * total_files +# +# Excluded from comparison (non-deterministic): +# * created_at / generated_at timestamps +# * any UUID / sequence-number fields +# * iteration order in arrays (sorted by repo name before extraction) +# +# Usage: +# ./chapel/tests/rayon_vs_chapel_diff.sh +# Assumes panic-attack and chapel/mass-panic are both built. +# +# Exit codes: 0 zero-diff, 1 aggregate divergence, 2 setup problem. + +set -euo pipefail + +PA_BIN="${PA_BIN:-./target/release/panic-attack}" +MP_BIN="${MP_BIN:-./chapel/mass-panic}" +WORK="$(mktemp -d /tmp/rayon-vs-chapel-XXXXXX)" +trap 'rm -rf "$WORK"' EXIT + +if [[ ! -x "$PA_BIN" ]]; then + echo "rayon_vs_chapel_diff: ERROR: $PA_BIN not executable" >&2; exit 2 +fi +if [[ ! -x "$MP_BIN" ]]; then + echo "rayon_vs_chapel_diff: ERROR: $MP_BIN not executable" >&2; exit 2 +fi +if ! command -v jq >/dev/null 2>&1; then + echo "rayon_vs_chapel_diff: ERROR: jq is required" >&2; exit 2 +fi + +# Synthesize two minimal "repos" — git-tracked dirs with a single Rust file +# each containing one known weak-point pattern (unsafe block). +mkdir -p "$WORK/corpus/repo-alpha/src" "$WORK/corpus/repo-beta/src" +cat > "$WORK/corpus/repo-alpha/src/lib.rs" <<'EOF' +// A trivial unsafe block to give assail something to find. +pub unsafe fn poke() -> *const u8 { core::ptr::null() } +EOF +cat > "$WORK/corpus/repo-beta/src/lib.rs" <<'EOF' +pub fn nudge() { unsafe { let _ = 1 as *const u8; } } +EOF +(cd "$WORK/corpus/repo-alpha" && git init -q && git add -A && git -c user.email=ci@example.com -c user.name=ci commit -q -m init) +(cd "$WORK/corpus/repo-beta" && git init -q && git add -A && git -c user.email=ci@example.com -c user.name=ci commit -q -m init) + +# Run rayon path (assemblyline). +"$PA_BIN" --quiet assemblyline "$WORK/corpus" --output "$WORK/rayon.json" + +# Run Chapel single-locale path. Pass --panicAttackBin explicitly so the +# harness invokes the just-built binary rather than searching $PATH (which +# may be empty in CI before `cargo install`). +"$MP_BIN" --repoDirectory="$WORK/corpus" \ + --numLocales=1 \ + --quiet \ + --panicAttackBin="$(realpath "$PA_BIN")" \ + --outputDir="$WORK/chapel-out" \ + --verisimdbDir="$WORK/chapel-verisimdb" + +# Find the system-image JSON Chapel wrote. +CHAPEL_IMG="$(ls -1t "$WORK/chapel-out"/system-image-*.json 2>/dev/null | head -1 || true)" +if [[ -z "$CHAPEL_IMG" ]]; then + echo "rayon_vs_chapel_diff: ERROR: Chapel did not write a system-image JSON to $WORK/chapel-out" >&2 + exit 2 +fi + +# Extract comparable aggregates. +rayon_wp=$(jq -r '.total_weak_points // (.repos | map(.weak_point_count) | add) // 0' "$WORK/rayon.json") +rayon_crit=$(jq -r '.total_critical // (.repos | map(.critical_count) | add) // 0' "$WORK/rayon.json") +rayon_repos=$(jq -r '.repos_scanned // (.repos | length) // 0' "$WORK/rayon.json") + +chapel_wp=$(jq -r '.total_weak_points' "$CHAPEL_IMG") +chapel_crit=$(jq -r '.total_critical' "$CHAPEL_IMG") +chapel_repos=$(jq -r '.repos_scanned' "$CHAPEL_IMG") + +errors=0 +check() { + local field="$1" rayon="$2" chapel="$3" + if [[ "$rayon" != "$chapel" ]]; then + echo "rayon_vs_chapel_diff: FAIL [$field] rayon=$rayon chapel=$chapel" >&2 + errors=$((errors + 1)) + else + echo "rayon_vs_chapel_diff: ok [$field] = $rayon" + fi +} +check total_weak_points "$rayon_wp" "$chapel_wp" +check total_critical "$rayon_crit" "$chapel_crit" +check repos_scanned "$rayon_repos" "$chapel_repos" + +if [[ $errors -eq 0 ]]; then + echo "rayon_vs_chapel_diff: PASS — rayon and Chapel single-locale aggregates agree." + exit 0 +fi +echo "rayon_vs_chapel_diff: $errors aggregate(s) drifted between rayon and Chapel." >&2 +echo " rayon JSON: $WORK/rayon.json (kept for triage: $WORK)" >&2 +trap - EXIT +exit 1 diff --git a/docs/adr/0001-chapel-distributed-scanner.md b/docs/adr/0001-chapel-distributed-scanner.md new file mode 100644 index 0000000..edee5fa --- /dev/null +++ b/docs/adr/0001-chapel-distributed-scanner.md @@ -0,0 +1,146 @@ + + +# ADR 0001 — Chapel as a detachable distributed-scanner harness + +* **Status:** Accepted (Wave 1 landed via PR `feat/chapel-ci-strict-gates`) +* **Date:** 2026-05-30 +* **Refs:** [issue #33](https://github.com/hyperpolymath/panic-attack/issues/33) (VeriSimDB hexad persistence S1–S3), `chapel/README.md`, `.github/workflows/chapel-ci.yml` + +## Context + +`panic-attack` ships three deployment modes (see `.claude/CLAUDE.md`): + +1. **Standalone** — single binary, zero dependencies, USB-stick-portable. +2. **Panicbot** — automated JSON scanning in CI (gitbot-fleet, GH Actions). +3. **Mass-panic** — org-scale batch scanning across many repos. + +Mode 3 has two layers: + +* `src/assemblyline.rs` — rayon-parallel single-machine batch scanner. +* `chapel/` — multi-machine fan-out built on Chapel locales, spawning the + `panic-attack` Rust binary on each worker via `Subprocess`. + +Until this PR, the Chapel layer was prose-only: 2354 LOC of well-structured +code with no CI, no smoke test, and no contract enforcement against the +Rust binary it shells out to. Every Subprocess call assumed the Rust +side's flag set matched what `MassPanic.chpl::buildCommandArgs` emits; +nothing detected silent drift. The data path from `RepoResult` → +`SystemImage` → JSON had four silent losses (`path`, `highCount`, +`error`, `categoryBreakdown` all populated but never serialised by +`Imaging.chpl::writeNodeJson`). The README claimed a `--resume` / +`--scheduler=static` interaction that was implemented as a warning +ignored at runtime. + +## Decision + +### Detachability is the load-bearing principle + +The Chapel layer is **strictly outboard**. Removing `chapel/` from the +repo must leave the Rust build green and the single-machine USB-stick +experience intact. Therefore: + +* No Cargo dependency on Chapel. +* No `src/` references to `chapel/` paths. +* The Rust binary gains no Chapel-specific flags; the new + `describe-contract` subcommand is framed as a **general orchestrator + capability** (useful to Nextflow / Airflow / Slurm / shell scripts / + any future driver), not a Chapel bridge. +* Path triggers on `.github/workflows/chapel-ci.yml` are scoped to + `chapel/**` + the Rust contract-defining files (`src/main.rs`, + `src/types.rs`, `Cargo.toml`, `Cargo.lock`). A pure-Rust PR that + doesn't touch these paths never wakes Chapel CI. + +### CI gates land six strict jobs + +No `continue-on-error` anywhere. Failure on any of the six fails the PR: + +1. `chapel-parse-check` — `chpl --parse-only` on every module + smoke. +2. `chapel-build` — `just chapel-build-ci` (toolbox-free, .deb install). +3. `chapel-smoke` — `chapel/smoke/two_repo_smoke` exercises the + `RepoResult → SystemImage → JSON` data flow + asserts the four + silent-loss fields appear. +4. `chapel-e2e` — `mass-panic --numLocales=1` against a synthetic + 2-repo manifest. (True `-nl 2` requires `CHPL_COMM=gasnet`; the + stock .deb ships `CHPL_COMM=none`. Tracked for Wave 2.) +5. `chapel-cli-contract` — runs `panic-attack describe-contract` and + asserts the live JSON matches `chapel/tests/expected_contract.json`. +6. `chapel-rust-diff` — rayon assemblyline vs Chapel single-locale on + the same synthetic corpus; aggregates (`total_weak_points`, + `total_critical`, `repos_scanned`) must agree. + +### Bug fixes that were in scope here + +* `Imaging.chpl::writeNodeJson` now serialises `path`, `high_count`, + `error`, `category_breakdown` (with JSON-escape) — four fields that + were populated in memory but dropped from the SystemImage JSON. +* `MassPanic.chpl::selectAndAnnounceScheduler` now hard-fails when + `--scheduler=static --resume` is combined (previously: warning then + proceeded silently). +* `MassPanic.chpl::buildCommandArgs` adjudicate mode now passes `--quiet` + for consistency with assail / assault / ambush (was the only mode + that didn't, risking banner-bleed into Chapel's JSON parser). +* `journalEscape` renamed local `out` → `buf` — Chapel 2.8.0 parses + `out` as the intent keyword more strictly than older versions. + +### Bug fixes deferred (Wave 2 trackers) + +* **Subprocess hang kill path** — `panic-attack --timeout=N` is currently + the only safeguard; Chapel `sub.wait()` blocks indefinitely if the + subprocess ignores the timeout. Needs a Chapel-side grace-period + loop + SIGKILL. +* **NFS journal lock semantics** — the journal directory assumes POSIX + `flock` works; NFSv3 violates this. Needs a startup probe + warning. +* **True multi-locale CI** — requires installing Chapel built with + `CHPL_COMM=gasnet`, which the stock `.deb` doesn't ship. Needs either + a multilocale Chapel build step or a maintained `chapel-multilocale` + package on the runner. +* **`describe-contract` SHA-pin for the .deb download** — workflow + currently trusts the HTTPS endpoint at `chapel-lang/chapel` releases. + Add SHA256 verification before promoting Chapel to a production gate. +* **Real BoJ-estate scheduler benchmark** — README claims queue is + ~5–15% slower; this is theoretical. A 350-repo, 2-locale measurement + is Wave 2 work. +* **`buildCommandArgs` callsite consolidation** — `MassPanic.chpl` has + five sites that spawn the Rust binary; one (`buildCommandArgs`) is + the canonical builder. Three other sites (`runAttackPass`, + `runAdjudicatePass`, `computeFingerprint`) bypass it. Consolidating + to a single helper is a refactor that doesn't belong in the CI PR. + +## Relationship to issue #33 (VeriSimDB hexad persistence S1–S3) + +Chapel's `Temporal.chpl::writeTemporalHexad` is the **producer** for +mass-panic temporal snapshots; the Rust-side hexad reader in +`src/storage/mod.rs` is the **consumer**. Issue #33's S1/S2/S3 stages +landed in the Rust side. This PR makes the producer side CI-enforced: +any change to either the Chapel writer or the Rust hexad schema that +breaks the contract is caught by `chapel-rust-diff` (aggregate parity) +or `chapel-cli-contract` (CLI shape). The four silent-loss fixes +ensure the producer no longer drops fields the hexad would otherwise +have persisted. + +## Consequences + +* `chapel/` is now a load-bearing CI tree. New Chapel module additions + must compile under `chpl --parse-only` and pass the smoke (a one-line + assertion can be added per new field via `chapel/smoke/two_repo_smoke.chpl`). +* Any clap flag rename in `src/main.rs` that affects the five Chapel-used + modes (assail, assault, ambush, attack, adjudicate) will fail + `chapel-cli-contract`. The fix is to update both clap and + `chapel/tests/expected_contract.json` in the same PR. +* Pure-Rust PRs that don't touch the trigger paths skip chapel-ci entirely; + Chapel CI failure cannot block a Rust-only release. +* Wave 2 enables real-cluster validation (`-nl 16+` on actual nodes). + +## Alternatives considered + +* **Promote Chapel to a Rust dependency** — rejected. Would break + detachability and the USB-stick experience. +* **Merge `assemblyline.rs` into Chapel** — rejected. Rayon is the + right tool for single-machine, Chapel for cross-machine. They are + not redundant. +* **Use clap's `CommandFactory` reflection to auto-generate the contract + fixture** — adopted for the `describe-contract` handler itself + (auto-syncs with clap). Rejected for the fixture (`expected_contract.json`) + because the fixture defines what Chapel *requires*, not what Rust + currently *offers*; keeping it static documents the contract surface + Chapel depends on. diff --git a/docs/adr/0001-chapel-issue-33-comment.md b/docs/adr/0001-chapel-issue-33-comment.md new file mode 100644 index 0000000..2ce4f52 --- /dev/null +++ b/docs/adr/0001-chapel-issue-33-comment.md @@ -0,0 +1,44 @@ + + + +# Cross-link: Chapel-side hexad producer is now CI-gated (#33 / PR `feat/chapel-ci-strict-gates`) + +`Temporal.chpl::writeTemporalHexad` is the Chapel-side producer for +mass-panic temporal snapshots, which feed the VeriSimDB hexad readers +this issue tracks (S1 per-finding, S2 campaign-state, S3 query). + +PR `feat/chapel-ci-strict-gates` lands six strict CI gates on the +`chapel/` tree and the Chapel↔Rust contract: + +| Gate | What it catches | +|------|------------------| +| `chapel-parse-check` | Chapel syntax regressions in any of 4 modules + smoke | +| `chapel-build` | Cross-module build break (stock ubuntu .deb, no toolbox) | +| `chapel-smoke` | `RepoResult → SystemImage → JSON` data-flow regressions | +| `chapel-e2e` | mass-panic full pipeline end-to-end (single-locale) | +| `chapel-cli-contract` | Rust clap drift breaking Chapel's argv shape | +| `chapel-rust-diff` | Aggregate divergence between rayon and Chapel paths | + +The four silent-loss fixes (`path`, `high_count`, `error`, +`category_breakdown` previously dropped by `writeNodeJson`) mean +the producer side now preserves every ImageNode field the hexad +consumer can persist. Mapping of Chapel writers → hexad facets: + +* `provenance` ← `Temporal.chpl` (tool, version, locales, scan_surface) +* `temporal` ← `Temporal.chpl` (timestamp, sequence_number, label) +* `semantic` ← `Imaging.chpl` (global_health, global_risk, totals) +* `structural` ← `Imaging.chpl` (totalFiles, totalLines, riskDistribution) +* `document` ← `Imaging.chpl::writeSystemImageJson` (full SystemImage) + +Out of scope here, tracked for Wave 2: + +* True multi-locale CI (`CHPL_COMM=gasnet` install). +* Subprocess kill-path on hang. +* NFS journal lock semantics. +* BoJ-estate scheduler benchmark to back the "~5–15% slower" claim. + +See `docs/adr/0001-chapel-distributed-scanner.md` for the full +rollout decision record. diff --git a/src/main.rs b/src/main.rs index fd40afe..b2cf45f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -795,6 +795,16 @@ enum Commands { #[arg(long, value_enum, default_value_t = QueryFormatArg::Table)] format: QueryFormatArg, }, + + /// Emit a machine-readable description of the panic-attack CLI contract + /// (accepted flags per subcommand, report `schema_version`, CLI version) + /// for external orchestrators. + /// + /// This is a generic capability — useful to Chapel mass-panic, Nextflow, + /// Airflow, Slurm, or any shell script that needs to discover the + /// panic-attack interface at runtime without coupling to its source. + /// The output schema is stable across patch releases. + DescribeContract, } #[derive(clap::ValueEnum, Clone, Debug)] @@ -2482,6 +2492,67 @@ fn run_main() -> Result<()> { return Ok(()); } + Commands::DescribeContract => { + let cmd = Cli::command(); + let mut modes = serde_json::Map::new(); + for sub in cmd.get_subcommands() { + let name = sub.get_name().to_string(); + let mut flags: Vec = sub + .get_arguments() + .filter(|a| !a.is_positional()) + .map(|a| a.get_id().to_string()) + .collect(); + flags.sort(); + let mut positional: Vec = sub + .get_arguments() + .filter(|a| a.is_positional()) + .map(|a| { + a.get_value_names() + .and_then(|v| v.first().map(|s| s.to_string())) + .unwrap_or_else(|| a.get_id().to_string()) + }) + .collect(); + positional.sort(); + let description = sub + .get_about() + .map(|s| s.to_string()) + .unwrap_or_default(); + modes.insert( + name, + serde_json::json!({ + "description": description, + "positional": positional, + "flags": flags, + }), + ); + } + let mut global_flags: Vec = cmd + .get_arguments() + .filter(|a| a.is_global_set()) + .map(|a| a.get_id().to_string()) + .collect(); + global_flags.sort(); + // Report-schema version mirrors types.rs::assail_schema_version + // and types.rs::assault_schema_version (both "2.5" in v2.5.0). + // The chapel-cli-contract CI gate catches drift between this + // string and the serialiser defaults. + let contract = serde_json::json!({ + "schema_version": "1", + "tool": "panic-attack", + "cli_version": env!("CARGO_PKG_VERSION"), + "report_schema_version": "2.5", + "detachability": { + "standalone": true, + "orchestrator_agnostic": true, + "chapel_optional": true, + }, + "global_flags": global_flags, + "modes": modes, + }); + println!("{}", serde_json::to_string_pretty(&contract)?); + return Ok(()); + } + Commands::Campaign { action } => { match action { CampaignAction::RegisterPr {