use std::process::{Command, Output};
use std::time::SystemTime;
use std::{
ops::Deref,
path::{Path, PathBuf},
};
fn binary() -> &'static str {
env!("CARGO_BIN_EXE_shepherd")
}
struct FixtureDir {
_guard: tempfile::TempDir,
root: PathBuf,
}
impl Deref for FixtureDir {
type Target = Path;
fn deref(&self) -> &Self::Target {
&self.root
}
}
impl AsRef<Path> for FixtureDir {
fn as_ref(&self) -> &Path {
&self.root
}
}
fn fixture(label: &str) -> FixtureDir {
let guard = tempfile::Builder::new()
.prefix(&format!("shepherd-wave-c-bootstrap-{label}-"))
.tempdir()
.expect("create fixture root");
let root = std::fs::canonicalize(guard.path()).expect("canonical fixture root");
let status = Command::new("git")
.args(["init", "--quiet"])
.current_dir(&root)
.status()
.expect("initialize fixture git repository");
assert!(status.success(), "git init must succeed");
FixtureDir {
_guard: guard,
root,
}
}
fn invoke(root: &Path, args: &[&str]) -> Output {
Command::new(binary())
.args(args)
.current_dir(root)
.env("SHEPHERD_HOME", root.join("isolated-home"))
.output()
.expect("run shepherd binary")
}
fn invoke_with_path(root: &Path, args: &[&str], path: &str) -> Output {
Command::new(binary())
.args(args)
.current_dir(root)
.env("SHEPHERD_HOME", root.join("isolated-home"))
.env("PATH", path)
.output()
.expect("run shepherd binary with an overridden PATH")
}
fn path_without_any_shepherd() -> String {
let real = std::env::var_os("PATH").unwrap_or_default();
let filtered: Vec<PathBuf> = std::env::split_paths(&real)
.filter(|dir| !dir.join("shepherd").exists() && !dir.join("shepherd.exe").exists())
.collect();
std::env::join_paths(filtered)
.expect("join filtered PATH")
.to_string_lossy()
.into_owned()
}
fn text(bytes: &[u8]) -> String {
String::from_utf8_lossy(bytes).into_owned()
}
fn registry_path(root: &Path) -> PathBuf {
root.join(".shepherd/shepherd.db")
}
fn identity_path(root: &Path) -> PathBuf {
root.join(".shepherd/project.json")
}
fn project_identity_id(path: &Path) -> String {
let bytes = std::fs::read(path).expect("read project identity");
let value: serde_json::Value =
serde_json::from_slice(&bytes).expect("project identity must be valid JSON");
value["id"]
.as_str()
.expect("project identity id must be a string")
.to_owned()
}
fn project_identity_document(path: &Path) -> serde_json::Value {
serde_json::from_slice(&std::fs::read(path).expect("read project identity"))
.expect("project identity must be valid JSON")
}
fn count_project_rows(database: &Path) -> i64 {
let connection = rusqlite::Connection::open(database).expect("open registry");
connection
.query_row("SELECT COUNT(*) FROM projects", [], |row| row.get(0))
.expect("count projects rows")
}
#[test]
fn init_refuses_unconfirmed_mutation_then_materializes_only_layout_v5_roots() {
let root = fixture("init");
let refused = invoke(&root, &["init"]);
assert_eq!(refused.status.code(), Some(2));
assert_eq!(
text(&refused.stderr),
"ERROR: init is mutating; re-run with --confirm\n"
);
assert!(!root.join(".shepherd").exists());
let initialized = invoke(&root, &["init", "--confirm"]);
assert_eq!(
initialized.status.code(),
Some(0),
"stderr={}",
text(&initialized.stderr)
);
assert!(
text(&initialized.stdout).starts_with("initialized layout-v5 namespace: "),
"stdout={}",
text(&initialized.stdout)
);
for directory in ["docs", "ctx", "runs"] {
assert!(
root.join(".shepherd").join(directory).is_dir(),
"{directory} must be part of the approved layout-v5 project shape"
);
}
assert!(root.join(".shepherd/shepherd.toml").is_file());
assert!(root.join(".shepherd/shepherd.db").is_file());
for retired in ["graph", "reports", "audits", "plans", "dispatch"] {
assert!(
!root.join(".shepherd").join(retired).exists(),
"init must not recreate retired root {retired}"
);
}
let identity = identity_path(&root);
let identity_metadata =
std::fs::symlink_metadata(&identity).expect("project identity must exist");
assert!(
identity_metadata.is_file(),
"project identity must be a regular file"
);
let id = project_identity_id(&identity);
let parsed = uuid::Uuid::parse_str(&id).expect("project identity id must be a valid uuid");
assert_eq!(
parsed.get_version_num(),
7,
"project identity id must be a uuid v7"
);
let database = registry_path(&root);
let connection = rusqlite::Connection::open(&database).expect("open registry");
let rows: Vec<String> = connection
.prepare("SELECT id FROM projects")
.expect("prepare projects query")
.query_map([], |row| row.get(0))
.expect("query projects")
.collect::<Result<Vec<_>, _>>()
.expect("collect project rows");
assert_eq!(
rows,
vec![id],
"the projects table must hold exactly one row matching the identity file"
);
let repeated = invoke(&root, &["init", "--confirm"]);
assert_eq!(
repeated.status.code(),
Some(0),
"stderr={}",
text(&repeated.stderr)
);
drop(connection);
}
#[test]
fn config_reads_typed_defaults_and_requires_confirmation_to_create_its_document() {
let root = fixture("config");
let expected = std::fs::canonicalize(&root)
.expect("canonical root")
.join(".shepherd/shepherd.toml");
let path = invoke(&root, &["config", "path"]);
assert_eq!(path.status.code(), Some(0), "stderr={}", text(&path.stderr));
assert_eq!(text(&path.stdout), format!("{}\n", expected.display()));
assert!(!root.join(".shepherd").exists(), "path is read-only");
let value = invoke(&root, &["config", "get", "paths.runs"]);
assert_eq!(
value.status.code(),
Some(0),
"stderr={}",
text(&value.stderr)
);
assert_eq!(text(&value.stdout), ".shepherd/runs\n");
assert!(!root.join(".shepherd").exists(), "get is read-only");
let refused = invoke(&root, &["config", "init"]);
assert_eq!(refused.status.code(), Some(2));
assert_eq!(
text(&refused.stderr),
"ERROR: config init is mutating; re-run with --confirm\n"
);
assert!(!root.join(".shepherd").exists());
let initialized = invoke(&root, &["config", "init", "--confirm"]);
assert_eq!(
initialized.status.code(),
Some(0),
"stderr={}",
text(&initialized.stderr)
);
assert!(root.join(".shepherd/shepherd.toml").is_file());
let show = invoke(&root, &["config", "show"]);
assert_eq!(show.status.code(), Some(0), "stderr={}", text(&show.stderr));
assert!(text(&show.stdout).contains("\"paths\""));
}
#[test]
fn home_and_doctor_are_read_only_until_explicitly_confirmed() {
let root = fixture("home-doctor");
let home = root.join("isolated-home");
let which = invoke(&root, &["home", "which"]);
assert_eq!(
which.status.code(),
Some(0),
"stderr={}",
text(&which.stderr)
);
assert_eq!(text(&which.stdout), format!("{}\n", home.display()));
assert!(!home.exists(), "home which must not create the path");
let show = invoke(&root, &["home", "show"]);
assert_eq!(show.status.code(), Some(0), "stderr={}", text(&show.stderr));
assert_eq!(text(&show.stdout), format!("home: {}\n", home.display()));
assert!(!home.exists(), "home show must not create the path");
let refused = invoke(&root, &["home", "init"]);
assert_eq!(refused.status.code(), Some(2));
assert_eq!(
text(&refused.stderr),
"ERROR: home init is mutating; re-run with --confirm\n"
);
assert!(!home.exists());
let sick = invoke(&root, &["doctor", "--json"]);
assert_eq!(sick.status.code(), Some(3));
let report: serde_json::Value = serde_json::from_slice(&sick.stdout).expect("doctor JSON");
assert_eq!(report["ok"], false);
assert!(
!root.join(".shepherd").exists(),
"doctor must not bootstrap"
);
let initialized = invoke(&root, &["home", "init", "--confirm"]);
assert_eq!(
initialized.status.code(),
Some(0),
"stderr={}",
text(&initialized.stderr)
);
assert!(home.is_dir());
assert!(
!home.join("profiles").exists(),
"unused profile authority must not be recreated"
);
assert!(
!home.join("templates").exists(),
"user templates have no native resolver and must not be recreated"
);
let project = invoke(&root, &["init", "--confirm"]);
assert_eq!(
project.status.code(),
Some(0),
"stderr={}",
text(&project.stderr)
);
let healthy = invoke(&root, &["doctor", "--json"]);
assert_eq!(
healthy.status.code(),
Some(0),
"stderr={}",
text(&healthy.stderr)
);
let report: serde_json::Value = serde_json::from_slice(&healthy.stdout).expect("doctor JSON");
assert_eq!(report["ok"], true);
assert!(report["registry_schema"].as_u64().is_some());
}
#[test]
fn init_user_option_bootstraps_only_the_separately_resolved_user_home() {
let root = fixture("init-user");
let home = root.join("isolated-home");
let initialized = invoke(&root, &["init", "--confirm", "--user"]);
assert_eq!(
initialized.status.code(),
Some(0),
"stderr={}",
text(&initialized.stderr)
);
assert!(home.is_dir());
assert!(!home.join("profiles").exists());
assert!(!home.join("templates").exists());
assert!(
!home.join("runs").exists(),
"project roots never leak into user home"
);
}
#[cfg(unix)]
#[test]
fn init_refuses_a_symlink_namespace_without_touching_its_target() {
use std::os::unix::fs::symlink;
let root = fixture("symlink");
let outside = root.join("outside");
std::fs::create_dir_all(&outside).expect("create outside target");
symlink(&outside, root.join(".shepherd")).expect("create namespace symlink");
let result = invoke(&root, &["init", "--confirm"]);
assert_ne!(result.status.code(), Some(0));
assert!(!outside.join("docs").exists());
assert!(!outside.join("shepherd.toml").exists());
assert!(!outside.join("shepherd.db").exists());
}
#[cfg(unix)]
#[test]
fn init_refuses_an_existing_config_symlink_instead_of_reporting_success() {
use std::os::unix::fs::symlink;
let root = fixture("config-symlink");
let namespace = root.join(".shepherd");
std::fs::create_dir_all(&namespace).expect("create real namespace");
let outside = root.join("outside-shepherd.toml");
let original = b"# external config must remain untouched\n";
std::fs::write(&outside, original).expect("write external config");
symlink(&outside, namespace.join("shepherd.toml")).expect("create config symlink");
let result = invoke(&root, &["init", "--confirm"]);
assert_ne!(result.status.code(), Some(0));
assert!(text(&result.stderr).contains("symbolic") || text(&result.stderr).contains("link"));
assert_eq!(
std::fs::read(&outside).expect("read external config"),
original
);
assert!(!namespace.join("shepherd.db").exists());
}
#[test]
fn top_level_migrate_stays_an_explicit_dry_run_until_confirmation() {
let root = fixture("migrate");
let namespace = root.join(".shepherd");
std::fs::create_dir_all(namespace.join("runs/v645")).expect("create canonical run root");
std::fs::create_dir_all(namespace.join("docs/specs")).expect("create legacy docs root");
std::fs::write(
namespace.join("runs/v645/run.json"),
b"{\"run\":\"v645\",\"schema_version\":1,\"status\":\"planned\"}\n",
)
.expect("write run document");
std::fs::write(namespace.join("docs/specs/legacy.md"), b"legacy\n")
.expect("write legacy document");
let result = invoke(&root, &["migrate", "--dry-run"]);
assert_eq!(
result.status.code(),
Some(0),
"stderr={}",
text(&result.stderr)
);
let report: serde_json::Value = serde_json::from_slice(&result.stdout).expect("migrate JSON");
assert_eq!(report["schema"], "shepherd-migrate-v5");
assert!(report["execution"].is_null());
assert!(namespace.join("docs/specs/legacy.md").is_file());
assert!(!namespace.join("docs/legacy.md").exists());
}
#[test]
fn doctor_fails_loudly_on_a_namespace_missing_project_identity() {
let root = fixture("doctor-missing-identity");
let initialized = invoke(&root, &["init", "--confirm"]);
assert_eq!(
initialized.status.code(),
Some(0),
"stderr={}",
text(&initialized.stderr)
);
std::fs::remove_file(identity_path(&root)).expect("remove project identity");
let sick = invoke(&root, &["doctor"]);
assert_eq!(sick.status.code(), Some(3));
let stdout = text(&sick.stdout);
assert!(
!stdout.contains("status: ok"),
"doctor must not report ok on a namespace missing identity: {stdout}"
);
assert!(
stdout.contains("project identity is absent"),
"doctor must name the missing identity: {stdout}"
);
let sick_json = invoke(&root, &["doctor", "--json"]);
assert_eq!(sick_json.status.code(), Some(3));
let report: serde_json::Value = serde_json::from_slice(&sick_json.stdout).expect("doctor JSON");
assert_eq!(report["ok"], false);
}
#[test]
fn init_rolls_back_everything_it_created_when_identity_cannot_be_written() {
let root = fixture("atomic-rollback");
let namespace = root.join(".shepherd");
std::fs::create_dir_all(namespace.join("project.json"))
.expect("pre-create a conflicting project.json directory");
let result = invoke(&root, &["init", "--confirm"]);
assert_ne!(
result.status.code(),
Some(0),
"stdout={}",
text(&result.stdout)
);
for directory in ["docs", "ctx", "runs"] {
assert!(
!namespace.join(directory).exists(),
"{directory} must be rolled back after a failed init"
);
}
assert!(
!namespace.join("shepherd.toml").exists(),
"shepherd.toml must be rolled back after a failed init"
);
assert!(
!namespace.join("shepherd.db").exists(),
"shepherd.db must be rolled back after a failed init"
);
assert!(
namespace.join("project.json").is_dir(),
"the pre-existing project.json directory must survive untouched"
);
}
#[test]
fn init_is_idempotent_and_heals_a_namespace_missing_only_identity() {
let root = fixture("idempotent-heal");
let database = registry_path(&root);
let identity = identity_path(&root);
let first = invoke(&root, &["init", "--confirm"]);
assert_eq!(
first.status.code(),
Some(0),
"stderr={}",
text(&first.stderr)
);
let first_id = project_identity_id(&identity);
assert_eq!(count_project_rows(&database), 1);
let repeated = invoke(&root, &["init", "--confirm"]);
assert_eq!(
repeated.status.code(),
Some(0),
"stderr={}",
text(&repeated.stderr)
);
assert_eq!(
project_identity_id(&identity),
first_id,
"a repeated init must not mint a new identity"
);
assert_eq!(
count_project_rows(&database),
1,
"a repeated init must not duplicate the projects row"
);
std::fs::remove_file(&identity).expect("remove project identity");
rusqlite::Connection::open(&database)
.expect("open registry")
.execute("DELETE FROM projects", [])
.expect("clear projects row");
let healed = invoke(&root, &["init", "--confirm"]);
assert_eq!(
healed.status.code(),
Some(0),
"stderr={}",
text(&healed.stderr)
);
assert!(identity.is_file(), "heal must recreate project identity");
assert_eq!(
count_project_rows(&database),
1,
"heal must register exactly one project row"
);
}
#[test]
fn init_confirm_upgrades_a_rootless_legacy_identity_atomically_and_idempotently() {
let root = fixture("rootless-legacy");
let identity = identity_path(&root);
let first = invoke(&root, &["init", "--confirm"]);
assert_eq!(first.status.code(), Some(0), "{}", text(&first.stderr));
let original = project_identity_document(&identity);
let legacy = serde_json::json!({
"id": original["id"],
"scaffolded_at": original["scaffolded_at"],
});
let legacy_bytes = serde_json::to_vec(&legacy).expect("legacy identity JSON");
std::fs::write(&identity, &legacy_bytes).expect("write rootless legacy identity");
assert_eq!(count_project_rows(®istry_path(&root)), 1);
let healed = invoke(&root, &["init", "--confirm"]);
assert_eq!(healed.status.code(), Some(0), "{}", text(&healed.stderr));
let upgraded = project_identity_document(&identity);
assert_eq!(upgraded["id"], original["id"]);
assert_eq!(upgraded["scaffolded_at"], original["scaffolded_at"]);
assert_eq!(
upgraded["root"],
root.canonicalize()
.expect("canonical fixture root")
.display()
.to_string()
);
let upgraded_bytes = std::fs::read(&identity).expect("upgraded identity");
let repeated = invoke(&root, &["init", "--confirm"]);
assert_eq!(
repeated.status.code(),
Some(0),
"{}",
text(&repeated.stderr)
);
assert_eq!(
std::fs::read(&identity).expect("repeated identity"),
upgraded_bytes,
"repeated init must not rewrite an already-bound identity"
);
assert_eq!(count_project_rows(®istry_path(&root)), 1);
}
#[test]
fn init_and_doctor_reject_explicit_null_root_without_rebinding_or_registry_mutation() {
let root = fixture("explicit-null-root");
let identity = identity_path(&root);
let initialized = invoke(&root, &["init", "--confirm"]);
assert!(
initialized.status.success(),
"{}",
text(&initialized.stderr)
);
let mut invalid = project_identity_document(&identity);
invalid["root"] = serde_json::Value::Null;
let identity_before = serde_json::to_vec(&invalid).expect("null-root identity");
std::fs::write(&identity, &identity_before).expect("write null-root identity");
let registry_before = std::fs::read(registry_path(&root)).expect("registry before refusal");
let doctor = invoke(&root, &["doctor", "--json"]);
assert_eq!(doctor.status.code(), Some(3));
let report: serde_json::Value = serde_json::from_slice(&doctor.stdout).expect("doctor JSON");
let findings = report["findings"].as_array().expect("doctor findings");
assert!(
findings.iter().any(|finding| {
finding
.as_str()
.is_some_and(|finding| finding.contains("project identity is invalid"))
}),
"doctor must diagnose corruption, not legacy recovery: {report}"
);
let refused = invoke(&root, &["init", "--confirm"]);
assert!(!refused.status.success(), "explicit null root was rebound");
assert!(text(&refused.stderr).contains("invalid project identity document"));
assert_eq!(
std::fs::read(&identity).expect("identity after refusal"),
identity_before
);
assert_eq!(
std::fs::read(registry_path(&root)).expect("registry after refusal"),
registry_before
);
}
#[test]
fn doctor_diagnoses_a_rootless_legacy_identity_before_registry_repair() {
let root = fixture("doctor-rootless-legacy");
let identity = identity_path(&root);
let initialized = invoke(&root, &["init", "--confirm"]);
assert_eq!(
initialized.status.code(),
Some(0),
"{}",
text(&initialized.stderr)
);
let original = project_identity_document(&identity);
std::fs::write(
&identity,
serde_json::to_vec(&serde_json::json!({
"id": original["id"],
"scaffolded_at": original["scaffolded_at"],
}))
.expect("legacy identity JSON"),
)
.expect("write rootless identity");
let doctor = invoke(&root, &["doctor", "--json"]);
assert_eq!(doctor.status.code(), Some(3));
let report: serde_json::Value = serde_json::from_slice(&doctor.stdout).expect("doctor JSON");
assert_eq!(report["ok"], false);
let findings = report["findings"].as_array().expect("doctor findings");
assert!(findings.iter().any(|finding| {
finding
.as_str()
.is_some_and(|finding| finding.contains("project identity root is absent"))
}));
assert!(
findings.iter().any(|finding| {
finding
.as_str()
.is_some_and(|finding| finding.contains("shepherd init --confirm"))
}),
"doctor must name the confirmed root-binding remedy: {report}"
);
}
#[test]
fn init_confirm_binds_rootless_identity_before_explicit_repair_of_a_degraded_registry() {
let root = fixture("rootless-degraded-registry");
let identity = identity_path(&root);
let initialized = invoke(&root, &["init", "--confirm"]);
assert_eq!(
initialized.status.code(),
Some(0),
"{}",
text(&initialized.stderr)
);
let original = project_identity_document(&identity);
std::fs::write(
&identity,
serde_json::to_vec(&serde_json::json!({
"id": original["id"],
"scaffolded_at": original["scaffolded_at"],
}))
.expect("legacy identity JSON"),
)
.expect("write rootless identity");
rusqlite::Connection::open(registry_path(&root))
.expect("open registry")
.execute_batch(
"PRAGMA foreign_keys = OFF;
DROP INDEX idx_singleton_publications_preparing;
DROP INDEX idx_singleton_publications_key;
DROP TABLE dispatch_singleton_publications;
DROP INDEX idx_singleton_claims_publication_nonce;
ALTER TABLE dispatch_singleton_claims DROP COLUMN publication_nonce;
DELETE FROM schema_versions WHERE version = 23;
DROP TABLE dispatch_singleton_claims;
ALTER TABLE sessions ADD COLUMN harness TEXT;
ALTER TABLE sessions ADD COLUMN status TEXT NOT NULL DEFAULT 'active';
ALTER TABLE sessions ADD COLUMN last_seen_at INTEGER;
CREATE TABLE session_heartbeats (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
harness TEXT NOT NULL, phase TEXT NOT NULL, ts INTEGER NOT NULL,
payload TEXT CHECK(payload IS NULL OR json_valid(payload))
);
CREATE INDEX idx_session_heartbeats_session_ts
ON session_heartbeats(project_id, session_id, ts DESC);
CREATE TABLE notifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
sender TEXT NOT NULL, recipient TEXT NOT NULL, kind TEXT NOT NULL,
message TEXT NOT NULL, payload TEXT CHECK(payload IS NULL OR json_valid(payload)),
created_at INTEGER NOT NULL, consumed_at INTEGER
);
CREATE INDEX idx_notifications_pending
ON notifications(project_id, recipient, id)
WHERE consumed_at IS NULL;
UPDATE projects SET metadata = NULL;
UPDATE schema_versions
SET checksum = '4c3a7d304ed26f2fdd694e5b28c066b0b01e28a4d302ef060ad75eedfd81884c'
WHERE version = 22;",
)
.expect("degrade current registry to v22");
let registry_before = std::fs::read(registry_path(&root)).expect("registry before init");
let recovered = invoke(&root, &["init", "--confirm"]);
assert_eq!(recovered.status.code(), Some(3));
assert_eq!(
std::fs::read(registry_path(&root)).expect("registry after init"),
registry_before,
"root binding must not mutate the degraded registry before explicit repair"
);
let upgraded = project_identity_document(&identity);
assert_eq!(
upgraded["root"],
root.canonicalize()
.expect("canonical fixture root")
.display()
.to_string()
);
for suffix in ["-wal", "-shm", "-journal"] {
let _ = std::fs::remove_file(format!("{}{}", registry_path(&root).display(), suffix));
}
let repaired = invoke(
&root,
&[
"registry",
"repair",
"--confirm",
"--snapshot-dir",
"repair-evidence",
"--json",
],
);
assert!(repaired.status.success(), "{}", text(&repaired.stderr));
let connection =
rusqlite::Connection::open(registry_path(&root)).expect("open repaired registry");
let metadata: String = connection
.query_row("SELECT metadata FROM projects", [], |row| row.get(0))
.expect("repaired project metadata");
let metadata: serde_json::Value =
serde_json::from_str(&metadata).expect("valid repaired project metadata");
assert_eq!(
metadata["root"],
root.canonicalize()
.expect("canonical repaired root")
.display()
.to_string(),
"snapshot-backed repair must bind legacy NULL project metadata"
);
}
#[test]
fn init_rejects_unknown_mismatched_and_ambiguous_legacy_identity_without_mutation() {
for (label, document) in [
(
"unknown-field",
serde_json::json!({
"id": "018f47ce-72d7-7f64-9eb1-2f651d521c2a",
"scaffolded_at": 1000,
"unexpected": true,
}),
),
(
"mismatched-root",
serde_json::json!({
"id": "018f47ce-72d7-7f64-9eb1-2f651d521c2a",
"scaffolded_at": 1000,
"root": "/different/project",
}),
),
(
"invalid-scaffolded-at",
serde_json::json!({
"id": "018f47ce-72d7-7f64-9eb1-2f651d521c2a",
"scaffolded_at": "not-a-timestamp",
}),
),
] {
let root = fixture(label);
let identity = identity_path(&root);
let initialized = invoke(&root, &["init", "--confirm"]);
assert_eq!(
initialized.status.code(),
Some(0),
"{}",
text(&initialized.stderr)
);
let bytes = serde_json::to_vec(&document).expect("invalid legacy identity JSON");
std::fs::write(&identity, &bytes).expect("write invalid identity");
let before = std::fs::read(&identity).expect("identity before refusal");
let refused = invoke(&root, &["init", "--confirm"]);
assert_ne!(refused.status.code(), Some(0), "{label} was accepted");
assert_eq!(
std::fs::read(&identity).expect("identity after refusal"),
before
);
}
let root = fixture("mismatched-project-created-at");
let identity = identity_path(&root);
let initialized = invoke(&root, &["init", "--confirm"]);
assert_eq!(
initialized.status.code(),
Some(0),
"{}",
text(&initialized.stderr)
);
let original = project_identity_document(&identity);
let mismatched = serde_json::json!({
"id": original["id"],
"scaffolded_at": original["scaffolded_at"].as_i64().expect("scaffolded_at") + 1,
});
let mismatched_bytes = serde_json::to_vec(&mismatched).expect("mismatched legacy identity");
std::fs::write(&identity, &mismatched_bytes).expect("write mismatched legacy identity");
let refused = invoke(&root, &["init", "--confirm"]);
assert_ne!(refused.status.code(), Some(0));
assert!(
text(&refused.stderr).contains("scaffolded_at disagrees"),
"{}",
text(&refused.stderr)
);
assert_eq!(
std::fs::read(&identity).expect("identity after timestamp refusal"),
mismatched_bytes
);
for (label, metadata) in [
("registry-metadata-missing-root", "{}".to_owned()),
(
"registry-metadata-wrong-root",
serde_json::json!({"root": "/different/project"}).to_string(),
),
] {
let root = fixture(label);
let identity = identity_path(&root);
let initialized = invoke(&root, &["init", "--confirm"]);
assert_eq!(
initialized.status.code(),
Some(0),
"{}",
text(&initialized.stderr)
);
let original = project_identity_document(&identity);
let rootless = serde_json::to_vec(&serde_json::json!({
"id": original["id"],
"scaffolded_at": original["scaffolded_at"],
}))
.expect("rootless legacy identity");
std::fs::write(&identity, &rootless).expect("write rootless identity");
rusqlite::Connection::open(registry_path(&root))
.expect("open registry")
.execute("UPDATE projects SET metadata = ?1", [metadata])
.expect("replace registry metadata");
let refused = invoke(&root, &["init", "--confirm"]);
assert_ne!(refused.status.code(), Some(0), "{label} was accepted");
assert_eq!(
std::fs::read(&identity).expect("identity after metadata refusal"),
rootless
);
}
let root = fixture("multiple-project-rows");
let identity = identity_path(&root);
let initialized = invoke(&root, &["init", "--confirm"]);
assert_eq!(
initialized.status.code(),
Some(0),
"{}",
text(&initialized.stderr)
);
let original = project_identity_document(&identity);
std::fs::write(
&identity,
serde_json::to_vec(&serde_json::json!({
"id": original["id"],
"scaffolded_at": original["scaffolded_at"],
}))
.expect("legacy identity JSON"),
)
.expect("write rootless identity");
let connection = rusqlite::Connection::open(registry_path(&root)).expect("open registry");
connection
.execute(
"INSERT INTO projects (id, metadata, created_at, updated_at) VALUES (?1, ?2, 1, 1)",
(
"0192f6e8-7b2c-7abc-8def-0123456789ac",
serde_json::json!({"root": root.display().to_string()}).to_string(),
),
)
.expect("insert conflicting project");
drop(connection);
let before = std::fs::read(&identity).expect("identity before ambiguity refusal");
let refused = invoke(&root, &["init", "--confirm"]);
assert_ne!(refused.status.code(), Some(0));
assert_eq!(
std::fs::read(&identity).expect("identity after refusal"),
before
);
}
#[cfg(unix)]
#[test]
fn init_rejects_rootless_identity_symlink_and_hardlink_without_mutation() {
use std::{fs::hard_link, os::unix::fs::symlink};
let root = fixture("rootless-symlink");
let identity = identity_path(&root);
let initialized = invoke(&root, &["init", "--confirm"]);
assert_eq!(
initialized.status.code(),
Some(0),
"{}",
text(&initialized.stderr)
);
let outside = root.join("outside-identity.json");
std::fs::write(&outside, b"legacy outside\n").expect("outside identity");
std::fs::remove_file(&identity).expect("remove identity");
symlink(&outside, &identity).expect("identity symlink");
let refused = invoke(&root, &["init", "--confirm"]);
assert_ne!(refused.status.code(), Some(0));
assert_eq!(
std::fs::read(&outside).expect("outside identity"),
b"legacy outside\n"
);
let root = fixture("rootless-hardlink");
let identity = identity_path(&root);
let initialized = invoke(&root, &["init", "--confirm"]);
assert_eq!(
initialized.status.code(),
Some(0),
"{}",
text(&initialized.stderr)
);
let outside = root.join("outside-identity.json");
std::fs::write(
&outside,
br#"{"id":"018f47ce-72d7-7f64-9eb1-2f651d521c2a","scaffolded_at":1000}"#,
)
.expect("outside identity");
std::fs::remove_file(&identity).expect("remove identity");
hard_link(&outside, &identity).expect("identity hard link");
let before = std::fs::read(&outside).expect("outside identity before");
let refused = invoke(&root, &["init", "--confirm"]);
assert_ne!(refused.status.code(), Some(0));
assert_eq!(
std::fs::read(&outside).expect("outside identity after"),
before
);
}
#[test]
fn doctor_reports_a_stale_shepherd_resolved_from_path() {
let root = fixture("stale-path");
let initialized = invoke(&root, &["init", "--confirm"]);
assert_eq!(
initialized.status.code(),
Some(0),
"stderr={}",
text(&initialized.stderr)
);
let scratch = root.join("scratch-path");
std::fs::create_dir_all(&scratch).expect("create scratch PATH directory");
let stale = scratch.join(if cfg!(windows) {
"shepherd.exe"
} else {
"shepherd"
});
std::fs::copy(binary(), &stale).expect("copy the native binary into the scratch PATH entry");
let ancient = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_000_000_000);
std::fs::OpenOptions::new()
.write(true)
.open(&stale)
.expect("open the stale copy to back-date it")
.set_modified(ancient)
.expect("back-date the stale binary's mtime");
let existing = std::env::var_os("PATH").unwrap_or_default();
let mut entries = vec![scratch.clone()];
entries.extend(std::env::split_paths(&existing));
let path = std::env::join_paths(entries).expect("join PATH entries");
let path = path.to_string_lossy().into_owned();
let json = invoke_with_path(&root, &["doctor", "--json"], path.as_str());
assert_eq!(json.status.code(), Some(0), "stderr={}", text(&json.stderr));
let report: serde_json::Value = serde_json::from_slice(&json.stdout).expect("doctor JSON");
assert_eq!(
report["ok"], true,
"a stale PATH binary is an environment finding, never a namespace defect: {report}"
);
assert_eq!(
report["resolved_shepherd_path"].as_str().map(PathBuf::from),
Some(stale.clone()),
"doctor must resolve PATH in order and find the scratch entry first: {report}"
);
assert_eq!(
report["resolved_shepherd_native"], true,
"a byte-for-byte copy of the native binary must classify as native: {report}"
);
let skew = report["resolved_shepherd_skew_seconds"]
.as_i64()
.unwrap_or_else(|| {
panic!("doctor must report a numeric skew for a resolvable stale binary: {report}")
});
assert!(
skew < 0,
"a binary back-dated to 2001 must read as stale relative to the freshly built test binary: {report}"
);
let text_report = invoke_with_path(&root, &["doctor"], path.as_str());
assert_eq!(
text_report.status.code(),
Some(0),
"stderr={}",
text(&text_report.stderr)
);
let rendered = text(&text_report.stdout);
assert!(
rendered.contains("stale") || rendered.contains("older"),
"doctor's text report must name the skew: {rendered}"
);
}
#[test]
fn doctor_reports_a_sensible_result_when_nothing_answers_shepherd_on_path() {
let root = fixture("no-path-binary");
let initialized = invoke(&root, &["init", "--confirm"]);
assert_eq!(
initialized.status.code(),
Some(0),
"stderr={}",
text(&initialized.stderr)
);
let path = path_without_any_shepherd();
let json = invoke_with_path(&root, &["doctor", "--json"], path.as_str());
assert_eq!(json.status.code(), Some(0), "stderr={}", text(&json.stderr));
let report: serde_json::Value = serde_json::from_slice(&json.stdout).expect("doctor JSON");
assert_eq!(
report["ok"], true,
"no PATH binary at all must not fail a healthy namespace: {report}"
);
assert!(
report["resolved_shepherd_path"].is_null(),
"nothing on PATH must resolve to nothing: {report}"
);
assert!(
report["warnings"]
.as_array()
.expect("warnings must be an array")
.iter()
.any(|warning| warning.as_str().is_some_and(|text| text.contains("PATH"))),
"doctor must still say something about the missing PATH binary: {report}"
);
}
fn collect_rs_files(dir: &Path) -> Vec<PathBuf> {
let mut files = Vec::new();
for entry in
std::fs::read_dir(dir).unwrap_or_else(|error| panic!("read_dir {}: {error}", dir.display()))
{
let entry = entry.expect("directory entry");
let path = entry.path();
if entry.file_type().expect("entry file type").is_dir() {
files.extend(collect_rs_files(&path));
} else if path.extension().is_some_and(|extension| extension == "rs") {
files.push(path);
}
}
files
}
#[test]
fn wave_c_bootstrap_remains_the_sole_project_creation_inserter() {
let workspace_root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.canonicalize()
.expect("canonicalize workspace root from CARGO_MANIFEST_DIR");
let crates_dir = workspace_root.join("crates");
let mut sources = Vec::new();
for entry in std::fs::read_dir(&crates_dir).expect("read crates/ directory") {
let entry = entry.expect("crates/ directory entry");
if !entry.file_type().expect("crate entry file type").is_dir() {
continue;
}
let src_dir = entry.path().join("src");
if src_dir.is_dir() {
sources.extend(collect_rs_files(&src_dir));
}
}
let mut offenders: Vec<String> = sources
.into_iter()
.filter(|path| {
std::fs::read_to_string(path)
.map(|contents| {
let production = match contents.find("#[cfg(test)]\nmod ") {
Some(start) => &contents[..start],
None => contents.as_str(),
};
production
.to_ascii_uppercase()
.contains("INSERT INTO PROJECTS")
})
.unwrap_or(false)
})
.map(|path| {
path.strip_prefix(&workspace_root)
.unwrap_or(path.as_path())
.to_string_lossy()
.replace('\\', "/")
})
.collect();
offenders.sort();
assert_eq!(
offenders,
vec!["crates/cli/src/cmd/bootstrap.rs".to_string()],
"found {} production `INSERT INTO projects` writer(s): {offenders:?}. The bootstrap \
command remains the sole project-creation path. Five commands resolve \
\"the current project\" with `SELECT id FROM projects ORDER BY id LIMIT 1` \
(crates/cli/src/cmd/execution.rs:563, crates/cli/src/cmd/planning.rs:785, \
crates/cli/src/cmd/lock.rs:121, crates/cli/src/cmd/coordination.rs:541, \
crates/cli/src/cmd/knowledge.rs:409); project creation must stay keyed to \
`.shepherd/project.json`.",
offenders.len(),
);
}
#[test]
fn scaffolding_ships_a_gitignore_that_never_hides_evidence() {
let root = fixture("gitignore-ships");
let init = invoke(&root, &["init", "--confirm"]);
assert!(
init.status.success(),
"{}",
String::from_utf8_lossy(&init.stderr)
);
let ignore = root.join(".shepherd/.gitignore");
assert!(ignore.is_file(), "init must write .shepherd/.gitignore");
let check = |relative: &str| -> bool {
let path = root.join(relative);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("fixture parent");
}
std::fs::write(&path, b"x").expect("fixture file");
Command::new("git")
.args(["check-ignore", "-q", relative])
.current_dir(&root)
.status()
.expect("git check-ignore")
.success()
};
for ignored in [
".shepherd/shepherd.db",
".shepherd/shepherd.db-wal",
".shepherd/shepherd.db-shm",
".shepherd/runs/.root-session.abc.json",
".shepherd/shepherd.local.toml",
] {
assert!(check(ignored), "{ignored} must be ignored");
}
for tracked in [
".shepherd/runs/v660/evidence/attestation.json",
".shepherd/runs/v660/seed.md",
".shepherd/runs/v660/plan.md",
".shepherd/shepherd.toml",
".shepherd/project.json",
] {
assert!(!check(tracked), "{tracked} must stay tracked");
}
}
#[test]
fn doctor_reports_typed_registry_structure_findings_and_the_repair_route() {
let root = fixture("doctor-registry-structure");
let init = invoke(&root, &["init", "--confirm"]);
assert!(init.status.success(), "{}", text(&init.stderr));
downgrade_to_v22_missing_dispatch_table(®istry_path(&root));
let doctor = invoke(&root, &["doctor", "--json"]);
assert_eq!(doctor.status.code(), Some(3));
let report: serde_json::Value = serde_json::from_slice(&doctor.stdout).expect("doctor JSON");
assert_eq!(report["ok"], false);
let findings = report["registry_findings"]
.as_array()
.expect("typed findings");
assert!(findings.iter().any(|finding| {
finding["class"] == "missing_object"
&& finding["kind"] == "table"
&& finding["object"] == "dispatch_singleton_claims"
}));
assert!(text(&doctor.stdout).contains("shepherd registry repair --confirm"));
assert!(!text(&doctor.stdout).contains("no such table"));
}
fn downgrade_to_v22_missing_dispatch_table(path: &Path) {
let connection = rusqlite::Connection::open(path).expect("open registry fixture");
connection
.execute_batch(
"PRAGMA foreign_keys = OFF;
DROP INDEX idx_singleton_publications_preparing;
DROP INDEX idx_singleton_publications_key;
DROP TABLE dispatch_singleton_publications;
DROP INDEX idx_singleton_claims_publication_nonce;
ALTER TABLE dispatch_singleton_claims DROP COLUMN publication_nonce;
DELETE FROM schema_versions WHERE version = 23;
DROP TABLE dispatch_singleton_claims;",
)
.expect("model v22 registry with missing dispatch table");
}