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 == "go" {
77        inputs.limitations.push("A Go test states its claim with an `if` and reports the violation through t.Error or t.Fatal, so the report is the inventoried site. Custom assertion helpers that wrap it are not recognized.".into());
78    }
79    if language == "jvm" {
80        inputs.limitations.push("Assertion forms spelled assertSomething, assertThat or fail are inventoried, which covers JUnit, TestNG, AssertJ, Hamcrest and kotlin.test. Kotest's infix matchers and custom assertion helpers are not.".into());
81    }
82    if language == "javascript" {
83        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());
84    }
85    for path in paths.into_iter().map(simplified).collect::<BTreeSet<_>>() {
86        let full = if path.is_absolute() {
87            root.join(path.strip_prefix(&supplied_root).unwrap_or(&path))
88        } else {
89            root.join(&path)
90        };
91        if !full.exists() {
92            continue;
93        }
94        let relative = full
95            .strip_prefix(&root)
96            .map_err(|_| format!("assertion input outside project: {}", full.display()))?
97            .to_string_lossy()
98            .replace('\\', "/");
99        if !local_path(&relative)
100            || !canonicalize_simplified(&full)
101                .map_err(|e| e.to_string())?
102                .starts_with(&root)
103        {
104            return Err(format!("assertion input outside project: {relative}"));
105        }
106        if inputs.files.contains_key(&relative) {
107            continue;
108        }
109        let bytes = fs::read(&full).map_err(|e| format!("{relative}: {e}"))?;
110        let Ok(text) = String::from_utf8(bytes) else {
111            inputs.limitations.push(format!(
112                "Non-UTF-8 input omitted from source anchors: {relative}"
113            ));
114            continue;
115        };
116        let extension = path.extension().and_then(|s| s.to_str()).unwrap_or("");
117        let ranges = match extension {
118            "js" | "mjs" | "cjs" | "jsx" | "ts" | "mts" | "cts" | "tsx" => {
119                crate::js_instrumenter::assertion_ranges_with_expect_modules(
120                    &relative,
121                    &text,
122                    expect_modules,
123                )
124            }
125            "rs" => rust_ranges(&text),
126            "py" => python_ranges(&text),
127            "rb" => ruby_ranges(&text),
128            "go" => go_ranges(&text),
129            "java" => jvm_ranges(&text, crate::jvm_instrumenter::JvmLanguage::Java),
130            "kt" => jvm_ranges(&text, crate::jvm_instrumenter::JvmLanguage::Kotlin),
131            _ => Ok(vec![]),
132        };
133        match ranges {
134            Ok(ranges) => {
135                inputs
136                    .assertions
137                    .extend(
138                        ranges
139                            .into_iter()
140                            .map(|(start, end, operation)| InventorySite {
141                                at: Anchor::new(&relative, &text, start, end),
142                                operation,
143                            }),
144                    )
145            }
146            Err(e) => inputs
147                .limitations
148                .push(format!("Inventory unavailable for {relative}: {e}")),
149        }
150        inputs.files.insert(relative, text);
151    }
152    inputs.assertions.sort_by(|a, b| a.at.cmp(&b.at));
153    Ok(inputs)
154}
155
156pub fn append(
157    mut entries: Vec<EvidenceArchiveEntry>,
158    inputs: &Inputs,
159) -> Result<Vec<EvidenceArchiveEntry>, String> {
160    if entries.iter().any(|e| e.path == ARCHIVE_PATH) {
161        return Err("duplicate assertion inputs".into());
162    }
163    entries.push(EvidenceArchiveEntry {
164        path: ARCHIVE_PATH.into(),
165        contents: serde_json::to_vec(&inputs.manifest()).map_err(|e| e.to_string())?,
166    });
167    Ok(entries)
168}
169
170/// Read project files only when their exact bytes still match the run manifest.
171/// This is source identity checking, not semantic dependency analysis.
172pub fn current_sources(root: &Path, manifest: &InputManifest) -> Result<Inputs, String> {
173    let root = canonicalize_simplified(root).map_err(|e| e.to_string())?;
174    let mut files = Files::new();
175    for (file, expected) in &manifest.files {
176        if !local_path(file) {
177            return Err(format!("Invalid assertion input path: {file}"));
178        }
179        let path = root.join(file);
180        let source = (|| {
181            let canonical = canonicalize_simplified(&path).ok()?;
182            if !canonical.starts_with(&root) || !canonical.is_file() {
183                return None;
184            }
185            let text = fs::read_to_string(canonical).ok()?;
186            (FileFingerprint::of(&text) == *expected).then_some(text)
187        })();
188        let Some(source) = source else {
189            return Err(format!(
190                "Current source differs from the run or is unavailable: {file}; rerun tests to inherit the map for the current checkout"
191            ));
192        };
193        files.insert(file.clone(), source);
194    }
195    let inputs = manifest.with_sources(files);
196    if inputs
197        .assertions
198        .iter()
199        .any(|s| s.at.offset(&inputs.files).is_none())
200    {
201        return Err("Invalid assertion identities in run manifest".into());
202    }
203    Ok(inputs)
204}
205
206/// Every call in a file, as (byte range, callee text).
207///
208/// Shared by Go, Java and Kotlin because the question is the same in all
209/// three: which calls are the ones that make a claim. Only the node kinds and
210/// the names differ, and the caller decides those.
211fn calls(tree: &tree_sitter::Tree, source: &str, kinds: &[&str]) -> Vec<(usize, usize, String)> {
212    let mut found = Vec::new();
213    let mut stack = vec![tree.root_node()];
214    while let Some(node) = stack.pop() {
215        let mut cursor = node.walk();
216        for child in node.children(&mut cursor) {
217            stack.push(child);
218        }
219        if !kinds.contains(&node.kind()) {
220            continue;
221        }
222        // The callee is the part before the arguments, whatever the grammar
223        // calls it: a field where one is named, the first child otherwise.
224        let callee = node
225            .child_by_field_name("function")
226            .or_else(|| node.child_by_field_name("name"))
227            .or_else(|| node.named_child(0));
228        let Some(callee) = callee else {
229            continue;
230        };
231        found.push((
232            node.start_byte(),
233            node.end_byte(),
234            source[callee.byte_range()].trim().to_owned(),
235        ));
236    }
237    found.sort();
238    found
239}
240
241/// Go's assertion forms.
242///
243/// A Go test states its claim with an `if` and reports the violation through
244/// `t.Error` or `t.Fatal`, so the report is what marks the claim: there is no
245/// assertion expression to point at. testify's `assert` and `require` are the
246/// other form nearly every Go suite uses.
247fn go_ranges(source: &str) -> Result<Vec<(usize, usize, String)>, String> {
248    let tree = crate::go_instrumenter::parse(source).map_err(|e| e.to_string())?;
249    let harnesses = testing_parameters(&tree, source);
250    Ok(calls(&tree, source, &["call_expression"])
251        .into_iter()
252        .filter(|(_, _, callee)| {
253            let Some((receiver, method)) = callee.rsplit_once('.') else {
254                return false;
255            };
256            // `t.Errorf` and `fmt.Errorf` are the same shape, and only one of
257            // them is a claim. The receiver has to be something the file
258            // actually declared as a *testing.T.
259            (harnesses.contains(receiver)
260                && matches!(method, "Error" | "Errorf" | "Fatal" | "Fatalf"))
261                || matches!(receiver, "assert" | "require")
262        })
263        .collect())
264}
265
266/// Every identifier the file binds to a `*testing.T`, `*testing.B` or
267/// `*testing.F`.
268///
269/// Collected from the declarations rather than assumed to be `t`: a subtest
270/// closure rebinds it, a benchmark names it `b`, and a file that chose
271/// something else is still a test file.
272fn testing_parameters(tree: &tree_sitter::Tree, source: &str) -> BTreeSet<String> {
273    let mut names = BTreeSet::new();
274    let mut stack = vec![tree.root_node()];
275    while let Some(node) = stack.pop() {
276        let mut cursor = node.walk();
277        for child in node.children(&mut cursor) {
278            stack.push(child);
279        }
280        if node.kind() != "parameter_declaration" {
281            continue;
282        }
283        let Some(kind) = node.child_by_field_name("type") else {
284            continue;
285        };
286        if !matches!(
287            source[kind.byte_range()].trim(),
288            "*testing.T" | "*testing.B" | "*testing.F"
289        ) {
290            continue;
291        }
292        if let Some(name) = node.child_by_field_name("name") {
293            names.insert(source[name.byte_range()].trim().to_owned());
294        }
295    }
296    names
297}
298
299/// Java and Kotlin's assertion forms.
300///
301/// JUnit, TestNG, AssertJ, Hamcrest and kotlin.test all spell theirs
302/// `assertSomething` or `assertThat`, so the prefix covers every one of them
303/// without naming a framework. `fail` is the other half of the same idiom.
304/// Kotest writes its own infix matchers, which no call-shaped rule reaches.
305fn jvm_ranges(
306    source: &str,
307    language: crate::jvm_instrumenter::JvmLanguage,
308) -> Result<Vec<(usize, usize, String)>, String> {
309    let tree = crate::jvm_instrumenter::parse(source, language).map_err(|e| e.to_string())?;
310    Ok(
311        calls(&tree, source, &["method_invocation", "call_expression"])
312            .into_iter()
313            .filter(|(_, _, callee)| {
314                let last = callee.rsplit('.').next().unwrap_or_default();
315                last.starts_with("assert") || last == "fail"
316            })
317            .collect(),
318    )
319}
320
321fn rust_ranges(source: &str) -> Result<Vec<(usize, usize, String)>, String> {
322    use ra_ap_syntax::{AstNode, Edition, SourceFile, ast};
323    let parsed = SourceFile::parse(source, Edition::Edition2024);
324    if !parsed.errors().is_empty() {
325        return Err("Rust parse errors".into());
326    }
327    Ok(parsed
328        .tree()
329        .syntax()
330        .descendants()
331        .filter_map(ast::MacroCall::cast)
332        .filter_map(|m| {
333            let path = m.path()?.syntax().text().to_string();
334            if !matches!(
335                path.rsplit("::").next()?,
336                "assert"
337                    | "assert_eq"
338                    | "assert_ne"
339                    | "debug_assert"
340                    | "debug_assert_eq"
341                    | "debug_assert_ne"
342            ) {
343                return None;
344            }
345            let range = m.syntax().text_range();
346            Some((
347                u32::from(range.start()) as usize,
348                u32::from(range.end()) as usize,
349                path,
350            ))
351        })
352        .collect())
353}
354fn python_ranges(source: &str) -> Result<Vec<(usize, usize, String)>, String> {
355    use ruff_python_ast::{
356        Expr, Stmt,
357        visitor::{Visitor, walk_expr, walk_stmt},
358    };
359    use ruff_text_size::Ranged;
360    struct Collector(Vec<(usize, usize, String)>);
361    impl<'a> Visitor<'a> for Collector {
362        fn visit_stmt(&mut self, stmt: &'a Stmt) {
363            if let Stmt::Assert(_) = stmt {
364                self.0.push((
365                    stmt.range().start().to_usize(),
366                    stmt.range().end().to_usize(),
367                    "assert".into(),
368                ));
369            }
370            walk_stmt(self, stmt);
371        }
372        fn visit_expr(&mut self, expr: &'a Expr) {
373            if let Expr::Call(call) = expr
374                && let Expr::Attribute(attr) = call.func.as_ref()
375                && attr.attr.as_str().starts_with("assert")
376            {
377                self.0.push((
378                    expr.range().start().to_usize(),
379                    expr.range().end().to_usize(),
380                    attr.attr.to_string(),
381                ));
382            }
383            walk_expr(self, expr);
384        }
385    }
386    let parsed = ruff_python_parser::parse_module(source).map_err(|e| e.to_string())?;
387    let mut collector = Collector(vec![]);
388    for stmt in &parsed.syntax().body {
389        collector.visit_stmt(stmt);
390    }
391    Ok(collector.0)
392}
393fn ruby_ranges(source: &str) -> Result<Vec<(usize, usize, String)>, String> {
394    use ruby_prism::{CallNode, Visit};
395    struct Collector(Vec<(usize, usize, String)>);
396    impl<'a> Visit<'a> for Collector {
397        fn visit_call_node(&mut self, node: &CallNode<'a>) {
398            let name = String::from_utf8_lossy(node.name().as_slice()).into_owned();
399            if name == "assert"
400                || name == "refute"
401                || name.starts_with("assert_")
402                || name.starts_with("refute_")
403                || matches!(name.as_str(), "to" | "not_to" | "to_not")
404            {
405                let location = node.location();
406                self.0
407                    .push((location.start_offset(), location.end_offset(), name));
408            }
409            ruby_prism::visit_call_node(self, node);
410        }
411    }
412    let parsed = ruby_prism::parse(source.as_bytes());
413    if parsed.errors().next().is_some() {
414        return Err("Ruby parse errors".into());
415    }
416    let mut collector = Collector(vec![]);
417    collector.visit(&parsed.node());
418    Ok(collector.0)
419}
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424
425    fn empty(_: &str) -> Option<String> {
426        None
427    }
428
429    #[test]
430    fn incidental_environment_never_reaches_context_identity() {
431        // The values a shell, package manager or terminal happens to export are
432        // not semantic inputs; without an explicit selection the identity is a
433        // constant, so a map authored in one session stays current in the next.
434        let baseline = selected_context_digest("", empty);
435        assert_eq!(
436            baseline,
437            selected_context_digest("", |_| {
438                panic!("no variable may be read without an explicit selection")
439            })
440        );
441        assert_eq!(baseline, selected_context_digest("  ,  ,", empty));
442    }
443
444    #[test]
445    fn explicitly_selected_variables_participate_and_distinguish_absence() {
446        let unset = selected_context_digest("TZ", empty);
447        let utc = selected_context_digest("TZ", |name| (name == "TZ").then(|| "UTC".to_owned()));
448        let berlin = selected_context_digest("TZ", |name| {
449            (name == "TZ").then(|| "Europe/Berlin".to_owned())
450        });
451        assert_ne!(unset, utc, "an unset variable differs from a set one");
452        assert_ne!(utc, berlin, "the value participates, not just the name");
453        assert_ne!(
454            utc,
455            selected_context_digest("", empty),
456            "selecting a variable differs from selecting none"
457        );
458        // Order and padding in the selection are not themselves inputs.
459        let pair = selected_context_digest("TZ,LANG", |name| Some(name.to_owned()));
460        assert_eq!(
461            pair,
462            selected_context_digest(" LANG , TZ ", |name| Some(name.to_owned()))
463        );
464    }
465
466    #[test]
467    fn go_s_assertion_forms_are_the_failure_report_and_testify() {
468        // A Go test states its claim with an `if` and reports the violation,
469        // so there is no assertion expression to point at: the report is the
470        // site. testify is the other form nearly every Go suite uses.
471        let source = "package p\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestThings(t *testing.T) {\n\tif got := f(); got != 1 {\n\t\tt.Errorf(\"got %d\", got)\n\t}\n\tif err := g(); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tassert.Equal(t, 1, f())\n\trequire.NoError(t, g())\n\tt.Log(\"not a claim\")\n\tfmt.Errorf(\"not a claim either\")\n}\n";
472        let operations = go_ranges(source)
473            .expect("parse")
474            .into_iter()
475            .map(|(_, _, operation)| operation)
476            .collect::<Vec<_>>();
477        assert_eq!(
478            operations,
479            ["t.Errorf", "t.Fatal", "assert.Equal", "require.NoError"],
480            "fmt.Errorf is the same shape as t.Errorf and is not a claim"
481        );
482    }
483
484    #[test]
485    fn a_subtest_and_a_benchmark_name_their_harness_whatever_they_like() {
486        // `t` is the convention, not a rule: a subtest closure rebinds it, a
487        // benchmark calls it `b`, and a file is free to choose. Taking the
488        // name from the declaration is what makes all three work.
489        let source = "package p\n\nimport \"testing\"\n\nfunc TestOuter(outer *testing.T) {\n\touter.Run(\"inner\", func(inner *testing.T) {\n\t\tinner.Fatal(\"inner failed\")\n\t})\n}\n\nfunc BenchmarkThing(b *testing.B) {\n\tb.Fatalf(\"setup failed\")\n}\n";
490        let operations = go_ranges(source)
491            .expect("parse")
492            .into_iter()
493            .map(|(_, _, operation)| operation)
494            .collect::<Vec<_>>();
495        assert_eq!(operations, ["inner.Fatal", "b.Fatalf"]);
496    }
497
498    #[test]
499    fn the_jvm_s_assertion_forms_are_recognised_by_shape_not_by_framework() {
500        // JUnit, TestNG, AssertJ, Hamcrest and kotlin.test all spell theirs
501        // the same way, so one rule covers every one of them without naming a
502        // framework or pinning a version.
503        let java = "class T {\n  void t() {\n    assertEquals(1, f());\n    Assertions.assertTrue(g());\n    assertThat(h()).isEqualTo(2);\n    org.junit.Assert.fail(\"boom\");\n    log(\"not a claim\");\n  }\n}";
504        let operations = jvm_ranges(java, crate::jvm_instrumenter::JvmLanguage::Java)
505            .expect("parse")
506            .into_iter()
507            .map(|(_, _, operation)| operation)
508            .collect::<Vec<_>>();
509        for expected in ["assertEquals", "assertTrue", "assertThat", "fail"] {
510            assert!(
511                operations
512                    .iter()
513                    .any(|operation| operation.ends_with(expected)),
514                "{expected} missing from {operations:?}"
515            );
516        }
517        assert!(
518            !operations.iter().any(|operation| operation.contains("log")),
519            "{operations:?}"
520        );
521
522        let kotlin = "fun t() {\n    assertEquals(1, f())\n    assertTrue(g())\n    println(\"not a claim\")\n}\n";
523        let operations = jvm_ranges(kotlin, crate::jvm_instrumenter::JvmLanguage::Kotlin)
524            .expect("parse")
525            .into_iter()
526            .map(|(_, _, operation)| operation)
527            .collect::<Vec<_>>();
528        for expected in ["assertEquals", "assertTrue"] {
529            assert!(
530                operations
531                    .iter()
532                    .any(|operation| operation.ends_with(expected)),
533                "{expected} missing from {operations:?}"
534            );
535        }
536        assert!(
537            !operations
538                .iter()
539                .any(|operation| operation.contains("println")),
540            "{operations:?}"
541        );
542    }
543}