use super::{Check, GateResult, run_plugins};
use crate::config::Config;
use crate::paths::Paths;
use crate::projection::drift;
use crate::spec::ears::{self, Conformance};
use crate::spec::placeholder;
use crate::spec::{Criterion, SPEC_SCHEMA, Spec};
use crate::store;
use anyhow::Result;
pub fn run(paths: &Paths, cfg: &Config, spec: &Spec) -> Result<GateResult> {
let mut checks = vec![
schema(spec),
criteria_present(spec),
criterion_ids_unique(spec),
no_placeholders(spec),
ears_conformance(spec),
oracle_presence(spec),
oracle_wellformed(spec),
ambiguity(spec, cfg),
criterion_budget(spec, cfg),
spec_length(spec, cfg),
scope_declared(spec),
change_budget_declared(spec),
oracle_mix(spec),
human_cost(spec),
store_drift(paths, cfg)?,
shared_stores_check(paths, cfg),
];
checks.extend(run_plugins(paths, cfg, "G0", Some(&spec.front.slug)));
Ok(GateResult::new("G0", Some(spec.front.slug.clone()), checks))
}
fn schema(spec: &Spec) -> Check {
if spec.front.schema != SPEC_SCHEMA {
return Check::fail("schema", SPEC_SCHEMA, spec.front.schema.clone());
}
if spec.front.id.trim().is_empty() || spec.front.slug.trim().is_empty() {
return Check::fail("schema", "front matter has id and slug", "one is empty");
}
Check::pass("schema", format!("{} ({})", spec.front.id, SPEC_SCHEMA))
}
fn criteria_present(spec: &Spec) -> Check {
if spec.criteria.is_empty() {
return Check::fail(
"criteria-present",
"at least one `### AC-n` acceptance criterion",
"none found",
);
}
Check::pass("criteria-present", format!("{} criteria", spec.criteria.len()))
}
fn criterion_ids_unique(spec: &Spec) -> Check {
let mut seen: Vec<&str> = Vec::new();
let mut dupes: Vec<&str> = Vec::new();
for c in &spec.criteria {
if seen.contains(&c.id.as_str()) {
if !dupes.contains(&c.id.as_str()) {
dupes.push(&c.id);
}
} else {
seen.push(&c.id);
}
}
if dupes.is_empty() {
return Check::pass("criterion-ids-unique", "no duplicate ids");
}
Check::fail("criterion-ids-unique", "unique criterion ids", format!("duplicated: {}", dupes.join(", ")))
}
fn no_placeholders(spec: &Spec) -> Check {
let mut found: Vec<String> = Vec::new();
for c in &spec.criteria {
for p in placeholder::scan(&c.statement) {
found.push(format!("{} statement: `{p}`", c.id));
}
for o in &c.oracles {
for p in placeholder::scan(o.payload()) {
found.push(format!("{} oracle: `{p}`", c.id));
}
}
}
if found.is_empty() {
return Check::pass("no-placeholders", "no unfilled scaffold text");
}
Check::fail(
"no-placeholders",
"criteria and oracles contain no scaffold placeholders",
format!("{} โ fill these in (backtick real paths to exempt them)", super::join_capped(&found, 6)),
)
}
fn ears_conformance(spec: &Spec) -> Check {
let mut bad: Vec<String> = Vec::new();
for c in &spec.criteria {
if let Conformance::Bad(why) = ears::classify(&c.statement) {
bad.push(format!("{} (line {}): {why}", c.id, c.line));
}
}
if bad.is_empty() {
let mut kinds: Vec<String> = Vec::new();
for c in &spec.criteria {
if let Conformance::Ok(p) = ears::classify(&c.statement) {
kinds.push(p.name().to_string());
}
}
kinds.sort();
kinds.dedup();
return Check::pass(
"ears-conformance",
format!("{}/{} conform ({})", spec.criteria.len(), spec.criteria.len(), kinds.join(", ")),
);
}
Check::fail("ears-conformance", "every criterion in EARS form", bad.join("; "))
}
fn oracle_presence(spec: &Spec) -> Check {
let missing: Vec<String> = spec
.criteria
.iter()
.filter(|c| c.oracles.is_empty())
.map(|c| format!("{} (line {})", c.id, c.line))
.collect();
if missing.is_empty() {
let total: usize = spec.criteria.iter().map(|c| c.oracles.len()).sum();
return Check::pass("oracle-presence", format!("{total} oracles across {} criteria", spec.criteria.len()));
}
Check::fail(
"oracle-presence",
"every criterion names a machine-checkable oracle",
format!("no oracle on: {}", super::join_capped(&missing, 5)),
)
}
fn oracle_wellformed(spec: &Spec) -> Check {
let bad: Vec<String> = spec
.criteria
.iter()
.flat_map(|c: &Criterion| {
c.bad_oracles.iter().map(move |(raw, why)| format!("{}: `{raw}` โ {why}", c.id))
})
.collect();
if bad.is_empty() {
return Check::pass("oracle-wellformed", "all oracles parse");
}
Check::fail("oracle-wellformed", "every oracle parses", bad.join("; "))
}
fn ambiguity(spec: &Spec, cfg: &Config) -> Check {
let mut found: Vec<String> = Vec::new();
for c in &spec.criteria {
for a in ears::ambiguities(&c.statement) {
found.push(format!("{} `{}` in โ{}โ", c.id, a.term, a.context));
}
}
let max = cfg.spec.max_ambiguities;
if found.len() <= max {
return Check::pass(
"ambiguity",
if found.is_empty() {
"no ambiguous phrasing in criteria".to_string()
} else {
format!("{} ambiguities, within the tolerance of {max}", found.len())
},
);
}
Check::fail(
"ambiguity",
format!("at most {max} ambiguous phrases in criteria"),
format!("{}: {}", found.len(), found.join("; ")),
)
}
fn criterion_budget(spec: &Spec, cfg: &Config) -> Check {
let n = spec.criteria.len();
let hard = cfg.spec.max_criteria;
let declared = spec.front.budget.criteria.unwrap_or(hard).min(hard);
if n > declared {
return Check::fail(
"criterion-budget",
format!("at most {declared} criteria"),
format!("{n} criteria โ split the spec or raise the budget deliberately"),
);
}
Check::pass("criterion-budget", format!("{n}/{declared} criteria"))
}
fn spec_length(spec: &Spec, cfg: &Config) -> Check {
let max = cfg.spec.max_lines;
if spec.lines > max {
return Check::fail(
"spec-length",
format!("at most {max} lines"),
format!("{} lines โ spec ceremony is the documented failure mode here", spec.lines),
);
}
Check::pass("spec-length", format!("{}/{max} lines", spec.lines))
}
fn scope_declared(spec: &Spec) -> Check {
let scope: Vec<&String> = spec.front.scope.iter().filter(|s| !s.trim().is_empty()).collect();
if scope.is_empty() {
return Check::fail(
"scope-declared",
"front matter declares `scope:` globs",
"scope is empty โ G1 cannot compute a blast radius without it",
);
}
Check::pass("scope-declared", format!("{} glob(s): {}", scope.len(),
scope.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")))
}
fn change_budget_declared(spec: &Spec) -> Check {
match spec.front.budget.lines {
Some(n) if n > 0 => Check::pass("change-budget", format!("{n} lines of diff budgeted")),
_ => Check::fail(
"change-budget",
"front matter declares `budget.lines`",
"no diff budget โ G2 has nothing to ratchet against",
),
}
}
fn oracle_mix(spec: &Spec) -> Check {
let mut counts: std::collections::BTreeMap<&'static str, usize> = Default::default();
for c in &spec.criteria {
for o in &c.oracles {
*counts.entry(o.kind()).or_default() += 1;
}
}
if counts.is_empty() {
return Check::blocked("oracle-mix", "no oracles to classify");
}
let total: usize = counts.values().sum();
let runnable = total - counts.get("human").copied().unwrap_or(0);
let summary = counts.iter().map(|(k, n)| format!("{n} {k}")).collect::<Vec<_>>().join(", ");
Check::pass("oracle-mix", format!("{runnable}/{total} runnable ({summary})"))
}
fn human_cost(spec: &Spec) -> Check {
let human_only = spec.human_only_criteria();
let total_human: usize = spec
.criteria
.iter()
.map(|c| c.oracles.iter().filter(|o| o.is_human()).count())
.sum();
if human_only.is_empty() {
return Check::pass("human-cost", format!("{total_human} human oracle(s), none load-bearing"));
}
let detail: Vec<String> = human_only
.iter()
.map(|c| {
let what = c.oracles.first().map(|o| o.summary()).unwrap_or_default();
format!("{} ({what})", c.id)
})
.collect();
Check::pass(
"human-cost",
format!(
"{} criteria verifiable only by a person โ budget review time: {}",
human_only.len(),
detail.join("; ")
),
)
}
pub fn shared_stores_check(paths: &Paths, cfg: &Config) -> Check {
let stores = crate::store::shared(paths, cfg);
if stores.is_empty() {
return Check::pass("shared-stores", "none configured");
}
let missing_required: Vec<String> = stores
.iter()
.filter(|s| s.missing && s.required)
.map(|s| format!("{} ({})", s.id, s.root.display()))
.collect();
let missing_optional: Vec<String> = stores
.iter()
.filter(|s| s.missing && !s.required)
.map(|s| s.id.clone())
.collect();
if !missing_required.is_empty() {
return Check::fail(
"shared-stores",
"every required shared store resolves",
format!("{} โ its rules are not in force", missing_required.join(", ")),
);
}
if !missing_optional.is_empty() {
return Check::blocked(
"shared-stores",
format!("optional store(s) absent: {}", missing_optional.join(", ")),
);
}
Check::pass("shared-stores", format!("{} resolved", stores.len()))
}
fn store_drift(paths: &Paths, cfg: &Config) -> Result<Check> {
let hash = store::store_hash_with_shared(paths, cfg)?;
let reports = drift::check_all(paths, cfg, &hash)?;
let bad: Vec<String> = reports
.iter()
.filter(|r| r.state.is_blocking())
.map(|r| format!("{} ({})", r.path, r.state.glyph()))
.collect();
if bad.is_empty() {
return Ok(Check::pass("store-drift", format!("{} projections current", reports.len())));
}
Ok(Check::fail(
"store-drift",
"no drifted, stale or missing projections",
bad.join(", "),
))
}