use std::{
fs,
path::{Path, PathBuf},
process::{Command, Output},
time::{SystemTime, UNIX_EPOCH},
};
fn binary() -> &'static str {
env!("CARGO_BIN_EXE_shepherd")
}
fn fixture(label: &str) -> PathBuf {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock is after epoch")
.as_nanos();
let root = std::env::temp_dir().join(format!(
"shepherd-wave-d-{label}-{}-{nonce:x}",
std::process::id()
));
fs::create_dir_all(&root).expect("create fixture");
let status = Command::new("git")
.args(["init", "--quiet"])
.current_dir(&root)
.status()
.expect("initialize fixture git repository");
assert!(status.success(), "git init must succeed");
let eol = Command::new("git")
.args(["config", "core.autocrlf", "false"])
.current_dir(&root)
.status()
.expect("pin fixture end-of-line policy");
assert!(eol.success(), "git config core.autocrlf must succeed");
root
}
fn invoke(root: &Path, args: &[&str]) -> Output {
Command::new(binary())
.args(args)
.current_dir(root)
.env("SHEPHERD_HOME", root.join("isolated-home"))
.env("SHEPHERD_MODEL_QUOTA", "4")
.output()
.expect("run shepherd binary")
}
#[cfg(unix)]
fn invoke_with_path(root: &Path, args: &[&str], path: &std::ffi::OsStr) -> Output {
Command::new(binary())
.args(args)
.current_dir(root)
.env("PATH", path)
.env("SHEPHERD_HOME", root.join("isolated-home"))
.env("SHEPHERD_MODEL_QUOTA", "4")
.output()
.expect("run shepherd binary with controlled PATH")
}
#[cfg(unix)]
fn executable_on_path(name: &str) -> PathBuf {
use std::os::unix::fs::PermissionsExt;
std::env::split_paths(&std::env::var_os("PATH").expect("test PATH"))
.map(|directory| directory.join(name))
.find(|candidate| {
fs::metadata(candidate).is_ok_and(|metadata| {
metadata.is_file() && metadata.permissions().mode() & 0o111 != 0
})
})
.unwrap_or_else(|| panic!("{name} must be available on PATH"))
}
fn plan_v2_source(run: &str) -> String {
let host_process_ceiling = std::thread::available_parallelism()
.map(usize::from)
.unwrap_or(1);
let effective_process_ceiling = host_process_ceiling.min(2);
let deliverables = [
"deliverable-flock",
"deliverable-planning",
"deliverable-carriers",
"deliverable-portability",
"deliverable-custody",
"deliverable-release",
];
let lanes = (0..deliverables.len())
.map(|index| format!("lane-{index}"))
.collect::<Vec<_>>();
let bindings = |prefix: &str| {
(0..deliverables.len())
.map(|index| format!("lane-{index}={prefix}-{index}"))
.collect::<Vec<_>>()
.join(", ")
};
let conductors = (0..deliverables.len())
.map(|index| format!("lane-{index}=conductor"))
.collect::<Vec<_>>()
.join(", ");
let mut source = format!(
"# Plan: {run}\n\
## Scope contract\n\
- Schema: shepherd.plan/2\n\
- Run: {run}\n\
- Seed: .shepherd/runs/{run}/seed.md\n\
- Mesh: .shepherd/runs/{run}/mesh.md\n\
- Planning evidence: .shepherd/runs/{run}/phase0.md\n\
- Goal: ship measured outcomes\n\
- Deliverables: [{}]\n\
- Lanes: [{}]\n\
- Root roles: [shepherd, planter]\n\
- Child lead roles: [engineer, conductor]\n\
- Planning lead: engineer\n\
- Engineer count: 1\n\
- Review rejection limit: 3\n\
- Fourth rejection: malignant-revoke-quarantine-preserve-evidence-no-resume-root-lineage-replacement\n\
- Root continuation: fresh-root-preferred-new-run-binding-clears-child-authority\n\
- Exclusions: [native state, unowned work]\n\
## Assumptions and decisions\n\
| id | statement | source | owner | blocking | evidence/disposition |\n\
| a-1 | repository facts are verified | phase0 | engineer | false | accepted |\n\
## Interfaces\n\
| id | version | producer | consumers | acceptance |\n\
| source | 1 | baseline | all nodes | exact |\n\
## Phases\n\
| phase | predecessor frontier | node ids | disjointness proof |\n\
| build | baseline | all | exact paths |\n\
## Capacity\n\
```yaml\n\
logical_lane_limit: 3\n\
host_process_ceiling: {host_process_ceiling}\n\
project_spawn_max_parallel: 4\n\
plan_process_ceiling: 2\n\
parent_role_cap: 2\n\
run_budget: 2\n\
simultaneous_process_ceiling: {effective_process_ceiling}\n\
per_lane_child_wave_ceiling: 8\n\
disk_min_mib: 1024\n\
model_quota: 2\n\
backpressure: queue-fair\n\
cargo_targets: [{}]\n\
conductors: [{conductors}]\n\
schedule: [lane-0+lane-1@{effective_process_ceiling}, lane-2+lane-3@{effective_process_ceiling}, lane-4+lane-5@{effective_process_ceiling}]\n\
scale_outcome: outcome-plan-v2\n\
```\n\
## Nodes\n",
deliverables.join(", "),
lanes.join(", "),
bindings("plan-v2"),
);
for (index, deliverable) in deliverables.iter().enumerate() {
source.push_str(&format!(
"### node-{index}\n\
```yaml\n\
id: node-{index}\n\
seed_deliverables: [{deliverable}]\n\
lane: lane-{index}\n\
role: coder\n\
work_kind: production\n\
outcome: lane {index} accepted\n\
owns: [src/lane-{index}.rs]\n\
forbidden: [.shepherd, src/other-{index}.rs]\n\
consumes: [source@1]\n\
produces: [lane-{index}@1]\n\
depends_on: []\n\
red: {{command: [cargo, test, -p, lane-{index}], expects: failure, reason: regression proves absence}}\n\
green: {{command: [cargo, test, -p, lane-{index}], expects: success, reason: regression proves behavior}}\n\
eval: {{command: [cargo, test, -p, lane-{index}, --test, eval], threshold: 80}}\n\
evidence: .shepherd/runs/{run}/lanes/lane-{index}/evidence.json\n\
review: auditor: exact lane acceptance\n\
failure_route: conductor-to-root\n\
rollback: revert lane-{index} owned paths\n\
```\n"
));
}
source
}
fn setup_plan_v2(root: &Path) {
setup_run(root);
let run = root.join(".shepherd/runs/v900");
let seed = include_str!("../../../.shepherd/runs/v657/seed.md")
.replace("v657", "v900")
.replace(" result: \"", " result: \"All checks pass: ");
fs::write(run.join("seed.md"), seed).expect("write typed seed");
let mesh = include_str!("../../../.shepherd/runs/v657/mesh.md").replace("v657", "v900");
fs::write(run.join("mesh.md"), mesh).expect("write mesh");
fs::write(run.join("phase0.md"), "# Orientation\n\nverified\n").expect("write phase0");
fs::write(run.join("plan.md"), plan_v2_source("v900")).expect("write plan-v2");
let persisted = invoke(
root,
&[
"run",
"set",
"v900",
"--seed",
".shepherd/runs/v900/seed.md",
],
);
assert!(persisted.status.success(), "{}", text(&persisted.stderr));
}
fn commit_fixture(root: &Path) -> String {
let add = Command::new("git")
.args(["add", "."])
.current_dir(root)
.status()
.expect("git add fixture");
assert!(add.success());
let commit = Command::new("git")
.args([
"-c",
"user.name=Shepherd Tests",
"-c",
"user.email=shepherd-tests@example.invalid",
"commit",
"--quiet",
"-m",
"fixture",
])
.current_dir(root)
.status()
.expect("commit fixture");
assert!(commit.success());
text(
&Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(root)
.output()
.expect("read fixture head")
.stdout,
)
.trim()
.to_owned()
}
fn worktree_identity(root: &Path) -> String {
let canonical = fs::canonicalize(root).expect("canonical worktree");
shepherd_cli::cmd::planning::worktree_identity(&canonical).expect("worktree identity")
}
fn text(bytes: &[u8]) -> String {
String::from_utf8_lossy(bytes).into_owned()
}
fn setup_run(root: &Path) {
let init = invoke(root, &["init", "--confirm"]);
assert!(init.status.success(), "init stderr={}", text(&init.stderr));
let run = invoke(root, &["run", "init", "v900"]);
assert!(
run.status.success(),
"run init stderr={}",
text(&run.stderr)
);
}
#[test]
fn plan_hash_validate_topology_and_reports_are_run_scoped() {
let root = fixture("plan-report");
setup_run(&root);
let run = root.join(".shepherd/runs/v900");
fs::write(run.join("plan.md"), "# Sprint\nlane: l2\nlane: l1\n").expect("write plan");
fs::create_dir_all(run.join("audits")).expect("create audit fixture");
fs::create_dir_all(run.join("reports")).expect("create report fixture");
fs::write(run.join("audits/review.md"), "# Audit\npass\n").expect("write audit");
fs::write(
run.join("reports/discovery-pass.md"),
"# Discovery\nfound\n",
)
.expect("write discovery");
let validate = invoke(&root, &["plan", "validate", "--run", "v900"]);
assert_eq!(validate.status.code(), Some(0));
assert_eq!(
text(&validate.stdout),
"OK: 1 heading(s), 2 declared lane(s)\n"
);
let hash = invoke(&root, &["plan", "hash", "--run", "v900"]);
assert_eq!(hash.status.code(), Some(0));
assert_eq!(
text(&hash.stdout),
"0716f6a0b7c6756d689b6059ea838828ede3979298e5b33166a675593a9afd1c\n"
);
let topology = invoke(&root, &["plan", "topology", "--run", "v900", "--json"]);
assert_eq!(topology.status.code(), Some(0));
let topology: serde_json::Value =
serde_json::from_slice(&topology.stdout).expect("topology json");
assert_eq!(topology["schema"], "shepherd.plan-topology/1");
assert_eq!(topology["lanes"], serde_json::json!(["l1", "l2"]));
let audit = invoke(&root, &["report", "audit", "--run", "v900"]);
assert_eq!(audit.status.code(), Some(0));
assert!(text(&audit.stdout).contains("# Audit"));
let discovery = invoke(&root, &["discovery", "--run", "v900"]);
assert_eq!(discovery.status.code(), Some(0));
assert!(text(&discovery.stdout).contains("# Discovery"));
fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn plan_verify_rejects_legacy_heading_only_acceptance_without_mutating_state() {
let root = fixture("plan-verify-legacy");
setup_run(&root);
let run = root.join(".shepherd/runs/v900");
fs::write(run.join("plan.md"), "# Sprint\nlane: l1\n").expect("write legacy plan");
let state = fs::read(run.join("run.json")).expect("state before verification");
let verified = invoke(&root, &["plan", "verify", "--run", "v900", "--json"]);
assert_eq!(
verified.status.code(),
Some(6),
"legacy headings became execution-ready: stdout={} stderr={}",
text(&verified.stdout),
text(&verified.stderr)
);
assert!(text(&verified.stderr).contains("plan parse error"));
assert_eq!(fs::read(run.join("run.json")).expect("state after"), state);
assert!(!run.join("plan-probes.json").exists());
let diagnostic = invoke(&root, &["plan", "validate", "--run", "v900"]);
assert!(
diagnostic.status.success(),
"legacy inspection remains available"
);
fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn plan_verify_requires_projections_and_fresh_canonical_owned_path_probes() {
let root = fixture("plan-verify-ready");
setup_plan_v2(&root);
let run = root.join(".shepherd/runs/v900");
fs::create_dir_all(root.join("src")).expect("source directory");
for index in 0..6 {
fs::write(
root.join(format!("src/lane-{index}.rs")),
"fn accepted() {}\n",
)
.expect("owned source fixture");
}
let missing_projection = invoke(&root, &["plan", "verify", "--run", "v900"]);
assert_eq!(missing_projection.status.code(), Some(6));
let materialized = invoke(&root, &["plan", "materialize", "--run", "v900"]);
assert!(
materialized.status.success(),
"{}",
text(&materialized.stderr)
);
let baseline = commit_fixture(&root);
let no_probes = invoke(&root, &["plan", "verify", "--run", "v900"]);
assert_eq!(no_probes.status.code(), Some(6));
assert!(text(&no_probes.stderr).contains("plan-probes.json"));
let mut probes = serde_json::json!({
"schema": "shepherd.plan-probes/1",
"worktree_identity": worktree_identity(&root),
"baseline": baseline,
"probes": (0..6).map(|index| serde_json::json!({
"kind": "path", "path": format!("src/lane-{index}.rs"),
"expectation": "modify", "path_kind": "file"
})).collect::<Vec<_>>()
});
let probes_path = run.join("plan-probes.json");
fs::write(
&probes_path,
serde_json::to_vec(&probes).expect("probes JSON"),
)
.expect("canonical probes");
let state = fs::read(run.join("run.json")).expect("state before verify");
let verified = invoke(&root, &["plan", "verify", "--run", "v900", "--json"]);
assert!(verified.status.success(), "{}", text(&verified.stderr));
let report: serde_json::Value =
serde_json::from_slice(&verified.stdout).expect("readiness JSON");
assert_eq!(report["schema"], "shepherd.plan-readiness/1");
assert_eq!(report["run"], "v900");
assert_eq!(report["baseline"], baseline);
assert_eq!(report["probe_count"], 6);
assert_eq!(
report["run_artifacts"]
.as_object()
.expect("artifact inventory")
.len(),
12
);
assert_eq!(
fs::read(run.join("run.json")).expect("read-only state"),
state
);
probes["probes"].as_array_mut().expect("probe list").pop();
fs::write(
&probes_path,
serde_json::to_vec(&probes).expect("incomplete JSON"),
)
.expect("missing owned path probe");
let incomplete = invoke(&root, &["plan", "verify", "--run", "v900"]);
assert_eq!(incomplete.status.code(), Some(6));
assert!(text(&incomplete.stderr).contains("src/lane-5.rs"));
probes["probes"] = serde_json::json!([]);
fs::write(
&probes_path,
serde_json::to_vec(&probes).expect("empty JSON"),
)
.expect("empty probes");
let empty = invoke(&root, &["plan", "verify", "--run", "v900"]);
assert_eq!(empty.status.code(), Some(6));
assert_eq!(
fs::read(run.join("run.json")).expect("failed verify state"),
state
);
fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn linked_worktree_plan_verification_uses_selected_artifacts_and_native_run_state() {
let root = fixture("plan-selected-worktree");
setup_plan_v2(&root);
fs::create_dir_all(root.join("src")).expect("source directory");
for index in 0..6 {
fs::write(
root.join(format!("src/lane-{index}.rs")),
"fn accepted() {}\n",
)
.expect("source fixture");
}
let materialized = invoke(&root, &["plan", "materialize", "--run", "v900"]);
assert!(
materialized.status.success(),
"{}",
text(&materialized.stderr)
);
let baseline = commit_fixture(&root);
let linked = root.with_file_name(format!(
"{}-linked",
root.file_name().expect("fixture name").to_string_lossy()
));
let added = Command::new("git")
.args(["worktree", "add", "--detach", "--quiet"])
.arg(&linked)
.arg(&baseline)
.current_dir(&root)
.status()
.expect("linked worktree");
assert!(added.success());
let run = linked.join(".shepherd/runs/v900");
fs::remove_file(run.join("run.json")).expect("no shadow native state in authoring worktree");
let primary_state =
fs::read(root.join(".shepherd/runs/v900/run.json")).expect("primary native state");
let primary_graph =
fs::read(root.join(".shepherd/runs/v900/graph/topology.json")).expect("primary projection");
fs::write(
root.join(".shepherd/runs/v900/plan.md"),
"# stale primary worktree plan\n",
)
.expect("stale primary plan");
let selected = invoke(
&linked,
&["plan", "materialize", "--run", "v900", "--check"],
);
assert!(
selected.status.success(),
"selected plan was not validated against native held state: {}",
text(&selected.stderr)
);
let probes = serde_json::json!({
"schema":"shepherd.plan-probes/1", "worktree_identity":worktree_identity(&linked), "baseline":baseline,
"probes": (0..6).map(|index| serde_json::json!({"kind":"path","path":format!("src/lane-{index}.rs"),"expectation":"modify","path_kind":"file"})).collect::<Vec<_>>()
});
fs::write(
run.join("plan-probes.json"),
serde_json::to_vec(&probes).expect("probe JSON"),
)
.expect("selected probes");
let verified = invoke(&linked, &["plan", "verify", "--run", "v900", "--json"]);
assert!(verified.status.success(), "{}", text(&verified.stderr));
let report: serde_json::Value = serde_json::from_slice(&verified.stdout).expect("readiness");
assert_eq!(
report["worktree_root"],
fs::canonicalize(&linked)
.expect("canonical linked root")
.to_string_lossy()
.as_ref()
);
assert!(!run.join("run.json").exists());
assert_eq!(
fs::read(root.join(".shepherd/runs/v900/run.json")).expect("unchanged primary state"),
primary_state
);
assert_eq!(
fs::read(root.join(".shepherd/runs/v900/graph/topology.json"))
.expect("unchanged primary projection"),
primary_graph
);
fs::remove_dir_all(&linked).expect("clean linked fixture");
fs::remove_dir_all(root).expect("clean primary fixture");
}
#[test]
fn render_uses_project_template_root_and_returns_provenance() {
let root = fixture("render");
setup_run(&root);
let missing = invoke(&root, &["render", "absent"]);
assert_eq!(missing.status.code(), Some(3));
fs::create_dir_all(root.join(".shepherd/templates")).expect("create template root");
fs::write(
root.join(".shepherd/templates/greeting.j2"),
"{{ greeting }}, {{ name }}!\n",
)
.expect("write template");
let rendered = invoke(
&root,
&[
"render",
"greeting",
"--vars-json",
r#"{"greeting":"Hello","name":"Base"}"#,
"--var",
"name=Shepherd",
"--json",
],
);
assert_eq!(
rendered.status.code(),
Some(0),
"stderr={}",
text(&rendered.stderr)
);
let value: serde_json::Value = serde_json::from_slice(&rendered.stdout).expect("render json");
assert_eq!(value["text"], "Hello, Shepherd!\n");
assert_eq!(
value["manifest"]["template_sha256"].as_str().map(str::len),
Some(64)
);
assert_eq!(
value["manifest"]["vars_sha256"].as_str().map(str::len),
Some(64)
);
assert_eq!(
value["manifest"]["output_sha256"].as_str().map(str::len),
Some(64)
);
let escape = invoke(&root, &["render", "../greeting"]);
assert_eq!(escape.status.code(), Some(2));
assert!(text(&escape.stderr).contains("safe relative path"));
fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn graph_is_read_only_and_malformed_state_fails_closed() {
let root = fixture("graph");
setup_run(&root);
let graph = root.join(".shepherd/runs/v900/graph");
fs::create_dir_all(&graph).expect("create graph fixture");
fs::write(
graph.join("state.json"),
r#"{"nodes":[{"id":"start","label":"Start"},{"id":"finish"}],"edges":[{"from":"start","to":"finish"}]}"#,
)
.expect("write state");
fs::write(graph.join("trace.jsonl"), "{\"event\":\"start\"}\n").expect("write trace");
let diagram = invoke(&root, &["graph", "diagram", "--run", "v900"]);
assert_eq!(
diagram.status.code(),
Some(0),
"stderr={}",
text(&diagram.stderr)
);
assert_eq!(
text(&diagram.stdout),
"flowchart TD\n start[\"Start\"]\n finish[\"finish\"]\n start --> finish\n"
);
let trace = invoke(&root, &["graph", "trace", "--run", "v900", "--json"]);
assert_eq!(trace.status.code(), Some(0));
assert_eq!(
serde_json::from_slice::<serde_json::Value>(&trace.stdout).expect("trace json")["records"],
serde_json::json!([{"event":"start"}])
);
fs::write(
graph.join("state.json"),
"{\"nodes\":[{\"id\":\"bad-id\"}],\"edges\":[]}",
)
.expect("write malformed state");
let malformed = invoke(&root, &["graph", "diagram", "--run", "v900"]);
assert_eq!(malformed.status.code(), Some(6));
assert!(text(&malformed.stderr).contains("Mermaid-safe"));
fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn close_lane_updates_only_the_registered_lane_in_the_run_ledger() {
let root = fixture("close-lane");
setup_run(&root);
let add = invoke(
&root,
&[
"run",
"lane",
"add",
"v900",
"l1-engine",
"--branch",
"lane/l1",
],
);
assert_eq!(add.status.code(), Some(0), "stderr={}", text(&add.stderr));
let clean = invoke(
&root,
&[
"close-lane",
"--run",
"v900",
"l1-engine",
"--status",
"clean",
],
);
assert_eq!(clean.status.code(), Some(0));
assert_eq!(
text(&clean.stdout),
"lane l1-engine closed in v900 (clean)\n"
);
let complete = invoke(&root, &["run", "show", "v900"]);
assert!(text(&complete.stdout).contains("l1-engine: complete"));
let partial = invoke(
&root,
&[
"close-lane",
"--run",
"v900",
"l1-engine",
"--status",
"partial",
],
);
assert_eq!(partial.status.code(), Some(0));
let error = invoke(&root, &["run", "show", "v900"]);
assert!(text(&error.stdout).contains("l1-engine: error"));
let invalid = invoke(
&root,
&[
"close-lane",
"--run",
"v900",
"l1-engine",
"--status",
"bogus",
],
);
assert_eq!(invalid.status.code(), Some(2));
let unchanged = invoke(&root, &["run", "show", "v900"]);
assert!(text(&unchanged.stdout).contains("l1-engine: error"));
fs::remove_dir_all(root).expect("cleanup");
}
#[cfg(unix)]
#[test]
fn artifact_and_template_symlinks_are_refused_without_reading_targets() {
use std::os::unix::fs::symlink;
let root = fixture("symlinks");
setup_run(&root);
let run = root.join(".shepherd/runs/v900");
let outside = root.join("outside.md");
fs::write(&outside, "OUTSIDE-SENTINEL\n").expect("write outside fixture");
symlink(&outside, run.join("plan.md")).expect("link plan");
let plan = invoke(&root, &["plan", "hash", "--run", "v900"]);
assert_eq!(plan.status.code(), Some(2));
assert!(!text(&plan.stdout).contains("OUTSIDE-SENTINEL"));
let outside_audits = root.join("outside-audits");
fs::create_dir_all(&outside_audits).expect("create outside audit directory");
fs::write(outside_audits.join("audit.md"), "OUTSIDE-SENTINEL\n").expect("write audit");
symlink(&outside_audits, run.join("audits")).expect("link audits");
let audit = invoke(&root, &["report", "audit", "--run", "v900"]);
assert_eq!(audit.status.code(), Some(2));
assert!(!text(&audit.stdout).contains("OUTSIDE-SENTINEL"));
fs::create_dir_all(root.join(".shepherd/templates")).expect("create template root");
symlink(&outside, root.join(".shepherd/templates/greeting.j2")).expect("link template");
let render = invoke(&root, &["render", "greeting", "--var", "name=Shepherd"]);
assert_eq!(render.status.code(), Some(2));
assert!(!text(&render.stdout).contains("OUTSIDE-SENTINEL"));
fs::remove_dir_all(root).expect("cleanup");
}
#[cfg(unix)]
#[test]
fn descriptor_read_never_returns_content_from_a_raced_symlink_swap() {
use std::{
os::unix::fs::symlink,
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
thread,
};
let root = fixture("swap");
setup_run(&root);
let run = root.join(".shepherd/runs/v900");
let target = run.join("plan.md");
let parked = run.join("plan.parked.md");
let outside = root.join("outside.md");
fs::write(&target, "# Safe\n").expect("write safe plan");
fs::write(&outside, "OUTSIDE-SENTINEL\n").expect("write outside plan");
let baseline = invoke(&root, &["plan", "hash", "--run", "v900"]);
assert_eq!(baseline.status.code(), Some(0));
let safe_hash = text(&baseline.stdout);
let stop = Arc::new(AtomicBool::new(false));
let thread_stop = Arc::clone(&stop);
let swap_target = target.clone();
let swap_parked = parked.clone();
let swap_outside = outside.clone();
let swapper = thread::spawn(move || {
while !thread_stop.load(Ordering::Relaxed) {
let _ = fs::rename(&swap_target, &swap_parked);
let _ = symlink(&swap_outside, &swap_target);
let _ = fs::remove_file(&swap_target);
let _ = fs::rename(&swap_parked, &swap_target);
}
});
for _ in 0..64 {
let result = invoke(&root, &["plan", "hash", "--run", "v900"]);
if result.status.success() {
assert_eq!(text(&result.stdout), safe_hash);
}
assert!(!text(&result.stdout).contains("OUTSIDE-SENTINEL"));
assert!(!text(&result.stderr).contains("OUTSIDE-SENTINEL"));
}
stop.store(true, Ordering::Relaxed);
swapper.join().expect("join swapper");
fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn plan_v2_materializes_atomic_byte_identical_topology_and_lane_slices() {
let root = fixture("plan-v2-materialize");
setup_plan_v2(&root);
let materialize = invoke(&root, &["plan", "materialize", "--run", "v900"]);
assert_eq!(
materialize.status.code(),
Some(0),
"stderr={}",
text(&materialize.stderr)
);
let topology_path = root.join(".shepherd/runs/v900/graph/topology.json");
let lane_path = root.join(".shepherd/runs/v900/lanes/lane-0/plan.md");
let topology = fs::read(&topology_path).expect("materialized topology");
let lane = fs::read(&lane_path).expect("materialized lane");
assert!(text(&topology).contains("shepherd.plan-topology/2"));
assert!(text(&lane).contains("# Lane: lane-0"));
assert!(!text(&lane).contains("node-1"));
let check = invoke(&root, &["plan", "materialize", "--run", "v900", "--check"]);
assert_eq!(
check.status.code(),
Some(0),
"stderr={}",
text(&check.stderr)
);
assert_eq!(fs::read(&topology_path).expect("topology repeat"), topology);
assert_eq!(fs::read(&lane_path).expect("lane repeat"), lane);
fs::write(&topology_path, b"stale topology\n").expect("tamper projection");
let stale = invoke(&root, &["plan", "materialize", "--run", "v900", "--check"]);
assert_eq!(stale.status.code(), Some(6));
assert_eq!(
fs::read(&topology_path).expect("read stale"),
b"stale topology\n"
);
let repair = invoke(&root, &["plan", "materialize", "--run", "v900"]);
assert_eq!(
repair.status.code(),
Some(0),
"stderr={}",
text(&repair.stderr)
);
assert_eq!(
fs::read(&topology_path).expect("repaired topology"),
topology
);
fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn plan_v2_environment_check_binds_identity_baseline_probes_and_semantic_marker() {
let root = fixture("plan-v2-check");
setup_plan_v2(&root);
fs::create_dir_all(root.join("src")).expect("create source fixture");
fs::write(root.join("src/lane-0.rs"), "fn accepted_symbol() {}\n")
.expect("write source fixture");
fs::create_dir_all(root.join("contracts")).expect("create contracts fixture");
fs::write(
root.join("contracts/example.json"),
"{\"schema\":\"shepherd.example/1\",\"version\":\"1\"}\n",
)
.expect("write interface fixture");
let baseline = commit_fixture(&root);
let probes_path = root.join(".shepherd/runs/v900/plan-probes.json");
let probes = serde_json::json!({
"schema": "shepherd.plan-probes/1",
"worktree_identity": worktree_identity(&root),
"baseline": baseline,
"probes": [
{"kind":"path", "path":"src/lane-0.rs", "expectation":"modify", "path_kind":"file"},
{"kind":"symbol", "path":"src/lane-0.rs", "symbol":"accepted_symbol", "expected_matches":1},
{"kind":"interface", "path":"contracts/example.json", "schema":"shepherd.example/1", "version":"1"},
{"kind":"command", "argv":["git","rev-parse","--verify","HEAD"], "expected_exit":0, "semantic_marker":baseline}
]
});
fs::write(
&probes_path,
serde_json::to_vec_pretty(&probes).expect("encode probes"),
)
.expect("write probes");
let worktree = fs::canonicalize(&root)
.expect("canonical worktree")
.to_string_lossy()
.into_owned();
let checked = invoke(
&root,
&[
"plan",
"check",
"--run",
"v900",
"--worktree",
&worktree,
"--probes",
".shepherd/runs/v900/plan-probes.json",
"--json",
],
);
assert_eq!(
checked.status.code(),
Some(0),
"stderr={}",
text(&checked.stderr)
);
let report: serde_json::Value =
serde_json::from_slice(&checked.stdout).expect("plan check report");
assert_eq!(report["schema"], "shepherd.plan-check-report/1");
assert_eq!(report["report"]["probe_count"], 4);
assert_eq!(report["topology_sha256"].as_str().map(str::len), Some(64));
assert_eq!(report["probes_sha256"].as_str().map(str::len), Some(64));
let aliased_probe_path = format!("{worktree}/.shepherd/runs/v900/../v900/plan-probes.json");
for rejected_path in [aliased_probe_path.as_str(), "C:/outside/plan-probes.json"] {
let rejected = invoke(
&root,
&[
"plan",
"check",
"--run",
"v900",
"--worktree",
&worktree,
"--probes",
rejected_path,
"--json",
],
);
assert_eq!(
rejected.status.code(),
Some(2),
"unsafe probe manifest path was accepted: {rejected_path}; stderr={}",
text(&rejected.stderr)
);
}
let sentinel = root.join("unsafe-sentinel");
let mut unsafe_probes = probes;
unsafe_probes["probes"] = serde_json::json!([{
"kind":"command",
"argv":["bash","-c",format!("touch {}", sentinel.display())],
"expected_exit":0,
"semantic_marker":"self-attested"
}]);
fs::write(
&probes_path,
serde_json::to_vec_pretty(&unsafe_probes).expect("encode unsafe probes"),
)
.expect("replace probes");
let rejected = invoke(
&root,
&[
"plan",
"check",
"--run",
"v900",
"--worktree",
&worktree,
"--probes",
".shepherd/runs/v900/plan-probes.json",
"--json",
],
);
assert_eq!(rejected.status.code(), Some(6));
assert!(!sentinel.exists(), "unsafe shell probe executed");
fs::remove_dir_all(root).expect("cleanup");
}
#[cfg(unix)]
#[test]
fn plan_v2_environment_check_never_executes_checkout_path_shims() {
use std::os::unix::fs::PermissionsExt;
let root = fixture("plan-v2-path-shim");
setup_plan_v2(&root);
let baseline = commit_fixture(&root);
let probes_path = root.join(".shepherd/runs/v900/plan-probes.json");
let probes = serde_json::json!({
"schema": "shepherd.plan-probes/1",
"worktree_identity": worktree_identity(&root),
"baseline": baseline,
"probes": [{
"kind":"command",
"argv":["git","rev-parse","--verify","HEAD"],
"expected_exit":0,
"semantic_marker":baseline
}]
});
fs::write(
&probes_path,
serde_json::to_vec_pretty(&probes).expect("encode probes"),
)
.expect("write probes");
let shims = root.join("checkout-bin");
fs::create_dir(&shims).expect("create checkout shim directory");
let sentinel = root.join("path-shim-executed");
let real_git = executable_on_path("git");
let shim = shims.join("git");
fs::write(
&shim,
format!(
"#!/bin/sh\n: > '{}'\nexec '{}' \"$@\"\n",
sentinel.display(),
real_git.display()
),
)
.expect("write checkout git shim");
let mut permissions = fs::metadata(&shim).expect("shim metadata").permissions();
permissions.set_mode(0o755);
fs::set_permissions(&shim, permissions).expect("make shim executable");
let existing = std::env::var_os("PATH").expect("test PATH");
let path = std::env::join_paths(std::iter::once(shims).chain(std::env::split_paths(&existing)))
.expect("controlled PATH");
let worktree = fs::canonicalize(&root)
.expect("canonical worktree")
.to_string_lossy()
.into_owned();
let checked = invoke_with_path(
&root,
&[
"plan",
"check",
"--run",
"v900",
"--worktree",
&worktree,
"--probes",
".shepherd/runs/v900/plan-probes.json",
],
&path,
);
assert_eq!(
checked.status.code(),
Some(0),
"stderr={}",
text(&checked.stderr)
);
assert!(
!sentinel.exists(),
"plan check executed a checkout-controlled PATH shim"
);
fs::remove_dir_all(root).expect("cleanup");
}
#[cfg(unix)]
#[test]
fn plan_v2_materialization_refuses_symlinked_projection_targets() {
use std::os::unix::fs::symlink;
let root = fixture("plan-v2-output-link");
setup_plan_v2(&root);
let first = invoke(&root, &["plan", "materialize", "--run", "v900"]);
assert_eq!(
first.status.code(),
Some(0),
"stderr={}",
text(&first.stderr)
);
let topology = root.join(".shepherd/runs/v900/graph/topology.json");
let outside = root.join("outside.json");
fs::write(&outside, b"outside sentinel\n").expect("outside fixture");
fs::remove_file(&topology).expect("remove projection fixture");
symlink(&outside, &topology).expect("link projection");
let rejected = invoke(&root, &["plan", "materialize", "--run", "v900"]);
assert_eq!(rejected.status.code(), Some(2));
assert_eq!(
fs::read(&outside).expect("outside unchanged"),
b"outside sentinel\n"
);
fs::remove_dir_all(root).expect("cleanup");
}