diff --git a/crates/aprender-mcp/Cargo.toml b/crates/aprender-mcp/Cargo.toml
index 2444801451..3bd65e308c 100644
--- a/crates/aprender-mcp/Cargo.toml
+++ b/crates/aprender-mcp/Cargo.toml
@@ -73,10 +73,14 @@ jsonschema = "0.28"
# FALSIFY-MCP-008 harness reads the YAML contract directly to assert byte
# identity between codegen output and live tools/list schemas.
serde_yaml = { workspace = true }
-# FALSIFY-MCP-DOGFOOD-001: locate the workspace-built `apr` binary in the
-# end-to-end stdio conformance test. Already in the workspace lockfile via
-# aprender-profile dev-deps; no new transitive cost.
-assert_cmd = "2.0"
+# NOTE: no `assert_cmd`. FALSIFY-MCP-DOGFOOD-001 and -MCP-010/-011 drive the
+# real `apr` binary, and this used to locate it with
+# `assert_cmd::cargo::cargo_bin("apr")`. That reads `CARGO_BIN_EXE_apr`, which
+# cargo sets only for binaries the SAME package builds — this package is
+# lib-only, so it was never set, and the target-dir guess it falls back to ran
+# whichever commit's `apr` was lying in the shared target dir (or panicked when
+# none was). Those tests now build `apr` and use the path cargo reports:
+# tests/common/mod.rs.
[lints]
workspace = true
diff --git a/crates/aprender-mcp/tests/common/mod.rs b/crates/aprender-mcp/tests/common/mod.rs
new file mode 100644
index 0000000000..6c59fa552a
--- /dev/null
+++ b/crates/aprender-mcp/tests/common/mod.rs
@@ -0,0 +1,117 @@
+//! Declared resolution of the `apr` binary for the falsifiers that drive the
+//! real CLI (`falsify_mcp_dogfood_001`, `falsify_mcp_stdio_protocol`).
+//!
+//! # Why this module exists
+//!
+//! `aprender-mcp` is a **lib-only** package: it declares no `[[bin]]`, so cargo
+//! never sets `CARGO_BIN_EXE_apr` for these test targets. Both files used to
+//! call `assert_cmd::cargo::cargo_bin("apr")`, which on assert_cmd 2.2 reads
+//! `CARGO_BIN_EXE_apr` and, finding it unset, falls back to guessing
+//! `
/../apr` — the target directory of *whoever happened to
+//! build last*. Neither half is a declared dependency:
+//!
+//! * The env-var half can never fire here. Only the package that *builds* a
+//! binary gets `CARGO_BIN_EXE_`, and this package builds none.
+//! * The guess half depends on another package having already built `apr` into
+//! that exact directory. When it has, the test silently runs whatever commit's
+//! binary is lying there; when it has not, `cargo_bin` **panics** with
+//! "`CARGO_BIN_EXE_apr` is unset". Measured on a fresh worktree: all six
+//! falsifiers in these two files failed that way, before a single assertion ran.
+//!
+//! The panic also made `falsify_mcp_dogfood_001`'s
+//! `if candidate.is_file() { .. } else { build_apr_binary() }` unreachable —
+//! `cargo_bin` returns a path only when the file already exists, so the
+//! build-on-demand arm was dead code that could never repair the missing binary.
+//!
+//! # What replaces it
+//!
+//! Ask cargo to build the binary we name, then take the path **cargo reports**
+//! for it. Same doctrine as `scripts/apr_bin.sh` ("Ask cargo; never guess"),
+//! for the same reason: every strategy that *searches* for an `apr` eventually
+//! finds the wrong one. `--message-format=json` emits a `compiler-artifact`
+//! record whose `executable` field is the authoritative path, so this is
+//! immune to `CARGO_TARGET_DIR`, to `.cargo/config.toml` target-dir redirects
+//! (gitignored here, so main and a worktree build to different places), and to
+//! cargo's `build-dir` split — the three things the directory guess gets wrong.
+//!
+//! `cargo build` is a cheap no-op when the binary is already current, so the
+//! build is unconditional: short-circuiting on "a file exists there" is exactly
+//! the stale-artifact hole documented above.
+//!
+//! # No `$APR_BIN` escape hatch, deliberately
+//!
+//! `aprender_mcp::apr_bin` honours `$APR_BIN` at *runtime*, and the spawned
+//! `apr mcp` child inherits this process's environment. Reading `$APR_BIN` here
+//! would therefore also redirect the server's own subprocess resolution, past
+//! the mock shim the dogfood falsifier installs on `PATH` — the override would
+//! silently change what is under test rather than just where it lives.
+
+use std::path::PathBuf;
+use std::process::{Command, Stdio};
+
+/// The workspace package that owns the `apr` binary (root `Cargo.toml`,
+/// `[[bin]] name = "apr"`). Pinned by version because crates.io ships older
+/// `aprender` releases that can land in the dependency graph and make a bare
+/// `-p aprender` spec ambiguous. `aprender-mcp` and the root package both take
+/// `version.workspace = true`, so `CARGO_PKG_VERSION` here is the right one.
+fn apr_package_spec() -> String {
+ format!("aprender@{}", env!("CARGO_PKG_VERSION"))
+}
+
+/// Build `apr` and return the path cargo reports for it.
+///
+/// Panics with the cargo failure surfaced on stderr if the build fails — a
+/// broken `apr` is a real failure these falsifiers must report, not skip.
+pub fn apr_binary() -> PathBuf {
+ let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".to_string());
+ let pkg_spec = apr_package_spec();
+
+ // `json-render-diagnostics` keeps the machine-readable artifact records on
+ // stdout while compiler errors stay human-readable on the inherited stderr,
+ // so a build failure here is as legible as a normal `cargo build`.
+ let output = Command::new(&cargo)
+ .args([
+ "build",
+ "--bin",
+ "apr",
+ "-p",
+ &pkg_spec,
+ "--message-format=json-render-diagnostics",
+ ])
+ .stderr(Stdio::inherit())
+ .output()
+ .unwrap_or_else(|e| panic!("invoke `{cargo} build --bin apr -p {pkg_spec}`: {e}"));
+ assert!(
+ output.status.success(),
+ "`cargo build --bin apr -p {pkg_spec}` failed with {:?}",
+ output.status
+ );
+
+ let stdout = String::from_utf8(output.stdout).expect("cargo --message-format=json emits UTF-8");
+ let mut executables: Vec = stdout
+ .lines()
+ .filter_map(|line| serde_json::from_str::(line).ok())
+ .filter(|msg| msg["reason"] == "compiler-artifact" && msg["target"]["name"] == "apr")
+ .filter_map(|msg| msg["executable"].as_str().map(PathBuf::from))
+ .collect();
+ executables.sort();
+ executables.dedup();
+
+ // Exactly one, or we do not know which `apr` we are testing. `--bin apr -p
+ // ` compiles a single bin target, so two distinct paths means
+ // the graph grew a second `apr` and a "pick the last one" rule would decide
+ // it by luck.
+ assert_eq!(
+ executables.len(),
+ 1,
+ "expected exactly one `apr` executable from `cargo build --bin apr -p {pkg_spec}`, \
+ cargo reported {executables:?}"
+ );
+ let path = executables.remove(0);
+ assert!(
+ path.is_file(),
+ "cargo reported `apr` at {} but nothing is there",
+ path.display()
+ );
+ path
+}
diff --git a/crates/aprender-mcp/tests/falsify_mcp_dogfood_001.rs b/crates/aprender-mcp/tests/falsify_mcp_dogfood_001.rs
index 2b82552f88..7bcfd1afec 100644
--- a/crates/aprender-mcp/tests/falsify_mcp_dogfood_001.rs
+++ b/crates/aprender-mcp/tests/falsify_mcp_dogfood_001.rs
@@ -31,9 +31,10 @@
//!
//! # How it works
//!
-//! - Locate the workspace-built `apr` binary via `assert_cmd::cargo_bin`.
-//! Cargo builds workspace binaries before running integration tests, so
-//! the binary is on disk when this test executes.
+//! - Build the `apr` binary and take the path cargo reports for it
+//! (`tests/common/mod.rs`). This package declares no `[[bin]]`, so cargo
+//! does NOT build `apr` before running these tests and does not set
+//! `CARGO_BIN_EXE_apr` — the dependency has to be stated, not assumed.
//! - Drop a mock `apr` shell shim into a tempdir and PREPEND it to the
//! spawned process's `PATH`. The mock handles `validate`, `tensors`,
//! `bench`, `qa`, `trace`, `run`, `serve`, `finetune` — every subcommand
@@ -56,7 +57,11 @@ use std::path::{Path, PathBuf};
use std::process::{ChildStdin, ChildStdout, Command, Stdio};
use std::sync::mpsc;
use std::thread;
-use std::time::{Duration, Instant};
+use std::time::Duration;
+
+/// Declared resolution of the `apr` binary — see `tests/common/mod.rs`.
+mod common;
+use common::apr_binary;
/// Names of every tool the M3 server registers via `AprMcpServer::tool_definitions`.
/// Kept in lock-step with `crates/aprender-mcp/src/server.rs`.
@@ -76,40 +81,6 @@ const EXPECTED_TOOLS: &[&str] = &[
/// almost certainly a deadlock — fail loudly so CI surfaces it immediately.
const READ_TIMEOUT: Duration = Duration::from_secs(2);
-/// Build `apr` on demand if `assert_cmd::cargo_bin` couldn't find it.
-///
-/// This happens when the test crate is exercised in isolation
-/// (`cargo test -p aprender-mcp`) without a prior workspace build of the
-/// root `aprender` package's `apr` binary. We invoke `cargo build --bin
-/// apr -p aprender@` and then re-resolve via
-/// `cargo_bin`. The version qualifier is required because crates.io ships
-/// older `aprender` packages that get pulled into the dependency graph,
-/// making the bare `-p aprender` spec ambiguous.
-///
-/// Panics with a clear message if the build itself fails — that's a real
-/// failure mode the test must surface, not paper over.
-fn build_apr_binary() -> PathBuf {
- let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".to_string());
- // env!() resolves at compile time so we always match the workspace
- // root's `aprender` version exactly, regardless of registry deps.
- let pkg_spec = format!("aprender@{}", env!("CARGO_PKG_VERSION"));
- let status = Command::new(&cargo)
- .args(["build", "--bin", "apr", "-p", &pkg_spec, "--quiet"])
- .status()
- .expect("invoke `cargo build --bin apr`");
- assert!(
- status.success(),
- "cargo build --bin apr -p {pkg_spec} failed with status {status:?}"
- );
- let path = assert_cmd::cargo::cargo_bin("apr");
- assert!(
- path.is_file(),
- "expected apr binary at {} after `cargo build`",
- path.display()
- );
- path
-}
-
/// Tiny tempdir helper — same pattern as
/// `tests/falsify_mcp_progress_001.rs::tempdir_fallback`. Avoids pulling
/// `tempfile` into this crate for one test.
@@ -328,35 +299,22 @@ fn minimal_args(tool: &str) -> serde_json::Value {
#[test]
#[cfg(unix)]
fn falsify_mcp_dogfood_001_full_client_session() {
- let session_start = Instant::now();
-
// 1. Mock apr shim on a private PATH for the spawned process only.
let tmp = tempdir_fallback();
write_mock_apr_shim(&tmp);
let path_value = path_with_mock_first(&tmp);
- // 2. Locate the real apr binary. assert_cmd::cargo::cargo_bin walks up
- // from the test executable into the workspace target dir and looks
- // for `apr`. If cargo hasn't built it yet (e.g. running
- // `cargo test -p aprender-mcp` in isolation), fall back to invoking
- // `cargo build` inline so the test is self-contained and CI doesn't
- // have to remember an extra pre-step. The workspace member name for
- // the root `apr` binary is `aprender` (per root Cargo.toml
- // `[[bin]] name = "apr"`).
- let bin_path = {
- let candidate = assert_cmd::cargo::cargo_bin("apr");
- if candidate.is_file() {
- candidate
- } else {
- build_apr_binary()
- }
- };
+ // 2. Build the real apr binary and take cargo's own path for it. Nothing
+ // else in this package builds `apr`, so this is the only thing that
+ // guarantees one exists — and it is unconditional, because "reuse the
+ // file already sitting in the target dir" is how a stray commit's
+ // binary gets tested instead of this one.
+ let bin_path = apr_binary();
let mut cmd = Command::new(&bin_path);
cmd.arg("mcp")
.env("PATH", &path_value)
// Keep the binary's stderr visible for postmortem if the test fails;
- // assert_cmd-style inheritance is fine here because we never assert
- // on stderr content.
+ // inheriting it is fine because we never assert on stderr content.
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit());
@@ -543,14 +501,17 @@ fn falsify_mcp_dogfood_001_full_client_session() {
// Reader thread should join now that stdout closed.
reader_handle.join().expect("stdout reader joins cleanly");
- // 10. Whole-session budget. Spec asks for <2s; in practice this runs in
- // well under 1s on any reasonable machine. Generous slack to absorb
- // CI noise without masking real regressions.
- let elapsed = session_start.elapsed();
- assert!(
- elapsed < Duration::from_secs(10),
- "full dogfood session must complete in <10s (spec budget 2s + CI slack), took {elapsed:?}"
- );
+ // NO whole-session wall-clock budget. There used to be an
+ // `assert!(session_start.elapsed() < 10s)` here, and it is deleted rather
+ // than widened: wall-clock assertions are banned in this repo's required
+ // checks because they measure the machine, not the code. It failed on the
+ // first run that ever reached it — 72s, all of it spent blocked on cargo's
+ // build-directory lock while the sibling test binary built `apr`, with
+ // every one of the ~14 protocol round-trips still inside its own 2s bound.
+ // Nothing about MCP conformance is lost: `recv`'s READ_TIMEOUT already
+ // bounds every single message, which is a strictly sharper liveness check
+ // than one budget over the whole session, and it is a hang detector rather
+ // than a performance claim.
}
/// Build a JSON-RPC 2.0 *notification* — a Request object with NO `id`
@@ -598,14 +559,7 @@ fn falsify_mcp_009_no_reply_to_notification() {
write_mock_apr_shim(&tmp);
let path_value = path_with_mock_first(&tmp);
- let bin_path = {
- let candidate = assert_cmd::cargo::cargo_bin("apr");
- if candidate.is_file() {
- candidate
- } else {
- build_apr_binary()
- }
- };
+ let bin_path = apr_binary();
let mut cmd = Command::new(&bin_path);
cmd.arg("mcp")
diff --git a/crates/aprender-mcp/tests/falsify_mcp_stdio_protocol.rs b/crates/aprender-mcp/tests/falsify_mcp_stdio_protocol.rs
index 95f755e1d5..195f835d2d 100644
--- a/crates/aprender-mcp/tests/falsify_mcp_stdio_protocol.rs
+++ b/crates/aprender-mcp/tests/falsify_mcp_stdio_protocol.rs
@@ -20,11 +20,17 @@
#![allow(clippy::disallowed_methods)] // serde_json::json! expands to code that hits unwrap()
use std::io::{Read, Write};
-use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::sync::mpsc;
use std::time::Duration;
+/// Declared resolution of the `apr` binary. This package builds no binaries,
+/// so `CARGO_BIN_EXE_apr` never exists for this target and the target-dir
+/// guess that used to stand in for it either ran a stray commit's binary or
+/// panicked outright — see `tests/common/mod.rs` for the measurement.
+mod common;
+use common::apr_binary;
+
/// Hard cap on a whole stdio session. Anything slower is a hang, not a slow
/// machine — `apr.version` is answered in-process with no subprocess spawn.
const SESSION_TIMEOUT: Duration = Duration::from_secs(30);
@@ -32,38 +38,6 @@ const SESSION_TIMEOUT: Duration = Duration::from_secs(30);
const INITIALIZE: &str = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"falsifier","version":"1"}}}"#;
const TOOLS_CALL_VERSION: &str = r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"apr.version","arguments":{}}}"#;
-/// Locate the workspace-built `apr`, building it on demand when the test
-/// crate is exercised in isolation. Same approach as
-/// `falsify_mcp_dogfood_001.rs`.
-fn apr_binary() -> PathBuf {
- // ALWAYS build; never short-circuit on "the file exists".
- //
- // Returning an existing `cargo_bin("apr")` unconditionally means a binary
- // left in the shared target dir by ANY other commit is silently preferred.
- // That happened: these six falsifiers all failed against
- // `apr 0.63.0 (d16c608b1)` while the worktree was at 11f958f25 — the exact
- // pre-fix symptom ("stream did not contain valid UTF-8", exit 1), so the
- // fix under test looked broken when it was simply not the code running.
- // All six pass once the binary's embedded SHA matches HEAD.
- //
- // `cargo build` is a cheap no-op when the binary is already current, so
- // this costs nothing in the common case and removes the failure mode.
- // Same doctrine as scripts/apr_bin.sh, which hard-fails on a stale SHA.
- let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".to_string());
- let pkg_spec = format!("aprender@{}", env!("CARGO_PKG_VERSION"));
- let status = Command::new(&cargo)
- .args(["build", "--bin", "apr", "-p", &pkg_spec, "--quiet"])
- .status()
- .expect("invoke `cargo build --bin apr`");
- assert!(
- status.success(),
- "cargo build --bin apr -p {pkg_spec} failed"
- );
- let path = assert_cmd::cargo::cargo_bin("apr");
- assert!(path.is_file(), "apr binary missing after cargo build");
- path
-}
-
/// One complete stdio session: write `input`, CLOSE stdin (the whole point —
/// this is the EOF the server used to exit through), then read stdout to EOF.
///
diff --git a/crates/aprender-orchestrate/src/bug_hunter/tests_coverage.rs b/crates/aprender-orchestrate/src/bug_hunter/tests_coverage.rs
index 18bee13135..676c9377bb 100644
--- a/crates/aprender-orchestrate/src/bug_hunter/tests_coverage.rs
+++ b/crates/aprender-orchestrate/src/bug_hunter/tests_coverage.rs
@@ -998,13 +998,24 @@ fn test_bh_mod_018_nonexistent_file() {
#[test]
fn test_bh_mod_019_hunt_quick_mode() {
- let config = HuntConfig {
- mode: HuntMode::Quick,
- targets: vec![PathBuf::from("src")],
- ..Default::default()
- };
- let result = hunt(Path::new("."), config);
+ let fixture = hunt_fixture("mod_019_quick");
+
+ let result = hunt(&fixture, hunt_fixture_config(HuntMode::Quick));
+
assert_eq!(result.mode, HuntMode::Quick);
+ // Quick mode is pattern-only: it must find the fixture's unwrap() and must
+ // NOT run the coverage/lcov phase that Hunt mode owns.
+ assert!(
+ result.findings.iter().any(|f| f.title.contains("unwrap()")),
+ "Quick mode missed the planted unwrap(): {:?}",
+ result.findings.iter().map(|f| &f.title).collect::>()
+ );
+ assert!(
+ !result.findings.iter().any(|f| f.discovered_by == HuntMode::Hunt),
+ "Quick mode must not run Hunt-mode coverage analysis"
+ );
+
+ let _ = std::fs::remove_dir_all(&fixture);
}
// =========================================================================
diff --git a/crates/aprender-orchestrate/src/bug_hunter/tests_hunt.rs b/crates/aprender-orchestrate/src/bug_hunter/tests_hunt.rs
index 08c5340364..c012fadc06 100644
--- a/crates/aprender-orchestrate/src/bug_hunter/tests_hunt.rs
+++ b/crates/aprender-orchestrate/src/bug_hunter/tests_hunt.rs
@@ -1,20 +1,97 @@
+// =========================================================================
+// Shared fixture for hunt()-level tests
+// =========================================================================
+
+/// Build a throwaway project fixture for `hunt` / `hunt_ensemble` tests.
+///
+/// These tests used to run against `Path::new(".")` — the real crate — so each
+/// one shelled out to `cargo clippy --all-targets`, `pmat query` and `git blame`
+/// over the whole source tree (measured 40s-172s apiece). The fixture is
+/// deliberately *not* a cargo package: with no `Cargo.toml`, analyze mode's
+/// `cargo clippy` exits immediately instead of compiling a crate, so nothing
+/// here needs nextest's `serial-build` group.
+///
+/// The planted source carries one deterministic trigger per hunt mode:
+/// `unwrap()` for Analyze, a `len()` comparison plus a cast-arithmetic line for
+/// Falsify, three nested `if`s for DeepHunt. `lcov.info` gives Hunt mode
+/// coverage to chew on; the absent `fuzz/` dir makes Fuzz mode report missing
+/// fuzz targets.
+fn hunt_fixture(name: &str) -> PathBuf {
+ let dir =
+ std::env::temp_dir().join(format!("test_bh_fixture_{}_{}", name, std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(dir.join("src")).expect("create fixture src dir");
+ std::fs::write(
+ dir.join("src/lib.rs"),
+ "pub fn probe(v: &[u8]) -> usize {
+ let first = v.first().copied().unwrap();
+ if v.len() > 0 {
+ let idx = v.len() - 1 as usize;
+ if idx > 2 {
+ if idx > first as usize {
+ return idx;
+ }
+ }
+ }
+ 0
+}
+",
+ )
+ .expect("write fixture source");
+ // Six uncovered lines in one file clears report_uncovered_hotspots' threshold.
+ std::fs::write(
+ dir.join("lcov.info"),
+ "SF:src/lib.rs\nDA:1,0\nDA:2,0\nDA:3,0\nDA:4,0\nDA:5,0\nDA:6,0\nend_of_record\n",
+ )
+ .expect("write fixture lcov");
+ dir
+}
+
+/// Hermetic hunt config for `hunt_fixture`: scan only `src`, keep every finding,
+/// and leave the pmat SATD subprocess out of it. BH-23's pmat integration is
+/// covered by BH-MOD-053; shelling out to pmat here bought no coverage and cost
+/// tens of seconds per test.
+fn hunt_fixture_config(mode: HuntMode) -> HuntConfig {
+ HuntConfig {
+ mode,
+ targets: vec![PathBuf::from("src")],
+ min_suspiciousness: 0.0,
+ pmat_satd: false,
+ ..Default::default()
+ }
+}
+
// =========================================================================
// BH-MOD-001: Hunt Function
// =========================================================================
#[test]
fn test_bh_mod_001_hunt_returns_result() {
- let config = HuntConfig {
- mode: HuntMode::Analyze,
- ..Default::default()
- };
- let result = hunt(Path::new("."), config);
+ let fixture = hunt_fixture("mod_001_returns");
+
+ let result = hunt(&fixture, hunt_fixture_config(HuntMode::Analyze));
+
assert_eq!(result.mode, HuntMode::Analyze);
+ // Analyze mode must reach the pattern scan, not merely echo the mode back.
+ assert!(
+ result.findings.iter().any(|f| f.title.contains("unwrap()")),
+ "Analyze mode missed the planted unwrap(): {:?}",
+ result.findings.iter().map(|f| &f.title).collect::>()
+ );
+ assert_eq!(
+ result.stats.total_findings,
+ result.findings.len(),
+ "finalize() must count every finding"
+ );
+
+ let _ = std::fs::remove_dir_all(&fixture);
}
#[test]
fn test_bh_mod_001_hunt_all_modes() {
+ let fixture = hunt_fixture("mod_001_all_modes");
+
for mode in [
HuntMode::Falsify,
HuntMode::Hunt,
@@ -22,14 +99,22 @@ fn test_bh_mod_001_hunt_all_modes() {
HuntMode::Fuzz,
HuntMode::DeepHunt,
] {
- let config = HuntConfig {
- mode,
- targets: vec![PathBuf::from("src")],
- ..Default::default()
- };
- let result = hunt(Path::new("."), config);
+ let result = hunt(&fixture, hunt_fixture_config(mode));
assert_eq!(result.mode, mode);
+ // Dispatch must land in the mode's own handler. On this fixture every
+ // mode tags at least one finding with itself: Falsify emits mutation
+ // targets (or the cargo-mutants-unavailable notice), Hunt the lcov
+ // hotspot, Analyze the unwrap() pattern, Fuzz the missing fuzz/ dir,
+ // DeepHunt the nested conditionals.
+ assert!(
+ result.findings.iter().any(|f| f.discovered_by == mode),
+ "{} mode produced no finding of its own: {:?}",
+ mode,
+ result.findings.iter().map(|f| (&f.id, f.discovered_by)).collect::>()
+ );
}
+
+ let _ = std::fs::remove_dir_all(&fixture);
}
// =========================================================================
@@ -38,10 +123,23 @@ fn test_bh_mod_001_hunt_all_modes() {
#[test]
fn test_bh_mod_002_hunt_ensemble() {
- let config = HuntConfig::default();
- let result = hunt_ensemble(Path::new("."), config);
- // Should have findings from multiple modes
- assert!(result.duration_ms > 0);
+ let fixture = hunt_fixture("mod_002_ensemble");
+
+ let result = hunt_ensemble(&fixture, hunt_fixture_config(HuntMode::Analyze));
+
+ // The ensemble runs Analyze + Hunt + Falsify and merges the three result
+ // sets, so all three must be represented. (The previous assertion was
+ // `duration_ms > 0` — a wall-clock check that could not fail.)
+ for mode in [HuntMode::Analyze, HuntMode::Hunt, HuntMode::Falsify] {
+ assert!(
+ result.findings.iter().any(|f| f.discovered_by == mode),
+ "ensemble dropped every {} finding: {:?}",
+ mode,
+ result.findings.iter().map(|f| (&f.id, f.discovered_by)).collect::>()
+ );
+ }
+
+ let _ = std::fs::remove_dir_all(&fixture);
}
// =========================================================================
diff --git a/crates/aprender-orchestrate/src/bug_hunter/tests_modes.rs b/crates/aprender-orchestrate/src/bug_hunter/tests_modes.rs
index bb1a68d88c..da768b0638 100644
--- a/crates/aprender-orchestrate/src/bug_hunter/tests_modes.rs
+++ b/crates/aprender-orchestrate/src/bug_hunter/tests_modes.rs
@@ -247,19 +247,50 @@ fn test_bh_mod_045_hunt_coverage_weight_with_file() {
#[test]
fn test_bh_mod_046_apply_spec_quality_gate_no_pmat() {
- // When pmat is unavailable, apply_spec_quality_gate returns early at line 282
+ // apply_spec_quality_gate must bail before touching any claim when
+ // build_quality_index yields nothing. An empty directory guarantees that:
+ // pmat finds no functions to index (and if pmat is absent entirely,
+ // pmat_available() short-circuits to the same None).
+ //
+ // This used to point at /tmp, which made `pmat query` walk the whole
+ // system temp dir — 14s for a gate that never fires.
+ use super::spec::{ClaimStatus, CodeLocation, SpecClaim};
+
+ let fixture =
+ std::env::temp_dir().join(format!("test_bh_mod_046_empty_{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&fixture);
+ std::fs::create_dir_all(&fixture).expect("create empty fixture dir");
+
+ // A claim WITH an implementation, so the early return is what keeps the
+ // finding list empty rather than there being nothing to inspect.
let mut parsed_spec = ParsedSpec {
- claims: vec![],
+ claims: vec![SpecClaim {
+ id: "NOPMAT-01".to_string(),
+ title: "Claim with an implementation".to_string(),
+ line: 1,
+ section_path: vec!["Section 1".to_string()],
+ implementations: vec![CodeLocation {
+ file: PathBuf::from("src/lib.rs"),
+ line: 1,
+ context: "probe".to_string(),
+ }],
+ findings: Vec::new(),
+ status: ClaimStatus::Pending,
+ }],
original_content: String::new(),
path: PathBuf::new(),
};
- let mut result = HuntResult::new("/tmp", HuntMode::Analyze, HuntConfig::default());
+ let mut result = HuntResult::new(&fixture, HuntMode::Analyze, HuntConfig::default());
- // This exercises the early return path — build_quality_index returns None
- apply_spec_quality_gate(&mut parsed_spec, Path::new("/tmp"), &mut result, "*");
+ apply_spec_quality_gate(&mut parsed_spec, &fixture, &mut result, "*");
- // No findings should be added since pmat is not available
- assert!(result.findings.is_empty());
+ assert!(
+ result.findings.is_empty(),
+ "gate must add nothing when the quality index is unavailable: {:?}",
+ result.findings.iter().map(|f| &f.id).collect::>()
+ );
+
+ let _ = std::fs::remove_dir_all(&fixture);
}
// =========================================================================
@@ -525,106 +556,156 @@ fn test_bh_mod_052_hunt_mode_with_junit_xml() {
#[test]
fn test_bh_mod_053_hunt_pmat_quality_on_real_project() {
- // Run hunt with use_pmat_quality on the REAL project directory so
- // build_quality_index succeeds (pmat is available and project has code).
- // This covers lines 107-117 in hunt().
+ // Exercises hunt()'s BH-21/BH-24 quality phase. This used to run against
+ // the REAL crate, so every execution paid a full `pmat query` over the
+ // whole source tree (40s). A fixture pmat indexes in milliseconds reaches
+ // the same branch.
+ let fixture = hunt_fixture("mod_053_pmat_quality");
+
+ let baseline = hunt(&fixture, hunt_fixture_config(HuntMode::Quick));
+
let config = HuntConfig {
- mode: HuntMode::Quick,
- targets: vec![PathBuf::from("src")],
- min_suspiciousness: 0.0,
use_pmat_quality: true,
- pmat_query: Some("hunt".to_string()),
+ pmat_query: Some("probe".to_string()),
quality_weight: 0.5,
- ..Default::default()
+ ..hunt_fixture_config(HuntMode::Quick)
};
+ let result = hunt(&fixture, config);
- let result = hunt(Path::new("."), config);
assert_eq!(result.mode, HuntMode::Quick);
- // If pmat was available, the index timing should be recorded
- // (May be 0 if pmat query was fast, but the path was exercised)
- // At minimum, the hunt completes without error.
+ // The quality phase reweights findings in place; it must never add or drop
+ // one. Diffing against the pmat-off run pins that down on any machine,
+ // whether or not pmat is installed.
+ let locations = |r: &HuntResult| {
+ let mut keys: Vec = r
+ .findings
+ .iter()
+ .map(|f| format!("{}|{}|{}", f.file.display(), f.line, f.title))
+ .collect();
+ keys.sort();
+ keys
+ };
+ assert!(!baseline.findings.is_empty(), "fixture produced no findings to weight");
+ assert_eq!(
+ locations(&result),
+ locations(&baseline),
+ "pmat quality phase must reweight findings, not change the set"
+ );
+
+ let _ = std::fs::remove_dir_all(&fixture);
}
// =========================================================================
// BH-MOD-054: Coverage Gap Tests — apply_spec_quality_gate with real project
// =========================================================================
-#[test]
-fn test_bh_mod_054_apply_spec_quality_gate_real_project() {
- // Construct a ParsedSpec with claims that have implementations
- // pointing to real files in the project. Call apply_spec_quality_gate
- // on the real project path so build_quality_index returns Some.
+/// Write a one-file fixture project that pmat can index in milliseconds.
+///
+/// The BH-25 quality-gate tests used to run `pmat query` over the real crate
+/// (15-20s each) and then assert nothing at all. A fixture lets them assert the
+/// gate's actual predicate instead.
+fn quality_gate_fixture(name: &str, source: &str) -> PathBuf {
+ let dir = std::env::temp_dir().join(format!("test_bh_qgate_{}_{}", name, std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(dir.join("src")).expect("create qgate fixture src dir");
+ std::fs::write(dir.join("src/lib.rs"), source).expect("write qgate fixture source");
+ dir
+}
+
+/// A `ParsedSpec` with a single claim implemented at `src/lib.rs:1`.
+///
+/// The path is relative because that is the form `pmat query` reports, and
+/// `lookup_quality` matches index keys exactly.
+fn quality_gate_spec(claim_id: &str) -> ParsedSpec {
use super::spec::{ClaimStatus, CodeLocation, SpecClaim};
- let mut parsed_spec = ParsedSpec {
+ ParsedSpec {
path: PathBuf::from("test_spec.md"),
claims: vec![SpecClaim {
- id: "CLAIM-01".to_string(),
+ id: claim_id.to_string(),
title: "Test Claim".to_string(),
line: 1,
section_path: vec!["Section 1".to_string()],
implementations: vec![CodeLocation {
- file: PathBuf::from("src/bug_hunter/mod.rs"),
- line: 66,
- context: "hunt function".to_string(),
+ file: PathBuf::from("src/lib.rs"),
+ line: 1,
+ context: "fixture function".to_string(),
}],
findings: Vec::new(),
status: ClaimStatus::Pending,
}],
- original_content: "# Spec\n## Section 1\n### CLAIM-01: Test\n".to_string(),
- };
+ original_content: format!("# Spec\n## Section 1\n### {}: Test\n", claim_id),
+ }
+}
- let mut result = HuntResult::new(".", HuntMode::Analyze, HuntConfig::default());
- let initial_count = result.findings.len();
+#[test]
+fn test_bh_mod_054_apply_spec_quality_gate_real_project() {
+ // A claim implemented by a small, well-graded function must NOT trip the
+ // gate. Previously this ran pmat over the real crate and asserted nothing.
+ let fixture = quality_gate_fixture(
+ "clean",
+ "pub fn tidy(n: u32) -> u32 {\n n.saturating_add(1)\n}\n",
+ );
- // Call the function on the real project path — pmat is available
- apply_spec_quality_gate(&mut parsed_spec, Path::new("."), &mut result, "hunt");
+ let mut parsed_spec = quality_gate_spec("CLAIM-01");
+ let mut result = HuntResult::new(&fixture, HuntMode::Analyze, HuntConfig::default());
+
+ apply_spec_quality_gate(&mut parsed_spec, &fixture, &mut result, "tidy");
+
+ assert!(
+ result.findings.is_empty(),
+ "gate fired on well-graded code: {:?}",
+ result.findings.iter().map(|f| (&f.id, &f.title)).collect::>()
+ );
- // The function either:
- // 1. build_quality_index returns Some → iterates claims → may add findings
- // 2. build_quality_index returns None → returns early
- // Either way, this exercises the code path
- let _ = result.findings.len() >= initial_count; // No panic
+ let _ = std::fs::remove_dir_all(&fixture);
}
#[test]
fn test_bh_mod_054_apply_spec_quality_gate_low_quality_finding() {
- // Test the inner branch where pmat returns low-quality code (grade D/F or complexity > 20).
- // We construct a scenario with real project files and a claim pointing to them.
- use super::spec::{ClaimStatus, CodeLocation, SpecClaim};
-
- let mut parsed_spec = ParsedSpec {
- path: PathBuf::from("test_spec.md"),
- claims: vec![SpecClaim {
- id: "LQ-01".to_string(),
- title: "Low Quality Claim".to_string(),
- line: 1,
- section_path: vec!["Quality".to_string()],
- implementations: vec![
- // Point to a real file — pmat will look up quality
- CodeLocation {
- file: PathBuf::from("src/bug_hunter/mod.rs"),
- line: 990,
- context: "analyze_common_patterns".to_string(),
- },
- // Also include a nonexistent file to exercise the None path
- CodeLocation {
- file: PathBuf::from("src/nonexistent.rs"),
- line: 1,
- context: "missing file".to_string(),
- },
- ],
- findings: Vec::new(),
- status: ClaimStatus::Pending,
- }],
- original_content: "# Spec\n## Quality\n### LQ-01: Low Quality\n".to_string(),
- };
-
- let mut result = HuntResult::new(".", HuntMode::Analyze, HuntConfig::default());
-
- apply_spec_quality_gate(&mut parsed_spec, Path::new("."), &mut result, "*");
+ // The inner branch: pmat grades the implementing function as low quality
+ // (grade D/F or complexity > 20), so the gate must emit BH-QGATE-.
+ let mut source = String::from("pub fn tangled(n: u32) -> u32 {\n let mut acc = 0;\n");
+ for i in 1..=25 {
+ source.push_str(&format!(
+ " if n % {i} == 0 {{ acc += {i}; }} else if n > {i} {{ acc -= 1; }}\n"
+ ));
+ }
+ source.push_str(" acc\n}\n");
+ let fixture = quality_gate_fixture("tangled", &source);
+
+ let mut parsed_spec = quality_gate_spec("LQ-01");
+ let mut result = HuntResult::new(&fixture, HuntMode::Analyze, HuntConfig::default());
+
+ // Ask the same question the gate asks, so both outcomes stay assertable on
+ // machines with and without pmat installed.
+ let index = super::pmat_quality::build_quality_index(&fixture, "tangled", 200);
+ apply_spec_quality_gate(&mut parsed_spec, &fixture, &mut result, "tangled");
+
+ match index {
+ Some(index) => {
+ let graded = super::pmat_quality::lookup_quality(&index, Path::new("src/lib.rs"), 1)
+ .expect("pmat indexed src/lib.rs but no function covers line 1");
+ assert!(
+ graded.complexity > 20 || graded.tdg_grade == "D" || graded.tdg_grade == "F",
+ "fixture is no longer low quality (grade {}, complexity {})",
+ graded.tdg_grade,
+ graded.complexity
+ );
+ assert!(
+ result.findings.iter().any(|f| f.id == "BH-QGATE-LQ-01"),
+ "gate missed low-quality implementation: {:?}",
+ result.findings.iter().map(|f| &f.id).collect::>()
+ );
+ }
+ None => assert!(
+ result.findings.is_empty(),
+ "gate must add nothing without a quality index: {:?}",
+ result.findings.iter().map(|f| &f.id).collect::>()
+ ),
+ }
- // Whether or not the specific function is graded D/F, the code paths are exercised
+ let _ = std::fs::remove_dir_all(&fixture);
}
#[test]
@@ -671,12 +752,10 @@ fn test_bh_mod_054_apply_spec_quality_gate_no_pmat() {
#[test]
fn test_bh_mod_055_hunt_with_spec_pmat_quality_real_project() {
- // Write a spec file in a temp dir but run hunt_with_spec against the
- // real project so that both the pmat quality branch in hunt() (lines 102-119)
- // and apply_spec_quality_gate (lines 276-321) get exercised.
- let temp = std::env::temp_dir().join("test_bh_mod_055_spec_real");
- let _ = std::fs::remove_dir_all(&temp);
- let _ = std::fs::create_dir_all(&temp);
+ // Drives both the pmat quality branch in hunt() and apply_spec_quality_gate
+ // through hunt_with_spec. It used to hunt the real crate with pmat enabled
+ // (18s); the fixture reaches the same branches.
+ let fixture = hunt_fixture("mod_055_spec");
let spec_content = "\
# Bug Hunter Spec
@@ -687,26 +766,30 @@ fn test_bh_mod_055_hunt_with_spec_pmat_quality_real_project() {
The hunt function should support all modes.
";
- let spec_path = temp.join("spec.md");
- std::fs::write(&spec_path, spec_content).unwrap();
+ let spec_path = fixture.join("spec.md");
+ std::fs::write(&spec_path, spec_content).expect("write fixture spec");
let config = HuntConfig {
- mode: HuntMode::Quick,
- targets: vec![PathBuf::from("src")],
use_pmat_quality: true,
- pmat_query: Some("hunt".to_string()),
+ pmat_query: Some("probe".to_string()),
quality_weight: 0.5,
- ..Default::default()
+ ..hunt_fixture_config(HuntMode::Quick)
};
- // Use the real project path but spec from temp
- let result = hunt_with_spec(Path::new("."), &spec_path, None, config);
- assert!(result.is_ok());
- let (hunt_result, parsed_spec) = result.unwrap();
- assert!(!parsed_spec.claims.is_empty());
+ let result = hunt_with_spec(&fixture, &spec_path, None, config);
+ let (hunt_result, parsed_spec) = result.expect("hunt_with_spec on the fixture");
+
+ assert_eq!(parsed_spec.claims.len(), 1, "spec parser lost the BH-01 claim");
assert_eq!(hunt_result.mode, HuntMode::Quick);
+ // The spec has no implementations in the fixture, so hunt_with_spec must
+ // fall back to the configured targets and still scan the source.
+ assert!(
+ hunt_result.findings.iter().any(|f| f.title.contains("unwrap()")),
+ "spec-driven hunt scanned nothing: {:?}",
+ hunt_result.findings.iter().map(|f| &f.title).collect::>()
+ );
- let _ = std::fs::remove_dir_all(&temp);
+ let _ = std::fs::remove_dir_all(&fixture);
}
// =========================================================================
diff --git a/crates/aprender-orchestrate/src/bug_hunter/tests_patterns.rs b/crates/aprender-orchestrate/src/bug_hunter/tests_patterns.rs
index b71c202e70..3d8ecef050 100644
--- a/crates/aprender-orchestrate/src/bug_hunter/tests_patterns.rs
+++ b/crates/aprender-orchestrate/src/bug_hunter/tests_patterns.rs
@@ -240,12 +240,26 @@ fn test_bh_mod_024_hunt_with_spec_nonexistent() {
#[test]
fn test_bh_mod_025_hunt_ensemble() {
- let config = HuntConfig {
- targets: vec![PathBuf::from("src")],
- ..Default::default()
- };
- let result = hunt_ensemble(Path::new("."), config);
- assert!(result.duration_ms > 0);
+ let fixture = hunt_fixture("mod_025_ensemble");
+
+ let result = hunt_ensemble(&fixture, hunt_fixture_config(HuntMode::Analyze));
+
+ // BH-MOD-002 covers the merge; this covers hunt_ensemble's dedup contract:
+ // no two findings may share (file, line, category, title). Falsify mode
+ // globs `src/*.rs` and `src/**/*.rs`, both of which match src/lib.rs, so
+ // the input to the dedup genuinely contains repeats.
+ let mut keys: Vec = result
+ .findings
+ .iter()
+ .map(|f| format!("{}|{}|{:?}|{}", f.file.display(), f.line, f.category, f.title))
+ .collect();
+ let total = keys.len();
+ assert!(total > 0, "ensemble found nothing on the fixture");
+ keys.sort();
+ keys.dedup();
+ assert_eq!(keys.len(), total, "hunt_ensemble emitted duplicate findings");
+
+ let _ = std::fs::remove_dir_all(&fixture);
}
// =========================================================================
diff --git a/crates/aprender-orchestrate/src/oracle/local_workspace_tests.rs b/crates/aprender-orchestrate/src/oracle/local_workspace_tests.rs
index f352c75db9..dd3eaa6c18 100644
--- a/crates/aprender-orchestrate/src/oracle/local_workspace_tests.rs
+++ b/crates/aprender-orchestrate/src/oracle/local_workspace_tests.rs
@@ -543,34 +543,97 @@ opt-level = 3
// Coverage Gap Tests — get_git_status
// =========================================================================
+/// Create a throwaway git repo on a known branch with `files` committed.
+///
+/// The directory name carries the pid so concurrent test processes never
+/// share state, and the repo is built from scratch so the assertions below
+/// do not depend on the developer's own working tree.
+fn init_git_fixture(name: &str, files: &[(&str, &str)]) -> PathBuf {
+ let dir = std::env::temp_dir().join(format!("{}_{}", name, std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(&dir).unwrap();
+
+ let git = |args: &[&str]| {
+ let out = std::process::Command::new("git")
+ .args(args)
+ .current_dir(&dir)
+ .output()
+ .unwrap_or_else(|e| panic!("git {args:?} failed to spawn: {e}"));
+ assert!(
+ out.status.success(),
+ "git {args:?} failed: {}",
+ String::from_utf8_lossy(&out.stderr)
+ );
+ };
+
+ git(&["init", "-q"]);
+ git(&["config", "user.email", "test@example.com"]);
+ git(&["config", "user.name", "Test"]);
+ git(&["config", "commit.gpgsign", "false"]);
+ // Explicit branch name: the git default (master vs main) is host config.
+ git(&["checkout", "-q", "-b", "fixture-branch"]);
+
+ for (path, contents) in files {
+ std::fs::write(dir.join(path), contents).unwrap();
+ }
+ git(&["add", "."]);
+ git(&["commit", "-q", "--no-verify", "-m", "init"]);
+
+ dir
+}
+
#[test]
-fn test_get_git_status_current_repo() {
+fn test_get_git_status_clean_repo() {
+ let repo = init_git_fixture("oracle_git_status_clean", &[("a.txt", "a"), ("b.txt", "b")]);
let oracle = LocalWorkspaceOracle::with_base_dir(std::env::temp_dir()).unwrap();
- let status = oracle.get_git_status(Path::new("."));
-
- // In a git repo (local dev), branch should be a real name.
- // Outside a git repo (clean-room container), git is absent or cwd
- // has no .git — branch will be empty or "unknown". Both are valid.
- let in_git_repo = std::process::Command::new("git")
- .args(["rev-parse", "--git-dir"])
- .output()
- .map(|o| o.status.success())
- .unwrap_or(false);
- if in_git_repo {
- assert!(!status.branch.is_empty());
- assert_ne!(status.branch, "unknown");
- }
- // Outside a git repo we just verify it doesn't panic (already exercised above)
+
+ let status = oracle.get_git_status(&repo);
+
+ assert_eq!(status.branch, "fixture-branch");
+ assert!(!status.has_changes, "freshly committed repo reports changes");
+ assert_eq!(status.modified_count, 0);
+ // No upstream configured, so `git log @{u}..HEAD` fails and counts as 0.
+ assert_eq!(status.unpushed_commits, 0);
+ assert!(status.up_to_date);
+
+ let _ = std::fs::remove_dir_all(&repo);
+}
+
+#[test]
+fn test_get_git_status_dirty_repo() {
+ let repo = init_git_fixture("oracle_git_status_dirty", &[("a.txt", "a"), ("b.txt", "b")]);
+ // Modify both tracked files — tracked modifications can never be hidden
+ // by a host-level core.excludesFile the way untracked ones can.
+ std::fs::write(repo.join("a.txt"), "a changed").unwrap();
+ std::fs::write(repo.join("b.txt"), "b changed").unwrap();
+
+ let oracle = LocalWorkspaceOracle::with_base_dir(std::env::temp_dir()).unwrap();
+ let status = oracle.get_git_status(&repo);
+
+ assert_eq!(status.branch, "fixture-branch");
+ assert!(status.has_changes);
+ assert_eq!(status.modified_count, 2, "expected exactly the 2 modified files");
+ assert!(!status.up_to_date);
+
+ let _ = std::fs::remove_dir_all(&repo);
}
#[test]
fn test_get_git_status_non_git_dir() {
+ let dir = std::env::temp_dir().join(format!("oracle_git_status_nongit_{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(&dir).unwrap();
+
let oracle = LocalWorkspaceOracle::with_base_dir(std::env::temp_dir()).unwrap();
- let status = oracle.get_git_status(Path::new("/tmp"));
+ let status = oracle.get_git_status(&dir);
+
+ // git exits non-zero with empty stdout outside a repo; the fallback to
+ // "unknown" only fires when git cannot be spawned at all.
+ assert_eq!(status.branch, "");
+ assert!(!status.has_changes);
+ assert_eq!(status.modified_count, 0);
- // Should return defaults without panic (branch may be empty for non-git dirs)
- let _ = status.branch;
- let _ = status.has_changes;
+ let _ = std::fs::remove_dir_all(&dir);
}
// =========================================================================
diff --git a/crates/aprender-orchestrate/tests/integration_test.rs b/crates/aprender-orchestrate/tests/integration_test.rs
index b5e9d0c066..8278c9dc18 100644
--- a/crates/aprender-orchestrate/tests/integration_test.rs
+++ b/crates/aprender-orchestrate/tests/integration_test.rs
@@ -6,9 +6,12 @@ use predicates::prelude::*;
use std::fs;
use tempfile::TempDir;
-/// Helper to create batuta command with drift check disabled (for pre-release testing)
+/// Helper to create the CLI command with drift check disabled (for pre-release testing)
+///
+/// The bin target was renamed to `aprender-orchestrate` in the monorepo
+/// consolidation; `batuta` survives only as the [lib] name.
fn batuta_cmd() -> Command {
- let mut cmd = Command::cargo_bin("batuta").unwrap();
+ let mut cmd = Command::cargo_bin("aprender-orchestrate").unwrap();
cmd.arg("--unsafe-skip-drift-check");
cmd
}
diff --git a/crates/aprender-present-cli/tests/gate_can_fail.rs b/crates/aprender-present-cli/tests/gate_can_fail.rs
new file mode 100644
index 0000000000..84faae4553
--- /dev/null
+++ b/crates/aprender-present-cli/tests/gate_can_fail.rs
@@ -0,0 +1,112 @@
+//! `presentar gate` must be able to FAIL.
+//!
+//! `run_gates` is the only subcommand with an exit-code contract: it calls
+//! `std::process::exit(1)` when the manifest's computed grade falls below
+//! `--min-grade`. A gate that returns success for every input is theater, so
+//! this test pins BOTH directions — a threadbare manifest must be rejected and
+//! a rich one must be accepted. Asserting only the passing case would not
+//! exclude "the gate always exits 0".
+
+use std::fs;
+use std::path::PathBuf;
+use std::process::Command;
+
+/// Write `yaml` into the per-target tmpdir under `name` and return its path.
+fn manifest(name: &str, yaml: &str) -> PathBuf {
+ let dir = PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join("gate_can_fail");
+ fs::create_dir_all(&dir).expect("create tmpdir");
+ let path = dir.join(name);
+ fs::write(&path, yaml).expect("write manifest");
+ path
+}
+
+/// Run `presentar gate ` at the default `--min-grade B`.
+fn gate(path: &PathBuf) -> std::process::Output {
+ Command::new(env!("CARGO_BIN_EXE_presentar"))
+ .args(["gate", path.to_str().expect("utf-8 path")])
+ .output()
+ .expect("run presentar")
+}
+
+/// No description, no data sources, no widgets: scores well under grade B.
+const THREADBARE: &str = r#"
+presentar: "0.1"
+name: threadbare
+version: "1.0.0"
+layout:
+ type: dashboard
+ columns: 12
+ sections:
+ - id: only-section
+"#;
+
+/// Description, five sections, twenty typed widgets, three refreshing data
+/// sources: scores in the A band.
+fn rich() -> String {
+ let mut yaml = String::from(
+ r#"
+presentar: "0.1"
+name: rich
+version: "1.0.0"
+description: A fully specified dashboard used to prove the gate can pass.
+data:
+ a:
+ source: "file://a.csv"
+ format: csv
+ refresh: 60s
+ b:
+ source: "file://b.csv"
+ format: csv
+ c:
+ source: "file://c.csv"
+ format: csv
+layout:
+ type: dashboard
+ columns: 12
+ sections:
+"#,
+ );
+ for section in 0..5 {
+ yaml.push_str(&format!(" - id: section-{section}\n widgets:\n"));
+ for widget in 0..4 {
+ yaml.push_str(&format!(
+ " - type: text\n id: w-{section}-{widget}\n"
+ ));
+ }
+ }
+ yaml
+}
+
+#[test]
+fn gate_rejects_a_threadbare_manifest() {
+ let out = gate(&manifest("threadbare.yaml", THREADBARE));
+ let stderr = String::from_utf8_lossy(&out.stderr);
+
+ assert!(
+ !out.status.success(),
+ "presentar gate exited 0 on a manifest with no description, no data \
+ sources and no widgets — the gate cannot fail. stdout:\n{}\nstderr:\n{stderr}",
+ String::from_utf8_lossy(&out.stdout)
+ );
+ assert!(
+ stderr.contains("GATE FAILED"),
+ "expected a GATE FAILED diagnostic on stderr, got:\n{stderr}"
+ );
+}
+
+#[test]
+fn gate_accepts_a_rich_manifest() {
+ let out = gate(&manifest("rich.yaml", &rich()));
+ let stdout = String::from_utf8_lossy(&out.stdout);
+
+ assert!(
+ out.status.success(),
+ "presentar gate rejected a fully specified manifest — the gate cannot \
+ pass. stdout:\n{stdout}\nstderr:\n{}",
+ String::from_utf8_lossy(&out.stderr)
+ );
+ assert!(
+ stdout.contains("GATE PASSED"),
+ "expected a GATE PASSED line on stdout, got:\n{stdout}"
+ );
+}
diff --git a/crates/aprender-ptx-debug/Cargo.toml b/crates/aprender-ptx-debug/Cargo.toml
index 5a027c716e..fd08e0dd15 100644
--- a/crates/aprender-ptx-debug/Cargo.toml
+++ b/crates/aprender-ptx-debug/Cargo.toml
@@ -15,6 +15,7 @@ name = "trueno_ptx_debug"
[dependencies]
thiserror = "2.0"
+clap.workspace = true
# Inherit workspace lints
[lints.rust]
diff --git a/crates/aprender-ptx-debug/src/bin/main.rs b/crates/aprender-ptx-debug/src/bin/main.rs
index 46c9d5182c..de5d61f86b 100644
--- a/crates/aprender-ptx-debug/src/bin/main.rs
+++ b/crates/aprender-ptx-debug/src/bin/main.rs
@@ -1,44 +1,46 @@
-//! trueno-ptx-debug CLI
+//! aprender-ptx-debug CLI
//!
//! Pure Rust PTX debugging and static analysis tool.
//!
//! Usage:
-//! trueno-ptx-debug analyze [--falsify] [--min-score N]
-//! trueno-ptx-debug gen-fkr [-o tests.rs]
+//! aprender-ptx-debug analyze [--falsify] [--min-score N]
+//! aprender-ptx-debug gen-fkr [-o tests.rs]
+//!
+//! Argument parsing is declarative and lives in `trueno_ptx_debug::cli`.
-use std::env;
use std::fs;
use std::process;
+// Imported anonymously: `clap::Parser` would otherwise collide with the PTX
+// `Parser` used below.
+use clap::Parser as _;
+
use trueno_ptx_debug::bugs::BugRegistry;
+use trueno_ptx_debug::cli::{
+ exit_code_for_parse_error, version_string, AnalyzeArgs, Cli, Command, GenFkrArgs,
+};
use trueno_ptx_debug::falsification::FalsificationRegistry;
use trueno_ptx_debug::output::{generate_fkr_tests, generate_html_report, AnalysisResult};
use trueno_ptx_debug::parser::Parser;
fn main() {
- let args: Vec = env::args().collect();
-
- if args.len() < 2 {
- print_usage();
- process::exit(1);
- }
-
- let result = match args[1].as_str() {
- "analyze" => cmd_analyze(&args[2..]),
- "gen-fkr" => cmd_gen_fkr(&args[2..]),
- "help" | "--help" | "-h" => {
- print_usage();
- Ok(())
+ let cli = match Cli::try_parse() {
+ Ok(cli) => cli,
+ Err(err) => {
+ // clap picks stdout for --help/--version and stderr for real
+ // failures; the exit code is chosen the same way.
+ let _ = err.print();
+ process::exit(exit_code_for_parse_error(&err));
}
- "version" | "--version" | "-V" => {
- println!("trueno-ptx-debug {}", env!("CARGO_PKG_VERSION"));
+ };
+
+ let result = match cli.command {
+ Command::Analyze(args) => cmd_analyze(args),
+ Command::GenFkr(args) => cmd_gen_fkr(args),
+ Command::Version => {
+ print!("{}", version_string());
Ok(())
}
- _ => {
- eprintln!("Unknown command: {}", args[1]);
- print_usage();
- process::exit(1);
- }
};
if let Err(e) = result {
@@ -47,137 +49,6 @@ fn main() {
}
}
-fn print_usage() {
- println!(
- r"trueno-ptx-debug - Pure Rust PTX debugging and static analysis tool
-
-USAGE:
- trueno-ptx-debug [OPTIONS]
-
-COMMANDS:
- analyze Analyze PTX file for bugs and issues
- --falsify Run full 100-point falsification framework
- --min-score N Fail if score < N (default: 70)
- --html Write HTML report to file
- --json Output JSON format
-
- gen-fkr Generate FKR tests for jugar-probar
- -o Output file (default: stdout)
-
- help Show this help message
- version Show version information
-
-EXIT CODES:
- 0 - Analysis passed (score >= 90)
- 1 - Analysis passed with warnings (score 70-89)
- 2 - Analysis failed (score < 70)
- 3 - Critical bugs detected
- 10 - Parse error
- 11 - I/O error
-
-EXAMPLES:
- trueno-ptx-debug analyze kernel.ptx --falsify
- trueno-ptx-debug analyze kernel.ptx --min-score 90 --html report.html
- trueno-ptx-debug gen-fkr kernel.ptx -o tests/kernel_fkr.rs
-"
- );
-}
-
-/// Parsed arguments for the `analyze` subcommand.
-struct AnalyzeArgs {
- file_path: String,
- #[allow(dead_code)]
- run_falsify: bool,
- min_score: f64,
- html_output: Option,
- json_output: bool,
-}
-
-/// Consume the next positional argument from `args[i+1]`, returning an error
-/// if the slice is exhausted. Returns the new index and the consumed value.
-fn consume_valued_option(args: &[String], i: usize, flag: &str) -> Result<(usize, String), String> {
- let next = i + 1;
- if next >= args.len() {
- return Err(format!("{} requires a value", flag));
- }
- Ok((next, args[next].clone()))
-}
-
-/// Apply a single CLI token to the in-progress `AnalyzeArgs` builder.
-/// Returns the (possibly advanced) index after consuming the token.
-fn apply_analyze_flag(
- args: &[String],
- i: usize,
- run_falsify: &mut bool,
- min_score: &mut f64,
- html_output: &mut Option,
- json_output: &mut bool,
- file_path: &mut Option,
-) -> Result {
- match args[i].as_str() {
- "--falsify" => {
- *run_falsify = true;
- Ok(i)
- }
- "--json" => {
- *json_output = true;
- Ok(i)
- }
- "--min-score" => {
- let (next, val) = consume_valued_option(args, i, "--min-score")?;
- *min_score = val
- .parse()
- .map_err(|_| "Invalid min-score value".to_string())?;
- Ok(next)
- }
- "--html" => {
- let (next, val) = consume_valued_option(args, i, "--html")?;
- *html_output = Some(val);
- Ok(next)
- }
- arg if !arg.starts_with('-') => {
- *file_path = Some(arg.to_string());
- Ok(i)
- }
- arg => Err(format!("Unknown option: {}", arg)),
- }
-}
-
-/// Parse the CLI arguments for the `analyze` subcommand.
-fn parse_analyze_args(args: &[String]) -> Result {
- if args.is_empty() {
- return Err("Missing PTX file argument".into());
- }
-
- let mut file_path = None;
- let mut run_falsify = false;
- let mut min_score = 70.0;
- let mut html_output = None;
- let mut json_output = false;
-
- let mut i = 0;
- while i < args.len() {
- i = apply_analyze_flag(
- args,
- i,
- &mut run_falsify,
- &mut min_score,
- &mut html_output,
- &mut json_output,
- &mut file_path,
- )?;
- i += 1;
- }
-
- Ok(AnalyzeArgs {
- file_path: file_path.ok_or("Missing PTX file argument")?,
- run_falsify,
- min_score,
- html_output,
- json_output,
- })
-}
-
/// Print analysis results as JSON.
fn print_json_report(
result: &AnalysisResult,
@@ -234,19 +105,18 @@ fn exit_for_score(
}
}
-fn cmd_analyze(args: &[String]) -> Result<(), String> {
- let opts = parse_analyze_args(args)?;
- let result = analyze_ptx_file(&opts.file_path)?;
+fn cmd_analyze(opts: AnalyzeArgs) -> Result<(), String> {
+ let result = analyze_ptx_file(&opts.file)?;
// Output results
- if opts.json_output {
+ if opts.json {
print_json_report(&result, &result.falsification_report);
} else {
print_text_report(&result, &result.falsification_report);
}
// Write HTML report if requested
- if let Some(html_path) = opts.html_output {
+ if let Some(html_path) = opts.html {
let html = generate_html_report(&result);
fs::write(&html_path, html).map_err(|e| format!("Failed to write {}: {}", html_path, e))?;
println!("\nHTML report written to: {}", html_path);
@@ -261,55 +131,6 @@ fn cmd_analyze(args: &[String]) -> Result<(), String> {
Ok(())
}
-/// Parsed arguments for the `gen-fkr` subcommand.
-struct GenFkrArgs {
- file_path: String,
- output_file: Option,
-}
-
-/// Apply a single CLI token to the in-progress `GenFkrArgs` builder.
-/// Returns the (possibly advanced) index after consuming the token.
-fn apply_gen_fkr_flag(
- args: &[String],
- i: usize,
- output_file: &mut Option,
- file_path: &mut Option,
-) -> Result {
- match args[i].as_str() {
- "-o" => {
- let (next, val) = consume_valued_option(args, i, "-o")?;
- *output_file = Some(val);
- Ok(next)
- }
- arg if !arg.starts_with('-') => {
- *file_path = Some(arg.to_string());
- Ok(i)
- }
- arg => Err(format!("Unknown option: {}", arg)),
- }
-}
-
-/// Parse the CLI arguments for the `gen-fkr` subcommand.
-fn parse_gen_fkr_args(args: &[String]) -> Result {
- if args.is_empty() {
- return Err("Missing PTX file argument".into());
- }
-
- let mut file_path = None;
- let mut output_file = None;
-
- let mut i = 0;
- while i < args.len() {
- i = apply_gen_fkr_flag(args, i, &mut output_file, &mut file_path)?;
- i += 1;
- }
-
- Ok(GenFkrArgs {
- file_path: file_path.ok_or("Missing PTX file argument")?,
- output_file,
- })
-}
-
/// Read a PTX file, parse it, run analysis, and return the result.
fn analyze_ptx_file(file_path: &str) -> Result {
let ptx_source = fs::read_to_string(file_path)
@@ -342,9 +163,8 @@ fn write_or_print(content: &str, output_path: Option, label: &str) -> Re
Ok(())
}
-fn cmd_gen_fkr(args: &[String]) -> Result<(), String> {
- let opts = parse_gen_fkr_args(args)?;
- let result = analyze_ptx_file(&opts.file_path)?;
+fn cmd_gen_fkr(opts: GenFkrArgs) -> Result<(), String> {
+ let result = analyze_ptx_file(&opts.file)?;
let fkr_tests = generate_fkr_tests(&result);
- write_or_print(&fkr_tests, opts.output_file, "FKR tests")
+ write_or_print(&fkr_tests, opts.output, "FKR tests")
}
diff --git a/crates/aprender-ptx-debug/src/cli.rs b/crates/aprender-ptx-debug/src/cli.rs
new file mode 100644
index 0000000000..1d8fc3a4f2
--- /dev/null
+++ b/crates/aprender-ptx-debug/src/cli.rs
@@ -0,0 +1,117 @@
+//! Declarative CLI definition for the `aprender-ptx-debug` binary.
+//!
+//! The parser lives in the library rather than in `src/bin/main.rs` so that
+//! integration tests can exercise it directly, matching the house pattern used
+//! by the other CLI crates in this workspace.
+//!
+//! Hand-rolled `match args[1]` dispatch is banned here: unknown flags fall
+//! through catch-all arms, a valued flag given without a value gets discarded,
+//! and an unparseable value degrades into a default instead of an error. clap
+//! derive makes each of those a hard parse failure.
+
+use clap::error::ErrorKind;
+use clap::{Args, CommandFactory, Parser, Subcommand};
+
+/// Trailing help text, preserved verbatim from the original usage banner.
+const AFTER_HELP: &str = "EXIT CODES:
+ 0 - Analysis passed (score >= 90)
+ 1 - Analysis passed with warnings (score 70-89)
+ 2 - Analysis failed (score < 70)
+ 3 - Critical bugs detected
+ 10 - Parse error
+ 11 - I/O error
+
+EXAMPLES:
+ aprender-ptx-debug analyze kernel.ptx --falsify
+ aprender-ptx-debug analyze kernel.ptx --min-score 90 --html report.html
+ aprender-ptx-debug gen-fkr kernel.ptx -o tests/kernel_fkr.rs";
+
+/// Top-level command line for `aprender-ptx-debug`.
+#[derive(Debug, Parser)]
+#[command(
+ name = "aprender-ptx-debug",
+ about = "Pure Rust PTX debugging and static analysis tool",
+ version,
+ subcommand_required = true,
+ arg_required_else_help = true,
+ after_help = AFTER_HELP
+)]
+pub struct Cli {
+ /// Subcommand to execute.
+ #[command(subcommand)]
+ pub command: Command,
+}
+
+/// Available subcommands.
+#[derive(Debug, Subcommand)]
+pub enum Command {
+ /// Analyze PTX file for bugs and issues
+ Analyze(AnalyzeArgs),
+
+ /// Generate FKR tests for jugar-probar
+ #[command(name = "gen-fkr")]
+ GenFkr(GenFkrArgs),
+
+ /// Show version information
+ Version,
+}
+
+/// Arguments for the `analyze` subcommand.
+#[derive(Debug, Args)]
+pub struct AnalyzeArgs {
+ /// PTX file to analyze
+ #[arg(value_name = "FILE")]
+ pub file: String,
+
+ /// Run full 100-point falsification framework.
+ ///
+ /// The framework is always evaluated by `analyze`, so this flag is accepted
+ /// for backwards compatibility and does not currently change the output.
+ #[arg(long)]
+ pub falsify: bool,
+
+ /// Fail if score < N
+ #[arg(long = "min-score", value_name = "N", default_value_t = 70.0)]
+ pub min_score: f64,
+
+ /// Write HTML report to file
+ #[arg(long, value_name = "FILE")]
+ pub html: Option,
+
+ /// Output JSON format
+ #[arg(long)]
+ pub json: bool,
+}
+
+/// Arguments for the `gen-fkr` subcommand.
+#[derive(Debug, Args)]
+pub struct GenFkrArgs {
+ /// PTX file to generate tests from
+ #[arg(value_name = "FILE")]
+ pub file: String,
+
+ /// Output file (default: stdout)
+ #[arg(short = 'o', value_name = "FILE")]
+ pub output: Option,
+}
+
+/// Render the version string used by both `--version` and the `version`
+/// subcommand, so the two surfaces cannot drift apart.
+#[must_use]
+pub fn version_string() -> String {
+ Cli::command().render_version()
+}
+
+/// Map a clap parse failure onto the process exit code.
+///
+/// `--help` and `--version` are reported by clap as errors but are successful
+/// invocations. Every other parse failure exits 1, preserving the exit status
+/// the hand-rolled parser used for an unknown command, a missing argument, or a
+/// bad option value.
+#[must_use]
+pub fn exit_code_for_parse_error(err: &clap::Error) -> i32 {
+ match err.kind() {
+ ErrorKind::DisplayHelp | ErrorKind::DisplayVersion => 0,
+ _ => 1,
+ }
+}
diff --git a/crates/aprender-ptx-debug/src/lib.rs b/crates/aprender-ptx-debug/src/lib.rs
index 6601435470..2108e7e157 100644
--- a/crates/aprender-ptx-debug/src/lib.rs
+++ b/crates/aprender-ptx-debug/src/lib.rs
@@ -44,6 +44,7 @@
pub mod analyzer;
pub mod bugs;
+pub mod cli;
pub mod falsification;
pub mod output;
pub mod parser;
diff --git a/crates/aprender-ptx-debug/tests/cli_args.rs b/crates/aprender-ptx-debug/tests/cli_args.rs
new file mode 100644
index 0000000000..240fbf0be5
--- /dev/null
+++ b/crates/aprender-ptx-debug/tests/cli_args.rs
@@ -0,0 +1,287 @@
+//! Falsification tests for the `aprender-ptx-debug` argument parser.
+//!
+//! This CLI used hand-rolled `match args[1]` dispatch. The identical pattern in
+//! a sibling crate silently dropped `--seed`: unknown flags fell through a
+//! catch-all arm, a flag given without a value was discarded, and an
+//! unparseable value became a default instead of an error. Each test below pins
+//! one of those failure modes to a hard parse error, so a regression back to a
+//! permissive parser turns the suite red.
+
+use clap::error::ErrorKind;
+use clap::{CommandFactory, Parser};
+use trueno_ptx_debug::cli::{exit_code_for_parse_error, AnalyzeArgs, Cli, Command, GenFkrArgs};
+
+fn parse(args: &[&str]) -> Result {
+ Cli::try_parse_from(args)
+}
+
+/// Parse arguments that are expected to fail, returning the error kind.
+fn parse_err_kind(args: &[&str]) -> ErrorKind {
+ match parse(args) {
+ Ok(cli) => panic!(
+ "expected `{}` to be rejected, parsed {cli:?}",
+ args.join(" ")
+ ),
+ Err(e) => e.kind(),
+ }
+}
+
+fn analyze_args(args: &[&str]) -> AnalyzeArgs {
+ match parse(args)
+ .unwrap_or_else(|e| panic!("expected `{}` to parse: {e}", args.join(" ")))
+ .command
+ {
+ Command::Analyze(a) => a,
+ other => panic!("expected `analyze`, got {other:?}"),
+ }
+}
+
+fn gen_fkr_args(args: &[&str]) -> GenFkrArgs {
+ match parse(args)
+ .unwrap_or_else(|e| panic!("expected `{}` to parse: {e}", args.join(" ")))
+ .command
+ {
+ Command::GenFkr(a) => a,
+ other => panic!("expected `gen-fkr`, got {other:?}"),
+ }
+}
+
+/// clap's own structural validation of the command tree.
+#[test]
+fn command_tree_is_valid() {
+ Cli::command().debug_assert();
+}
+
+// --- Failure mode 1: unknown flags must not be silently ignored -------------
+
+#[test]
+fn unknown_flag_is_rejected_not_ignored() {
+ // The literal defect from the sibling crate: `--seed` is not a flag of this
+ // CLI, so it must be an error rather than being dropped on the floor.
+ assert_eq!(
+ parse_err_kind(&["aprender-ptx-debug", "analyze", "k.ptx", "--seed", "42"]),
+ ErrorKind::UnknownArgument
+ );
+ assert_eq!(
+ parse_err_kind(&["aprender-ptx-debug", "analyze", "k.ptx", "--nope"]),
+ ErrorKind::UnknownArgument
+ );
+ assert_eq!(
+ parse_err_kind(&["aprender-ptx-debug", "gen-fkr", "k.ptx", "--seed", "42"]),
+ ErrorKind::UnknownArgument
+ );
+}
+
+#[test]
+fn unknown_short_flag_is_rejected() {
+ // `-o` belongs to gen-fkr only; analyze must not quietly accept it.
+ assert_eq!(
+ parse_err_kind(&["aprender-ptx-debug", "analyze", "k.ptx", "-o", "out.rs"]),
+ ErrorKind::UnknownArgument
+ );
+}
+
+#[test]
+fn extra_positional_is_rejected() {
+ // The hand-rolled parser let the last positional silently win.
+ assert_eq!(
+ parse_err_kind(&["aprender-ptx-debug", "analyze", "a.ptx", "b.ptx"]),
+ ErrorKind::UnknownArgument
+ );
+}
+
+#[test]
+fn unknown_subcommand_is_rejected() {
+ assert_eq!(
+ parse_err_kind(&["aprender-ptx-debug", "bogus"]),
+ ErrorKind::InvalidSubcommand
+ );
+}
+
+// --- Failure mode 2: a valued flag given without a value must be an error ---
+
+#[test]
+fn valued_flag_without_value_is_rejected() {
+ for args in [
+ &["aprender-ptx-debug", "analyze", "k.ptx", "--min-score"][..],
+ &["aprender-ptx-debug", "analyze", "k.ptx", "--html"][..],
+ &["aprender-ptx-debug", "gen-fkr", "k.ptx", "-o"][..],
+ ] {
+ assert_eq!(
+ parse_err_kind(args),
+ ErrorKind::InvalidValue,
+ "`{}` must not discard the dangling flag",
+ args.join(" ")
+ );
+ }
+}
+
+#[test]
+fn missing_required_file_is_rejected() {
+ assert_eq!(
+ parse_err_kind(&["aprender-ptx-debug", "analyze"]),
+ ErrorKind::MissingRequiredArgument
+ );
+ assert_eq!(
+ parse_err_kind(&["aprender-ptx-debug", "gen-fkr"]),
+ ErrorKind::MissingRequiredArgument
+ );
+}
+
+#[test]
+fn no_arguments_is_rejected() {
+ assert_eq!(
+ parse_err_kind(&["aprender-ptx-debug"]),
+ ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
+ );
+}
+
+// --- Failure mode 3: an unparseable value must error, not fall back ---------
+
+#[test]
+fn unparseable_min_score_is_an_error_not_the_default() {
+ assert_eq!(
+ parse_err_kind(&[
+ "aprender-ptx-debug",
+ "analyze",
+ "k.ptx",
+ "--min-score",
+ "notanumber",
+ ]),
+ ErrorKind::ValueValidation
+ );
+
+ // Positive control: the flag really is wired up, so the assertion above
+ // cannot be passing merely because `--min-score` is ignored outright.
+ let ok = analyze_args(&[
+ "aprender-ptx-debug",
+ "analyze",
+ "k.ptx",
+ "--min-score",
+ "91.5",
+ ]);
+ assert!(
+ (ok.min_score - 91.5).abs() < f64::EPSILON,
+ "min_score should be 91.5, got {}",
+ ok.min_score
+ );
+ assert!(
+ (ok.min_score - 70.0).abs() > f64::EPSILON,
+ "min_score must not fall back to the 70.0 default"
+ );
+}
+
+// --- Every subcommand is reachable -----------------------------------------
+
+#[test]
+fn every_subcommand_is_reachable() {
+ assert!(matches!(
+ parse(&["aprender-ptx-debug", "analyze", "k.ptx"]).map(|c| c.command),
+ Ok(Command::Analyze(_))
+ ));
+ assert!(matches!(
+ parse(&["aprender-ptx-debug", "gen-fkr", "k.ptx"]).map(|c| c.command),
+ Ok(Command::GenFkr(_))
+ ));
+ assert!(matches!(
+ parse(&["aprender-ptx-debug", "version"]).map(|c| c.command),
+ Ok(Command::Version)
+ ));
+ // `help`, `--help` and `--version` are reported by clap as errors that the
+ // binary turns into a successful exit.
+ assert_eq!(
+ parse_err_kind(&["aprender-ptx-debug", "help"]),
+ ErrorKind::DisplayHelp
+ );
+ assert_eq!(
+ parse_err_kind(&["aprender-ptx-debug", "--help"]),
+ ErrorKind::DisplayHelp
+ );
+ assert_eq!(
+ parse_err_kind(&["aprender-ptx-debug", "-h"]),
+ ErrorKind::DisplayHelp
+ );
+ assert_eq!(
+ parse_err_kind(&["aprender-ptx-debug", "--version"]),
+ ErrorKind::DisplayVersion
+ );
+ assert_eq!(
+ parse_err_kind(&["aprender-ptx-debug", "-V"]),
+ ErrorKind::DisplayVersion
+ );
+}
+
+// --- Flags and defaults preserved from the hand-rolled parser ---------------
+
+#[test]
+fn analyze_defaults_match_the_documented_behaviour() {
+ let args = analyze_args(&["aprender-ptx-debug", "analyze", "kernel.ptx"]);
+ assert_eq!(args.file, "kernel.ptx");
+ assert!(!args.falsify);
+ assert!(!args.json);
+ assert_eq!(args.html, None);
+ assert!(
+ (args.min_score - 70.0).abs() < f64::EPSILON,
+ "documented default min-score is 70, got {}",
+ args.min_score
+ );
+}
+
+#[test]
+fn analyze_accepts_every_documented_flag() {
+ let args = analyze_args(&[
+ "aprender-ptx-debug",
+ "analyze",
+ "kernel.ptx",
+ "--falsify",
+ "--min-score",
+ "90",
+ "--html",
+ "report.html",
+ "--json",
+ ]);
+ assert_eq!(args.file, "kernel.ptx");
+ assert!(args.falsify);
+ assert!(args.json);
+ assert_eq!(args.html.as_deref(), Some("report.html"));
+ assert!((args.min_score - 90.0).abs() < f64::EPSILON);
+}
+
+#[test]
+fn gen_fkr_accepts_its_output_flag() {
+ let defaults = gen_fkr_args(&["aprender-ptx-debug", "gen-fkr", "kernel.ptx"]);
+ assert_eq!(defaults.file, "kernel.ptx");
+ assert_eq!(defaults.output, None, "gen-fkr defaults to stdout");
+
+ let with_output = gen_fkr_args(&[
+ "aprender-ptx-debug",
+ "gen-fkr",
+ "kernel.ptx",
+ "-o",
+ "tests/kernel_fkr.rs",
+ ]);
+ assert_eq!(with_output.output.as_deref(), Some("tests/kernel_fkr.rs"));
+}
+
+// --- Exit code mapping ------------------------------------------------------
+
+#[test]
+fn help_and_version_exit_zero_every_other_parse_failure_exits_one() {
+ let code = |args: &[&str]| match parse(args) {
+ Ok(_) => panic!("`{}` should not parse cleanly", args.join(" ")),
+ Err(e) => exit_code_for_parse_error(&e),
+ };
+
+ assert_eq!(code(&["aprender-ptx-debug", "--help"]), 0);
+ assert_eq!(code(&["aprender-ptx-debug", "help"]), 0);
+ assert_eq!(code(&["aprender-ptx-debug", "--version"]), 0);
+
+ // Preserved from the hand-rolled parser: usage failures exit 1.
+ assert_eq!(code(&["aprender-ptx-debug"]), 1);
+ assert_eq!(code(&["aprender-ptx-debug", "bogus"]), 1);
+ assert_eq!(code(&["aprender-ptx-debug", "analyze"]), 1);
+ assert_eq!(
+ code(&["aprender-ptx-debug", "analyze", "k.ptx", "--seed", "42"]),
+ 1
+ );
+}
diff --git a/crates/aprender-ptx-debug/tests/cli_binary.rs b/crates/aprender-ptx-debug/tests/cli_binary.rs
new file mode 100644
index 0000000000..69ee3e03c7
--- /dev/null
+++ b/crates/aprender-ptx-debug/tests/cli_binary.rs
@@ -0,0 +1,107 @@
+//! End-to-end checks that the parsed command actually reaches its handler.
+//!
+//! `tests/cli_args.rs` proves the argument grammar; these tests prove the
+//! dispatch behind it, so a subcommand cannot be parsed correctly and then
+//! wired to nothing.
+
+use std::process::Command;
+
+/// Path to the freshly built binary, supplied by cargo. Never resolve a binary
+/// through `$PATH` or a hardcoded path.
+const BIN: &str = env!("CARGO_BIN_EXE_aprender-ptx-debug");
+
+/// A path that cannot exist, used to reach a handler without a PTX fixture:
+/// only the handler itself can produce the "Failed to read" diagnostic.
+const MISSING_PTX: &str = "/nonexistent/aprender-ptx-debug/fixture.ptx";
+
+struct Run {
+ code: Option,
+ stdout: String,
+ stderr: String,
+}
+
+fn run(args: &[&str]) -> Run {
+ let out = Command::new(BIN)
+ .args(args)
+ .output()
+ .unwrap_or_else(|e| panic!("failed to spawn {BIN}: {e}"));
+ Run {
+ code: out.status.code(),
+ stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
+ stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
+ }
+}
+
+#[test]
+fn unknown_flag_fails_the_process() {
+ let r = run(&["analyze", "kernel.ptx", "--seed", "42"]);
+ assert_eq!(r.code, Some(1), "stderr: {}", r.stderr);
+ assert!(
+ r.stderr.contains("--seed"),
+ "the rejected flag should be named; stderr: {}",
+ r.stderr
+ );
+ assert!(
+ !r.stdout.contains("PTX Analysis Report"),
+ "analysis must not run when parsing failed; stdout: {}",
+ r.stdout
+ );
+}
+
+#[test]
+fn no_arguments_exits_one() {
+ let r = run(&[]);
+ assert_eq!(r.code, Some(1));
+}
+
+#[test]
+fn analyze_subcommand_reaches_its_handler() {
+ let r = run(&["analyze", MISSING_PTX]);
+ assert_eq!(r.code, Some(1), "stderr: {}", r.stderr);
+ assert!(
+ r.stderr.contains("Failed to read"),
+ "analyze should have reached the file read; stderr: {}",
+ r.stderr
+ );
+}
+
+#[test]
+fn gen_fkr_subcommand_reaches_its_handler() {
+ let r = run(&["gen-fkr", MISSING_PTX]);
+ assert_eq!(r.code, Some(1), "stderr: {}", r.stderr);
+ assert!(
+ r.stderr.contains("Failed to read"),
+ "gen-fkr should have reached the file read; stderr: {}",
+ r.stderr
+ );
+}
+
+#[test]
+fn version_subcommand_and_version_flag_do_not_drift() {
+ let sub = run(&["version"]);
+ let flag = run(&["--version"]);
+ assert_eq!(sub.code, Some(0), "stderr: {}", sub.stderr);
+ assert_eq!(flag.code, Some(0), "stderr: {}", flag.stderr);
+ assert!(
+ sub.stdout.contains(env!("CARGO_PKG_VERSION")),
+ "stdout: {}",
+ sub.stdout
+ );
+ assert_eq!(
+ sub.stdout, flag.stdout,
+ "`version` and `--version` must print the same string"
+ );
+}
+
+#[test]
+fn help_lists_every_subcommand() {
+ let r = run(&["help"]);
+ assert_eq!(r.code, Some(0), "stderr: {}", r.stderr);
+ for expected in ["analyze", "gen-fkr", "version", "EXIT CODES", "EXAMPLES"] {
+ assert!(
+ r.stdout.contains(expected),
+ "help should mention `{expected}`; stdout: {}",
+ r.stdout
+ );
+ }
+}
diff --git a/crates/aprender-serve/tests/integration_cli.rs b/crates/aprender-serve/tests/integration_cli.rs
deleted file mode 100644
index 87d3e50ef0..0000000000
--- a/crates/aprender-serve/tests/integration_cli.rs
+++ /dev/null
@@ -1,423 +0,0 @@
-//! Integration tests for CLI binary
-//!
-//! T-COV-95 In-Process Integration: Black Box Falsification (PMAT-802)
-//!
-//! Dr. Popper's directive: "Stop unit-testing helpers. Use std::process::Command
-//! to invoke the compiled binary. This is 'Black Box Falsification.'"
-//!
-//! These tests verify the `realizar` CLI commands work correctly by invoking
-//! the actual binary with real arguments and real files.
-
-#![allow(deprecated)]
-
-use std::io::Write;
-use std::process::Command;
-
-use assert_cmd::{assert::OutputAssertExt, cargo::CommandCargoExt};
-use predicates::prelude::*;
-use tempfile::NamedTempFile;
-
-#[test]
-fn test_cli_help() {
- let mut cmd = Command::cargo_bin("realizar").expect("test");
- cmd.arg("--help");
- cmd.assert()
- .success()
- .stdout(predicate::str::contains("Usage: realizar"));
-}
-
-#[test]
-fn test_cli_info_command() {
- let mut cmd = Command::cargo_bin("realizar").expect("test");
- cmd.arg("info");
- cmd.assert()
- .success()
- .stdout(predicate::str::contains("Realizar"))
- .stdout(predicate::str::contains("v0.")); // Accept any v0.x.y version
-}
-
-#[test]
-fn test_cli_serve_requires_demo_or_model() {
- let mut cmd = Command::cargo_bin("realizar").expect("test");
- cmd.arg("serve");
- // Without --demo flag, it should fail since no model path is provided
- cmd.assert().failure();
-}
-
-#[test]
-fn test_cli_serve_help() {
- let mut cmd = Command::cargo_bin("realizar").expect("test");
- cmd.arg("serve").arg("--help");
- cmd.assert()
- .success()
- .stdout(predicate::str::contains("--demo"))
- .stdout(predicate::str::contains("--port"))
- .stdout(predicate::str::contains("--host"));
-}
-
-#[test]
-fn test_cli_serve_invalid_port() {
- let mut cmd = Command::cargo_bin("realizar").expect("test");
- cmd.arg("serve").arg("--demo").arg("--port").arg("invalid");
- cmd.assert().failure();
-}
-
-#[test]
-fn test_cli_unknown_command() {
- let mut cmd = Command::cargo_bin("realizar").expect("test");
- cmd.arg("unknown");
- cmd.assert().failure();
-}
-
-#[test]
-fn test_cli_version_flag() {
- let mut cmd = Command::cargo_bin("realizar").expect("test");
- cmd.arg("--version");
- cmd.assert()
- .success()
- .stdout(predicate::str::contains("realizar"));
-}
-
-// ============================================================================
-// T-COV-95: Black Box Falsification - Benchmark Commands
-// ============================================================================
-
-#[test]
-fn test_cli_bench_list() {
- let mut cmd = Command::cargo_bin("realizar").expect("test");
- cmd.arg("bench").arg("--list");
- cmd.assert()
- .success()
- .stdout(predicate::str::contains("tensor_ops"))
- .stdout(predicate::str::contains("inference"))
- .stdout(predicate::str::contains("cache"));
-}
-
-#[test]
-fn test_cli_bench_help() {
- let mut cmd = Command::cargo_bin("realizar").expect("test");
- cmd.arg("bench").arg("--help");
- cmd.assert()
- .success()
- .stdout(predicate::str::contains("SUITE"))
- .stdout(predicate::str::contains("--list"));
-}
-
-// ============================================================================
-// T-COV-95: Black Box Falsification - Viz Command
-// ============================================================================
-
-#[test]
-fn test_cli_viz_command() {
- let mut cmd = Command::cargo_bin("realizar").expect("test");
- cmd.arg("viz").arg("--samples").arg("10");
- cmd.assert()
- .success()
- .stdout(predicate::str::contains("Visualization"));
-}
-
-#[test]
-fn test_cli_viz_with_color() {
- let mut cmd = Command::cargo_bin("realizar").expect("test");
- cmd.arg("viz").arg("--color").arg("--samples").arg("5");
- cmd.assert()
- .success()
- .stdout(predicate::str::contains("Visualization"));
-}
-
-#[test]
-fn test_cli_viz_help() {
- let mut cmd = Command::cargo_bin("realizar").expect("test");
- cmd.arg("viz").arg("--help");
- cmd.assert()
- .success()
- .stdout(predicate::str::contains("--color"))
- .stdout(predicate::str::contains("--samples"));
-}
-
-// ============================================================================
-// T-COV-95: Black Box Falsification - List Command
-// ============================================================================
-
-#[test]
-fn test_cli_list_help() {
- let mut cmd = Command::cargo_bin("realizar").expect("test");
- cmd.arg("list").arg("--help");
- cmd.assert()
- .success()
- .stdout(predicate::str::contains("--format"))
- .stdout(predicate::str::contains("--remote"));
-}
-
-#[test]
-fn test_cli_list_json_format() {
- let mut cmd = Command::cargo_bin("realizar").expect("test");
- cmd.arg("list").arg("--format").arg("json");
- // May succeed or fail depending on model directory, but exercises code
- let output = cmd.output().expect("run");
- // Code path was exercised regardless of exit status
- assert!(output.status.success() || !output.status.success());
-}
-
-#[test]
-fn test_cli_list_table_format() {
- let mut cmd = Command::cargo_bin("realizar").expect("test");
- cmd.arg("list").arg("--format").arg("table");
- let output = cmd.output().expect("run");
- assert!(output.status.success() || !output.status.success());
-}
-
-// ============================================================================
-// T-COV-95: Black Box Falsification - Run Command Error Paths
-// ============================================================================
-
-#[test]
-fn test_cli_run_nonexistent_model() {
- let mut cmd = Command::cargo_bin("realizar").expect("test");
- cmd.arg("run")
- .arg("/nonexistent/model.gguf")
- .arg("--prompt")
- .arg("Hello");
- cmd.assert().failure().stderr(
- predicate::str::contains("error")
- .or(predicate::str::contains("Error"))
- .or(predicate::str::contains("not found")),
- );
-}
-
-#[test]
-fn test_cli_run_help() {
- let mut cmd = Command::cargo_bin("realizar").expect("test");
- cmd.arg("run").arg("--help");
- cmd.assert()
- .success()
- .stdout(predicate::str::contains("PROMPT"))
- .stdout(predicate::str::contains("max-tokens"))
- .stdout(predicate::str::contains("temperature"));
-}
-
-// ============================================================================
-// T-COV-95: Black Box Falsification - Chat Command
-// ============================================================================
-
-#[test]
-fn test_cli_chat_help() {
- let mut cmd = Command::cargo_bin("realizar").expect("test");
- cmd.arg("chat").arg("--help");
- cmd.assert()
- .success()
- .stdout(predicate::str::contains("--system"))
- .stdout(predicate::str::contains("--history"));
-}
-
-#[test]
-fn test_cli_chat_nonexistent_model() {
- let mut cmd = Command::cargo_bin("realizar").expect("test");
- cmd.arg("chat").arg("/nonexistent/model.gguf");
- cmd.assert().failure();
-}
-
-// ============================================================================
-// T-COV-95: Black Box Falsification - Pull/Push Commands
-// ============================================================================
-
-#[test]
-fn test_cli_pull_help() {
- let mut cmd = Command::cargo_bin("realizar").expect("test");
- cmd.arg("pull").arg("--help");
- cmd.assert()
- .success()
- .stdout(predicate::str::contains("--force"))
- .stdout(predicate::str::contains("--quantize"));
-}
-
-#[test]
-fn test_cli_push_help() {
- let mut cmd = Command::cargo_bin("realizar").expect("test");
- cmd.arg("push").arg("--help");
- cmd.assert()
- .success()
- .stdout(predicate::str::contains("--to"));
-}
-
-// ============================================================================
-// T-COV-95: Black Box Falsification - Bench Compare/Regression
-// ============================================================================
-
-#[test]
-fn test_cli_bench_compare_nonexistent() {
- let mut cmd = Command::cargo_bin("realizar").expect("test");
- cmd.arg("bench-compare")
- .arg("/nonexistent/file1.json")
- .arg("/nonexistent/file2.json");
- cmd.assert().failure();
-}
-
-#[test]
-fn test_cli_bench_compare_help() {
- let mut cmd = Command::cargo_bin("realizar").expect("test");
- cmd.arg("bench-compare").arg("--help");
- cmd.assert()
- .success()
- .stdout(predicate::str::contains("threshold"));
-}
-
-#[test]
-fn test_cli_bench_regression_help() {
- let mut cmd = Command::cargo_bin("realizar").expect("test");
- cmd.arg("bench-regression").arg("--help");
- cmd.assert()
- .success()
- .stdout(predicate::str::contains("--strict"))
- .stdout(predicate::str::contains("baseline"))
- .stdout(predicate::str::contains("current"));
-}
-
-#[test]
-fn test_cli_bench_regression_nonexistent() {
- let mut cmd = Command::cargo_bin("realizar").expect("test");
- cmd.arg("bench-regression")
- .arg("/nonexistent/baseline.json")
- .arg("/nonexistent/current.json");
- cmd.assert().failure();
-}
-
-// ============================================================================
-// T-COV-95: Black Box Falsification - Bench Convoy/Saturation
-// ============================================================================
-
-#[test]
-fn test_cli_bench_convoy_help() {
- let mut cmd = Command::cargo_bin("realizar").expect("test");
- cmd.arg("bench-convoy").arg("--help");
- cmd.assert()
- .success()
- .stdout(predicate::str::contains("--runtime"))
- .stdout(predicate::str::contains("--model"));
-}
-
-#[test]
-fn test_cli_bench_saturation_help() {
- let mut cmd = Command::cargo_bin("realizar").expect("test");
- cmd.arg("bench-saturation").arg("--help");
- cmd.assert()
- .success()
- .stdout(predicate::str::contains("--runtime"))
- .stdout(predicate::str::contains("--model"));
-}
-
-// ============================================================================
-// T-COV-95: Black Box Falsification - Active Pygmy Model Tests
-// ============================================================================
-
-/// Create a minimal valid GGUF file (Active Pygmy) for testing
-fn create_active_pygmy_gguf() -> NamedTempFile {
- let mut temp = NamedTempFile::with_suffix(".gguf").expect("create temp file");
-
- // Minimal GGUF header: magic + version + tensor_count + metadata_count
- let magic: u32 = 0x46554747; // "GGUF"
- let version: u32 = 3;
- let tensor_count: u64 = 0;
- let metadata_count: u64 = 0;
-
- temp.write_all(&magic.to_le_bytes()).unwrap();
- temp.write_all(&version.to_le_bytes()).unwrap();
- temp.write_all(&tensor_count.to_le_bytes()).unwrap();
- temp.write_all(&metadata_count.to_le_bytes()).unwrap();
- temp.flush().unwrap();
-
- temp
-}
-
-#[test]
-fn test_cli_run_with_pygmy_gguf() {
- let pygmy = create_active_pygmy_gguf();
-
- let mut cmd = Command::cargo_bin("realizar").expect("test");
- cmd.arg("run")
- .arg(pygmy.path())
- .arg("--prompt")
- .arg("test")
- .arg("--max-tokens")
- .arg("1");
-
- // Will fail because pygmy has no tensors, but exercises the code path
- let output = cmd.output().expect("run");
- // Failure is expected - the important thing is the CLI code ran
- assert!(!output.status.success() || output.status.success());
-}
-
-#[test]
-fn test_cli_chat_with_pygmy_gguf() {
- let pygmy = create_active_pygmy_gguf();
-
- let mut cmd = Command::cargo_bin("realizar").expect("test");
- cmd.arg("chat").arg(pygmy.path());
-
- // Will fail parsing, but exercises the code path
- let output = cmd.output().expect("run");
- assert!(!output.status.success() || output.status.success());
-}
-
-// ============================================================================
-// T-COV-95: Black Box Falsification - Poisoned File Tests
-// ============================================================================
-
-/// Create a corrupted GGUF file (Poisoned Pygmy)
-fn create_poisoned_gguf() -> NamedTempFile {
- let mut temp = NamedTempFile::with_suffix(".gguf").expect("create temp file");
- // Write garbage that looks like it might be GGUF but isn't
- temp.write_all(b"GGUF\x00\x00\x00\x03CORRUPTED_DATA_HERE")
- .unwrap();
- temp.flush().unwrap();
- temp
-}
-
-#[test]
-fn test_cli_run_with_poisoned_gguf() {
- let poisoned = create_poisoned_gguf();
-
- let mut cmd = Command::cargo_bin("realizar").expect("test");
- cmd.arg("run")
- .arg(poisoned.path())
- .arg("--prompt")
- .arg("test");
-
- // Should fail gracefully with error message
- cmd.assert().failure();
-}
-
-/// Create a file with wrong extension
-fn create_wrong_extension_file() -> NamedTempFile {
- let mut temp = NamedTempFile::with_suffix(".txt").expect("create temp file");
- temp.write_all(b"This is not a model file").unwrap();
- temp.flush().unwrap();
- temp
-}
-
-#[test]
-fn test_cli_run_with_wrong_extension() {
- let wrong = create_wrong_extension_file();
-
- let mut cmd = Command::cargo_bin("realizar").expect("test");
- cmd.arg("run").arg(wrong.path()).arg("--prompt").arg("test");
-
- // Should handle gracefully
- let output = cmd.output().expect("run");
- assert!(!output.status.success() || output.status.success());
-}
-
-// ============================================================================
-// T-COV-95: Black Box Falsification - Empty File Tests
-// ============================================================================
-
-#[test]
-fn test_cli_run_with_empty_file() {
- let temp = NamedTempFile::with_suffix(".gguf").expect("create temp file");
- // File is empty
-
- let mut cmd = Command::cargo_bin("realizar").expect("test");
- cmd.arg("run").arg(temp.path()).arg("--prompt").arg("test");
-
- cmd.assert().failure();
-}
diff --git a/crates/aprender-shell/Cargo.toml b/crates/aprender-shell/Cargo.toml
index 87dc2bd283..ca1f9bbecf 100644
--- a/crates/aprender-shell/Cargo.toml
+++ b/crates/aprender-shell/Cargo.toml
@@ -38,9 +38,10 @@ format-compression = []
format-encryption = []
[dev-dependencies]
-assert_cmd = "2"
+# assert_cmd/predicates dropped with the CLI test targets: this crate has no
+# [[bin]] (removed by contracts/apr-mono-binary-rule-v1.yaml), so there is no
+# binary left to drive.
criterion = { workspace = true }
-predicates = "3"
proptest = "1"
tempfile = "3"
# Note: Chaos testing uses renacer CLI tool (not library)
diff --git a/crates/aprender-shell/src/lib.rs b/crates/aprender-shell/src/lib.rs
index 13651e1e04..75fb2de03e 100644
--- a/crates/aprender-shell/src/lib.rs
+++ b/crates/aprender-shell/src/lib.rs
@@ -26,6 +26,10 @@ pub mod synthetic;
pub mod trie;
pub mod validation;
+// Library-level robustness suite salvaged from the deleted CLI test targets.
+#[cfg(test)]
+mod robustness_tests;
+
// Re-exports for convenience
pub use config::{suggest_with_fallback, ShellConfig};
pub use error::ShellError;
diff --git a/crates/aprender-shell/src/robustness_tests.rs b/crates/aprender-shell/src/robustness_tests.rs
new file mode 100644
index 0000000000..6e1959c9db
--- /dev/null
+++ b/crates/aprender-shell/src/robustness_tests.rs
@@ -0,0 +1,323 @@
+//! Robustness tests salvaged from the deleted `aprender-shell` CLI test suites.
+//!
+//! The `aprender-shell` binary was removed in f5db50ae0 ("enforce Rule 1 --
+//! delete 7 unauthorized bins") per `contracts/apr-mono-binary-rule-v1.yaml`.
+//! Its `tests/cli_integration.rs`, `tests/real_world_tests.rs` and
+//! `tests/performance_tests.rs` kept driving `Command::cargo_bin("aprender-shell")`
+//! and had been failing (or silently `#[ignore]`d) ever since.
+//!
+//! The assertions that were about *library* behaviour rather than argv plumbing
+//! are re-expressed here against the public API, so they run under `--lib` — the
+//! only aprender-shell target CI actually executes.
+
+use crate::config::{suggest_with_fallback, ShellConfig};
+use crate::error::ShellError;
+use crate::model::MarkovModel;
+use crate::paged_model::PagedMarkovModel;
+use crate::validation::load_model_graceful;
+use std::io::Write;
+use tempfile::NamedTempFile;
+
+// Benchmark fixtures, previously loaded by tests/real_world_tests.rs.
+const SMALL_HISTORY: &str = include_str!("../benches/fixtures/small_history.txt");
+const MEDIUM_HISTORY: &str = include_str!("../benches/fixtures/medium_history.txt");
+const LARGE_HISTORY: &str = include_str!("../benches/fixtures/large_history.txt");
+
+/// Strip comments and blank lines from a fixture, as the CLI's history parser did.
+fn fixture_commands(content: &str) -> Vec {
+ content
+ .lines()
+ .map(str::trim)
+ .filter(|line| !line.is_empty() && !line.starts_with('#'))
+ .map(ToString::to_string)
+ .collect()
+}
+
+/// Train an in-memory model on a fixture corpus.
+fn train_on(content: &str) -> MarkovModel {
+ let mut model = MarkovModel::new(3);
+ model.train(&fixture_commands(content));
+ model
+}
+
+// =========================================================================
+// Chaos: malformed model files must degrade, never panic (was CLI_021)
+// =========================================================================
+
+/// A file holding only the APR magic bytes is a *corrupt* model, not a missing one.
+#[test]
+fn test_truncated_model_is_corrupt_not_missing() {
+ let mut tmp = NamedTempFile::new().expect("create temp file");
+ tmp.write_all(b"APRN").expect("write magic");
+ tmp.flush().expect("flush");
+
+ let result = load_model_graceful(tmp.path());
+
+ // The file exists, so ModelNotFound would be a misdiagnosis.
+ assert!(
+ matches!(
+ result,
+ Err(ShellError::ModelCorrupted { .. }) | Err(ShellError::ModelLoadFailed { .. })
+ ),
+ "truncated model must report corruption, got {:?}",
+ result.map(|_| "Ok(model)")
+ );
+}
+
+/// Wrong magic bytes must be rejected rather than parsed as a body.
+#[test]
+fn test_wrong_magic_bytes_rejected() {
+ let mut tmp = NamedTempFile::new().expect("create temp file");
+ tmp.write_all(b"XXXX12345678901234567890")
+ .expect("write bad magic");
+ tmp.flush().expect("flush");
+
+ let result = load_model_graceful(tmp.path());
+
+ assert!(
+ matches!(
+ result,
+ Err(ShellError::ModelCorrupted { .. }) | Err(ShellError::ModelLoadFailed { .. })
+ ),
+ "wrong magic must report corruption, got {:?}",
+ result.map(|_| "Ok(model)")
+ );
+}
+
+// =========================================================================
+// Chaos: adversarial prefixes (was CLI_021)
+// =========================================================================
+
+/// A 10 KB prefix must actually be truncated to the configured bound.
+#[test]
+fn test_oversized_prefix_is_truncated_to_bound() {
+ let config = ShellConfig::default();
+ let long_prefix = "g".repeat(10_000);
+
+ let truncated = config.truncate_prefix(&long_prefix);
+
+ assert_eq!(
+ truncated.len(),
+ config.max_prefix_length,
+ "oversized prefix was not truncated to max_prefix_length"
+ );
+
+ // And the full pipeline still honours the suggestion cap.
+ let model = train_on(SMALL_HISTORY);
+ let suggestions = suggest_with_fallback(&long_prefix, Some(&model), &config);
+ assert!(suggestions.len() <= config.max_suggestions);
+}
+
+/// Truncation must land on a UTF-8 char boundary, not slice through a codepoint.
+#[test]
+fn test_truncation_backs_off_to_char_boundary() {
+ // 100 x "é" = 200 bytes; byte 101 is mid-codepoint.
+ let prefix = "é".repeat(100);
+ let config = ShellConfig::default().with_max_prefix_length(101);
+
+ let truncated = config.truncate_prefix(&prefix);
+
+ assert_eq!(
+ truncated.len(),
+ 100,
+ "truncation must back off from the mid-codepoint boundary at 101"
+ );
+ assert_eq!(truncated.chars().count(), 50);
+}
+
+/// Unicode edge cases must flow through the suggestion pipeline without panicking.
+///
+/// The bound is deliberately 7 bytes so that several of these prefixes are cut
+/// mid-codepoint — a naive `&prefix[..max]` slice panics here.
+#[test]
+fn test_unicode_prefixes_handled_gracefully() {
+ let model = train_on(SMALL_HISTORY);
+ let config = ShellConfig::default().with_max_prefix_length(7);
+
+ let cases = [
+ "🚀".to_string(), // emoji
+ "日本語".to_string(), // CJK (9 bytes, cut at 7)
+ "مرحبا".to_string(), // RTL (10 bytes, cut at 7)
+ "\u{FEFF}git".to_string(), // BOM
+ "git\u{200B}status".to_string(), // zero-width space
+ "git\u{202E}status".to_string(), // RTL override
+ "é".repeat(100), // many multi-byte chars (200 bytes)
+ ];
+
+ for prefix in &cases {
+ let truncated = config.truncate_prefix(prefix);
+ assert!(
+ truncated.len() <= config.max_prefix_length,
+ "prefix {prefix:?} exceeded the byte bound after truncation"
+ );
+ assert!(
+ prefix.starts_with(truncated),
+ "prefix {prefix:?} truncated to a non-prefix {truncated:?}"
+ );
+
+ let suggestions = suggest_with_fallback(prefix, Some(&model), &config);
+ assert!(
+ suggestions.len() <= config.max_suggestions,
+ "prefix {prefix:?} exceeded the suggestion cap"
+ );
+ assert!(
+ suggestions.iter().all(|(s, _)| !s.is_empty()),
+ "prefix {prefix:?} produced an empty suggestion"
+ );
+ }
+}
+
+// =========================================================================
+// Chaos: concurrent readers of one model file (was CLI_021)
+// =========================================================================
+
+/// Five threads loading the same model file must all see identical suggestions.
+#[test]
+fn test_concurrent_readers_agree() {
+ let model = train_on(MEDIUM_HISTORY);
+ let path = NamedTempFile::new().expect("create model file");
+ model.save(path.path()).expect("save model");
+
+ // Ask for far more than exist so score ties cannot change the set.
+ let reference: std::collections::BTreeSet = MarkovModel::load(path.path())
+ .expect("load model")
+ .suggest("git ", 1000)
+ .into_iter()
+ .map(|(s, _)| s)
+ .collect();
+ assert!(
+ !reference.is_empty(),
+ "medium fixture must yield git suggestions"
+ );
+
+ let model_path = path.path().to_path_buf();
+ let handles: Vec<_> = (0..5)
+ .map(|_| {
+ let model_path = model_path.clone();
+ let reference = reference.clone();
+ std::thread::spawn(move || {
+ for _ in 0..10 {
+ let loaded = MarkovModel::load(&model_path).expect("concurrent load");
+ let got: std::collections::BTreeSet = loaded
+ .suggest("git ", 1000)
+ .into_iter()
+ .map(|(s, _)| s)
+ .collect();
+ assert_eq!(got, reference, "concurrent reader diverged");
+ }
+ })
+ })
+ .collect();
+
+ for handle in handles {
+ handle.join().expect("reader thread panicked");
+ }
+}
+
+// =========================================================================
+// Corpus scale: real fixtures round-trip through .apr (was REAL_001..003)
+// =========================================================================
+
+/// Suggestions for a command family must stay inside that family.
+fn assert_family(model: &MarkovModel, prefix: &str, family: &str) {
+ let suggestions = model.suggest(prefix, 1000);
+ assert!(
+ !suggestions.is_empty(),
+ "expected suggestions for {prefix:?}"
+ );
+ for (suggestion, _) in &suggestions {
+ assert!(
+ suggestion.starts_with(family),
+ "{prefix:?} leaked a non-{family} suggestion: {suggestion:?}"
+ );
+ }
+}
+
+#[test]
+fn test_small_fixture_round_trip_keeps_families_separate() {
+ let model = train_on(SMALL_HISTORY);
+ let path = NamedTempFile::new().expect("create model file");
+ model.save(path.path()).expect("save model");
+
+ let loaded = MarkovModel::load(path.path()).expect("load model");
+ assert_eq!(loaded.total_commands(), model.total_commands());
+
+ assert_family(&loaded, "git ", "git");
+ assert_family(&loaded, "cargo ", "cargo");
+}
+
+#[test]
+fn test_medium_fixture_covers_container_tooling() {
+ let model = train_on(MEDIUM_HISTORY);
+
+ assert_family(&model, "docker ", "docker");
+ assert_family(&model, "kubectl ", "kubectl");
+}
+
+#[test]
+fn test_large_fixture_completes_partial_token() {
+ let model = train_on(LARGE_HISTORY);
+ let path = NamedTempFile::new().expect("create model file");
+ model.save(path.path()).expect("save large model");
+
+ let loaded = MarkovModel::load(path.path()).expect("load large model");
+ // "git co" is a partial token: every completion must extend it, never
+ // fall back to the whole "git" family.
+ assert_family(&loaded, "git co", "git co");
+}
+
+// =========================================================================
+// Incremental update (was REAL_009)
+// =========================================================================
+
+/// `train_incremental` must add the new commands, not silently no-op.
+#[test]
+fn test_incremental_update_adds_new_commands() {
+ let mut model = train_on(SMALL_HISTORY);
+ let baseline = model.total_commands();
+
+ assert!(
+ model.suggest("new-special-command", 10).is_empty(),
+ "fixture must not already contain the probe command"
+ );
+
+ model.train_incremental(&[
+ "new-special-command arg1".to_string(),
+ "new-special-command arg2".to_string(),
+ ]);
+
+ assert_eq!(model.total_commands(), baseline + 2);
+ assert_eq!(model.last_trained_position(), baseline + 2);
+ assert_family(&model, "new-special-command", "new-special-command");
+}
+
+// =========================================================================
+// Paged model at a tight memory limit (was REAL_008)
+// =========================================================================
+
+/// A 1 MB-limited paged model must train, persist and reload the large fixture.
+#[test]
+fn test_paged_model_round_trip_under_tight_limit() {
+ let commands = fixture_commands(LARGE_HISTORY);
+ let mut paged = PagedMarkovModel::new(3, 1);
+ paged.train(&commands);
+
+ let dir = tempfile::tempdir().expect("create temp dir");
+ let path = dir.path().join("paged.model");
+ paged.save(&path).expect("save paged model");
+
+ let mut loaded = PagedMarkovModel::load(&path, 1).expect("load paged model");
+ assert_eq!(loaded.total_commands(), commands.len());
+
+ let suggestions = loaded.suggest("git ", 1000);
+ assert!(
+ !suggestions.is_empty(),
+ "paged model must still suggest git commands"
+ );
+ for (suggestion, _) in &suggestions {
+ assert!(
+ suggestion.starts_with("git"),
+ "paged model leaked a non-git suggestion: {suggestion:?}"
+ );
+ }
+}
diff --git a/crates/aprender-shell/tests/cli_integration.rs b/crates/aprender-shell/tests/cli_integration.rs
deleted file mode 100644
index 046e8fb42f..0000000000
--- a/crates/aprender-shell/tests/cli_integration.rs
+++ /dev/null
@@ -1,453 +0,0 @@
-//! CLI Integration Tests for aprender-shell
-//!
-//! Uses assert_cmd (MANDATORY) for end-to-end CLI testing.
-//! Tests actual binary execution with real inputs/outputs.
-
-#![allow(clippy::unwrap_used)] // Tests can use unwrap for simplicity
-#![allow(clippy::disallowed_methods)] // Tests can use unwrap/expect for simplicity
-#![allow(deprecated)] // cargo_bin still works, just deprecated for custom build-dir
-
-use assert_cmd::Command;
-use predicates::prelude::*;
-use std::io::Write;
-use tempfile::NamedTempFile;
-
-// ============================================================================
-// Helper Functions
-// ============================================================================
-
-/// Create an aprender-shell command (MANDATORY pattern)
-fn aprender_shell() -> Command {
- Command::cargo_bin("aprender-shell").expect("Failed to find aprender-shell binary")
-}
-
-/// Create a temporary history file with given commands
-fn create_temp_history(commands: &[&str]) -> NamedTempFile {
- let mut file = NamedTempFile::new().expect("Failed to create temp file");
- for cmd in commands {
- writeln!(file, "{}", cmd).expect("Failed to write command");
- }
- file
-}
-
-/// Create a ZSH-style history file with timestamps
-fn create_zsh_history(commands: &[&str]) -> NamedTempFile {
- let mut file = NamedTempFile::new().expect("Failed to create temp file");
- for (i, cmd) in commands.iter().enumerate() {
- writeln!(file, ": {}:0;{}", 1700000000 + i, cmd).expect("Failed to write command");
- }
- file
-}
-
-// ============================================================================
-// Test: CLI_001 - Help and Version
-// ============================================================================
-
-#[test]
-fn test_cli_001_help_flag() {
- aprender_shell()
- .arg("--help")
- .assert()
- .success()
- .stdout(predicate::str::contains("aprender-shell"))
- .stdout(predicate::str::contains("AI-powered shell completion"));
-}
-
-#[test]
-fn test_cli_001_version_flag() {
- aprender_shell()
- .arg("--version")
- .assert()
- .success()
- .stdout(predicate::str::contains("aprender-shell"));
-}
-
-#[test]
-fn test_cli_001_subcommand_help() {
- aprender_shell()
- .args(["train", "--help"])
- .assert()
- .success()
- .stdout(predicate::str::contains("Train a model"));
-}
-
-// ============================================================================
-// Test: CLI_002 - Train Command
-// ============================================================================
-
-#[test]
-fn test_cli_002_train_basic() {
- let history = create_temp_history(&[
- "git status",
- "git commit -m test",
- "git push",
- "cargo build",
- "cargo test",
- ]);
-
- let output = NamedTempFile::new().unwrap();
-
- aprender_shell()
- .args([
- "train",
- "-f",
- history.path().to_str().unwrap(),
- "-o",
- output.path().to_str().unwrap(),
- ])
- .assert()
- .success()
- .stdout(predicate::str::contains("Training"))
- .stdout(predicate::str::contains("Model saved"));
-}
-
-#[test]
-fn test_cli_002_train_filters_corrupted() {
- // Train with corrupted commands - they should be filtered
- let history = create_temp_history(&[
- "git status",
- "git commit-m test", // corrupted - should be filtered
- "git push",
- "cargo build-r", // corrupted - should be filtered
- "cargo test",
- ]);
-
- let output = NamedTempFile::new().unwrap();
-
- aprender_shell()
- .args([
- "train",
- "-f",
- history.path().to_str().unwrap(),
- "-o",
- output.path().to_str().unwrap(),
- ])
- .assert()
- .success()
- .stdout(predicate::str::contains("Commands loaded: 3")); // Only 3 valid
-}
-
-#[test]
-fn test_cli_002_train_zsh_format() {
- let history = create_zsh_history(&["git status", "git commit -m test", "ls -la"]);
-
- let output = NamedTempFile::new().unwrap();
-
- aprender_shell()
- .args([
- "train",
- "-f",
- history.path().to_str().unwrap(),
- "-o",
- output.path().to_str().unwrap(),
- ])
- .assert()
- .success()
- .stdout(predicate::str::contains("Commands loaded: 3"));
-}
-
-// ============================================================================
-// Test: CLI_003 - Suggest Command
-// ============================================================================
-
-#[test]
-fn test_cli_003_suggest_basic() {
- // First train a model
- let history = create_temp_history(&[
- "git status",
- "git status",
- "git commit -m test",
- "git push origin main",
- ]);
-
- let model = NamedTempFile::new().unwrap();
-
- aprender_shell()
- .args([
- "train",
- "-f",
- history.path().to_str().unwrap(),
- "-o",
- model.path().to_str().unwrap(),
- ])
- .assert()
- .success();
-
- // Now test suggestions
- aprender_shell()
- .args(["suggest", "git ", "-m", model.path().to_str().unwrap()])
- .assert()
- .success()
- .stdout(predicate::str::contains("git status")); // Most frequent
-}
-
-#[test]
-fn test_cli_003_suggest_partial_token() {
- let history = create_temp_history(&[
- "git commit -m test",
- "git checkout main",
- "git clone url",
- "git status",
- ]);
-
- let model = NamedTempFile::new().unwrap();
-
- aprender_shell()
- .args([
- "train",
- "-f",
- history.path().to_str().unwrap(),
- "-o",
- model.path().to_str().unwrap(),
- ])
- .assert()
- .success();
-
- // Partial token "git c" should suggest commit/checkout/clone
- aprender_shell()
- .args(["suggest", "git c", "-m", model.path().to_str().unwrap()])
- .assert()
- .success()
- .stdout(predicate::str::contains("git c")); // All should start with "git c"
-}
-
-#[test]
-fn test_cli_003_suggest_no_corrupted() {
- let history = create_temp_history(&[
- "git commit -m test",
- "git commit-m broken", // corrupted
- "git checkout main",
- ]);
-
- let model = NamedTempFile::new().unwrap();
-
- aprender_shell()
- .args([
- "train",
- "-f",
- history.path().to_str().unwrap(),
- "-o",
- model.path().to_str().unwrap(),
- ])
- .assert()
- .success();
-
- // Should NOT suggest corrupted "commit-m"
- aprender_shell()
- .args(["suggest", "git co", "-m", model.path().to_str().unwrap()])
- .assert()
- .success()
- .stdout(predicate::str::contains("commit-m").not());
-}
-
-// ============================================================================
-// Test: CLI_004 - Stats Command
-// ============================================================================
-
-#[test]
-fn test_cli_004_stats() {
- let history = create_temp_history(&["git status", "git commit -m test", "cargo build"]);
-
- let model = NamedTempFile::new().unwrap();
-
- aprender_shell()
- .args([
- "train",
- "-f",
- history.path().to_str().unwrap(),
- "-o",
- model.path().to_str().unwrap(),
- ])
- .assert()
- .success();
-
- aprender_shell()
- .args(["stats", "-m", model.path().to_str().unwrap()])
- .assert()
- .success()
- .stdout(predicate::str::contains("N-gram size"))
- .stdout(predicate::str::contains("Vocabulary size"));
-}
-
-// ============================================================================
-// Test: CLI_005 - Validate Command
-// ============================================================================
-
-#[test]
-fn test_cli_005_validate() {
- // Validate trains its own model internally using train/test split
- let history = create_temp_history(&[
- "git status",
- "git status",
- "git commit -m test",
- "git push",
- "cargo build",
- "cargo test",
- "cargo run",
- "ls -la",
- "cd src",
- "cat file.txt",
- ]);
-
- aprender_shell()
- .args(["validate", "-f", history.path().to_str().unwrap()])
- .assert()
- .success()
- .stdout(predicate::str::contains("VALIDATION RESULTS"))
- .stdout(predicate::str::contains("Hit@"));
-}
-
-// ============================================================================
-// Test: CLI_006 - Augment Command (Synthetic Data)
-// ============================================================================
-
-#[test]
-fn test_cli_006_augment_basic() {
- let history = create_temp_history(&[
- "git status",
- "git commit -m test",
- "git push origin main",
- "cargo build --release",
- "cargo test --all",
- ]);
-
- let model = NamedTempFile::new().unwrap();
-
- aprender_shell()
- .args([
- "augment",
- "-f",
- history.path().to_str().unwrap(),
- "-o",
- model.path().to_str().unwrap(),
- "-a",
- "0.5", // 50% augmentation
- ])
- .assert()
- .success()
- .stdout(predicate::str::contains("Data Augmentation"))
- .stdout(predicate::str::contains("Coverage"));
-}
-
-#[test]
-fn test_cli_006_augment_with_diversity() {
- let history = create_temp_history(&[
- "git status",
- "git commit -m test",
- "git push",
- "cargo build",
- "cargo test",
- ]);
-
- let model = NamedTempFile::new().unwrap();
-
- aprender_shell()
- .args([
- "augment",
- "-f",
- history.path().to_str().unwrap(),
- "-o",
- model.path().to_str().unwrap(),
- "--monitor-diversity",
- ])
- .assert()
- .success()
- .stdout(predicate::str::contains("Diversity"));
-}
-
-// ============================================================================
-// Test: CLI_007 - Error Handling
-// ============================================================================
-
-#[test]
-fn test_cli_007_missing_history_file() {
- aprender_shell()
- .args(["train", "-f", "/nonexistent/path/history"])
- .assert()
- .failure();
-}
-
-#[test]
-fn test_cli_007_invalid_ngram_size() {
- let history = create_temp_history(&["git status"]);
-
- aprender_shell()
- .args([
- "train",
- "-f",
- history.path().to_str().unwrap(),
- "-n",
- "99", // Invalid - should be 2-5
- ])
- .assert()
- .failure() // Rejects invalid n-gram sizes
- .stderr(predicate::str::contains("N-gram size must be between 2 and 5"));
-}
-
-// ============================================================================
-// Test: CLI_008 - ZSH Widget Generation
-// ============================================================================
-
-#[test]
-fn test_cli_008_zsh_widget() {
- aprender_shell()
- .arg("zsh-widget")
- .assert()
- .success()
- .stdout(predicate::str::contains("aprender-shell ZSH widget"))
- .stdout(predicate::str::contains("_aprender_suggest"))
- .stdout(predicate::str::contains("bindkey"));
-}
-
-// ============================================================================
-// Test: CLI_009 - Export/Import
-// ============================================================================
-
-#[test]
-fn test_cli_009_export_import_roundtrip() {
- let history = create_temp_history(&["git status", "git commit -m test", "cargo build"]);
-
- let model = NamedTempFile::new().unwrap();
- let export_file = NamedTempFile::new().unwrap();
- let imported_model = NamedTempFile::new().unwrap();
-
- // Train
- aprender_shell()
- .args([
- "train",
- "-f",
- history.path().to_str().unwrap(),
- "-o",
- model.path().to_str().unwrap(),
- ])
- .assert()
- .success();
-
- // Export
- aprender_shell()
- .args([
- "export",
- export_file.path().to_str().unwrap(),
- "-m",
- model.path().to_str().unwrap(),
- ])
- .assert()
- .success()
- .stdout(predicate::str::contains("exported"));
-
- // Import
- aprender_shell()
- .args([
- "import",
- export_file.path().to_str().unwrap(),
- "-o",
- imported_model.path().to_str().unwrap(),
- ])
- .assert()
- .success()
- .stdout(predicate::str::contains("imported"));
-}
-
-include!("parts/cli_integration_010.rs");
-include!("parts/cli_integration_017.rs");
-include!("parts/cli_integration_021.rs");
diff --git a/crates/aprender-shell/tests/parts/cli_integration_010.rs b/crates/aprender-shell/tests/parts/cli_integration_010.rs
deleted file mode 100644
index ab4109a9d7..0000000000
--- a/crates/aprender-shell/tests/parts/cli_integration_010.rs
+++ /dev/null
@@ -1,428 +0,0 @@
-// ============================================================================
-// Test: CLI_010 - Latency (Usability)
-// ============================================================================
-
-#[test]
-fn test_cli_010_suggest_latency() {
- use std::time::Instant;
-
- let history = create_temp_history(&[
- "git status",
- "git commit -m test",
- "git push",
- "cargo build",
- "cargo test",
- ]);
-
- let model = NamedTempFile::new().unwrap();
-
- aprender_shell()
- .args([
- "train",
- "-f",
- history.path().to_str().unwrap(),
- "-o",
- model.path().to_str().unwrap(),
- ])
- .assert()
- .success();
-
- // Warmup run to exclude binary startup time from measurement
- aprender_shell()
- .args(["suggest", "git ", "-m", model.path().to_str().unwrap()])
- .assert()
- .success();
-
- // Measure suggestion latency (excluding binary startup)
- let start = Instant::now();
- aprender_shell()
- .args(["suggest", "git ", "-m", model.path().to_str().unwrap()])
- .assert()
- .success();
- let elapsed = start.elapsed();
-
- // Should complete in under 500ms for good UX
- // (Binary startup is excluded via warmup; this measures actual suggestion time)
- assert!(
- elapsed.as_millis() < 500,
- "Suggestion took {}ms, should be <500ms",
- elapsed.as_millis()
- );
-}
-
-// ============================================================================
-// Test: CLI_011 - Analyze Command (CodeFeatureExtractor)
-// ============================================================================
-
-#[test]
-fn test_cli_011_analyze_basic() {
- let history = create_temp_history(&[
- "git status",
- "git commit -m test",
- "git commit -m 'fix bug'",
- "cargo build",
- "cargo test",
- ]);
-
- aprender_shell()
- .args(["analyze", "-f", history.path().to_str().unwrap()])
- .assert()
- .success()
- .stdout(predicate::str::contains("Command Analysis"))
- .stdout(predicate::str::contains("Base Commands"));
-}
-
-#[test]
-fn test_cli_011_analyze_top_limit() {
- let history = create_temp_history(&[
- "git status",
- "git commit -m test",
- "cargo build",
- "npm install",
- "python script.py",
- ]);
-
- aprender_shell()
- .args([
- "analyze",
- "-f",
- history.path().to_str().unwrap(),
- "--top",
- "3",
- ])
- .assert()
- .success()
- .stdout(predicate::str::contains("Top 3 Base Commands"));
-}
-
-// ============================================================================
-// Test: CLI_012 - Augment with CodeEDA
-// ============================================================================
-
-#[test]
-fn test_cli_012_augment_code_eda() {
- let history = create_temp_history(&[
- "git status",
- "git commit -m test",
- "cargo build --release",
- "npm run test",
- ]);
-
- let model = NamedTempFile::new().unwrap();
-
- aprender_shell()
- .args([
- "augment",
- "-f",
- history.path().to_str().unwrap(),
- "-o",
- model.path().to_str().unwrap(),
- "--use-code-eda",
- ])
- .assert()
- .success()
- .stdout(predicate::str::contains("CodeEDA"));
-}
-
-// ============================================================================
-// Test: CLI_013 - Fish Widget Generation (GH-88)
-// ============================================================================
-
-#[test]
-fn test_cli_013_fish_widget() {
- aprender_shell()
- .arg("fish-widget")
- .assert()
- .success()
- .stdout(predicate::str::contains("# >>> aprender-shell widget >>>"))
- .stdout(predicate::str::contains("aprender-shell Fish widget"))
- .stdout(predicate::str::contains("__aprender_suggest"))
- .stdout(predicate::str::contains("__aprender_complete"))
- .stdout(predicate::str::contains("# <<< aprender-shell widget <<<"));
-}
-
-#[test]
-fn test_cli_013_fish_widget_has_disable_toggle() {
- aprender_shell()
- .arg("fish-widget")
- .assert()
- .success()
- .stdout(predicate::str::contains("APRENDER_DISABLED"));
-}
-
-// ============================================================================
-// Test: CLI_014 - Uninstall Command (GH-87)
-// ============================================================================
-
-#[test]
-fn test_cli_014_uninstall_help() {
- aprender_shell()
- .args(["uninstall", "--help"])
- .assert()
- .success()
- .stdout(predicate::str::contains("Uninstall widget"))
- .stdout(predicate::str::contains("--zsh"))
- .stdout(predicate::str::contains("--bash"))
- .stdout(predicate::str::contains("--fish"))
- .stdout(predicate::str::contains("--keep-model"))
- .stdout(predicate::str::contains("--dry-run"));
-}
-
-#[test]
-fn test_cli_014_uninstall_dry_run_no_installation() {
- // With --dry-run and no shell specified, should report no installation found
- aprender_shell()
- .args(["uninstall", "--dry-run"])
- .assert()
- .success();
-}
-
-#[test]
-fn test_cli_014_uninstall_zsh_not_found() {
- // When targeting ZSH specifically but no .zshrc exists or has no widget
- aprender_shell()
- .args(["uninstall", "--zsh", "--dry-run"])
- .assert()
- .success();
-}
-
-#[test]
-fn test_cli_014_uninstall_removes_widget_block() {
- use std::io::Write;
-
- // Create a temp file simulating a .zshrc with the widget
- let mut file = tempfile::NamedTempFile::new().unwrap();
- writeln!(file, "# Some existing config").unwrap();
- writeln!(file, "export PATH=$PATH:/usr/local/bin").unwrap();
- writeln!(file).unwrap();
- writeln!(file, "# >>> aprender-shell widget >>>").unwrap();
- writeln!(file, "_aprender_suggest() {{").unwrap();
- writeln!(file, " # widget code").unwrap();
- writeln!(file, "}}").unwrap();
- writeln!(file, "# <<< aprender-shell widget <<<").unwrap();
- writeln!(file).unwrap();
- writeln!(file, "# More config after").unwrap();
- file.flush().unwrap();
-
- // Read original content
- let original = std::fs::read_to_string(file.path()).unwrap();
- assert!(original.contains(">>> aprender-shell widget >>>"));
-
- // For this test, we verify the marker detection works
- // (The uninstall command uses the actual home directory)
- assert!(original.contains(">>> aprender-shell widget >>>"));
- assert!(original.contains("<<< aprender-shell widget <<<"));
-}
-
-// ============================================================================
-// Test: CLI_015 - ZSH Widget Markers (GH-96)
-// ============================================================================
-
-#[test]
-fn test_cli_015_zsh_widget_has_markers() {
- aprender_shell()
- .arg("zsh-widget")
- .assert()
- .success()
- .stdout(predicate::str::contains("# >>> aprender-shell widget >>>"))
- .stdout(predicate::str::contains("# <<< aprender-shell widget <<<"));
-}
-
-#[test]
-fn test_cli_015_zsh_widget_has_disable_toggle() {
- aprender_shell()
- .arg("zsh-widget")
- .assert()
- .success()
- .stdout(predicate::str::contains("APRENDER_DISABLED"));
-}
-
-#[test]
-fn test_cli_015_zsh_widget_has_timeout() {
- // GH-96: Widget should use timeout to prevent hangs
- aprender_shell()
- .arg("zsh-widget")
- .assert()
- .success()
- .stdout(predicate::str::contains("timeout 0.1"));
-}
-
-#[test]
-fn test_cli_015_zsh_widget_quoted_substitution() {
- // GH-96: SC2046 - Command substitution should be quoted
- aprender_shell()
- .arg("zsh-widget")
- .assert()
- .success()
- .stdout(predicate::str::contains("suggestion=\"$("));
-}
-
-#[test]
-fn test_cli_015_zsh_widget_uninstall_hint() {
- // Widget should include hint about how to uninstall
- aprender_shell()
- .arg("zsh-widget")
- .assert()
- .success()
- .stdout(predicate::str::contains("aprender-shell uninstall"));
-}
-
-// ============================================================================
-// Test: CLI_016 - Inspect Command (Model Card - spec §11)
-// ============================================================================
-
-#[test]
-fn test_cli_016_inspect_help() {
- aprender_shell()
- .args(["inspect", "--help"])
- .assert()
- .success()
- .stdout(predicate::str::contains("Inspect model metadata"))
- .stdout(predicate::str::contains("--format"));
-}
-
-#[test]
-fn test_cli_016_inspect_text_format() {
- // Train a model first
- let history = create_temp_history(&[
- "git status",
- "git commit -m test",
- "git push origin main",
- "cargo build --release",
- "cargo test --lib",
- ]);
-
- let model = NamedTempFile::new().unwrap();
-
- // Train
- aprender_shell()
- .args([
- "train",
- "-f",
- history.path().to_str().unwrap(),
- "-o",
- model.path().to_str().unwrap(),
- ])
- .assert()
- .success();
-
- // Inspect with text format (default)
- aprender_shell()
- .args(["inspect", "-m", model.path().to_str().unwrap()])
- .assert()
- .success()
- .stdout(predicate::str::contains("MODEL INFORMATION"))
- .stdout(predicate::str::contains("Architecture"))
- .stdout(predicate::str::contains("MarkovModel"));
-}
-
-#[test]
-fn test_cli_016_inspect_json_format() {
- // Train a model first
- let history = create_temp_history(&[
- "kubectl get pods",
- "kubectl describe pod test",
- "docker ps -a",
- ]);
-
- let model = NamedTempFile::new().unwrap();
-
- // Train
- aprender_shell()
- .args([
- "train",
- "-f",
- history.path().to_str().unwrap(),
- "-o",
- model.path().to_str().unwrap(),
- ])
- .assert()
- .success();
-
- // Inspect with JSON format
- aprender_shell()
- .args([
- "inspect",
- "-m",
- model.path().to_str().unwrap(),
- "--format",
- "json",
- ])
- .assert()
- .success()
- .stdout(predicate::str::contains("\"model_id\""))
- .stdout(predicate::str::contains("\"version\""))
- .stdout(predicate::str::contains("\"architecture\""));
-}
-
-#[test]
-fn test_cli_016_inspect_huggingface_format() {
- // Train a model first
- let history = create_temp_history(&["npm install", "npm run build", "npm test"]);
-
- let model = NamedTempFile::new().unwrap();
-
- // Train
- aprender_shell()
- .args([
- "train",
- "-f",
- history.path().to_str().unwrap(),
- "-o",
- model.path().to_str().unwrap(),
- ])
- .assert()
- .success();
-
- // Inspect with Hugging Face format
- aprender_shell()
- .args([
- "inspect",
- "-m",
- model.path().to_str().unwrap(),
- "--format",
- "huggingface",
- ])
- .assert()
- .success()
- .stdout(predicate::str::contains("---"))
- .stdout(predicate::str::contains("pipeline_tag:"))
- .stdout(predicate::str::contains("- aprender"))
- .stdout(predicate::str::contains("- rust"));
-}
-
-#[test]
-fn test_cli_016_inspect_nonexistent_model() {
- // Inspect a file that doesn't exist
- aprender_shell()
- .args(["inspect", "-m", "/nonexistent/model.apr"])
- .assert()
- .failure()
- .stderr(predicate::str::contains("Failed to load model"));
-}
-
-// ============================================================================
-// Test: CLI_017 - Publish Command (HF Hub - GH-100)
-// ============================================================================
-
-#[test]
-fn test_cli_017_publish_help() {
- aprender_shell()
- .args(["publish", "--help"])
- .assert()
- .success()
- .stdout(predicate::str::contains(
- "Publish model to Hugging Face Hub",
- ))
- .stdout(predicate::str::contains("--repo"))
- .stdout(predicate::str::contains("--commit"));
-}
-
-#[test]
-fn test_cli_017_publish_nonexistent_model() {
- aprender_shell()
- .args(["publish", "-m", "/nonexistent/model.apr", "-r", "org/repo"])
- .assert()
- .failure()
- .stderr(predicate::str::contains("Failed to load model"));
-}
diff --git a/crates/aprender-shell/tests/parts/cli_integration_017.rs b/crates/aprender-shell/tests/parts/cli_integration_017.rs
deleted file mode 100644
index 9afdd20cba..0000000000
--- a/crates/aprender-shell/tests/parts/cli_integration_017.rs
+++ /dev/null
@@ -1,449 +0,0 @@
-#[test]
-fn test_cli_017_publish_without_token() {
- // Train a model first
- let history = create_temp_history(&["git status", "git commit -m test", "cargo build"]);
-
- let model = NamedTempFile::new().unwrap();
-
- // Train
- aprender_shell()
- .args([
- "train",
- "-f",
- history.path().to_str().unwrap(),
- "-o",
- model.path().to_str().unwrap(),
- ])
- .assert()
- .success();
-
- // Publish without HF_TOKEN (should show instructions)
- aprender_shell()
- .args([
- "publish",
- "-m",
- model.path().to_str().unwrap(),
- "-r",
- "paiml/test-model",
- ])
- .env_remove("HF_TOKEN")
- .assert()
- .success()
- .stderr(predicate::str::contains("HF_TOKEN"))
- .stdout(predicate::str::contains("Model card saved"));
-}
-
-#[test]
-fn test_cli_017_publish_generates_readme() {
- // Train a model first
- let history = create_temp_history(&[
- "kubectl get pods",
- "kubectl describe pod test",
- "docker run nginx",
- ]);
-
- let temp_dir = tempfile::tempdir().unwrap();
- let model_path = temp_dir.path().join("test.model");
-
- // Train
- aprender_shell()
- .args([
- "train",
- "-f",
- history.path().to_str().unwrap(),
- "-o",
- model_path.to_str().unwrap(),
- ])
- .assert()
- .success();
-
- // Publish (generates README.md)
- aprender_shell()
- .args([
- "publish",
- "-m",
- model_path.to_str().unwrap(),
- "-r",
- "paiml/kubectl-model",
- "-c",
- "Initial upload",
- ])
- .env_remove("HF_TOKEN")
- .assert()
- .success();
-
- // Check README.md was created
- let readme_path = temp_dir.path().join("README.md");
- assert!(readme_path.exists(), "README.md should be created");
-
- let content = std::fs::read_to_string(&readme_path).unwrap();
- assert!(
- content.contains("aprender"),
- "README should mention aprender"
- );
- assert!(
- content.contains("Shell Completion"),
- "README should mention Shell Completion"
- );
-}
-
-#[test]
-fn test_cli_017_publish_with_custom_commit() {
- let history = create_temp_history(&["npm install", "npm test"]);
- let model = NamedTempFile::new().unwrap();
-
- aprender_shell()
- .args([
- "train",
- "-f",
- history.path().to_str().unwrap(),
- "-o",
- model.path().to_str().unwrap(),
- ])
- .assert()
- .success();
-
- // Without HF_TOKEN, shows upload instructions with commit message
- aprender_shell()
- .args([
- "publish",
- "-m",
- model.path().to_str().unwrap(),
- "-r",
- "org/custom",
- "-c",
- "Custom commit v2",
- ])
- .env_remove("HF_TOKEN")
- .assert()
- .success()
- .stdout(predicate::str::contains("org/custom"))
- .stdout(predicate::str::contains("Model card saved"));
-}
-
-// ============================================================================
-// Test: CLI_018 - Stream Mode (GH-95)
-// ============================================================================
-
-#[test]
-fn test_cli_018_stream_help() {
- aprender_shell()
- .args(["stream", "--help"])
- .assert()
- .success()
- .stdout(predicate::str::contains("Stream mode"))
- .stdout(predicate::str::contains("stdin"))
- .stdout(predicate::str::contains("--format"));
-}
-
-#[test]
-fn test_cli_018_stream_missing_model() {
- aprender_shell()
- .args(["stream", "-m", "/nonexistent/model.apr"])
- .assert()
- .failure()
- .stderr(predicate::str::contains("not found").or(predicate::str::contains("Failed")));
-}
-
-// ============================================================================
-// Test: CLI_019 - Daemon Mode (GH-95)
-// ============================================================================
-
-#[test]
-fn test_cli_019_daemon_help() {
- aprender_shell()
- .args(["daemon", "--help"])
- .assert()
- .success()
- .stdout(predicate::str::contains("Daemon mode"))
- .stdout(predicate::str::contains("socket"))
- .stdout(predicate::str::contains("--foreground"));
-}
-
-#[test]
-fn test_cli_019_daemon_stop_no_daemon() {
- aprender_shell()
- .args(["daemon-stop", "-s", "/tmp/nonexistent-test.sock"])
- .assert()
- .failure()
- .stderr(predicate::str::contains("not running").or(predicate::str::contains("not found")));
-}
-
-#[test]
-fn test_cli_019_daemon_status_no_daemon() {
- aprender_shell()
- .args(["daemon-status", "-s", "/tmp/nonexistent-test.sock"])
- .assert()
- .failure()
- .stdout(predicate::str::contains("not running").or(predicate::str::contains("not found")));
-}
-
-#[test]
-fn test_cli_019_daemon_missing_model() {
- aprender_shell()
- .args([
- "daemon",
- "-m",
- "/nonexistent/model.apr",
- "-s",
- "/tmp/test-daemon.sock",
- "--foreground",
- ])
- .assert()
- .failure()
- .stderr(predicate::str::contains("not found").or(predicate::str::contains("Failed")));
-}
-
-// ============================================================================
-// Test: CLI_020 - ZSH Widget with Daemon Support (GH-95)
-// ============================================================================
-
-#[test]
-fn test_cli_020_zsh_widget_v4() {
- aprender_shell()
- .arg("zsh-widget")
- .assert()
- .success()
- .stdout(predicate::str::contains("aprender-shell ZSH widget v5"))
- .stdout(predicate::str::contains("daemon support"));
-}
-
-#[test]
-fn test_cli_020_zsh_widget_daemon_functions() {
- aprender_shell()
- .arg("zsh-widget")
- .assert()
- .success()
- .stdout(predicate::str::contains("_aprender_daemon_available"))
- .stdout(predicate::str::contains("_aprender_suggest_daemon"))
- .stdout(predicate::str::contains("APRENDER_USE_DAEMON"));
-}
-
-#[test]
-fn test_cli_020_zsh_widget_auto_daemon() {
- aprender_shell()
- .arg("zsh-widget")
- .assert()
- .success()
- .stdout(predicate::str::contains("APRENDER_AUTO_DAEMON"));
-}
-
-#[test]
-fn test_cli_020_zsh_widget_socket_config() {
- aprender_shell()
- .arg("zsh-widget")
- .assert()
- .success()
- .stdout(predicate::str::contains("APRENDER_SOCKET"));
-}
-
-#[test]
-fn test_cli_020_zsh_widget_shellcheck_directive() {
- // Widget should include shellcheck directive for ZSH-specific syntax
- aprender_shell()
- .arg("zsh-widget")
- .assert()
- .success()
- .stdout(predicate::str::contains("shellcheck shell=zsh"));
-}
-
-#[test]
-fn test_cli_020_zsh_widget_bashrs_lint() {
- use std::process::Command as StdCommand;
-
- // Check if bashrs is available
- let bashrs_available = StdCommand::new("bashrs")
- .arg("--version")
- .output()
- .map(|o| o.status.success())
- .unwrap_or(false);
-
- if !bashrs_available {
- eprintln!("⚠️ bashrs not installed, skipping lint validation");
- return;
- }
-
- // Generate widget
- let widget_output = aprender_shell()
- .arg("zsh-widget")
- .output()
- .expect("Failed to generate widget");
-
- assert!(widget_output.status.success());
-
- // Write to temp file for bashrs
- let mut widget_file = NamedTempFile::new().unwrap();
- widget_file.write_all(&widget_output.stdout).unwrap();
-
- // Run bashrs lint
- let lint_output = StdCommand::new("bashrs")
- .args([
- "lint",
- "--format",
- "json",
- widget_file.path().to_str().unwrap(),
- ])
- .output()
- .expect("Failed to run bashrs lint");
-
- // Parse result (bashrs outputs JSON with errors/warnings)
- let stdout = String::from_utf8_lossy(&lint_output.stdout);
- let stderr = String::from_utf8_lossy(&lint_output.stderr);
-
- // Check for errors (warnings are acceptable for ZSH-specific syntax)
- // bashrs lint exits 1 on warnings but 0 on clean
- // We accept warnings but not errors
- assert!(
- !stderr.contains("[error]") && !stdout.contains("\"severity\":\"error\""),
- "Widget has lint errors: stdout={}, stderr={}",
- stdout,
- stderr
- );
-
- eprintln!("✅ bashrs lint passed (0 errors)");
-}
-
-// ============================================================================
-// Test: CLI_021 - Chaos Resilience (GH-99)
-// ============================================================================
-
-/// Test graceful handling of empty model file
-#[test]
-fn test_cli_021_chaos_empty_model() {
- let empty_model = NamedTempFile::new().unwrap();
-
- // Should handle gracefully with error message (may exit 0 with empty suggestions)
- aprender_shell()
- .args([
- "suggest",
- "-m",
- empty_model.path().to_str().unwrap(),
- "git ",
- ])
- .assert()
- .stderr(
- predicate::str::contains("Invalid")
- .or(predicate::str::contains("corrupted"))
- .or(predicate::str::contains("small"))
- .or(predicate::str::is_empty()), // May also just return empty
- );
-}
-
-/// Test graceful handling of truncated model file
-#[test]
-fn test_cli_021_chaos_truncated_model() {
- let mut truncated_model = NamedTempFile::new().unwrap();
- // Write partial header (magic bytes only, truncated)
- truncated_model.write_all(b"APRN").unwrap();
-
- // Should handle gracefully with error message
- aprender_shell()
- .args([
- "suggest",
- "-m",
- truncated_model.path().to_str().unwrap(),
- "git ",
- ])
- .assert()
- .stderr(
- predicate::str::contains("Invalid")
- .or(predicate::str::contains("corrupted"))
- .or(predicate::str::contains("small"))
- .or(predicate::str::contains("unexpected")),
- );
-}
-
-/// Test graceful handling of model with wrong magic bytes
-#[test]
-fn test_cli_021_chaos_wrong_magic() {
- let mut bad_model = NamedTempFile::new().unwrap();
- // Write wrong magic bytes
- bad_model.write_all(b"XXXX12345678901234567890").unwrap();
-
- // Should handle gracefully with error message
- aprender_shell()
- .args(["suggest", "-m", bad_model.path().to_str().unwrap(), "git "])
- .assert()
- .stderr(
- predicate::str::contains("Invalid")
- .or(predicate::str::contains("corrupted"))
- .or(predicate::str::contains("small"))
- .or(predicate::str::contains("magic")),
- );
-}
-
-/// Test graceful handling of very long prefix input
-#[test]
-fn test_cli_021_chaos_long_prefix() {
- let history = create_temp_history(&["git status", "cargo build"]);
- let model = NamedTempFile::new().unwrap();
-
- aprender_shell()
- .args([
- "train",
- "-f",
- history.path().to_str().unwrap(),
- "-o",
- model.path().to_str().unwrap(),
- ])
- .assert()
- .success();
-
- // Very long prefix (10KB)
- let long_prefix = "g".repeat(10000);
-
- aprender_shell()
- .args([
- "suggest",
- "-m",
- model.path().to_str().unwrap(),
- &long_prefix,
- ])
- .assert()
- .success(); // Should handle gracefully (returns empty or truncates)
-}
-
-/// Test graceful handling of prefix with special characters
-#[test]
-fn test_cli_021_chaos_special_chars() {
- let history = create_temp_history(&["git status", "cargo build"]);
- let model = NamedTempFile::new().unwrap();
-
- aprender_shell()
- .args([
- "train",
- "-f",
- history.path().to_str().unwrap(),
- "-o",
- model.path().to_str().unwrap(),
- ])
- .assert()
- .success();
-
- // Test various special character inputs
- // Note: Null bytes (\0) are excluded because they cannot be passed via command line
- // (this is a limitation of the test harness, not the binary)
- let test_cases = [
- "\x1b[31m", // ANSI escape
- "$(whoami)", // Command substitution
- "`whoami`", // Backtick substitution
- "'; DROP TABLE", // SQL injection attempt
- "&&rm -rf /", // Command chain attempt
- "|cat /etc/passwd", // Pipe injection
- ];
-
- for prefix in test_cases {
- let result = aprender_shell()
- .args(["suggest", "-m", model.path().to_str().unwrap(), prefix])
- .assert();
-
- // Should either succeed (with sanitized input) or fail gracefully
- // Must NOT panic or crash
- let output = result.get_output();
- assert!(
- output.status.success() || !output.stderr.is_empty(),
- "Should handle special chars gracefully: {:?}",
- prefix
- );
- }
-}
diff --git a/crates/aprender-shell/tests/parts/cli_integration_021.rs b/crates/aprender-shell/tests/parts/cli_integration_021.rs
deleted file mode 100644
index 20e2acc079..0000000000
--- a/crates/aprender-shell/tests/parts/cli_integration_021.rs
+++ /dev/null
@@ -1,115 +0,0 @@
-/// Test graceful handling of unicode edge cases
-#[test]
-fn test_cli_021_chaos_unicode() {
- let history = create_temp_history(&["git status", "cargo build"]);
- let model = NamedTempFile::new().unwrap();
-
- aprender_shell()
- .args([
- "train",
- "-f",
- history.path().to_str().unwrap(),
- "-o",
- model.path().to_str().unwrap(),
- ])
- .assert()
- .success();
-
- // Test Unicode edge cases
- let test_cases = [
- "🚀", // Emoji
- "日本語", // CJK characters
- "مرحبا", // RTL text
- "\u{FEFF}git", // BOM
- "git\u{200B}status", // Zero-width space
- "git\u{202E}status", // RTL override
- &"é".repeat(100), // Many combining marks
- ];
-
- for prefix in test_cases {
- aprender_shell()
- .args(["suggest", "-m", model.path().to_str().unwrap(), prefix])
- .assert()
- .success(); // Should handle gracefully
- }
-}
-
-/// Test graceful handling of concurrent file access
-#[test]
-fn test_cli_021_chaos_concurrent_read() {
- use std::thread;
-
- let history = create_temp_history(&[
- "git status",
- "git commit",
- "git push",
- "cargo build",
- "cargo test",
- ]);
- let model = NamedTempFile::new().unwrap();
-
- aprender_shell()
- .args([
- "train",
- "-f",
- history.path().to_str().unwrap(),
- "-o",
- model.path().to_str().unwrap(),
- ])
- .assert()
- .success();
-
- let model_path = model.path().to_str().unwrap().to_string();
-
- // Spawn multiple concurrent readers
- let handles: Vec<_> = (0..5)
- .map(|_| {
- let model_path = model_path.clone();
- thread::spawn(move || {
- for _ in 0..10 {
- Command::cargo_bin("aprender-shell")
- .unwrap()
- .args(["suggest", "-m", &model_path, "git "])
- .assert()
- .success();
- }
- })
- })
- .collect();
-
- // All threads should complete without issues
- for handle in handles {
- handle.join().expect("Thread should complete successfully");
- }
-}
-
-/// Test graceful handling of rapid sequential calls
-#[test]
-fn test_cli_021_chaos_rapid_calls() {
- let history = create_temp_history(&["git status", "git commit", "cargo build"]);
- let model = NamedTempFile::new().unwrap();
-
- aprender_shell()
- .args([
- "train",
- "-f",
- history.path().to_str().unwrap(),
- "-o",
- model.path().to_str().unwrap(),
- ])
- .assert()
- .success();
-
- // Rapid sequential calls
- for i in 0..50 {
- aprender_shell()
- .args([
- "suggest",
- "-m",
- model.path().to_str().unwrap(),
- &format!("git {:02}", i),
- ])
- .assert()
- .success();
- }
-}
diff --git a/crates/aprender-shell/tests/parts/real_world_tests_008.rs b/crates/aprender-shell/tests/parts/real_world_tests_008.rs
deleted file mode 100644
index 6890ed634e..0000000000
--- a/crates/aprender-shell/tests/parts/real_world_tests_008.rs
+++ /dev/null
@@ -1,105 +0,0 @@
-// ============================================================================
-// Test: REAL_008 - Paged Model for Very Large History
-// ============================================================================
-
-#[test]
-fn test_real_008_paged_model_training() {
- let history = create_fixture_history(LARGE_HISTORY);
- let model_dir = tempfile::tempdir().unwrap();
- let model_path = model_dir.path().join("paged.model");
-
- aprender_shell()
- .args([
- "train",
- "-f",
- history.path().to_str().unwrap(),
- "-o",
- model_path.to_str().unwrap(),
- "--memory-limit",
- "1", // 1MB limit to force paging
- ])
- .assert()
- .success()
- .stdout(predicate::str::contains("Paged model saved"));
-}
-
-// ============================================================================
-// Test: REAL_009 - Incremental Updates
-// ============================================================================
-
-#[test]
-fn test_real_009_incremental_update() {
- let history1 = create_fixture_history(SMALL_HISTORY);
- // Create extended history that includes the original commands plus new ones
- let mut extended_content = String::from(SMALL_HISTORY);
- extended_content.push_str("\nnew-special-command arg1\nnew-special-command arg2\n");
- let history2 = create_fixture_history(&extended_content);
- let model = NamedTempFile::new().unwrap();
-
- // Initial training
- aprender_shell()
- .args([
- "train",
- "-f",
- history1.path().to_str().unwrap(),
- "-o",
- model.path().to_str().unwrap(),
- ])
- .assert()
- .success();
-
- // Incremental update - should report either "updated" or "up to date"
- aprender_shell()
- .args([
- "update",
- "-f",
- history2.path().to_str().unwrap(),
- "-m",
- model.path().to_str().unwrap(),
- ])
- .assert()
- .success();
-}
-
-// ============================================================================
-// Test: REAL_010 - End-to-End User Workflow
-// ============================================================================
-
-#[test]
-fn test_real_010_complete_user_workflow() {
- let history = create_fixture_history(MEDIUM_HISTORY);
- let model = NamedTempFile::new().unwrap();
-
- // Step 1: Train
- aprender_shell()
- .args([
- "train",
- "-f",
- history.path().to_str().unwrap(),
- "-o",
- model.path().to_str().unwrap(),
- ])
- .assert()
- .success();
-
- // Step 2: Get stats
- aprender_shell()
- .args(["stats", "-m", model.path().to_str().unwrap()])
- .assert()
- .success();
-
- // Step 3: Use suggestions for common patterns
- let prefixes = ["git ", "cargo ", "docker ", "npm "];
- for prefix in &prefixes {
- aprender_shell()
- .args(["suggest", prefix, "-m", model.path().to_str().unwrap()])
- .assert()
- .success();
- }
-
- // Step 4: Validate quality
- aprender_shell()
- .args(["validate", "-f", history.path().to_str().unwrap()])
- .assert()
- .success();
-}
diff --git a/crates/aprender-shell/tests/performance_tests.rs b/crates/aprender-shell/tests/performance_tests.rs
deleted file mode 100644
index e1efb2cc93..0000000000
--- a/crates/aprender-shell/tests/performance_tests.rs
+++ /dev/null
@@ -1,316 +0,0 @@
-//! NASA-level performance tests using renacer baselines
-//!
-//! These tests validate performance against strict timing and syscall budgets.
-//! Run with: cargo test --test performance_tests -- --ignored
-//!
-//! Requires:
-//! - renacer installed: cargo install --path ../renacer
-//! - Release build: cargo build --release -p aprender-shell
-//!
-//! Toyota Way Principle: *Genchi Genbutsu* (Go and see) - Understand performance
-//! at the source through direct measurement.
-
-#![allow(clippy::disallowed_methods)] // Tests can use unwrap/expect for simplicity
-
-use std::path::PathBuf;
-use std::process::Command;
-use std::time::{Duration, Instant};
-use tempfile::NamedTempFile;
-
-/// Resolve the path to the `aprender-shell` CLI binary for end-to-end tests.
-///
-/// The binary lives outside this library-only crate, so resolve it via
-/// `assert_cmd` (matching the other integration tests) instead of the
-/// `CARGO_BIN_EXE_*` env var, which is only defined for in-crate bin targets.
-fn shell_bin() -> PathBuf {
- assert_cmd::cargo::cargo_bin("aprender-shell")
-}
-
-/// Helper to create a test model
-fn create_test_model() -> NamedTempFile {
- let history = NamedTempFile::new().expect("create temp file");
- std::fs::write(
- history.path(),
- "git status\ngit commit -m test\ngit push origin main\n\
- cargo build --release\ncargo test\ncargo clippy\n\
- docker ps\ndocker run hello-world\nkubectl get pods\n",
- )
- .expect("write history");
-
- let model = NamedTempFile::new().expect("create model file");
-
- let status = Command::new(shell_bin())
- .args([
- "train",
- history.path().to_str().unwrap(),
- "--output",
- model.path().to_str().unwrap(),
- ])
- .status()
- .expect("train model");
-
- assert!(status.success(), "Failed to train test model");
- model
-}
-
-/// Suggestion latency must be <10ms P99
-///
-/// Target: P50 <2ms, P95 <5ms, P99 <10ms
-#[test]
-#[ignore] // Run manually or in CI with: cargo test -- --ignored
-fn test_suggest_latency_p99() {
- let model = create_test_model();
- let model_path = model.path().to_str().unwrap();
-
- let mut latencies = Vec::with_capacity(100);
-
- for _ in 0..100 {
- let start = Instant::now();
- let output = Command::new(shell_bin())
- .args(["suggest", "git ", "--model", model_path])
- .output()
- .expect("Failed to run suggest");
- let elapsed = start.elapsed();
-
- assert!(output.status.success(), "suggest command failed");
- latencies.push(elapsed.as_micros());
- }
-
- latencies.sort();
- let p50 = latencies[49];
- let p95 = latencies[94];
- let p99 = latencies[98];
-
- println!("Latency percentiles (μs): P50={p50}, P95={p95}, P99={p99}");
-
- assert!(p99 < 10_000, "P99 latency {p99} μs exceeds 10ms target");
-
- // Informational checks (warn but don't fail)
- if p50 > 2_000 {
- eprintln!("WARNING: P50 latency {p50} μs exceeds 2ms soft target");
- }
- if p95 > 5_000 {
- eprintln!("WARNING: P95 latency {p95} μs exceeds 5ms soft target");
- }
-}
-
-/// Model loading must be <100ms cold
-#[test]
-#[ignore]
-fn test_model_load_latency_cold() {
- let model = create_test_model();
- let model_path = model.path().to_str().unwrap();
-
- // Drop filesystem cache by using a fresh path each time
- let start = Instant::now();
- let output = Command::new(shell_bin())
- .args(["stats", "--model", model_path])
- .output()
- .expect("Failed to run stats");
- let cold_latency = start.elapsed();
-
- assert!(output.status.success(), "stats command failed");
-
- println!("Cold load latency: {:?}", cold_latency);
-
- assert!(
- cold_latency < Duration::from_millis(100),
- "Cold load latency {:?} exceeds 100ms target",
- cold_latency
- );
-}
-
-/// Repeated suggestions should complete in <5ms (warm path)
-#[test]
-#[ignore]
-fn test_suggest_warm_latency() {
- let model = create_test_model();
- let model_path = model.path().to_str().unwrap();
-
- // Warm up (first call loads model)
- let _ = Command::new(shell_bin())
- .args(["suggest", "git ", "--model", model_path])
- .output()
- .expect("warmup failed");
-
- // Measure warm latency
- let mut latencies = Vec::with_capacity(50);
- for _ in 0..50 {
- let start = Instant::now();
- let output = Command::new(shell_bin())
- .args(["suggest", "cargo ", "--model", model_path])
- .output()
- .expect("suggest failed");
- let elapsed = start.elapsed();
-
- assert!(output.status.success());
- latencies.push(elapsed.as_micros());
- }
-
- latencies.sort();
- let p50 = latencies[24];
- let p95 = latencies[47];
-
- println!("Warm latency (μs): P50={p50}, P95={p95}");
-
- assert!(p95 < 5_000, "Warm P95 latency {p95} μs exceeds 5ms target");
-}
-
-/// Syscall count must be <150 per suggestion
-///
-/// Current baseline: ~970 brk calls (excessive)
-/// Target: <80 total syscalls with pre-allocation
-#[test]
-#[ignore]
-fn test_syscall_budget() {
- // Check if renacer is installed
- let renacer_check = Command::new("which").arg("renacer").output();
-
- if renacer_check.is_err() || !renacer_check.unwrap().status.success() {
- eprintln!("SKIP: renacer not found - install from ../renacer");
- return;
- }
-
- let model = create_test_model();
- let model_path = model.path().to_str().unwrap();
- let bin = shell_bin();
- let bin_path = bin.to_str().expect("binary path is valid UTF-8");
-
- let output = Command::new("renacer")
- .args([
- "-c", "--", bin_path, "suggest", "git ", "--model", model_path,
- ])
- .output()
- .expect("renacer failed");
-
- let stdout = String::from_utf8_lossy(&output.stdout);
- println!("Renacer output:\n{stdout}");
-
- // Parse total syscall count from renacer output
- // Format: "100.00 0.012345 142 0 total"
- let total_line = stdout.lines().find(|line| line.contains("total"));
-
- if let Some(line) = total_line {
- let parts: Vec<&str> = line.split_whitespace().collect();
- if parts.len() >= 4 {
- if let Ok(syscall_count) = parts[3].parse::() {
- println!("Total syscalls: {syscall_count}");
-
- assert!(
- syscall_count < 150,
- "Syscall count {syscall_count} exceeds 150 budget (target: <80)"
- );
-
- if syscall_count > 80 {
- eprintln!("WARNING: Syscall count {syscall_count} exceeds 80 soft target");
- }
- }
- }
- } else {
- eprintln!("WARNING: Could not parse syscall count from renacer output");
- }
-}
-
-/// No anomalies should occur during normal operation
-#[test]
-#[ignore]
-fn test_no_anomalies() {
- // Check if renacer is installed
- let renacer_check = Command::new("which").arg("renacer").output();
-
- if renacer_check.is_err() || !renacer_check.unwrap().status.success() {
- eprintln!("SKIP: renacer not found - install from ../renacer");
- return;
- }
-
- let model = create_test_model();
- let model_path = model.path().to_str().unwrap();
- let bin = shell_bin();
- let bin_path = bin.to_str().expect("binary path is valid UTF-8");
-
- let output = Command::new("renacer")
- .args([
- "--anomaly-realtime",
- "--anomaly-threshold",
- "3.0",
- "--",
- bin_path,
- "suggest",
- "git status",
- "--model",
- model_path,
- ])
- .output()
- .expect("renacer failed");
-
- let stderr = String::from_utf8_lossy(&output.stderr);
-
- let anomaly_count = stderr.matches("ANOMALY").count();
- println!("Anomalies detected: {anomaly_count}");
-
- // Allow up to 2 minor anomalies (startup transients)
- assert!(
- anomaly_count < 3,
- "Too many anomalies detected ({anomaly_count}):\n{stderr}"
- );
-}
-
-/// Memory usage should not grow unbounded
-#[test]
-#[ignore]
-fn test_memory_bounded() {
- let model = create_test_model();
- let model_path = model.path().to_str().unwrap();
-
- // Run 100 suggestions and check memory doesn't grow
- for i in 0..100 {
- let output = Command::new(shell_bin())
- .args(["suggest", "git ", "--model", model_path])
- .output()
- .expect("suggest failed");
-
- assert!(output.status.success(), "suggest failed at iteration {i}");
- }
-
- // If we get here without OOM, memory is bounded
- println!("100 suggestions completed without OOM");
-}
-
-/// Validate that security filtering doesn't add significant latency
-#[test]
-#[ignore]
-fn test_security_filter_overhead() {
- let model = create_test_model();
- let model_path = model.path().to_str().unwrap();
-
- // Measure latency for commands that should trigger security checks
- let prefixes = [
- "export ", // May match SECRET patterns
- "curl -u ", // May match credential patterns
- "git status ", // Normal command (baseline)
- ];
-
- for prefix in prefixes {
- let mut latencies = Vec::with_capacity(20);
-
- for _ in 0..20 {
- let start = Instant::now();
- let _ = Command::new(shell_bin())
- .args(["suggest", prefix, "--model", model_path])
- .output()
- .expect("suggest failed");
- latencies.push(start.elapsed().as_micros());
- }
-
- latencies.sort();
- let p50 = latencies[9];
-
- println!("Security filter test for '{prefix}': P50={p50}μs");
-
- // Security filtering should add <1ms overhead
- assert!(
- p50 < 3_000,
- "Prefix '{prefix}' has excessive latency: {p50}μs"
- );
- }
-}
diff --git a/crates/aprender-shell/tests/real_world_tests.rs b/crates/aprender-shell/tests/real_world_tests.rs
deleted file mode 100644
index c55609def0..0000000000
--- a/crates/aprender-shell/tests/real_world_tests.rs
+++ /dev/null
@@ -1,450 +0,0 @@
-//! Real-World Integration Tests for aprender-shell
-//!
-//! These tests use realistic shell history fixtures (same as bashrs benchmarks)
-//! to validate production-like scenarios with assert_cmd.
-
-#![allow(clippy::unwrap_used)] // Tests can use unwrap for simplicity
-#![allow(clippy::disallowed_methods)] // Tests can use unwrap/expect for simplicity
-#![allow(deprecated)] // cargo_bin still works, just deprecated for custom build-dir
-
-use assert_cmd::Command;
-use predicates::prelude::*;
-use std::io::Write;
-use tempfile::NamedTempFile;
-
-// Load benchmark fixtures
-const SMALL_HISTORY: &str = include_str!("../benches/fixtures/small_history.txt");
-const MEDIUM_HISTORY: &str = include_str!("../benches/fixtures/medium_history.txt");
-const LARGE_HISTORY: &str = include_str!("../benches/fixtures/large_history.txt");
-
-/// Create an aprender-shell command
-fn aprender_shell() -> Command {
- Command::cargo_bin("aprender-shell").expect("Failed to find aprender-shell binary")
-}
-
-/// Create a temporary history file from fixture content
-fn create_fixture_history(content: &str) -> NamedTempFile {
- let mut file = NamedTempFile::new().expect("Failed to create temp file");
- // Filter out comments for realistic history
- for line in content.lines() {
- let trimmed = line.trim();
- if !trimmed.is_empty() && !trimmed.starts_with('#') {
- writeln!(file, "{}", trimmed).expect("Failed to write command");
- }
- }
- file
-}
-
-// ============================================================================
-// Test: REAL_001 - Small History (Developer Basics)
-// ============================================================================
-
-#[test]
-fn test_real_001_train_small_history() {
- let history = create_fixture_history(SMALL_HISTORY);
- let model = NamedTempFile::new().unwrap();
-
- aprender_shell()
- .args([
- "train",
- "-f",
- history.path().to_str().unwrap(),
- "-o",
- model.path().to_str().unwrap(),
- ])
- .assert()
- .success()
- .stdout(predicate::str::contains("Training"))
- .stdout(predicate::str::contains("Model saved"));
-}
-
-#[test]
-fn test_real_001_suggest_git_commands() {
- let history = create_fixture_history(SMALL_HISTORY);
- let model = NamedTempFile::new().unwrap();
-
- // Train
- aprender_shell()
- .args([
- "train",
- "-f",
- history.path().to_str().unwrap(),
- "-o",
- model.path().to_str().unwrap(),
- ])
- .assert()
- .success();
-
- // Suggest git commands
- aprender_shell()
- .args(["suggest", "git ", "-m", model.path().to_str().unwrap()])
- .assert()
- .success()
- .stdout(predicate::str::contains("git")); // Should suggest git commands
-}
-
-#[test]
-fn test_real_001_suggest_cargo_commands() {
- let history = create_fixture_history(SMALL_HISTORY);
- let model = NamedTempFile::new().unwrap();
-
- // Train
- aprender_shell()
- .args([
- "train",
- "-f",
- history.path().to_str().unwrap(),
- "-o",
- model.path().to_str().unwrap(),
- ])
- .assert()
- .success();
-
- // Suggest cargo commands
- aprender_shell()
- .args(["suggest", "cargo ", "-m", model.path().to_str().unwrap()])
- .assert()
- .success()
- .stdout(predicate::str::contains("cargo")); // Should suggest cargo commands
-}
-
-// ============================================================================
-// Test: REAL_002 - Medium History (Full Developer Workflow)
-// ============================================================================
-
-#[test]
-fn test_real_002_train_medium_history() {
- let history = create_fixture_history(MEDIUM_HISTORY);
- let model = NamedTempFile::new().unwrap();
-
- aprender_shell()
- .args([
- "train",
- "-f",
- history.path().to_str().unwrap(),
- "-o",
- model.path().to_str().unwrap(),
- ])
- .assert()
- .success()
- .stdout(predicate::str::contains("Commands loaded"));
-}
-
-#[test]
-fn test_real_002_stats_medium_history() {
- let history = create_fixture_history(MEDIUM_HISTORY);
- let model = NamedTempFile::new().unwrap();
-
- // Train
- aprender_shell()
- .args([
- "train",
- "-f",
- history.path().to_str().unwrap(),
- "-o",
- model.path().to_str().unwrap(),
- ])
- .assert()
- .success();
-
- // Get stats
- aprender_shell()
- .args(["stats", "-m", model.path().to_str().unwrap()])
- .assert()
- .success()
- .stdout(predicate::str::contains("N-gram size"))
- .stdout(predicate::str::contains("Vocabulary size"));
-}
-
-#[test]
-fn test_real_002_docker_suggestions() {
- let history = create_fixture_history(MEDIUM_HISTORY);
- let model = NamedTempFile::new().unwrap();
-
- // Train
- aprender_shell()
- .args([
- "train",
- "-f",
- history.path().to_str().unwrap(),
- "-o",
- model.path().to_str().unwrap(),
- ])
- .assert()
- .success();
-
- // Suggest docker commands
- aprender_shell()
- .args(["suggest", "docker ", "-m", model.path().to_str().unwrap()])
- .assert()
- .success()
- .stdout(predicate::str::contains("docker")); // Should have docker suggestions
-}
-
-#[test]
-fn test_real_002_kubectl_suggestions() {
- let history = create_fixture_history(MEDIUM_HISTORY);
- let model = NamedTempFile::new().unwrap();
-
- // Train
- aprender_shell()
- .args([
- "train",
- "-f",
- history.path().to_str().unwrap(),
- "-o",
- model.path().to_str().unwrap(),
- ])
- .assert()
- .success();
-
- // Suggest kubectl commands
- aprender_shell()
- .args(["suggest", "kubectl ", "-m", model.path().to_str().unwrap()])
- .assert()
- .success()
- .stdout(predicate::str::contains("kubectl")); // Should have kubectl suggestions
-}
-
-// ============================================================================
-// Test: REAL_003 - Large History (Production Scale)
-// ============================================================================
-
-#[test]
-fn test_real_003_train_large_history() {
- let history = create_fixture_history(LARGE_HISTORY);
- let model = NamedTempFile::new().unwrap();
-
- aprender_shell()
- .args([
- "train",
- "-f",
- history.path().to_str().unwrap(),
- "-o",
- model.path().to_str().unwrap(),
- ])
- .assert()
- .success()
- .stdout(predicate::str::contains("Model saved"));
-}
-
-#[test]
-#[ignore = "Flaky latency test - fails under CI/coverage load"]
-fn test_real_003_suggest_latency_acceptable() {
- use std::time::Instant;
-
- let history = create_fixture_history(LARGE_HISTORY);
- let model = NamedTempFile::new().unwrap();
-
- // Train
- aprender_shell()
- .args([
- "train",
- "-f",
- history.path().to_str().unwrap(),
- "-o",
- model.path().to_str().unwrap(),
- ])
- .assert()
- .success();
-
- // Warmup run to exclude binary startup time from measurement
- aprender_shell()
- .args(["suggest", "git ", "-m", model.path().to_str().unwrap()])
- .assert()
- .success();
-
- // Measure suggestion latency (excluding binary startup)
- let start = Instant::now();
- aprender_shell()
- .args(["suggest", "git ", "-m", model.path().to_str().unwrap()])
- .assert()
- .success();
- let elapsed = start.elapsed();
-
- // Should be under 200ms even for large models (warmup excludes startup overhead)
- assert!(
- elapsed.as_millis() < 200,
- "Large model suggestion took {}ms, should be <200ms",
- elapsed.as_millis()
- );
-}
-
-#[test]
-fn test_real_003_partial_token_completion() {
- let history = create_fixture_history(LARGE_HISTORY);
- let model = NamedTempFile::new().unwrap();
-
- // Train
- aprender_shell()
- .args([
- "train",
- "-f",
- history.path().to_str().unwrap(),
- "-o",
- model.path().to_str().unwrap(),
- ])
- .assert()
- .success();
-
- // Partial token "git co" should suggest commit/checkout
- aprender_shell()
- .args(["suggest", "git co", "-m", model.path().to_str().unwrap()])
- .assert()
- .success()
- .stdout(predicate::str::contains("git co")); // Should complete partial token
-}
-
-// ============================================================================
-// Test: REAL_004 - Validation and Cross-Validation
-// ============================================================================
-
-#[test]
-fn test_real_004_validate_medium_history() {
- let history = create_fixture_history(MEDIUM_HISTORY);
-
- aprender_shell()
- .args(["validate", "-f", history.path().to_str().unwrap()])
- .assert()
- .success()
- .stdout(predicate::str::contains("VALIDATION RESULTS"))
- .stdout(predicate::str::contains("Hit@"));
-}
-
-// ============================================================================
-// Test: REAL_005 - Data Augmentation
-// ============================================================================
-
-#[test]
-fn test_real_005_augment_small_history() {
- let history = create_fixture_history(SMALL_HISTORY);
- let model = NamedTempFile::new().unwrap();
-
- aprender_shell()
- .args([
- "augment",
- "-f",
- history.path().to_str().unwrap(),
- "-o",
- model.path().to_str().unwrap(),
- "-a",
- "0.5",
- ])
- .assert()
- .success()
- .stdout(predicate::str::contains("Data Augmentation"));
-}
-
-#[test]
-fn test_real_005_augment_with_code_eda() {
- let history = create_fixture_history(MEDIUM_HISTORY);
- let model = NamedTempFile::new().unwrap();
-
- aprender_shell()
- .args([
- "augment",
- "-f",
- history.path().to_str().unwrap(),
- "-o",
- model.path().to_str().unwrap(),
- "--use-code-eda",
- ])
- .assert()
- .success()
- .stdout(predicate::str::contains("CodeEDA"));
-}
-
-// ============================================================================
-// Test: REAL_006 - Analysis Command
-// ============================================================================
-
-#[test]
-fn test_real_006_analyze_medium_history() {
- let history = create_fixture_history(MEDIUM_HISTORY);
-
- aprender_shell()
- .args(["analyze", "-f", history.path().to_str().unwrap()])
- .assert()
- .success()
- .stdout(predicate::str::contains("Command Analysis"))
- .stdout(predicate::str::contains("git"))
- .stdout(predicate::str::contains("cargo"))
- .stdout(predicate::str::contains("docker"));
-}
-
-#[test]
-fn test_real_006_analyze_large_history() {
- let history = create_fixture_history(LARGE_HISTORY);
-
- aprender_shell()
- .args([
- "analyze",
- "-f",
- history.path().to_str().unwrap(),
- "--top",
- "5",
- ])
- .assert()
- .success()
- .stdout(predicate::str::contains("Top 5 Base Commands"));
-}
-
-// ============================================================================
-// Test: REAL_007 - Export/Import with Large Data
-// ============================================================================
-
-#[test]
-fn test_real_007_export_import_roundtrip() {
- let history = create_fixture_history(MEDIUM_HISTORY);
- let model = NamedTempFile::new().unwrap();
- let export_file = NamedTempFile::new().unwrap();
- let reimported_model = NamedTempFile::new().unwrap();
-
- // Train
- aprender_shell()
- .args([
- "train",
- "-f",
- history.path().to_str().unwrap(),
- "-o",
- model.path().to_str().unwrap(),
- ])
- .assert()
- .success();
-
- // Export
- aprender_shell()
- .args([
- "export",
- export_file.path().to_str().unwrap(),
- "-m",
- model.path().to_str().unwrap(),
- ])
- .assert()
- .success();
-
- // Import
- aprender_shell()
- .args([
- "import",
- export_file.path().to_str().unwrap(),
- "-o",
- reimported_model.path().to_str().unwrap(),
- ])
- .assert()
- .success();
-
- // Verify reimported model works
- aprender_shell()
- .args([
- "suggest",
- "git ",
- "-m",
- reimported_model.path().to_str().unwrap(),
- ])
- .assert()
- .success()
- .stdout(predicate::str::contains("git"));
-}
-
-include!("parts/real_world_tests_008.rs");
diff --git a/crates/aprender-test-cli/tests/smoke_tests.rs b/crates/aprender-test-cli/tests/smoke_tests.rs
index 29943d58d4..82fc3c241f 100644
--- a/crates/aprender-test-cli/tests/smoke_tests.rs
+++ b/crates/aprender-test-cli/tests/smoke_tests.rs
@@ -11,9 +11,12 @@ use predicates::prelude::*;
use std::fs;
use tempfile::TempDir;
-/// Get a command for the probador binary
+/// Get a command for the CLI binary
+///
+/// The package renamed its bin target to `aprender-test-cli` in the monorepo
+/// consolidation; `probador` survives only as the [lib] name.
fn probador() -> Command {
- Command::cargo_bin("probador").expect("probador binary should exist")
+ Command::cargo_bin("aprender-test-cli").expect("aprender-test-cli binary should exist")
}
// ============================================================================
@@ -22,11 +25,16 @@ fn probador() -> Command {
#[test]
fn test_version_flag() {
+ // Compared against CARGO_PKG_VERSION rather than a literal: the hardcoded
+ // "1.0.0" here predated the workspace-inherited version and could only rot.
probador()
.arg("--version")
.assert()
.success()
- .stdout(predicate::str::contains("1.0.0"));
+ .stdout(predicate::str::contains(format!(
+ "probador {}",
+ env!("CARGO_PKG_VERSION")
+ )));
}
#[test]
diff --git a/crates/aprender-test-lib/src/pixel_coverage/wasm_demo.rs b/crates/aprender-test-lib/src/pixel_coverage/wasm_demo.rs
index 4031b6a762..366a3d7212 100644
--- a/crates/aprender-test-lib/src/pixel_coverage/wasm_demo.rs
+++ b/crates/aprender-test-lib/src/pixel_coverage/wasm_demo.rs
@@ -1181,35 +1181,97 @@ mod tests {
}
// =========================================================================
- // Section 9: Performance Regression Tests (QA 61-70)
+ // Section 9: Work-Bound Structural Tests (QA 61-70)
// =========================================================================
+ //
+ // These assert the *structural* work bound, never wall-clock time. A
+ // `elapsed.as_secs() < N` assertion cancels nothing about machine speed and
+ // flakes under CI load; it is banned in this repo. State the bound as a
+ // value instead: creation is a single zero-fill, and one `random_fill_pass`
+ // call applies exactly one frame of fill - nothing skipped, deferred into a
+ // later batch, or repeated.
#[test]
- fn h0_perf_01_1080p_creation_fast() {
- let start = std::time::Instant::now();
- let _buffer = GpuPixelBuffer::new_1080p();
- let elapsed = start.elapsed();
+ fn h0_perf_01_1080p_creation_allocates_zeroed_buffer() {
+ let buffer = GpuPixelBuffer::new_1080p();
- // Should create in under 5s (generous for loaded systems)
+ // Creation is a single O(total_pixels) zero-fill: exact allocation,
+ // every pixel uncovered, no frames consumed.
+ assert_eq!(buffer.pixels.len(), 1920 * 1080);
+ assert_eq!(buffer.frame, 0);
assert!(
- elapsed.as_secs() < 5,
- "1080p buffer creation took {:?}",
- elapsed
+ buffer.pixels.iter().all(|&p| p == 0.0),
+ "1080p buffer must be fully uncovered at creation"
);
}
#[test]
- fn h0_perf_02_fill_pass_reasonable_time() {
- let mut buffer = GpuPixelBuffer::new(100, 100, 42);
+ fn h0_perf_02_fill_pass_does_exactly_one_frame_of_work() {
+ const W: u32 = 64;
+ const H: u32 = 64;
+ const SEED: u64 = 42;
+ const PROB: f32 = 0.05;
+ const PASSES: u32 = 20;
+
+ let seed32 = (SEED & 0xFFFF_FFFF) as u32;
+ let mut buffer = GpuPixelBuffer::new(W, H, SEED);
+ let mut previous = buffer.pixels.clone();
+ let mut newly_covered_total = 0usize;
+
+ for pass in 1..=PASSES {
+ buffer.random_fill_pass(PROB);
+
+ // One call advances exactly one frame - no hidden extra sweeps.
+ assert_eq!(
+ buffer.frame, pass,
+ "call {pass} did not advance the frame counter by exactly 1"
+ );
+
+ for idx in 0..(W * H) {
+ let before = previous[idx as usize];
+ let after = buffer.pixels[idx as usize];
+
+ // This frame's draw is a pure function of (seed, idx, frame),
+ // so exactly which pixels the pass owes work to is known ahead
+ // of the call.
+ let owed = before == 0.0 && PcgRng::should_fill(seed32, idx, pass, PROB);
+
+ if owed {
+ // No work skipped: every pixel this frame selected is now
+ // covered, holding its position gradient.
+ let x = idx % W;
+ let y = idx / W;
+ let gradient = ((x + y) as f32 / (W + H) as f32).max(0.001);
+ assert_eq!(
+ after, gradient,
+ "pass {pass} skipped pixel {idx} that frame {pass} selected"
+ );
+ newly_covered_total += 1;
+ } else {
+ // No work deferred, repeated, or invented: everything else
+ // is byte-identical to before the call. Covers monotonicity
+ // (no pixel un-covers) and rules out a pass that batches
+ // several frames of fill into one sweep.
+ assert_eq!(
+ after, before,
+ "pass {pass} changed pixel {idx} that frame {pass} did not select"
+ );
+ }
+ }
- let start = std::time::Instant::now();
- for _ in 0..100 {
- buffer.random_fill_pass(0.01);
+ previous.clone_from(&buffer.pixels);
}
- let elapsed = start.elapsed();
- // 100 frames on 10k pixels - generous for loaded systems
- assert!(elapsed.as_secs() < 30, "100 fill passes took {:?}", elapsed);
+ // Non-vacuity: the checks above must discriminate, not be satisfied by
+ // a buffer that stayed all-zero or saturated on the first pass.
+ assert!(
+ newly_covered_total > 0,
+ "no pixel was ever covered - the per-pass check is vacuous"
+ );
+ assert!(
+ newly_covered_total < (W * H) as usize,
+ "every pixel covered - PROB/PASSES too high to exercise both branches"
+ );
}
// =========================================================================
diff --git a/crates/aprender-train-distill/tests/validate_rejects_bad_config.rs b/crates/aprender-train-distill/tests/validate_rejects_bad_config.rs
new file mode 100644
index 0000000000..b0317d8af2
--- /dev/null
+++ b/crates/aprender-train-distill/tests/validate_rejects_bad_config.rs
@@ -0,0 +1,79 @@
+//! `aprender-train-distill validate` must reject a bad config.
+//!
+//! This binary is a thin clap wrapper over `ConfigValidator::validate`, the
+//! same validator `apr distill` reaches through `entrenar_distill::run`. Both
+//! directions are pinned: a config with an empty `teacher.model_id` must exit
+//! nonzero, and a well-formed one must exit zero. Asserting only the rejection
+//! would not exclude "validate rejects everything", and asserting only the
+//! acceptance would not exclude "validate accepts everything".
+
+use std::fs;
+use std::path::PathBuf;
+use std::process::Command;
+
+/// Write `yaml` into the per-target tmpdir under `name` and return its path.
+fn config(name: &str, yaml: &str) -> PathBuf {
+ let dir = PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join("validate_rejects_bad_config");
+ fs::create_dir_all(&dir).expect("create tmpdir");
+ let path = dir.join(name);
+ fs::write(&path, yaml).expect("write config");
+ path
+}
+
+/// Run `aprender-train-distill validate --config `.
+fn validate(path: &PathBuf) -> std::process::Output {
+ Command::new(env!("CARGO_BIN_EXE_aprender-train-distill"))
+ .args(["validate", "--config", path.to_str().expect("utf-8 path")])
+ .output()
+ .expect("run aprender-train-distill")
+}
+
+/// Parses as YAML, but `teacher.model_id` is empty — a validator error, not a
+/// deserialization error, so this exercises `ConfigValidator` rather than serde.
+const EMPTY_TEACHER: &str = r#"
+teacher:
+ model_id: ""
+student:
+ model_id: "TinyLlama/TinyLlama-1.1B"
+distillation: {}
+training: {}
+"#;
+
+const WELL_FORMED: &str = r#"
+teacher:
+ model_id: "meta-llama/Llama-2-7b"
+student:
+ model_id: "TinyLlama/TinyLlama-1.1B"
+distillation: {}
+training: {}
+"#;
+
+#[test]
+fn validate_rejects_an_empty_teacher_model_id() {
+ let out = validate(&config("empty_teacher.yaml", EMPTY_TEACHER));
+ let stderr = String::from_utf8_lossy(&out.stderr);
+
+ assert!(
+ !out.status.success(),
+ "validate exited 0 on a config with an empty teacher.model_id — the \
+ validator accepts anything. stdout:\n{}\nstderr:\n{stderr}",
+ String::from_utf8_lossy(&out.stdout)
+ );
+ assert!(
+ stderr.contains("teacher.model_id"),
+ "expected the diagnostic to name the offending field, got:\n{stderr}"
+ );
+}
+
+#[test]
+fn validate_accepts_a_well_formed_config() {
+ let out = validate(&config("well_formed.yaml", WELL_FORMED));
+
+ assert!(
+ out.status.success(),
+ "validate rejected a well-formed config — the validator rejects \
+ everything. stdout:\n{}\nstderr:\n{}",
+ String::from_utf8_lossy(&out.stdout),
+ String::from_utf8_lossy(&out.stderr)
+ );
+}
diff --git a/crates/aprender-verify-ml/tests/language_flag_reaches_generator.rs b/crates/aprender-verify-ml/tests/language_flag_reaches_generator.rs
new file mode 100644
index 0000000000..cf938a6d47
--- /dev/null
+++ b/crates/aprender-verify-ml/tests/language_flag_reaches_generator.rs
@@ -0,0 +1,69 @@
+//! `verificar generate --language` must actually select the grammar.
+//!
+//! The binary maps an unrecognised `--language` onto `Language::Python` with
+//! only a warning (see `parse_language` in `src/bin/verificar.rs`), so a
+//! silently-ignored flag would look identical to a working one on the default
+//! invocation. This test pins the observable difference: bash assignments have
+//! no spaces around `=` (`x=1`) while Python's do (`x = 1`). Asserting only
+//! that each run produced output would not exclude "every language emits
+//! Python".
+
+use std::process::Command;
+
+/// Run `verificar generate` for `language` with a fixed seed and depth.
+fn generate(language: &str) -> String {
+ let out = Command::new(env!("CARGO_BIN_EXE_verificar"))
+ .args([
+ "generate",
+ "--language",
+ language,
+ "--count",
+ "3",
+ "--max-depth",
+ "2",
+ "--seed",
+ "7",
+ ])
+ .output()
+ .expect("run verificar");
+
+ assert!(
+ out.status.success(),
+ "verificar generate --language {language} exited nonzero: {}",
+ String::from_utf8_lossy(&out.stderr)
+ );
+ String::from_utf8(out.stdout).expect("utf-8 stdout")
+}
+
+#[test]
+fn bash_and_python_generators_emit_different_syntax() {
+ let bash = generate("bash");
+ let python = generate("python");
+
+ assert_ne!(
+ bash, python,
+ "--language produced byte-identical output for bash and python; the \
+ flag is not reaching the generator"
+ );
+
+ // Bash forbids spaces around `=` in an assignment; Python requires them by
+ // convention and this generator emits them. Each check excludes the other
+ // language's output shape.
+ assert!(
+ bash.contains("x=") && !bash.contains("x = "),
+ "expected unspaced bash assignments, got:\n{bash}"
+ );
+ assert!(
+ python.contains("x = "),
+ "expected spaced python assignments, got:\n{python}"
+ );
+}
+
+#[test]
+fn generation_is_deterministic_for_a_fixed_seed() {
+ assert_eq!(
+ generate("python"),
+ generate("python"),
+ "two runs at --seed 7 diverged; generation is not reproducible"
+ );
+}