Skip to content

feat(harden): AST-based no-panic baseline detector (#187 PR 8a)#228

Merged
EffortlessSteven merged 2 commits into
mainfrom
feat/no-panic-baseline-detector-187
May 12, 2026
Merged

feat(harden): AST-based no-panic baseline detector (#187 PR 8a)#228
EffortlessSteven merged 2 commits into
mainfrom
feat/no-panic-baseline-detector-187

Conversation

@EffortlessSteven

Copy link
Copy Markdown
Member

Summary

Splits #187 into two PRs per the no-surprise-enforcement pattern. This PR (8a) ships the detector + baseline only; PR 8b will add the verify mode (cargo xtask no-panic check) and wire the release CI gate.

  • cargo xtask no-panic baseline walks tracked production source via syn, classifies every panic-family call site, groups by exact identity, and writes policy/no-panic-baseline.json (marked linguist-generated=true).
  • AST-based detection (not regex) so .lock().unwrap(), macro context, and cfg-gated test blocks are classified correctly.
  • Three-layer test-code exclusion: directory (tests//benches//examples/), filename (tests.rs/*_tests.rs), and AST attribute walk (#[cfg(test)] / #[test] / #[bench]).

Initial baseline (committed)

```
no-panic baseline: files_scanned=50 files_with_findings=25
production_sites=59 entries=28
(dropped 1547 test-code sites)
unwrap: 48 index: 7 expect: 4
```

Distribution is concentrated where you'd expect — .lock().unwrap() in engine/parallel/ (Mutex poisoning is unhandled today), serde_json::Value indexing in state/execution_state/, and a few plan-side .expect(\"pkg exists\") invariants. The 28 grouped entries are reviewable end-to-end.

Why the data shape changed from the issue plan

  • JSON, not TOML. The baseline is dense (~28 entries with 7 fields each plus counts) and machine-generated. TOML's table-per-entry shape becomes noisy at this density; JSON serializes more compactly and parses faster. The other policy files (allowlists, ledgers) stay TOML because they're human-curated. Documented in docs/NO_PANIC_POLICY.md.
  • Test code is excluded from the production baseline, matching the policy doc ("excluded from production-code baseline"). The issue's example schema showed a test_code field, but with 1,547 test-code sites versus 59 production sites, a mixed baseline would be 96% noise. The detector still walks all of it (so the cfg(test)/test-fn attribute decisions are honoured), then drops test findings before serialization.

Edge cases — decisions baked in

Case Decision
.lock().unwrap() chained on a method Counted as one call site at the .unwrap() position
panic!() in a match arm Counted (unreachable!() would be preferred per policy, but the planner decided it's still a panic family member until refactored)
vec[i] with bounds-checked i Counted (syn cannot prove the bound; per policy doc, indexing is family index)
Chained expect("msg").b.expect("msg") Counted as two distinct sites
tempdir().expect(\"tempdir\") in #[test] fn Skipped — #[test] triggers the cfg-test depth counter
tempdir().expect(\"tempdir\") in a tests.rs file Skipped — filename exclusion
Helper fn in tests.rs (no #[test] attr) Skipped — filename exclusion catches this
Macro bodies (vec![...], assert_eq!(...)) Not walked. syn does not parse macro inputs, so any .unwrap() inside is invisible. Documented limitation.

Test plan

  • cargo build --workspace --all-targets — clean
  • cargo clippy --workspace --all-targets -- -D warnings — clean (new ratchet activations from PR 7: Clippy 1.94/1.95 ratchets #191 also clean against xtask/src/no_panic.rs)
  • cargo fmt --all -- --check — clean
  • cargo xtask no-panic baseline — runs in <2s, produces 28 entries, exits 0
  • cargo xtask check-generated --mode advisorypolicy/no-panic-baseline.json is receipted via the new [[file]] entry in generated-allowlist.toml
  • cargo xtask check-file-policy --mode advisory — covered by existing policy/** glob in non-rust-allowlist.toml
  • cargo xtask policy-report — all 7 areas clean
  • CI green (full nextest matrix, MSRV check, security audit, docs, builds)

What lands in PR 8b (follow-up)

  • cargo xtask no-panic check — compare current vs baseline, fail on any new entry
  • Integration with cargo xtask policy-report
  • Release CI gate (shipper-release.yml) calling no-panic check before publish
  • A few unit tests for attrs_imply_test and is_excluded_dir (deferred to PR 8b since the detector's behaviour is exercised by the baseline run)

Refs #187. Closes nothing yet — #187 stays open until PR 8b lands.

`cargo xtask no-panic baseline` walks every tracked production source file
in the workspace (`crates/*/src/**/*.rs`), parses each via `syn`, and
classifies every panic-family call site by exact identity:

  family          selector_kind   selector_callee    where it matches
  ─────────────   ─────────────   ───────────────    ─────────────────
  unwrap          method_call     unwrap             .unwrap() on Option/Result
  expect          method_call     expect             .expect("...") on Option/Result
  panic           macro           panic | panic_any  panic!() / panic_any()
  unreachable     macro           unreachable        unreachable!()
  todo            macro           todo               todo!()
  unimplemented   macro           unimplemented      unimplemented!()
  index           syntax          []                 a[b] (Index trait)

Each finding is keyed by (path, family, selector_kind, selector_callee,
snippet) and counted; the deduplicated entries land in
policy/no-panic-baseline.json. AST-based detection avoids the regex
failure modes the planner flagged: `.lock().unwrap()` is correctly
attributed to its own call site, macro context is unambiguous, and
cfg-gated test blocks are skipped via attribute walking.

Test code is excluded from the production baseline per
docs/NO_PANIC_POLICY.md via three overlapping mechanisms:

  - File-path exclusion: `tests/`, `benches/`, `examples/` directories.
  - File-name exclusion: `tests.rs` and `*_tests.rs` files.
  - AST attribute walk: `#[cfg(test)]` on `mod`/`impl`, `#[test]`/`#[bench]`
    on `fn`. The visitor pushes/pops a depth counter as it enters/exits
    these scopes; findings inside are tagged and filtered before
    grouping.

This catches three classes of test code: dedicated test files, in-source
test modules, and `#[test]` functions inline in production files.

Current production surface (initial baseline):
  files_scanned=50 files_with_findings=25
  production_sites=59 entries=28 (dropped 1547 test-code sites)
  unwrap: 48   index: 7   expect: 4

Concentrations are unsurprising: `.lock().unwrap()` patterns in
engine/parallel/ (Mutex poisoning is unhandled today), `serde_json::Value`
indexing in state/execution_state/mod.rs, and plan-side `.expect("pkg
exists")` invariants. The conversion sweep is out of scope for #187;
PR 8b wires the no-new-debt check and the release CI gate, and
subsequent PRs address the debt.

  policy/no-panic-baseline.json    new, marked linguist-generated=true
  .gitattributes                   new, linguist-generated rule
  policy/generated-allowlist.toml  receipt for the new artifact
  docs/NO_PANIC_POLICY.md          updated: JSON not TOML; scope section
  xtask/Cargo.toml                 syn 2 (full,visit,extra-traits)
                                   + proc-macro2 (span-locations)
  xtask/src/no_panic.rs            new — detector + visitor + writer
  xtask/src/main.rs                new `no-panic baseline` subcommand
  CHANGELOG.md                     Added section entry
@gemini-code-assist

Copy link
Copy Markdown

Warning

You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!

@coderabbitai

coderabbitai Bot commented May 12, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@EffortlessSteven has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 2 minutes and 54 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: fd80c8a7-fafa-4a78-9f1c-48cf77a7614a

📥 Commits

Reviewing files that changed from the base of the PR and between a4d7244 and bf9caab.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (9)
  • .gitattributes
  • CHANGELOG.md
  • docs/NO_PANIC_POLICY.md
  • policy/generated-allowlist.toml
  • policy/no-panic-baseline.json
  • policy/non-rust-allowlist.toml
  • xtask/Cargo.toml
  • xtask/src/main.rs
  • xtask/src/no_panic.rs
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/no-panic-baseline-detector-187

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@factory-droid

factory-droid Bot commented May 12, 2026

Copy link
Copy Markdown

Droid finished @EffortlessSteven's task —— View job


Droid review complete.

Validation result: No actionable findings. The AST-based no-panic baseline detector (PR 8a / #187) is sound.

Inspected surfaces:

  • xtask/src/no_panic.rs::PanicVisitor — AST visitor, method-call/macro/index detection, cfg-test depth counter
  • xtask/src/no_panic.rs::attrs_imply_test#[test]/#[cfg(test)]/#[bench] attribute detection
  • xtask/src/no_panic.rs::enumerate_source_files — git ls-files enumeration with /tests///benches///examples/ exclusion
  • xtask/src/no_panic.rs::is_excluded_dirtests.rs/*_tests.rs filename exclusion at enumeration time
  • xtask/Cargo.tomlsyn and proc-macro2 with span-locations added as xtask-only deps
  • xtask/src/main.rsNoPanic subcommand enum and dispatch
  • policy/no-panic-baseline.json — 59 production sites across 28 grouped entries, JSON schema v1.0
  • policy/generated-allowlist.toml — new [[file]] entry for the baseline with linguist-generated=true
  • .gitattributeslinguist-generated=true annotation for the baseline file
  • docs/NO_PANIC_POLICY.md — updated policy doc for JSON format, scope, and command changes
  • CHANGELOG.md — entry added for the no-panic baseline detector

Checks performed:

  • Three-layer test-code exclusion confirmed: path/name exclusion at enumeration (is_excluded_dir), AST attribute walk via cfg-test depth counter, production findings filtered post-walk
  • No unsafe code introduced (workspace-wide unsafe_code = "forbid" maintained)
  • No token/redaction surface touched
  • No registry publish surface touched
  • No events/state/receipt surface touched
  • syn and proc-macro2 are xtask-only deps — do not leak into workspace crates
  • Baseline is frozen at 28 entries; future growth is blocked by PR 8b verify mode

Why no comments:
No publish-state or registry-truth changes. No state/receipt/event surface changes. No token handling changes. The PR introduces a pure policy-tooling xtask with no impact on shipper-core publish correctness, resume idempotency, or token resolution. The detector's AST classification logic is precise and its three-layer exclusion is well-documented.

Residual risk:
The check subcommand (PR 8b) will compare future baselines against the current snapshot. Any divergence will fail CI. The macro-body limitation (syn does not parse macro inputs) means .unwrap() inside vec![…] is invisible to the detector — documented in the policy doc. This is acceptable as a known limitation.

Validation signal:

  • Observed: cargo build --workspace --all-targets clean (PR description)
  • Observed: cargo clippy --workspace --all-targets -- -D warnings clean (PR description)
  • Observed: cargo xtask no-panic baseline produces 28 entries, exits 0 (PR description)
  • Observed: cargo xtask check-generated --mode advisory passes (PR description)
  • Reported: CI green not yet verified in this review

No review comments posted (no actionable findings).

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ef10758ae4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread xtask/src/no_panic.rs
.arg(workspace_root)
.arg("ls-files")
.arg("-z")
.arg("crates/*/src/**/*.rs")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Scan top-level crate sources

Observed: this pathspec only matches files below a subdirectory of src, so files such as crates/shipper-cli/src/lib.rs, crates/shipper-core/src/lib.rs, and every single-file support crate are never scanned; git ls-files 'crates/*/src/**/*.rs' returns 62 files here, while git ls-files 'crates/*/src/*.rs' shows 86 tracked source files including the omitted top-level ones. Once the follow-up check uses this baseline, new production unwrap/panic! sites in those omitted files can be added without being reported, which defeats the no-new-debt gate. Include both crates/*/src/*.rs and recursive children, or use a path walk rooted at each crate's src.

Useful? React with 👍 / 👎.

Comment thread xtask/src/no_panic.rs
Comment on lines +385 to +390
let family: Option<&'static str> = match name.as_str() {
"panic" | "panic_any" => Some("panic"),
"unreachable" => Some("unreachable"),
"todo" => Some("todo"),
"unimplemented" => Some("unimplemented"),
_ => None,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle panic_any as a function call

Observed: panic_any is listed here with macro invocations, but the Rust API is std::panic::panic_any(...), which syn represents as an ExprCall, not an ExprMacro; there is no panic_any! macro for this branch to catch. A new production panic_any(...) call will therefore be absent from the generated baseline/check results even though the policy explicitly lists panic_any() as part of the panic family. Add an ExprCall visitor for path calls whose last segment is panic_any.

Useful? React with 👍 / 👎.

@factory-droid factory-droid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — no issues found. The implementation is sound: the AST-based detector correctly classifies panic-family call sites, the three-layer test-code exclusion is precise, file enumeration uses a hardcoded git pattern with no user input, no unsafe code is introduced, and the baseline correctly reflects 59 production sites across 28 grouped entries. Security review found no vulnerabilities.

The PR 8a baseline detector added .gitattributes (linguist-generated
marker for policy/no-panic-baseline.json) but did not receipt it in
non-rust-allowlist.toml. CI's blocking-allowlist check on .github/
workflows/ci.yml flagged it as 1 unreceipted file and failed the Policy
job. Mirrors the .gitignore entry directly above it.
@factory-droid

factory-droid Bot commented May 12, 2026

Copy link
Copy Markdown

Droid finished @EffortlessSteven's task —— View job


Droid Phase 2 validation complete.

Validation result: 4 findings confirmed across xtask/src/no_panic.rs and docs/NO_PANIC_POLICY.md. PR 8a for #187 ships a solid AST-based no-panic detector with a correct three-layer test exclusion. Four issues require fixes before or after merge:

  1. [P1] git ls-files pathspec crates/*/src/**/*.rs misses 24 top-level crate source filesgit ls-files 'crates/*/src/**/*.rs' returns 62 files vs 86 from git ls-files 'crates/*/src/*.rs'. Files like crates/shipper-core/src/lib.rs are never scanned.
  2. [P2] panic_any listed as a macro but is a function call — never detectedstd::panic::panic_any(...) is ExprCall, not ExprMacro. visit_expr_call is not implemented on PanicVisitor.
  3. [P3] cfg-test attribute recursion stops at one level of any()/all()/not() nesting — inner callback in attr_implies_test does not recurse into further any/all/not calls.
  4. [P1] docs/NO_PANIC_POLICY.md line 14 says .toml but baseline is .json.

Inspected surfaces: xtask/src/no_panic.rs::enumerate_source_files, xtask/src/no_panic.rs::PanicVisitor, xtask/src/no_panic.rs::attr_implies_test, docs/NO_PANIC_POLICY.md, policy/no-panic-baseline.json, policy/generated-allowlist.toml, .gitattributes

Why no security issues: This PR adds an xtask-only tool with syn/proc-macro2 as xtask-only deps. No shipper-core changes. No registry, token, events, state, or resume surface touched.

Post-processing: 4/4 validated review comments posted to PR #228. Security analysis performed.

@EffortlessSteven EffortlessSteven merged commit 31db93d into main May 12, 2026
21 checks passed
@EffortlessSteven EffortlessSteven deleted the feat/no-panic-baseline-detector-187 branch May 12, 2026 07:48
Comment thread xtask/src/no_panic.rs
baseline.counts.files_with_findings,
baseline.counts.total_call_sites,
baseline.counts.total_entries,
dropped_test_sites,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Incomplete file enumeration — pathspec misses top-level crate sources

The enumerate_source_files function uses crates/*/src/**/*.rs as a git pathspec. This pattern requires at least one subdirectory under src/, so it excludes files directly in crates/*/src/ such as crates/shipper-cli/src/lib.rs, crates/shipper-core/src/lib.rs, or any crates/*/src/main.rs. Verified locally: git ls-files 'crates/*/src/**/*.rs' returns 62 files versus 86 from git ls-files 'crates/*/src/*.rs' — a 24-file gap. Any top-level crate source files with panic-family calls will be missing from the baseline entirely, allowing new unwrap/panic! sites in those files to bypass the no-new-debt gate when PR 8b lands.

Fix direction: Change the enumeration to use two git ls-files calls (one for crates/*/src/*.rs, one for crates/*/src/**/*.rs) and deduplicate, or walk crates/*/src/ as a directory tree.

@factory-droid factory-droid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Validation complete — 4 findings confirmed. Post-processing remaining comments.

Comment thread xtask/src/no_panic.rs
Comment on lines +198 to +214
);
for (family, n) in &baseline.counts.by_family {
println!(" {family}: {n}");
}
println!("wrote {}", BASELINE_PATH);
Ok(())
}

// ─── File enumeration ──────────────────────────────────────────────────────

fn enumerate_source_files(workspace_root: &Path) -> Result<Vec<PathBuf>> {
let output = Command::new("git")
.arg("-C")
.arg(workspace_root)
.arg("ls-files")
.arg("-z")
.arg("crates/*/src/**/*.rs")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] panic_any is listed as a macro but is actually a function call — never detected

std::panic::panic_any(...) is a function call, not a macro — syn represents it as ExprCall, not ExprMacro. The visit_expr_call method is not implemented on PanicVisitor, so panic_any(...) invocations are silently undetected despite being listed in the panic family in the policy doc.

Fix direction: Implement visit_expr_call on PanicVisitor and check if the callee path's last segment is panic_any.

Comment thread xtask/src/no_panic.rs
Comment on lines +198 to +214
);
for (family, n) in &baseline.counts.by_family {
println!(" {family}: {n}");
}
println!("wrote {}", BASELINE_PATH);
Ok(())
}

// ─── File enumeration ──────────────────────────────────────────────────────

fn enumerate_source_files(workspace_root: &Path) -> Result<Vec<PathBuf>> {
let output = Command::new("git")
.arg("-C")
.arg(workspace_root)
.arg("ls-files")
.arg("-z")
.arg("crates/*/src/**/*.rs")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3] cfg attribute test detection does not recurse beyond one level of any()/all()/not() nesting

In attr_implies_test, when the outer parse_nested_meta callback encounters any/all/not, it recurses via meta.parse_nested_meta(inner). However, the inner callback does not itself check for and recurse into further any/all/not calls. A pattern like #[cfg(any(all(test, feature = "x"), other))] would have its inner test missed because the inner callback never descends into the all(...).

Fix direction: In the inner callback, add the same if meta.path.is_ident("any") || meta.path.is_ident("all") || meta.path.is_ident("not") check that exists in the outer callback, and recurse.

Comment thread docs/NO_PANIC_POLICY.md
@@ -1,6 +1,6 @@
# No-Panic Policy

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Inconsistent file extension — TOML vs JSON

Line 14 of docs/NO_PANIC_POLICY.md reads receipted in \policy/no-panic-baseline.toml`but the actual baseline file ispolicy/no-panic-baseline.json. All other references in the same document correctly use .json`.

EffortlessSteven added a commit that referenced this pull request May 12, 2026
…#190) (#235)

Refactors three safe-by-construction indexing operations to their
idiomatic Rust equivalents, removing them from the no-panic baseline.
All three were already provably safe — the panic surface was statically
unreachable — but the new forms make that obvious and let the check
recognise them as resolved.

  ops/cargo/mod.rs       is_valid_package_name: replace the
                         `name.is_empty()` guard + `chars: Vec<char>`
                         collection + `chars[0]` indexing with a
                         direct `chars.next()` returning Option<char>.
                         Drops the Vec allocation as a small bonus.

  runtime/environment/mod.rs
                         normalize_version: replace
                         `.collect::<Vec<_>>()` + `.len() >= 2` guard +
                         `[1]` with `.nth(1)`. Reduces a five-line
                         function to one expression.

  plan/chunking/mod.rs   chunk_by_max_concurrent: replace the
                         hand-rolled index/while loop with the
                         standard `slice::chunks(batch_size)`. Drops
                         the `if items.is_empty()` guard since
                         `[T]::chunks` on an empty slice is already a
                         no-op.

Baseline state:

  Before:  28 entries, 59 production sites
           by family: expect=4 index=7 unwrap=48
  After:   25 entries, 55 production sites
           by family: expect=4 index=3 unwrap=48

`cargo xtask no-panic check` against the prior baseline correctly
reported `resolved=3`, identifying each site by exact identity (path,
family, selector_kind, selector_callee, snippet). After regenerating
the baseline, `check` reports clean.

This is the first burn-down validating the no-panic loop end-to-end.

The issue's chosen target (shipper-duration) had zero production debt —
all 53 sites flagged in the issue body were in test code, which the
detector already excludes from the production baseline via the
`*_tests.rs` / `#[cfg(test)]` filtering landed in #228. The real debt
lives in shipper-core, so the burn-down moved there.

Tests: cargo test -p shipper-core --lib passes (1900/1900).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant