provable_contracts/lint/duplicate_stems.rs
1//! Gate: PV-DUP-001 — duplicate contract stems.
2//!
3//! Contracts are addressed by STEM (`apr-cli-v1`), not by path: `depends_on` and
4//! `assumes.from_contract` both name a bare stem. `contracts/` is a tree, so two
5//! files in different directories can carry the same stem. When they do, any code
6//! that collapses the corpus into a stem-keyed map has to pick one — and the pick
7//! was previously made by `read_dir` order.
8//!
9//! That made a REQUIRED status check a function of filesystem walk order. It was
10//! falsified by renaming a directory and touching no file content:
11//! `mv contracts/aprender contracts/e-aprender` moved the composition gate from
12//! `edges_broken: 0` to `edges_broken: 5`, flipping `pv lint` PASS -> FAIL.
13//!
14//! The fix has two halves, and BOTH are needed:
15//!
16//! 1. **Refuse, do not resolve.** A stem whose copies have DIVERGENT content is
17//! *ambiguous*. There is no defensible winner — the 546-line and the 357-line
18//! `apr-architecture-schema-v1` are not two renderings of one contract, they are
19//! two different contracts wearing one name. Ambiguous stems are excluded from
20//! the stem index entirely (see `composition_gate.rs`). A tie-break rule — even a
21//! deterministic one like "lexicographically greatest path wins" — would be the
22//! same defect with a stable seed, and would STILL move under a directory rename,
23//! because the directory name is part of the path that the rule sorts on.
24//!
25//! Copies that are byte-identical are NOT ambiguous: every candidate resolves to
26//! the same contract, so the choice is invisible by construction.
27//!
28//! 2. **Report loudly, and ratchet.** Refusing silently would hide the corpus defect.
29//! Every divergent stem is listed in the gate output on every run, and the set is
30//! frozen against `scripts/contract_duplicate_stem_baseline.txt`. A stem that
31//! diverges but is not in the baseline FAILS the gate; a baseline entry that no
32//! longer diverges also FAILS, so the number can only go down.
33//!
34//! WHERE THE REPORT LIVES. This gate post-dates `GateDetail`, which is frozen at the
35//! eight variants `provable-contracts` 0.3.1 published and which 28 vendored 0.3.1
36//! programs `match` exhaustively (see the doc comment on `GateDetail`). So the census
37//! rides in `GateResult::extra` as `GateExtra::DuplicateStems` — same five fields,
38//! same JSON keys, a type 0.3.1 cannot see — and `detail` reports through `Validate`,
39//! whose three numbers are true for this gate as written. Nothing is quieter for it:
40//! `the_gate_names_every_ambiguous_stem_with_its_paths` fixes what must be reported
41//! independently of which channel carries it.
42
43use std::collections::{BTreeMap, BTreeSet};
44use std::path::Path;
45use std::time::Instant;
46
47use super::rules::RuleSeverity;
48
49use super::finding::LintFinding;
50use super::{GateDetail, GateExtra, GateResult};
51
52/// Path of the ratchet baseline, relative to the project root.
53pub const BASELINE_REL_PATH: &str = "scripts/contract_duplicate_stem_baseline.txt";
54
55/// One contract stem claimed by more than one file, with divergent content.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct DuplicateStem {
58 /// The colliding stem, e.g. `apr-architecture-schema-v1`.
59 pub stem: String,
60 /// Every path claiming the stem, sorted. Always at least 2 entries.
61 pub paths: Vec<String>,
62 /// Number of DISTINCT contents among those paths. Always at least 2.
63 pub variants: usize,
64}
65
66/// Scan a contract tree for stems claimed by files with divergent content.
67///
68/// Returns one entry per ambiguous stem, sorted by stem. Byte-identical copies
69/// are deliberately omitted: they are duplication, not ambiguity.
70pub fn scan_duplicate_stems(dir: &Path) -> Vec<DuplicateStem> {
71 let mut paths = Vec::new();
72 super::gates::collect_yaml_files(dir, &mut paths);
73 paths.sort();
74
75 let mut by_stem: BTreeMap<String, Vec<std::path::PathBuf>> = BTreeMap::new();
76 for path in paths {
77 let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
78 continue;
79 };
80 by_stem.entry(stem.to_string()).or_default().push(path);
81 }
82
83 by_stem
84 .into_iter()
85 .filter(|(_, ps)| ps.len() > 1)
86 .filter_map(|(stem, ps)| build_duplicate(&stem, &ps))
87 .collect()
88}
89
90/// Build a `DuplicateStem` if the copies diverge; `None` if they are identical.
91fn build_duplicate(stem: &str, paths: &[std::path::PathBuf]) -> Option<DuplicateStem> {
92 let mut contents: BTreeSet<Vec<u8>> = BTreeSet::new();
93 for p in paths {
94 contents.insert(std::fs::read(p).unwrap_or_default());
95 }
96 if contents.len() < 2 {
97 return None;
98 }
99 Some(DuplicateStem {
100 stem: stem.to_string(),
101 paths: paths.iter().map(|p| p.display().to_string()).collect(),
102 variants: contents.len(),
103 })
104}
105
106/// The set of stems that must NOT be resolved to a single contract.
107pub fn ambiguous_stems(duplicates: &[DuplicateStem]) -> BTreeSet<String> {
108 duplicates.iter().map(|d| d.stem.clone()).collect()
109}
110
111/// Read the ratchet baseline. A missing file means an EMPTY baseline, which makes
112/// every divergent stem a hard error — the safe direction for a tree that has none.
113pub fn read_baseline(project_root: &Path) -> BTreeSet<String> {
114 let path = project_root.join(BASELINE_REL_PATH);
115 let Ok(text) = std::fs::read_to_string(path) else {
116 return BTreeSet::new();
117 };
118 text.lines()
119 .map(str::trim)
120 .filter(|l| !l.is_empty() && !l.starts_with('#'))
121 .map(ToString::to_string)
122 .collect()
123}
124
125/// Run PV-DUP-001. Fails on any divergent stem outside the baseline, and on any
126/// baseline entry that no longer diverges (so the ratchet cannot be left slack).
127pub(crate) fn run_duplicate_stem_gate(
128 duplicates: &[DuplicateStem],
129 baseline: &BTreeSet<String>,
130) -> (GateResult, Vec<LintFinding>) {
131 let start = Instant::now();
132 let found: BTreeSet<String> = ambiguous_stems(duplicates);
133
134 let unbaselined: Vec<String> = found.difference(baseline).cloned().collect();
135 let stale: Vec<String> = baseline.difference(&found).cloned().collect();
136 let passed = unbaselined.is_empty() && stale.is_empty();
137
138 let mut findings = Vec::new();
139 for stem in &unbaselined {
140 let paths = duplicates
141 .iter()
142 .find(|d| &d.stem == stem)
143 .map_or_else(String::new, |d| d.paths.join(", "));
144 findings.push(
145 LintFinding::new(
146 "PV-DUP-001",
147 RuleSeverity::Error,
148 format!(
149 "Stem `{stem}` is claimed by multiple files with DIVERGENT content, \
150 so it cannot be resolved to one contract: {paths}"
151 ),
152 format!("contracts/{stem}.yaml"),
153 )
154 .with_stem(stem.clone()),
155 );
156 }
157 for stem in &stale {
158 findings.push(LintFinding::new(
159 "PV-DUP-002",
160 RuleSeverity::Error,
161 format!(
162 "Stem `{stem}` no longer diverges — remove it from {BASELINE_REL_PATH}. \
163 The ratchet only turns one way."
164 ),
165 BASELINE_REL_PATH.to_string(),
166 ));
167 }
168
169 // `detail` uses `Validate`, the truest of the eight FROZEN `GateDetail` variants
170 // (see the doc comment on `GateDetail`: a ninth variant is a compile error in the
171 // vendored 0.3.1 corpus). Every number below means exactly what the field name
172 // says, so an unmodified 0.3.1 renderer prints a true line for this gate:
173 // contracts contract FILES implicated in an ambiguous stem
174 // errors the failures — unbaselined stems plus stale baseline entries
175 // warnings ambiguous stems the ratchet currently tolerates
176 // error_messages those failures, in words
177 // The gate's OWN shape — which stems, how many variants, at which paths — is not
178 // expressible in that vocabulary and is carried in `extra` instead, losing
179 // nothing: `GateExtra::DuplicateStems` has the same five fields, and the same
180 // JSON key names, that a dedicated `GateDetail` variant would have had.
181 let implicated_files = duplicates.iter().map(|d| d.paths.len()).sum();
182 let error_messages: Vec<String> = findings.iter().map(|f| f.message.clone()).collect();
183 let result = GateResult {
184 name: "duplicate-stems".into(),
185 passed,
186 skipped: false,
187 duration_ms: u64::try_from(start.elapsed().as_millis()).unwrap_or(0),
188 detail: GateDetail::Validate {
189 contracts: implicated_files,
190 errors: unbaselined.len() + stale.len(),
191 warnings: found.intersection(baseline).count(),
192 error_messages,
193 },
194 extra: Some(GateExtra::DuplicateStems {
195 divergent: duplicates.len(),
196 baselined: found.intersection(baseline).count(),
197 unbaselined,
198 stale,
199 divergent_stems: duplicates
200 .iter()
201 .map(|d| {
202 format!(
203 "{} [{} variants] {}",
204 d.stem,
205 d.variants,
206 d.paths.join(" | ")
207 )
208 })
209 .collect(),
210 }),
211 };
212 (result, findings)
213}
214
215#[cfg(test)]
216#[path = "duplicate_stems_tests.rs"]
217mod tests;