aprender_contracts_cli/commands/validate.rs
1use std::collections::HashSet;
2use std::path::{Path, PathBuf};
3
4use provable_contracts::error::Severity;
5use provable_contracts::lint::collect_yaml_files;
6use provable_contracts::schema::{parse_contract_str, validate_artifact, ArtifactKind};
7
8use crate::contract_walk::{has_contract_files, ZeroContracts};
9
10/// `pv validate <path>` — validate whatever artifact `path` holds.
11///
12/// Dispatches on what the file IS, not on the assumption that everything under
13/// `contracts/` is a `Contract`. Five files in the corpus are not: two pv
14/// binding registries and three publish manifests, all of which failed here
15/// with ``missing field `metadata` `` while the directory walkers that lint
16/// them already knew to treat them differently. See
17/// `provable_contracts::schema::artifact`.
18///
19/// PVL-1 (PMAT-1099): a path that does not exist, or a directory without a
20/// contract file, is a DECLINE — exit 2, [`ZeroContracts`] — never an OS error
21/// at exit 1. Measured 2026-09-11 by the third review quorum on #3093, after
22/// two PASS quorums: `pv validate <empty dir>` answered `Is a directory (os
23/// error 21)` and `pv validate /nonexistent` answered `No such file or
24/// directory (os error 2)`, both exit 1 — validate reads one artifact and never
25/// went through the walker every other reporting command was routed through.
26/// A directory WITH contract files validates every file lint would walk
27/// (the one definition of the corpus) and fails if any of them fails:
28/// measured, and named.
29pub fn run(path: &Path, check_ids: bool) -> Result<(), Box<dyn std::error::Error>> {
30 if !path.exists() || (path.is_dir() && !has_contract_files(path)) {
31 return Err(ZeroContracts {
32 path: path.to_path_buf(),
33 filter: None,
34 }
35 .into());
36 }
37 if check_ids {
38 return run_check_ids(path);
39 }
40 if path.is_dir() {
41 return run_dir(path);
42 }
43 run_file(path)
44}
45
46/// `pv validate <path> --check-ids` — report the obligation-id denominators.
47///
48/// This exists to be RUN ONCE, as the evidence that a consumer reads the key.
49/// `ProofObligation` had no `id` field until #3314's follow-up: `id:` was
50/// written to disk and silently dropped on parse, so 3,612 generated ids were
51/// decoration. Counting them from YAML would have proved nothing -- the count
52/// has to come through the same deserialization every other pv command uses,
53/// which is why this walks parsed contracts and not the text.
54///
55/// Reports, deliberately, three numbers and not a verdict:
56/// obligations every proof obligation in the corpus
57/// with id how many carry an `id:` AFTER deserialization
58/// referenced kani harnesses whose `obligation:` RESOLVES to one of them
59///
60/// `referenced` is the second half of the same question: an id nothing cites is
61/// still not a citation. Requiring these (ids mandatory, references resolving,
62/// `N evaluated` instead of `0 errors`) is the NEXT change; this one only
63/// counts, so it can land without red-lining a corpus that is still being named.
64fn run_check_ids(path: &Path) -> Result<(), Box<dyn std::error::Error>> {
65 let mut files: Vec<PathBuf> = Vec::new();
66 if path.is_dir() {
67 collect_yaml_files(path, &mut files);
68 } else {
69 files.push(path.to_path_buf());
70 }
71 files.sort();
72
73 let (mut obligations, mut with_id, mut referenced, mut contracts) =
74 (0usize, 0usize, 0usize, 0usize);
75 for file in &files {
76 let Ok(content) = std::fs::read_to_string(file) else {
77 continue;
78 };
79 // Not every YAML under contracts/ is a Contract -- binding registries
80 // and publish manifests live there too. A parse failure here is "not a
81 // contract", not an error: `pv validate` already judges those.
82 let Ok(contract) = parse_contract_str(&content) else {
83 continue;
84 };
85 contracts += 1;
86 let ids: HashSet<&str> = contract
87 .proof_obligations
88 .iter()
89 .filter_map(|ob| ob.id.as_deref())
90 .collect();
91 obligations += contract.proof_obligations.len();
92 with_id += ids.len();
93 referenced += contract
94 .kani_harnesses
95 .iter()
96 .filter(|h| ids.contains(h.obligation.as_str()))
97 .count();
98 }
99
100 println!(
101 "{} obligations, {} with id, {} referenced ({} contract(s) under {})",
102 obligations,
103 with_id,
104 referenced,
105 contracts,
106 path.display()
107 );
108 Ok(())
109}
110
111/// Validate every contract file under `dir`, accumulating failures instead of
112/// stopping at the first, so the verdict names all of them.
113fn run_dir(dir: &Path) -> Result<(), Box<dyn std::error::Error>> {
114 let mut files: Vec<PathBuf> = Vec::new();
115 collect_yaml_files(dir, &mut files);
116 files.sort();
117 let mut failed: Vec<PathBuf> = Vec::new();
118 for file in &files {
119 println!("== {}", file.display());
120 if let Err(e) = run_file(file) {
121 println!("{e}");
122 failed.push(file.clone());
123 }
124 }
125 println!(
126 "\n{} artifact(s) under {}, {} failed",
127 files.len(),
128 dir.display(),
129 failed.len()
130 );
131 if failed.is_empty() {
132 Ok(())
133 } else {
134 Err(format!(
135 "{} of {} artifacts under {} failed validation",
136 failed.len(),
137 files.len(),
138 dir.display()
139 )
140 .into())
141 }
142}
143
144fn run_file(path: &Path) -> Result<(), Box<dyn std::error::Error>> {
145 let (kind, violations) = validate_artifact(path)?;
146
147 let errors: Vec<_> = violations
148 .iter()
149 .filter(|v| v.severity == Severity::Error)
150 .collect();
151 let warnings: Vec<_> = violations
152 .iter()
153 .filter(|v| v.severity == Severity::Warning)
154 .collect();
155
156 for v in &violations {
157 println!("{v}");
158 }
159
160 println!("\n{} error(s), {} warning(s)", errors.len(), warnings.len());
161
162 if errors.is_empty() {
163 println!("{} is valid.", noun(kind));
164 Ok(())
165 } else {
166 Err(format!("{} has {} validation error(s)", noun(kind), errors.len()).into())
167 }
168}
169
170/// How to name the artifact in the verdict line. Naming the kind is the point:
171/// a reader who runs `pv validate contracts/binding.yaml` and is told
172/// "Contract is valid." has been told something false about which rules ran.
173fn noun(kind: ArtifactKind) -> &'static str {
174 match kind {
175 ArtifactKind::Contract => "Contract",
176 ArtifactKind::Binding => "Binding registry (kind: binding)",
177 ArtifactKind::PublishManifest => "Publish manifest (kind: publish-manifest)",
178 ArtifactKind::ExternalCorpora => "External-corpora declaration (kind: external-corpora)",
179 }
180}