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
18pub fn capture(
19    root: &Path,
20    language: &str,
21    paths: impl IntoIterator<Item = PathBuf>,
22) -> Result<Inputs, String> {
23    capture_with_expect_modules(root, language, paths, &[])
24}
25
26pub fn capture_with_expect_modules(
27    root: &Path,
28    language: &str,
29    paths: impl IntoIterator<Item = PathBuf>,
30    expect_modules: &[String],
31) -> Result<Inputs, String> {
32    let supplied_root = simplified(root.to_owned());
33    let root = canonicalize_simplified(root).map_err(|e| e.to_string())?;
34    // Store only a digest of environment inputs, never their values. Engine
35    // run IDs, working directories and shell nesting are not semantic inputs.
36    let environment = std::env::vars()
37        .filter(|(k, _)| {
38            !k.starts_with("SUPERCOV_") && !matches!(k.as_str(), "PWD" | "OLDPWD" | "SHLVL" | "_")
39        })
40        .collect::<std::collections::BTreeMap<_, _>>();
41    let mut inputs = Inputs { schema_version: 1, language: language.into(), context_digest: crate::assertion_map::digest(&environment), files: Files::new(), assertions: vec![], limitations: vec![
42        "Syntax inventory covers recognized assertion forms, not every possible custom assertion. Agents may add exact source sites; missing runtime identity never earns credit.".into()
43    ] };
44    if language == "javascript" {
45        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());
46    }
47    for path in paths.into_iter().map(simplified).collect::<BTreeSet<_>>() {
48        let full = if path.is_absolute() {
49            root.join(path.strip_prefix(&supplied_root).unwrap_or(&path))
50        } else {
51            root.join(&path)
52        };
53        if !full.exists() {
54            continue;
55        }
56        let relative = full
57            .strip_prefix(&root)
58            .map_err(|_| format!("assertion input outside project: {}", full.display()))?
59            .to_string_lossy()
60            .replace('\\', "/");
61        if !local_path(&relative)
62            || !canonicalize_simplified(&full)
63                .map_err(|e| e.to_string())?
64                .starts_with(&root)
65        {
66            return Err(format!("assertion input outside project: {relative}"));
67        }
68        if inputs.files.contains_key(&relative) {
69            continue;
70        }
71        let bytes = fs::read(&full).map_err(|e| format!("{relative}: {e}"))?;
72        let Ok(text) = String::from_utf8(bytes) else {
73            inputs.limitations.push(format!(
74                "Non-UTF-8 input omitted from source anchors: {relative}"
75            ));
76            continue;
77        };
78        let extension = path.extension().and_then(|s| s.to_str()).unwrap_or("");
79        let ranges = match extension {
80            "js" | "mjs" | "cjs" | "jsx" | "ts" | "mts" | "cts" | "tsx" => {
81                crate::js_instrumenter::assertion_ranges_with_expect_modules(
82                    &relative,
83                    &text,
84                    expect_modules,
85                )
86            }
87            "rs" => rust_ranges(&text),
88            "py" => python_ranges(&text),
89            "rb" => ruby_ranges(&text),
90            _ => Ok(vec![]),
91        };
92        match ranges {
93            Ok(ranges) => {
94                inputs
95                    .assertions
96                    .extend(
97                        ranges
98                            .into_iter()
99                            .map(|(start, end, operation)| InventorySite {
100                                at: Anchor::new(&relative, &text, start, end),
101                                operation,
102                            }),
103                    )
104            }
105            Err(e) => inputs
106                .limitations
107                .push(format!("Inventory unavailable for {relative}: {e}")),
108        }
109        inputs.files.insert(relative, text);
110    }
111    inputs.assertions.sort_by(|a, b| a.at.cmp(&b.at));
112    Ok(inputs)
113}
114
115pub fn append(
116    mut entries: Vec<EvidenceArchiveEntry>,
117    inputs: &Inputs,
118) -> Result<Vec<EvidenceArchiveEntry>, String> {
119    if entries.iter().any(|e| e.path == ARCHIVE_PATH) {
120        return Err("duplicate assertion inputs".into());
121    }
122    entries.push(EvidenceArchiveEntry {
123        path: ARCHIVE_PATH.into(),
124        contents: serde_json::to_vec(&inputs.manifest()).map_err(|e| e.to_string())?,
125    });
126    Ok(entries)
127}
128
129/// Read project files only when their exact bytes still match the run manifest.
130/// This is source identity checking, not semantic dependency analysis.
131pub fn current_sources(root: &Path, manifest: &InputManifest) -> Result<Inputs, String> {
132    let root = canonicalize_simplified(root).map_err(|e| e.to_string())?;
133    let mut files = Files::new();
134    for (file, expected) in &manifest.files {
135        if !local_path(file) {
136            return Err(format!("Invalid assertion input path: {file}"));
137        }
138        let path = root.join(file);
139        let source = (|| {
140            let canonical = canonicalize_simplified(&path).ok()?;
141            if !canonical.starts_with(&root) || !canonical.is_file() {
142                return None;
143            }
144            let text = fs::read_to_string(canonical).ok()?;
145            (FileFingerprint::of(&text) == *expected).then_some(text)
146        })();
147        let Some(source) = source else {
148            return Err(format!(
149                "Current source differs from the run or is unavailable: {file}; rerun tests to inherit the map for the current checkout"
150            ));
151        };
152        files.insert(file.clone(), source);
153    }
154    let inputs = manifest.with_sources(files);
155    if inputs
156        .assertions
157        .iter()
158        .any(|s| s.at.offset(&inputs.files).is_none())
159    {
160        return Err("Invalid assertion identities in run manifest".into());
161    }
162    Ok(inputs)
163}
164
165fn rust_ranges(source: &str) -> Result<Vec<(usize, usize, String)>, String> {
166    use ra_ap_syntax::{AstNode, Edition, SourceFile, ast};
167    let parsed = SourceFile::parse(source, Edition::Edition2024);
168    if !parsed.errors().is_empty() {
169        return Err("Rust parse errors".into());
170    }
171    Ok(parsed
172        .tree()
173        .syntax()
174        .descendants()
175        .filter_map(ast::MacroCall::cast)
176        .filter_map(|m| {
177            let path = m.path()?.syntax().text().to_string();
178            if !matches!(
179                path.rsplit("::").next()?,
180                "assert"
181                    | "assert_eq"
182                    | "assert_ne"
183                    | "debug_assert"
184                    | "debug_assert_eq"
185                    | "debug_assert_ne"
186            ) {
187                return None;
188            }
189            let range = m.syntax().text_range();
190            Some((
191                u32::from(range.start()) as usize,
192                u32::from(range.end()) as usize,
193                path,
194            ))
195        })
196        .collect())
197}
198fn python_ranges(source: &str) -> Result<Vec<(usize, usize, String)>, String> {
199    use ruff_python_ast::{
200        Expr, Stmt,
201        visitor::{Visitor, walk_expr, walk_stmt},
202    };
203    use ruff_text_size::Ranged;
204    struct Collector(Vec<(usize, usize, String)>);
205    impl<'a> Visitor<'a> for Collector {
206        fn visit_stmt(&mut self, stmt: &'a Stmt) {
207            if let Stmt::Assert(_) = stmt {
208                self.0.push((
209                    stmt.range().start().to_usize(),
210                    stmt.range().end().to_usize(),
211                    "assert".into(),
212                ));
213            }
214            walk_stmt(self, stmt);
215        }
216        fn visit_expr(&mut self, expr: &'a Expr) {
217            if let Expr::Call(call) = expr
218                && let Expr::Attribute(attr) = call.func.as_ref()
219                && attr.attr.as_str().starts_with("assert")
220            {
221                self.0.push((
222                    expr.range().start().to_usize(),
223                    expr.range().end().to_usize(),
224                    attr.attr.to_string(),
225                ));
226            }
227            walk_expr(self, expr);
228        }
229    }
230    let parsed = ruff_python_parser::parse_module(source).map_err(|e| e.to_string())?;
231    let mut collector = Collector(vec![]);
232    for stmt in &parsed.syntax().body {
233        collector.visit_stmt(stmt);
234    }
235    Ok(collector.0)
236}
237fn ruby_ranges(source: &str) -> Result<Vec<(usize, usize, String)>, String> {
238    use ruby_prism::{CallNode, Visit};
239    struct Collector(Vec<(usize, usize, String)>);
240    impl<'a> Visit<'a> for Collector {
241        fn visit_call_node(&mut self, node: &CallNode<'a>) {
242            let name = String::from_utf8_lossy(node.name().as_slice()).into_owned();
243            if name == "assert"
244                || name == "refute"
245                || name.starts_with("assert_")
246                || name.starts_with("refute_")
247                || matches!(name.as_str(), "to" | "not_to" | "to_not")
248            {
249                let location = node.location();
250                self.0
251                    .push((location.start_offset(), location.end_offset(), name));
252            }
253            ruby_prism::visit_call_node(self, node);
254        }
255    }
256    let parsed = ruby_prism::parse(source.as_bytes());
257    if parsed.errors().next().is_some() {
258        return Err("Ruby parse errors".into());
259    }
260    let mut collector = Collector(vec![]);
261    collector.visit(&parsed.node());
262    Ok(collector.0)
263}