1use crate::{
3 assertion_inputs::ARCHIVE_PATH,
4 assertion_map::{self as model, *},
5 coverage_report::{
6 ArchiveReportRequest, CoverageReport, ExitCodeInput, analyze_coverage_archive,
7 },
8 evidence_archive::read_archive,
9 lifecycle::atomic_write,
10 run_store::{RunFingerprint, RunMetadata, StoredRun, discover_runs},
11 source_units::named,
12};
13use serde_json::{Value, json};
14use sha2::{Digest, Sha256};
15use std::{
16 collections::{BTreeMap, BTreeSet},
17 fs,
18 path::Path,
19};
20
21pub const MAP_FILE: &str = "assertions.json";
22pub const STATE_FILE: &str = "assertions.state.json";
23const REPORT_CACHE_FILE: &str = "assertions.report.cache.json";
24pub struct RunManifest {
25 pub manifest: InputManifest,
26 pub evidence_digest: String,
27 legacy_digest: Option<String>,
28 pub statement_exclusions: Vec<Value>,
29}
30pub struct RunInputs {
31 pub inputs: Inputs,
32 pub stored: RunManifest,
33}
34impl std::ops::Deref for RunInputs {
35 type Target = RunManifest;
36 fn deref(&self) -> &RunManifest {
37 &self.stored
38 }
39}
40pub fn load_inputs(root: &Path, run: &StoredRun) -> Result<RunInputs, String> {
41 let stored = load_manifest(run)?;
42 let inputs = crate::assertion_inputs::current_sources(root, &stored.manifest)?;
43 Ok(RunInputs { inputs, stored })
44}
45pub fn load_manifest(run: &StoredRun) -> Result<RunManifest, String> {
46 load_optional_manifest(run)?.ok_or_else(|| {
47 "This older run has no assertion manifest. Run tests once with this version of Supercov"
48 .into()
49 })
50}
51fn load_optional_manifest(run: &StoredRun) -> Result<Option<RunManifest>, String> {
52 if run.metadata.merged == Some(true) {
53 return Err(
54 "Use a single run for assertion maps; merged runs have multiple input manifests".into(),
55 );
56 }
57 let bytes = fs::read(&run.evidence_path).map_err(|e| e.to_string())?;
58 let evidence_digest = format!("{:x}", Sha256::digest(&bytes));
59 let entries = read_archive(&run.evidence_path).map_err(|e| e.to_string())?;
60 let Some(input) = entries.iter().find(|e| e.path == ARCHIVE_PATH) else {
61 return Ok(None);
62 };
63 let value: Value = serde_json::from_slice(&input.contents).map_err(|e| e.to_string())?;
64 let (manifest, legacy_digest) = match value["schemaVersion"].as_u64() {
65 Some(2) => (
66 serde_json::from_value::<InputManifest>(value).map_err(|e| e.to_string())?,
67 None,
68 ),
69 Some(1) => {
70 let legacy: Inputs = serde_json::from_value(value).map_err(|e| e.to_string())?;
73 if legacy
74 .assertions
75 .iter()
76 .any(|s| s.at.offset(&legacy.files).is_none())
77 {
78 return Err("Invalid legacy assertion inputs".into());
79 }
80 (legacy.manifest(), Some(digest(&legacy)))
81 }
82 _ => return Err("Unsupported assertion input schema".into()),
83 };
84 if manifest.files.iter().any(|(p, f)| {
85 !local_path(p) || f.sha256.len() != 64 || !f.sha256.bytes().all(|b| b.is_ascii_hexdigit())
86 }) || manifest.assertions.iter().any(|s| {
87 !manifest.files.contains_key(&s.at.file)
88 || s.at.line == 0
89 || s.at.column == 0
90 || s.at.text.is_empty()
91 }) {
92 return Err("Invalid assertion input manifest".into());
93 }
94 if fs::read(&run.evidence_path).map_err(|e| e.to_string())? != bytes {
95 return Err("Run archive changed during read".into());
96 }
97 Ok(Some(RunManifest {
98 manifest,
99 evidence_digest,
100 legacy_digest,
101 statement_exclusions: entries
102 .iter()
103 .find(|e| e.path == "statement-exclusions.json")
104 .map(|entry| serde_json::from_slice(&entry.contents).map_err(|e| e.to_string()))
105 .transpose()?
106 .unwrap_or_default(),
107 }))
108}
109pub fn load(run: &StoredRun, input: &RunManifest) -> Result<(AssertionMap, State), String> {
110 let read = |file: &str| {
111 fs::read(run.directory.join(file))
112 .map_err(|e| format!("{file}: {e}; new test runs create assertion maps automatically"))
113 };
114 let map = model::parse_stored(&read(MAP_FILE)?).map_err(|e| format!("{MAP_FILE}: {e}"))?;
115 let state = model::parse_state(
116 &read(STATE_FILE)?,
117 &map,
118 &input.manifest,
119 &input.evidence_digest,
120 input.legacy_digest.as_deref(),
121 )?;
122 Ok((map, state))
123}
124fn write_json(
125 root: &Path,
126 run: &StoredRun,
127 name: &str,
128 value: &impl serde::Serialize,
129) -> Result<(), String> {
130 let mut bytes = serde_json::to_vec_pretty(value).map_err(|e| e.to_string())?;
131 bytes.push(b'\n');
132 atomic_write(root, &run.directory.join(name), &bytes).map_err(|e| e.to_string())
133}
134pub(crate) fn prepare_publication(
138 root: &Path,
139 directory: &Path,
140 metadata: &RunMetadata,
141) -> Result<(), String> {
142 if metadata.merged == Some(true) {
143 return Ok(());
144 }
145 let run = StoredRun {
146 id: metadata.id.clone(),
147 directory: directory.into(),
148 evidence_path: directory.join("evidence.raw.gz"),
149 metadata_path: directory.join("run.json"),
150 query_index_path: directory.join(crate::run_store::RUST_QUERY_INDEX_FILE),
151 metadata: metadata.clone(),
152 };
153 let Some(input) = load_optional_manifest(&run)? else {
154 return Ok(());
155 };
156 if directory.join(MAP_FILE).exists() || directory.join(STATE_FILE).exists() {
158 return Err("Refusing to replace an existing assertion map or review state".into());
159 }
160 let current = crate::assertion_inputs::current_sources(root, &input.manifest);
161 let inventory = discover_runs(root).map_err(|e| e.to_string())?;
162 let mut inheritance = Inheritance::default();
163 let mut inherited = None;
164 for previous in &inventory.runs {
165 if previous.id == run.id
166 || previous.metadata.merged == Some(true)
167 || previous.metadata.command != metadata.command
168 || (!previous.directory.join(MAP_FILE).exists()
169 && !previous.directory.join(STATE_FILE).exists())
170 {
171 continue;
172 }
173 let attempt = (|| {
174 let old = load_manifest(previous)?;
175 if old.manifest.language != input.manifest.language {
176 return Ok(None);
177 }
178 let (map, state) = load(previous, &old)?;
179 let a = &previous.metadata.integrity.fingerprint;
180 let b = &metadata.integrity.fingerprint;
181 let delta = context_delta(
182 a,
183 b,
184 &old.manifest.context_digest,
185 &input.manifest.context_digest,
186 );
187 let context_changed = delta.execution;
188 let dependencies_changed = delta.dependencies;
189 let current = match ¤t {
190 Ok(current) => current,
191 Err(reason) => {
192 let (mut next, mut next_state) =
196 seed_manifest(&input.manifest, &input.evidence_digest);
197 next.retired_assertions = map.retired_assertions;
198 for assertion in map.assertions {
199 if let Some(site) =
200 next.assertions.iter_mut().find(|a| a.at == assertion.at)
201 {
202 *site = assertion;
203 } else {
204 next.retired_assertions.push(Retired {
205 assertion,
206 reason: reason.clone(),
207 });
208 }
209 }
210 let mut reserved = next
211 .retired_assertions
212 .iter()
213 .map(|r| r.assertion.id.clone())
214 .chain(
215 next.assertions
216 .iter()
217 .filter(|a| !a.flows.is_empty())
218 .map(|a| a.id.clone()),
219 )
220 .collect::<BTreeSet<_>>();
221 for assertion in next.assertions.iter_mut().filter(|a| a.flows.is_empty()) {
222 while !reserved.insert(assertion.id.clone()) {
223 assertion.id.push('_');
224 }
225 }
226 invalidate(&mut next_state, &next, reason);
227 add_change(
228 &mut next_state,
229 None,
230 None,
231 None,
232 reason.clone(),
233 BTreeSet::new(),
234 BTreeSet::new(),
235 );
236 return Ok(Some((next, next_state)));
237 }
238 };
239 model::carry(
240 &map,
241 &state,
242 &old.manifest,
243 current,
244 &input.evidence_digest,
245 context_changed,
246 )
247 .map(|(next, mut next_state)| {
248 if dependencies_changed {
249 add_change(
250 &mut next_state,
251 None,
252 Some(a.dependencies.clone()),
253 Some(b.dependencies.clone()),
254 "installed dependencies changed".into(),
255 BTreeSet::new(),
256 BTreeSet::new(),
257 );
258 }
259 Some((next, next_state))
260 })
261 })();
262 match attempt {
263 Ok(Some(pair)) => {
264 inheritance.from = Some(previous.id.clone());
265 inherited = Some(pair);
266 break;
267 }
268 Ok(None) => (),
269 Err(reason) => inheritance.skipped.push(SkippedMap {
270 run: previous.id.clone(),
271 reason,
272 }),
273 }
274 }
275 let (map, mut state) =
276 inherited.unwrap_or_else(|| seed_manifest(&input.manifest, &input.evidence_digest));
277 if !inheritance.skipped.is_empty() {
280 invalidate(
281 &mut state,
282 &map,
283 "newer assertion map could not be reused; inspect inherited claims",
284 );
285 }
286 state.executions = coverage(&run).ok().and_then(|report| {
290 executions(
291 &report,
292 &input.manifest,
293 current.as_ref().ok().map(|inputs| &inputs.files),
294 )
295 });
296 if let Err(reason) = current {
297 invalidate(&mut state, &map, &reason);
298 add_change(
299 &mut state,
300 None,
301 None,
302 None,
303 reason,
304 BTreeSet::new(),
305 BTreeSet::new(),
306 );
307 }
308 state.inheritance = Some(inheritance);
309 write_json(root, &run, MAP_FILE, &map)?;
310 write_json(root, &run, STATE_FILE, &state)?;
311 Ok(())
312}
313pub fn executions(
319 coverage: &CoverageReport,
320 manifest: &InputManifest,
321 sources: Option<&Files>,
322) -> Option<Executions> {
323 let mut located: std::collections::HashMap<&str, (&str, usize)> =
324 std::collections::HashMap::new();
325 let mut probed: BTreeMap<String, BTreeSet<usize>> = BTreeMap::new();
326 for point in &coverage.view.points {
327 let meta = &point.meta;
328 let Some(code) = manifest
329 .files
330 .get(&meta.file)
331 .and_then(|fingerprint| fingerprint.code.as_ref())
332 else {
333 continue;
334 };
335 let column = if manifest.language == "javascript" {
336 let source = sources?.get(&meta.file)?;
337 let Some(column) = byte_column(source, meta.line, meta.column, &manifest.language)
338 else {
339 continue;
340 };
341 column
342 } else {
343 meta.column + 1
344 };
345 let unit = code.unit_at(meta.line, column);
346 located.insert(meta.id.as_str(), (meta.file.as_str(), unit));
347 if code.units[unit].is_code() {
348 probed.entry(meta.file.clone()).or_default().insert(unit);
349 }
350 }
351 let mut tests: BTreeMap<TestSelector, Execution> = BTreeMap::new();
352 for test in &coverage.view.tests {
353 let Some(file) = &test.file else {
354 continue;
355 };
356 let selector = TestSelector {
357 file: file.clone(),
358 name: test.name.clone(),
359 };
360 let record = tests.entry(selector.clone()).or_insert_with(|| Execution {
361 test: selector,
362 passed: false,
363 files: BTreeMap::new(),
364 });
365 record.passed |= test.outcome == "passed";
366 for hit in &test.hits {
367 if let Some((file, unit)) = located.get(hit.as_str()) {
368 record
369 .files
370 .entry((*file).to_owned())
371 .or_default()
372 .push(*unit);
373 }
374 }
375 }
376 let mut tests = tests.into_values().collect::<Vec<_>>();
377 for record in &mut tests {
378 for units in record.files.values_mut() {
379 units.sort_unstable();
380 units.dedup();
381 }
382 }
383 Some(Executions {
384 tests,
385 probed: probed
386 .into_iter()
387 .map(|(file, units)| (file, units.into_iter().collect()))
388 .collect(),
389 })
390}
391pub fn affected_tests(root: &Path, run: &StoredRun) -> Result<Value, String> {
401 let stored = load_manifest(run)?;
402 let (_, state) = load(run, &stored)?;
403 let Some(executions) = &state.executions else {
404 return Err(
405 "This run has no per-test execution record; rerun tests with this version of Supercov"
406 .into(),
407 );
408 };
409 let root = crate::workspace::canonicalize_simplified(root).map_err(|e| e.to_string())?;
410 let manifest = &stored.manifest;
411 let now = manifest
412 .files
413 .keys()
414 .filter_map(|file| {
415 let path = root.join(file);
416 let canonical = crate::workspace::canonicalize_simplified(&path).ok()?;
417 if !canonical.starts_with(&root) || !canonical.is_file() {
418 return None;
419 }
420 let text = fs::read_to_string(canonical).ok()?;
421 Some((file.clone(), FileFingerprint::read(file, &text)))
422 })
423 .collect::<FileManifest>();
424 let changes = manifest
425 .files
426 .keys()
427 .filter_map(|file| {
428 model::file_change(
429 manifest.files.get(file),
430 now.get(file),
431 executions.probed.get(file).map(Vec::as_slice),
432 )
433 .map(|change| (file.as_str(), change))
434 })
435 .collect::<BTreeMap<_, _>>();
436 let files = changes
437 .iter()
438 .filter_map(|(file, change)| {
439 let (kind, detail) = match change {
440 FileChange::Same | FileChange::Added => return None,
441 FileChange::CommentsOnly => ("formatting", None),
442 FileChange::Removed => ("removed", None),
443 FileChange::Bytes => ("changed", None),
444 FileChange::Code {
445 before,
446 after,
447 diff,
448 narrow,
449 } => (
450 if *narrow { "bodies" } else { "declarations" },
451 Some(model::describe(before, after, diff)),
452 ),
453 };
454 Some(json!({"file":file,"change":kind,"detail":detail}))
455 })
456 .collect::<Vec<_>>();
457 let mut affected = Vec::new();
458 let mut unaffected = Vec::new();
459 for record in &executions.tests {
460 let mut reasons = Vec::new();
461 if !record.passed {
462 reasons.push("did not pass in the run".to_owned());
463 }
464 match changes.get(record.test.file.as_str()) {
465 None | Some(FileChange::Same | FileChange::CommentsOnly | FileChange::Added) => {}
466 Some(FileChange::Removed) => reasons.push("test file removed".to_owned()),
467 Some(FileChange::Bytes) => reasons.push("test file changed".to_owned()),
468 Some(FileChange::Code {
469 before,
470 after,
471 diff,
472 ..
473 }) => reasons.push(format!(
474 "test file changed: {}",
475 model::describe(before, after, diff)
476 )),
477 }
478 for (file, units) in &record.files {
479 let code = manifest.files.get(file).and_then(|f| f.code.as_ref());
480 let ran = units
481 .iter()
482 .flat_map(|unit| match code {
483 Some(code) if *unit < code.units.len() => {
484 code.ancestors(*unit).collect::<Vec<_>>()
485 }
486 _ => vec![*unit],
487 })
488 .collect::<BTreeSet<_>>();
489 match changes.get(file.as_str()) {
490 None | Some(FileChange::Same | FileChange::CommentsOnly | FileChange::Added) => {}
491 Some(FileChange::Removed) => {
492 reasons.push(format!("{file} removed (this test ran code in it)"));
493 }
494 Some(FileChange::Bytes) => {
495 reasons.push(format!("{file} changed (this test ran code in it)"));
496 }
497 Some(FileChange::Code {
498 before,
499 after,
500 diff,
501 narrow,
502 }) => {
503 if *narrow {
504 let hit = diff
505 .changed
506 .iter()
507 .filter(|i| ran.contains(i))
508 .map(|i| &before.units[*i])
509 .collect::<Vec<_>>();
510 if !hit.is_empty() {
511 reasons
512 .push(format!("{file}: {} changed (this test ran it)", named(hit)));
513 }
514 } else {
515 reasons.push(format!(
516 "{file}: {} changed (this test ran code in this file)",
517 model::describe(before, after, diff)
518 ));
519 }
520 }
521 }
522 }
523 let entry = json!({"file":record.test.file,"name":record.test.name,"reasons":reasons});
524 if reasons.is_empty() {
525 unaffected.push(entry);
526 } else {
527 affected.push(entry);
528 }
529 }
530 Ok(json!({
531 "run": run.id,
532 "affected": affected,
533 "unaffected": unaffected,
534 "changedFiles": files,
535 "summary": {"tests": executions.tests.len(), "affected": affected.len(), "unaffected": unaffected.len(), "changedFiles": files.len()},
536 "meaning": "Tests whose recorded execution a change since the run could have reached. A file the run never captured, a dependency or a configuration change is not seen here; see workingTree."
537 }))
538}
539struct ContextDelta {
541 execution: bool,
544 dependencies: bool,
547}
548
549fn context_delta(
564 a: &RunFingerprint,
565 b: &RunFingerprint,
566 old_context: &str,
567 new_context: &str,
568) -> ContextDelta {
569 ContextDelta {
570 execution: a.configuration != b.configuration || old_context != new_context,
571 dependencies: a.dependencies != b.dependencies,
572 }
573}
574
575pub fn coverage(run: &StoredRun) -> Result<CoverageReport, String> {
576 analyze_coverage_archive(&ArchiveReportRequest {
577 archive_path: run.evidence_path.clone(),
578 run_id: run.id.clone(),
579 generated_at: run.metadata.started_at.clone(),
580 integrity: None,
581 test_exit_code: ExitCodeInput::Present(run.metadata.test_exit_code),
582 })
583 .map_err(|e| format!("{e:?}"))
584}
585fn byte_column(source: &str, line: usize, column: usize, language: &str) -> Option<usize> {
586 if line == 0 {
587 return None;
588 }
589 let line = source.lines().nth(line - 1)?;
590 if language != "javascript" {
591 return column.checked_add(1);
593 }
594 if column == 0 {
595 return None;
596 }
597 let mut units = 0;
598 for (byte, ch) in line.char_indices() {
599 if units == column - 1 {
600 return Some(byte + 1);
601 }
602 units += ch.len_utf16();
603 }
604 (units == column - 1).then_some(line.len() + 1)
605}
606fn phase_location<'a>(
607 phase: &'a crate::coverage_report::CoveragePhase,
608 inputs: &Inputs,
609) -> Option<(&'a str, usize, usize)> {
610 if phase.kind != "assertion" || phase.status.as_deref() != Some("passed") {
611 return None;
612 }
613 let source = phase
614 .operation
615 .strip_prefix("Rust assertion at ")
616 .or(phase.source.as_deref());
617 let location = source?;
618 let mut parts = location.rsplitn(3, ':');
619 let column = parts.next()?.parse::<usize>().ok()?;
620 let line = parts.next()?.parse::<usize>().ok()?;
621 let file = parts.next()?;
622 let column = byte_column(inputs.files.get(file)?, line, column, &inputs.language)?;
623 Some((file, line, column))
624}
625
626pub fn report(root: &Path, run: &StoredRun) -> Result<Value, String> {
629 report_with_detail(root, run, None)
630}
631pub fn assertion(root: &Path, run: &StoredRun, id: &str) -> Result<Value, String> {
633 report_with_detail(root, run, Some(id))
634}
635fn report_with_detail(root: &Path, run: &StoredRun, id: Option<&str>) -> Result<Value, String> {
636 let input = load_inputs(root, run)?;
637 let (map, state) = load(run, &input)?;
638 let cache_key = digest(&(
639 env!("SUPERCOV_ENGINE_SOURCE_SHA256"),
640 &run.id,
641 run.metadata.test_exit_code,
642 &map,
643 &state,
644 &input.evidence_digest,
645 ));
646 let cache_path = run.directory.join(REPORT_CACHE_FILE);
647 let mut report = read_report_cache(&cache_path, &cache_key).unwrap_or_else(|| Value::Null);
648 if report.is_null() {
649 let coverage = coverage(run)?;
650 report = assess(
651 &map,
652 &state,
653 &input.inputs,
654 &coverage,
655 run.metadata.test_exit_code == Some(0),
656 );
657 report["excludedStatements"] = json!(input.statement_exclusions);
658 report["summary"]["excludedStatements"] = json!(input.statement_exclusions.len());
659 let cached = json!({"key":cache_key,"digest":digest(&report),"report":report});
662 let _ = write_json(root, run, REPORT_CACHE_FILE, &cached);
663 }
664 if let Some(id) = id {
665 let matches = report["assertions"]
666 .as_array()
667 .unwrap()
668 .iter()
669 .filter(|a| a["id"] == id)
670 .collect::<Vec<_>>();
671 let mut row = match matches.as_slice() {
672 [row] => (*row).clone(),
673 [] => {
674 return Err(format!(
675 "Unknown assertion ID: {id}; use runs {} assertions to list IDs",
676 run.id
677 ));
678 }
679 _ => {
680 return Err(format!(
681 "Ambiguous assertion ID: {id}; repair duplicate IDs in assertions.json"
682 ));
683 }
684 };
685 if let Some(authored) = map.assertions.iter().find(|a| a.id == id) {
686 for (flow, authored) in row["flows"]
687 .as_array_mut()
688 .unwrap()
689 .iter_mut()
690 .zip(&authored.flows)
691 {
692 let assessment = flow.as_object().unwrap().clone();
693 *flow = serde_json::to_value(authored).map_err(|e| e.to_string())?;
694 flow.as_object_mut().unwrap().extend(assessment);
695 }
696 }
697 report["assertion"] = row;
698 }
699 report["inheritance"] = json!(state.inheritance);
700 report["revision"] = json!(digest(&(&map, &state, &input.evidence_digest)));
701 Ok(report)
702}
703
704fn read_report_cache(path: &Path, key: &str) -> Option<Value> {
705 if fs::metadata(path).ok()?.len() > 256 * 1024 * 1024 {
706 return None;
707 }
708 let cached: Value = serde_json::from_slice(&fs::read(path).ok()?).ok()?;
709 let report = &cached["report"];
710 (cached["key"] == key
711 && cached["digest"] == digest(report)
712 && report["summary"].is_object()
713 && report["assertions"].is_array())
714 .then(|| report.clone())
715}
716
717pub fn assess(
718 map: &AssertionMap,
719 state: &State,
720 inputs: &Inputs,
721 coverage: &CoverageReport,
722 passed: bool,
723) -> Value {
724 let manifest = inputs.manifest();
725 let ledger = model::Ledger::new(map, state, &manifest);
726 let validation = model::validation(map, state, inputs);
727 let errors = validation["errors"]
728 .as_array()
729 .unwrap()
730 .iter()
731 .map(|e| e.as_str().unwrap().to_owned())
732 .collect::<Vec<_>>();
733 let pending_changes = validation["changes"]
734 .as_array()
735 .unwrap()
736 .iter()
737 .filter(|c| c["current"] != true)
738 .count();
739 let view = &coverage.filters.passed;
740 let tests = view
741 .tests
742 .iter()
743 .filter(|t| t.role == "test")
744 .map(|t| (&t.id, t))
745 .collect::<BTreeMap<_, _>>();
746 let measured_statements = view
747 .points
748 .iter()
749 .filter(|p| p.measured && p.meta.kind == crate::coverage_analysis::PointKind::Statement)
750 .collect::<Vec<_>>();
751 let all_points = coverage
752 .view
753 .points
754 .iter()
755 .map(|p| (&p.meta.id, p))
756 .collect::<BTreeMap<_, _>>();
757 let all_tests = coverage
758 .view
759 .tests
760 .iter()
761 .map(|t| (&t.id, t))
762 .collect::<BTreeMap<_, _>>();
763 let mut by_file = BTreeMap::<&str, Vec<(usize, &crate::coverage_report::PointResult)>>::new();
764 let mut by_line = BTreeMap::<(String, usize), Vec<&crate::coverage_report::PointResult>>::new();
765 for point in &measured_statements {
766 by_line
767 .entry((point.meta.file.clone(), point.meta.line))
768 .or_default()
769 .push(point);
770 if let Some(text) = inputs.files.get(&point.meta.file)
771 && let Some(column) =
772 byte_column(text, point.meta.line, point.meta.column, &inputs.language)
773 {
774 let at = Anchor {
775 file: point.meta.file.clone(),
776 line: point.meta.line,
777 column,
778 text: point.meta.source.clone(),
779 };
780 if let Some(start) = at.offset(&inputs.files) {
781 by_file
782 .entry(&point.meta.file)
783 .or_default()
784 .push((start, point));
785 }
786 }
787 }
788 for points in by_file.values_mut() {
789 points.sort_by_key(|(pos, _)| *pos);
790 }
791 let mut rows = Vec::new();
792 let mut claimed_points = BTreeSet::new();
793 let mut credited_points = BTreeSet::new();
794 let mut point_flows = BTreeMap::<String, BTreeSet<String>>::new();
795 let mut draft_flows = 0;
796 let mut stale_flows = 0;
797 let mut invalid_flows = 0;
798 let mut current_flows = 0;
799 let mut credit_flows = 0;
800 let mut line_assertions = BTreeMap::<(String, usize), BTreeSet<String>>::new();
801 let identities_valid = state.inputs_digest == inputs.identity()
804 && errors.iter().all(|e| {
805 !e.contains("duplicate") && !e.contains("schema") && !e.contains("assertion ID")
806 });
807 let mut phases_by_location = BTreeMap::new();
810 for p in &view.phases {
811 if !tests.contains_key(&p.test) {
812 continue;
813 }
814 if let Some(location) = phase_location(&p.phase, inputs) {
815 phases_by_location
816 .entry(location)
817 .or_insert_with(Vec::new)
818 .push(p);
819 }
820 }
821 let mut inventory = BTreeMap::<&Anchor, BTreeSet<&str>>::new();
822 for site in &inputs.assertions {
823 inventory
824 .entry(&site.at)
825 .or_default()
826 .insert(&site.operation);
827 }
828 let mapped_anchors = map
829 .assertions
830 .iter()
831 .map(|a| &a.at)
832 .collect::<BTreeSet<_>>();
833 let mut used_ids = map
834 .assertions
835 .iter()
836 .map(|a| a.id.clone())
837 .chain(
838 map.retired_assertions
839 .iter()
840 .map(|r| r.assertion.id.clone()),
841 )
842 .collect::<BTreeSet<_>>();
843 let missing = seed(inputs, "")
844 .0
845 .assertions
846 .into_iter()
847 .filter(|a| !mapped_anchors.contains(&a.at))
848 .map(|mut a| {
849 while !used_ids.insert(a.id.clone()) {
850 a.id.push('_');
851 }
852 a
853 })
854 .collect::<Vec<_>>();
855 for (a, in_map) in map
856 .assertions
857 .iter()
858 .map(|a| (a, true))
859 .chain(missing.iter().map(|a| (a, false)))
860 {
861 let witnesses = phases_by_location
862 .get(&(a.at.file.as_str(), a.at.line, a.at.column))
863 .into_iter()
864 .flatten()
865 .filter(|p| {
868 inputs.language != "javascript"
869 || inventory
870 .get(&a.at)
871 .is_some_and(|operations| operations.contains(p.phase.operation.as_str()))
872 })
873 .map(|p| p.test.clone())
874 .collect::<BTreeSet<_>>();
875 let mut flows = Vec::new();
876 for f in &a.flows {
877 let dirty = model::reasons_with(a, f, state, inputs, &manifest, &ledger);
878 let valid = model::validate_flow(f, &inputs.files).is_empty()
879 && a.at.offset(&inputs.files).is_some();
880 let freshness = if f.basis.is_none() {
881 draft_flows += 1;
882 "draft"
883 } else if f.basis.as_deref()
884 != Some(model::expected_basis_with(a, f, state, &manifest, &ledger).as_str())
885 {
886 stale_flows += 1;
887 "stale"
888 } else {
889 "current"
890 };
891 if !valid {
892 invalid_flows += 1;
893 }
894 if dirty.is_empty() {
895 current_flows += 1;
896 }
897 let mut resolved = BTreeSet::new();
900 let mut selectors = Vec::new();
901 for selector in &f.applies_to {
902 let matches = coverage
903 .view
904 .tests
905 .iter()
906 .filter(|t| {
907 t.file.as_deref() == Some(selector.file.as_str()) && t.name == selector.name
908 })
909 .collect::<Vec<_>>();
910 let status = match matches.as_slice() {
911 [test] if witnesses.contains(&test.id) && tests.contains_key(&test.id) => {
912 resolved.insert(test.id.clone());
913 "observed"
914 }
915 [] => "missing",
916 [_] => "unobserved",
917 _ => "ambiguous",
918 };
919 selectors.push(json!({"file":selector.file,"name":selector.name,"status":status,
920 "outcomes":matches.iter().map(|t| &t.outcome).collect::<Vec<_>>(),
921 "reason":match matches.as_slice() {
922 [] => "No test execution record matches this selector. The test may be disabled or outside this run.",
923 [test] if test.outcome != "passed" => "The selected test has no passing outcome.",
924 [_] if status == "unobserved" => "The test passed but this assertion occurrence was not recorded. Its branch may not have run, or attribution may be missing.",
925 [_] => "A passing assertion occurrence matches this test.",
926 _ => "Multiple test identities match this selector."
927 }}));
928 }
929 let applicable = resolved;
930 let eligible = passed && identities_valid && dirty.is_empty() && !applicable.is_empty();
931 let mut blockers = Vec::new();
932 if !passed {
933 blockers.push("run did not pass");
934 }
935 if !identities_valid {
936 blockers.push("invalid map identities");
937 }
938 if !dirty.is_empty() {
939 blockers.push("flow requires review or reference repair");
940 }
941 if applicable.is_empty() {
942 blockers.push("no matching passing assertion occurrence for appliesTo");
943 }
944 if eligible {
945 credit_flows += 1;
946 }
947 let mut lines = BTreeSet::new();
948 let mut node_credit = Vec::new();
949 for node in &f.nodes {
950 let claimed = f.counts_as_asserted.contains(&node.id);
951 let start = node.at.offset(&inputs.files);
952 let mut matched = Vec::new();
953 if let Some(start) = start
954 && let Some(points) = by_file.get(node.at.file.as_str())
955 {
956 let first = points.partition_point(|(pos, _)| *pos < start);
957 for (_, point) in points[first..].iter().take_while(|(pos, _)| *pos == start) {
958 if point.meta.source == node.at.text {
961 matched.push(*point);
962 }
963 }
964 }
965 let matching_tests = matched
966 .iter()
967 .filter(|p| p.covered)
968 .flat_map(|p| p.tests.iter())
969 .filter(|test| applicable.contains(*test))
970 .collect::<BTreeSet<_>>();
971 let credited = claimed && eligible && !matching_tests.is_empty();
972 let mut reasons = Vec::new();
973 let mut reason = |code: &str, message: String| {
974 reasons.push(json!({"code":code,"message":message}));
975 };
976 if !claimed {
977 reason(
978 "context_only",
979 "The agent included this node as context, not in countsAsAsserted.".into(),
980 );
981 } else if credited {
982 reason("same_test_execution", "Current agent claim, passing assertion, and statement execution in the same selected test.".into());
983 } else {
984 if start.is_none() {
985 reason(
986 "invalid_source_anchor",
987 "The node's source anchor does not match the run's current source."
988 .into(),
989 );
990 } else if matched.is_empty() {
991 reason("no_measured_statement", "The node does not exactly identify a measured production statement in this run.".into());
992 }
993 if !passed {
994 reason("run_failed", "The test run did not pass.".into());
995 }
996 if !identities_valid {
997 reason("invalid_map_identity", "Map identities or managed input state are invalid; see validation errors.".into());
998 }
999 if !dirty.is_empty() {
1000 reason(
1001 "flow_needs_attention",
1002 format!(
1003 "Flow needs investigation: {}",
1004 dirty.iter().cloned().collect::<Vec<_>>().join("; ")
1005 ),
1006 );
1007 }
1008 if applicable.is_empty() {
1009 reason("no_passing_assertion", "No selected test has a matching passing occurrence of this assertion; see flow selectors.".into());
1010 }
1011 if !matched.is_empty() {
1012 if !matched.iter().any(|p| p.covered) {
1013 let executed = matched
1014 .iter()
1015 .filter_map(|p| all_points.get(&p.meta.id))
1016 .filter(|p| p.covered)
1017 .collect::<Vec<_>>();
1018 if !executed.is_empty() {
1019 let setup = executed
1020 .iter()
1021 .flat_map(|p| &p.tests)
1022 .any(|id| all_tests.get(id).is_some_and(|t| t.role == "setup"));
1023 reason(if setup {"shared_setup_execution"} else {"execution_outside_passing_tests"},
1024 if setup {"Execution was recorded in a separate setup scope. Shared setup is not automatically credited to consuming tests."} else {"Execution was recorded outside passing tests (for example module initialization, background work or a failed test). It cannot establish same-test execution."}.into());
1025 }
1026 reason(
1027 "no_passing_execution",
1028 "No execution evidence from passing tests for this statement."
1029 .into(),
1030 );
1031 } else if !applicable.is_empty() && matching_tests.is_empty() {
1032 if matched
1033 .iter()
1034 .flat_map(|p| &p.tests)
1035 .any(|id| all_tests.get(id).is_some_and(|t| t.role == "setup"))
1036 {
1037 reason("shared_setup_execution", "Execution was recorded in a separate setup scope. Shared setup is not automatically credited to consuming tests.".into());
1038 }
1039 reason("no_same_test_execution", "No execution evidence attributed to a selected passing test for this assertion.".into());
1040 }
1041 }
1042 }
1043 node_credit.push(json!({
1044 "nodeId":node.id,
1045 "location":{"file":node.at.file,"line":node.at.line,"column":node.at.column},
1046 "status":if !claimed {"context"} else if credited {"credited"} else {"notCredited"},
1047 "statementIds":matched.iter().map(|p| &p.meta.id).collect::<Vec<_>>(),
1048 "matchingTests":matching_tests,
1049 "reasons":reasons,
1050 }));
1051 for point in matched.into_iter().filter(|_| claimed) {
1052 claimed_points.insert(point.meta.id.clone());
1053 if eligible
1054 && point.covered
1055 && point.tests.iter().any(|t| applicable.contains(t))
1056 {
1057 credited_points.insert(point.meta.id.clone());
1058 point_flows
1059 .entry(point.meta.id.clone())
1060 .or_default()
1061 .insert(flow_key(a, f));
1062 lines.insert((node.at.file.clone(), point.meta.line));
1063 line_assertions
1064 .entry((node.at.file.clone(), point.meta.line))
1065 .or_default()
1066 .insert(a.id.clone());
1067 }
1068 }
1069 }
1070 let notices = state
1071 .flows
1072 .get(&flow_key(a, f))
1073 .map(|s| s.notices.clone())
1074 .unwrap_or_default();
1075 let exposed_to = state
1076 .changes
1077 .iter()
1078 .filter(|c| c.exposed.contains(&flow_key(a, f)))
1079 .map(|c| c.id.clone())
1080 .collect::<Vec<_>>();
1081 flows.push(json!({"id":f.id,"freshness":freshness,"valid":valid,"current":dirty.is_empty(),"expectedBasis":model::expected_basis_with(a,f,state,&manifest,&ledger),"selectors":selectors,"questions":f.questions,"reasons":dirty,"notices":notices,"exposedTo":exposed_to,"eligible":eligible,"blockers":blockers,"matchingTests":applicable,"creditedStatementLines":lines,"nodeCredit":node_credit}));
1082 }
1083 let observation = if !witnesses.is_empty() {
1084 "Passing assertion occurrence recorded."
1085 } else if flows
1086 .iter()
1087 .flat_map(|f| f["selectors"].as_array().into_iter().flatten())
1088 .any(|s| s["status"] == "missing")
1089 {
1090 "No passing occurrence. Some selected tests have no execution record (for example disabled tests or tests outside this run)."
1091 } else if flows
1092 .iter()
1093 .flat_map(|f| f["selectors"].as_array().into_iter().flatten())
1094 .any(|s| {
1095 s["outcomes"]
1096 .as_array()
1097 .is_some_and(|outcomes| outcomes.iter().any(|o| o == "skipped"))
1098 })
1099 {
1100 "No passing occurrence. Selected tests include skipped/TODO executions."
1101 } else {
1102 "No passing occurrence recorded. The assertion may be in an untaken branch or its execution attribution may be missing; inspect the selected tests."
1103 };
1104 rows.push(json!({"observation":observation,"id":a.id,"at":a.at,"questions":a.questions,"inMap":in_map,"observes":a.observes,"operations":inventory.get(&a.at).cloned().unwrap_or_default(),"observedPassingTests":witnesses,"flows":flows}));
1105 }
1106 let denominator = coverage
1114 .view
1115 .lines
1116 .iter()
1117 .filter(|l| l.measured)
1118 .map(|l| (l.file.clone(), l.line))
1119 .filter(|location| by_line.contains_key(location))
1120 .collect::<BTreeSet<_>>();
1121 let mut credited = BTreeSet::new();
1122 let mut declared = BTreeSet::new();
1123 for location in &denominator {
1124 let statements = by_line.get(location).map(Vec::as_slice).unwrap_or(&[]);
1125 if !statements.is_empty()
1126 && statements
1127 .iter()
1128 .all(|p| claimed_points.contains(&p.meta.id))
1129 {
1130 declared.insert(location.clone());
1131 }
1132 if !statements.is_empty()
1133 && statements
1134 .iter()
1135 .all(|p| credited_points.contains(&p.meta.id))
1136 {
1137 credited.insert(location.clone());
1138 }
1139 }
1140 let missing_inventory = inputs
1141 .assertions
1142 .iter()
1143 .filter(|s| !map.assertions.iter().any(|a| a.at == s.at))
1144 .count();
1145 let (status, reason) = if !passed || !identities_valid {
1146 ("unavailable", "Run failed or map identities are invalid")
1147 } else if pending_changes > 0 {
1148 ("pending", "Source changes need impact assessment")
1149 } else if measured_statements.is_empty() {
1150 ("notApplicable", "No measured statements")
1151 } else if credit_flows > 0 {
1152 (
1153 "available",
1154 "Agent-assessed statements; mapping completeness is unknown",
1155 )
1156 } else if map.assertions.iter().all(|a| a.flows.is_empty()) {
1157 ("notAssessed", "No recorded flow explanations")
1158 } else {
1159 (
1160 "pending",
1161 "No current flow with matching passing assertion evidence",
1162 )
1163 };
1164 let assertions_without_current_explanation = rows
1165 .iter()
1166 .filter(|a| {
1167 inventory.keys().any(|at| json!(at) == a["at"])
1168 && a["observedPassingTests"]
1169 .as_array()
1170 .is_some_and(|v| !v.is_empty())
1171 && a["flows"]
1172 .as_array()
1173 .is_none_or(|v| !v.iter().any(|f| f["eligible"] == true))
1174 })
1175 .count();
1176 let total = denominator.len();
1177 let statements = measured_statements.iter().map(|p| {
1178 let at = inputs.files.get(&p.meta.file)
1179 .and_then(|text| byte_column(text, p.meta.line, p.meta.column, &inputs.language))
1180 .map(|column| Anchor { file: p.meta.file.clone(), line: p.meta.line, column, text: p.meta.source.clone() })
1181 .filter(|at| at.offset(&inputs.files).is_some());
1182 let all = all_points.get(&p.meta.id);
1183 json!({"id":p.meta.id,"file":p.meta.file,"line":p.meta.line,"at":at,"covered":p.covered,"tests":p.tests,"declared":claimed_points.contains(&p.meta.id),"asserted":credited_points.contains(&p.meta.id),"flows":point_flows.get(&p.meta.id).cloned().unwrap_or_default(),
1184 "executionEvidence":{"anyExecution":all.is_some_and(|p| p.covered),"passingTests":p.tests.iter().filter(|id| tests.contains_key(id)).collect::<Vec<_>>(),
1185 "outsidePassingTests":all.into_iter().flat_map(|p| &p.tests).filter(|id| !tests.contains_key(id)).collect::<Vec<_>>()}})
1186 }).collect::<Vec<_>>();
1187 json!({"basis":"agent-assessed; passing assertion identity and same-test execution required; not mutation resistance",
1188 "summary":{"status":status,"reason":reason,"pendingChanges":pending_changes,"metric":"measured statements","statements":{"asserted":credited_points.len(),"declared":claimed_points.len(),"total":measured_statements.len(),"percentage":if status != "available" { None } else {Some(credited_points.len() as f64 * 100.0 / measured_statements.len() as f64)}},"assertions":map.assertions.len(),"inventoryAssertions":inputs.assertions.len(),"missingInventoryAssertions":missing_inventory,
1189 "assertionsWithFlows":rows.iter().filter(|a| a["flows"].as_array().is_some_and(|f| !f.is_empty())).count(),
1190 "assertionsWithoutFlows":rows.iter().filter(|a| a["flows"].as_array().is_none_or(Vec::is_empty)).count(),
1191 "observedAssertionsWithoutCurrentExplanation":assertions_without_current_explanation,
1192 "questions":map.assertions.iter().map(|a| a.questions.len()+a.flows.iter().map(|f| f.questions.len()).sum::<usize>()).sum::<usize>(),
1193 "currentFlows":current_flows,"draftFlows":draft_flows,"staleFlows":stale_flows,"invalidFlows":invalid_flows,"eligibleFlows":credit_flows,"retiredAssertions":map.retired_assertions.len(),
1194 "unobservedAssertions":rows.iter().filter(|a| a["observedPassingTests"].as_array().is_none_or(Vec::is_empty)).count(),
1195 "inventoryFailures":inputs.limitations.iter().filter(|s| s.starts_with("Inventory unavailable for ")).count(),
1196 "unanchoredStatements":statements.iter().filter(|s| s["at"].is_null()).count(),
1197 "runPassed":passed,
1198 "lines":{"asserted":credited.len(),"declared":declared.len(),"total":total,"percentage":if total==0 || status != "available" {None} else {Some(credited.len() as f64 * 100.0 / total as f64)}}},
1199 "assertions":rows,"statements":statements,"tests":coverage.view.tests.iter().map(|t| json!({"id":t.id,"file":t.file,"name":t.name,"role":t.role,"outcome":t.outcome,"provenance":t.provenance,
1200 "hasExecutionEvidence":!t.hits.is_empty() || !t.decisions.is_empty() || !t.lines.is_empty()})).collect::<Vec<_>>(),
1201 "creditedLines":credited.iter().map(|loc| json!({"file":loc.0,"line":loc.1,"assertions":line_assertions.get(loc)})).collect::<Vec<_>>(),
1202 "unassertedLines":denominator.difference(&credited).map(|(f,l)| json!({"file":f,"line":l})).collect::<Vec<_>>(),
1203 "changes":validation["changes"],"validationErrors":errors,"advisories":model::advisories(map),"limitations":inputs.limitations})
1204}
1205
1206#[cfg(test)]
1207mod tests {
1208 use super::*;
1209 fn fingerprint() -> RunFingerprint {
1210 RunFingerprint {
1211 algorithm: "sha256".into(),
1212 source: "source".into(),
1213 tests: "tests".into(),
1214 dependencies: "dependencies".into(),
1215 configuration: "configuration".into(),
1216 instrumenter: "instrumenter".into(),
1217 execution: "execution".into(),
1218 combined: "combined".into(),
1219 source_files: 1,
1220 test_files: 1,
1221 }
1222 }
1223
1224 #[test]
1225 fn upgrading_supercov_does_not_make_every_map_stale() {
1226 let before = fingerprint();
1231 let mut after = fingerprint();
1232 after.instrumenter = "a new supercov".into();
1233 let delta = context_delta(&before, &after, "context", "context");
1234 assert!(!delta.execution);
1235 assert!(!delta.dependencies);
1236 }
1237
1238 #[test]
1239 fn a_dependency_upgrade_is_reported_without_invalidating_anything() {
1240 let before = fingerprint();
1244 let mut after = fingerprint();
1245 after.dependencies = "an upgraded lockfile".into();
1246 let delta = context_delta(&before, &after, "context", "context");
1247 assert!(delta.dependencies);
1248 assert!(!delta.execution, "an upgrade must not invalidate flows");
1249 }
1250
1251 #[test]
1252 fn what_actually_executes_still_invalidates_every_flow() {
1253 let before = fingerprint();
1256 let mut after = fingerprint();
1257 after.configuration = "a different tsconfig".into();
1258 assert!(context_delta(&before, &after, "context", "context").execution);
1259 assert!(
1260 context_delta(&before, &fingerprint(), "context", "another context").execution,
1261 "declared context variables still count"
1262 );
1263 assert!(!context_delta(&before, &fingerprint(), "context", "context").execution);
1264 }
1265
1266 #[test]
1267 fn report_cache_requires_exact_revision_and_intact_payload() {
1268 let directory =
1269 std::env::temp_dir().join(format!("supercov-report-cache-{}", std::process::id()));
1270 fs::create_dir_all(&directory).unwrap();
1271 let path = directory.join(REPORT_CACHE_FILE);
1272 let report = json!({"summary":{"statements":{"asserted":4,"percentage":97.02842377260981}},"assertions":[]});
1273 let mut cache = json!({"key":"revision-one","digest":digest(&report),"report":report});
1274 fs::write(&path, serde_json::to_vec(&cache).unwrap()).unwrap();
1275 assert_eq!(read_report_cache(&path, "revision-one"), Some(report));
1276 assert!(read_report_cache(&path, "revision-two").is_none());
1277 cache["report"]["summary"]["statements"]["asserted"] = json!(100);
1278 fs::write(&path, serde_json::to_vec(&cache).unwrap()).unwrap();
1279 assert!(read_report_cache(&path, "revision-one").is_none());
1280 fs::write(&path, "interrupted write").unwrap();
1281 assert!(read_report_cache(&path, "revision-one").is_none());
1282 fs::remove_dir_all(directory).unwrap();
1283 }
1284 use crate::evidence_archive::{EvidenceArchiveEntry, write_archive};
1285
1286 #[test]
1289 fn publication_records_what_each_test_ran_and_affected_tests_reads_it() {
1290 let root = std::env::temp_dir().join(format!("supercov-affected-{}", std::process::id()));
1291 let _ = fs::remove_dir_all(&root);
1292 fs::create_dir_all(root.join("src")).unwrap();
1293 fs::create_dir_all(root.join("tests")).unwrap();
1294 let app = "function work() {\n return 1;\n}\nfunction idle() {\n return 2;\n}\n";
1298 let test = "import assert from 'node:assert/strict';\nassert.equal(work(), 1);\n";
1299 fs::write(root.join("src/app.js"), app).unwrap();
1300 fs::write(root.join("tests/app.test.js"), test).unwrap();
1301 let inputs = crate::assertion_inputs::capture(
1305 &root,
1306 "python",
1307 ["src/app.js".into(), "tests/app.test.js".into()],
1308 )
1309 .unwrap();
1310 let directory = crate::run_store::create_analyzable_test_run(&root, "first");
1311 let path = directory.join("evidence.raw.gz");
1312 let entries =
1313 crate::assertion_inputs::append(read_archive(&path).unwrap(), &inputs).unwrap();
1314 let archive = write_archive(entries, &path).unwrap();
1315 let metadata_path = directory.join("run.json");
1316 let mut metadata: RunMetadata =
1317 serde_json::from_slice(&fs::read(&metadata_path).unwrap()).unwrap();
1318 metadata.raw_evidence.files = archive.files;
1319 metadata.raw_evidence.compressed_bytes = archive.compressed_bytes;
1320 metadata.raw_evidence.uncompressed_bytes = archive.uncompressed_bytes;
1321 fs::write(&metadata_path, serde_json::to_vec(&metadata).unwrap()).unwrap();
1322 prepare_publication(&root, &directory, &metadata).unwrap();
1323 let run = discover_runs(&root).unwrap().runs.remove(0);
1324 let stored = load_manifest(&run).unwrap();
1325 let (_, state) = load(&run, &stored).unwrap();
1326 let executions = state.executions.as_ref().expect("a record");
1327 let code = stored.manifest.files["src/app.js"].code.as_ref().unwrap();
1328 let work = code.units.iter().position(|u| u.path == "work").unwrap();
1329 let idle = code.units.iter().position(|u| u.path == "idle").unwrap();
1330 assert_eq!(executions.tests.len(), 1);
1331 let record = &executions.tests[0];
1332 assert_eq!(record.test.file, "tests/app.test.js");
1333 assert_eq!(record.test.name, "test");
1334 assert!(record.passed);
1335 assert_eq!(record.files["src/app.js"], vec![work]);
1336 assert_eq!(
1337 executions.probed["src/app.js"],
1338 vec![work],
1339 "idle holds no probe in this run"
1340 );
1341 let _ = idle;
1342
1343 let names = |value: &Value, key: &str| {
1344 value[key]
1345 .as_array()
1346 .unwrap()
1347 .iter()
1348 .map(|t| t["name"].as_str().unwrap().to_owned())
1349 .collect::<Vec<_>>()
1350 };
1351 let report = affected_tests(&root, &run).unwrap();
1353 assert!(names(&report, "affected").is_empty(), "{report}");
1354 assert_eq!(names(&report, "unaffected"), ["test"]);
1355 assert_eq!(report["summary"]["changedFiles"], 0);
1356 fs::write(root.join("src/app.js"), format!("// about\n{app}")).unwrap();
1358 let report = affected_tests(&root, &run).unwrap();
1359 assert!(names(&report, "affected").is_empty(), "{report}");
1360 assert_eq!(report["changedFiles"][0]["change"], "formatting");
1361 fs::write(
1363 root.join("src/app.js"),
1364 app.replace("return 1;", "return 1 + 0;"),
1365 )
1366 .unwrap();
1367 let report = affected_tests(&root, &run).unwrap();
1368 assert_eq!(names(&report, "affected"), ["test"]);
1369 assert_eq!(
1370 report["affected"][0]["reasons"][0],
1371 "src/app.js: work (line 1) changed (this test ran it)"
1372 );
1373 fs::write(
1377 root.join("src/app.js"),
1378 app.replace("return 2;", "return 2 + 0;"),
1379 )
1380 .unwrap();
1381 let report = affected_tests(&root, &run).unwrap();
1382 assert_eq!(names(&report, "affected"), ["test"], "{report}");
1383 assert!(
1384 report["affected"][0]["reasons"][0]
1385 .as_str()
1386 .unwrap()
1387 .contains("this test ran code in this file"),
1388 "{report}"
1389 );
1390 fs::write(
1392 root.join("src/app.js"),
1393 format!("{app}function more() {{}}\n"),
1394 )
1395 .unwrap();
1396 let report = affected_tests(&root, &run).unwrap();
1397 assert_eq!(names(&report, "affected"), ["test"]);
1398 assert_eq!(report["changedFiles"][0]["change"], "declarations");
1399 fs::write(root.join("src/app.js"), app).unwrap();
1401 fs::write(root.join("tests/app.test.js"), test.replace("1)", "2)")).unwrap();
1402 let report = affected_tests(&root, &run).unwrap();
1403 assert_eq!(names(&report, "affected"), ["test"]);
1404 assert!(
1405 report["affected"][0]["reasons"][0]
1406 .as_str()
1407 .unwrap()
1408 .starts_with("test file changed"),
1409 "{report}"
1410 );
1411 fs::write(root.join("tests/app.test.js"), test).unwrap();
1413 fs::remove_file(root.join("src/app.js")).unwrap();
1414 let report = affected_tests(&root, &run).unwrap();
1415 assert_eq!(
1416 report["affected"][0]["reasons"][0],
1417 "src/app.js removed (this test ran code in it)"
1418 );
1419 fs::remove_dir_all(root).unwrap();
1420 }
1421
1422 #[test]
1423 fn legacy_maps_import_without_the_old_checkout_and_require_review() {
1424 let root = std::env::temp_dir().join(format!("supercov-legacy-map-{}", std::process::id()));
1425 fs::create_dir_all(&root).unwrap();
1426 let source = "import assert from 'node:assert/strict'; assert.equal(1, 1);\n";
1427 fs::write(root.join("test.js"), source).unwrap();
1428 let old =
1429 crate::assertion_inputs::capture(&root, "javascript", ["test.js".into()]).unwrap();
1430 let directory = crate::run_store::create_analyzable_test_run(&root, "legacy");
1431 let path = directory.join("evidence.raw.gz");
1432 let mut entries = read_archive(&path).unwrap();
1433 entries.push(EvidenceArchiveEntry {
1434 path: ARCHIVE_PATH.into(),
1435 contents: serde_json::to_vec(&old).unwrap(),
1436 });
1437 let archive = write_archive(entries, &path).unwrap();
1438 let metadata_path = directory.join("run.json");
1439 let mut metadata: RunMetadata =
1440 serde_json::from_slice(&fs::read(&metadata_path).unwrap()).unwrap();
1441 metadata.raw_evidence.files = archive.files;
1442 metadata.raw_evidence.compressed_bytes = archive.compressed_bytes;
1443 metadata.raw_evidence.uncompressed_bytes = archive.uncompressed_bytes;
1444 fs::write(&metadata_path, serde_json::to_vec(&metadata).unwrap()).unwrap();
1445 let run = discover_runs(&root).unwrap().runs.remove(0);
1446 let stored = load_manifest(&run).unwrap();
1447 let (map, _) = seed(&old, &stored.evidence_digest);
1448 let legacy_map = json!({"schemaVersion":1,"assertions":[{"id":map.assertions[0].id,"at":map.assertions[0].at,"analysis":"mapped","observes":[],"flows":[{
1449 "id":"constant","explanation":"The assertion checks the constant one.","appliesTo":[],"nodes":[],"edges":[],"countsAsAsserted":[],"watch":[{"kind":"span","at":map.assertions[0].at}]
1450 }]}]});
1451 let legacy_state = json!({"schemaVersion":1,"inputsDigest":digest(&old),"evidenceDigest":stored.evidence_digest,"reviews":{},"scopeReview":[]});
1452 write_json(&root, &run, MAP_FILE, &legacy_map).unwrap();
1453 write_json(&root, &run, STATE_FILE, &legacy_state).unwrap();
1454 let map = model::parse_stored(&serde_json::to_vec(&legacy_map).unwrap()).unwrap();
1455 let map_bytes = fs::read(directory.join(MAP_FILE)).unwrap();
1456 let state_bytes = fs::read(directory.join(STATE_FILE)).unwrap();
1457
1458 fs::write(root.join("test.js"), format!("\n{source}")).unwrap();
1459 assert!(load_inputs(&root, &run).is_err());
1460 let (imported, state) = load(&run, &stored).unwrap();
1461 let new =
1462 crate::assertion_inputs::capture(&root, "javascript", ["test.js".into()]).unwrap();
1463 let (next, state) =
1464 carry(&imported, &state, &stored.manifest, &new, "new-run", false).unwrap();
1465 assert_eq!(next.assertions[0].id, map.assertions[0].id);
1466 assert_eq!(
1467 next.assertions[0].flows[0].explanation,
1468 map.assertions[0].flows[0].explanation
1469 );
1470 assert!(next.assertions[0].flows[0].basis.is_none());
1471 assert!(!next.assertions[0].flows[0].questions.is_empty());
1472 assert_eq!(fs::read(directory.join(MAP_FILE)).unwrap(), map_bytes);
1473 assert_eq!(fs::read(directory.join(STATE_FILE)).unwrap(), state_bytes);
1474
1475 let next_directory = crate::run_store::create_analyzable_test_run(&root, "next");
1478 let mut entries = read_archive(&path).unwrap();
1479 entries
1480 .iter_mut()
1481 .find(|e| e.path == ARCHIVE_PATH)
1482 .unwrap()
1483 .contents = serde_json::to_vec(&old.manifest()).unwrap();
1484 let raw = write_archive(entries, &next_directory.join("evidence.raw.gz")).unwrap();
1485 let mut next_metadata = metadata.clone();
1486 next_metadata.id = "next".into();
1487 next_metadata.started_at = "next".into();
1488 next_metadata.raw_evidence.compressed_bytes = raw.compressed_bytes;
1489 next_metadata.raw_evidence.uncompressed_bytes = raw.uncompressed_bytes;
1490 fs::write(
1491 next_directory.join("run.json"),
1492 serde_json::to_vec(&next_metadata).unwrap(),
1493 )
1494 .unwrap();
1495 fs::remove_file(root.join("test.js")).unwrap();
1496 prepare_publication(&root, &next_directory, &next_metadata).unwrap();
1497 let next_run = discover_runs(&root)
1498 .unwrap()
1499 .runs
1500 .into_iter()
1501 .find(|r| r.id == "next")
1502 .unwrap();
1503 let next_manifest = load_manifest(&next_run).unwrap();
1504 let (pending, pending_state) = load(&next_run, &next_manifest).unwrap();
1505 assert_eq!(pending.assertions[0], map.assertions[0]);
1506 assert!(!pending_state.changes.is_empty());
1507 assert!(
1508 !pending_state.flows
1509 [&flow_key(&pending.assertions[0], &pending.assertions[0].flows[0])]
1510 .reasons
1511 .is_empty()
1512 );
1513 assert!(load_inputs(&root, &next_run).is_err());
1514 assert_eq!(fs::read(directory.join(MAP_FILE)).unwrap(), map_bytes);
1515 assert_eq!(fs::read(directory.join(STATE_FILE)).unwrap(), state_bytes);
1516
1517 let mut corrupt = state.clone();
1518 corrupt.evidence_digest = "wrong".into();
1519 write_json(&root, &run, STATE_FILE, &corrupt).unwrap();
1520 assert!(load(&run, &stored).is_err());
1521 fs::remove_dir_all(root).unwrap();
1522 }
1523}