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::{RunMetadata, StoredRun, discover_runs},
11};
12use serde_json::{Value, json};
13use sha2::{Digest, Sha256};
14use std::{
15 collections::{BTreeMap, BTreeSet},
16 fs,
17 path::Path,
18};
19
20pub const MAP_FILE: &str = "assertions.json";
21pub const STATE_FILE: &str = "assertions.state.json";
22pub struct RunManifest {
23 pub manifest: InputManifest,
24 pub evidence_digest: String,
25 legacy_digest: Option<String>,
26}
27pub struct RunInputs {
28 pub inputs: Inputs,
29 pub stored: RunManifest,
30}
31impl std::ops::Deref for RunInputs {
32 type Target = RunManifest;
33 fn deref(&self) -> &RunManifest {
34 &self.stored
35 }
36}
37pub fn load_inputs(root: &Path, run: &StoredRun) -> Result<RunInputs, String> {
38 let stored = load_manifest(run)?;
39 let inputs = crate::assertion_inputs::current_sources(root, &stored.manifest)?;
40 Ok(RunInputs { inputs, stored })
41}
42pub fn load_manifest(run: &StoredRun) -> Result<RunManifest, String> {
43 load_optional_manifest(run)?.ok_or_else(|| {
44 "This older run has no assertion manifest. Run tests once with this version of Supercov"
45 .into()
46 })
47}
48fn load_optional_manifest(run: &StoredRun) -> Result<Option<RunManifest>, String> {
49 if run.metadata.merged == Some(true) {
50 return Err(
51 "Use a single run for assertion maps; merged runs have multiple input manifests".into(),
52 );
53 }
54 let bytes = fs::read(&run.evidence_path).map_err(|e| e.to_string())?;
55 let evidence_digest = format!("{:x}", Sha256::digest(&bytes));
56 let entries = read_archive(&run.evidence_path).map_err(|e| e.to_string())?;
57 let Some(input) = entries.iter().find(|e| e.path == ARCHIVE_PATH) else {
58 return Ok(None);
59 };
60 let value: Value = serde_json::from_slice(&input.contents).map_err(|e| e.to_string())?;
61 let (manifest, legacy_digest) = match value["schemaVersion"].as_u64() {
62 Some(2) => (
63 serde_json::from_value::<InputManifest>(value).map_err(|e| e.to_string())?,
64 None,
65 ),
66 Some(1) => {
67 let legacy: Inputs = serde_json::from_value(value).map_err(|e| e.to_string())?;
70 if legacy
71 .assertions
72 .iter()
73 .any(|s| s.at.offset(&legacy.files).is_none())
74 {
75 return Err("Invalid legacy assertion inputs".into());
76 }
77 (legacy.manifest(), Some(digest(&legacy)))
78 }
79 _ => return Err("Unsupported assertion input schema".into()),
80 };
81 if manifest.files.iter().any(|(p, f)| {
82 !local_path(p) || f.sha256.len() != 64 || !f.sha256.bytes().all(|b| b.is_ascii_hexdigit())
83 }) || manifest.assertions.iter().any(|s| {
84 !manifest.files.contains_key(&s.at.file)
85 || s.at.line == 0
86 || s.at.column == 0
87 || s.at.text.is_empty()
88 }) {
89 return Err("Invalid assertion input manifest".into());
90 }
91 if fs::read(&run.evidence_path).map_err(|e| e.to_string())? != bytes {
92 return Err("Run archive changed during read".into());
93 }
94 Ok(Some(RunManifest {
95 manifest,
96 evidence_digest,
97 legacy_digest,
98 }))
99}
100pub fn load(run: &StoredRun, input: &RunManifest) -> Result<(AssertionMap, State), String> {
101 let read = |file: &str| {
102 fs::read(run.directory.join(file))
103 .map_err(|e| format!("{file}: {e}; new test runs create assertion maps automatically"))
104 };
105 let map = model::parse_stored(&read(MAP_FILE)?).map_err(|e| format!("{MAP_FILE}: {e}"))?;
106 let state = model::parse_state(
107 &read(STATE_FILE)?,
108 &map,
109 &input.manifest,
110 &input.evidence_digest,
111 input.legacy_digest.as_deref(),
112 )?;
113 Ok((map, state))
114}
115fn write_json(
116 root: &Path,
117 run: &StoredRun,
118 name: &str,
119 value: &impl serde::Serialize,
120) -> Result<(), String> {
121 let mut bytes = serde_json::to_vec_pretty(value).map_err(|e| e.to_string())?;
122 bytes.push(b'\n');
123 atomic_write(root, &run.directory.join(name), &bytes).map_err(|e| e.to_string())
124}
125pub(crate) fn prepare_publication(
129 root: &Path,
130 directory: &Path,
131 metadata: &RunMetadata,
132) -> Result<(), String> {
133 if metadata.merged == Some(true) {
134 return Ok(());
135 }
136 let run = StoredRun {
137 id: metadata.id.clone(),
138 directory: directory.into(),
139 evidence_path: directory.join("evidence.raw.gz"),
140 metadata_path: directory.join("run.json"),
141 query_index_path: directory.join(crate::run_store::RUST_QUERY_INDEX_FILE),
142 metadata: metadata.clone(),
143 };
144 let Some(input) = load_optional_manifest(&run)? else {
145 return Ok(());
146 };
147 if directory.join(MAP_FILE).exists() || directory.join(STATE_FILE).exists() {
149 return Err("Refusing to replace an existing assertion map or review state".into());
150 }
151 let current = crate::assertion_inputs::current_sources(root, &input.manifest);
152 let inventory = discover_runs(root).map_err(|e| e.to_string())?;
153 let mut inheritance = Inheritance::default();
154 let mut inherited = None;
155 for previous in &inventory.runs {
156 if previous.id == run.id
157 || previous.metadata.merged == Some(true)
158 || previous.metadata.command != metadata.command
159 || (!previous.directory.join(MAP_FILE).exists()
160 && !previous.directory.join(STATE_FILE).exists())
161 {
162 continue;
163 }
164 let attempt = (|| {
165 let old = load_manifest(previous)?;
166 if old.manifest.language != input.manifest.language {
167 return Ok(None);
168 }
169 let (map, state) = load(previous, &old)?;
170 let a = &previous.metadata.integrity.fingerprint;
171 let b = &metadata.integrity.fingerprint;
172 let context_changed = a.configuration != b.configuration
175 || a.dependencies != b.dependencies
176 || a.instrumenter != b.instrumenter
177 || old.manifest.context_digest != input.manifest.context_digest;
178 let current = match ¤t {
179 Ok(current) => current,
180 Err(reason) => {
181 let (mut next, mut next_state) =
185 seed_manifest(&input.manifest, &input.evidence_digest);
186 next.retired_assertions = map.retired_assertions;
187 for assertion in map.assertions {
188 if let Some(site) =
189 next.assertions.iter_mut().find(|a| a.at == assertion.at)
190 {
191 *site = assertion;
192 } else {
193 next.retired_assertions.push(Retired {
194 assertion,
195 reason: reason.clone(),
196 });
197 }
198 }
199 let mut reserved = next
200 .retired_assertions
201 .iter()
202 .map(|r| r.assertion.id.clone())
203 .chain(
204 next.assertions
205 .iter()
206 .filter(|a| !a.flows.is_empty())
207 .map(|a| a.id.clone()),
208 )
209 .collect::<BTreeSet<_>>();
210 for assertion in next.assertions.iter_mut().filter(|a| a.flows.is_empty()) {
211 while !reserved.insert(assertion.id.clone()) {
212 assertion.id.push('_');
213 }
214 }
215 invalidate(&mut next_state, &next, reason);
216 add_change(
217 &mut next_state,
218 None,
219 None,
220 None,
221 reason.clone(),
222 BTreeSet::new(),
223 );
224 return Ok(Some((next, next_state)));
225 }
226 };
227 model::carry(
228 &map,
229 &state,
230 &old.manifest,
231 current,
232 &input.evidence_digest,
233 context_changed,
234 )
235 .map(Some)
236 })();
237 match attempt {
238 Ok(Some(pair)) => {
239 inheritance.from = Some(previous.id.clone());
240 inherited = Some(pair);
241 break;
242 }
243 Ok(None) => (),
244 Err(reason) => inheritance.skipped.push(SkippedMap {
245 run: previous.id.clone(),
246 reason,
247 }),
248 }
249 }
250 let (map, mut state) =
251 inherited.unwrap_or_else(|| seed_manifest(&input.manifest, &input.evidence_digest));
252 if !inheritance.skipped.is_empty() {
255 invalidate(
256 &mut state,
257 &map,
258 "newer assertion map could not be reused; inspect inherited claims",
259 );
260 }
261 if let Err(reason) = current {
262 invalidate(&mut state, &map, &reason);
263 add_change(&mut state, None, None, None, reason, BTreeSet::new());
264 }
265 state.inheritance = Some(inheritance);
266 write_json(root, &run, MAP_FILE, &map)?;
267 write_json(root, &run, STATE_FILE, &state)?;
268 Ok(())
269}
270pub fn coverage(run: &StoredRun) -> Result<CoverageReport, String> {
271 analyze_coverage_archive(&ArchiveReportRequest {
272 archive_path: run.evidence_path.clone(),
273 run_id: run.id.clone(),
274 generated_at: run.metadata.started_at.clone(),
275 integrity: None,
276 test_exit_code: ExitCodeInput::Present(run.metadata.test_exit_code),
277 })
278 .map_err(|e| format!("{e:?}"))
279}
280fn byte_column(source: &str, line: usize, column: usize, language: &str) -> Option<usize> {
281 if line == 0 {
282 return None;
283 }
284 let line = source.lines().nth(line - 1)?;
285 if language != "javascript" {
286 return column.checked_add(1);
288 }
289 if column == 0 {
290 return None;
291 }
292 let mut units = 0;
293 for (byte, ch) in line.char_indices() {
294 if units == column - 1 {
295 return Some(byte + 1);
296 }
297 units += ch.len_utf16();
298 }
299 (units == column - 1).then_some(line.len() + 1)
300}
301fn phase_location<'a>(
302 phase: &'a crate::coverage_report::CoveragePhase,
303 inputs: &Inputs,
304) -> Option<(&'a str, usize, usize)> {
305 if phase.kind != "assertion" || phase.status.as_deref() != Some("passed") {
306 return None;
307 }
308 let source = phase
309 .operation
310 .strip_prefix("Rust assertion at ")
311 .or(phase.source.as_deref());
312 let location = source?;
313 let mut parts = location.rsplitn(3, ':');
314 let column = parts.next()?.parse::<usize>().ok()?;
315 let line = parts.next()?.parse::<usize>().ok()?;
316 let file = parts.next()?;
317 let column = byte_column(inputs.files.get(file)?, line, column, &inputs.language)?;
318 Some((file, line, column))
319}
320
321pub fn report(root: &Path, run: &StoredRun) -> Result<Value, String> {
324 report_with_detail(root, run, None)
325}
326pub fn assertion(root: &Path, run: &StoredRun, id: &str) -> Result<Value, String> {
328 report_with_detail(root, run, Some(id))
329}
330fn report_with_detail(root: &Path, run: &StoredRun, id: Option<&str>) -> Result<Value, String> {
331 let input = load_inputs(root, run)?;
332 let (map, state) = load(run, &input)?;
333 let coverage = coverage(run)?;
334 let mut report = assess(
335 &map,
336 &state,
337 &input.inputs,
338 &coverage,
339 run.metadata.test_exit_code == Some(0),
340 );
341 if let Some(id) = id {
342 let matches = report["assertions"]
343 .as_array()
344 .unwrap()
345 .iter()
346 .filter(|a| a["id"] == id)
347 .collect::<Vec<_>>();
348 let mut row = match matches.as_slice() {
349 [row] => (*row).clone(),
350 [] => {
351 return Err(format!(
352 "Unknown assertion ID: {id}; use runs {} assertions to list IDs",
353 run.id
354 ));
355 }
356 _ => {
357 return Err(format!(
358 "Ambiguous assertion ID: {id}; repair duplicate IDs in assertions.json"
359 ));
360 }
361 };
362 if let Some(authored) = map.assertions.iter().find(|a| a.id == id) {
363 for (flow, authored) in row["flows"]
364 .as_array_mut()
365 .unwrap()
366 .iter_mut()
367 .zip(&authored.flows)
368 {
369 let assessment = flow.as_object().unwrap().clone();
370 *flow = serde_json::to_value(authored).map_err(|e| e.to_string())?;
371 flow.as_object_mut().unwrap().extend(assessment);
372 }
373 }
374 report["assertion"] = row;
375 }
376 report["inheritance"] = json!(state.inheritance);
377 report["revision"] = json!(digest(&(&map, &state, &input.evidence_digest)));
378 Ok(report)
379}
380
381pub fn assess(
382 map: &AssertionMap,
383 state: &State,
384 inputs: &Inputs,
385 coverage: &CoverageReport,
386 passed: bool,
387) -> Value {
388 let manifest = inputs.manifest();
389 let validation = model::validation(map, state, inputs);
390 let errors = validation["errors"]
391 .as_array()
392 .unwrap()
393 .iter()
394 .map(|e| e.as_str().unwrap().to_owned())
395 .collect::<Vec<_>>();
396 let pending_changes = validation["changes"]
397 .as_array()
398 .unwrap()
399 .iter()
400 .filter(|c| c["current"] != true)
401 .count();
402 let view = &coverage.filters.passed;
403 let tests = view
404 .tests
405 .iter()
406 .map(|t| (&t.id, t))
407 .collect::<BTreeMap<_, _>>();
408 let measured_statements = view
409 .points
410 .iter()
411 .filter(|p| p.measured && p.meta.kind == crate::coverage_analysis::PointKind::Statement)
412 .collect::<Vec<_>>();
413 let mut by_file = BTreeMap::<&str, Vec<(usize, &crate::coverage_report::PointResult)>>::new();
414 let mut by_line = BTreeMap::<(String, usize), Vec<&crate::coverage_report::PointResult>>::new();
415 for point in &measured_statements {
416 by_line
417 .entry((point.meta.file.clone(), point.meta.line))
418 .or_default()
419 .push(point);
420 if let Some(text) = inputs.files.get(&point.meta.file)
421 && let Some(column) =
422 byte_column(text, point.meta.line, point.meta.column, &inputs.language)
423 {
424 let at = Anchor {
425 file: point.meta.file.clone(),
426 line: point.meta.line,
427 column,
428 text: point.meta.source.clone(),
429 };
430 if let Some(start) = at.offset(&inputs.files) {
431 by_file
432 .entry(&point.meta.file)
433 .or_default()
434 .push((start, point));
435 }
436 }
437 }
438 for points in by_file.values_mut() {
439 points.sort_by_key(|(pos, _)| *pos);
440 }
441 let mut rows = Vec::new();
442 let mut claimed_points = BTreeSet::new();
443 let mut credited_points = BTreeSet::new();
444 let mut point_flows = BTreeMap::<String, BTreeSet<String>>::new();
445 let mut draft_flows = 0;
446 let mut stale_flows = 0;
447 let mut invalid_flows = 0;
448 let mut current_flows = 0;
449 let mut credit_flows = 0;
450 let mut line_assertions = BTreeMap::<(String, usize), BTreeSet<String>>::new();
451 let identities_valid = state.inputs_digest == inputs.identity()
454 && errors.iter().all(|e| {
455 !e.contains("duplicate") && !e.contains("schema") && !e.contains("assertion ID")
456 });
457 let mut phases_by_location = BTreeMap::new();
460 for p in &view.phases {
461 if let Some(location) = phase_location(&p.phase, inputs) {
462 phases_by_location
463 .entry(location)
464 .or_insert_with(Vec::new)
465 .push(p);
466 }
467 }
468 let mut inventory = BTreeMap::<&Anchor, BTreeSet<&str>>::new();
469 for site in &inputs.assertions {
470 inventory
471 .entry(&site.at)
472 .or_default()
473 .insert(&site.operation);
474 }
475 let mapped_anchors = map
476 .assertions
477 .iter()
478 .map(|a| &a.at)
479 .collect::<BTreeSet<_>>();
480 let mut used_ids = map
481 .assertions
482 .iter()
483 .map(|a| a.id.clone())
484 .chain(
485 map.retired_assertions
486 .iter()
487 .map(|r| r.assertion.id.clone()),
488 )
489 .collect::<BTreeSet<_>>();
490 let missing = seed(inputs, "")
491 .0
492 .assertions
493 .into_iter()
494 .filter(|a| !mapped_anchors.contains(&a.at))
495 .map(|mut a| {
496 while !used_ids.insert(a.id.clone()) {
497 a.id.push('_');
498 }
499 a
500 })
501 .collect::<Vec<_>>();
502 for (a, in_map) in map
503 .assertions
504 .iter()
505 .map(|a| (a, true))
506 .chain(missing.iter().map(|a| (a, false)))
507 {
508 let witnesses = phases_by_location
509 .get(&(a.at.file.as_str(), a.at.line, a.at.column))
510 .into_iter()
511 .flatten()
512 .filter(|p| {
515 inputs.language != "javascript"
516 || inventory
517 .get(&a.at)
518 .is_some_and(|operations| operations.contains(p.phase.operation.as_str()))
519 })
520 .map(|p| p.test.clone())
521 .collect::<BTreeSet<_>>();
522 let mut flows = Vec::new();
523 for f in &a.flows {
524 let dirty = model::reasons_for_manifest(a, f, map, state, inputs, &manifest);
525 let valid = model::validate_flow(f, &inputs.files).is_empty()
526 && a.at.offset(&inputs.files).is_some();
527 let freshness = if f.basis.is_none() {
528 draft_flows += 1;
529 "draft"
530 } else if f.basis.as_deref()
531 != Some(model::expected_basis(a, f, map, state, &manifest).as_str())
532 {
533 stale_flows += 1;
534 "stale"
535 } else {
536 "current"
537 };
538 if !valid {
539 invalid_flows += 1;
540 }
541 if dirty.is_empty() {
542 current_flows += 1;
543 }
544 let mut resolved = BTreeSet::new();
547 let mut selectors = Vec::new();
548 for selector in &f.applies_to {
549 let matches = coverage
550 .view
551 .tests
552 .iter()
553 .filter(|t| {
554 t.file.as_deref() == Some(selector.file.as_str()) && t.name == selector.name
555 })
556 .collect::<Vec<_>>();
557 let status = match matches.as_slice() {
558 [test] if witnesses.contains(&test.id) && tests.contains_key(&test.id) => {
559 resolved.insert(test.id.clone());
560 "observed"
561 }
562 [] => "missing",
563 [_] => "unobserved",
564 _ => "ambiguous",
565 };
566 selectors.push(json!({"file":selector.file,"name":selector.name,"status":status}));
567 }
568 let applicable = resolved;
569 let eligible = passed && identities_valid && dirty.is_empty() && !applicable.is_empty();
570 let mut blockers = Vec::new();
571 if !passed {
572 blockers.push("run did not pass");
573 }
574 if !identities_valid {
575 blockers.push("invalid map identities");
576 }
577 if !dirty.is_empty() {
578 blockers.push("flow requires review or reference repair");
579 }
580 if applicable.is_empty() {
581 blockers.push("no matching passing assertion occurrence for appliesTo");
582 }
583 if eligible {
584 credit_flows += 1;
585 }
586 let mut lines = BTreeSet::new();
587 for node in f
588 .nodes
589 .iter()
590 .filter(|n| f.counts_as_asserted.contains(&n.id))
591 {
592 let Some(start) = node.at.offset(&inputs.files) else {
593 continue;
594 };
595 let Some(points) = by_file.get(node.at.file.as_str()) else {
596 continue;
597 };
598 let first = points.partition_point(|(pos, _)| *pos < start);
599 for (pos, point) in points[first..].iter().take_while(|(pos, _)| *pos == start) {
600 if *pos != start || point.meta.source != node.at.text {
604 continue;
605 }
606 claimed_points.insert(point.meta.id.clone());
607 if eligible
608 && point.covered
609 && point.tests.iter().any(|t| applicable.contains(t))
610 {
611 credited_points.insert(point.meta.id.clone());
612 point_flows
613 .entry(point.meta.id.clone())
614 .or_default()
615 .insert(flow_key(a, f));
616 lines.insert((node.at.file.clone(), point.meta.line));
617 line_assertions
618 .entry((node.at.file.clone(), point.meta.line))
619 .or_default()
620 .insert(a.id.clone());
621 }
622 }
623 }
624 flows.push(json!({"id":f.id,"freshness":freshness,"valid":valid,"current":dirty.is_empty(),"expectedBasis":model::expected_basis(a,f,map,state,&manifest),"selectors":selectors,"questions":f.questions,"reasons":dirty,"eligible":eligible,"blockers":blockers,"matchingTests":applicable,"creditedStatementLines":lines}));
625 }
626 rows.push(json!({"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}));
627 }
628 let denominator = coverage
629 .view
630 .lines
631 .iter()
632 .filter(|l| l.measured)
633 .map(|l| (l.file.clone(), l.line))
634 .collect::<BTreeSet<_>>();
635 let mut credited = BTreeSet::new();
636 let mut declared = BTreeSet::new();
637 for location in &denominator {
638 let statements = by_line.get(location).map(Vec::as_slice).unwrap_or(&[]);
639 if !statements.is_empty()
640 && statements
641 .iter()
642 .all(|p| claimed_points.contains(&p.meta.id))
643 {
644 declared.insert(location.clone());
645 }
646 if !statements.is_empty()
647 && statements
648 .iter()
649 .all(|p| credited_points.contains(&p.meta.id))
650 {
651 credited.insert(location.clone());
652 }
653 }
654 let missing_inventory = inputs
655 .assertions
656 .iter()
657 .filter(|s| !map.assertions.iter().any(|a| a.at == s.at))
658 .count();
659 let (status, reason) = if !passed || !identities_valid {
660 ("unavailable", "Run failed or map identities are invalid")
661 } else if pending_changes > 0 {
662 ("pending", "Source changes need impact assessment")
663 } else if measured_statements.is_empty() {
664 ("notApplicable", "No measured statements")
665 } else if credit_flows > 0 {
666 (
667 "available",
668 "Agent-assessed statements; mapping completeness is unknown",
669 )
670 } else if map.assertions.iter().all(|a| a.flows.is_empty()) {
671 ("notAssessed", "No recorded flow explanations")
672 } else {
673 (
674 "pending",
675 "No current flow with matching passing assertion evidence",
676 )
677 };
678 let assertions_without_current_explanation = rows
679 .iter()
680 .filter(|a| {
681 inventory.keys().any(|at| json!(at) == a["at"])
682 && a["observedPassingTests"]
683 .as_array()
684 .is_some_and(|v| !v.is_empty())
685 && a["flows"]
686 .as_array()
687 .is_none_or(|v| !v.iter().any(|f| f["eligible"] == true))
688 })
689 .count();
690 let total = denominator.len();
691 let statements = measured_statements.iter().map(|p| {
692 let at = inputs.files.get(&p.meta.file)
693 .and_then(|text| byte_column(text, p.meta.line, p.meta.column, &inputs.language))
694 .map(|column| Anchor { file: p.meta.file.clone(), line: p.meta.line, column, text: p.meta.source.clone() })
695 .filter(|at| at.offset(&inputs.files).is_some());
696 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()})
697 }).collect::<Vec<_>>();
698 json!({"basis":"agent-assessed; passing assertion identity and same-test execution required; not mutation resistance",
699 "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,
700 "assertionsWithFlows":rows.iter().filter(|a| a["flows"].as_array().is_some_and(|f| !f.is_empty())).count(),
701 "assertionsWithoutFlows":rows.iter().filter(|a| a["flows"].as_array().is_none_or(Vec::is_empty)).count(),
702 "observedAssertionsWithoutCurrentExplanation":assertions_without_current_explanation,
703 "questions":map.assertions.iter().map(|a| a.questions.len()+a.flows.iter().map(|f| f.questions.len()).sum::<usize>()).sum::<usize>(),
704 "currentFlows":current_flows,"draftFlows":draft_flows,"staleFlows":stale_flows,"invalidFlows":invalid_flows,"eligibleFlows":credit_flows,"retiredAssertions":map.retired_assertions.len(),
705 "unobservedAssertions":rows.iter().filter(|a| a["observedPassingTests"].as_array().is_none_or(Vec::is_empty)).count(),
706 "inventoryFailures":inputs.limitations.iter().filter(|s| s.starts_with("Inventory unavailable for ")).count(),
707 "unanchoredStatements":statements.iter().filter(|s| s["at"].is_null()).count(),
708 "runPassed":passed,
709 "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)}}},
710 "assertions":rows,"statements":statements,"tests":tests.values().map(|t| json!({"id":t.id,"file":t.file,"name":t.name})).collect::<Vec<_>>(),
711 "creditedLines":credited.iter().map(|loc| json!({"file":loc.0,"line":loc.1,"assertions":line_assertions.get(loc)})).collect::<Vec<_>>(),
712 "unassertedLines":denominator.difference(&credited).map(|(f,l)| json!({"file":f,"line":l})).collect::<Vec<_>>(),
713 "changes":validation["changes"],"validationErrors":errors,"limitations":inputs.limitations})
714}
715
716#[cfg(test)]
717mod tests {
718 use super::*;
719 use crate::evidence_archive::{EvidenceArchiveEntry, write_archive};
720
721 #[test]
722 fn legacy_maps_import_without_the_old_checkout_and_require_review() {
723 let root = std::env::temp_dir().join(format!("supercov-legacy-map-{}", std::process::id()));
724 fs::create_dir_all(&root).unwrap();
725 let source = "import assert from 'node:assert/strict'; assert.equal(1, 1);\n";
726 fs::write(root.join("test.js"), source).unwrap();
727 let old =
728 crate::assertion_inputs::capture(&root, "javascript", ["test.js".into()]).unwrap();
729 let directory = crate::run_store::create_analyzable_test_run(&root, "legacy");
730 let path = directory.join("evidence.raw.gz");
731 let mut entries = read_archive(&path).unwrap();
732 entries.push(EvidenceArchiveEntry {
733 path: ARCHIVE_PATH.into(),
734 contents: serde_json::to_vec(&old).unwrap(),
735 });
736 let archive = write_archive(entries, &path).unwrap();
737 let metadata_path = directory.join("run.json");
738 let mut metadata: RunMetadata =
739 serde_json::from_slice(&fs::read(&metadata_path).unwrap()).unwrap();
740 metadata.raw_evidence.files = archive.files;
741 metadata.raw_evidence.compressed_bytes = archive.compressed_bytes;
742 metadata.raw_evidence.uncompressed_bytes = archive.uncompressed_bytes;
743 fs::write(&metadata_path, serde_json::to_vec(&metadata).unwrap()).unwrap();
744 let run = discover_runs(&root).unwrap().runs.remove(0);
745 let stored = load_manifest(&run).unwrap();
746 let (map, _) = seed(&old, &stored.evidence_digest);
747 let legacy_map = json!({"schemaVersion":1,"assertions":[{"id":map.assertions[0].id,"at":map.assertions[0].at,"analysis":"mapped","observes":[],"flows":[{
748 "id":"constant","explanation":"The assertion checks the constant one.","appliesTo":[],"nodes":[],"edges":[],"countsAsAsserted":[],"watch":[{"kind":"span","at":map.assertions[0].at}]
749 }]}]});
750 let legacy_state = json!({"schemaVersion":1,"inputsDigest":digest(&old),"evidenceDigest":stored.evidence_digest,"reviews":{},"scopeReview":[]});
751 write_json(&root, &run, MAP_FILE, &legacy_map).unwrap();
752 write_json(&root, &run, STATE_FILE, &legacy_state).unwrap();
753 let map = model::parse_stored(&serde_json::to_vec(&legacy_map).unwrap()).unwrap();
754 let map_bytes = fs::read(directory.join(MAP_FILE)).unwrap();
755 let state_bytes = fs::read(directory.join(STATE_FILE)).unwrap();
756
757 fs::write(root.join("test.js"), format!("\n{source}")).unwrap();
758 assert!(load_inputs(&root, &run).is_err());
759 let (imported, state) = load(&run, &stored).unwrap();
760 let new =
761 crate::assertion_inputs::capture(&root, "javascript", ["test.js".into()]).unwrap();
762 let (next, state) =
763 carry(&imported, &state, &stored.manifest, &new, "new-run", false).unwrap();
764 assert_eq!(next.assertions[0].id, map.assertions[0].id);
765 assert_eq!(
766 next.assertions[0].flows[0].explanation,
767 map.assertions[0].flows[0].explanation
768 );
769 assert!(next.assertions[0].flows[0].basis.is_none());
770 assert!(!next.assertions[0].flows[0].questions.is_empty());
771 assert_eq!(fs::read(directory.join(MAP_FILE)).unwrap(), map_bytes);
772 assert_eq!(fs::read(directory.join(STATE_FILE)).unwrap(), state_bytes);
773
774 let next_directory = crate::run_store::create_analyzable_test_run(&root, "next");
777 let mut entries = read_archive(&path).unwrap();
778 entries
779 .iter_mut()
780 .find(|e| e.path == ARCHIVE_PATH)
781 .unwrap()
782 .contents = serde_json::to_vec(&old.manifest()).unwrap();
783 let raw = write_archive(entries, &next_directory.join("evidence.raw.gz")).unwrap();
784 let mut next_metadata = metadata.clone();
785 next_metadata.id = "next".into();
786 next_metadata.started_at = "next".into();
787 next_metadata.raw_evidence.compressed_bytes = raw.compressed_bytes;
788 next_metadata.raw_evidence.uncompressed_bytes = raw.uncompressed_bytes;
789 fs::write(
790 next_directory.join("run.json"),
791 serde_json::to_vec(&next_metadata).unwrap(),
792 )
793 .unwrap();
794 fs::remove_file(root.join("test.js")).unwrap();
795 prepare_publication(&root, &next_directory, &next_metadata).unwrap();
796 let next_run = discover_runs(&root)
797 .unwrap()
798 .runs
799 .into_iter()
800 .find(|r| r.id == "next")
801 .unwrap();
802 let next_manifest = load_manifest(&next_run).unwrap();
803 let (pending, pending_state) = load(&next_run, &next_manifest).unwrap();
804 assert_eq!(pending.assertions[0], map.assertions[0]);
805 assert!(!pending_state.changes.is_empty());
806 assert!(
807 !pending_state.flows
808 [&flow_key(&pending.assertions[0], &pending.assertions[0].flows[0])]
809 .reasons
810 .is_empty()
811 );
812 assert!(load_inputs(&root, &next_run).is_err());
813 assert_eq!(fs::read(directory.join(MAP_FILE)).unwrap(), map_bytes);
814 assert_eq!(fs::read(directory.join(STATE_FILE)).unwrap(), state_bytes);
815
816 let mut corrupt = state.clone();
817 corrupt.evidence_digest = "wrong".into();
818 write_json(&root, &run, STATE_FILE, &corrupt).unwrap();
819 assert!(load(&run, &stored).is_err());
820 fs::remove_dir_all(root).unwrap();
821 }
822}