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)
187 .same_bytes(expected)
188 .then_some(text)
189 })();
190 let Some(source) = source else {
191 return Err(format!(
192 "Current source differs from the run or is unavailable: {file}; rerun tests to inherit the map for the current checkout"
193 ));
194 };
195 files.insert(file.clone(), source);
196 }
197 let inputs = manifest.with_sources(files);
198 if inputs
199 .assertions
200 .iter()
201 .any(|s| s.at.offset(&inputs.files).is_none())
202 {
203 return Err("Invalid assertion identities in run manifest".into());
204 }
205 Ok(inputs)
206}
207
208fn calls(tree: &tree_sitter::Tree, source: &str, kinds: &[&str]) -> Vec<(usize, usize, String)> {
214 let mut found = Vec::new();
215 let mut stack = vec![tree.root_node()];
216 while let Some(node) = stack.pop() {
217 let mut cursor = node.walk();
218 for child in node.children(&mut cursor) {
219 stack.push(child);
220 }
221 if !kinds.contains(&node.kind()) {
222 continue;
223 }
224 let callee = node
227 .child_by_field_name("function")
228 .or_else(|| node.child_by_field_name("name"))
229 .or_else(|| node.named_child(0));
230 let Some(callee) = callee else {
231 continue;
232 };
233 found.push((
234 node.start_byte(),
235 node.end_byte(),
236 source[callee.byte_range()].trim().to_owned(),
237 ));
238 }
239 found.sort();
240 found
241}
242
243fn go_ranges(source: &str) -> Result<Vec<(usize, usize, String)>, String> {
250 let tree = crate::go_instrumenter::parse(source).map_err(|e| e.to_string())?;
251 let harnesses = testing_parameters(&tree, source);
252 Ok(calls(&tree, source, &["call_expression"])
253 .into_iter()
254 .filter(|(_, _, callee)| {
255 let Some((receiver, method)) = callee.rsplit_once('.') else {
256 return false;
257 };
258 (harnesses.contains(receiver)
262 && matches!(method, "Error" | "Errorf" | "Fatal" | "Fatalf"))
263 || matches!(receiver, "assert" | "require")
264 })
265 .collect())
266}
267
268fn testing_parameters(tree: &tree_sitter::Tree, source: &str) -> BTreeSet<String> {
275 let mut names = BTreeSet::new();
276 let mut stack = vec![tree.root_node()];
277 while let Some(node) = stack.pop() {
278 let mut cursor = node.walk();
279 for child in node.children(&mut cursor) {
280 stack.push(child);
281 }
282 if node.kind() != "parameter_declaration" {
283 continue;
284 }
285 let Some(kind) = node.child_by_field_name("type") else {
286 continue;
287 };
288 if !matches!(
289 source[kind.byte_range()].trim(),
290 "*testing.T" | "*testing.B" | "*testing.F"
291 ) {
292 continue;
293 }
294 if let Some(name) = node.child_by_field_name("name") {
295 names.insert(source[name.byte_range()].trim().to_owned());
296 }
297 }
298 names
299}
300
301fn jvm_ranges(
308 source: &str,
309 language: crate::jvm_instrumenter::JvmLanguage,
310) -> Result<Vec<(usize, usize, String)>, String> {
311 let tree = crate::jvm_instrumenter::parse(source, language).map_err(|e| e.to_string())?;
312 Ok(
313 calls(&tree, source, &["method_invocation", "call_expression"])
314 .into_iter()
315 .filter(|(_, _, callee)| {
316 let last = callee.rsplit('.').next().unwrap_or_default();
317 last.starts_with("assert") || last == "fail"
318 })
319 .collect(),
320 )
321}
322
323fn rust_ranges(source: &str) -> Result<Vec<(usize, usize, String)>, String> {
324 use ra_ap_syntax::{AstNode, Edition, SourceFile, ast};
325 let parsed = SourceFile::parse(source, Edition::Edition2024);
326 if !parsed.errors().is_empty() {
327 return Err("Rust parse errors".into());
328 }
329 Ok(parsed
330 .tree()
331 .syntax()
332 .descendants()
333 .filter_map(ast::MacroCall::cast)
334 .filter_map(|m| {
335 let path = m.path()?.syntax().text().to_string();
336 if !matches!(
337 path.rsplit("::").next()?,
338 "assert"
339 | "assert_eq"
340 | "assert_ne"
341 | "debug_assert"
342 | "debug_assert_eq"
343 | "debug_assert_ne"
344 ) {
345 return None;
346 }
347 let range = m.syntax().text_range();
348 Some((
349 u32::from(range.start()) as usize,
350 u32::from(range.end()) as usize,
351 path,
352 ))
353 })
354 .collect())
355}
356fn python_ranges(source: &str) -> Result<Vec<(usize, usize, String)>, String> {
357 use ruff_python_ast::{
358 Expr, Stmt,
359 visitor::{Visitor, walk_expr, walk_stmt},
360 };
361 use ruff_text_size::Ranged;
362 struct Collector(Vec<(usize, usize, String)>);
363 impl<'a> Visitor<'a> for Collector {
364 fn visit_stmt(&mut self, stmt: &'a Stmt) {
365 if let Stmt::Assert(_) = stmt {
366 self.0.push((
367 stmt.range().start().to_usize(),
368 stmt.range().end().to_usize(),
369 "assert".into(),
370 ));
371 }
372 walk_stmt(self, stmt);
373 }
374 fn visit_expr(&mut self, expr: &'a Expr) {
375 if let Expr::Call(call) = expr
376 && let Expr::Attribute(attr) = call.func.as_ref()
377 && attr.attr.as_str().starts_with("assert")
378 {
379 self.0.push((
380 expr.range().start().to_usize(),
381 expr.range().end().to_usize(),
382 attr.attr.to_string(),
383 ));
384 }
385 walk_expr(self, expr);
386 }
387 }
388 let parsed = ruff_python_parser::parse_module(source).map_err(|e| e.to_string())?;
389 let mut collector = Collector(vec![]);
390 for stmt in &parsed.syntax().body {
391 collector.visit_stmt(stmt);
392 }
393 Ok(collector.0)
394}
395fn ruby_ranges(source: &str) -> Result<Vec<(usize, usize, String)>, String> {
396 use ruby_prism::{CallNode, Visit};
397 struct Collector(Vec<(usize, usize, String)>);
398 impl<'a> Visit<'a> for Collector {
399 fn visit_call_node(&mut self, node: &CallNode<'a>) {
400 let name = String::from_utf8_lossy(node.name().as_slice()).into_owned();
401 if name == "assert"
402 || name == "refute"
403 || name.starts_with("assert_")
404 || name.starts_with("refute_")
405 || matches!(name.as_str(), "to" | "not_to" | "to_not")
406 {
407 let location = node.location();
408 self.0
409 .push((location.start_offset(), location.end_offset(), name));
410 }
411 ruby_prism::visit_call_node(self, node);
412 }
413 }
414 let parsed = ruby_prism::parse(source.as_bytes());
415 if parsed.errors().next().is_some() {
416 return Err("Ruby parse errors".into());
417 }
418 let mut collector = Collector(vec![]);
419 collector.visit(&parsed.node());
420 Ok(collector.0)
421}
422
423#[cfg(test)]
424mod tests {
425 use super::*;
426
427 fn empty(_: &str) -> Option<String> {
428 None
429 }
430
431 #[test]
432 fn incidental_environment_never_reaches_context_identity() {
433 let baseline = selected_context_digest("", empty);
437 assert_eq!(
438 baseline,
439 selected_context_digest("", |_| {
440 panic!("no variable may be read without an explicit selection")
441 })
442 );
443 assert_eq!(baseline, selected_context_digest(" , ,", empty));
444 }
445
446 #[test]
447 fn explicitly_selected_variables_participate_and_distinguish_absence() {
448 let unset = selected_context_digest("TZ", empty);
449 let utc = selected_context_digest("TZ", |name| (name == "TZ").then(|| "UTC".to_owned()));
450 let berlin = selected_context_digest("TZ", |name| {
451 (name == "TZ").then(|| "Europe/Berlin".to_owned())
452 });
453 assert_ne!(unset, utc, "an unset variable differs from a set one");
454 assert_ne!(utc, berlin, "the value participates, not just the name");
455 assert_ne!(
456 utc,
457 selected_context_digest("", empty),
458 "selecting a variable differs from selecting none"
459 );
460 let pair = selected_context_digest("TZ,LANG", |name| Some(name.to_owned()));
462 assert_eq!(
463 pair,
464 selected_context_digest(" LANG , TZ ", |name| Some(name.to_owned()))
465 );
466 }
467
468 #[test]
469 fn go_s_assertion_forms_are_the_failure_report_and_testify() {
470 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";
474 let operations = go_ranges(source)
475 .expect("parse")
476 .into_iter()
477 .map(|(_, _, operation)| operation)
478 .collect::<Vec<_>>();
479 assert_eq!(
480 operations,
481 ["t.Errorf", "t.Fatal", "assert.Equal", "require.NoError"],
482 "fmt.Errorf is the same shape as t.Errorf and is not a claim"
483 );
484 }
485
486 #[test]
487 fn a_subtest_and_a_benchmark_name_their_harness_whatever_they_like() {
488 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";
492 let operations = go_ranges(source)
493 .expect("parse")
494 .into_iter()
495 .map(|(_, _, operation)| operation)
496 .collect::<Vec<_>>();
497 assert_eq!(operations, ["inner.Fatal", "b.Fatalf"]);
498 }
499
500 #[test]
501 fn the_jvm_s_assertion_forms_are_recognised_by_shape_not_by_framework() {
502 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}";
506 let operations = jvm_ranges(java, crate::jvm_instrumenter::JvmLanguage::Java)
507 .expect("parse")
508 .into_iter()
509 .map(|(_, _, operation)| operation)
510 .collect::<Vec<_>>();
511 for expected in ["assertEquals", "assertTrue", "assertThat", "fail"] {
512 assert!(
513 operations
514 .iter()
515 .any(|operation| operation.ends_with(expected)),
516 "{expected} missing from {operations:?}"
517 );
518 }
519 assert!(
520 !operations.iter().any(|operation| operation.contains("log")),
521 "{operations:?}"
522 );
523
524 let kotlin = "fun t() {\n assertEquals(1, f())\n assertTrue(g())\n println(\"not a claim\")\n}\n";
525 let operations = jvm_ranges(kotlin, crate::jvm_instrumenter::JvmLanguage::Kotlin)
526 .expect("parse")
527 .into_iter()
528 .map(|(_, _, operation)| operation)
529 .collect::<Vec<_>>();
530 for expected in ["assertEquals", "assertTrue"] {
531 assert!(
532 operations
533 .iter()
534 .any(|operation| operation.ends_with(expected)),
535 "{expected} missing from {operations:?}"
536 );
537 }
538 assert!(
539 !operations
540 .iter()
541 .any(|operation| operation.contains("println")),
542 "{operations:?}"
543 );
544 }
545}