2026-07-25 07:20:10 +00:00
|
|
|
use std::fs;
|
|
|
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
|
use std::process::Command;
|
|
|
|
|
|
|
|
|
|
fn yoke_bin() -> PathBuf {
|
|
|
|
|
let mut path = std::env::current_exe()
|
|
|
|
|
.expect("current_exe")
|
|
|
|
|
.parent()
|
|
|
|
|
.expect("parent of test binary")
|
|
|
|
|
.parent()
|
|
|
|
|
.expect("parent of deps dir")
|
|
|
|
|
.to_path_buf();
|
|
|
|
|
path.push("yoke");
|
|
|
|
|
path
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn build_yoke() {
|
|
|
|
|
let status = Command::new("cargo")
|
|
|
|
|
.args(["build", "--quiet"])
|
|
|
|
|
.status()
|
|
|
|
|
.expect("cargo build");
|
|
|
|
|
assert!(status.success(), "cargo build failed");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn git(project: &Path, args: &[&str]) {
|
|
|
|
|
let out = Command::new("git")
|
|
|
|
|
.args(args)
|
|
|
|
|
.current_dir(project)
|
|
|
|
|
.env("GIT_CONFIG_NOSYSTEM", "1")
|
|
|
|
|
.env("GIT_AUTHOR_NAME", "test")
|
|
|
|
|
.env("GIT_AUTHOR_EMAIL", "test@test")
|
|
|
|
|
.env("GIT_COMMITTER_NAME", "test")
|
|
|
|
|
.env("GIT_COMMITTER_EMAIL", "test@test")
|
|
|
|
|
.output()
|
|
|
|
|
.unwrap_or_else(|e| panic!("git {:?} failed: {}", args, e));
|
|
|
|
|
assert!(
|
|
|
|
|
out.status.success(),
|
|
|
|
|
"git {:?} failed: {}",
|
|
|
|
|
args,
|
|
|
|
|
String::from_utf8_lossy(&out.stderr)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn seed_project(project: &Path) {
|
|
|
|
|
fs::write(
|
|
|
|
|
project.join("Cargo.toml"),
|
|
|
|
|
"[package]\nname = \"grind-subject\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[lib]\npath = \"src/lib.rs\"\n",
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
fs::create_dir(project.join("src")).unwrap();
|
|
|
|
|
fs::write(project.join("src/lib.rs"), "pub fn hot() -> u32 { 1 }\n").unwrap();
|
|
|
|
|
|
|
|
|
|
git(project, &["init"]);
|
|
|
|
|
git(project, &["add", "Cargo.toml", "src/lib.rs"]);
|
|
|
|
|
git(
|
|
|
|
|
project,
|
|
|
|
|
&[
|
|
|
|
|
"-c",
|
|
|
|
|
"user.name=test",
|
|
|
|
|
"-c",
|
|
|
|
|
"user.email=test@test",
|
|
|
|
|
"commit",
|
|
|
|
|
"-m",
|
|
|
|
|
"init",
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn write_executable(path: &Path, content: &str) {
|
|
|
|
|
fs::write(path, content).unwrap();
|
|
|
|
|
fs::set_permissions(path, fs::Permissions::from_mode(0o755)).unwrap();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn mock_path(mock_bin_dir: &Path) -> String {
|
|
|
|
|
let original_path = std::env::var("PATH").unwrap_or_default();
|
|
|
|
|
format!("{}:{}", mock_bin_dir.display(), original_path)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn constant_cstat(cost: f64) -> String {
|
|
|
|
|
let score = format!("{cost:.1}");
|
|
|
|
|
format!(
|
|
|
|
|
"#!/usr/bin/env bash\nset -euo pipefail\nprintf '{{\"cstat_version\":\"fake\",\"score_version\":\"code_complexity_cost_v0\",\"target\":\".\",\"code_complexity_cost\":{score},\"top_contributors\":[{{\"kind\":\"function\",\"file\":\"src/lib.rs\",\"function\":\"hot\",\"cost\":{score}}}]}}\\n'\n"
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn run_yoke_init_grind(yoke: &Path, project: &Path, test_path: &str) -> std::process::Output {
|
|
|
|
|
Command::new(yoke)
|
|
|
|
|
.args(["init", "grind"])
|
|
|
|
|
.current_dir(project)
|
|
|
|
|
.env("PATH", test_path)
|
|
|
|
|
.output()
|
|
|
|
|
.expect("yoke init grind")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn init_grind_creates_profile_and_captures_baseline() {
|
|
|
|
|
build_yoke();
|
|
|
|
|
let yoke = yoke_bin();
|
|
|
|
|
let tmp = tempfile::tempdir().expect("tempdir");
|
|
|
|
|
let project = tmp.path();
|
|
|
|
|
seed_project(project);
|
|
|
|
|
|
|
|
|
|
let mock_bin_dir = project.join("mock-bin");
|
|
|
|
|
fs::create_dir(&mock_bin_dir).unwrap();
|
|
|
|
|
write_executable(&mock_bin_dir.join("cstat"), &constant_cstat(5.0));
|
|
|
|
|
let test_path = mock_path(&mock_bin_dir);
|
|
|
|
|
|
|
|
|
|
let output = run_yoke_init_grind(&yoke, project, &test_path);
|
|
|
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
|
|
|
assert!(
|
|
|
|
|
output.status.success(),
|
|
|
|
|
"yoke init grind should exit 0. exit={:?}\nstderr:\n{}",
|
|
|
|
|
output.status.code(),
|
|
|
|
|
stderr
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let loop_dir = project.join(".loop");
|
|
|
|
|
for name in [
|
|
|
|
|
"grind-gate.py",
|
|
|
|
|
"cstat-baseline.json",
|
|
|
|
|
"protocol.md",
|
|
|
|
|
"yoke.conf",
|
|
|
|
|
"notes.md",
|
|
|
|
|
"guard-results.md",
|
2026-07-28 08:32:38 +00:00
|
|
|
"judge.md",
|
|
|
|
|
"verdict.md",
|
2026-07-25 07:20:10 +00:00
|
|
|
] {
|
|
|
|
|
assert!(loop_dir.join(name).exists(), "missing .loop/{name}");
|
|
|
|
|
}
|
2026-07-28 08:32:38 +00:00
|
|
|
assert!(
|
|
|
|
|
loop_dir.join("archive").is_dir(),
|
|
|
|
|
"missing .loop/archive/ note archive"
|
|
|
|
|
);
|
2026-07-25 07:20:10 +00:00
|
|
|
for name in ["grind.md", "plan.md"] {
|
|
|
|
|
assert!(!loop_dir.join(name).exists(), "unexpected .loop/{name}");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let baseline = fs::read_to_string(loop_dir.join("cstat-baseline.json")).unwrap();
|
|
|
|
|
let compact: String = baseline.chars().filter(|c| !c.is_whitespace()).collect();
|
|
|
|
|
assert!(
|
|
|
|
|
compact.contains("\"code_complexity_cost\":5.0"),
|
|
|
|
|
"baseline should contain captured scorecard cost. baseline:\n{}",
|
|
|
|
|
baseline
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let conf = fs::read_to_string(loop_dir.join("yoke.conf")).unwrap();
|
|
|
|
|
assert!(
|
|
|
|
|
conf.contains("guard python3 .loop/grind-gate.py"),
|
|
|
|
|
"grind guard must be configured. yoke.conf:\n{}",
|
|
|
|
|
conf
|
|
|
|
|
);
|
2026-07-28 08:32:38 +00:00
|
|
|
assert!(
|
|
|
|
|
conf.contains("grind-passes 1"),
|
|
|
|
|
"grind pass count must default to one. yoke.conf:\n{}",
|
|
|
|
|
conf
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
conf.contains("# judge-every 3"),
|
|
|
|
|
"grind config should document optional judge cadence. yoke.conf:\n{}",
|
|
|
|
|
conf
|
|
|
|
|
);
|
2026-07-25 07:20:10 +00:00
|
|
|
assert!(
|
|
|
|
|
conf.contains("# no-modify tests/"),
|
|
|
|
|
"tests no-modify example should be commented. yoke.conf:\n{}",
|
|
|
|
|
conf
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
!conf
|
|
|
|
|
.lines()
|
|
|
|
|
.any(|line| line.trim_start().starts_with("no-modify tests/")),
|
|
|
|
|
"tests no-modify rule must not be active by default. yoke.conf:\n{}",
|
|
|
|
|
conf
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let protocol = fs::read_to_string(loop_dir.join("protocol.md")).unwrap();
|
|
|
|
|
assert!(
|
|
|
|
|
!protocol.contains(".loop/plan.md"),
|
|
|
|
|
"grind protocol must not reference plan.md. protocol:\n{}",
|
|
|
|
|
protocol
|
|
|
|
|
);
|
2026-07-28 08:32:38 +00:00
|
|
|
assert!(
|
|
|
|
|
protocol.contains("grind-passes"),
|
|
|
|
|
"grind protocol should document pass-count completion. protocol:\n{}",
|
|
|
|
|
protocol
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
protocol.contains("grind mode ignores it for completion"),
|
|
|
|
|
"grind protocol should remove status-based completion. protocol:\n{}",
|
|
|
|
|
protocol
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
protocol.contains(".loop/archive/<name>.md"),
|
|
|
|
|
"grind protocol should document the secondary note archive. protocol:\n{}",
|
|
|
|
|
protocol
|
|
|
|
|
);
|
2026-07-25 07:20:10 +00:00
|
|
|
for command in [
|
|
|
|
|
"loc",
|
|
|
|
|
"symbols",
|
|
|
|
|
"branching",
|
|
|
|
|
"signature",
|
|
|
|
|
"span",
|
|
|
|
|
"scorecard",
|
|
|
|
|
"deps",
|
|
|
|
|
"dead-code",
|
|
|
|
|
"test-reachability",
|
|
|
|
|
"call-trace",
|
|
|
|
|
"coverage",
|
|
|
|
|
"cluster",
|
|
|
|
|
] {
|
|
|
|
|
assert!(
|
|
|
|
|
protocol.contains(&format!("`{command}`")),
|
|
|
|
|
"grind protocol should document cstat {command}. protocol:\n{}",
|
|
|
|
|
protocol
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
assert!(
|
|
|
|
|
protocol.contains("Do not stop after one simplification"),
|
|
|
|
|
"grind protocol should push agents past timid one-edit cleanup. protocol:\n{}",
|
|
|
|
|
protocol
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn grind_dry_run_rejects_without_cstat_improvement() {
|
|
|
|
|
build_yoke();
|
|
|
|
|
let yoke = yoke_bin();
|
|
|
|
|
let tmp = tempfile::tempdir().expect("tempdir");
|
|
|
|
|
let project = tmp.path();
|
|
|
|
|
seed_project(project);
|
|
|
|
|
|
|
|
|
|
let mock_bin_dir = project.join("mock-bin");
|
|
|
|
|
fs::create_dir(&mock_bin_dir).unwrap();
|
|
|
|
|
write_executable(&mock_bin_dir.join("cstat"), &constant_cstat(5.0));
|
|
|
|
|
let test_path = mock_path(&mock_bin_dir);
|
|
|
|
|
|
|
|
|
|
let init = run_yoke_init_grind(&yoke, project, &test_path);
|
|
|
|
|
assert!(
|
|
|
|
|
init.status.success(),
|
|
|
|
|
"init failed: {}",
|
|
|
|
|
String::from_utf8_lossy(&init.stderr)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let output = Command::new(&yoke)
|
|
|
|
|
.args(["run", "--dry-run"])
|
|
|
|
|
.current_dir(project)
|
|
|
|
|
.env("PATH", &test_path)
|
|
|
|
|
.env("GRIND_ORACLE", "true")
|
|
|
|
|
.output()
|
|
|
|
|
.expect("yoke run --dry-run");
|
|
|
|
|
assert!(
|
|
|
|
|
!output.status.success(),
|
|
|
|
|
"dry run should reject unchanged cstat score"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let guard_results = fs::read_to_string(project.join(".loop/guard-results.md")).unwrap();
|
|
|
|
|
assert!(
|
|
|
|
|
guard_results.contains("GRIND: FAIL"),
|
|
|
|
|
"guard results should show grind failure. guard-results:\n{}",
|
|
|
|
|
guard_results
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
guard_results.contains("cstat scorecard did not improve"),
|
|
|
|
|
"guard results should explain unchanged score. guard-results:\n{}",
|
|
|
|
|
guard_results
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-28 08:32:38 +00:00
|
|
|
#[test]
|
|
|
|
|
fn grind_counts_gate_passes_without_status_and_runs_optional_judge() {
|
|
|
|
|
build_yoke();
|
|
|
|
|
let yoke = yoke_bin();
|
|
|
|
|
let tmp = tempfile::tempdir().expect("tempdir");
|
|
|
|
|
let project = tmp.path();
|
|
|
|
|
seed_project(project);
|
|
|
|
|
|
|
|
|
|
let mock_bin_dir = project.join("mock-bin");
|
|
|
|
|
fs::create_dir(&mock_bin_dir).unwrap();
|
|
|
|
|
write_executable(
|
|
|
|
|
&mock_bin_dir.join("cstat"),
|
|
|
|
|
r#"#!/usr/bin/env bash
|
|
|
|
|
set -euo pipefail
|
|
|
|
|
count_file=".cstat-count"
|
|
|
|
|
count=0
|
|
|
|
|
if [ -f "$count_file" ]; then
|
|
|
|
|
count=$(cat "$count_file")
|
|
|
|
|
fi
|
|
|
|
|
count=$((count + 1))
|
|
|
|
|
printf '%s\n' "$count" > "$count_file"
|
|
|
|
|
if [ "$count" -eq 1 ]; then
|
|
|
|
|
cost=10.0
|
|
|
|
|
else
|
|
|
|
|
cost=8.0
|
|
|
|
|
fi
|
|
|
|
|
printf '{"cstat_version":"fake","score_version":"code_complexity_cost_v0","target":".","code_complexity_cost":%s,"top_contributors":[{"kind":"function","file":"src/lib.rs","function":"hot","cost":%s}]}\n' "$cost" "$cost"
|
|
|
|
|
"#,
|
|
|
|
|
);
|
|
|
|
|
write_executable(
|
|
|
|
|
&mock_bin_dir.join("omp"),
|
|
|
|
|
r#"#!/usr/bin/env bash
|
|
|
|
|
set -euo pipefail
|
|
|
|
|
args="$*"
|
|
|
|
|
if [[ "$args" == *".loop/judge.md"* ]]; then
|
|
|
|
|
count_file=".judge-count"
|
|
|
|
|
count=0
|
|
|
|
|
if [ -f "$count_file" ]; then
|
|
|
|
|
count=$(cat "$count_file")
|
|
|
|
|
fi
|
|
|
|
|
count=$((count + 1))
|
|
|
|
|
printf '%s\n' "$count" > "$count_file"
|
|
|
|
|
cat > .loop/verdict.md <<'EOF'
|
|
|
|
|
VERDICT: PASS
|
|
|
|
|
|
|
|
|
|
grind gate passed and cleanup remains acceptable
|
|
|
|
|
EOF
|
|
|
|
|
exit 0
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
count_file=".worker-count"
|
|
|
|
|
count=0
|
|
|
|
|
if [ -f "$count_file" ]; then
|
|
|
|
|
count=$(cat "$count_file")
|
|
|
|
|
fi
|
|
|
|
|
count=$((count + 1))
|
|
|
|
|
printf '%s\n' "$count" > "$count_file"
|
|
|
|
|
if [ "$count" -gt 2 ]; then
|
|
|
|
|
exit 42
|
|
|
|
|
fi
|
|
|
|
|
printf 'pass %s completed without status\n' "$count" > .loop/notes.md
|
|
|
|
|
exit 0
|
|
|
|
|
"#,
|
|
|
|
|
);
|
|
|
|
|
let test_path = mock_path(&mock_bin_dir);
|
|
|
|
|
|
|
|
|
|
let init = run_yoke_init_grind(&yoke, project, &test_path);
|
|
|
|
|
assert!(
|
|
|
|
|
init.status.success(),
|
|
|
|
|
"init failed: {}",
|
|
|
|
|
String::from_utf8_lossy(&init.stderr)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let conf_path = project.join(".loop/yoke.conf");
|
|
|
|
|
let conf = fs::read_to_string(&conf_path)
|
|
|
|
|
.unwrap()
|
|
|
|
|
.replace("grind-passes 1", "grind-passes 2\njudge-every 1");
|
|
|
|
|
fs::write(&conf_path, conf).unwrap();
|
|
|
|
|
|
|
|
|
|
let output = Command::new(&yoke)
|
|
|
|
|
.args(["run"])
|
|
|
|
|
.current_dir(project)
|
|
|
|
|
.env("PATH", &test_path)
|
|
|
|
|
.env("GRIND_ORACLE", "true")
|
|
|
|
|
.output()
|
|
|
|
|
.expect("yoke run");
|
|
|
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
|
|
|
assert!(
|
|
|
|
|
output.status.success(),
|
|
|
|
|
"yoke run should complete two judged grind passes. exit={:?}\nstderr:\n{}",
|
|
|
|
|
output.status.code(),
|
|
|
|
|
stderr
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let worker_count = fs::read_to_string(project.join(".worker-count")).unwrap();
|
|
|
|
|
assert_eq!(
|
|
|
|
|
worker_count.trim(),
|
|
|
|
|
"2",
|
|
|
|
|
"grind-passes=2 should invoke exactly two workers"
|
|
|
|
|
);
|
|
|
|
|
let judge_count = fs::read_to_string(project.join(".judge-count")).unwrap();
|
|
|
|
|
assert_eq!(
|
|
|
|
|
judge_count.trim(),
|
|
|
|
|
"2",
|
|
|
|
|
"judge-every=1 should judge each successful grind pass"
|
|
|
|
|
);
|
|
|
|
|
let notes = fs::read_to_string(project.join(".loop/notes.md")).unwrap();
|
|
|
|
|
assert!(
|
|
|
|
|
!notes.starts_with("STATUS:"),
|
|
|
|
|
"grind completion must not depend on STATUS notes. notes:\n{}",
|
|
|
|
|
notes
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-25 07:20:10 +00:00
|
|
|
#[test]
|
|
|
|
|
fn grind_restores_gate_and_baseline_before_guard() {
|
|
|
|
|
build_yoke();
|
|
|
|
|
let yoke = yoke_bin();
|
|
|
|
|
let tmp = tempfile::tempdir().expect("tempdir");
|
|
|
|
|
let project = tmp.path();
|
|
|
|
|
seed_project(project);
|
|
|
|
|
|
|
|
|
|
let mock_bin_dir = project.join("mock-bin");
|
|
|
|
|
fs::create_dir(&mock_bin_dir).unwrap();
|
|
|
|
|
write_executable(
|
|
|
|
|
&mock_bin_dir.join("cstat"),
|
|
|
|
|
r#"#!/usr/bin/env bash
|
|
|
|
|
set -euo pipefail
|
|
|
|
|
count_file=".cstat-count"
|
|
|
|
|
count=0
|
|
|
|
|
if [ -f "$count_file" ]; then
|
|
|
|
|
count=$(cat "$count_file")
|
|
|
|
|
fi
|
|
|
|
|
count=$((count + 1))
|
|
|
|
|
printf '%s\n' "$count" > "$count_file"
|
|
|
|
|
if [ "$count" -eq 1 ]; then
|
|
|
|
|
cost=10.0
|
|
|
|
|
else
|
|
|
|
|
cost=8.0
|
|
|
|
|
fi
|
|
|
|
|
printf '{"cstat_version":"fake","score_version":"code_complexity_cost_v0","target":".","code_complexity_cost":%s,"top_contributors":[{"kind":"function","file":"src/lib.rs","function":"hot","cost":%s}]}\n' "$cost" "$cost"
|
|
|
|
|
"#,
|
|
|
|
|
);
|
|
|
|
|
write_executable(
|
|
|
|
|
&mock_bin_dir.join("omp"),
|
|
|
|
|
r#"#!/usr/bin/env bash
|
|
|
|
|
set -euo pipefail
|
|
|
|
|
printf 'STATUS: DONE\n' > .loop/notes.md
|
|
|
|
|
cat > .loop/grind-gate.py <<'PY'
|
|
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
import sys
|
|
|
|
|
sys.exit(99)
|
|
|
|
|
PY
|
|
|
|
|
printf '{"code_complexity_cost":1.0}\n' > .loop/cstat-baseline.json
|
|
|
|
|
exit 0
|
|
|
|
|
"#,
|
|
|
|
|
);
|
|
|
|
|
let test_path = mock_path(&mock_bin_dir);
|
|
|
|
|
|
|
|
|
|
let init = run_yoke_init_grind(&yoke, project, &test_path);
|
|
|
|
|
assert!(
|
|
|
|
|
init.status.success(),
|
|
|
|
|
"init failed: {}",
|
|
|
|
|
String::from_utf8_lossy(&init.stderr)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let output = Command::new(&yoke)
|
|
|
|
|
.args(["run"])
|
|
|
|
|
.current_dir(project)
|
|
|
|
|
.env("PATH", &test_path)
|
|
|
|
|
.env("GRIND_ORACLE", "true")
|
|
|
|
|
.output()
|
|
|
|
|
.expect("yoke run");
|
|
|
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
|
|
|
assert!(
|
|
|
|
|
output.status.success(),
|
|
|
|
|
"yoke run should pass after restoring protected grind files. exit={:?}\nstderr:\n{}",
|
|
|
|
|
output.status.code(),
|
|
|
|
|
stderr
|
|
|
|
|
);
|
|
|
|
|
}
|