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 == "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
161pub 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 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 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}