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 covered: BTreeMap<String, BTreeSet<usize>> = BTreeMap::new();
356 for test in &coverage.view.tests {
357 for hit in &test.hits {
358 if let Some((file, unit)) = located.get(hit.as_str()) {
359 covered.entry((*file).to_owned()).or_default().insert(*unit);
360 }
361 }
362 }
363
364 let mut tests: BTreeMap<TestSelector, Execution> = BTreeMap::new();
365 for test in &coverage.view.tests {
366 let Some(file) = &test.file else {
367 continue;
368 };
369 let selector = TestSelector {
370 file: file.clone(),
371 name: test.name.clone(),
372 };
373 let record = tests.entry(selector.clone()).or_insert_with(|| Execution {
374 test: selector,
375 passed: false,
376 files: BTreeMap::new(),
377 attribution: crate::coverage_report::ATTRIBUTION_EXACT.to_owned(),
378 });
379 record.passed |= test.outcome == "passed";
380 if !crate::coverage_report::coverage_is_complete(&record.attribution) {
384 } else if !crate::coverage_report::coverage_is_complete(&test.attribution) {
386 record.attribution = test.attribution.clone();
387 }
388 for hit in &test.hits {
389 if let Some((file, unit)) = located.get(hit.as_str()) {
390 record
391 .files
392 .entry((*file).to_owned())
393 .or_default()
394 .push(*unit);
395 }
396 }
397 }
398 let mut tests = tests.into_values().collect::<Vec<_>>();
399 for record in &mut tests {
400 for units in record.files.values_mut() {
401 units.sort_unstable();
402 units.dedup();
403 }
404 }
405 Some(Executions {
406 tests,
407 probed: probed
408 .into_iter()
409 .map(|(file, units)| (file, units.into_iter().collect()))
410 .collect(),
411 covered: covered
412 .into_iter()
413 .map(|(file, units)| (file, units.into_iter().collect()))
414 .collect(),
415 })
416}
417pub fn affected_tests(root: &Path, run: &StoredRun) -> Result<Value, String> {
427 let stored = load_manifest(run)?;
428 let (_, state) = load(run, &stored)?;
429 let Some(executions) = &state.executions else {
430 return Err(
431 "This run has no per-test execution record; rerun tests with this version of Supercov"
432 .into(),
433 );
434 };
435 let root = crate::workspace::canonicalize_simplified(root).map_err(|e| e.to_string())?;
436 let manifest = &stored.manifest;
437 let now = manifest
438 .files
439 .keys()
440 .filter_map(|file| {
441 let path = root.join(file);
442 let canonical = crate::workspace::canonicalize_simplified(&path).ok()?;
443 if !canonical.starts_with(&root) || !canonical.is_file() {
444 return None;
445 }
446 let text = fs::read_to_string(canonical).ok()?;
447 Some((file.clone(), FileFingerprint::read(file, &text)))
448 })
449 .collect::<FileManifest>();
450 let changes = manifest
451 .files
452 .keys()
453 .filter_map(|file| {
454 model::file_change(
455 manifest.files.get(file),
456 now.get(file),
457 executions.probed.get(file).map(Vec::as_slice),
458 )
459 .map(|change| (file.as_str(), change))
460 })
461 .collect::<BTreeMap<_, _>>();
462 let files = changes
463 .iter()
464 .filter_map(|(file, change)| {
465 let (kind, detail) = match change {
466 FileChange::Same | FileChange::Added => return None,
467 FileChange::CommentsOnly => ("formatting", None),
468 FileChange::Removed => ("removed", None),
469 FileChange::Bytes => ("changed", None),
470 FileChange::Code {
471 before,
472 after,
473 diff,
474 narrow,
475 } => (
476 if *narrow { "bodies" } else { "declarations" },
477 Some(model::describe(before, after, diff)),
478 ),
479 };
480 Some(json!({"file":file,"change":kind,"detail":detail}))
481 })
482 .collect::<Vec<_>>();
483 let beyond_the_run = changes
497 .iter()
498 .filter_map(|(file, change)| match change {
499 FileChange::Same | FileChange::CommentsOnly | FileChange::Added => None,
500 FileChange::Removed => Some(format!("{file} removed (the run covered code in it)")),
501 FileChange::Bytes => Some(format!("{file} changed (the run covered code in it)")),
502 FileChange::Code { before, diff, .. } => {
503 let code = manifest.files.get(*file).and_then(|f| f.code.as_ref());
508 let reached = executions
509 .covered
510 .get(*file)
511 .map(Vec::as_slice)
512 .unwrap_or_default()
513 .iter()
514 .flat_map(|unit| match code {
515 Some(code) if *unit < code.units.len() => {
516 code.ancestors(*unit).collect::<Vec<_>>()
517 }
518 _ => vec![*unit],
519 })
520 .collect::<BTreeSet<_>>();
521 let hit = diff
522 .changed
523 .iter()
524 .filter(|unit| reached.contains(unit))
525 .filter_map(|unit| before.units.get(*unit))
526 .collect::<Vec<_>>();
527 (!hit.is_empty())
528 .then(|| format!("{file}: {} changed (the run covered it)", named(hit)))
529 }
530 })
531 .collect::<Vec<_>>();
532
533 let mut affected = Vec::new();
534 let mut undetermined = Vec::new();
535 let mut unaffected = Vec::new();
536 for record in &executions.tests {
537 let mut reasons = Vec::new();
538 if !record.passed {
539 reasons.push("did not pass in the run".to_owned());
540 }
541 match changes.get(record.test.file.as_str()) {
542 None | Some(FileChange::Same | FileChange::CommentsOnly | FileChange::Added) => {}
543 Some(FileChange::Removed) => reasons.push("test file removed".to_owned()),
544 Some(FileChange::Bytes) => reasons.push("test file changed".to_owned()),
545 Some(FileChange::Code {
546 before,
547 after,
548 diff,
549 ..
550 }) => reasons.push(format!(
551 "test file changed: {}",
552 model::describe(before, after, diff)
553 )),
554 }
555 for (file, units) in &record.files {
556 let code = manifest.files.get(file).and_then(|f| f.code.as_ref());
557 let ran = units
558 .iter()
559 .flat_map(|unit| match code {
560 Some(code) if *unit < code.units.len() => {
561 code.ancestors(*unit).collect::<Vec<_>>()
562 }
563 _ => vec![*unit],
564 })
565 .collect::<BTreeSet<_>>();
566 match changes.get(file.as_str()) {
567 None | Some(FileChange::Same | FileChange::CommentsOnly | FileChange::Added) => {}
568 Some(FileChange::Removed) => {
569 reasons.push(format!("{file} removed (this test ran code in it)"));
570 }
571 Some(FileChange::Bytes) => {
572 reasons.push(format!("{file} changed (this test ran code in it)"));
573 }
574 Some(FileChange::Code {
575 before,
576 after,
577 diff,
578 narrow,
579 }) => {
580 if *narrow {
581 let hit = diff
582 .changed
583 .iter()
584 .filter(|i| ran.contains(i))
585 .map(|i| &before.units[*i])
586 .collect::<Vec<_>>();
587 if !hit.is_empty() {
588 reasons
589 .push(format!("{file}: {} changed (this test ran it)", named(hit)));
590 }
591 } else {
592 reasons.push(format!(
593 "{file}: {} changed (this test ran code in this file)",
594 model::describe(before, after, diff)
595 ));
596 }
597 }
598 }
599 }
600 if !crate::coverage_report::coverage_is_complete(&record.attribution) {
601 let proven = !reasons.is_empty();
605 if !proven {
609 reasons.extend(beyond_the_run.iter().cloned());
610 reasons.sort();
611 reasons.dedup();
612 }
613 let entry = json!({
614 "file": record.test.file,
615 "name": record.test.name,
616 "reasons": reasons,
617 "attribution": record.attribution,
618 });
619 if proven {
620 affected.push(entry);
621 } else if reasons.is_empty() {
622 unaffected.push(entry);
623 } else {
624 undetermined.push(entry);
625 }
626 continue;
627 }
628 let entry = json!({"file":record.test.file,"name":record.test.name,"reasons":reasons});
629 if reasons.is_empty() {
630 unaffected.push(entry);
631 } else {
632 affected.push(entry);
633 }
634 }
635 Ok(json!({
636 "run": run.id,
637 "affected": affected,
638 "undetermined": undetermined,
645 "unaffected": unaffected,
646 "changedFiles": files,
647 "summary": {
648 "tests": executions.tests.len(),
649 "affected": affected.len(),
650 "undetermined": undetermined.len(),
651 "unaffected": unaffected.len(),
652 "changedFiles": files.len(),
653 },
654 "meaning": "Tests whose recorded execution a change since the run could have reached, and those whose execution nothing recorded: a test that ran alongside others has no coverage of its own, so it is undetermined whenever a change reaches anything the run covered. Run both. A file the run never captured, a dependency or a configuration change is not seen here; see workingTree."
655 }))
656}
657struct ContextDelta {
659 execution: bool,
662 dependencies: bool,
665}
666
667fn context_delta(
682 a: &RunFingerprint,
683 b: &RunFingerprint,
684 old_context: &str,
685 new_context: &str,
686) -> ContextDelta {
687 ContextDelta {
688 execution: a.configuration != b.configuration || old_context != new_context,
689 dependencies: a.dependencies != b.dependencies,
690 }
691}
692
693pub fn coverage(run: &StoredRun) -> Result<CoverageReport, String> {
694 analyze_coverage_archive(&ArchiveReportRequest {
695 archive_path: run.evidence_path.clone(),
696 run_id: run.id.clone(),
697 generated_at: run.metadata.started_at.clone(),
698 integrity: None,
699 test_exit_code: ExitCodeInput::Present(run.metadata.test_exit_code),
700 })
701 .map_err(|e| format!("{e:?}"))
702}
703fn byte_column(source: &str, line: usize, column: usize, language: &str) -> Option<usize> {
704 if line == 0 {
705 return None;
706 }
707 let line = source.lines().nth(line - 1)?;
708 if language != "javascript" {
709 return column.checked_add(1);
711 }
712 if column == 0 {
713 return None;
714 }
715 let mut units = 0;
716 for (byte, ch) in line.char_indices() {
717 if units == column - 1 {
718 return Some(byte + 1);
719 }
720 units += ch.len_utf16();
721 }
722 (units == column - 1).then_some(line.len() + 1)
723}
724fn phase_location<'a>(
725 phase: &'a crate::coverage_report::CoveragePhase,
726 inputs: &Inputs,
727) -> Option<(&'a str, usize, usize)> {
728 if phase.kind != "assertion" || phase.status.as_deref() != Some("passed") {
729 return None;
730 }
731 let source = phase
732 .operation
733 .strip_prefix("Rust assertion at ")
734 .or(phase.source.as_deref());
735 let location = source?;
736 let mut parts = location.rsplitn(3, ':');
737 let column = parts.next()?.parse::<usize>().ok()?;
738 let line = parts.next()?.parse::<usize>().ok()?;
739 let file = parts.next()?;
740 let column = byte_column(inputs.files.get(file)?, line, column, &inputs.language)?;
741 Some((file, line, column))
742}
743
744pub fn report(root: &Path, run: &StoredRun) -> Result<Value, String> {
747 report_with_detail(root, run, None)
748}
749pub fn assertion(root: &Path, run: &StoredRun, id: &str) -> Result<Value, String> {
751 report_with_detail(root, run, Some(id))
752}
753fn report_with_detail(root: &Path, run: &StoredRun, id: Option<&str>) -> Result<Value, String> {
754 let input = load_inputs(root, run)?;
755 let (map, state) = load(run, &input)?;
756 let cache_key = digest(&(
757 env!("SUPERCOV_ENGINE_SOURCE_SHA256"),
758 &run.id,
759 run.metadata.test_exit_code,
760 &map,
761 &state,
762 &input.evidence_digest,
763 ));
764 let cache_path = run.directory.join(REPORT_CACHE_FILE);
765 let mut report = read_report_cache(&cache_path, &cache_key).unwrap_or_else(|| Value::Null);
766 if report.is_null() {
767 let coverage = coverage(run)?;
768 report = assess(
769 &map,
770 &state,
771 &input.inputs,
772 &coverage,
773 run.metadata.test_exit_code == Some(0),
774 );
775 report["excludedStatements"] = json!(input.statement_exclusions);
776 report["summary"]["excludedStatements"] = json!(input.statement_exclusions.len());
777 let cached = json!({"key":cache_key,"digest":digest(&report),"report":report});
780 let _ = write_json(root, run, REPORT_CACHE_FILE, &cached);
781 }
782 if let Some(id) = id {
783 let matches = report["assertions"]
784 .as_array()
785 .unwrap()
786 .iter()
787 .filter(|a| a["id"] == id)
788 .collect::<Vec<_>>();
789 let mut row = match matches.as_slice() {
790 [row] => (*row).clone(),
791 [] => {
792 return Err(format!(
793 "Unknown assertion ID: {id}; use runs {} assertions to list IDs",
794 run.id
795 ));
796 }
797 _ => {
798 return Err(format!(
799 "Ambiguous assertion ID: {id}; repair duplicate IDs in assertions.json"
800 ));
801 }
802 };
803 if let Some(authored) = map.assertions.iter().find(|a| a.id == id) {
804 for (flow, authored) in row["flows"]
805 .as_array_mut()
806 .unwrap()
807 .iter_mut()
808 .zip(&authored.flows)
809 {
810 let assessment = flow.as_object().unwrap().clone();
811 *flow = serde_json::to_value(authored).map_err(|e| e.to_string())?;
812 flow.as_object_mut().unwrap().extend(assessment);
813 }
814 }
815 report["assertion"] = row;
816 }
817 report["inheritance"] = json!(state.inheritance);
818 report["revision"] = json!(digest(&(&map, &state, &input.evidence_digest)));
819 Ok(report)
820}
821
822fn read_report_cache(path: &Path, key: &str) -> Option<Value> {
823 if fs::metadata(path).ok()?.len() > 256 * 1024 * 1024 {
824 return None;
825 }
826 let cached: Value = serde_json::from_slice(&fs::read(path).ok()?).ok()?;
827 let report = &cached["report"];
828 (cached["key"] == key
829 && cached["digest"] == digest(report)
830 && report["summary"].is_object()
831 && report["assertions"].is_array())
832 .then(|| report.clone())
833}
834
835pub fn assess(
836 map: &AssertionMap,
837 state: &State,
838 inputs: &Inputs,
839 coverage: &CoverageReport,
840 passed: bool,
841) -> Value {
842 let manifest = inputs.manifest();
843 let ledger = model::Ledger::new(map, state, &manifest);
844 let validation = model::validation(map, state, inputs);
845 let errors = validation["errors"]
846 .as_array()
847 .unwrap()
848 .iter()
849 .map(|e| e.as_str().unwrap().to_owned())
850 .collect::<Vec<_>>();
851 let pending_changes = validation["changes"]
852 .as_array()
853 .unwrap()
854 .iter()
855 .filter(|c| c["current"] != true)
856 .count();
857 let view = &coverage.filters.passed;
858 let tests = view
859 .tests
860 .iter()
861 .filter(|t| t.role == "test")
862 .map(|t| (&t.id, t))
863 .collect::<BTreeMap<_, _>>();
864 let measured_statements = view
865 .points
866 .iter()
867 .filter(|p| p.measured && p.meta.kind == crate::coverage_analysis::PointKind::Statement)
868 .collect::<Vec<_>>();
869 let all_points = coverage
870 .view
871 .points
872 .iter()
873 .map(|p| (&p.meta.id, p))
874 .collect::<BTreeMap<_, _>>();
875 let all_tests = coverage
876 .view
877 .tests
878 .iter()
879 .map(|t| (&t.id, t))
880 .collect::<BTreeMap<_, _>>();
881 let mut by_file = BTreeMap::<&str, Vec<(usize, &crate::coverage_report::PointResult)>>::new();
882 let mut by_line = BTreeMap::<(String, usize), Vec<&crate::coverage_report::PointResult>>::new();
883 for point in &measured_statements {
884 by_line
885 .entry((point.meta.file.clone(), point.meta.line))
886 .or_default()
887 .push(point);
888 if let Some(text) = inputs.files.get(&point.meta.file)
889 && let Some(column) =
890 byte_column(text, point.meta.line, point.meta.column, &inputs.language)
891 {
892 let at = Anchor {
893 file: point.meta.file.clone(),
894 line: point.meta.line,
895 column,
896 text: point.meta.source.clone(),
897 };
898 if let Some(start) = at.offset(&inputs.files) {
899 by_file
900 .entry(&point.meta.file)
901 .or_default()
902 .push((start, point));
903 }
904 }
905 }
906 for points in by_file.values_mut() {
907 points.sort_by_key(|(pos, _)| *pos);
908 }
909 let mut rows = Vec::new();
910 let mut claimed_points = BTreeSet::new();
911 let mut credited_points = BTreeSet::new();
912 let mut point_flows = BTreeMap::<String, BTreeSet<String>>::new();
913 let mut draft_flows = 0;
914 let mut stale_flows = 0;
915 let mut invalid_flows = 0;
916 let mut current_flows = 0;
917 let mut credit_flows = 0;
918 let mut line_assertions = BTreeMap::<(String, usize), BTreeSet<String>>::new();
919 let identities_valid = state.inputs_digest == inputs.identity()
922 && errors.iter().all(|e| {
923 !e.contains("duplicate") && !e.contains("schema") && !e.contains("assertion ID")
924 });
925 let mut phases_by_location = BTreeMap::new();
928 for p in &view.phases {
929 if !tests.contains_key(&p.test) {
930 continue;
931 }
932 if let Some(location) = phase_location(&p.phase, inputs) {
933 phases_by_location
934 .entry(location)
935 .or_insert_with(Vec::new)
936 .push(p);
937 }
938 }
939 let mut inventory = BTreeMap::<&Anchor, BTreeSet<&str>>::new();
940 for site in &inputs.assertions {
941 inventory
942 .entry(&site.at)
943 .or_default()
944 .insert(&site.operation);
945 }
946 let mapped_anchors = map
947 .assertions
948 .iter()
949 .map(|a| &a.at)
950 .collect::<BTreeSet<_>>();
951 let mut used_ids = map
952 .assertions
953 .iter()
954 .map(|a| a.id.clone())
955 .chain(
956 map.retired_assertions
957 .iter()
958 .map(|r| r.assertion.id.clone()),
959 )
960 .collect::<BTreeSet<_>>();
961 let missing = seed(inputs, "")
962 .0
963 .assertions
964 .into_iter()
965 .filter(|a| !mapped_anchors.contains(&a.at))
966 .map(|mut a| {
967 while !used_ids.insert(a.id.clone()) {
968 a.id.push('_');
969 }
970 a
971 })
972 .collect::<Vec<_>>();
973 for (a, in_map) in map
974 .assertions
975 .iter()
976 .map(|a| (a, true))
977 .chain(missing.iter().map(|a| (a, false)))
978 {
979 let witnesses = phases_by_location
980 .get(&(a.at.file.as_str(), a.at.line, a.at.column))
981 .into_iter()
982 .flatten()
983 .filter(|p| {
986 inputs.language != "javascript"
987 || inventory
988 .get(&a.at)
989 .is_some_and(|operations| operations.contains(p.phase.operation.as_str()))
990 })
991 .map(|p| p.test.clone())
992 .collect::<BTreeSet<_>>();
993 let mut flows = Vec::new();
994 for f in &a.flows {
995 let dirty = model::reasons_with(a, f, state, inputs, &manifest, &ledger);
996 let valid = model::validate_flow(f, &inputs.files).is_empty()
997 && a.at.offset(&inputs.files).is_some();
998 let freshness = if f.basis.is_none() {
999 draft_flows += 1;
1000 "draft"
1001 } else if f.basis.as_deref()
1002 != Some(model::expected_basis_with(a, f, state, &manifest, &ledger).as_str())
1003 {
1004 stale_flows += 1;
1005 "stale"
1006 } else {
1007 "current"
1008 };
1009 if !valid {
1010 invalid_flows += 1;
1011 }
1012 if dirty.is_empty() {
1013 current_flows += 1;
1014 }
1015 let mut resolved = BTreeSet::new();
1018 let mut selectors = Vec::new();
1019 for selector in &f.applies_to {
1020 let matches = coverage
1021 .view
1022 .tests
1023 .iter()
1024 .filter(|t| {
1025 t.file.as_deref() == Some(selector.file.as_str()) && t.name == selector.name
1026 })
1027 .collect::<Vec<_>>();
1028 let status = match matches.as_slice() {
1029 [test] if witnesses.contains(&test.id) && tests.contains_key(&test.id) => {
1030 resolved.insert(test.id.clone());
1031 "observed"
1032 }
1033 [] => "missing",
1034 [_] => "unobserved",
1035 _ => "ambiguous",
1036 };
1037 selectors.push(json!({"file":selector.file,"name":selector.name,"status":status,
1038 "outcomes":matches.iter().map(|t| &t.outcome).collect::<Vec<_>>(),
1039 "reason":match matches.as_slice() {
1040 [] => "No test execution record matches this selector. The test may be disabled or outside this run.",
1041 [test] if test.outcome != "passed" => "The selected test has no passing outcome.",
1042 [_] if status == "unobserved" => "The test passed but this assertion occurrence was not recorded. Its branch may not have run, or attribution may be missing.",
1043 [_] => "A passing assertion occurrence matches this test.",
1044 _ => "Multiple test identities match this selector."
1045 }}));
1046 }
1047 let applicable = resolved;
1048 let eligible = passed && identities_valid && dirty.is_empty() && !applicable.is_empty();
1049 let mut blockers = Vec::new();
1050 if !passed {
1051 blockers.push("run did not pass");
1052 }
1053 if !identities_valid {
1054 blockers.push("invalid map identities");
1055 }
1056 if !dirty.is_empty() {
1057 blockers.push("flow requires review or reference repair");
1058 }
1059 if applicable.is_empty() {
1060 blockers.push("no matching passing assertion occurrence for appliesTo");
1061 }
1062 if eligible {
1063 credit_flows += 1;
1064 }
1065 let mut lines = BTreeSet::new();
1066 let mut node_credit = Vec::new();
1067 for node in &f.nodes {
1068 let claimed = f.counts_as_asserted.contains(&node.id);
1069 let start = node.at.offset(&inputs.files);
1070 let mut matched = Vec::new();
1071 if let Some(start) = start
1072 && let Some(points) = by_file.get(node.at.file.as_str())
1073 {
1074 let first = points.partition_point(|(pos, _)| *pos < start);
1075 for (_, point) in points[first..].iter().take_while(|(pos, _)| *pos == start) {
1076 if point.meta.source == node.at.text {
1079 matched.push(*point);
1080 }
1081 }
1082 }
1083 let matching_tests = matched
1084 .iter()
1085 .filter(|p| p.covered)
1086 .flat_map(|p| p.tests.iter())
1087 .filter(|test| applicable.contains(*test))
1088 .collect::<BTreeSet<_>>();
1089 let credited = claimed && eligible && !matching_tests.is_empty();
1090 let mut reasons = Vec::new();
1091 let mut reason = |code: &str, message: String| {
1092 reasons.push(json!({"code":code,"message":message}));
1093 };
1094 if !claimed {
1095 reason(
1096 "context_only",
1097 "The agent included this node as context, not in countsAsAsserted.".into(),
1098 );
1099 } else if credited {
1100 reason("same_test_execution", "Current agent claim, passing assertion, and statement execution in the same selected test.".into());
1101 } else {
1102 if start.is_none() {
1103 reason(
1104 "invalid_source_anchor",
1105 "The node's source anchor does not match the run's current source."
1106 .into(),
1107 );
1108 } else if matched.is_empty() {
1109 reason("no_measured_statement", "The node does not exactly identify a measured production statement in this run.".into());
1110 }
1111 if !passed {
1112 reason("run_failed", "The test run did not pass.".into());
1113 }
1114 if !identities_valid {
1115 reason("invalid_map_identity", "Map identities or managed input state are invalid; see validation errors.".into());
1116 }
1117 if !dirty.is_empty() {
1118 reason(
1119 "flow_needs_attention",
1120 format!(
1121 "Flow needs investigation: {}",
1122 dirty.iter().cloned().collect::<Vec<_>>().join("; ")
1123 ),
1124 );
1125 }
1126 if applicable.is_empty() {
1127 reason("no_passing_assertion", "No selected test has a matching passing occurrence of this assertion; see flow selectors.".into());
1128 }
1129 if !matched.is_empty() {
1130 if !matched.iter().any(|p| p.covered) {
1131 let executed = matched
1132 .iter()
1133 .filter_map(|p| all_points.get(&p.meta.id))
1134 .filter(|p| p.covered)
1135 .collect::<Vec<_>>();
1136 if !executed.is_empty() {
1137 let setup = executed
1138 .iter()
1139 .flat_map(|p| &p.tests)
1140 .any(|id| all_tests.get(id).is_some_and(|t| t.role == "setup"));
1141 reason(if setup {"shared_setup_execution"} else {"execution_outside_passing_tests"},
1142 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());
1143 }
1144 reason(
1145 "no_passing_execution",
1146 "No execution evidence from passing tests for this statement."
1147 .into(),
1148 );
1149 } else if !applicable.is_empty() && matching_tests.is_empty() {
1150 if matched
1151 .iter()
1152 .flat_map(|p| &p.tests)
1153 .any(|id| all_tests.get(id).is_some_and(|t| t.role == "setup"))
1154 {
1155 reason("shared_setup_execution", "Execution was recorded in a separate setup scope. Shared setup is not automatically credited to consuming tests.".into());
1156 }
1157 reason("no_same_test_execution", "No execution evidence attributed to a selected passing test for this assertion.".into());
1158 }
1159 }
1160 }
1161 node_credit.push(json!({
1162 "nodeId":node.id,
1163 "location":{"file":node.at.file,"line":node.at.line,"column":node.at.column},
1164 "status":if !claimed {"context"} else if credited {"credited"} else {"notCredited"},
1165 "statementIds":matched.iter().map(|p| &p.meta.id).collect::<Vec<_>>(),
1166 "matchingTests":matching_tests,
1167 "reasons":reasons,
1168 }));
1169 for point in matched.into_iter().filter(|_| claimed) {
1170 claimed_points.insert(point.meta.id.clone());
1171 if eligible
1172 && point.covered
1173 && point.tests.iter().any(|t| applicable.contains(t))
1174 {
1175 credited_points.insert(point.meta.id.clone());
1176 point_flows
1177 .entry(point.meta.id.clone())
1178 .or_default()
1179 .insert(flow_key(a, f));
1180 lines.insert((node.at.file.clone(), point.meta.line));
1181 line_assertions
1182 .entry((node.at.file.clone(), point.meta.line))
1183 .or_default()
1184 .insert(a.id.clone());
1185 }
1186 }
1187 }
1188 let notices = state
1189 .flows
1190 .get(&flow_key(a, f))
1191 .map(|s| s.notices.clone())
1192 .unwrap_or_default();
1193 let exposed_to = state
1194 .changes
1195 .iter()
1196 .filter(|c| c.exposed.contains(&flow_key(a, f)))
1197 .map(|c| c.id.clone())
1198 .collect::<Vec<_>>();
1199 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}));
1200 }
1201 let observation = if !witnesses.is_empty() {
1202 "Passing assertion occurrence recorded."
1203 } else if flows
1204 .iter()
1205 .flat_map(|f| f["selectors"].as_array().into_iter().flatten())
1206 .any(|s| s["status"] == "missing")
1207 {
1208 "No passing occurrence. Some selected tests have no execution record (for example disabled tests or tests outside this run)."
1209 } else if flows
1210 .iter()
1211 .flat_map(|f| f["selectors"].as_array().into_iter().flatten())
1212 .any(|s| {
1213 s["outcomes"]
1214 .as_array()
1215 .is_some_and(|outcomes| outcomes.iter().any(|o| o == "skipped"))
1216 })
1217 {
1218 "No passing occurrence. Selected tests include skipped/TODO executions."
1219 } else {
1220 "No passing occurrence recorded. The assertion may be in an untaken branch or its execution attribution may be missing; inspect the selected tests."
1221 };
1222 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}));
1223 }
1224 let denominator = coverage
1232 .view
1233 .lines
1234 .iter()
1235 .filter(|l| l.measured)
1236 .map(|l| (l.file.clone(), l.line))
1237 .filter(|location| by_line.contains_key(location))
1238 .collect::<BTreeSet<_>>();
1239 let mut credited = BTreeSet::new();
1240 let mut declared = BTreeSet::new();
1241 for location in &denominator {
1242 let statements = by_line.get(location).map(Vec::as_slice).unwrap_or(&[]);
1243 if !statements.is_empty()
1244 && statements
1245 .iter()
1246 .all(|p| claimed_points.contains(&p.meta.id))
1247 {
1248 declared.insert(location.clone());
1249 }
1250 if !statements.is_empty()
1251 && statements
1252 .iter()
1253 .all(|p| credited_points.contains(&p.meta.id))
1254 {
1255 credited.insert(location.clone());
1256 }
1257 }
1258 let missing_inventory = inputs
1259 .assertions
1260 .iter()
1261 .filter(|s| !map.assertions.iter().any(|a| a.at == s.at))
1262 .count();
1263 let (status, reason) = if !passed || !identities_valid {
1264 ("unavailable", "Run failed or map identities are invalid")
1265 } else if pending_changes > 0 {
1266 ("pending", "Source changes need impact assessment")
1267 } else if measured_statements.is_empty() {
1268 ("notApplicable", "No measured statements")
1269 } else if credit_flows > 0 {
1270 (
1271 "available",
1272 "Agent-assessed statements; mapping completeness is unknown",
1273 )
1274 } else if map.assertions.iter().all(|a| a.flows.is_empty()) {
1275 ("notAssessed", "No recorded flow explanations")
1276 } else {
1277 (
1278 "pending",
1279 "No current flow with matching passing assertion evidence",
1280 )
1281 };
1282 let assertions_without_current_explanation = rows
1283 .iter()
1284 .filter(|a| {
1285 inventory.keys().any(|at| json!(at) == a["at"])
1286 && a["observedPassingTests"]
1287 .as_array()
1288 .is_some_and(|v| !v.is_empty())
1289 && a["flows"]
1290 .as_array()
1291 .is_none_or(|v| !v.iter().any(|f| f["eligible"] == true))
1292 })
1293 .count();
1294 let total = denominator.len();
1295 let statements = measured_statements.iter().map(|p| {
1296 let at = inputs.files.get(&p.meta.file)
1297 .and_then(|text| byte_column(text, p.meta.line, p.meta.column, &inputs.language))
1298 .map(|column| Anchor { file: p.meta.file.clone(), line: p.meta.line, column, text: p.meta.source.clone() })
1299 .filter(|at| at.offset(&inputs.files).is_some());
1300 let all = all_points.get(&p.meta.id);
1301 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(),
1302 "executionEvidence":{"anyExecution":all.is_some_and(|p| p.covered),"passingTests":p.tests.iter().filter(|id| tests.contains_key(id)).collect::<Vec<_>>(),
1303 "outsidePassingTests":all.into_iter().flat_map(|p| &p.tests).filter(|id| !tests.contains_key(id)).collect::<Vec<_>>()}})
1304 }).collect::<Vec<_>>();
1305 json!({"basis":"agent-assessed; passing assertion identity and same-test execution required; not mutation resistance",
1306 "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,
1307 "assertionsWithFlows":rows.iter().filter(|a| a["flows"].as_array().is_some_and(|f| !f.is_empty())).count(),
1308 "assertionsWithoutFlows":rows.iter().filter(|a| a["flows"].as_array().is_none_or(Vec::is_empty)).count(),
1309 "observedAssertionsWithoutCurrentExplanation":assertions_without_current_explanation,
1310 "questions":map.assertions.iter().map(|a| a.questions.len()+a.flows.iter().map(|f| f.questions.len()).sum::<usize>()).sum::<usize>(),
1311 "currentFlows":current_flows,"draftFlows":draft_flows,"staleFlows":stale_flows,"invalidFlows":invalid_flows,"eligibleFlows":credit_flows,"retiredAssertions":map.retired_assertions.len(),
1312 "unobservedAssertions":rows.iter().filter(|a| a["observedPassingTests"].as_array().is_none_or(Vec::is_empty)).count(),
1313 "inventoryFailures":inputs.limitations.iter().filter(|s| s.starts_with("Inventory unavailable for ")).count(),
1314 "unanchoredStatements":statements.iter().filter(|s| s["at"].is_null()).count(),
1315 "runPassed":passed,
1316 "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)}}},
1317 "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,
1318 "hasExecutionEvidence":!t.hits.is_empty() || !t.decisions.is_empty() || !t.lines.is_empty()})).collect::<Vec<_>>(),
1319 "creditedLines":credited.iter().map(|loc| json!({"file":loc.0,"line":loc.1,"assertions":line_assertions.get(loc)})).collect::<Vec<_>>(),
1320 "unassertedLines":denominator.difference(&credited).map(|(f,l)| json!({"file":f,"line":l})).collect::<Vec<_>>(),
1321 "changes":validation["changes"],"validationErrors":errors,"advisories":model::advisories(map),"limitations":inputs.limitations})
1322}
1323
1324#[cfg(test)]
1325mod tests {
1326 use super::*;
1327 fn fingerprint() -> RunFingerprint {
1328 RunFingerprint {
1329 algorithm: "sha256".into(),
1330 source: "source".into(),
1331 tests: "tests".into(),
1332 dependencies: "dependencies".into(),
1333 configuration: "configuration".into(),
1334 instrumenter: "instrumenter".into(),
1335 execution: "execution".into(),
1336 combined: "combined".into(),
1337 source_files: 1,
1338 test_files: 1,
1339 }
1340 }
1341
1342 #[test]
1343 fn upgrading_supercov_does_not_make_every_map_stale() {
1344 let before = fingerprint();
1349 let mut after = fingerprint();
1350 after.instrumenter = "a new supercov".into();
1351 let delta = context_delta(&before, &after, "context", "context");
1352 assert!(!delta.execution);
1353 assert!(!delta.dependencies);
1354 }
1355
1356 #[test]
1357 fn a_dependency_upgrade_is_reported_without_invalidating_anything() {
1358 let before = fingerprint();
1362 let mut after = fingerprint();
1363 after.dependencies = "an upgraded lockfile".into();
1364 let delta = context_delta(&before, &after, "context", "context");
1365 assert!(delta.dependencies);
1366 assert!(!delta.execution, "an upgrade must not invalidate flows");
1367 }
1368
1369 #[test]
1370 fn what_actually_executes_still_invalidates_every_flow() {
1371 let before = fingerprint();
1374 let mut after = fingerprint();
1375 after.configuration = "a different tsconfig".into();
1376 assert!(context_delta(&before, &after, "context", "context").execution);
1377 assert!(
1378 context_delta(&before, &fingerprint(), "context", "another context").execution,
1379 "declared context variables still count"
1380 );
1381 assert!(!context_delta(&before, &fingerprint(), "context", "context").execution);
1382 }
1383
1384 #[test]
1385 fn report_cache_requires_exact_revision_and_intact_payload() {
1386 let directory =
1387 std::env::temp_dir().join(format!("supercov-report-cache-{}", std::process::id()));
1388 fs::create_dir_all(&directory).unwrap();
1389 let path = directory.join(REPORT_CACHE_FILE);
1390 let report = json!({"summary":{"statements":{"asserted":4,"percentage":97.02842377260981}},"assertions":[]});
1391 let mut cache = json!({"key":"revision-one","digest":digest(&report),"report":report});
1392 fs::write(&path, serde_json::to_vec(&cache).unwrap()).unwrap();
1393 assert_eq!(read_report_cache(&path, "revision-one"), Some(report));
1394 assert!(read_report_cache(&path, "revision-two").is_none());
1395 cache["report"]["summary"]["statements"]["asserted"] = json!(100);
1396 fs::write(&path, serde_json::to_vec(&cache).unwrap()).unwrap();
1397 assert!(read_report_cache(&path, "revision-one").is_none());
1398 fs::write(&path, "interrupted write").unwrap();
1399 assert!(read_report_cache(&path, "revision-one").is_none());
1400 fs::remove_dir_all(directory).unwrap();
1401 }
1402 use crate::evidence_archive::{EvidenceArchiveEntry, write_archive};
1403
1404 #[test]
1407 fn publication_records_what_each_test_ran_and_affected_tests_reads_it() {
1408 let root = std::env::temp_dir().join(format!("supercov-affected-{}", std::process::id()));
1409 let _ = fs::remove_dir_all(&root);
1410 fs::create_dir_all(root.join("src")).unwrap();
1411 fs::create_dir_all(root.join("tests")).unwrap();
1412 let app = "function work() {\n return 1;\n}\nfunction idle() {\n return 2;\n}\n";
1416 let test = "import assert from 'node:assert/strict';\nassert.equal(work(), 1);\n";
1417 fs::write(root.join("src/app.js"), app).unwrap();
1418 fs::write(root.join("tests/app.test.js"), test).unwrap();
1419 let inputs = crate::assertion_inputs::capture(
1423 &root,
1424 "python",
1425 ["src/app.js".into(), "tests/app.test.js".into()],
1426 )
1427 .unwrap();
1428 let directory = crate::run_store::create_analyzable_test_run(&root, "first");
1429 let path = directory.join("evidence.raw.gz");
1430 let entries =
1431 crate::assertion_inputs::append(read_archive(&path).unwrap(), &inputs).unwrap();
1432 let archive = write_archive(entries, &path).unwrap();
1433 let metadata_path = directory.join("run.json");
1434 let mut metadata: RunMetadata =
1435 serde_json::from_slice(&fs::read(&metadata_path).unwrap()).unwrap();
1436 metadata.raw_evidence.files = archive.files;
1437 metadata.raw_evidence.compressed_bytes = archive.compressed_bytes;
1438 metadata.raw_evidence.uncompressed_bytes = archive.uncompressed_bytes;
1439 fs::write(&metadata_path, serde_json::to_vec(&metadata).unwrap()).unwrap();
1440 prepare_publication(&root, &directory, &metadata).unwrap();
1441 let run = discover_runs(&root).unwrap().runs.remove(0);
1442 let stored = load_manifest(&run).unwrap();
1443 let (_, state) = load(&run, &stored).unwrap();
1444 let executions = state.executions.as_ref().expect("a record");
1445 let code = stored.manifest.files["src/app.js"].code.as_ref().unwrap();
1446 let work = code.units.iter().position(|u| u.path == "work").unwrap();
1447 let idle = code.units.iter().position(|u| u.path == "idle").unwrap();
1448 assert_eq!(executions.tests.len(), 1);
1449 let record = &executions.tests[0];
1450 assert_eq!(record.test.file, "tests/app.test.js");
1451 assert_eq!(record.test.name, "test");
1452 assert!(record.passed);
1453 assert_eq!(record.files["src/app.js"], vec![work]);
1454 assert_eq!(
1455 executions.probed["src/app.js"],
1456 vec![work],
1457 "idle holds no probe in this run"
1458 );
1459 let _ = idle;
1460
1461 let names = |value: &Value, key: &str| {
1462 value[key]
1463 .as_array()
1464 .unwrap()
1465 .iter()
1466 .map(|t| t["name"].as_str().unwrap().to_owned())
1467 .collect::<Vec<_>>()
1468 };
1469 let report = affected_tests(&root, &run).unwrap();
1471 assert!(names(&report, "affected").is_empty(), "{report}");
1472 assert_eq!(names(&report, "unaffected"), ["test"]);
1473 assert_eq!(report["summary"]["changedFiles"], 0);
1474 fs::write(root.join("src/app.js"), format!("// about\n{app}")).unwrap();
1476 let report = affected_tests(&root, &run).unwrap();
1477 assert!(names(&report, "affected").is_empty(), "{report}");
1478 assert_eq!(report["changedFiles"][0]["change"], "formatting");
1479 fs::write(
1481 root.join("src/app.js"),
1482 app.replace("return 1;", "return 1 + 0;"),
1483 )
1484 .unwrap();
1485 let report = affected_tests(&root, &run).unwrap();
1486 assert_eq!(names(&report, "affected"), ["test"]);
1487 assert_eq!(
1488 report["affected"][0]["reasons"][0],
1489 "src/app.js: work (line 1) changed (this test ran it)"
1490 );
1491 fs::write(
1495 root.join("src/app.js"),
1496 app.replace("return 2;", "return 2 + 0;"),
1497 )
1498 .unwrap();
1499 let report = affected_tests(&root, &run).unwrap();
1500 assert_eq!(names(&report, "affected"), ["test"], "{report}");
1501 assert!(
1502 report["affected"][0]["reasons"][0]
1503 .as_str()
1504 .unwrap()
1505 .contains("this test ran code in this file"),
1506 "{report}"
1507 );
1508 fs::write(
1510 root.join("src/app.js"),
1511 format!("{app}function more() {{}}\n"),
1512 )
1513 .unwrap();
1514 let report = affected_tests(&root, &run).unwrap();
1515 assert_eq!(names(&report, "affected"), ["test"]);
1516 assert_eq!(report["changedFiles"][0]["change"], "declarations");
1517 fs::write(root.join("src/app.js"), app).unwrap();
1519 fs::write(root.join("tests/app.test.js"), test.replace("1)", "2)")).unwrap();
1520 let report = affected_tests(&root, &run).unwrap();
1521 assert_eq!(names(&report, "affected"), ["test"]);
1522 assert!(
1523 report["affected"][0]["reasons"][0]
1524 .as_str()
1525 .unwrap()
1526 .starts_with("test file changed"),
1527 "{report}"
1528 );
1529 fs::write(root.join("tests/app.test.js"), test).unwrap();
1531 fs::remove_file(root.join("src/app.js")).unwrap();
1532 let report = affected_tests(&root, &run).unwrap();
1533 assert_eq!(
1534 report["affected"][0]["reasons"][0],
1535 "src/app.js removed (this test ran code in it)"
1536 );
1537 fs::remove_dir_all(root).unwrap();
1538 }
1539
1540 #[test]
1541 fn a_test_that_ran_beside_others_is_undetermined_rather_than_unaffected() {
1542 let root = std::env::temp_dir().join(format!(
1549 "supercov-undetermined-{}-{}",
1550 std::process::id(),
1551 std::time::SystemTime::now()
1552 .duration_since(std::time::UNIX_EPOCH)
1553 .unwrap()
1554 .as_nanos()
1555 ));
1556 let _ = fs::remove_dir_all(&root);
1557 fs::create_dir_all(root.join("src")).unwrap();
1558 fs::create_dir_all(root.join("tests")).unwrap();
1559 let app = "function work() {\n return 1;\n}\nfunction idle() {\n return 2;\n}\n";
1560 let test = "import assert from 'node:assert/strict';\nassert.equal(work(), 1);\n";
1561 fs::write(root.join("src/app.js"), app).unwrap();
1562 fs::write(root.join("tests/app.test.js"), test).unwrap();
1563 let inputs = crate::assertion_inputs::capture(
1564 &root,
1565 "python",
1566 ["src/app.js".into(), "tests/app.test.js".into()],
1567 )
1568 .unwrap();
1569 let directory = crate::run_store::create_run_wide_test_run(&root, "parallel");
1570 let path = directory.join("evidence.raw.gz");
1571 let entries =
1572 crate::assertion_inputs::append(read_archive(&path).unwrap(), &inputs).unwrap();
1573 let archive = write_archive(entries, &path).unwrap();
1574 let metadata_path = directory.join("run.json");
1575 let mut metadata: RunMetadata =
1576 serde_json::from_slice(&fs::read(&metadata_path).unwrap()).unwrap();
1577 metadata.raw_evidence.files = archive.files;
1578 metadata.raw_evidence.compressed_bytes = archive.compressed_bytes;
1579 metadata.raw_evidence.uncompressed_bytes = archive.uncompressed_bytes;
1580 fs::write(&metadata_path, serde_json::to_vec(&metadata).unwrap()).unwrap();
1581 prepare_publication(&root, &directory, &metadata).unwrap();
1582 let run = discover_runs(&root).unwrap().runs.remove(0);
1583
1584 let (_, state) = load(&run, &load_manifest(&run).unwrap()).unwrap();
1585 let executions = state.executions.as_ref().expect("a record");
1586 assert_eq!(executions.tests.len(), 1, "the named test, not the record");
1587 let record = &executions.tests[0];
1588 assert_eq!(
1589 record.attribution,
1590 crate::coverage_report::ATTRIBUTION_RUN_WIDE
1591 );
1592 assert!(
1593 record.files.is_empty(),
1594 "it is credited with nothing: {:?}",
1595 record.files
1596 );
1597 assert!(
1598 !executions.covered["src/app.js"].is_empty(),
1599 "and the run is credited with what it reached"
1600 );
1601
1602 let bucket = |value: &Value, key: &str| {
1603 value[key]
1604 .as_array()
1605 .unwrap()
1606 .iter()
1607 .map(|t| t["name"].as_str().unwrap().to_owned())
1608 .collect::<Vec<_>>()
1609 };
1610
1611 let report = affected_tests(&root, &run).unwrap();
1614 assert_eq!(bucket(&report, "unaffected"), ["test"], "{report}");
1615 assert!(bucket(&report, "undetermined").is_empty(), "{report}");
1616
1617 fs::write(
1620 root.join("src/app.js"),
1621 app.replace("return 1;", "return 3;"),
1622 )
1623 .unwrap();
1624 let report = affected_tests(&root, &run).unwrap();
1625 assert!(
1626 bucket(&report, "affected").is_empty(),
1627 "nothing proves it ran the change: {report}"
1628 );
1629 assert_eq!(
1630 bucket(&report, "undetermined"),
1631 ["test"],
1632 "the run reached it, so this test might have: {report}"
1633 );
1634 assert_eq!(
1635 report["undetermined"][0]["attribution"],
1636 crate::coverage_report::ATTRIBUTION_RUN_WIDE,
1637 "{report}"
1638 );
1639 fs::remove_dir_all(root).unwrap();
1640 }
1641
1642 #[test]
1643 fn a_lower_bound_still_proves_what_it_recorded() {
1644 let root = std::env::temp_dir().join(format!(
1651 "supercov-partial-{}-{}",
1652 std::process::id(),
1653 std::time::SystemTime::now()
1654 .duration_since(std::time::UNIX_EPOCH)
1655 .unwrap()
1656 .as_nanos()
1657 ));
1658 let _ = fs::remove_dir_all(&root);
1659 fs::create_dir_all(root.join("src")).unwrap();
1660 fs::create_dir_all(root.join("tests")).unwrap();
1661 let app = "function work() {\n return 1;\n}\nfunction idle() {\n return 2;\n}\n";
1662 let test = "import assert from 'node:assert/strict';\nassert.equal(work(), 1);\n";
1663 fs::write(root.join("src/app.js"), app).unwrap();
1664 fs::write(root.join("tests/app.test.js"), test).unwrap();
1665 let inputs = crate::assertion_inputs::capture(
1666 &root,
1667 "python",
1668 ["src/app.js".into(), "tests/app.test.js".into()],
1669 )
1670 .unwrap();
1671 let directory = crate::run_store::create_partial_test_run(&root, "partial");
1672 let path = directory.join("evidence.raw.gz");
1673 let entries =
1674 crate::assertion_inputs::append(read_archive(&path).unwrap(), &inputs).unwrap();
1675 let archive = write_archive(entries, &path).unwrap();
1676 let metadata_path = directory.join("run.json");
1677 let mut metadata: RunMetadata =
1678 serde_json::from_slice(&fs::read(&metadata_path).unwrap()).unwrap();
1679 metadata.raw_evidence.files = archive.files;
1680 metadata.raw_evidence.compressed_bytes = archive.compressed_bytes;
1681 metadata.raw_evidence.uncompressed_bytes = archive.uncompressed_bytes;
1682 fs::write(&metadata_path, serde_json::to_vec(&metadata).unwrap()).unwrap();
1683 prepare_publication(&root, &directory, &metadata).unwrap();
1684 let run = discover_runs(&root).unwrap().runs.remove(0);
1685
1686 let (_, state) = load(&run, &load_manifest(&run).unwrap()).unwrap();
1687 let record = &state.executions.as_ref().expect("a record").tests[0];
1688 assert_eq!(
1689 record.attribution,
1690 crate::coverage_report::ATTRIBUTION_PARTIAL
1691 );
1692 assert!(
1693 !record.files.is_empty(),
1694 "a lower bound is coverage it was credited with"
1695 );
1696
1697 let names = |value: &Value, key: &str| {
1698 value[key]
1699 .as_array()
1700 .unwrap()
1701 .iter()
1702 .map(|t| t["name"].as_str().unwrap().to_owned())
1703 .collect::<Vec<_>>()
1704 };
1705 fs::write(
1706 root.join("src/app.js"),
1707 app.replace("return 1;", "return 3;"),
1708 )
1709 .unwrap();
1710 let report = affected_tests(&root, &run).unwrap();
1711 assert_eq!(
1712 names(&report, "affected"),
1713 ["test"],
1714 "what its own record proves, it proves: {report}"
1715 );
1716 assert!(names(&report, "undetermined").is_empty(), "{report}");
1717 let reasons = report["affected"][0]["reasons"].as_array().unwrap();
1719 assert_eq!(reasons.len(), 1, "{report}");
1720 let reason = reasons[0].as_str().unwrap();
1721 assert!(
1722 reason.contains("this test ran it"),
1723 "the reason is its own: {report}"
1724 );
1725 assert!(
1726 !reason.contains("the run covered"),
1727 "and not the run's stand-in: {report}"
1728 );
1729 fs::remove_dir_all(root).unwrap();
1730 }
1731
1732 #[test]
1733 fn legacy_maps_import_without_the_old_checkout_and_require_review() {
1734 let root = std::env::temp_dir().join(format!("supercov-legacy-map-{}", std::process::id()));
1735 fs::create_dir_all(&root).unwrap();
1736 let source = "import assert from 'node:assert/strict'; assert.equal(1, 1);\n";
1737 fs::write(root.join("test.js"), source).unwrap();
1738 let old =
1739 crate::assertion_inputs::capture(&root, "javascript", ["test.js".into()]).unwrap();
1740 let directory = crate::run_store::create_analyzable_test_run(&root, "legacy");
1741 let path = directory.join("evidence.raw.gz");
1742 let mut entries = read_archive(&path).unwrap();
1743 entries.push(EvidenceArchiveEntry {
1744 path: ARCHIVE_PATH.into(),
1745 contents: serde_json::to_vec(&old).unwrap(),
1746 });
1747 let archive = write_archive(entries, &path).unwrap();
1748 let metadata_path = directory.join("run.json");
1749 let mut metadata: RunMetadata =
1750 serde_json::from_slice(&fs::read(&metadata_path).unwrap()).unwrap();
1751 metadata.raw_evidence.files = archive.files;
1752 metadata.raw_evidence.compressed_bytes = archive.compressed_bytes;
1753 metadata.raw_evidence.uncompressed_bytes = archive.uncompressed_bytes;
1754 fs::write(&metadata_path, serde_json::to_vec(&metadata).unwrap()).unwrap();
1755 let run = discover_runs(&root).unwrap().runs.remove(0);
1756 let stored = load_manifest(&run).unwrap();
1757 let (map, _) = seed(&old, &stored.evidence_digest);
1758 let legacy_map = json!({"schemaVersion":1,"assertions":[{"id":map.assertions[0].id,"at":map.assertions[0].at,"analysis":"mapped","observes":[],"flows":[{
1759 "id":"constant","explanation":"The assertion checks the constant one.","appliesTo":[],"nodes":[],"edges":[],"countsAsAsserted":[],"watch":[{"kind":"span","at":map.assertions[0].at}]
1760 }]}]});
1761 let legacy_state = json!({"schemaVersion":1,"inputsDigest":digest(&old),"evidenceDigest":stored.evidence_digest,"reviews":{},"scopeReview":[]});
1762 write_json(&root, &run, MAP_FILE, &legacy_map).unwrap();
1763 write_json(&root, &run, STATE_FILE, &legacy_state).unwrap();
1764 let map = model::parse_stored(&serde_json::to_vec(&legacy_map).unwrap()).unwrap();
1765 let map_bytes = fs::read(directory.join(MAP_FILE)).unwrap();
1766 let state_bytes = fs::read(directory.join(STATE_FILE)).unwrap();
1767
1768 fs::write(root.join("test.js"), format!("\n{source}")).unwrap();
1769 assert!(load_inputs(&root, &run).is_err());
1770 let (imported, state) = load(&run, &stored).unwrap();
1771 let new =
1772 crate::assertion_inputs::capture(&root, "javascript", ["test.js".into()]).unwrap();
1773 let (next, state) =
1774 carry(&imported, &state, &stored.manifest, &new, "new-run", false).unwrap();
1775 assert_eq!(next.assertions[0].id, map.assertions[0].id);
1776 assert_eq!(
1777 next.assertions[0].flows[0].explanation,
1778 map.assertions[0].flows[0].explanation
1779 );
1780 assert!(next.assertions[0].flows[0].basis.is_none());
1781 assert!(!next.assertions[0].flows[0].questions.is_empty());
1782 assert_eq!(fs::read(directory.join(MAP_FILE)).unwrap(), map_bytes);
1783 assert_eq!(fs::read(directory.join(STATE_FILE)).unwrap(), state_bytes);
1784
1785 let next_directory = crate::run_store::create_analyzable_test_run(&root, "next");
1788 let mut entries = read_archive(&path).unwrap();
1789 entries
1790 .iter_mut()
1791 .find(|e| e.path == ARCHIVE_PATH)
1792 .unwrap()
1793 .contents = serde_json::to_vec(&old.manifest()).unwrap();
1794 let raw = write_archive(entries, &next_directory.join("evidence.raw.gz")).unwrap();
1795 let mut next_metadata = metadata.clone();
1796 next_metadata.id = "next".into();
1797 next_metadata.started_at = "next".into();
1798 next_metadata.raw_evidence.compressed_bytes = raw.compressed_bytes;
1799 next_metadata.raw_evidence.uncompressed_bytes = raw.uncompressed_bytes;
1800 fs::write(
1801 next_directory.join("run.json"),
1802 serde_json::to_vec(&next_metadata).unwrap(),
1803 )
1804 .unwrap();
1805 fs::remove_file(root.join("test.js")).unwrap();
1806 prepare_publication(&root, &next_directory, &next_metadata).unwrap();
1807 let next_run = discover_runs(&root)
1808 .unwrap()
1809 .runs
1810 .into_iter()
1811 .find(|r| r.id == "next")
1812 .unwrap();
1813 let next_manifest = load_manifest(&next_run).unwrap();
1814 let (pending, pending_state) = load(&next_run, &next_manifest).unwrap();
1815 assert_eq!(pending.assertions[0], map.assertions[0]);
1816 assert!(!pending_state.changes.is_empty());
1817 assert!(
1818 !pending_state.flows
1819 [&flow_key(&pending.assertions[0], &pending.assertions[0].flows[0])]
1820 .reasons
1821 .is_empty()
1822 );
1823 assert!(load_inputs(&root, &next_run).is_err());
1824 assert_eq!(fs::read(directory.join(MAP_FILE)).unwrap(), map_bytes);
1825 assert_eq!(fs::read(directory.join(STATE_FILE)).unwrap(), state_bytes);
1826
1827 let mut corrupt = state.clone();
1828 corrupt.evidence_digest = "wrong".into();
1829 write_json(&root, &run, STATE_FILE, &corrupt).unwrap();
1830 assert!(load(&run, &stored).is_err());
1831 fs::remove_dir_all(root).unwrap();
1832 }
1833}