1use 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 const CONTEXT_ENVIRONMENT: &str = "SUPERCOV_ASSERTION_CONTEXT_ENV";
21
22fn 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 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
170pub 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
206fn 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 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
241fn 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 (harnesses.contains(receiver)
260 && matches!(method, "Error" | "Errorf" | "Fatal" | "Fatalf"))
261 || matches!(receiver, "assert" | "require")
262 })
263 .collect())
264}
265
266fn 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
299fn 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 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 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 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 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 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}