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 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 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
129pub 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}