Skip to main content

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;