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_selected,
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";
24const SUMMARY_CACHE_FILE: &str = "assertions.summary.cache.json";
26const INPUTS_CACHE_FILE: &str = "assertions.inputs.cache.json";
31
32#[derive(serde::Serialize, serde::Deserialize)]
33#[serde(rename_all = "camelCase", deny_unknown_fields)]
34struct InputsCache {
35 evidence_digest: String,
36 manifest: InputManifest,
37 #[serde(default, skip_serializing_if = "Option::is_none")]
38 legacy_digest: Option<String>,
39 #[serde(default)]
40 statement_exclusions: Vec<Value>,
41}
42pub struct RunManifest {
43 pub manifest: InputManifest,
44 pub evidence_digest: String,
45 legacy_digest: Option<String>,
46 pub statement_exclusions: Vec<Value>,
47}
48pub struct RunInputs {
49 pub inputs: Inputs,
50 pub stored: RunManifest,
51}
52impl std::ops::Deref for RunInputs {
53 type Target = RunManifest;
54 fn deref(&self) -> &RunManifest {
55 &self.stored
56 }
57}
58pub fn load_inputs(root: &Path, run: &StoredRun) -> Result<RunInputs, String> {
59 let stored = load_manifest(run)?;
60 let inputs = crate::assertion_inputs::current_sources(root, &stored.manifest)?;
61 Ok(RunInputs { inputs, stored })
62}
63pub fn load_manifest(run: &StoredRun) -> Result<RunManifest, String> {
64 load_optional_manifest(run)?.ok_or_else(|| {
65 "This older run has no assertion manifest. Run tests once with this version of Supercov"
66 .into()
67 })
68}
69fn load_optional_manifest(run: &StoredRun) -> Result<Option<RunManifest>, String> {
70 if run.metadata.merged == Some(true) {
71 return Err(
72 "Use a single run for assertion maps; merged runs have multiple input manifests".into(),
73 );
74 }
75 let bytes = fs::read(&run.evidence_path).map_err(|e| e.to_string())?;
76 let evidence_digest = format!("{:x}", Sha256::digest(&bytes));
77 if let Some(cached) = fs::read(run.directory.join(INPUTS_CACHE_FILE))
78 .ok()
79 .and_then(|bytes| serde_json::from_slice::<InputsCache>(&bytes).ok())
80 .filter(|cached| cached.evidence_digest == evidence_digest)
81 .filter(|cached| validate_manifest(&cached.manifest).is_ok())
82 {
83 return Ok(Some(RunManifest {
84 manifest: cached.manifest,
85 evidence_digest,
86 legacy_digest: cached.legacy_digest,
87 statement_exclusions: cached.statement_exclusions,
88 }));
89 }
90 let entries = read_archive_selected(&run.evidence_path, |path| {
91 path == ARCHIVE_PATH || path == "statement-exclusions.json"
92 })
93 .map_err(|e| e.to_string())?;
94 let Some(input) = entries.iter().find(|e| e.path == ARCHIVE_PATH) else {
95 return Ok(None);
96 };
97 let value: Value = serde_json::from_slice(&input.contents).map_err(|e| e.to_string())?;
98 let (manifest, legacy_digest) = match value["schemaVersion"].as_u64() {
99 Some(2) => (
100 serde_json::from_value::<InputManifest>(value).map_err(|e| e.to_string())?,
101 None,
102 ),
103 Some(1) => {
104 let legacy: Inputs = serde_json::from_value(value).map_err(|e| e.to_string())?;
107 if legacy
108 .assertions
109 .iter()
110 .any(|s| s.at.offset(&legacy.files).is_none())
111 {
112 return Err("Invalid legacy assertion inputs".into());
113 }
114 (legacy.manifest(), Some(digest(&legacy)))
115 }
116 _ => return Err("Unsupported assertion input schema".into()),
117 };
118 validate_manifest(&manifest)?;
119 if fs::read(&run.evidence_path).map_err(|e| e.to_string())? != bytes {
120 return Err("Run archive changed during read".into());
121 }
122 Ok(Some(RunManifest {
123 manifest,
124 evidence_digest,
125 legacy_digest,
126 statement_exclusions: entries
127 .iter()
128 .find(|e| e.path == "statement-exclusions.json")
129 .map(|entry| serde_json::from_slice(&entry.contents).map_err(|e| e.to_string()))
130 .transpose()?
131 .unwrap_or_default(),
132 }))
133}
134
135fn validate_manifest(manifest: &InputManifest) -> Result<(), String> {
136 if manifest.files.iter().any(|(p, f)| {
137 !local_path(p) || f.sha256.len() != 64 || !f.sha256.bytes().all(|b| b.is_ascii_hexdigit())
138 }) || manifest.assertions.iter().any(|s| {
139 !manifest.files.contains_key(&s.at.file)
140 || s.at.line == 0
141 || s.at.column == 0
142 || s.at.text.is_empty()
143 }) {
144 return Err("Invalid assertion input manifest".into());
145 }
146 Ok(())
147}
148
149pub(crate) fn archived_manifest(
153 manifest: InputManifest,
154 evidence_digest: String,
155) -> Result<RunManifest, String> {
156 validate_manifest(&manifest)?;
157 Ok(RunManifest {
158 manifest,
159 evidence_digest,
160 legacy_digest: None,
161 statement_exclusions: Vec::new(),
162 })
163}
164pub fn load(run: &StoredRun, input: &RunManifest) -> Result<(AssertionMap, State), String> {
165 let read = |file: &str| {
166 fs::read(run.directory.join(file))
167 .map_err(|e| format!("{file}: {e}; new test runs create assertion maps automatically"))
168 };
169 let map = model::parse_stored(&read(MAP_FILE)?).map_err(|e| format!("{MAP_FILE}: {e}"))?;
170 let state = model::parse_state(
171 &read(STATE_FILE)?,
172 &map,
173 &input.manifest,
174 &input.evidence_digest,
175 input.legacy_digest.as_deref(),
176 )?;
177 Ok((map, state))
178}
179fn write_json(
180 root: &Path,
181 run: &StoredRun,
182 name: &str,
183 value: &impl serde::Serialize,
184) -> Result<(), String> {
185 let mut bytes = serde_json::to_vec_pretty(value).map_err(|e| e.to_string())?;
186 bytes.push(b'\n');
187 atomic_write(root, &run.directory.join(name), &bytes).map_err(|e| e.to_string())
188}
189#[cfg(test)]
197pub(crate) fn prepare_publication(
198 root: &Path,
199 directory: &Path,
200 metadata: &RunMetadata,
201 analysed: Option<&CoverageReport>,
202) -> Result<(), String> {
203 prepare_publication_with(root, directory, metadata, analysed, None)
204}
205
206pub(crate) fn prepare_publication_with(
209 root: &Path,
210 directory: &Path,
211 metadata: &RunMetadata,
212 analysed: Option<&CoverageReport>,
213 archived: Option<RunManifest>,
214) -> Result<(), String> {
215 if metadata.merged == Some(true) {
216 return Ok(());
217 }
218 let run = StoredRun {
219 id: metadata.id.clone(),
220 directory: directory.into(),
221 evidence_path: directory.join("evidence.raw.gz"),
222 metadata_path: directory.join("run.json"),
223 query_index_path: directory.join(crate::run_store::RUST_QUERY_INDEX_FILE),
224 metadata: metadata.clone(),
225 };
226 let input = match archived {
227 Some(input) => input,
228 None => match load_optional_manifest(&run)? {
229 Some(input) => input,
230 None => return Ok(()),
231 },
232 };
233 if directory.join(MAP_FILE).exists() || directory.join(STATE_FILE).exists() {
235 return Err("Refusing to replace an existing assertion map or review state".into());
236 }
237 let current = crate::assertion_inputs::current_sources(root, &input.manifest);
238 let inventory = discover_runs(root).map_err(|e| e.to_string())?;
239 let mut inheritance = Inheritance::default();
240 let mut inherited = None;
241 for previous in &inventory.runs {
242 if previous.id == run.id
243 || previous.metadata.merged == Some(true)
244 || previous.metadata.command != metadata.command
245 || (!previous.directory.join(MAP_FILE).exists()
246 && !previous.directory.join(STATE_FILE).exists())
247 {
248 continue;
249 }
250 let attempt = (|| {
251 let old = load_manifest(previous)?;
252 if old.manifest.language != input.manifest.language {
253 return Ok(None);
254 }
255 let (map, state) = load(previous, &old)?;
256 let a = &previous.metadata.integrity.fingerprint;
257 let b = &metadata.integrity.fingerprint;
258 let delta = context_delta(
259 a,
260 b,
261 &old.manifest.context_digest,
262 &input.manifest.context_digest,
263 );
264 let context_changed = delta.execution;
265 let dependencies_changed = delta.dependencies;
266 let current = match ¤t {
267 Ok(current) => current,
268 Err(reason) => {
269 let (mut next, mut next_state) =
273 seed_manifest(&input.manifest, &input.evidence_digest);
274 next.retired_assertions = map.retired_assertions;
275 for assertion in map.assertions {
276 if let Some(site) =
277 next.assertions.iter_mut().find(|a| a.at == assertion.at)
278 {
279 *site = assertion;
280 } else {
281 next.retired_assertions.push(Retired {
282 assertion,
283 reason: reason.clone(),
284 });
285 }
286 }
287 let mut reserved = next
288 .retired_assertions
289 .iter()
290 .map(|r| r.assertion.id.clone())
291 .chain(
292 next.assertions
293 .iter()
294 .filter(|a| !a.flows.is_empty())
295 .map(|a| a.id.clone()),
296 )
297 .collect::<BTreeSet<_>>();
298 for assertion in next.assertions.iter_mut().filter(|a| a.flows.is_empty()) {
299 while !reserved.insert(assertion.id.clone()) {
300 assertion.id.push('_');
301 }
302 }
303 invalidate(&mut next_state, &next, reason);
304 add_change(
305 &mut next_state,
306 None,
307 None,
308 None,
309 reason.clone(),
310 BTreeSet::new(),
311 BTreeSet::new(),
312 );
313 return Ok(Some((next, next_state)));
314 }
315 };
316 model::carry(
317 &map,
318 &state,
319 &old.manifest,
320 current,
321 &input.evidence_digest,
322 context_changed,
323 )
324 .map(|(next, mut next_state)| {
325 if dependencies_changed {
326 add_change(
327 &mut next_state,
328 None,
329 Some(a.dependencies.clone()),
330 Some(b.dependencies.clone()),
331 "installed dependencies changed".into(),
332 BTreeSet::new(),
333 BTreeSet::new(),
334 );
335 }
336 Some((next, next_state))
337 })
338 })();
339 match attempt {
340 Ok(Some(pair)) => {
341 inheritance.from = Some(previous.id.clone());
342 inherited = Some(pair);
343 break;
344 }
345 Ok(None) => (),
346 Err(reason) => inheritance.skipped.push(SkippedMap {
347 run: previous.id.clone(),
348 reason,
349 }),
350 }
351 }
352 let (map, mut state) =
353 inherited.unwrap_or_else(|| seed_manifest(&input.manifest, &input.evidence_digest));
354 if !inheritance.skipped.is_empty() {
357 invalidate(
358 &mut state,
359 &map,
360 "newer assertion map could not be reused; inspect inherited claims",
361 );
362 }
363 let owned;
367 let analysed = match analysed {
368 Some(report) => Some(report),
369 None => {
370 owned = coverage(&run).ok();
371 owned.as_ref()
372 }
373 };
374 state.executions = analysed.and_then(|report| {
375 executions(
376 report,
377 &input.manifest,
378 current.as_ref().ok().map(|inputs| &inputs.files),
379 )
380 });
381 if let Err(reason) = ¤t {
382 invalidate(&mut state, &map, reason);
383 add_change(
384 &mut state,
385 None,
386 None,
387 None,
388 reason.clone(),
389 BTreeSet::new(),
390 BTreeSet::new(),
391 );
392 }
393 state.inheritance = Some(inheritance);
394 write_json(root, &run, MAP_FILE, &map)?;
395 write_json(root, &run, STATE_FILE, &state)?;
396 let _ = write_json(
398 root,
399 &run,
400 INPUTS_CACHE_FILE,
401 &InputsCache {
402 evidence_digest: input.evidence_digest.clone(),
403 manifest: input.manifest.clone(),
404 legacy_digest: input.legacy_digest.clone(),
405 statement_exclusions: input.statement_exclusions.clone(),
406 },
407 );
408 if let (Some(report), Ok(inputs)) = (analysed, current) {
412 let input = RunInputs {
416 inputs,
417 stored: input,
418 };
419 let _ = report_with_inputs(root, &run, None, false, Some(report), &input);
420 }
421 Ok(())
422}
423pub fn executions(
429 coverage: &CoverageReport,
430 manifest: &InputManifest,
431 sources: Option<&Files>,
432) -> Option<Executions> {
433 let mut located: crate::interned::FastMap<&str, (usize, usize)> = Default::default();
437 let mut files: Vec<&str> = Vec::new();
438 let mut file_indexes: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
439 let mut probed: BTreeMap<String, BTreeSet<usize>> = BTreeMap::new();
440 for point in &coverage.view.points {
441 let meta = &point.meta;
442 let Some(code) = manifest
443 .files
444 .get(&meta.file)
445 .and_then(|fingerprint| fingerprint.code.as_ref())
446 else {
447 continue;
448 };
449 let column = if manifest.language == "javascript" {
450 let source = sources?.get(&meta.file)?;
451 let Some(column) = byte_column(source, meta.line, meta.column, &manifest.language)
452 else {
453 continue;
454 };
455 column
456 } else {
457 meta.column + 1
458 };
459 let unit = code.unit_at(meta.line, column);
460 let file = *file_indexes.entry(meta.file.as_str()).or_insert_with(|| {
461 files.push(meta.file.as_str());
462 files.len() - 1
463 });
464 located.insert(meta.id.as_str(), (file, unit));
465 if code.units[unit].is_code() {
466 probed.entry(meta.file.clone()).or_default().insert(unit);
467 }
468 }
469 let mut covered_by_file = vec![Vec::new(); files.len()];
474 for test in &coverage.view.tests {
475 for hit in &test.hits {
476 if let Some((file, unit)) = located.get(hit.as_str()) {
477 covered_by_file[*file].push(*unit);
478 }
479 }
480 }
481 let covered = files
482 .iter()
483 .zip(covered_by_file)
484 .filter(|(_, units)| !units.is_empty())
485 .map(|(file, units)| {
486 (
487 (*file).to_owned(),
488 units.into_iter().collect::<BTreeSet<_>>(),
489 )
490 })
491 .collect::<BTreeMap<_, _>>();
492
493 let mut tests: BTreeMap<TestSelector, Execution> = BTreeMap::new();
494 for test in &coverage.view.tests {
495 let Some(file) = &test.file else {
496 continue;
497 };
498 let selector = TestSelector {
499 file: file.clone(),
500 name: test.name.clone(),
501 };
502 let record = tests.entry(selector.clone()).or_insert_with(|| Execution {
503 test: selector,
504 passed: false,
505 files: BTreeMap::new(),
506 attribution: crate::coverage_report::ATTRIBUTION_EXACT.to_owned(),
507 });
508 record.passed |= test.outcome == "passed";
509 if !crate::coverage_report::coverage_is_complete(&record.attribution) {
513 } else if !crate::coverage_report::coverage_is_complete(&test.attribution) {
515 record.attribution = test.attribution.clone();
516 }
517 let mut reached = test
518 .hits
519 .iter()
520 .filter_map(|hit| located.get(hit.as_str()).copied())
521 .collect::<Vec<_>>();
522 reached.sort_unstable();
523 for group in reached.chunk_by(|left, right| left.0 == right.0) {
524 record
525 .files
526 .entry(files[group[0].0].to_owned())
527 .or_default()
528 .extend(group.iter().map(|(_, unit)| *unit));
529 }
530 }
531 let mut tests = tests.into_values().collect::<Vec<_>>();
532 for record in &mut tests {
533 for units in record.files.values_mut() {
534 units.sort_unstable();
535 units.dedup();
536 }
537 }
538 Some(Executions {
539 tests,
540 probed: probed
541 .into_iter()
542 .map(|(file, units)| (file, units.into_iter().collect()))
543 .collect(),
544 covered: covered
545 .into_iter()
546 .map(|(file, units)| (file, units.into_iter().collect()))
547 .collect(),
548 })
549}
550pub fn affected_tests(root: &Path, run: &StoredRun) -> Result<Value, String> {
560 let stored = load_manifest(run)?;
561 let (_, state) = load(run, &stored)?;
562 let Some(executions) = &state.executions else {
563 return Err(
564 "This run has no per-test execution record; rerun tests with this version of Supercov"
565 .into(),
566 );
567 };
568 let root = crate::workspace::canonicalize_simplified(root).map_err(|e| e.to_string())?;
569 let manifest = &stored.manifest;
570 let now = manifest
571 .files
572 .keys()
573 .filter_map(|file| {
574 let path = root.join(file);
575 let canonical = crate::workspace::canonicalize_simplified(&path).ok()?;
576 if !canonical.starts_with(&root) || !canonical.is_file() {
577 return None;
578 }
579 let text = fs::read_to_string(canonical).ok()?;
580 Some((file.clone(), FileFingerprint::read(file, &text)))
581 })
582 .collect::<FileManifest>();
583 let changes = manifest
584 .files
585 .keys()
586 .filter_map(|file| {
587 model::file_change(
588 manifest.files.get(file),
589 now.get(file),
590 executions.probed.get(file).map(Vec::as_slice),
591 )
592 .map(|change| (file.as_str(), change))
593 })
594 .collect::<BTreeMap<_, _>>();
595 let files = changes
596 .iter()
597 .filter_map(|(file, change)| {
598 let (kind, detail) = match change {
599 FileChange::Same | FileChange::Added => return None,
600 FileChange::CommentsOnly => ("formatting", None),
601 FileChange::Removed => ("removed", None),
602 FileChange::Bytes => ("changed", None),
603 FileChange::Code {
604 before,
605 after,
606 diff,
607 narrow,
608 } => (
609 if *narrow { "bodies" } else { "declarations" },
610 Some(model::describe(before, after, diff)),
611 ),
612 };
613 Some(json!({"file":file,"change":kind,"detail":detail}))
614 })
615 .collect::<Vec<_>>();
616 let beyond_the_run = changes
630 .iter()
631 .filter_map(|(file, change)| match change {
632 FileChange::Same | FileChange::CommentsOnly | FileChange::Added => None,
633 FileChange::Removed => Some(format!("{file} removed (the run covered code in it)")),
634 FileChange::Bytes => Some(format!("{file} changed (the run covered code in it)")),
635 FileChange::Code { before, diff, .. } => {
636 let code = manifest.files.get(*file).and_then(|f| f.code.as_ref());
641 let reached = executions
642 .covered
643 .get(*file)
644 .map(Vec::as_slice)
645 .unwrap_or_default()
646 .iter()
647 .flat_map(|unit| match code {
648 Some(code) if *unit < code.units.len() => {
649 code.ancestors(*unit).collect::<Vec<_>>()
650 }
651 _ => vec![*unit],
652 })
653 .collect::<BTreeSet<_>>();
654 let hit = diff
655 .changed
656 .iter()
657 .filter(|unit| reached.contains(unit))
658 .filter_map(|unit| before.units.get(*unit))
659 .collect::<Vec<_>>();
660 (!hit.is_empty())
661 .then(|| format!("{file}: {} changed (the run covered it)", named(hit)))
662 }
663 })
664 .collect::<Vec<_>>();
665
666 let mut affected = Vec::new();
667 let mut undetermined = Vec::new();
668 let mut unaffected = Vec::new();
669 for record in &executions.tests {
670 let mut reasons = Vec::new();
671 if !record.passed {
672 reasons.push("did not pass in the run".to_owned());
673 }
674 match changes.get(record.test.file.as_str()) {
675 None | Some(FileChange::Same | FileChange::CommentsOnly | FileChange::Added) => {}
676 Some(FileChange::Removed) => reasons.push("test file removed".to_owned()),
677 Some(FileChange::Bytes) => reasons.push("test file changed".to_owned()),
678 Some(FileChange::Code {
679 before,
680 after,
681 diff,
682 ..
683 }) => reasons.push(format!(
684 "test file changed: {}",
685 model::describe(before, after, diff)
686 )),
687 }
688 for (file, units) in &record.files {
689 let code = manifest.files.get(file).and_then(|f| f.code.as_ref());
690 let ran = units
691 .iter()
692 .flat_map(|unit| match code {
693 Some(code) if *unit < code.units.len() => {
694 code.ancestors(*unit).collect::<Vec<_>>()
695 }
696 _ => vec![*unit],
697 })
698 .collect::<BTreeSet<_>>();
699 match changes.get(file.as_str()) {
700 None | Some(FileChange::Same | FileChange::CommentsOnly | FileChange::Added) => {}
701 Some(FileChange::Removed) => {
702 reasons.push(format!("{file} removed (this test ran code in it)"));
703 }
704 Some(FileChange::Bytes) => {
705 reasons.push(format!("{file} changed (this test ran code in it)"));
706 }
707 Some(FileChange::Code {
708 before,
709 after,
710 diff,
711 narrow,
712 }) => {
713 if *narrow {
714 let hit = diff
715 .changed
716 .iter()
717 .filter(|i| ran.contains(i))
718 .map(|i| &before.units[*i])
719 .collect::<Vec<_>>();
720 if !hit.is_empty() {
721 reasons
722 .push(format!("{file}: {} changed (this test ran it)", named(hit)));
723 }
724 } else {
725 reasons.push(format!(
726 "{file}: {} changed (this test ran code in this file)",
727 model::describe(before, after, diff)
728 ));
729 }
730 }
731 }
732 }
733 if !crate::coverage_report::coverage_is_complete(&record.attribution) {
734 let proven = !reasons.is_empty();
738 if !proven {
742 reasons.extend(beyond_the_run.iter().cloned());
743 reasons.sort();
744 reasons.dedup();
745 }
746 let entry = json!({
747 "file": record.test.file,
748 "name": record.test.name,
749 "reasons": reasons,
750 "attribution": record.attribution,
751 });
752 if proven {
753 affected.push(entry);
754 } else if reasons.is_empty() {
755 unaffected.push(entry);
756 } else {
757 undetermined.push(entry);
758 }
759 continue;
760 }
761 let entry = json!({"file":record.test.file,"name":record.test.name,"reasons":reasons});
762 if reasons.is_empty() {
763 unaffected.push(entry);
764 } else {
765 affected.push(entry);
766 }
767 }
768 Ok(json!({
769 "run": run.id,
770 "affected": affected,
771 "undetermined": undetermined,
778 "unaffected": unaffected,
779 "changedFiles": files,
780 "summary": {
781 "tests": executions.tests.len(),
782 "affected": affected.len(),
783 "undetermined": undetermined.len(),
784 "unaffected": unaffected.len(),
785 "changedFiles": files.len(),
786 },
787 "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."
788 }))
789}
790struct ContextDelta {
792 execution: bool,
795 dependencies: bool,
798}
799
800fn context_delta(
815 a: &RunFingerprint,
816 b: &RunFingerprint,
817 old_context: &str,
818 new_context: &str,
819) -> ContextDelta {
820 ContextDelta {
821 execution: a.configuration != b.configuration || old_context != new_context,
822 dependencies: a.dependencies != b.dependencies,
823 }
824}
825
826pub fn coverage(run: &StoredRun) -> Result<CoverageReport, String> {
827 analyze_coverage_archive(&ArchiveReportRequest {
828 archive_path: run.evidence_path.clone(),
829 run_id: run.id.clone(),
830 generated_at: run.metadata.started_at.clone(),
831 integrity: None,
832 test_exit_code: ExitCodeInput::Present(run.metadata.test_exit_code),
833 })
834 .map_err(|e| format!("{e:?}"))
835}
836fn byte_column(source: &str, line: usize, column: usize, language: &str) -> Option<usize> {
837 byte_column_in(
838 source,
839 &crate::assertion_map::line_starts(source),
840 line,
841 column,
842 language,
843 )
844}
845
846fn byte_column_in(
849 source: &str,
850 starts: &[usize],
851 line: usize,
852 column: usize,
853 language: &str,
854) -> Option<usize> {
855 if line == 0 {
856 return None;
857 }
858 let line = crate::assertion_map::line_text(source, starts, line)?;
859 if language != "javascript" {
860 return column.checked_add(1);
862 }
863 if column == 0 {
864 return None;
865 }
866 let mut units = 0;
867 for (byte, ch) in line.char_indices() {
868 if units == column - 1 {
869 return Some(byte + 1);
870 }
871 units += ch.len_utf16();
872 }
873 (units == column - 1).then_some(line.len() + 1)
874}
875fn phase_location<'a>(
876 phase: &'a crate::coverage_report::CoveragePhase,
877 inputs: &Inputs,
878) -> Option<(&'a str, usize, usize)> {
879 if phase.kind != "assertion" || phase.status.as_deref() != Some("passed") {
880 return None;
881 }
882 let source = phase
883 .operation
884 .strip_prefix("Rust assertion at ")
885 .or(phase.source.as_deref());
886 let location = source?;
887 let mut parts = location.rsplitn(3, ':');
888 let column = parts.next()?.parse::<usize>().ok()?;
889 let line = parts.next()?.parse::<usize>().ok()?;
890 let file = parts.next()?;
891 let column = byte_column(inputs.files.get(file)?, line, column, &inputs.language)?;
892 Some((file, line, column))
893}
894
895pub fn report(root: &Path, run: &StoredRun) -> Result<Value, String> {
898 report_with_detail(root, run, None, true)
899}
900pub fn report_summary(root: &Path, run: &StoredRun) -> Result<Value, String> {
903 report_with_detail(root, run, None, false)
904}
905pub fn assertion(root: &Path, run: &StoredRun, id: &str) -> Result<Value, String> {
907 report_with_detail(root, run, Some(id), true)
908}
909fn report_with_detail(
910 root: &Path,
911 run: &StoredRun,
912 id: Option<&str>,
913 detail: bool,
914) -> Result<Value, String> {
915 report_with_detail_using(root, run, id, detail, None)
916}
917fn report_with_detail_using(
919 root: &Path,
920 run: &StoredRun,
921 id: Option<&str>,
922 detail: bool,
923 analysed: Option<&CoverageReport>,
924) -> Result<Value, String> {
925 let input = load_inputs(root, run)?;
926 report_with_inputs(root, run, id, detail, analysed, &input)
927}
928
929fn report_with_inputs(
930 root: &Path,
931 run: &StoredRun,
932 id: Option<&str>,
933 detail: bool,
934 analysed: Option<&CoverageReport>,
935 input: &RunInputs,
936) -> Result<Value, String> {
937 let (map, state) = load(run, input)?;
938 let cache_key = digest(&(
939 env!("SUPERCOV_ENGINE_SOURCE_SHA256"),
940 &run.id,
941 run.metadata.test_exit_code,
942 &map,
943 &state,
944 &input.evidence_digest,
945 ));
946 let cache_file = if detail {
947 REPORT_CACHE_FILE
948 } else {
949 SUMMARY_CACHE_FILE
950 };
951 let cache_path = run.directory.join(cache_file);
952 let mut report = read_report_cache(&cache_path, &cache_key).unwrap_or_else(|| Value::Null);
953 if report.is_null() {
954 let owned;
955 let coverage = match analysed {
956 Some(report) => report,
957 None => {
958 owned = coverage(run)?;
959 &owned
960 }
961 };
962 report = assess_with(
963 &map,
964 &state,
965 &input.inputs,
966 coverage,
967 run.metadata.test_exit_code == Some(0),
968 detail,
969 );
970 if detail {
971 report["excludedStatements"] = json!(input.statement_exclusions);
972 }
973 report["summary"]["excludedStatements"] = json!(input.statement_exclusions.len());
974 let cached = json!({"key":cache_key,"digest":digest(&report),"report":report});
977 let _ = write_json(root, run, cache_file, &cached);
978 }
979 if let Some(id) = id {
980 let matches = report["assertions"]
981 .as_array()
982 .unwrap()
983 .iter()
984 .filter(|a| a["id"] == id)
985 .collect::<Vec<_>>();
986 let mut row = match matches.as_slice() {
987 [row] => (*row).clone(),
988 [] => {
989 return Err(format!(
990 "Unknown assertion ID: {id}; use runs {} assertions to list IDs",
991 run.id
992 ));
993 }
994 _ => {
995 return Err(format!(
996 "Ambiguous assertion ID: {id}; repair duplicate IDs in assertions.json"
997 ));
998 }
999 };
1000 if let Some(authored) = map.assertions.iter().find(|a| a.id == id) {
1001 for (flow, authored) in row["flows"]
1002 .as_array_mut()
1003 .unwrap()
1004 .iter_mut()
1005 .zip(&authored.flows)
1006 {
1007 let assessment = flow.as_object().unwrap().clone();
1008 *flow = serde_json::to_value(authored).map_err(|e| e.to_string())?;
1009 flow.as_object_mut().unwrap().extend(assessment);
1010 }
1011 }
1012 report["assertion"] = row;
1013 }
1014 report["inheritance"] = json!(state.inheritance);
1015 report["revision"] = json!(digest(&(&map, &state, &input.evidence_digest)));
1016 Ok(report)
1017}
1018
1019fn read_report_cache(path: &Path, key: &str) -> Option<Value> {
1020 if fs::metadata(path).ok()?.len() > 256 * 1024 * 1024 {
1021 return None;
1022 }
1023 let cached: Value = serde_json::from_slice(&fs::read(path).ok()?).ok()?;
1024 let report = &cached["report"];
1025 (cached["key"] == key
1026 && cached["digest"] == digest(report)
1027 && report["summary"].is_object()
1028 && report["assertions"].is_array())
1029 .then(|| report.clone())
1030}
1031
1032pub fn assess(
1033 map: &AssertionMap,
1034 state: &State,
1035 inputs: &Inputs,
1036 coverage: &CoverageReport,
1037 passed: bool,
1038) -> Value {
1039 assess_with(map, state, inputs, coverage, passed, true)
1040}
1041
1042pub fn assess_summary(
1049 map: &AssertionMap,
1050 state: &State,
1051 inputs: &Inputs,
1052 coverage: &CoverageReport,
1053 passed: bool,
1054) -> Value {
1055 assess_with(map, state, inputs, coverage, passed, false)
1056}
1057
1058fn assess_with(
1059 map: &AssertionMap,
1060 state: &State,
1061 inputs: &Inputs,
1062 coverage: &CoverageReport,
1063 passed: bool,
1064 detail: bool,
1065) -> Value {
1066 let manifest = inputs.manifest();
1067 let lines = crate::assertion_map::LineIndex::new(&inputs.files);
1070 let ledger = model::Ledger::new(map, state, &manifest);
1071 let validation = model::validation(map, state, inputs);
1072 let errors = validation["errors"]
1073 .as_array()
1074 .unwrap()
1075 .iter()
1076 .map(|e| e.as_str().unwrap().to_owned())
1077 .collect::<Vec<_>>();
1078 let pending_changes = validation["changes"]
1079 .as_array()
1080 .unwrap()
1081 .iter()
1082 .filter(|c| c["current"] != true)
1083 .count();
1084 let view = &coverage.filters.passed;
1085 let tests = view
1086 .tests
1087 .iter()
1088 .filter(|t| t.role == "test")
1089 .map(|t| (t.id.as_str(), t))
1090 .collect::<BTreeMap<_, _>>();
1091 let measured_statements = view
1092 .points
1093 .iter()
1094 .filter(|p| p.measured && p.meta.kind == crate::coverage_analysis::PointKind::Statement)
1095 .collect::<Vec<_>>();
1096 let all_points = coverage
1097 .view
1098 .points
1099 .iter()
1100 .map(|p| (&p.meta.id, p))
1101 .collect::<BTreeMap<_, _>>();
1102 let all_tests = coverage
1103 .view
1104 .tests
1105 .iter()
1106 .map(|t| (t.id.as_str(), t))
1107 .collect::<BTreeMap<_, _>>();
1108 let mut by_file = BTreeMap::<&str, Vec<(usize, &crate::coverage_report::PointResult)>>::new();
1109 let mut by_line = BTreeMap::<(String, usize), Vec<&crate::coverage_report::PointResult>>::new();
1110 for point in &measured_statements {
1111 by_line
1112 .entry((point.meta.file.clone(), point.meta.line))
1113 .or_default()
1114 .push(point);
1115 if let Some((text, starts)) = lines.get(&point.meta.file)
1116 && let Some(column) = byte_column_in(
1117 text,
1118 starts,
1119 point.meta.line,
1120 point.meta.column,
1121 &inputs.language,
1122 )
1123 {
1124 let at = Anchor {
1125 file: point.meta.file.clone(),
1126 line: point.meta.line,
1127 column,
1128 text: point.meta.source.clone(),
1129 };
1130 if let Some(start) = at.offset_in(text, starts) {
1131 by_file
1132 .entry(&point.meta.file)
1133 .or_default()
1134 .push((start, point));
1135 }
1136 }
1137 }
1138 for points in by_file.values_mut() {
1139 points.sort_by_key(|(pos, _)| *pos);
1140 }
1141 let mut rows = Vec::new();
1142 let mut claimed_points = BTreeSet::new();
1143 let mut credited_points = BTreeSet::new();
1144 let mut point_flows = BTreeMap::<String, BTreeSet<String>>::new();
1145 let mut draft_flows = 0;
1146 let mut stale_flows = 0;
1147 let mut invalid_flows = 0;
1148 let mut current_flows = 0;
1149 let mut credit_flows = 0;
1150 let mut assertions_without_current_explanation = 0;
1151 let mut line_assertions = BTreeMap::<(String, usize), BTreeSet<String>>::new();
1152 let identities_valid = state.inputs_digest == inputs.identity()
1155 && errors.iter().all(|e| {
1156 !e.contains("duplicate") && !e.contains("schema") && !e.contains("assertion ID")
1157 });
1158 let mut phases_by_location = BTreeMap::new();
1161 for p in &view.phases {
1162 if !tests.contains_key(p.test.as_str()) {
1163 continue;
1164 }
1165 if let Some(location) = phase_location(&p.phase, inputs) {
1166 phases_by_location
1167 .entry(location)
1168 .or_insert_with(Vec::new)
1169 .push(p);
1170 }
1171 }
1172 let mut inventory = BTreeMap::<&Anchor, BTreeSet<&str>>::new();
1173 for site in &inputs.assertions {
1174 inventory
1175 .entry(&site.at)
1176 .or_default()
1177 .insert(&site.operation);
1178 }
1179 let mapped_anchors = map
1180 .assertions
1181 .iter()
1182 .map(|a| &a.at)
1183 .collect::<BTreeSet<_>>();
1184 let mut used_ids = map
1185 .assertions
1186 .iter()
1187 .map(|a| a.id.clone())
1188 .chain(
1189 map.retired_assertions
1190 .iter()
1191 .map(|r| r.assertion.id.clone()),
1192 )
1193 .collect::<BTreeSet<_>>();
1194 let missing = seed(inputs, "")
1195 .0
1196 .assertions
1197 .into_iter()
1198 .filter(|a| !mapped_anchors.contains(&a.at))
1199 .map(|mut a| {
1200 while !used_ids.insert(a.id.clone()) {
1201 a.id.push('_');
1202 }
1203 a
1204 })
1205 .collect::<Vec<_>>();
1206 for (a, in_map) in map
1207 .assertions
1208 .iter()
1209 .map(|a| (a, true))
1210 .chain(missing.iter().map(|a| (a, false)))
1211 {
1212 let witnesses = phases_by_location
1213 .get(&(a.at.file.as_str(), a.at.line, a.at.column))
1214 .into_iter()
1215 .flatten()
1216 .filter(|p| {
1219 inputs.language != "javascript"
1220 || inventory
1221 .get(&a.at)
1222 .is_some_and(|operations| operations.contains(p.phase.operation.as_str()))
1223 })
1224 .map(|p| p.test.clone())
1225 .collect::<BTreeSet<_>>();
1226 let mut flows = Vec::new();
1227 for f in &a.flows {
1228 let dirty = model::reasons_with(a, f, state, inputs, &manifest, &ledger);
1229 let valid = model::validate_flow(f, &inputs.files).is_empty()
1230 && a.at.offset(&inputs.files).is_some();
1231 let freshness = if f.basis.is_none() {
1232 draft_flows += 1;
1233 "draft"
1234 } else if f.basis.as_deref()
1235 != Some(model::expected_basis_with(a, f, state, &manifest, &ledger).as_str())
1236 {
1237 stale_flows += 1;
1238 "stale"
1239 } else {
1240 "current"
1241 };
1242 if !valid {
1243 invalid_flows += 1;
1244 }
1245 if dirty.is_empty() {
1246 current_flows += 1;
1247 }
1248 let mut resolved = BTreeSet::new();
1251 let mut selectors = Vec::new();
1252 for selector in &f.applies_to {
1253 let matches = coverage
1254 .view
1255 .tests
1256 .iter()
1257 .filter(|t| {
1258 t.file.as_deref() == Some(selector.file.as_str()) && t.name == selector.name
1259 })
1260 .collect::<Vec<_>>();
1261 let status = match matches.as_slice() {
1262 [test]
1263 if witnesses.contains(test.id.as_str())
1264 && tests.contains_key(test.id.as_str()) =>
1265 {
1266 resolved.insert(test.id.clone());
1267 "observed"
1268 }
1269 [] => "missing",
1270 [_] => "unobserved",
1271 _ => "ambiguous",
1272 };
1273 selectors.push(json!({"file":selector.file,"name":selector.name,"status":status,
1274 "outcomes":matches.iter().map(|t| &t.outcome).collect::<Vec<_>>(),
1275 "reason":match matches.as_slice() {
1276 [] => "No test execution record matches this selector. The test may be disabled or outside this run.",
1277 [test] if test.outcome != "passed" => "The selected test has no passing outcome.",
1278 [_] if status == "unobserved" => "The test passed but this assertion occurrence was not recorded. Its branch may not have run, or attribution may be missing.",
1279 [_] => "A passing assertion occurrence matches this test.",
1280 _ => "Multiple test identities match this selector."
1281 }}));
1282 }
1283 let applicable = resolved;
1284 let eligible = passed && identities_valid && dirty.is_empty() && !applicable.is_empty();
1285 let mut blockers = Vec::new();
1286 if !passed {
1287 blockers.push("run did not pass");
1288 }
1289 if !identities_valid {
1290 blockers.push("invalid map identities");
1291 }
1292 if !dirty.is_empty() {
1293 blockers.push("flow requires review or reference repair");
1294 }
1295 if applicable.is_empty() {
1296 blockers.push("no matching passing assertion occurrence for appliesTo");
1297 }
1298 if eligible {
1299 credit_flows += 1;
1300 }
1301 let mut lines = BTreeSet::new();
1302 let mut node_credit = Vec::new();
1303 for node in &f.nodes {
1304 let claimed = f.counts_as_asserted.contains(&node.id);
1305 let start = node.at.offset(&inputs.files);
1306 let mut matched = Vec::new();
1307 if let Some(start) = start
1308 && let Some(points) = by_file.get(node.at.file.as_str())
1309 {
1310 let first = points.partition_point(|(pos, _)| *pos < start);
1311 for (_, point) in points[first..].iter().take_while(|(pos, _)| *pos == start) {
1312 if point.meta.source == node.at.text {
1315 matched.push(*point);
1316 }
1317 }
1318 }
1319 let matching_tests = matched
1320 .iter()
1321 .filter(|p| p.covered)
1322 .flat_map(|p| p.tests.iter())
1323 .filter(|test| applicable.contains(test.as_str()))
1324 .collect::<BTreeSet<_>>();
1325 let credited = claimed && eligible && !matching_tests.is_empty();
1326 let mut reasons = Vec::new();
1327 let mut reason = |code: &str, message: String| {
1328 reasons.push(json!({"code":code,"message":message}));
1329 };
1330 if !claimed {
1331 reason(
1332 "context_only",
1333 "The agent included this node as context, not in countsAsAsserted.".into(),
1334 );
1335 } else if credited {
1336 reason("same_test_execution", "Current agent claim, passing assertion, and statement execution in the same selected test.".into());
1337 } else {
1338 if start.is_none() {
1339 reason(
1340 "invalid_source_anchor",
1341 "The node's source anchor does not match the run's current source."
1342 .into(),
1343 );
1344 } else if matched.is_empty() {
1345 reason("no_measured_statement", "The node does not exactly identify a measured production statement in this run.".into());
1346 }
1347 if !passed {
1348 reason("run_failed", "The test run did not pass.".into());
1349 }
1350 if !identities_valid {
1351 reason("invalid_map_identity", "Map identities or managed input state are invalid; see validation errors.".into());
1352 }
1353 if !dirty.is_empty() {
1354 reason(
1355 "flow_needs_attention",
1356 format!(
1357 "Flow needs investigation: {}",
1358 dirty.iter().cloned().collect::<Vec<_>>().join("; ")
1359 ),
1360 );
1361 }
1362 if applicable.is_empty() {
1363 reason("no_passing_assertion", "No selected test has a matching passing occurrence of this assertion; see flow selectors.".into());
1364 }
1365 if !matched.is_empty() {
1366 if !matched.iter().any(|p| p.covered) {
1367 let executed = matched
1368 .iter()
1369 .filter_map(|p| all_points.get(&p.meta.id))
1370 .filter(|p| p.covered)
1371 .collect::<Vec<_>>();
1372 if !executed.is_empty() {
1373 let setup = executed.iter().flat_map(|p| &p.tests).any(|id| {
1374 all_tests
1375 .get(id.as_str())
1376 .is_some_and(|t| t.role == "setup")
1377 });
1378 reason(if setup {"shared_setup_execution"} else {"execution_outside_passing_tests"},
1379 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());
1380 }
1381 reason(
1382 "no_passing_execution",
1383 "No execution evidence from passing tests for this statement."
1384 .into(),
1385 );
1386 } else if !applicable.is_empty() && matching_tests.is_empty() {
1387 if matched.iter().flat_map(|p| &p.tests).any(|id| {
1388 all_tests
1389 .get(id.as_str())
1390 .is_some_and(|t| t.role == "setup")
1391 }) {
1392 reason("shared_setup_execution", "Execution was recorded in a separate setup scope. Shared setup is not automatically credited to consuming tests.".into());
1393 }
1394 reason("no_same_test_execution", "No execution evidence attributed to a selected passing test for this assertion.".into());
1395 }
1396 }
1397 }
1398 node_credit.push(json!({
1399 "nodeId":node.id,
1400 "location":{"file":node.at.file,"line":node.at.line,"column":node.at.column},
1401 "status":if !claimed {"context"} else if credited {"credited"} else {"notCredited"},
1402 "statementIds":matched.iter().map(|p| &p.meta.id).collect::<Vec<_>>(),
1403 "matchingTests":matching_tests,
1404 "reasons":reasons,
1405 }));
1406 for point in matched.into_iter().filter(|_| claimed) {
1407 claimed_points.insert(point.meta.id.clone());
1408 if eligible
1409 && point.covered
1410 && point.tests.iter().any(|t| applicable.contains(t.as_str()))
1411 {
1412 credited_points.insert(point.meta.id.clone());
1413 point_flows
1414 .entry(point.meta.id.clone())
1415 .or_default()
1416 .insert(flow_key(a, f));
1417 lines.insert((node.at.file.clone(), point.meta.line));
1418 line_assertions
1419 .entry((node.at.file.clone(), point.meta.line))
1420 .or_default()
1421 .insert(a.id.clone());
1422 }
1423 }
1424 }
1425 let notices = state
1426 .flows
1427 .get(&flow_key(a, f))
1428 .map(|s| s.notices.clone())
1429 .unwrap_or_default();
1430 let exposed_to = state
1431 .changes
1432 .iter()
1433 .filter(|c| c.exposed.contains(&flow_key(a, f)))
1434 .map(|c| c.id.clone())
1435 .collect::<Vec<_>>();
1436 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}));
1437 }
1438 let observation = if !witnesses.is_empty() {
1439 "Passing assertion occurrence recorded."
1440 } else if flows
1441 .iter()
1442 .flat_map(|f| f["selectors"].as_array().into_iter().flatten())
1443 .any(|s| s["status"] == "missing")
1444 {
1445 "No passing occurrence. Some selected tests have no execution record (for example disabled tests or tests outside this run)."
1446 } else if flows
1447 .iter()
1448 .flat_map(|f| f["selectors"].as_array().into_iter().flatten())
1449 .any(|s| {
1450 s["outcomes"]
1451 .as_array()
1452 .is_some_and(|outcomes| outcomes.iter().any(|o| o == "skipped"))
1453 })
1454 {
1455 "No passing occurrence. Selected tests include skipped/TODO executions."
1456 } else {
1457 "No passing occurrence recorded. The assertion may be in an untaken branch or its execution attribution may be missing; inspect the selected tests."
1458 };
1459 if inventory.contains_key(&a.at)
1463 && !witnesses.is_empty()
1464 && !flows.iter().any(|f| f["eligible"] == true)
1465 {
1466 assertions_without_current_explanation += 1;
1467 }
1468 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}));
1469 }
1470 let denominator = coverage
1478 .view
1479 .lines
1480 .iter()
1481 .filter(|l| l.measured)
1482 .map(|l| (l.file.clone(), l.line))
1483 .filter(|location| by_line.contains_key(location))
1484 .collect::<BTreeSet<_>>();
1485 let mut credited = BTreeSet::new();
1486 let mut declared = BTreeSet::new();
1487 for location in &denominator {
1488 let statements = by_line.get(location).map(Vec::as_slice).unwrap_or(&[]);
1489 if !statements.is_empty()
1490 && statements
1491 .iter()
1492 .all(|p| claimed_points.contains(&p.meta.id))
1493 {
1494 declared.insert(location.clone());
1495 }
1496 if !statements.is_empty()
1497 && statements
1498 .iter()
1499 .all(|p| credited_points.contains(&p.meta.id))
1500 {
1501 credited.insert(location.clone());
1502 }
1503 }
1504 let missing_inventory = inputs
1505 .assertions
1506 .iter()
1507 .filter(|s| !mapped_anchors.contains(&s.at))
1508 .count();
1509 let (status, reason) = if !passed || !identities_valid {
1510 ("unavailable", "Run failed or map identities are invalid")
1511 } else if pending_changes > 0 {
1512 ("pending", "Source changes need impact assessment")
1513 } else if measured_statements.is_empty() {
1514 ("notApplicable", "No measured statements")
1515 } else if credit_flows > 0 {
1516 (
1517 "available",
1518 "Agent-assessed statements; mapping completeness is unknown",
1519 )
1520 } else if map.assertions.iter().all(|a| a.flows.is_empty()) {
1521 ("notAssessed", "No recorded flow explanations")
1522 } else {
1523 (
1524 "pending",
1525 "No current flow with matching passing assertion evidence",
1526 )
1527 };
1528 let total = denominator.len();
1529 let located = measured_statements
1533 .iter()
1534 .map(|p| {
1535 let (text, starts) = lines.get(&p.meta.file)?;
1536 let column =
1537 byte_column_in(text, starts, p.meta.line, p.meta.column, &inputs.language)?;
1538 crate::assertion_map::locate(
1539 &p.meta.file,
1540 p.meta.line,
1541 column,
1542 &p.meta.source,
1543 text,
1544 starts,
1545 )
1546 .map(|_| column)
1547 })
1548 .collect::<Vec<_>>();
1549 let unanchored = located.iter().filter(|column| column.is_none()).count();
1550 let statements = if !detail {
1551 Vec::new()
1552 } else {
1553 measured_statements.iter().zip(&located).map(|(p, column)| {
1554 let at = column.map(|column| Anchor { file: p.meta.file.clone(), line: p.meta.line, column, text: p.meta.source.clone() });
1555 let all = all_points.get(&p.meta.id);
1556 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(),
1557 "executionEvidence":{"anyExecution":all.is_some_and(|p| p.covered),"passingTests":p.tests.iter().filter(|id| tests.contains_key(id.as_str())).collect::<Vec<_>>(),
1558 "outsidePassingTests":all.into_iter().flat_map(|p| &p.tests).filter(|id| !tests.contains_key(id.as_str())).collect::<Vec<_>>()}})
1559 }).collect::<Vec<_>>()
1560 };
1561 json!({"basis":"agent-assessed; passing assertion identity and same-test execution required; not mutation resistance",
1562 "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,
1563 "assertionsWithFlows":rows.iter().filter(|a| a["flows"].as_array().is_some_and(|f| !f.is_empty())).count(),
1564 "assertionsWithoutFlows":rows.iter().filter(|a| a["flows"].as_array().is_none_or(Vec::is_empty)).count(),
1565 "observedAssertionsWithoutCurrentExplanation":assertions_without_current_explanation,
1566 "questions":map.assertions.iter().map(|a| a.questions.len()+a.flows.iter().map(|f| f.questions.len()).sum::<usize>()).sum::<usize>(),
1567 "currentFlows":current_flows,"draftFlows":draft_flows,"staleFlows":stale_flows,"invalidFlows":invalid_flows,"eligibleFlows":credit_flows,"retiredAssertions":map.retired_assertions.len(),
1568 "unobservedAssertions":rows.iter().filter(|a| a["observedPassingTests"].as_array().is_none_or(Vec::is_empty)).count(),
1569 "inventoryFailures":inputs.limitations.iter().filter(|s| s.starts_with("Inventory unavailable for ")).count(),
1570 "unanchoredStatements":unanchored,
1571 "runPassed":passed,
1572 "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)}}},
1573 "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,
1574 "hasExecutionEvidence":!t.hits.is_empty() || !t.decisions.is_empty() || !t.lines.is_empty()})).collect::<Vec<_>>(),
1575 "creditedLines":if !detail { Vec::new() } else { credited.iter().map(|loc| json!({"file":loc.0,"line":loc.1,"assertions":line_assertions.get(loc)})).collect::<Vec<_>>() },
1576 "unassertedLines":if !detail { Vec::new() } else { denominator.difference(&credited).map(|(f,l)| json!({"file":f,"line":l})).collect::<Vec<_>>() },
1577 "changes":validation["changes"],"validationErrors":errors,"advisories":model::advisories(map),"limitations":inputs.limitations})
1578}
1579
1580#[cfg(test)]
1581mod tests {
1582 use super::*;
1583 use crate::evidence_archive::read_archive;
1584 fn fingerprint() -> RunFingerprint {
1585 RunFingerprint {
1586 algorithm: "sha256".into(),
1587 source: "source".into(),
1588 tests: "tests".into(),
1589 dependencies: "dependencies".into(),
1590 configuration: "configuration".into(),
1591 instrumenter: "instrumenter".into(),
1592 execution: "execution".into(),
1593 combined: "combined".into(),
1594 source_files: 1,
1595 test_files: 1,
1596 }
1597 }
1598
1599 fn reference_byte_column(
1601 source: &str,
1602 line: usize,
1603 column: usize,
1604 language: &str,
1605 ) -> Option<usize> {
1606 if line == 0 {
1607 return None;
1608 }
1609 let line = source.lines().nth(line - 1)?;
1610 if language != "javascript" {
1611 return column.checked_add(1);
1612 }
1613 if column == 0 {
1614 return None;
1615 }
1616 let mut units = 0;
1617 for (byte, ch) in line.char_indices() {
1618 if units == column - 1 {
1619 return Some(byte + 1);
1620 }
1621 units += ch.len_utf16();
1622 }
1623 (units == column - 1).then_some(line.len() + 1)
1624 }
1625
1626 #[test]
1627 fn a_column_from_line_starts_agrees_with_walking_the_file() {
1628 for source in [
1631 "",
1632 "a",
1633 "a\n",
1634 "a\r\nb\r\n",
1635 "x\r",
1636 "héllo\nwörld\n",
1637 "😀x\ny😀z\n",
1638 "const a = 1;\n b();\n",
1639 ] {
1640 let starts = crate::assertion_map::line_starts(source);
1641 for language in ["javascript", "python"] {
1642 for line in 0..source.len() + 3 {
1643 for column in 0..source.len() + 4 {
1644 assert_eq!(
1645 byte_column_in(source, &starts, line, column, language),
1646 reference_byte_column(source, line, column, language),
1647 "{language} {line}:{column} in {source:?}"
1648 );
1649 }
1650 }
1651 }
1652 }
1653 }
1654
1655 #[test]
1656 fn upgrading_supercov_does_not_make_every_map_stale() {
1657 let before = fingerprint();
1662 let mut after = fingerprint();
1663 after.instrumenter = "a new supercov".into();
1664 let delta = context_delta(&before, &after, "context", "context");
1665 assert!(!delta.execution);
1666 assert!(!delta.dependencies);
1667 }
1668
1669 #[test]
1670 fn a_dependency_upgrade_is_reported_without_invalidating_anything() {
1671 let before = fingerprint();
1675 let mut after = fingerprint();
1676 after.dependencies = "an upgraded lockfile".into();
1677 let delta = context_delta(&before, &after, "context", "context");
1678 assert!(delta.dependencies);
1679 assert!(!delta.execution, "an upgrade must not invalidate flows");
1680 }
1681
1682 #[test]
1683 fn what_actually_executes_still_invalidates_every_flow() {
1684 let before = fingerprint();
1687 let mut after = fingerprint();
1688 after.configuration = "a different tsconfig".into();
1689 assert!(context_delta(&before, &after, "context", "context").execution);
1690 assert!(
1691 context_delta(&before, &fingerprint(), "context", "another context").execution,
1692 "declared context variables still count"
1693 );
1694 assert!(!context_delta(&before, &fingerprint(), "context", "context").execution);
1695 }
1696
1697 #[test]
1698 fn report_cache_requires_exact_revision_and_intact_payload() {
1699 let directory =
1700 std::env::temp_dir().join(format!("supercov-report-cache-{}", std::process::id()));
1701 fs::create_dir_all(&directory).unwrap();
1702 let path = directory.join(REPORT_CACHE_FILE);
1703 let report = json!({"summary":{"statements":{"asserted":4,"percentage":97.02842377260981}},"assertions":[]});
1704 let mut cache = json!({"key":"revision-one","digest":digest(&report),"report":report});
1705 fs::write(&path, serde_json::to_vec(&cache).unwrap()).unwrap();
1706 assert_eq!(read_report_cache(&path, "revision-one"), Some(report));
1707 assert!(read_report_cache(&path, "revision-two").is_none());
1708 cache["report"]["summary"]["statements"]["asserted"] = json!(100);
1709 fs::write(&path, serde_json::to_vec(&cache).unwrap()).unwrap();
1710 assert!(read_report_cache(&path, "revision-one").is_none());
1711 fs::write(&path, "interrupted write").unwrap();
1712 assert!(read_report_cache(&path, "revision-one").is_none());
1713 fs::remove_dir_all(directory).unwrap();
1714 }
1715 use crate::evidence_archive::{EvidenceArchiveEntry, write_archive};
1716
1717 #[test]
1721 fn the_summary_written_at_publication_is_the_one_a_query_computes() {
1722 let root = std::env::temp_dir().join(format!(
1723 "supercov-summary-{}-{}",
1724 std::process::id(),
1725 std::time::SystemTime::now()
1726 .duration_since(std::time::UNIX_EPOCH)
1727 .unwrap()
1728 .as_nanos()
1729 ));
1730 fs::create_dir_all(root.join("src")).unwrap();
1731 fs::create_dir_all(root.join("tests")).unwrap();
1732 fs::write(
1733 root.join("src/app.js"),
1734 "function work() {\n return 1;\n}\nfunction idle() {\n return 2;\n}\n",
1735 )
1736 .unwrap();
1737 fs::write(
1738 root.join("tests/app.test.js"),
1739 "import assert from 'node:assert/strict';\nassert.equal(work(), 1);\n",
1740 )
1741 .unwrap();
1742 let inputs = crate::assertion_inputs::capture(
1743 &root,
1744 "python",
1745 ["src/app.js".into(), "tests/app.test.js".into()],
1746 )
1747 .unwrap();
1748 let directory = crate::run_store::create_analyzable_test_run(&root, "first");
1749 let path = directory.join("evidence.raw.gz");
1750 let entries =
1751 crate::assertion_inputs::append(read_archive(&path).unwrap(), &inputs).unwrap();
1752 let archive = write_archive(entries, &path).unwrap();
1753 let metadata_path = directory.join("run.json");
1754 let mut metadata: RunMetadata =
1755 serde_json::from_slice(&fs::read(&metadata_path).unwrap()).unwrap();
1756 metadata.raw_evidence.files = archive.files;
1757 metadata.raw_evidence.compressed_bytes = archive.compressed_bytes;
1758 metadata.raw_evidence.uncompressed_bytes = archive.uncompressed_bytes;
1759 fs::write(&metadata_path, serde_json::to_vec(&metadata).unwrap()).unwrap();
1760 let run = discover_runs(&root).unwrap().runs.remove(0);
1761 let analysed = crate::run_store::analyze_stored_run(&run).unwrap();
1762 let mut own = coverage(&run).unwrap();
1766 for (view, published) in [
1767 (&mut own.view, &analysed.view),
1768 (&mut own.filters.passed, &analysed.filters.passed),
1769 (&mut own.filters.failed, &analysed.filters.failed),
1770 ] {
1771 assert!(view.integrity.is_none() && published.integrity.is_some());
1772 view.integrity = published.integrity.clone();
1773 }
1774 assert_eq!(own, analysed);
1775 prepare_publication(&root, &directory, &metadata, Some(&analysed)).unwrap();
1776 let files = [MAP_FILE, STATE_FILE, SUMMARY_CACHE_FILE];
1779 let from_archive = files.map(|file| fs::read(directory.join(file)).unwrap());
1780 for file in files {
1781 fs::remove_file(directory.join(file)).unwrap();
1782 }
1783 let digest = format!("{:x}", Sha256::digest(fs::read(&path).unwrap()));
1784 prepare_publication_with(
1785 &root,
1786 &directory,
1787 &metadata,
1788 Some(&analysed),
1789 Some(archived_manifest(inputs.manifest(), digest).unwrap()),
1790 )
1791 .unwrap();
1792 for (file, expected) in files.iter().zip(&from_archive) {
1793 assert_eq!(
1794 &fs::read(directory.join(file)).unwrap(),
1795 expected,
1796 "{file} is the one the archive gives"
1797 );
1798 }
1799 let cache = directory.join(SUMMARY_CACHE_FILE);
1800 let written = fs::read(&cache).expect("publication wrote the summary");
1801 let summary = report_summary(&root, &run).unwrap();
1802 fs::remove_file(&cache).unwrap();
1803 let computed = report_summary(&root, &run).unwrap();
1804 assert_eq!(fs::read(&cache).unwrap(), written, "the same cache");
1805 assert_eq!(summary, computed, "and the same summary");
1806 fs::write(root.join("src/app.js"), "function work() { return 3; }\n").unwrap();
1807 assert!(
1808 report_summary(&root, &run).is_err(),
1809 "a warmed summary must not bypass current-source validation"
1810 );
1811 fs::remove_dir_all(root).unwrap();
1812 }
1813
1814 #[test]
1817 fn publication_records_what_each_test_ran_and_affected_tests_reads_it() {
1818 let root = std::env::temp_dir().join(format!("supercov-affected-{}", std::process::id()));
1819 let _ = fs::remove_dir_all(&root);
1820 fs::create_dir_all(root.join("src")).unwrap();
1821 fs::create_dir_all(root.join("tests")).unwrap();
1822 let app = "function work() {\n return 1;\n}\nfunction idle() {\n return 2;\n}\n";
1826 let test = "import assert from 'node:assert/strict';\nassert.equal(work(), 1);\n";
1827 fs::write(root.join("src/app.js"), app).unwrap();
1828 fs::write(root.join("tests/app.test.js"), test).unwrap();
1829 let inputs = crate::assertion_inputs::capture(
1833 &root,
1834 "python",
1835 ["src/app.js".into(), "tests/app.test.js".into()],
1836 )
1837 .unwrap();
1838 let directory = crate::run_store::create_analyzable_test_run(&root, "first");
1839 let path = directory.join("evidence.raw.gz");
1840 let entries =
1841 crate::assertion_inputs::append(read_archive(&path).unwrap(), &inputs).unwrap();
1842 let archive = write_archive(entries, &path).unwrap();
1843 let metadata_path = directory.join("run.json");
1844 let mut metadata: RunMetadata =
1845 serde_json::from_slice(&fs::read(&metadata_path).unwrap()).unwrap();
1846 metadata.raw_evidence.files = archive.files;
1847 metadata.raw_evidence.compressed_bytes = archive.compressed_bytes;
1848 metadata.raw_evidence.uncompressed_bytes = archive.uncompressed_bytes;
1849 fs::write(&metadata_path, serde_json::to_vec(&metadata).unwrap()).unwrap();
1850 prepare_publication(&root, &directory, &metadata, None).unwrap();
1851 let run = discover_runs(&root).unwrap().runs.remove(0);
1852 let stored = load_manifest(&run).unwrap();
1853 let (_, state) = load(&run, &stored).unwrap();
1854 let executions = state.executions.as_ref().expect("a record");
1855 let code = stored.manifest.files["src/app.js"].code.as_ref().unwrap();
1856 let work = code.units.iter().position(|u| u.path == "work").unwrap();
1857 let idle = code.units.iter().position(|u| u.path == "idle").unwrap();
1858 assert_eq!(executions.tests.len(), 1);
1859 let record = &executions.tests[0];
1860 assert_eq!(record.test.file, "tests/app.test.js");
1861 assert_eq!(record.test.name, "test");
1862 assert!(record.passed);
1863 assert_eq!(record.files["src/app.js"], vec![work]);
1864 assert_eq!(
1865 executions.probed["src/app.js"],
1866 vec![work],
1867 "idle holds no probe in this run"
1868 );
1869 let _ = idle;
1870
1871 let names = |value: &Value, key: &str| {
1872 value[key]
1873 .as_array()
1874 .unwrap()
1875 .iter()
1876 .map(|t| t["name"].as_str().unwrap().to_owned())
1877 .collect::<Vec<_>>()
1878 };
1879 let report = affected_tests(&root, &run).unwrap();
1881 assert!(names(&report, "affected").is_empty(), "{report}");
1882 assert_eq!(names(&report, "unaffected"), ["test"]);
1883 assert_eq!(report["summary"]["changedFiles"], 0);
1884 fs::write(root.join("src/app.js"), format!("// about\n{app}")).unwrap();
1886 let report = affected_tests(&root, &run).unwrap();
1887 assert!(names(&report, "affected").is_empty(), "{report}");
1888 assert_eq!(report["changedFiles"][0]["change"], "formatting");
1889 fs::write(
1891 root.join("src/app.js"),
1892 app.replace("return 1;", "return 1 + 0;"),
1893 )
1894 .unwrap();
1895 let report = affected_tests(&root, &run).unwrap();
1896 assert_eq!(names(&report, "affected"), ["test"]);
1897 assert_eq!(
1898 report["affected"][0]["reasons"][0],
1899 "src/app.js: work (line 1) changed (this test ran it)"
1900 );
1901 fs::write(
1905 root.join("src/app.js"),
1906 app.replace("return 2;", "return 2 + 0;"),
1907 )
1908 .unwrap();
1909 let report = affected_tests(&root, &run).unwrap();
1910 assert_eq!(names(&report, "affected"), ["test"], "{report}");
1911 assert!(
1912 report["affected"][0]["reasons"][0]
1913 .as_str()
1914 .unwrap()
1915 .contains("this test ran code in this file"),
1916 "{report}"
1917 );
1918 fs::write(
1920 root.join("src/app.js"),
1921 format!("{app}function more() {{}}\n"),
1922 )
1923 .unwrap();
1924 let report = affected_tests(&root, &run).unwrap();
1925 assert_eq!(names(&report, "affected"), ["test"]);
1926 assert_eq!(report["changedFiles"][0]["change"], "declarations");
1927 fs::write(root.join("src/app.js"), app).unwrap();
1929 fs::write(root.join("tests/app.test.js"), test.replace("1)", "2)")).unwrap();
1930 let report = affected_tests(&root, &run).unwrap();
1931 assert_eq!(names(&report, "affected"), ["test"]);
1932 assert!(
1933 report["affected"][0]["reasons"][0]
1934 .as_str()
1935 .unwrap()
1936 .starts_with("test file changed"),
1937 "{report}"
1938 );
1939 fs::write(root.join("tests/app.test.js"), test).unwrap();
1941 fs::remove_file(root.join("src/app.js")).unwrap();
1942 let report = affected_tests(&root, &run).unwrap();
1943 assert_eq!(
1944 report["affected"][0]["reasons"][0],
1945 "src/app.js removed (this test ran code in it)"
1946 );
1947 fs::remove_dir_all(root).unwrap();
1948 }
1949
1950 #[test]
1951 fn a_test_that_ran_beside_others_is_undetermined_rather_than_unaffected() {
1952 let root = std::env::temp_dir().join(format!(
1959 "supercov-undetermined-{}-{}",
1960 std::process::id(),
1961 std::time::SystemTime::now()
1962 .duration_since(std::time::UNIX_EPOCH)
1963 .unwrap()
1964 .as_nanos()
1965 ));
1966 let _ = fs::remove_dir_all(&root);
1967 fs::create_dir_all(root.join("src")).unwrap();
1968 fs::create_dir_all(root.join("tests")).unwrap();
1969 let app = "function work() {\n return 1;\n}\nfunction idle() {\n return 2;\n}\n";
1970 let test = "import assert from 'node:assert/strict';\nassert.equal(work(), 1);\n";
1971 fs::write(root.join("src/app.js"), app).unwrap();
1972 fs::write(root.join("tests/app.test.js"), test).unwrap();
1973 let inputs = crate::assertion_inputs::capture(
1974 &root,
1975 "python",
1976 ["src/app.js".into(), "tests/app.test.js".into()],
1977 )
1978 .unwrap();
1979 let directory = crate::run_store::create_run_wide_test_run(&root, "parallel");
1980 let path = directory.join("evidence.raw.gz");
1981 let entries =
1982 crate::assertion_inputs::append(read_archive(&path).unwrap(), &inputs).unwrap();
1983 let archive = write_archive(entries, &path).unwrap();
1984 let metadata_path = directory.join("run.json");
1985 let mut metadata: RunMetadata =
1986 serde_json::from_slice(&fs::read(&metadata_path).unwrap()).unwrap();
1987 metadata.raw_evidence.files = archive.files;
1988 metadata.raw_evidence.compressed_bytes = archive.compressed_bytes;
1989 metadata.raw_evidence.uncompressed_bytes = archive.uncompressed_bytes;
1990 fs::write(&metadata_path, serde_json::to_vec(&metadata).unwrap()).unwrap();
1991 prepare_publication(&root, &directory, &metadata, None).unwrap();
1992 let run = discover_runs(&root).unwrap().runs.remove(0);
1993
1994 let (_, state) = load(&run, &load_manifest(&run).unwrap()).unwrap();
1995 let executions = state.executions.as_ref().expect("a record");
1996 assert_eq!(executions.tests.len(), 1, "the named test, not the record");
1997 let record = &executions.tests[0];
1998 assert_eq!(
1999 record.attribution,
2000 crate::coverage_report::ATTRIBUTION_RUN_WIDE
2001 );
2002 assert!(
2003 record.files.is_empty(),
2004 "it is credited with nothing: {:?}",
2005 record.files
2006 );
2007 assert!(
2008 !executions.covered["src/app.js"].is_empty(),
2009 "and the run is credited with what it reached"
2010 );
2011
2012 let bucket = |value: &Value, key: &str| {
2013 value[key]
2014 .as_array()
2015 .unwrap()
2016 .iter()
2017 .map(|t| t["name"].as_str().unwrap().to_owned())
2018 .collect::<Vec<_>>()
2019 };
2020
2021 let report = affected_tests(&root, &run).unwrap();
2024 assert_eq!(bucket(&report, "unaffected"), ["test"], "{report}");
2025 assert!(bucket(&report, "undetermined").is_empty(), "{report}");
2026
2027 fs::write(
2030 root.join("src/app.js"),
2031 app.replace("return 1;", "return 3;"),
2032 )
2033 .unwrap();
2034 let report = affected_tests(&root, &run).unwrap();
2035 assert!(
2036 bucket(&report, "affected").is_empty(),
2037 "nothing proves it ran the change: {report}"
2038 );
2039 assert_eq!(
2040 bucket(&report, "undetermined"),
2041 ["test"],
2042 "the run reached it, so this test might have: {report}"
2043 );
2044 assert_eq!(
2045 report["undetermined"][0]["attribution"],
2046 crate::coverage_report::ATTRIBUTION_RUN_WIDE,
2047 "{report}"
2048 );
2049 fs::remove_dir_all(root).unwrap();
2050 }
2051
2052 #[test]
2053 fn a_lower_bound_still_proves_what_it_recorded() {
2054 let root = std::env::temp_dir().join(format!(
2061 "supercov-partial-{}-{}",
2062 std::process::id(),
2063 std::time::SystemTime::now()
2064 .duration_since(std::time::UNIX_EPOCH)
2065 .unwrap()
2066 .as_nanos()
2067 ));
2068 let _ = fs::remove_dir_all(&root);
2069 fs::create_dir_all(root.join("src")).unwrap();
2070 fs::create_dir_all(root.join("tests")).unwrap();
2071 let app = "function work() {\n return 1;\n}\nfunction idle() {\n return 2;\n}\n";
2072 let test = "import assert from 'node:assert/strict';\nassert.equal(work(), 1);\n";
2073 fs::write(root.join("src/app.js"), app).unwrap();
2074 fs::write(root.join("tests/app.test.js"), test).unwrap();
2075 let inputs = crate::assertion_inputs::capture(
2076 &root,
2077 "python",
2078 ["src/app.js".into(), "tests/app.test.js".into()],
2079 )
2080 .unwrap();
2081 let directory = crate::run_store::create_partial_test_run(&root, "partial");
2082 let path = directory.join("evidence.raw.gz");
2083 let entries =
2084 crate::assertion_inputs::append(read_archive(&path).unwrap(), &inputs).unwrap();
2085 let archive = write_archive(entries, &path).unwrap();
2086 let metadata_path = directory.join("run.json");
2087 let mut metadata: RunMetadata =
2088 serde_json::from_slice(&fs::read(&metadata_path).unwrap()).unwrap();
2089 metadata.raw_evidence.files = archive.files;
2090 metadata.raw_evidence.compressed_bytes = archive.compressed_bytes;
2091 metadata.raw_evidence.uncompressed_bytes = archive.uncompressed_bytes;
2092 fs::write(&metadata_path, serde_json::to_vec(&metadata).unwrap()).unwrap();
2093 prepare_publication(&root, &directory, &metadata, None).unwrap();
2094 let run = discover_runs(&root).unwrap().runs.remove(0);
2095
2096 let (_, state) = load(&run, &load_manifest(&run).unwrap()).unwrap();
2097 let record = &state.executions.as_ref().expect("a record").tests[0];
2098 assert_eq!(
2099 record.attribution,
2100 crate::coverage_report::ATTRIBUTION_PARTIAL
2101 );
2102 assert!(
2103 !record.files.is_empty(),
2104 "a lower bound is coverage it was credited with"
2105 );
2106
2107 let names = |value: &Value, key: &str| {
2108 value[key]
2109 .as_array()
2110 .unwrap()
2111 .iter()
2112 .map(|t| t["name"].as_str().unwrap().to_owned())
2113 .collect::<Vec<_>>()
2114 };
2115 fs::write(
2116 root.join("src/app.js"),
2117 app.replace("return 1;", "return 3;"),
2118 )
2119 .unwrap();
2120 let report = affected_tests(&root, &run).unwrap();
2121 assert_eq!(
2122 names(&report, "affected"),
2123 ["test"],
2124 "what its own record proves, it proves: {report}"
2125 );
2126 assert!(names(&report, "undetermined").is_empty(), "{report}");
2127 let reasons = report["affected"][0]["reasons"].as_array().unwrap();
2129 assert_eq!(reasons.len(), 1, "{report}");
2130 let reason = reasons[0].as_str().unwrap();
2131 assert!(
2132 reason.contains("this test ran it"),
2133 "the reason is its own: {report}"
2134 );
2135 assert!(
2136 !reason.contains("the run covered"),
2137 "and not the run's stand-in: {report}"
2138 );
2139 fs::remove_dir_all(root).unwrap();
2140 }
2141
2142 #[test]
2143 fn legacy_maps_import_without_the_old_checkout_and_require_review() {
2144 let root = std::env::temp_dir().join(format!("supercov-legacy-map-{}", std::process::id()));
2145 fs::create_dir_all(&root).unwrap();
2146 let source = "import assert from 'node:assert/strict'; assert.equal(1, 1);\n";
2147 fs::write(root.join("test.js"), source).unwrap();
2148 let old =
2149 crate::assertion_inputs::capture(&root, "javascript", ["test.js".into()]).unwrap();
2150 let directory = crate::run_store::create_analyzable_test_run(&root, "legacy");
2151 let path = directory.join("evidence.raw.gz");
2152 let mut entries = read_archive(&path).unwrap();
2153 entries.push(EvidenceArchiveEntry {
2154 path: ARCHIVE_PATH.into(),
2155 contents: serde_json::to_vec(&old).unwrap(),
2156 });
2157 let archive = write_archive(entries, &path).unwrap();
2158 let metadata_path = directory.join("run.json");
2159 let mut metadata: RunMetadata =
2160 serde_json::from_slice(&fs::read(&metadata_path).unwrap()).unwrap();
2161 metadata.raw_evidence.files = archive.files;
2162 metadata.raw_evidence.compressed_bytes = archive.compressed_bytes;
2163 metadata.raw_evidence.uncompressed_bytes = archive.uncompressed_bytes;
2164 fs::write(&metadata_path, serde_json::to_vec(&metadata).unwrap()).unwrap();
2165 let run = discover_runs(&root).unwrap().runs.remove(0);
2166 let stored = load_manifest(&run).unwrap();
2167 let (map, _) = seed(&old, &stored.evidence_digest);
2168 let legacy_map = json!({"schemaVersion":1,"assertions":[{"id":map.assertions[0].id,"at":map.assertions[0].at,"analysis":"mapped","observes":[],"flows":[{
2169 "id":"constant","explanation":"The assertion checks the constant one.","appliesTo":[],"nodes":[],"edges":[],"countsAsAsserted":[],"watch":[{"kind":"span","at":map.assertions[0].at}]
2170 }]}]});
2171 let legacy_state = json!({"schemaVersion":1,"inputsDigest":digest(&old),"evidenceDigest":stored.evidence_digest,"reviews":{},"scopeReview":[]});
2172 write_json(&root, &run, MAP_FILE, &legacy_map).unwrap();
2173 write_json(&root, &run, STATE_FILE, &legacy_state).unwrap();
2174 let map = model::parse_stored(&serde_json::to_vec(&legacy_map).unwrap()).unwrap();
2175 let map_bytes = fs::read(directory.join(MAP_FILE)).unwrap();
2176 let state_bytes = fs::read(directory.join(STATE_FILE)).unwrap();
2177
2178 fs::write(root.join("test.js"), format!("\n{source}")).unwrap();
2179 assert!(load_inputs(&root, &run).is_err());
2180 let (imported, state) = load(&run, &stored).unwrap();
2181 let new =
2182 crate::assertion_inputs::capture(&root, "javascript", ["test.js".into()]).unwrap();
2183 let (next, state) =
2184 carry(&imported, &state, &stored.manifest, &new, "new-run", false).unwrap();
2185 assert_eq!(next.assertions[0].id, map.assertions[0].id);
2186 assert_eq!(
2187 next.assertions[0].flows[0].explanation,
2188 map.assertions[0].flows[0].explanation
2189 );
2190 assert!(next.assertions[0].flows[0].basis.is_none());
2191 assert!(!next.assertions[0].flows[0].questions.is_empty());
2192 assert_eq!(fs::read(directory.join(MAP_FILE)).unwrap(), map_bytes);
2193 assert_eq!(fs::read(directory.join(STATE_FILE)).unwrap(), state_bytes);
2194
2195 let next_directory = crate::run_store::create_analyzable_test_run(&root, "next");
2198 let mut entries = read_archive(&path).unwrap();
2199 entries
2200 .iter_mut()
2201 .find(|e| e.path == ARCHIVE_PATH)
2202 .unwrap()
2203 .contents = serde_json::to_vec(&old.manifest()).unwrap();
2204 let raw = write_archive(entries, &next_directory.join("evidence.raw.gz")).unwrap();
2205 let mut next_metadata = metadata.clone();
2206 next_metadata.id = "next".into();
2207 next_metadata.started_at = "next".into();
2208 next_metadata.raw_evidence.compressed_bytes = raw.compressed_bytes;
2209 next_metadata.raw_evidence.uncompressed_bytes = raw.uncompressed_bytes;
2210 fs::write(
2211 next_directory.join("run.json"),
2212 serde_json::to_vec(&next_metadata).unwrap(),
2213 )
2214 .unwrap();
2215 fs::remove_file(root.join("test.js")).unwrap();
2216 prepare_publication(&root, &next_directory, &next_metadata, None).unwrap();
2217 let next_run = discover_runs(&root)
2218 .unwrap()
2219 .runs
2220 .into_iter()
2221 .find(|r| r.id == "next")
2222 .unwrap();
2223 let next_manifest = load_manifest(&next_run).unwrap();
2224 let (pending, pending_state) = load(&next_run, &next_manifest).unwrap();
2225 assert_eq!(pending.assertions[0], map.assertions[0]);
2226 assert!(!pending_state.changes.is_empty());
2227 assert!(
2228 !pending_state.flows
2229 [&flow_key(&pending.assertions[0], &pending.assertions[0].flows[0])]
2230 .reasons
2231 .is_empty()
2232 );
2233 assert!(load_inputs(&root, &next_run).is_err());
2234 assert_eq!(fs::read(directory.join(MAP_FILE)).unwrap(), map_bytes);
2235 assert_eq!(fs::read(directory.join(STATE_FILE)).unwrap(), state_bytes);
2236
2237 let mut corrupt = state.clone();
2238 corrupt.evidence_digest = "wrong".into();
2239 write_json(&root, &run, STATE_FILE, &corrupt).unwrap();
2240 assert!(load(&run, &stored).is_err());
2241 fs::remove_dir_all(root).unwrap();
2242 }
2243}