Skip to main content

supercov_engine/
assertion_inputs.rs

1//! Thin syntax inventories and source fingerprints for assertion maps.
2//! No type/flow/dependency verifier belongs here.
3use crate::{
4    assertion_map::{
5        Anchor, FileFingerprint, Files, InputManifest, Inputs, InventorySite, local_path,
6    },
7    evidence_archive::EvidenceArchiveEntry,
8    workspace::{canonicalize_simplified, simplified},
9};
10use std::{
11    collections::BTreeSet,
12    fs,
13    path::{Path, PathBuf},
14};
15
16pub const ARCHIVE_PATH: &str = "assertion-inputs.json";
17
18/// Names the variables whose values participate in assertion context identity,
19/// as a comma-separated list. Empty or unset means none.
20pub const CONTEXT_ENVIRONMENT: &str = "SUPERCOV_ASSERTION_CONTEXT_ENV";
21
22/// Identity of the execution context an authored claim was reviewed against.
23///
24/// The ambient process environment is deliberately **not** part of this. A run
25/// from a different directory, terminal session, package manager or Node
26/// installation carries dozens of incidental variables (`INIT_CWD`, `npm_*`,
27/// `TERM_SESSION_ID`, per-session sockets and tokens), and folding those in
28/// invalidated every flow in a map at once for no semantic reason. Environment
29/// differences that actually change behaviour are already caught where it
30/// matters: build-relevant variables participate in the run's configuration,
31/// dependency and instrumenter fingerprints, and any real behavioural change
32/// shows up in re-collected evidence, because credit requires a passing
33/// assertion occurrence and execution of the claimed statement in the same
34/// selected test.
35///
36/// Projects that genuinely depend on specific variables name them in
37/// [`CONTEXT_ENVIRONMENT`]; only those participate, and a variable that is not
38/// set is recorded as absent rather than skipped.
39fn context_digest() -> String {
40    selected_context_digest(
41        &std::env::var(CONTEXT_ENVIRONMENT).unwrap_or_default(),
42        |name| std::env::var(name).ok(),
43    )
44}
45
46fn selected_context_digest(names: &str, value: impl Fn(&str) -> Option<String>) -> String {
47    let selected = names
48        .split(',')
49        .map(str::trim)
50        .filter(|name| !name.is_empty())
51        .map(|name| (name.to_owned(), value(name)))
52        .collect::<std::collections::BTreeMap<_, _>>();
53    crate::assertion_map::digest(&("supercov-assertion-context-v2", selected))
54}
55
56pub fn capture(
57    root: &Path,
58    language: &str,
59    paths: impl IntoIterator<Item = PathBuf>,
60) -> Result<Inputs, String> {
61    capture_with_expect_modules(root, language, paths, &[])
62}
63
64pub fn capture_with_expect_modules(
65    root: &Path,
66    language: &str,
67    paths: impl IntoIterator<Item = PathBuf>,
68    expect_modules: &[String],
69) -> Result<Inputs, String> {
70    let supplied_root = simplified(root.to_owned());
71    let root = canonicalize_simplified(root).map_err(|e| e.to_string())?;
72    // Store only a digest of the selected context, never its values.
73    let mut inputs = Inputs { schema_version: 1, language: language.into(), context_digest: context_digest(), files: Files::new(), assertions: vec![], limitations: vec![
74        "Syntax inventory covers recognized assertion forms, not every possible custom assertion. Agents may add exact source sites; missing runtime identity never earns credit.".into()
75    ] };
76    if language == "javascript" {
77        inputs.limitations.push("Optional assertion calls are inventoried but currently have no injected phase. Unrecognized custom assertion wrappers and dynamically selected matchers may be absent. Use check --require-observed to detect inventoried sites without passing evidence.".into());
78    }
79    for path in paths.into_iter().map(simplified).collect::<BTreeSet<_>>() {
80        let full = if path.is_absolute() {
81            root.join(path.strip_prefix(&supplied_root).unwrap_or(&path))
82        } else {
83            root.join(&path)
84        };
85        if !full.exists() {
86            continue;
87        }
88        let relative = full
89            .strip_prefix(&root)
90            .map_err(|_| format!("assertion input outside project: {}", full.display()))?
91            .to_string_lossy()
92            .replace('\\', "/");
93        if !local_path(&relative)
94            || !canonicalize_simplified(&full)
95                .map_err(|e| e.to_string())?
96                .starts_with(&root)
97        {
98            return Err(format!("assertion input outside project: {relative}"));
99        }
100        if inputs.files.contains_key(&relative) {
101            continue;
102        }
103        let bytes = fs::read(&full).map_err(|e| format!("{relative}: {e}"))?;
104        let Ok(text) = String::from_utf8(bytes) else {
105            inputs.limitations.push(format!(
106                "Non-UTF-8 input omitted from source anchors: {relative}"
107            ));
108            continue;
109        };
110        let extension = path.extension().and_then(|s| s.to_str()).unwrap_or("");
111        let ranges = match extension {
112            "js" | "mjs" | "cjs" | "jsx" | "ts" | "mts" | "cts" | "tsx" => {
113                crate::js_instrumenter::assertion_ranges_with_expect_modules(
114                    &relative,
115                    &text,
116                    expect_modules,
117                )
118            }
119            "rs" => rust_ranges(&text),
120            "py" => python_ranges(&text),
121            "rb" => ruby_ranges(&text),
122            _ => Ok(vec![]),
123        };
124        match ranges {
125            Ok(ranges) => {
126                inputs
127                    .assertions
128                    .extend(
129                        ranges
130                            .into_iter()
131                            .map(|(start, end, operation)| InventorySite {
132                                at: Anchor::new(&relative, &text, start, end),
133                                operation,
134                            }),
135                    )
136            }
137            Err(e) => inputs
138                .limitations
139                .push(format!("Inventory unavailable for {relative}: {e}")),
140        }
141        inputs.files.insert(relative, text);
142    }
143    inputs.assertions.sort_by(|a, b| a.at.cmp(&b.at));
144    Ok(inputs)
145}
146
147pub fn append(
148    mut entries: Vec<EvidenceArchiveEntry>,
149    inputs: &Inputs,
150) -> Result<Vec<EvidenceArchiveEntry>, String> {
151    if entries.iter().any(|e| e.path == ARCHIVE_PATH) {
152        return Err("duplicate assertion inputs".into());
153    }
154    entries.push(EvidenceArchiveEntry {
155        path: ARCHIVE_PATH.into(),
156        contents: serde_json::to_vec(&inputs.manifest()).map_err(|e| e.to_string())?,
157    });
158    Ok(entries)
159}
160
161/// Read project files only when their exact bytes still match the run manifest.
162/// This is source identity checking, not semantic dependency analysis.
163pub fn current_sources(root: &Path, manifest: &InputManifest) -> Result<Inputs, String> {
164    let root = canonicalize_simplified(root).map_err(|e| e.to_string())?;
165    let mut files = Files::new();
166    for (file, expected) in &manifest.files {
167        if !local_path(file) {
168            return Err(format!("Invalid assertion input path: {file}"));
169        }
170        let path = root.join(file);
171        let source = (|| {
172            let canonical = canonicalize_simplified(&path).ok()?;
173            if !canonical.starts_with(&root) || !canonical.is_file() {
174                return None;
175            }
176            let text = fs::read_to_string(canonical).ok()?;
177            (FileFingerprint::of(&text) == *expected).then_some(text)
178        })();
179        let Some(source) = source else {
180            return Err(format!(
181                "Current source differs from the run or is unavailable: {file}; rerun tests to inherit the map for the current checkout"
182            ));
183        };
184        files.insert(file.clone(), source);
185    }
186    let inputs = manifest.with_sources(files);
187    if inputs
188        .assertions
189        .iter()
190        .any(|s| s.at.offset(&inputs.files).is_none())
191    {
192        return Err("Invalid assertion identities in run manifest".into());
193    }
194    Ok(inputs)
195}
196
197fn rust_ranges(source: &str) -> Result<Vec<(usize, usize, String)>, String> {
198    use ra_ap_syntax::{AstNode, Edition, SourceFile, ast};
199    let parsed = SourceFile::parse(source, Edition::Edition2024);
200    if !parsed.errors().is_empty() {
201        return Err("Rust parse errors".into());
202    }
203    Ok(parsed
204        .tree()
205        .syntax()
206        .descendants()
207        .filter_map(ast::MacroCall::cast)
208        .filter_map(|m| {
209            let path = m.path()?.syntax().text().to_string();
210            if !matches!(
211                path.rsplit("::").next()?,
212                "assert"
213                    | "assert_eq"
214                    | "assert_ne"
215                    | "debug_assert"
216                    | "debug_assert_eq"
217                    | "debug_assert_ne"
218            ) {
219                return None;
220            }
221            let range = m.syntax().text_range();
222            Some((
223                u32::from(range.start()) as usize,
224                u32::from(range.end()) as usize,
225                path,
226            ))
227        })
228        .collect())
229}
230fn python_ranges(source: &str) -> Result<Vec<(usize, usize, String)>, String> {
231    use ruff_python_ast::{
232        Expr, Stmt,
233        visitor::{Visitor, walk_expr, walk_stmt},
234    };
235    use ruff_text_size::Ranged;
236    struct Collector(Vec<(usize, usize, String)>);
237    impl<'a> Visitor<'a> for Collector {
238        fn visit_stmt(&mut self, stmt: &'a Stmt) {
239            if let Stmt::Assert(_) = stmt {
240                self.0.push((
241                    stmt.range().start().to_usize(),
242                    stmt.range().end().to_usize(),
243                    "assert".into(),
244                ));
245            }
246            walk_stmt(self, stmt);
247        }
248        fn visit_expr(&mut self, expr: &'a Expr) {
249            if let Expr::Call(call) = expr
250                && let Expr::Attribute(attr) = call.func.as_ref()
251                && attr.attr.as_str().starts_with("assert")
252            {
253                self.0.push((
254                    expr.range().start().to_usize(),
255                    expr.range().end().to_usize(),
256                    attr.attr.to_string(),
257                ));
258            }
259            walk_expr(self, expr);
260        }
261    }
262    let parsed = ruff_python_parser::parse_module(source).map_err(|e| e.to_string())?;
263    let mut collector = Collector(vec![]);
264    for stmt in &parsed.syntax().body {
265        collector.visit_stmt(stmt);
266    }
267    Ok(collector.0)
268}
269fn ruby_ranges(source: &str) -> Result<Vec<(usize, usize, String)>, String> {
270    use ruby_prism::{CallNode, Visit};
271    struct Collector(Vec<(usize, usize, String)>);
272    impl<'a> Visit<'a> for Collector {
273        fn visit_call_node(&mut self, node: &CallNode<'a>) {
274            let name = String::from_utf8_lossy(node.name().as_slice()).into_owned();
275            if name == "assert"
276                || name == "refute"
277                || name.starts_with("assert_")
278                || name.starts_with("refute_")
279                || matches!(name.as_str(), "to" | "not_to" | "to_not")
280            {
281                let location = node.location();
282                self.0
283                    .push((location.start_offset(), location.end_offset(), name));
284            }
285            ruby_prism::visit_call_node(self, node);
286        }
287    }
288    let parsed = ruby_prism::parse(source.as_bytes());
289    if parsed.errors().next().is_some() {
290        return Err("Ruby parse errors".into());
291    }
292    let mut collector = Collector(vec![]);
293    collector.visit(&parsed.node());
294    Ok(collector.0)
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300
301    fn empty(_: &str) -> Option<String> {
302        None
303    }
304
305    #[test]
306    fn incidental_environment_never_reaches_context_identity() {
307        // The values a shell, package manager or terminal happens to export are
308        // not semantic inputs; without an explicit selection the identity is a
309        // constant, so a map authored in one session stays current in the next.
310        let baseline = selected_context_digest("", empty);
311        assert_eq!(
312            baseline,
313            selected_context_digest("", |_| {
314                panic!("no variable may be read without an explicit selection")
315            })
316        );
317        assert_eq!(baseline, selected_context_digest("  ,  ,", empty));
318    }
319
320    #[test]
321    fn explicitly_selected_variables_participate_and_distinguish_absence() {
322        let unset = selected_context_digest("TZ", empty);
323        let utc = selected_context_digest("TZ", |name| (name == "TZ").then(|| "UTC".to_owned()));
324        let berlin = selected_context_digest("TZ", |name| {
325            (name == "TZ").then(|| "Europe/Berlin".to_owned())
326        });
327        assert_ne!(unset, utc, "an unset variable differs from a set one");
328        assert_ne!(utc, berlin, "the value participates, not just the name");
329        assert_ne!(
330            utc,
331            selected_context_digest("", empty),
332            "selecting a variable differs from selecting none"
333        );
334        // Order and padding in the selection are not themselves inputs.
335        let pair = selected_context_digest("TZ,LANG", |name| Some(name.to_owned()));
336        assert_eq!(
337            pair,
338            selected_context_digest(" LANG , TZ ", |name| Some(name.to_owned()))
339        );
340    }
341}