use std::{
collections::BTreeSet,
io::{self, Write},
path::{Path, PathBuf},
};
#[cfg(unix)]
use std::io::Read;
use serde::Deserialize;
use sha2::{Digest, Sha256};
use shepherd::{RunState, dispatch::RunId};
use crate::{dispatch_service::trusted_git_executable, interface::CliError};
const MIN_MESH_ROWS: usize = 8;
const SPRINT_FOOTPRINT_CAP: usize = 400;
const PATCH_FOOTPRINT_CAP: usize = 200;
const MAX_SEED_BYTES: u64 = 1_048_576;
const USAGE: &str = "shepherd seed verify <path> [--quiet]\n shepherd seed verify-content <temporary-content> <canonical-target> [--quiet]\n Deterministic pre-flight gate for a *.seed.md.\n Exit 1 on >=1 HARD failure (blocks the SEED-GATE); 0 otherwise (warnings allowed).";
const NEW_MARKERS: [&str; 7] = ["(NEW", "(new", "(New", "#NEW", "#new", "# NEW", "# new"];
const SEED_SCHEMA: &str = "shepherd.seed/2";
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, clap::Args)]
#[command(disable_help_flag = true)]
pub struct SeedCmd {
#[arg(
value_name = "ARGS",
num_args = 0..,
allow_hyphen_values = true,
trailing_var_arg = true
)]
args: Vec<String>,
}
impl SeedCmd {
pub(crate) fn run(self) -> Result<(), CliError> {
let Some(subcommand) = self.args.first().map(String::as_str) else {
return write_stdout(USAGE);
};
if matches!(subcommand, "help" | "--help" | "-h") {
return write_stdout(USAGE);
}
if subcommand != "verify" && subcommand != "verify-content" {
write_stderr(&format!("unknown subcommand: {subcommand}\n{USAGE}"))?;
return Err(CliError::reported_with_code(2));
}
let mut quiet = false;
let mut paths = Vec::new();
for argument in self.args.iter().skip(1) {
if argument == "--quiet" {
quiet = true;
} else if argument.starts_with('-') {
write_stderr(&format!("unknown flag: {argument}"))?;
return Err(CliError::reported_with_code(2));
} else {
paths.push(PathBuf::from(argument));
}
}
let (content_path, logical_path) = match (subcommand, paths.as_slice()) {
("verify", [path]) => (path, path),
("verify-content", [content, target]) => (content, target),
("verify", _) => {
write_stderr("ERR: seed verify needs a <path>")?;
return Err(CliError::reported_with_code(2));
}
("verify-content", _) => {
write_stderr(
"ERR: seed verify-content needs <temporary-content> <canonical-target>",
)?;
return Err(CliError::reported_with_code(2));
}
_ => unreachable!("subcommand was checked above"),
};
if !content_path.is_file() {
write_stderr(&format!("ERR: no such file: {}", content_path.display()))?;
return Err(CliError::reported_with_code(2));
}
let report = verify(content_path, logical_path, quiet, subcommand == "verify")?;
if !report.lines.is_empty() {
write_stdout(&report.lines.join("\n"))?;
}
if report.hard == 0 {
Ok(())
} else {
Err(CliError::reported())
}
}
}
#[derive(Debug)]
struct Report {
hard: usize,
warnings: usize,
quiet: bool,
lines: Vec<String>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct VerifiedSeed {
pub(crate) relative_path: String,
pub(crate) sha256: String,
}
pub(crate) fn verify_persisted_seed(
project_root: &Path,
run: &RunId,
seed_pointer: &str,
) -> Result<VerifiedSeed, CliError> {
verify_persisted_seed_impl(project_root, run, seed_pointer, None)
}
pub(crate) fn verify_persisted_seed_with_state(
project_root: &Path,
run: &RunId,
seed_pointer: &str,
state: &RunState,
) -> Result<VerifiedSeed, CliError> {
verify_persisted_seed_impl(project_root, run, seed_pointer, Some(state))
}
fn verify_persisted_seed_impl(
project_root: &Path,
run: &RunId,
seed_pointer: &str,
state: Option<&RunState>,
) -> Result<VerifiedSeed, CliError> {
let expected = format!(".shepherd/runs/{run}/seed.md");
if seed_pointer != expected {
return Err(CliError::message(format!(
"verified seed pointer must be `{expected}`"
)));
}
let root = std::fs::canonicalize(project_root)
.map_err(|error| CliError::message(format!("cannot resolve project root: {error}")))?;
let seed = root.join(&expected);
let bytes = read_seed_bytes(&seed)?;
let report = verify_bytes(&bytes, &seed, true, true, state)?;
if report.hard != 0 {
return Err(CliError::message(format!(
"seed verification found {} hard failure(s)",
report.hard
)));
}
if read_seed_bytes(&seed)? != bytes {
return Err(CliError::message("seed bytes changed during verification"));
}
let digest = Sha256::digest(&bytes);
let mut sha256 = String::with_capacity(64);
for byte in digest {
sha256.push_str(&format!("{byte:02x}"));
}
Ok(VerifiedSeed {
relative_path: expected,
sha256,
})
}
impl Report {
fn new(quiet: bool) -> Self {
Self {
hard: 0,
warnings: 0,
quiet,
lines: Vec::new(),
}
}
fn hard(&mut self, message: impl Into<String>) {
self.hard += 1;
if !self.quiet {
self.lines.push(format!(" HARD {}", message.into()));
}
}
fn warn(&mut self, message: impl Into<String>) {
self.warnings += 1;
if !self.quiet {
self.lines.push(format!(" warn {}", message.into()));
}
}
fn finish(&mut self) {
if self.quiet {
return;
}
if self.hard == 0 {
self.lines
.push(format!("OK: 0 hard failures, {} warning(s)", self.warnings));
} else {
self.lines.push(format!(
"FAIL: {} hard failure(s), {} warning(s)",
self.hard, self.warnings
));
}
}
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct SeedContract {
schema: String,
run: String,
mesh: String,
goal: String,
issues: Vec<SeedIssue>,
scope: SeedScope,
contracts: Vec<SeedBoundaryContract>,
non_goals: Vec<SeedNonGoal>,
outcomes: Vec<SeedOutcome>,
deliverables: Vec<SeedDeliverable>,
constraints: Vec<String>,
exclusions: Vec<String>,
unresolved_decisions: Vec<SeedDecision>,
safe_parallelism: Vec<String>,
carry_forward: Vec<SeedCarryForward>,
sources: Vec<String>,
acceptance: Vec<SeedAcceptance>,
verification: SeedVerification,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct SeedIssue {
id: String,
title: String,
statement: String,
evidence: String,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct SeedScope {
include: Vec<String>,
exclude: Vec<String>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct SeedBoundaryContract {
id: String,
boundary: String,
assertion: String,
evidence: String,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct SeedNonGoal {
id: String,
statement: String,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct SeedOutcome {
id: String,
result: String,
evidence: String,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct SeedDeliverable {
id: String,
result: String,
sources: Vec<String>,
acceptance: String,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct SeedDecision {
id: String,
question: String,
blocking: bool,
owner: String,
evidence: String,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct SeedCarryForward {
finding: String,
source: String,
disposition: String,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct SeedAcceptance {
id: String,
assertion: String,
evidence_command: Option<String>,
artifact_predicate: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct SeedVerification {
command: String,
seed_path: String,
run_state: String,
postconditions: Vec<String>,
}
fn verify(
content_path: &Path,
logical_path: &Path,
quiet: bool,
strict_path: bool,
) -> Result<Report, CliError> {
let bytes = if strict_path {
read_seed_bytes(content_path)?
} else {
read_preflight_bytes(content_path)?
};
verify_bytes(&bytes, logical_path, quiet, strict_path, None)
}
fn verify_bytes(
bytes: &[u8],
logical_path: &Path,
quiet: bool,
strict_path: bool,
state: Option<&RunState>,
) -> Result<Report, CliError> {
let raw = std::str::from_utf8(bytes)
.map_err(|error| CliError::message(format!("seed input is not UTF-8: {error}")))?;
let content = raw.trim_end_matches('\n');
let lines = content.split('\n').collect::<Vec<_>>();
let mut report = Report::new(quiet);
match extract_frontmatter(content) {
Some(frontmatter) => {
if let Err(message) =
validate_seed_contract(logical_path, frontmatter, !strict_path, state)
{
report.hard(message);
}
}
None => report.hard("typed seed contract is required; prose-only seeds are not accepted"),
}
let kind = extract_kind(&lines);
let declared_cap = if kind == "patch-seed" {
PATCH_FOOTPRINT_CAP
} else {
SPRINT_FOOTPRINT_CAP
};
if lines.len() > SPRINT_FOOTPRINT_CAP {
report.hard(format!(
"footprint {} lines > cap {SPRINT_FOOTPRINT_CAP} (kind={})",
lines.len(),
if kind.is_empty() { "sprint" } else { &kind }
));
} else if kind == "patch-seed" && lines.len() > PATCH_FOOTPRINT_CAP {
report.warn(format!(
"footprint {} lines > patch cap {PATCH_FOOTPRINT_CAP} (kind=patch-seed) — sprint-shaped; relabel or move evidence to mesh.md",
lines.len()
));
} else if lines.len() > declared_cap * 3 / 4 {
report.warn(format!(
"footprint {} lines > smell threshold {}",
lines.len(),
declared_cap * 3 / 4
));
}
if contains_word_marker(content, "TODO:") || contains_word_marker(content, "FIXME:") {
report.hard("TODO:/FIXME: marker(s) present — resolve before commit");
}
if contains_lane_number(content) {
report.hard(
"prescriptive 'Lane N' numbering present — lane decomposition is engineer territory (#67)",
);
}
if lines.iter().any(|line| sequencing_directive(line)) {
report.warn("'Sequencing:' directive present — sequencing is engineer territory (#67)");
}
if contains_semver_judgment(content) {
report.warn("semver-content judgment present — version tier is the operator's call");
}
let scope = extract_scope_block(&lines);
if !scope.is_empty() {
let repo = repo_root();
let entries = parse_scope_entries(&scope);
let run_closed = strict_path
&& state.is_none()
&& is_run_scoped_seed_path(logical_path)
&& logical_path
.parent()
.is_some_and(|dir| dir.join("close.md").is_file());
for entry in &entries {
if !resolves(entry, repo.as_deref()) {
if run_closed {
report.warn(format!(
"file_scope path does not resolve: {} (run closed — close.md present; a closed run's seed is a record, not a proposal)",
first_token(entry)
));
} else {
report.hard(format!(
"file_scope path does not resolve and is not marked (NEW): {}",
first_token(entry)
));
}
}
}
if entries.is_empty() {
report.warn(
"file_scope present but no entries parsed — verify paths manually (unrecognized YAML shape)",
);
}
}
let deliverables = deliverable_blocks(&lines);
let missing = deliverables
.iter()
.filter(|(is_deliverable, has_gh)| *is_deliverable && !*has_gh)
.count();
if missing > 0 {
report.hard(format!(
"{missing} deliverable block(s) carry a priority but no **GH:** anchor (seed-anchored-by-issues.md)"
));
}
if is_canonical(content, &lines) {
let mesh_rows = lines.iter().filter(|line| is_mesh_row(line)).count();
if mesh_rows > 0 && mesh_rows < MIN_MESH_ROWS {
report.warn(format!(
"Phase 0 mesh has {mesh_rows} row(s) (< {MIN_MESH_ROWS} recommended)"
));
}
if has_any_priority(content) && !has_high_priority(content) {
report
.warn("no deliverable ranked CRITICAL or HIGH — confirm this sprint earns a slot");
}
if !lines.iter().any(|line| line.starts_with("milestone:")) {
report.warn("frontmatter missing 'milestone:' (engineer + critic parse it)");
}
if !lines.iter().any(|line| line.starts_with("kind:")) {
report.warn("frontmatter missing 'kind:' (sprint-seed | patch-seed)");
}
}
report.finish();
Ok(report)
}
fn read_seed_bytes(path: &Path) -> Result<Vec<u8>, CliError> {
let absolute = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()
.map_err(|error| {
CliError::message(format!("cannot resolve seed current directory: {error}"))
})?
.join(path)
};
let canonical = std::fs::canonicalize(&absolute).map_err(|error| {
CliError::message(format!(
"cannot resolve seed path without following links: {error}"
))
})?;
let parent = canonical
.parent()
.ok_or_else(|| CliError::message("seed path has no parent"))?;
let name = canonical
.file_name()
.and_then(|value| value.to_str())
.ok_or_else(|| CliError::message("seed path has no UTF-8 filename"))?;
read_contained(parent, name, MAX_SEED_BYTES).map_err(|error| {
if error.contains("exceeds") {
CliError::message(format!(
"seed input exceeds {MAX_SEED_BYTES} bytes: {}",
path.display()
))
} else {
CliError::message(format!("cannot read {} safely: {error}", path.display()))
}
})
}
fn read_preflight_bytes(path: &Path) -> Result<Vec<u8>, CliError> {
let absolute = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()
.map_err(|error| {
CliError::message(format!("cannot resolve content current directory: {error}"))
})?
.join(path)
};
let metadata = std::fs::symlink_metadata(&absolute)
.map_err(|error| CliError::message(format!("cannot inspect seed content: {error}")))?;
if metadata.file_type().is_symlink() || !metadata.is_file() {
return Err(CliError::message(format!(
"seed content is not a regular non-symlink file: {}",
absolute.display()
)));
}
let canonical = std::fs::canonicalize(&absolute).map_err(|error| {
CliError::message(format!("cannot resolve seed content safely: {error}"))
})?;
let parent = canonical
.parent()
.ok_or_else(|| CliError::message("seed content has no parent"))?;
let name = canonical
.file_name()
.and_then(|value| value.to_str())
.ok_or_else(|| CliError::message("seed content has no UTF-8 filename"))?;
read_contained(parent, name, MAX_SEED_BYTES).map_err(|error| {
if error.contains("exceeds") {
CliError::message(format!(
"seed input exceeds {MAX_SEED_BYTES} bytes: {}",
path.display()
))
} else {
CliError::message(format!("cannot read seed content safely: {error}"))
}
})
}
fn extract_frontmatter(content: &str) -> Option<&str> {
let content = content
.strip_prefix("---\n")
.or_else(|| content.strip_prefix("---\r\n"))?;
let end = content.find("\n---")?;
Some(content[..end].trim_end_matches('\r'))
}
fn validate_seed_contract(
path: &Path,
frontmatter: &str,
allow_missing_seed: bool,
native_state: Option<&RunState>,
) -> Result<(), String> {
let contract: SeedContract = serde_saphyr::from_str(frontmatter)
.map_err(|error| format!("seed contract: typed frontmatter is invalid: {error}"))?;
if contract.schema != SEED_SCHEMA {
return Err(format!(
"seed contract: schema must be {SEED_SCHEMA}, got `{}`",
contract.schema
));
}
validate_identifier_value("run", &contract.run)?;
validate_text("goal", &contract.goal, true)?;
let (project_root, run_id) = canonical_seed_layout(path, allow_missing_seed)?;
if native_state.is_none() && active_project_root()? != project_root {
return Err("seed contract: seed path belongs to a different project root".into());
}
if run_id != contract.run {
return Err(format!(
"seed contract: run `{}` does not match authoritative path run `{run_id}`",
contract.run
));
}
let expected_seed = format!(".shepherd/runs/{run_id}/seed.md");
let expected_state = format!(".shepherd/runs/{run_id}/run.json");
if contract.verification.seed_path != expected_seed {
return Err(format!(
"seed contract: verification.seed_path must be `{expected_seed}`"
));
}
if contract.verification.run_state != expected_state {
return Err(format!(
"seed contract: verification.run_state must be `{expected_state}`"
));
}
let state: serde_json::Value = if let Some(state) = native_state {
serde_json::to_value(state)
.map_err(|error| format!("seed contract: invalid native run state: {error}"))?
} else {
let bytes =
read_contained(&project_root, &expected_state, MAX_SEED_BYTES).map_err(|error| {
format!("seed contract: cannot read authoritative run state: {error}")
})?;
serde_json::from_slice(&bytes).map_err(|error| {
format!("seed contract: authoritative run state is invalid JSON: {error}")
})?
};
if state.get("run").and_then(serde_json::Value::as_str) != Some(run_id.as_str()) {
return Err("seed contract: authoritative run state has the wrong run".into());
}
if state.get("status").and_then(serde_json::Value::as_str) != Some("planted") {
return Err("seed contract: authoritative run state is not planted".into());
}
if let Some(seed_pointer) = state.get("seed").and_then(serde_json::Value::as_str)
&& !seed_pointer.is_empty()
&& seed_pointer != expected_seed
{
return Err("seed contract: authoritative seed pointer names the wrong path".into());
}
if contract.mesh != "mesh.md" {
return Err("seed contract: mesh must be the run-local `mesh.md`".into());
}
let mesh = String::from_utf8(
read_contained(
&project_root,
&format!(".shepherd/runs/{run_id}/mesh.md"),
MAX_SEED_BYTES,
)
.map_err(|error| format!("seed contract: cannot read mesh safely: {error}"))?,
)
.map_err(|error| format!("seed contract: mesh is not UTF-8: {error}"))?;
let source_ids = validate_sources(&project_root, &contract.sources, &mesh)?;
let mut ids = BTreeSet::new();
if contract.issues.is_empty() {
return Err("seed contract: issues must not be empty".into());
}
for issue in &contract.issues {
register_id(&mut ids, "issue", &issue.id)?;
validate_text("issue title", &issue.title, false)?;
validate_text("issue statement", &issue.statement, false)?;
validate_reference(&project_root, &issue.evidence, &source_ids, &mesh)?;
}
validate_scope(&project_root, &contract.scope)?;
if contract.contracts.is_empty() {
return Err("seed contract: contracts must not be empty".into());
}
for item in &contract.contracts {
register_id(&mut ids, "contract", &item.id)?;
validate_text("contract boundary", &item.boundary, false)?;
validate_text("contract assertion", &item.assertion, false)?;
validate_reference(&project_root, &item.evidence, &source_ids, &mesh)?;
}
if contract.non_goals.is_empty() {
return Err("seed contract: non_goals must not be empty".into());
}
for item in &contract.non_goals {
register_id(&mut ids, "non_goal", &item.id)?;
validate_text("non_goal statement", &item.statement, false)?;
}
if contract.outcomes.is_empty() {
return Err("seed contract: outcomes must not be empty".into());
}
for outcome in &contract.outcomes {
register_id(&mut ids, "outcome", &outcome.id)?;
validate_text("outcome result", &outcome.result, true)?;
validate_text("outcome evidence", &outcome.evidence, false)?;
}
if contract.deliverables.is_empty() {
return Err("seed contract: deliverables must not be empty".into());
}
for deliverable in &contract.deliverables {
register_id(&mut ids, "deliverable", &deliverable.id)?;
validate_text("deliverable result", &deliverable.result, false)?;
validate_text("deliverable acceptance", &deliverable.acceptance, false)?;
validate_source_ids(
&deliverable.sources,
&contract.sources,
&mesh,
&deliverable.id,
)?;
}
if contract.constraints.is_empty() || contract.exclusions.is_empty() {
return Err("seed contract: constraints and exclusions must not be empty".into());
}
for value in &contract.constraints {
validate_text("constraint", value, false)?;
}
for value in &contract.exclusions {
validate_text("exclusion", value, false)?;
}
if contract.safe_parallelism.is_empty() {
return Err("seed contract: safe_parallelism must not be empty".into());
}
for value in &contract.safe_parallelism {
validate_text("safe_parallelism", value, false)?;
}
for decision in &contract.unresolved_decisions {
register_id(&mut ids, "decision", &decision.id)?;
validate_text("decision question", &decision.question, false)?;
validate_text("decision owner", &decision.owner, false)?;
if !source_ids.contains(&decision.evidence) {
return Err(format!(
"seed contract: decision `{}` cites unresolved source `{}`",
decision.id, decision.evidence
));
}
if decision.blocking {
return Err(format!(
"seed contract: blocking unresolved decision `{}` prevents planning",
decision.id
));
}
}
for carry_forward in &contract.carry_forward {
validate_text("carry_forward finding", &carry_forward.finding, false)?;
validate_text("carry_forward source", &carry_forward.source, false)?;
if !matches!(
carry_forward.disposition.as_str(),
"include" | "exclude" | "defer" | "ask"
) {
return Err(format!(
"seed contract: invalid carry_forward disposition `{}`",
carry_forward.disposition
));
}
validate_reference(&project_root, &carry_forward.source, &source_ids, &mesh)?;
}
if contract.acceptance.is_empty() {
return Err("seed contract: acceptance must not be empty".into());
}
for item in &contract.acceptance {
register_id(&mut ids, "acceptance", &item.id)?;
validate_text("acceptance assertion", &item.assertion, false)?;
let command = item.evidence_command.as_deref().unwrap_or("");
let predicate = item.artifact_predicate.as_deref().unwrap_or("");
if command.trim().is_empty() && predicate.trim().is_empty() {
return Err(format!(
"seed contract: acceptance `{}` needs an evidence_command or artifact_predicate",
item.id
));
}
if !command.trim().is_empty() {
validate_text("acceptance evidence_command", command, false)?;
}
if !predicate.trim().is_empty() {
validate_text("acceptance artifact_predicate", predicate, false)?;
}
}
validate_text(
"verification command",
&contract.verification.command,
false,
)?;
if !contract
.verification
.command
.contains("shepherd seed verify")
{
return Err(
"seed contract: verification.command must invoke `shepherd seed verify`".into(),
);
}
if contract.verification.postconditions.is_empty() {
return Err("seed contract: verification.postconditions must not be empty".into());
}
for postcondition in &contract.verification.postconditions {
validate_text("verification postcondition", postcondition, false)?;
}
let postconditions = contract
.verification
.postconditions
.join(" ")
.to_ascii_lowercase();
if !contains_word(&postconditions, "planted") {
return Err("seed contract: verification must require the run to remain planted".into());
}
if !contains_word(&postconditions, "seed") || !contains_word(&postconditions, "pointer") {
return Err(
"seed contract: verification must require native seed-pointer persistence".into(),
);
}
Ok(())
}
fn validate_identifier_value(field: &str, value: &str) -> Result<(), String> {
let mut characters = value.chars();
let valid = characters
.next()
.is_some_and(|character| character.is_ascii_alphabetic())
&& characters
.all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_'));
if !valid || has_placeholder(value) {
return Err(format!(
"seed contract: {field} must be a non-placeholder identifier"
));
}
Ok(())
}
fn validate_text(field: &str, value: &str, measurable: bool) -> Result<(), String> {
if value.trim().is_empty() {
return Err(format!("seed contract: {field} must not be empty"));
}
if has_placeholder(value) {
return Err(format!("seed contract: {field} contains a placeholder"));
}
if measurable && !is_measurable(value) {
return Err(format!(
"seed contract: {field} must state a measurable outcome"
));
}
Ok(())
}
fn has_placeholder(value: &str) -> bool {
let lower = value.to_ascii_lowercase();
["tbd", "todo", "fixme", "placeholder", "replace_me"]
.iter()
.any(|marker| lower.contains(marker))
|| (value.contains('<') && value.contains('>'))
}
fn is_measurable(value: &str) -> bool {
let lower = value.to_ascii_lowercase();
value.chars().any(|character| character.is_ascii_digit())
|| lower.contains('%')
|| [
"count", "coverage", "exactly", "metric", "rate", "status", "exit", "pass", "fail",
"zero", "all", "each",
]
.iter()
.any(|marker| lower.contains(marker))
}
fn contains_word(value: &str, word: &str) -> bool {
value
.split(|character: char| !character.is_ascii_alphanumeric() && character != '_')
.any(|token| token == word)
}
fn is_source_id(value: &str) -> bool {
let mut characters = value.chars();
characters
.next()
.is_some_and(|character| character.is_ascii_alphabetic())
&& characters
.all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_'))
}
fn contains_mesh_source_id(mesh: &str, source_id: &str) -> bool {
mesh.split(|character: char| {
!(character.is_ascii_alphanumeric() || matches!(character, '-' | '_'))
})
.any(|token| token == source_id)
}
fn register_id(ids: &mut BTreeSet<String>, field: &str, value: &str) -> Result<(), String> {
validate_identifier_value(field, value)?;
if !ids.insert(value.to_owned()) {
return Err(format!("seed contract: duplicate id `{value}`"));
}
Ok(())
}
fn validate_scope(project_root: &Path, scope: &SeedScope) -> Result<(), String> {
if scope.include.is_empty() || scope.exclude.is_empty() {
return Err("seed contract: scope.include and scope.exclude must not be empty".into());
}
let mut include = BTreeSet::new();
let mut exclude = BTreeSet::new();
for value in &scope.include {
validate_scope_path(project_root, value, "scope.include")?;
if !include.insert(value) {
return Err(format!(
"seed contract: duplicate scope.include path `{value}`"
));
}
}
for value in &scope.exclude {
validate_scope_path(project_root, value, "scope.exclude")?;
if !exclude.insert(value) {
return Err(format!(
"seed contract: duplicate scope.exclude path `{value}`"
));
}
if include.iter().any(|included| {
let included = included.as_str();
let excluded = value.as_str();
excluded == included
|| excluded
.strip_prefix(included)
.is_some_and(|suffix| suffix.starts_with('/'))
|| included
.strip_prefix(excluded)
.is_some_and(|suffix| suffix.starts_with('/'))
}) {
return Err(format!(
"seed contract: path overlaps include and exclude scope: `{value}`"
));
}
}
Ok(())
}
fn validate_scope_path(project_root: &Path, value: &str, field: &str) -> Result<(), String> {
if value.is_empty() || Path::new(value).is_absolute() || value.contains(['*', '?', '[']) {
return Err(format!(
"seed contract: {field} must be an exact relative path: `{value}`"
));
}
let components = Path::new(value).components().collect::<Vec<_>>();
if components
.iter()
.any(|component| !matches!(component, std::path::Component::Normal(_)))
{
return Err(format!(
"seed contract: {field} contains unsafe path `{value}`"
));
}
let mut candidate = project_root.to_path_buf();
for (index, component) in components.iter().enumerate() {
let std::path::Component::Normal(component) = component else {
return Err(format!(
"seed contract: {field} contains unsafe path `{value}`"
));
};
candidate.push(component);
match std::fs::symlink_metadata(&candidate) {
Ok(metadata) => {
if metadata.file_type().is_symlink() {
return Err(format!(
"seed contract: {field} follows a symlink: `{value}`"
));
}
if index + 1 < components.len() && !metadata.is_dir() {
return Err(format!(
"seed contract: {field} has a non-directory parent: `{value}`"
));
}
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => break,
Err(error) => {
return Err(format!(
"seed contract: cannot inspect {field} `{value}`: {error}"
));
}
}
}
Ok(())
}
fn validate_sources(
project_root: &Path,
sources: &[String],
mesh: &str,
) -> Result<BTreeSet<String>, String> {
if sources.is_empty() {
return Err("seed contract: sources must not be empty".into());
}
let mut values = BTreeSet::new();
let mut ids = BTreeSet::new();
for source in sources {
validate_text("source", source, false)?;
if !values.insert(source) {
return Err(format!("seed contract: duplicate source `{source}`"));
}
if is_source_id(source) {
if !ids.insert(source.clone()) {
return Err(format!("seed contract: duplicate source id `{source}`"));
}
if !contains_mesh_source_id(mesh, source) {
return Err(format!(
"seed contract: unresolved mesh source id `{source}`"
));
}
} else {
read_contained(project_root, source, MAX_SEED_BYTES)
.map_err(|error| format!("seed contract: source `{source}` is unsafe: {error}"))?;
}
}
if ids.is_empty() {
return Err("seed contract: sources must include a resolved mesh source id".into());
}
Ok(ids)
}
fn validate_source_ids(
references: &[String],
sources: &[String],
mesh: &str,
owner: &str,
) -> Result<(), String> {
let source_ids = sources
.iter()
.filter(|source| is_source_id(source))
.cloned()
.collect::<BTreeSet<_>>();
if references.is_empty() {
return Err(format!("seed contract: `{owner}` must cite a source"));
}
let mut seen = BTreeSet::new();
for reference in references {
validate_identifier_value("source reference", reference)?;
if !seen.insert(reference) {
return Err(format!(
"seed contract: duplicate source reference `{reference}` in `{owner}`"
));
}
if !source_ids.contains(reference) || !contains_mesh_source_id(mesh, reference) {
return Err(format!(
"seed contract: `{owner}` references unresolved mesh source `{reference}`"
));
}
}
Ok(())
}
fn validate_reference(
project_root: &Path,
reference: &str,
source_ids: &BTreeSet<String>,
mesh: &str,
) -> Result<(), String> {
if is_source_id(reference) {
if !source_ids.contains(reference) || !contains_mesh_source_id(mesh, reference) {
return Err(format!(
"seed contract: unresolved source reference `{reference}`"
));
}
} else {
read_contained(project_root, reference, MAX_SEED_BYTES).map_err(|error| {
format!("seed contract: unsafe source reference `{reference}`: {error}")
})?;
}
Ok(())
}
fn canonical_seed_layout(path: &Path, allow_missing: bool) -> Result<(PathBuf, String), String> {
let absolute = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()
.map_err(|error| format!("cannot resolve seed current directory: {error}"))?
.join(path)
};
let canonical = match std::fs::canonicalize(&absolute) {
Ok(canonical) => canonical,
Err(error) if allow_missing && error.kind() == std::io::ErrorKind::NotFound => {
let name = absolute
.file_name()
.ok_or_else(|| "seed target has no filename".to_owned())?;
let parent = absolute
.parent()
.ok_or_else(|| "seed target has no parent".to_owned())?;
let canonical_parent = std::fs::canonicalize(parent).map_err(|parent_error| {
format!("cannot resolve seed target parent safely: {parent_error}")
})?;
canonical_parent.join(name)
}
Err(error) => return Err(format!("cannot resolve seed path safely: {error}")),
};
match std::fs::symlink_metadata(&absolute) {
Ok(metadata) if metadata.file_type().is_symlink() => {
return Err(format!("seed target is a symlink: {}", absolute.display()));
}
Ok(metadata) if !metadata.is_file() => {
return Err(format!(
"seed target is not a regular file: {}",
absolute.display()
));
}
Ok(_) => {}
Err(error) if allow_missing && error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
return Err(format!("cannot inspect seed target safely: {error}"));
}
}
let seed_name = canonical.file_name().and_then(|value| value.to_str());
let run_dir = canonical.parent();
let run_name = run_dir
.and_then(Path::file_name)
.and_then(|value| value.to_str());
let runs_dir = run_dir.and_then(Path::parent);
let shepherd_dir = runs_dir.and_then(Path::parent);
if seed_name != Some("seed.md")
|| runs_dir
.and_then(Path::file_name)
.and_then(|value| value.to_str())
!= Some("runs")
|| shepherd_dir
.and_then(Path::file_name)
.and_then(|value| value.to_str())
!= Some(".shepherd")
{
return Err("seed contract: seed must be exactly `.shepherd/runs/<run>/seed.md`".into());
}
let run_name =
run_name.ok_or_else(|| "seed contract: run path has no run identifier".to_owned())?;
validate_identifier_value("run path", run_name)?;
let project_root = shepherd_dir
.and_then(Path::parent)
.ok_or_else(|| "seed contract: run path has no project root".to_owned())?;
Ok((project_root.to_path_buf(), run_name.to_owned()))
}
#[cfg(unix)]
fn read_contained(root: &Path, relative: &str, limit: u64) -> Result<Vec<u8>, String> {
use rustix::fs::{AtFlags, FileType, Mode, OFlags, fstat, open, openat, statat};
use std::fs::File;
let mut parts = relative.split('/');
let first = parts.next().filter(|part| !part.is_empty());
let Some(first) = first else {
return Err("empty relative path".into());
};
let mut directory = open(
"/",
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC | OFlags::NOFOLLOW,
Mode::empty(),
)
.map_err(|error| format!("cannot open filesystem root without following links: {error}"))?;
for component in root.components() {
let std::path::Component::Normal(component) = component else {
continue;
};
let next = openat(
&directory,
component,
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC | OFlags::NOFOLLOW,
Mode::empty(),
)
.map_err(|error| format!("cannot open project root component safely: {error}"))?;
let metadata = fstat(&next)
.map_err(|error| format!("cannot inspect project root component: {error}"))?;
if !FileType::from_raw_mode(metadata.st_mode).is_dir()
|| (metadata.st_mode & 0o022 != 0 && metadata.st_mode & 0o1000 == 0)
{
return Err("project root contains a non-directory or writable component".into());
}
directory = next;
}
let mut components = vec![first];
components.extend(parts);
if components
.iter()
.any(|part| *part == "." || *part == ".." || part.is_empty())
{
return Err(format!("unsafe relative path: `{relative}`"));
}
for (index, component) in components.iter().enumerate() {
let final_component = index + 1 == components.len();
let listed =
statat(&directory, *component, AtFlags::SYMLINK_NOFOLLOW).map_err(|error| {
format!("cannot inspect `{relative}` without following links: {error}")
})?;
let listed_type = FileType::from_raw_mode(listed.st_mode);
if listed_type.is_symlink() {
return Err(format!("`{relative}` contains a symlink"));
}
if final_component && !listed_type.is_file() {
return Err(format!("`{relative}` is not a regular file"));
}
if !final_component && !listed_type.is_dir() {
return Err(format!("`{relative}` has a non-directory parent"));
}
let flags = if final_component {
OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW
} else {
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC | OFlags::NOFOLLOW
};
let next = openat(&directory, *component, flags, Mode::empty()).map_err(|error| {
format!("cannot open `{relative}` without following links: {error}")
})?;
if !final_component {
let opened = fstat(&next)
.map_err(|error| format!("cannot inspect `{relative}` parent: {error}"))?;
if opened.st_dev != listed.st_dev
|| opened.st_ino != listed.st_ino
|| opened.st_mode != listed.st_mode
{
return Err(format!("`{relative}` parent changed during open"));
}
directory = next;
continue;
}
let listed_before_open = listed;
let before =
fstat(&next).map_err(|error| format!("cannot inspect `{relative}`: {error}"))?;
if before.st_dev != listed_before_open.st_dev
|| before.st_ino != listed_before_open.st_ino
|| before.st_mode != listed_before_open.st_mode
|| before.st_nlink != listed_before_open.st_nlink
|| before.st_size != listed_before_open.st_size
{
return Err(format!("`{relative}` changed before open completed"));
}
if !FileType::from_raw_mode(before.st_mode).is_file() {
return Err(format!("`{relative}` is not a regular file"));
}
if before.st_nlink != 1 || before.st_mode & 0o022 != 0 {
return Err(format!("`{relative}` has unsafe link count or permissions"));
}
if before.st_size < 0 || u64::try_from(before.st_size).unwrap_or(u64::MAX) > limit {
return Err(format!("`{relative}` exceeds {limit} bytes"));
}
let mut file = File::from(next);
let mut bytes = Vec::new();
Read::take(&mut file, limit + 1)
.read_to_end(&mut bytes)
.map_err(|error| format!("cannot read `{relative}`: {error}"))?;
if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > limit {
return Err(format!("`{relative}` exceeds {limit} bytes"));
}
let after = fstat(&file)
.map_err(|error| format!("cannot inspect `{relative}` after read: {error}"))?;
let current = statat(&directory, *component, AtFlags::SYMLINK_NOFOLLOW)
.map_err(|error| format!("cannot recheck `{relative}`: {error}"))?;
let same = |candidate: &rustix::fs::Stat| {
candidate.st_dev == before.st_dev
&& candidate.st_ino == before.st_ino
&& candidate.st_mode == before.st_mode
&& candidate.st_nlink == before.st_nlink
&& candidate.st_size == before.st_size
&& candidate.st_mtime == before.st_mtime
&& candidate.st_mtime_nsec == before.st_mtime_nsec
&& candidate.st_ctime == before.st_ctime
&& candidate.st_ctime_nsec == before.st_ctime_nsec
};
if !same(&after) || !same(¤t) {
return Err(format!("`{relative}` changed during read"));
}
return Ok(bytes);
}
Err(format!("empty relative path: `{relative}`"))
}
#[cfg(not(unix))]
fn read_contained(root: &Path, relative: &str, limit: u64) -> Result<Vec<u8>, String> {
let components: Vec<&str> = relative.split('/').collect();
let Some(first) = components.first().filter(|part| !part.is_empty()) else {
return Err("empty relative path".into());
};
let _ = first;
if components
.iter()
.any(|part| *part == "." || *part == ".." || part.is_empty())
{
return Err(format!("unsafe relative path: `{relative}`"));
}
let mut path = root.to_path_buf();
for component in &components {
path.push(component);
}
crate::safe_fs::read_regular_nofollow(&path, limit)
.map_err(|error| format!("cannot read `{relative}` without following links: {error}"))
}
fn extract_kind(lines: &[&str]) -> String {
for line in lines {
let Some(value) = line.strip_prefix("kind:") else {
continue;
};
let value = value.trim_start();
let value = value
.find('#')
.filter(|index| {
value[..*index]
.chars()
.next_back()
.is_some_and(char::is_whitespace)
})
.map(|index| &value[..index])
.unwrap_or(value);
return value.trim_end().to_owned();
}
String::new()
}
fn contains_word_marker(content: &str, marker: &str) -> bool {
content.match_indices(marker).any(|(index, _)| {
index == 0
|| content[..index]
.chars()
.next_back()
.is_none_or(|value| !(value.is_alphanumeric() || value == '_'))
})
}
fn contains_lane_number(content: &str) -> bool {
content.match_indices("Lane").any(|(index, _)| {
let boundary = index == 0
|| content[..index]
.chars()
.next_back()
.is_none_or(|value| !(value.is_alphanumeric() || value == '_'));
if !boundary {
return false;
}
let suffix = &content[index + "Lane".len()..];
let spaces = suffix
.bytes()
.take_while(|byte| matches!(byte, b' ' | b'\t'))
.count();
spaces > 0
&& suffix
.as_bytes()
.get(spaces)
.is_some_and(u8::is_ascii_digit)
})
}
fn sequencing_directive(line: &str) -> bool {
line.trim_start()
.trim_start_matches('*')
.starts_with("Sequencing:")
&& line
.trim_start()
.chars()
.take_while(|value| *value == '*')
.count()
<= 2
}
fn contains_semver_judgment(content: &str) -> bool {
let lower = content.to_ascii_lowercase();
[
"too small for a patch",
"too big for a patch",
"too large for a patch",
"too small for a minor",
"too big for a minor",
"too large for a minor",
"too small for a sprint",
"too big for a sprint",
"too large for a sprint",
"should be a patch",
"should be a minor",
"should be a major",
"really a minor",
"really a major",
]
.iter()
.any(|needle| lower.contains(needle))
}
fn extract_scope_block<'a>(lines: &'a [&'a str]) -> Vec<&'a str> {
let mut scope = Vec::new();
let mut inside = false;
for line in lines {
if line.starts_with("file_scope:") {
inside = true;
continue;
}
if inside
&& (line.trim() == "---" || line.chars().next().is_some_and(|c| !c.is_whitespace()))
{
inside = false;
}
if inside {
scope.push(*line);
}
}
while scope.last().is_some_and(|line| line.is_empty()) {
scope.pop();
}
scope
}
fn parse_scope_entries(lines: &[&str]) -> Vec<String> {
let mut entries = Vec::new();
for line in lines {
let flow = (line.contains("exclusive:") || line.contains("additive:"))
&& line.contains('[')
&& line.contains(']');
if flow {
if let (Some(start), Some(end)) = (line.find('['), line.rfind(']')) {
entries.extend(
line[start + 1..end]
.split(',')
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_owned),
);
}
continue;
}
if !line.contains("- ") {
continue;
}
let entry = line
.trim_start()
.strip_prefix('-')
.unwrap_or(line)
.trim_start();
if entry.is_empty() || entry.starts_with("exclusive:") || entry.starts_with("additive:") {
continue;
}
entries.push(entry.to_owned());
}
entries
}
fn first_token(value: &str) -> &str {
value
.find(char::is_whitespace)
.map(|index| &value[..index])
.unwrap_or(value)
}
fn resolves(raw: &str, repo_root: Option<&Path>) -> bool {
if NEW_MARKERS.iter().any(|marker| raw.contains(marker)) {
return true;
}
let token = first_token(raw);
if token.is_empty() || (token.starts_with('<') && token.ends_with('>')) {
return true;
}
let path = PathBuf::from(token);
let candidate = if path.is_absolute() {
path
} else if let Some(root) = repo_root {
root.join(path)
} else {
path
};
if token.contains(['*', '?', '[']) {
glob_exists(&candidate)
} else {
candidate.exists()
}
}
fn is_run_scoped_seed_path(path: &Path) -> bool {
path.file_name().and_then(|name| name.to_str()) == Some("seed.md")
&& path
.parent()
.and_then(Path::parent)
.and_then(Path::file_name)
.and_then(|name| name.to_str())
== Some("runs")
}
fn active_project_root() -> Result<PathBuf, String> {
let root = repo_root()
.or_else(|| std::env::current_dir().ok())
.ok_or_else(|| "seed contract: cannot determine the active project root".to_owned())?;
std::fs::canonicalize(&root)
.map_err(|error| format!("seed contract: cannot resolve active project root: {error}"))
}
fn repo_root() -> Option<PathBuf> {
let output = std::process::Command::new(trusted_git_executable().ok()?)
.env_clear()
.args(["rev-parse", "--show-toplevel"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let value = String::from_utf8(output.stdout).ok()?;
let value = value.trim();
(!value.is_empty()).then(|| PathBuf::from(value))
}
fn glob_exists(pattern: &Path) -> bool {
let Some(pattern) = pattern.to_str() else {
return false;
};
let options = glob::MatchOptions {
case_sensitive: true,
require_literal_separator: true,
require_literal_leading_dot: true,
};
glob::glob_with(pattern, options).is_ok_and(|mut matches| matches.any(|entry| entry.is_ok()))
}
fn deliverable_blocks(lines: &[&str]) -> Vec<(bool, bool)> {
let mut blocks = Vec::new();
let mut started = false;
let mut deliverable = false;
let mut has_gh = false;
let flush = |blocks: &mut Vec<(bool, bool)>, started: bool, deliverable: bool, has_gh: bool| {
if started {
blocks.push((deliverable, has_gh));
}
};
for line in lines {
if line.starts_with("### ") || line.starts_with("###\t") {
flush(&mut blocks, started, deliverable, has_gh);
started = true;
deliverable = ["CRITICAL", "HIGH", "MEDIUM", "LOW"]
.iter()
.any(|priority| line.contains(&format!("[{priority}]")));
has_gh = false;
continue;
}
if line.starts_with("## ") || line.starts_with("##\t") {
flush(&mut blocks, started, deliverable, has_gh);
started = false;
deliverable = false;
has_gh = false;
}
if line.contains("**Priority:**") {
deliverable = true;
}
if line.contains("**GH:**") {
has_gh = true;
}
}
flush(&mut blocks, started, deliverable, has_gh);
blocks
}
fn is_canonical(content: &str, lines: &[&str]) -> bool {
content.contains("**Priority:**")
|| lines.iter().any(|line| line.starts_with("file_scope:"))
|| content.contains("Phase 0 mesh")
|| content.contains("**GH:**")
}
fn is_mesh_row(line: &str) -> bool {
let Some(rest) = line.strip_prefix('|') else {
return false;
};
let rest = rest.trim_start();
let digits = rest.bytes().take_while(u8::is_ascii_digit).count();
digits > 0 && rest[digits..].trim_start().starts_with('|')
}
fn has_any_priority(content: &str) -> bool {
content.contains("**Priority:**")
|| ["CRITICAL", "HIGH", "MEDIUM", "LOW"]
.iter()
.any(|priority| content.contains(&format!("[{priority}]")))
}
fn has_high_priority(content: &str) -> bool {
content.contains("[CRITICAL]")
|| content.contains("[HIGH]")
|| content
.lines()
.filter_map(|line| line.split_once("**Priority:**"))
.map(|(_, value)| value.trim_start())
.any(|value| value.starts_with("CRITICAL") || value.starts_with("HIGH"))
}
fn write_stdout(message: &str) -> Result<(), CliError> {
let mut output = io::stdout().lock();
output
.write_all(message.as_bytes())
.and_then(|()| output.write_all(b"\n"))
.and_then(|()| output.flush())
.map_err(|error| CliError::message(format!("cannot write stdout: {error}")))
}
fn write_stderr(message: &str) -> Result<(), CliError> {
let mut output = io::stderr().lock();
output
.write_all(message.as_bytes())
.and_then(|()| output.write_all(b"\n"))
.and_then(|()| output.flush())
.map_err(|error| CliError::message(format!("cannot write stderr: {error}")))
}
#[cfg(all(test, unix))]
mod sticky_root_tests {
use super::read_contained;
use std::{fs, os::unix::fs::PermissionsExt, path::PathBuf};
fn scratch(name: &str) -> PathBuf {
let base = std::env::temp_dir().join(format!(
"shepherd-seed-sticky-{}-{name}",
std::process::id()
));
let _ = fs::remove_dir_all(&base);
fs::create_dir_all(base.join("project")).expect("scratch project");
fs::write(base.join("project/seed.md"), b"seed\n").expect("seed bytes");
base
}
#[test]
fn sticky_world_writable_ancestor_is_accepted() {
let base = scratch("sticky");
fs::set_permissions(&base, fs::Permissions::from_mode(0o1777)).expect("sticky mode");
let root = fs::canonicalize(base.join("project")).expect("canonical project");
let bytes = read_contained(&root, "seed.md", 1024).expect("sticky ancestor is readable");
assert_eq!(bytes, b"seed\n".to_vec());
let _ = fs::remove_dir_all(&base);
}
#[test]
fn world_writable_ancestor_without_sticky_is_refused() {
let base = scratch("plain");
fs::set_permissions(&base, fs::Permissions::from_mode(0o0777)).expect("plain mode");
let root = fs::canonicalize(base.join("project")).expect("canonical project");
let error =
read_contained(&root, "seed.md", 1024).expect_err("writable ancestor is refused");
assert!(error.contains("writable component"), "{error}");
let _ = fs::remove_dir_all(&base);
}
}