Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,25 @@ Key decisions already made:
- Feature flag is `tree-sitter-rust`, not `language-rust`
- Edition 2024, dual MIT/Apache-2.0 license

## Source Check Convention

Most source checks follow this structure (a few legacy helpers in `output_module.rs` and `error_types.rs` use
different helper shapes but still satisfy the core contract that `run()` is the sole `CheckResult` constructor):

- **Struct** implements `Check` trait with `id()`, `group()`, `layer()`, `applicable()`, `run()`
- **`check_x()` helper** takes `(source: &str)` (or `(source: &str, file: &str)` when evidence needs file location
context) and returns `CheckStatus` (not `CheckResult`) — this is the unit-testable core
- **`run()` is the sole `CheckResult` constructor** — uses `self.id()`, `self.group()`, `self.layer()` to build the
result. Never hardcode ID/group/layer string literals in `check_x()` or anywhere outside `run()`
- **Tests call `check_x()`** and match on `CheckStatus` directly, not `result.status`

This prevents ID triplication (the same string literal in `id()`, `run()`, and `check_x()`) and ensures the `Check`
trait is the single source of truth for check metadata.

For cross-language pattern helpers, use `source::has_pattern_in()` / `source::find_pattern_matches_in()` /
`source::has_string_literal_in()` with a `Language` parameter — do not write private per-language helpers in individual
check files.

## Dogfooding Safety

Behavioral checks spawn the target binary as a child process. When dogfooding (`anc check .`), the target IS
Expand All @@ -81,6 +100,12 @@ agentnative. Two rules prevent recursive fork bombs:

## CI and Quality

**Toolchain pin:** `rust-toolchain.toml` pins the channel to a specific `X.Y.Z` version with a trailing comment naming
the rustc commit SHA. Rustup reads this file on every `cargo` invocation — both local and CI snap to identical bits.
Rustup verifies component SHA256s from the distribution manifest, so the version pin is effectively a SHA pin (the
manifest is the toolchain's "lockfile"). Bumping the toolchain is a reviewed PR that updates `rust-toolchain.toml`; no
runtime `rustup update` anywhere. Policy: bump only after a new stable has aged ≥7 days (supply-chain quarantine).

**Pre-push hook:** `scripts/hooks/pre-push` mirrors CI exactly: fmt, clippy with `-Dwarnings`, test, cargo-deny, and a
Windows compatibility check. Tracked in git and activated via `core.hooksPath`. After cloning, run: `git config
core.hooksPath scripts/hooks`
Expand Down
6 changes: 5 additions & 1 deletion rust-toolchain.toml
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
# Supply-chain pin: rustc version is immutable; rustup verifies component SHA256s
# from the distribution manifest (the "lockfile" for a toolchain release).
# Trailing comment documents the commit SHA for audit.
# Bump via reviewed PR only after a new stable has aged ≥7 days.
[toolchain]
channel = "stable"
channel = "1.94.1" # rustc e408947bfd200af42db322daf0fadfe7e26d3bd1, released 2026-03-25
components = ["rustfmt", "clippy"]
6 changes: 6 additions & 0 deletions scripts/hooks/pre-push
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ fail() { echo -e " ${RED}✗${RESET} $1"; exit 1; }

echo -e "${BOLD}Running local CI checks...${RESET}"

# The toolchain is pinned in rust-toolchain.toml — both local and CI read the same file
# and install the same version. Rustup verifies component SHA256s from the distribution
# manifest, so the pin is effectively a SHA pin. No `rustup update` step here: bumping
# the toolchain is a reviewed PR that updates rust-toolchain.toml.
pass "toolchain ($(rustc --version 2>/dev/null | awk '{print $2}' || echo unknown))"

# 1. Format check
cargo fmt -- --check 2>/dev/null || fail "cargo fmt -- --check"
pass "fmt"
Expand Down
65 changes: 22 additions & 43 deletions src/checks/source/rust/env_flags.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,7 @@ impl Check for EnvFlagsCheck {

for (path, parsed_file) in parsed.iter() {
let file_str = path.display().to_string();
let result = check_env_flags(&parsed_file.source, &file_str);
match &result.status {
match &check_env_flags(&parsed_file.source, &file_str) {
CheckStatus::Warn(evidence) => {
has_clap_attrs = true;
all_warn_evidence.push(evidence.clone());
Expand All @@ -69,10 +68,10 @@ impl Check for EnvFlagsCheck {
};

Ok(CheckResult {
id: "p6-env-flags".to_string(),
id: self.id().to_string(),
label: "Agentic flags have env backing".to_string(),
group: CheckGroup::P6,
layer: CheckLayer::Source,
group: self.group(),
layer: self.layer(),
status,
})
}
Expand All @@ -81,32 +80,20 @@ impl Check for EnvFlagsCheck {
/// Check a single source string for agentic flags missing `env = "..."`.
///
/// Kept public(crate) for unit testing with inline source strings.
pub(crate) fn check_env_flags(source: &str, file: &str) -> CheckResult {
pub(crate) fn check_env_flags(source: &str, file: &str) -> CheckStatus {
let missing = find_agentic_flags_missing_env(source, file);

// If we found no agentic arg attributes at all, check if there are *any* arg attributes.
if missing.found_agentic == 0 {
let has_any_arg = has_arg_attributes(source);
if !has_any_arg {
return CheckResult {
id: "p6-env-flags".to_string(),
label: "Agentic flags have env backing".to_string(),
group: CheckGroup::P6,
layer: CheckLayer::Source,
status: CheckStatus::Skip("No clap #[arg(...)] attributes found".to_string()),
};
return CheckStatus::Skip("No clap #[arg(...)] attributes found".to_string());
}
// Has arg attributes but none are agentic — that's still a skip for this file
return CheckResult {
id: "p6-env-flags".to_string(),
label: "Agentic flags have env backing".to_string(),
group: CheckGroup::P6,
layer: CheckLayer::Source,
status: CheckStatus::Skip("No agentic flags found".to_string()),
};
return CheckStatus::Skip("No agentic flags found".to_string());
}

let status = if missing.locations.is_empty() {
if missing.locations.is_empty() {
CheckStatus::Pass
} else {
let evidence = missing
Expand All @@ -121,14 +108,6 @@ pub(crate) fn check_env_flags(source: &str, file: &str) -> CheckResult {
.collect::<Vec<_>>()
.join("\n");
CheckStatus::Warn(evidence)
};

CheckResult {
id: "p6-env-flags".to_string(),
label: "Agentic flags have env backing".to_string(),
group: CheckGroup::P6,
layer: CheckLayer::Source,
status,
}
}

Expand Down Expand Up @@ -215,8 +194,8 @@ struct Cli {
verbose: bool,
}
"#;
let result = check_env_flags(source, "src/cli.rs");
assert_eq!(result.status, CheckStatus::Pass);
let status = check_env_flags(source, "src/cli.rs");
assert_eq!(status, CheckStatus::Pass);
}

#[test]
Expand All @@ -231,9 +210,9 @@ struct Cli {
quiet: bool,
}
"#;
let result = check_env_flags(source, "src/cli.rs");
assert!(matches!(result.status, CheckStatus::Warn(_)));
if let CheckStatus::Warn(evidence) = &result.status {
let status = check_env_flags(source, "src/cli.rs");
assert!(matches!(status, CheckStatus::Warn(_)));
if let CheckStatus::Warn(evidence) = &status {
assert!(evidence.contains("output"));
assert!(evidence.contains("quiet"));
assert!(evidence.contains("missing env"));
Expand All @@ -247,8 +226,8 @@ fn main() {
println!("Hello, world!");
}
"#;
let result = check_env_flags(source, "src/main.rs");
assert!(matches!(result.status, CheckStatus::Skip(_)));
let status = check_env_flags(source, "src/main.rs");
assert!(matches!(status, CheckStatus::Skip(_)));
}

#[test]
Expand All @@ -263,8 +242,8 @@ struct Cli {
config: Option<String>,
}
"#;
let result = check_env_flags(source, "src/cli.rs");
assert!(matches!(result.status, CheckStatus::Skip(_)));
let status = check_env_flags(source, "src/cli.rs");
assert!(matches!(status, CheckStatus::Skip(_)));
}

#[test]
Expand All @@ -279,9 +258,9 @@ struct Cli {
verbose: bool,
}
"#;
let result = check_env_flags(source, "src/cli.rs");
assert!(matches!(result.status, CheckStatus::Warn(_)));
if let CheckStatus::Warn(evidence) = &result.status {
let status = check_env_flags(source, "src/cli.rs");
assert!(matches!(status, CheckStatus::Warn(_)));
if let CheckStatus::Warn(evidence) = &status {
assert!(evidence.contains("verbose"));
assert!(!evidence.contains("output"));
}
Expand All @@ -296,8 +275,8 @@ struct Cli {
timeout: Option<u64>,
}
"#;
let result = check_env_flags(source, "src/cli.rs");
assert_eq!(result.status, CheckStatus::Pass);
let status = check_env_flags(source, "src/cli.rs");
assert_eq!(status, CheckStatus::Pass);
}

#[test]
Expand Down
6 changes: 3 additions & 3 deletions src/checks/source/rust/error_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,10 @@ impl Check for ErrorTypesCheck {
};

Ok(CheckResult {
id: "p4-error-types".to_string(),
id: self.id().to_string(),
label: "Structured error types".to_string(),
group: CheckGroup::P4,
layer: CheckLayer::Source,
group: self.group(),
layer: self.layer(),
status,
})
}
Expand Down
49 changes: 20 additions & 29 deletions src/checks/source/rust/exit_codes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,7 @@ impl Check for ExitCodesCheck {

for (path, parsed_file) in parsed.iter() {
let file_str = path.display().to_string();
let result = check_exit_codes(&parsed_file.source, &file_str);
if let CheckStatus::Warn(evidence) = result.status {
if let CheckStatus::Warn(evidence) = check_exit_codes(&parsed_file.source, &file_str) {
all_evidence.push(evidence);
}
}
Expand All @@ -53,10 +52,10 @@ impl Check for ExitCodesCheck {
};

Ok(CheckResult {
id: "p4-exit-codes".to_string(),
id: self.id().to_string(),
label: "Exit codes use named constants".to_string(),
group: CheckGroup::P4,
layer: CheckLayer::Source,
group: self.group(),
layer: self.layer(),
status,
})
}
Expand All @@ -65,10 +64,10 @@ impl Check for ExitCodesCheck {
/// Check a single source string for raw integer exit codes.
///
/// Kept public(crate) for unit testing with inline source strings.
pub(crate) fn check_exit_codes(source: &str, file: &str) -> CheckResult {
pub(crate) fn check_exit_codes(source: &str, file: &str) -> CheckStatus {
let violations = find_raw_exit_codes(source, file);

let status = if violations.is_empty() {
if violations.is_empty() {
CheckStatus::Pass
} else {
let evidence = violations
Expand All @@ -77,14 +76,6 @@ pub(crate) fn check_exit_codes(source: &str, file: &str) -> CheckResult {
.collect::<Vec<_>>()
.join("\n");
CheckStatus::Warn(evidence)
};

CheckResult {
id: "p4-exit-codes".to_string(),
label: "Exit codes use named constants".to_string(),
group: CheckGroup::P4,
layer: CheckLayer::Source,
status,
}
}

Expand Down Expand Up @@ -144,8 +135,8 @@ fn main() -> anyhow::Result<()> {
Ok(())
}
"#;
let result = check_exit_codes(source, "src/main.rs");
assert_eq!(result.status, CheckStatus::Pass);
let status = check_exit_codes(source, "src/main.rs");
assert_eq!(status, CheckStatus::Pass);
}

#[test]
Expand All @@ -163,8 +154,8 @@ fn main() {
process::exit(EXIT_SUCCESS);
}
"#;
let result = check_exit_codes(source, "src/main.rs");
assert_eq!(result.status, CheckStatus::Pass);
let status = check_exit_codes(source, "src/main.rs");
assert_eq!(status, CheckStatus::Pass);
}

#[test]
Expand All @@ -176,9 +167,9 @@ fn main() {
process::exit(1);
}
"#;
let result = check_exit_codes(source, "src/main.rs");
assert!(matches!(result.status, CheckStatus::Warn(_)));
if let CheckStatus::Warn(evidence) = &result.status {
let status = check_exit_codes(source, "src/main.rs");
assert!(matches!(status, CheckStatus::Warn(_)));
if let CheckStatus::Warn(evidence) = &status {
assert!(evidence.contains("process::exit(1)"));
assert!(evidence.contains("src/main.rs"));
}
Expand All @@ -191,9 +182,9 @@ fn bail() {
std::process::exit(2);
}
"#;
let result = check_exit_codes(source, "src/lib.rs");
assert!(matches!(result.status, CheckStatus::Warn(_)));
if let CheckStatus::Warn(evidence) = &result.status {
let status = check_exit_codes(source, "src/lib.rs");
assert!(matches!(status, CheckStatus::Warn(_)));
if let CheckStatus::Warn(evidence) = &status {
assert!(evidence.contains("std::process::exit(2)"));
}
}
Expand All @@ -210,8 +201,8 @@ fn main() {
process::exit(0);
}
"#;
let result = check_exit_codes(source, "src/main.rs");
if let CheckStatus::Warn(evidence) = &result.status {
let status = check_exit_codes(source, "src/main.rs");
if let CheckStatus::Warn(evidence) = &status {
assert_eq!(evidence.lines().count(), 2);
} else {
panic!("Expected Warn");
Expand All @@ -227,8 +218,8 @@ fn main() {
process::exit(code);
}
"#;
let result = check_exit_codes(source, "src/main.rs");
assert_eq!(result.status, CheckStatus::Pass);
let status = check_exit_codes(source, "src/main.rs");
assert_eq!(status, CheckStatus::Pass);
}

#[test]
Expand Down
Loading