Skip to main content

aprender_contracts_cli/
contract_walk.rs

1//! Shared recursive contract directory walker.
2//!
3//! Multiple `pv` subcommands need to load every YAML contract under
4//! a directory tree (including subdirectories like `contracts/aprender/`,
5//! `contracts/trueno/`, `contracts/patterns/`). This module provides the
6//! canonical walker so commands agree on what counts as a contract and
7//! which sidecar files to skip.
8//!
9//! PVL-1 (PMAT-1099): a corpus with ZERO contracts is REFUSED, never
10//! reported. Measured before this change (PV-LEAN-AUDIT-001, 2026-09-10):
11//! `pv lint /nonexistent-path` exited 0 with `Result: PASS` over 0 contracts,
12//! and `pv proof-status <empty dir>` printed `Proof Status (0 contracts)` at
13//! exit 0 — a gate that measures nothing and reports PASS. [`collect_corpus`]
14//! is the entry point every reporting subcommand uses: it returns
15//! [`ZeroContracts`] (exit [`ZERO_CONTRACTS_EXIT`]) for an empty or missing
16//! directory, and treats a single `.yaml` file as a one-contract corpus so
17//! `pv <cmd> <file>` reports that file instead of walking nothing.
18//!
19//! ONE definition of "empty". The set of contract files is the one `pv lint`
20//! walks — [`provable_contracts::lint::collect_yaml_files`] (the
21//! `is_contract_yaml` rule plus the skipped sidecar directories) — so no two
22//! commands can disagree on what is there. A contract file that fails to
23//! parse is NOT "no contract": it was measured, and it failed
24//! ([`ParseErrors`], exit 1, every file named). The second review quorum on
25//! #3093 measured `proof-status` and `lint --diff` refusing an
26//! unparsable-only directory as "0 contracts" (exit 2) while `lint` failed
27//! the same directory with `contracts: 1, errors: 1` (exit 1); this walker's
28//! old private rule also skipped every `*playbook*` stem, and the corpus holds
29//! real contracts named that way.
30
31use std::fmt;
32use std::path::{Path, PathBuf};
33
34use provable_contracts::lint::collect_yaml_files;
35use provable_contracts::ontology::arming::{ArmedGatesShrank, ArmedShapesShrank};
36use provable_contracts::ontology::verdict::Reason;
37use provable_contracts::schema::{parse_contract, Contract};
38
39/// Exit status of a refused empty corpus.
40///
41/// 1 means "the corpus was measured and failed"; 2 means "nothing was
42/// measured" — the invocation itself is wrong, which is also what clap
43/// returns for a usage error. A caller treating both as failure is
44/// unchanged; one that wants to tell them apart now can.
45pub const ZERO_CONTRACTS_EXIT: i32 = 2;
46
47/// The corpus under `path` holds no contract (after `filter`, when set).
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct ZeroContracts {
50    /// The directory (or file) that was asked for.
51    pub path: PathBuf,
52    /// A `--kind` filter that emptied a non-empty corpus, if any.
53    pub filter: Option<String>,
54}
55
56impl fmt::Display for ZeroContracts {
57    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
58        write!(f, "0 contracts under {}", self.path.display())?;
59        if let Some(k) = &self.filter {
60            write!(f, " (after --kind {k})")?;
61        }
62        Ok(())
63    }
64}
65
66impl std::error::Error for ZeroContracts {}
67
68/// Contract files under `path` that failed to parse: measured, and failed
69/// (exit 1) — never a silent skip, never "0 contracts".
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct ParseErrors {
72    /// The directory that was asked for.
73    pub path: PathBuf,
74    /// Contract files seen (parsed + failed).
75    pub files: usize,
76    /// `(file, error)` per failure, in walk order.
77    pub errors: Vec<(PathBuf, String)>,
78}
79
80impl fmt::Display for ParseErrors {
81    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
82        write!(
83            f,
84            "{} parse error{} under {}\n  {} of {} contract files measured did not parse",
85            self.errors.len(),
86            if self.errors.len() == 1 { "" } else { "s" },
87            self.path.display(),
88            self.errors.len(),
89            self.files,
90        )?;
91        for (file, err) in &self.errors {
92            write!(f, "\n  {}: {err}", file.display())?;
93        }
94        Ok(())
95    }
96}
97
98impl std::error::Error for ParseErrors {}
99
100/// Refuse an empty corpus: `Err(ZeroContracts)` when `corpus` is empty.
101pub fn require_contracts<T>(
102    path: &Path,
103    corpus: &[T],
104    filter: Option<&str>,
105) -> Result<(), ZeroContracts> {
106    if corpus.is_empty() {
107        return Err(ZeroContracts {
108            path: path.to_path_buf(),
109            filter: filter.map(str::to_string),
110        });
111    }
112    Ok(())
113}
114
115/// Is there at least one contract file under `path`? The one definition of a
116/// non-empty corpus, answered WITHOUT parsing (diff mode asks this before it
117/// says "nothing changed"). A file path is a one-contract corpus.
118pub fn has_contract_files(path: &Path) -> bool {
119    if path.is_file() {
120        return true;
121    }
122    let mut files = Vec::new();
123    collect_yaml_files(path, &mut files);
124    !files.is_empty()
125}
126
127/// Load the corpus at `path` and refuse an empty one.
128///
129/// - a directory is walked by [`walk_contracts`]; every contract file must
130///   parse — any failure is [`ParseErrors`] (exit 1), never a silent skip;
131/// - a single `.yaml` file is a one-contract corpus (a parse error is the
132///   file's own error, exit 1 — it WAS measured);
133/// - a missing path, or a directory without a contract file, is
134///   [`ZeroContracts`] (exit [`ZERO_CONTRACTS_EXIT`]).
135///
136/// The result is sorted by stem.
137pub fn collect_corpus(path: &Path) -> Result<Vec<(String, Contract)>, Box<dyn std::error::Error>> {
138    let mut out = Vec::new();
139    let mut errors = Vec::new();
140    if path.is_dir() {
141        walk_contracts(path, &mut out, &mut errors);
142    } else if path.is_file() {
143        out.push((stem_of(path), parse_contract(path)?));
144    }
145    if !errors.is_empty() {
146        return Err(ParseErrors {
147            path: path.to_path_buf(),
148            files: out.len() + errors.len(),
149            errors,
150        }
151        .into());
152    }
153    require_contracts(path, &out, None)?;
154    out.sort_by(|a, b| a.0.cmp(&b.0));
155    Ok(out)
156}
157
158/// `pv lint`'s armed meet is `Unknown(reason)` (ONT-001 §3.4): nothing armed was
159/// measured to a verdict. Exit 2; the line is exactly [`Verdict::decline_line`].
160///
161/// [`Verdict::decline_line`]: provable_contracts::ontology::verdict::Verdict::decline_line
162#[derive(Debug, Clone, Copy, PartialEq, Eq)]
163pub struct LintDeclined {
164    pub reason: Reason,
165}
166
167impl fmt::Display for LintDeclined {
168    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
169        write!(f, "{}", self.reason)
170    }
171}
172
173impl std::error::Error for LintDeclined {}
174
175/// `pv lint`'s armed meet is `Fail`: measured, and failed. Exit 1.
176#[derive(Debug, Clone, Copy, PartialEq, Eq)]
177pub struct LintRejected {
178    /// Armed gates whose verdict is `Pass`.
179    pub passed: usize,
180    /// Armed gates in the meet.
181    pub armed: usize,
182}
183
184impl fmt::Display for LintRejected {
185    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
186        write!(
187            f,
188            "lint failed ({}/{} armed gates passed)",
189            self.passed, self.armed
190        )
191    }
192}
193
194impl std::error::Error for LintRejected {}
195
196/// `pv lint --gate sigma` found Σ itself malformed (ONT-001 §5 ONT-2b): the DECLARATION is wrong, not the corpus,
197/// so it is `error:` at exit 3 and never `reject:`.
198#[derive(Debug, Clone, PartialEq, Eq)]
199pub struct SigmaMalformed(pub String);
200
201impl fmt::Display for SigmaMalformed {
202    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
203        write!(f, "{}", self.0)
204    }
205}
206
207impl std::error::Error for SigmaMalformed {}
208
209/// `--gate <name>` named a gate this build does not compute alone.
210#[derive(Debug, Clone, PartialEq, Eq)]
211pub struct UnknownGate {
212    pub asked: String,
213    pub known: Vec<String>,
214}
215
216impl fmt::Display for UnknownGate {
217    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
218        write!(
219            f,
220            "--gate {}: not a gate this build runs alone (try: {})",
221            self.asked,
222            self.known.join(", ")
223        )
224    }
225}
226
227impl std::error::Error for UnknownGate {}
228
229/// aprender#3715: a `--shape` / `--release-*` flag set that cannot name a release (a partial subject, a short
230/// sha, a flag outside `--gate shapes`). The CALLER's error, exit 3 — never a verdict about the evidence.
231#[derive(Debug, Clone, PartialEq, Eq)]
232pub struct ReleaseArgsRefused(pub String);
233
234impl fmt::Display for ReleaseArgsRefused {
235    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
236        write!(f, "{}", self.0)
237    }
238}
239
240impl std::error::Error for ReleaseArgsRefused {}
241
242/// Exit status of an `armed_gates` list that dropped a gate its comparand armed (ONT-001 §3.9).
243pub const ARMED_GATES_SHRANK_EXIT: i32 = 3;
244
245/// Exit status for a `dispatch` error: [`ZERO_CONTRACTS_EXIT`] for a refused
246/// empty corpus or a declined lint meet, [`ARMED_GATES_SHRANK_EXIT`] for a shrunk
247/// armed set, 1 for everything else (a parse failure and a rejected meet included).
248pub fn exit_code_for(err: &(dyn std::error::Error + 'static)) -> i32 {
249    if err.downcast_ref::<ZeroContracts>().is_some() || err.downcast_ref::<LintDeclined>().is_some()
250    {
251        ZERO_CONTRACTS_EXIT
252    } else if err.downcast_ref::<ArmedGatesShrank>().is_some()
253        || err.downcast_ref::<ArmedShapesShrank>().is_some()
254        || err.downcast_ref::<SigmaMalformed>().is_some()
255        || err.downcast_ref::<ReleaseArgsRefused>().is_some()
256    {
257        ARMED_GATES_SHRANK_EXIT
258    } else {
259        1
260    }
261}
262
263/// The verdict class `pv` prints before an error, in PVL-001 §0's vocabulary:
264/// `decline` (exit 2, nothing measured), `reject` (exit 1, measured and failed),
265/// `error` (anything else, the shrunk armed set at exit 3 included). One
266/// definition, so the word and the exit code cannot drift apart — ONT-001 §5
267/// ONT-1 asserts both halves of the line.
268#[must_use]
269pub fn verdict_for(err: &(dyn std::error::Error + 'static)) -> &'static str {
270    if err.downcast_ref::<ZeroContracts>().is_some() || err.downcast_ref::<LintDeclined>().is_some()
271    {
272        "decline"
273    } else if err.downcast_ref::<ParseErrors>().is_some()
274        || err.downcast_ref::<LintRejected>().is_some()
275    {
276        "reject"
277    } else {
278        "error"
279    }
280}
281
282fn stem_of(path: &Path) -> String {
283    path.file_stem()
284        .and_then(|s| s.to_str())
285        .unwrap_or("unknown")
286        .to_string()
287}
288
289/// Walk `dir` recursively with `pv lint`'s file rule: every contract file is
290/// parsed into `out` as `(stem, contract)`, or recorded in `errors` as
291/// `(file, error)`. Nothing is dropped.
292pub fn walk_contracts(
293    dir: &Path,
294    out: &mut Vec<(String, Contract)>,
295    errors: &mut Vec<(PathBuf, String)>,
296) {
297    let mut files = Vec::new();
298    collect_yaml_files(dir, &mut files);
299    for path in files {
300        match parse_contract(&path) {
301            Ok(c) => out.push((stem_of(&path), c)),
302            Err(e) => errors.push((path, e.to_string())),
303        }
304    }
305}
306
307/// Walk `dir` and collect every PARSEABLE contract, dropping the rest.
308///
309/// Answers "what is here" for callers that do not report over the result;
310/// anything that REPORTS goes through [`collect_corpus`], which refuses an
311/// empty corpus and fails a broken file.
312pub fn collect_contracts(dir: &Path, out: &mut Vec<(String, Contract)>) {
313    let mut dropped = Vec::new();
314    walk_contracts(dir, out, &mut dropped);
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320
321    const MINIMAL: &str = "metadata:\n  version: \"1.0.0\"\n  description: \"t\"\n  references: [\"x\"]\nequations:\n  eq1:\n    formula: \"f(x)=x\"\nproof_obligations: []\nfalsification_tests: []\nkani_harnesses: []\n";
322
323    #[test]
324    fn collect_from_missing_dir_is_empty() {
325        let mut out = Vec::new();
326        collect_contracts(Path::new("/nonexistent/path/to/contracts"), &mut out);
327        assert!(out.is_empty());
328    }
329
330    #[test]
331    fn collect_from_real_contracts_dir() {
332        let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../contracts");
333        if !dir.exists() {
334            return; // skip on CI without contracts
335        }
336        let mut out = Vec::new();
337        collect_contracts(&dir, &mut out);
338        // Should find hundreds of contracts including subdirs.
339        assert!(
340            out.len() > 100,
341            "expected > 100 contracts across the tree, got {}",
342            out.len()
343        );
344        // Every entry must have a non-empty stem, and the sidecar is skipped.
345        for (stem, _) in &out {
346            assert!(!stem.is_empty(), "empty stem in collected contracts");
347            assert_ne!(stem, "binding", "binding sidecar was not skipped");
348        }
349        // ONE file rule: the walker sees exactly the files `pv lint` walks — every
350        // one of them parses in this corpus (lint's validate gate: 0 errors), so
351        // the counts are equal; a `*playbook*` stem is a real contract, not a sidecar.
352        let mut files = Vec::new();
353        provable_contracts::lint::collect_yaml_files(&dir, &mut files);
354        assert_eq!(
355            out.len(),
356            files.len(),
357            "walker and lint disagree on the corpus"
358        );
359        assert!(
360            out.iter().any(|(stem, _)| stem.contains("playbook")),
361            "the corpus's playbook-named contracts were dropped"
362        );
363    }
364
365    #[test]
366    fn collect_skips_binding_yaml() {
367        let tmp = tempfile::tempdir().expect("temp dir is creatable");
368        std::fs::write(
369            tmp.path().join("binding.yaml"),
370            "crates: []\nbindings: []\n",
371        )
372        .expect("fixture file is writable");
373        std::fs::write(tmp.path().join("real-contract-v1.yaml"), MINIMAL)
374            .expect("fixture file is writable");
375
376        let mut out = Vec::new();
377        collect_contracts(tmp.path(), &mut out);
378
379        let stems: Vec<_> = out.iter().map(|(s, _)| s.as_str()).collect();
380        assert!(!stems.contains(&"binding"), "binding should be skipped");
381    }
382
383    #[test]
384    fn collect_recurses_into_subdirs() {
385        let tmp = tempfile::tempdir().expect("temp dir is creatable");
386        let sub = tmp.path().join("sub");
387        std::fs::create_dir_all(&sub).expect("fixture subdirectory is creatable");
388        std::fs::write(tmp.path().join("top-v1.yaml"), MINIMAL).expect("fixture file is writable");
389        std::fs::write(sub.join("nested-v1.yaml"), MINIMAL).expect("fixture file is writable");
390
391        let mut out = Vec::new();
392        collect_contracts(tmp.path(), &mut out);
393
394        let stems: Vec<_> = out.iter().map(|(s, _)| s.clone()).collect();
395        assert!(stems.contains(&"top-v1".to_string()));
396        assert!(stems.contains(&"nested-v1".to_string()));
397    }
398
399    // ---- PVL-1 (PMAT-1099): an empty corpus is refused, never reported ----
400
401    #[test]
402    fn zero_contracts_names_the_path_and_the_filter() {
403        let plain = ZeroContracts {
404            path: PathBuf::from("/x/contracts"),
405            filter: None,
406        };
407        assert_eq!(plain.to_string(), "0 contracts under /x/contracts");
408        let filtered = ZeroContracts {
409            path: PathBuf::from("/x/contracts"),
410            filter: Some("kernel".to_string()),
411        };
412        assert_eq!(
413            filtered.to_string(),
414            "0 contracts under /x/contracts (after --kind kernel)"
415        );
416    }
417
418    #[test]
419    fn collect_corpus_refuses_a_missing_path_with_exit_2() {
420        let err = collect_corpus(Path::new("/nonexistent/path/to/contracts"))
421            .expect_err("a missing path is an empty corpus");
422        assert!(
423            err.downcast_ref::<ZeroContracts>().is_some(),
424            "not a ZeroContracts: {err}"
425        );
426        assert_eq!(exit_code_for(err.as_ref()), ZERO_CONTRACTS_EXIT);
427        assert_eq!(
428            err.to_string(),
429            "0 contracts under /nonexistent/path/to/contracts"
430        );
431    }
432
433    #[test]
434    fn collect_corpus_refuses_an_empty_dir_and_a_sidecar_only_dir() {
435        let tmp = tempfile::tempdir().expect("temp dir is creatable");
436        assert!(
437            collect_corpus(tmp.path()).is_err(),
438            "empty dir must be refused"
439        );
440        std::fs::write(
441            tmp.path().join("binding.yaml"),
442            "crates: []\nbindings: []\n",
443        )
444        .expect("fixture file is writable");
445        assert!(
446            collect_corpus(tmp.path()).is_err(),
447            "a directory holding only sidecars is an empty corpus"
448        );
449    }
450
451    #[test]
452    fn collect_corpus_treats_a_file_as_a_one_contract_corpus() {
453        let tmp = tempfile::tempdir().expect("temp dir is creatable");
454        let file = tmp.path().join("solo-v1.yaml");
455        std::fs::write(&file, MINIMAL).expect("fixture file is writable");
456        let corpus = collect_corpus(&file).expect("one file is one contract");
457        assert_eq!(corpus.len(), 1);
458        assert_eq!(corpus[0].0, "solo-v1");
459    }
460
461    #[test]
462    fn collect_corpus_propagates_a_parse_error_at_exit_1() {
463        let tmp = tempfile::tempdir().expect("temp dir is creatable");
464        let file = tmp.path().join("garbage.yaml");
465        std::fs::write(&file, "{{{ not yaml at all: [\n").expect("fixture file is writable");
466        let err = collect_corpus(&file).expect_err("garbage is a parse error, not an empty corpus");
467        assert!(
468            err.downcast_ref::<ZeroContracts>().is_none(),
469            "a parse error is not ZeroContracts"
470        );
471        assert_eq!(exit_code_for(err.as_ref()), 1);
472    }
473
474    #[test]
475    fn lint_meet_errors_map_to_the_lattice_exits() {
476        let declined: Box<dyn std::error::Error> = Box::new(LintDeclined {
477            reason: Reason::NotArmed,
478        });
479        assert_eq!(exit_code_for(declined.as_ref()), 2);
480        assert_eq!(verdict_for(declined.as_ref()), "decline");
481        assert_eq!(
482            format!("{}: {declined}", verdict_for(declined.as_ref())),
483            "decline: NotArmed",
484            "the printed line is Verdict::decline_line"
485        );
486
487        let rejected: Box<dyn std::error::Error> = Box::new(LintRejected {
488            passed: 7,
489            armed: 8,
490        });
491        assert_eq!(exit_code_for(rejected.as_ref()), 1);
492        assert_eq!(verdict_for(rejected.as_ref()), "reject");
493
494        let shrank: Box<dyn std::error::Error> = Box::new(ArmedGatesShrank {
495            dropped: vec!["composition".into()],
496        });
497        assert_eq!(exit_code_for(shrank.as_ref()), ARMED_GATES_SHRANK_EXIT);
498        assert_eq!(ARMED_GATES_SHRANK_EXIT, 3);
499        assert_eq!(verdict_for(shrank.as_ref()), "error");
500
501        let other: Box<dyn std::error::Error> = "some other failure".into();
502        assert_eq!(
503            exit_code_for(other.as_ref()),
504            1,
505            "every existing error keeps exit 1"
506        );
507        assert_eq!(verdict_for(other.as_ref()), "error");
508    }
509
510    #[test]
511    fn require_contracts_passes_a_non_empty_corpus() {
512        assert!(require_contracts(Path::new("/x"), &[1], None).is_ok());
513        assert_eq!(
514            require_contracts::<u8>(Path::new("/x"), &[], Some("kernel")),
515            Err(ZeroContracts {
516                path: PathBuf::from("/x"),
517                filter: Some("kernel".to_string()),
518            })
519        );
520    }
521}