1use std::{
19 collections::{BTreeMap, BTreeSet},
20 fs::{self, File},
21 path::{Component, Path, PathBuf},
22};
23
24use memmap2::{Mmap, MmapOptions};
25use serde::Deserialize;
26use serde_json::json;
27use sha2::{Digest, Sha256};
28use supercov_contracts::{
29 AttributionPrecision, ExecutionModel, FrontendAttribution, FrontendLimitation,
30 FrontendLimitationScope, FrontendRunDeclaration, FrontendRunnerDeclaration,
31 LANGUAGE_FRONTEND_PROTOCOL_VERSION, StructuralSource,
32};
33
34use crate::{
35 coverage_analysis::McdcVector,
36 coverage_report::{
37 CoverageManifest, CoverageModelDeclaration, CoveragePhase, CoverageReportRequest,
38 DecisionMeta, DecisionSnapshot, ExecutionScope, ExitCodeInput, PersistedCoverageModel,
39 RawTestResult, RuntimeSnapshot, TestProvenance,
40 },
41 evidence_archive::EvidenceArchiveEntry,
42};
43
44pub const PYTHON_EVIDENCE_VERSION: u32 = 1;
45pub const PYTHON_FRONTEND_VERSION: &str = "python-probes-v1";
46pub const PYTEST_RUNNER: &str = "pytest";
47pub const UNITTEST_RUNNER: &str = "unittest";
48
49const TRANSPORT_MAGIC: &[u8; 8] = b"SCVPYTH1";
50const TRANSPORT_VERSION: u32 = 2;
55const TRANSPORT_VERSION_FNV: u32 = 1;
56const TRANSPORT_HEADER_SIZE: usize = 64;
57const TRANSPORT_RECORD_HEADER_SIZE: usize = 16;
58const TRANSPORT_MAX_RECORD_SIZE: usize = 4 * 1024 * 1024;
59
60fn default_runner() -> String {
61 PYTEST_RUNNER.into()
62}
63
64#[derive(Debug, Deserialize)]
67#[allow(dead_code)]
68#[serde(tag = "t", rename_all = "lowercase", deny_unknown_fields)]
69enum Record {
70 Process {
71 v: u32,
72 run: String,
73 pid: u64,
74 worker: String,
75 python: String,
76 executable: String,
77 argv: Vec<String>,
78 #[serde(default)]
81 frontend: Option<String>,
82 },
83 Worker {
84 worker: String,
85 },
86 Phase {
87 ctx: u64,
88 at: i64,
89 worker: String,
90 test: String,
91 retry: usize,
92 phase: String,
93 },
94 Outcome {
95 worker: String,
96 test: String,
97 retry: usize,
98 phase: String,
99 outcome: String,
100 xfail: bool,
101 #[serde(default = "default_runner")]
102 runner: String,
103 #[serde(default)]
106 file: Option<String>,
107 },
108 Hit {
109 ctx: u64,
110 id: String,
111 },
112 Hits {
116 ctx: u64,
117 ids: Vec<String>,
118 },
119 #[serde(skip)]
122 SlotHits {
123 ctx: u64,
124 positions: Vec<u32>,
125 },
126 Dec {
127 ctx: u64,
128 id: String,
129 v: String,
130 o: u8,
131 },
132 Decs {
134 ctx: u64,
135 v: Vec<(String, String, u8)>,
136 },
137 Runs {
143 ctx: u64,
144 r: Vec<usize>,
145 },
146 Assert {
149 ctx: u64,
150 },
151 Asite {
156 ctx: u64,
157 f: String,
158 l: usize,
159 },
160 Limitation {
161 id: String,
162 reason: String,
163 #[serde(default)]
164 file: Option<String>,
165 #[serde(default)]
166 obligation: Option<String>,
167 },
168 Exit {
169 at: i64,
170 },
171}
172
173#[derive(Debug)]
174pub enum PythonEvidenceError {
175 Io(String),
176 UnsafeEntry(String),
177 InvalidRecord {
178 file: String,
179 line: usize,
180 reason: String,
181 },
182 InvalidTransport {
183 file: String,
184 reason: String,
185 },
186 DroppedRecords {
187 file: String,
188 count: u64,
189 },
190 RunMismatch {
191 expected: String,
192 actual: String,
193 },
194 UnsupportedVersion(u32),
195 UnknownContext {
196 file: String,
197 line: usize,
198 context: u64,
199 },
200 UnknownObligation(String),
201 InvalidVector {
202 id: String,
203 expected: usize,
204 actual: usize,
205 },
206 NoInterpreter,
207 NoTests,
208 UnsupportedPython(String),
209}
210
211impl std::fmt::Display for PythonEvidenceError {
212 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
213 match self {
214 Self::Io(reason) => write!(formatter, "could not read Python evidence: {reason}"),
215 Self::UnsafeEntry(name) => write!(formatter, "unsafe Python evidence entry: {name}"),
216 Self::InvalidRecord { file, line, reason } => {
217 write!(formatter, "invalid Python evidence record {file}:{line}: {reason}")
218 }
219 Self::InvalidTransport { file, reason } => {
220 write!(formatter, "invalid Python evidence transport {file}: {reason}")
221 }
222 Self::DroppedRecords { file, count } => write!(
223 formatter,
224 "Python evidence transport {file} exhausted its bounded capacity and dropped {count} record(s)"
225 ),
226 Self::RunMismatch { expected, actual } => write!(
227 formatter,
228 "Python evidence belongs to run {actual}, expected {expected}"
229 ),
230 Self::UnsupportedVersion(version) => {
231 write!(formatter, "unsupported Python evidence version {version}")
232 }
233 Self::UnknownContext { file, line, context } => write!(
234 formatter,
235 "Python evidence {file}:{line} references undeclared context {context}"
236 ),
237 Self::UnknownObligation(id) => {
238 write!(formatter, "Python runtime reported an unknown obligation: {id}")
239 }
240 Self::InvalidVector {
241 id,
242 expected,
243 actual,
244 } => write!(
245 formatter,
246 "Python decision {id} reported {actual} condition values, expected {expected}"
247 ),
248 Self::NoInterpreter => formatter.write_str(
249 "no Supercov-hooked Python interpreter ran: the test command did not start CPython 3.9+ with Supercov's start-up hook (PYTHONPATH may be ignored by -I/-E/-S, or the runner is not Python)",
250 ),
251 Self::NoTests => formatter.write_str(
252 "the Python run produced no test outcomes; Supercov measures Python through pytest and unittest",
253 ),
254 Self::UnsupportedPython(version) => write!(
255 formatter,
256 "Supercov measures CPython 3.9 or newer; the test command ran Python {version}"
257 ),
258 }
259 }
260}
261
262impl std::error::Error for PythonEvidenceError {}
263
264fn stable_id(prefix: &str, values: &[&str]) -> String {
265 let mut hash = Sha256::new();
266 for value in values {
267 hash.update(value.as_bytes());
268 hash.update([0]);
269 }
270 let digest = hash.finalize();
271 let mut encoded = String::with_capacity(prefix.len() + 25);
272 encoded.push_str(prefix);
273 encoded.push(':');
274 for byte in &digest[..12] {
275 use std::fmt::Write as _;
276 write!(&mut encoded, "{byte:02x}").expect("string formatting");
277 }
278 encoded
279}
280
281#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
282struct Identity {
283 worker: String,
284 test: String,
285 retry: usize,
286 phase: String,
287}
288
289type ObservedVectors = BTreeSet<(Vec<Option<bool>>, bool)>;
290type OutcomesByAttempt = BTreeMap<(String, String, usize), Vec<(String, String, bool)>>;
292type RunnersByAttempt = BTreeMap<(String, String, usize), String>;
294type TestFilesByAttempt = BTreeMap<(String, String, usize), String>;
296type SitesByAttempt = BTreeMap<(String, String, usize), Vec<(String, usize)>>;
299
300pub struct PythonAssertionInventory {
309 root: PathBuf,
310 columns: BTreeMap<(String, usize), Vec<usize>>,
312}
313
314impl PythonAssertionInventory {
315 pub fn new(root: &Path, inputs: &crate::assertion_map::Inputs) -> Self {
316 let mut columns = BTreeMap::<(String, usize), Vec<usize>>::new();
317 for site in &inputs.assertions {
318 columns
319 .entry((site.at.file.clone(), site.at.line))
320 .or_default()
321 .push(site.at.column.saturating_sub(1));
324 }
325 for sites in columns.values_mut() {
326 sites.sort_unstable();
327 sites.dedup();
328 }
329 Self {
330 root: root.to_path_buf(),
331 columns,
332 }
333 }
334
335 pub fn empty() -> Self {
338 Self {
339 root: PathBuf::new(),
340 columns: BTreeMap::new(),
341 }
342 }
343
344 pub fn relative(&self, path: &str) -> Option<String> {
348 let candidate = Path::new(path);
349 let relative = if candidate.is_absolute() {
350 candidate.strip_prefix(&self.root).ok()?
351 } else {
352 candidate.strip_prefix("./").unwrap_or(candidate)
353 };
354 let text = relative.to_string_lossy().replace('\\', "/");
355 (!text.is_empty() && !text.starts_with("../")).then_some(text)
356 }
357
358 pub fn locate(&self, path: &str, line: usize) -> Option<String> {
362 let file = self.relative(path)?;
363 match self.columns.get(&(file.clone(), line))?.as_slice() {
364 [column] => Some(format!("{file}:{line}:{column}")),
365 _ => None,
366 }
367 }
368}
369
370#[derive(Debug, Default)]
371struct Observations {
372 hits: BTreeSet<String>,
373 slot_hits: Vec<u32>,
378 vectors: BTreeMap<String, ObservedVectors>,
379}
380
381impl Observations {
382 fn is_empty(&self) -> bool {
383 self.hits.is_empty() && self.slot_hits.is_empty() && self.vectors.is_empty()
384 }
385}
386
387#[derive(Debug, Clone)]
388struct RuntimeLimitation {
389 id: String,
390 reason: String,
391 file: Option<String>,
392 obligation: Option<String>,
393}
394
395#[derive(Debug, Default)]
396struct Evidence {
397 interpreters: usize,
398 python_versions: BTreeSet<String>,
399 per_identity: BTreeMap<Identity, Observations>,
400 background: BTreeMap<String, Observations>,
401 outcomes: OutcomesByAttempt,
402 runners: RunnersByAttempt,
403 test_files: TestFilesByAttempt,
404 sites: SitesByAttempt,
405 limitations: BTreeMap<(String, Option<String>, Option<String>), RuntimeLimitation>,
406 slot_ids: Vec<String>,
408}
409
410fn read_evidence_directory(
411 directory: &Path,
412 run_id: &str,
413) -> Result<Evidence, PythonEvidenceError> {
414 let mut evidence = Evidence::default();
415 let mut files = match fs::read_dir(directory) {
416 Ok(entries) => entries
417 .collect::<Result<Vec<_>, _>>()
418 .map_err(|error| PythonEvidenceError::Io(error.to_string()))?,
419 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
420 Err(error) => return Err(PythonEvidenceError::Io(error.to_string())),
421 };
422 files.sort_by_key(|entry| entry.file_name());
423 let mut transports = Vec::new();
424 let mut slots = BTreeMap::<String, Vec<(String, PathBuf)>>::new();
425 let mut layout_path = None;
426 for entry in files {
427 let name = entry
428 .file_name()
429 .into_string()
430 .map_err(|_| PythonEvidenceError::UnsafeEntry("<non-utf8>".into()))?;
431 if Path::new(&name)
432 .components()
433 .any(|component| !matches!(component, Component::Normal(_)))
434 {
435 return Err(PythonEvidenceError::UnsafeEntry(name));
436 }
437 if name.starts_with(SLOT_LAYOUT_NAME) && name.ends_with(".partial") {
440 continue;
441 }
442 let Some(metadata) = entry_metadata(&name, &entry.path())? else {
443 continue;
444 };
445 if !metadata.file_type().is_file() {
446 return Err(PythonEvidenceError::UnsafeEntry(name));
447 }
448 if name == SLOT_LAYOUT_NAME {
449 layout_path = Some(entry.path());
450 } else if name.ends_with(".mmap") {
451 transports.push((name, entry.path()));
452 } else if let Some((slot_name, transport)) = name.strip_suffix(".shm").and_then(|stem| {
453 let (transport, number) = stem.rsplit_once('.')?;
454 number
455 .parse::<u64>()
456 .ok()
457 .map(|_| (format!("{stem}.slot"), format!("{transport}.mmap")))
458 }) {
459 if let Some(path) = materialize_shared_slot(&entry.path(), &slot_name)? {
460 slots.entry(transport).or_default().push((slot_name, path));
461 }
462 } else if let Some(transport) = name
463 .strip_suffix(".slot")
464 .and_then(|stem| stem.rsplit_once('.'))
465 .filter(|(_, number)| number.parse::<u64>().is_ok())
466 .map(|(transport, _)| format!("{transport}.mmap"))
467 {
468 if metadata.len() > 0 {
470 slots
471 .entry(transport)
472 .or_default()
473 .push((name, entry.path()));
474 }
475 } else {
476 return Err(PythonEvidenceError::UnsafeEntry(name));
477 }
478 }
479 let layout = match layout_path {
480 Some(path) => Some(SlotLayout::read(&path)?),
481 None => None,
482 };
483 let work = transports
487 .into_iter()
488 .map(|(name, path)| {
489 let owned = slots.remove(&name).unwrap_or_default();
490 (name, path, owned)
491 })
492 .collect::<Vec<_>>();
493 let parse = |(name, path, owned): &(String, PathBuf, Vec<(String, PathBuf)>)| {
494 let contents = map_evidence(path)?;
495 let slot_contents = owned
496 .iter()
497 .filter_map(|(slot, path)| match map_slot(path) {
498 Ok(Some(contents)) => Some(Ok((slot.clone(), contents))),
499 Ok(None) => None,
500 Err(error) => Some(Err(error)),
501 })
502 .collect::<Result<Vec<_>, _>>()?;
503 parse_transport(name, &contents, &slot_contents, layout.as_ref())
504 };
505 let parsed = parallel_map(&work, parse);
506 for ((name, _, _), parsed) in work.iter().zip(parsed) {
507 apply_transport(name, parsed?, run_id, &mut evidence)?;
508 }
509 evidence.slot_ids = layout.map(|layout| layout.ids).unwrap_or_default();
510 if let Some((_, orphans)) = slots.into_iter().next() {
511 return Err(PythonEvidenceError::InvalidTransport {
512 file: orphans[0].0.clone(),
513 reason: "a slot was left without its process's transport".into(),
514 });
515 }
516 Ok(evidence)
517}
518
519fn entry_metadata(name: &str, path: &Path) -> Result<Option<fs::Metadata>, PythonEvidenceError> {
526 match fs::symlink_metadata(path) {
527 Ok(metadata) => Ok(Some(metadata)),
528 Err(error) if error.kind() == std::io::ErrorKind::NotFound && name.ends_with(".slot") => {
529 Ok(None)
530 }
531 Err(error) => Err(PythonEvidenceError::Io(error.to_string())),
532 }
533}
534
535fn materialize_shared_slot(
542 marker: &Path,
543 slot_name: &str,
544) -> Result<Option<PathBuf>, PythonEvidenceError> {
545 let slot = marker.with_file_name(slot_name);
546 if fs::symlink_metadata(&slot).is_ok() {
547 return Ok(None);
548 }
549 let contents = match fs::read(marker) {
550 Ok(contents) => contents,
551 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
552 Err(error) => return Err(PythonEvidenceError::Io(error.to_string())),
553 };
554 if contents.is_empty() {
556 return Ok(None);
557 }
558 let unsafe_marker = || PythonEvidenceError::UnsafeEntry(marker.to_string_lossy().into_owned());
560 let (name, size) = std::str::from_utf8(&contents)
561 .ok()
562 .and_then(|text| text.split_once(' '))
563 .and_then(|(name, size)| Some((name.as_bytes(), size.parse::<usize>().ok()?)))
564 .ok_or_else(unsafe_marker)?;
565 if name.len() > 31
566 || name.first() != Some(&b'/')
567 || !name[1..]
568 .iter()
569 .all(|byte| byte.is_ascii_alphanumeric() || *byte == b'.')
570 {
571 return Err(unsafe_marker());
572 }
573 shared_slot_bytes(name)?
574 .filter(|bytes| !bytes.is_empty())
576 .map(|mut bytes| {
577 if bytes.len() < size {
578 return Err(PythonEvidenceError::InvalidTransport {
579 file: slot_name.into(),
580 reason: "a shared slot is smaller than its process sized it".into(),
581 });
582 }
583 bytes.truncate(size);
584 let partial = marker.with_file_name(format!("{slot_name}.partial"));
585 fs::write(&partial, bytes)
586 .and_then(|()| fs::rename(&partial, &slot))
587 .map_err(|error| PythonEvidenceError::Io(error.to_string()))?;
588 Ok(slot)
589 })
590 .transpose()
591}
592
593#[cfg(target_os = "macos")]
595fn shared_slot_bytes(name: &[u8]) -> Result<Option<Vec<u8>>, PythonEvidenceError> {
596 use std::os::fd::FromRawFd;
597 let name = std::ffi::CString::new(name)
598 .map_err(|_| PythonEvidenceError::UnsafeEntry("<shared slot name>".into()))?;
599 let descriptor = unsafe { libc::shm_open(name.as_ptr(), libc::O_RDONLY) };
601 if descriptor < 0 {
602 let error = std::io::Error::last_os_error();
603 return if error.kind() == std::io::ErrorKind::NotFound {
604 Ok(None)
605 } else {
606 Err(PythonEvidenceError::Io(error.to_string()))
607 };
608 }
609 let file = unsafe { File::from_raw_fd(descriptor) };
611 let length = file
612 .metadata()
613 .map_err(|error| PythonEvidenceError::Io(error.to_string()))?
614 .len();
615 let bytes = if length == 0 {
616 Vec::new()
617 } else {
618 unsafe { MmapOptions::new().map(&file) }
620 .map_err(|error| PythonEvidenceError::Io(error.to_string()))?
621 .to_vec()
622 };
623 unsafe { libc::shm_unlink(name.as_ptr()) };
625 Ok(Some(bytes))
626}
627
628#[cfg(not(target_os = "macos"))]
629fn shared_slot_bytes(_name: &[u8]) -> Result<Option<Vec<u8>>, PythonEvidenceError> {
630 Ok(None)
631}
632
633fn map_slot(path: &Path) -> Result<Option<Mmap>, PythonEvidenceError> {
637 match File::open(path) {
638 Ok(file) => unsafe { MmapOptions::new().map(&file) }
639 .map(Some)
640 .map_err(|error| PythonEvidenceError::Io(error.to_string())),
641 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
642 Err(error) => Err(PythonEvidenceError::Io(error.to_string())),
643 }
644}
645
646fn map_evidence(path: &Path) -> Result<Mmap, PythonEvidenceError> {
647 let file = File::open(path).map_err(|error| PythonEvidenceError::Io(error.to_string()))?;
648 unsafe { MmapOptions::new().map(&file) }
652 .map_err(|error| PythonEvidenceError::Io(error.to_string()))
653}
654
655const SLOT_LAYOUT_NAME: &str = "layout.json";
656const SLOT_MAX_REGION_WIDTH: usize = 6;
659
660#[derive(Debug, Deserialize)]
663#[serde(rename_all = "camelCase", deny_unknown_fields)]
664struct SlotLayout {
665 #[allow(dead_code)]
666 version: u32,
667 digest: String,
668 header: usize,
669 bytes: usize,
670 ids: Vec<String>,
671 decisions: Vec<SlotDecision>,
672 #[serde(default)]
676 implied: Vec<(usize, Vec<usize>)>,
677 #[serde(skip)]
678 implied_by: std::collections::HashMap<usize, Vec<usize>>,
679}
680
681#[derive(Debug, Deserialize)]
682#[serde(rename_all = "camelCase", deny_unknown_fields)]
683struct SlotDecision {
684 start: usize,
685 width: usize,
686 id: String,
687 outcome_true: String,
688 outcome_false: String,
689 logical: Vec<SlotLogical>,
690}
691
692#[derive(Debug, Deserialize)]
693#[serde(rename_all = "camelCase", deny_unknown_fields)]
694struct SlotLogical {
695 evaluated: String,
696 short_circuit: String,
697 previous_leaves: Vec<usize>,
698 operand_leaves: Vec<usize>,
699}
700
701impl SlotDecision {
702 fn region_bytes(&self) -> usize {
705 if self.width == 1 {
706 2
707 } else {
708 2 * 3_usize.pow(self.width as u32)
709 }
710 }
711
712 fn vector(&self, offset: usize) -> (String, bool) {
714 if self.width == 1 {
715 let outcome = offset == 1;
716 return ((if outcome { "2" } else { "1" }).into(), outcome);
717 }
718 let mut mask = offset >> 1;
719 let digits = (0..self.width)
720 .map(|_| {
721 let digit = b"012"[mask % 3] as char;
722 mask /= 3;
723 digit
724 })
725 .collect();
726 (digits, offset & 1 == 1)
727 }
728
729 fn implied(&self, digits: &str, outcome: bool, into: &mut Vec<String>) {
732 into.push(
733 if outcome {
734 &self.outcome_true
735 } else {
736 &self.outcome_false
737 }
738 .clone(),
739 );
740 let evaluated = |leaves: &[usize]| {
741 leaves.iter().any(|index| {
742 digits
743 .as_bytes()
744 .get(*index)
745 .is_some_and(|digit| *digit != b'0')
746 })
747 };
748 for logical in &self.logical {
749 if evaluated(&logical.operand_leaves) {
750 into.push(logical.evaluated.clone());
751 } else if evaluated(&logical.previous_leaves) {
752 into.push(logical.short_circuit.clone());
753 }
754 }
755 }
756}
757
758impl SlotLayout {
759 fn read(path: &Path) -> Result<Self, PythonEvidenceError> {
760 let invalid = |reason: String| PythonEvidenceError::InvalidTransport {
761 file: SLOT_LAYOUT_NAME.into(),
762 reason,
763 };
764 let bytes = fs::read(path).map_err(|error| PythonEvidenceError::Io(error.to_string()))?;
765 let mut layout: Self =
766 serde_json::from_slice(&bytes).map_err(|error| invalid(error.to_string()))?;
767 if layout.digest.len() < 16 || !layout.digest.bytes().all(|byte| byte.is_ascii_hexdigit()) {
768 return Err(invalid("the layout digest is not hex".into()));
769 }
770 let mut cursor = layout.header + layout.ids.len();
773 for decision in &layout.decisions {
774 if decision.start != cursor
775 || decision.width == 0
776 || decision.width > SLOT_MAX_REGION_WIDTH
777 {
778 return Err(invalid(format!(
779 "decision {} has no valid region",
780 decision.id
781 )));
782 }
783 if decision
784 .logical
785 .iter()
786 .flat_map(|logical| {
787 logical
788 .previous_leaves
789 .iter()
790 .chain(&logical.operand_leaves)
791 })
792 .any(|leaf| *leaf >= decision.width)
793 {
794 return Err(invalid(format!(
795 "decision {} names a condition it lacks",
796 decision.id
797 )));
798 }
799 cursor += decision.region_bytes();
800 }
801 if cursor != layout.bytes || layout.header < 16 {
802 return Err(invalid("the regions do not fill the slot".into()));
803 }
804 let id_end = layout.header + layout.ids.len();
807 for (head, implied) in &layout.implied {
808 if *head < layout.header
809 || *head >= id_end
810 || implied
811 .iter()
812 .any(|byte| *byte < layout.header || *byte >= layout.bytes || *byte == *head)
813 {
814 return Err(invalid(format!(
815 "byte {head} implies bytes outside the slot"
816 )));
817 }
818 }
819 layout.implied_by = layout.implied.iter().cloned().collect();
820 if layout
821 .implied_by
822 .values()
823 .flatten()
824 .any(|byte| layout.implied_by.contains_key(byte))
825 {
826 return Err(invalid("an implied byte implies others".into()));
827 }
828 Ok(layout)
829 }
830
831 fn tag(&self) -> Vec<u8> {
832 (0..8)
833 .map(|index| {
834 u8::from_str_radix(&self.digest[index * 2..index * 2 + 2], 16).unwrap_or(0)
835 })
836 .collect()
837 }
838
839 fn records(&self, name: &str, contents: &[u8]) -> Result<Vec<Record>, PythonEvidenceError> {
841 let invalid = |reason: &str| PythonEvidenceError::InvalidTransport {
842 file: name.into(),
843 reason: reason.into(),
844 };
845 let tag = contents
846 .get(8..16)
847 .ok_or_else(|| invalid("slot header is missing"))?;
848 if tag.iter().all(|byte| *byte == 0) {
851 return Ok(Vec::new());
852 }
853 if tag != self.tag().as_slice() {
854 return Err(invalid("slot was written for another layout"));
855 }
856 if contents.len() != self.bytes {
857 return Err(invalid("slot size does not match the layout"));
858 }
859 let context =
860 transport_u64(contents, 0).ok_or_else(|| invalid("slot context is missing"))?;
861 let mut decoded = Decoded::default();
862 for (index, byte) in contents.iter().enumerate().skip(self.header) {
863 match byte {
864 0 => continue,
865 1 => self.decode(index, &mut decoded),
866 _ => return Err(invalid("slot byte is neither 0 nor 1")),
867 }
868 }
869 Ok(decoded.into_records(context))
870 }
871
872 fn runs(&self, context: u64, runs: &[usize]) -> Result<Vec<Record>, &'static str> {
874 if !runs.len().is_multiple_of(2) {
875 return Err("slot runs must pair a first byte with a length");
876 }
877 let mut decoded = Decoded::default();
878 for run in runs.chunks_exact(2) {
879 let (start, length) = (run[0], run[1]);
880 let end = start
881 .checked_add(length)
882 .filter(|end| length > 0 && start >= self.header && *end <= self.bytes)
883 .ok_or("a slot run lies outside the layout")?;
884 for index in start..end {
885 self.decode(index, &mut decoded);
886 }
887 }
888 Ok(decoded.into_records(context))
889 }
890
891 fn decode(&self, index: usize, into: &mut Decoded) {
893 self.decode_one(index, into);
894 if let Some(implied) = self.implied_by.get(&index) {
895 for byte in implied {
896 self.decode_one(*byte, into);
897 }
898 }
899 }
900
901 fn decode_one(&self, index: usize, into: &mut Decoded) {
904 let id_end = self.header + self.ids.len();
905 if index < id_end {
906 into.positions.push((index - self.header) as u32);
907 return;
908 }
909 let decision = &self.decisions[self
910 .decisions
911 .partition_point(|decision| decision.start <= index)
912 - 1];
913 let (digits, outcome) = decision.vector(index - decision.start);
914 decision.implied(&digits, outcome, &mut into.ids);
915 into.vectors
916 .push((decision.id.clone(), digits, u8::from(outcome)));
917 }
918}
919
920#[derive(Default)]
921struct Decoded {
922 ids: Vec<String>,
923 positions: Vec<u32>,
924 vectors: Vec<(String, String, u8)>,
925}
926
927impl Decoded {
928 fn into_records(self, context: u64) -> Vec<Record> {
929 let mut records = Vec::new();
930 if !self.positions.is_empty() {
931 records.push(Record::SlotHits {
932 ctx: context,
933 positions: self.positions,
934 });
935 }
936 if !self.ids.is_empty() {
937 records.push(Record::Hits {
938 ctx: context,
939 ids: self.ids,
940 });
941 }
942 if !self.vectors.is_empty() {
943 records.push(Record::Decs {
944 ctx: context,
945 v: self.vectors,
946 });
947 }
948 records
949 }
950}
951
952fn parallel_map<T: Sync, R: Send>(items: &[T], work: impl Fn(&T) -> R + Sync) -> Vec<R> {
954 let threads = std::thread::available_parallelism()
955 .map_or(1, std::num::NonZeroUsize::get)
956 .min(items.len().div_ceil(64).max(1));
957 if threads <= 1 {
958 return items.iter().map(work).collect();
959 }
960 let next = std::sync::atomic::AtomicUsize::new(0);
961 let mut done = std::thread::scope(|scope| {
962 let workers = (0..threads)
963 .map(|_| {
964 scope.spawn(|| {
965 let mut done = Vec::new();
966 loop {
967 let index = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
968 let Some(item) = items.get(index) else {
969 break done;
970 };
971 done.push((index, work(item)));
972 }
973 })
974 })
975 .collect::<Vec<_>>();
976 workers
977 .into_iter()
978 .flat_map(|worker| {
979 worker
980 .join()
981 .unwrap_or_else(|panic| std::panic::resume_unwind(panic))
982 })
983 .collect::<Vec<_>>()
984 });
985 done.sort_unstable_by_key(|(index, _)| *index);
986 done.into_iter().map(|(_, result)| result).collect()
987}
988
989fn transport_u32(bytes: &[u8], offset: usize) -> Option<u32> {
990 bytes
991 .get(offset..offset + 4)
992 .and_then(|value| value.try_into().ok())
993 .map(u32::from_le_bytes)
994}
995
996fn transport_u64(bytes: &[u8], offset: usize) -> Option<u64> {
997 bytes
998 .get(offset..offset + 8)
999 .and_then(|value| value.try_into().ok())
1000 .map(u64::from_le_bytes)
1001}
1002
1003fn transport_checksum(payload: &[u8], version: u32) -> u32 {
1004 if version == TRANSPORT_VERSION_FNV {
1005 return payload.iter().fold(0x811c_9dc5_u32, |value, byte| {
1006 (value ^ u32::from(*byte)).wrapping_mul(0x0100_0193)
1007 });
1008 }
1009 crc32fast::hash(payload)
1010}
1011
1012fn align_transport(value: usize) -> Option<usize> {
1013 value.checked_add(7).map(|value| value & !7)
1014}
1015
1016struct ParsedTransport {
1020 pid: u64,
1021 records: Vec<(usize, Record)>,
1022}
1023
1024fn parse_transport(
1025 name: &str,
1026 contents: &[u8],
1027 slots: &[(String, Mmap)],
1028 layout: Option<&SlotLayout>,
1029) -> Result<ParsedTransport, PythonEvidenceError> {
1030 let invalid_transport = |reason: &str| PythonEvidenceError::InvalidTransport {
1031 file: name.into(),
1032 reason: reason.into(),
1033 };
1034 let version = transport_u32(contents, 8);
1035 if contents.len() < TRANSPORT_HEADER_SIZE
1036 || contents.get(..8) != Some(TRANSPORT_MAGIC.as_slice())
1037 || !matches!(version, Some(TRANSPORT_VERSION | TRANSPORT_VERSION_FNV))
1038 || transport_u32(contents, 12) != Some(TRANSPORT_HEADER_SIZE as u32)
1039 {
1040 return Err(invalid_transport("header or version does not match"));
1041 }
1042 let version = version.unwrap_or(TRANSPORT_VERSION);
1043 let declared_capacity =
1044 transport_u64(contents, 16).ok_or_else(|| invalid_transport("capacity is missing"))?;
1045 if declared_capacity < TRANSPORT_HEADER_SIZE as u64 || declared_capacity > contents.len() as u64
1046 {
1047 return Err(invalid_transport(
1048 "declared capacity is outside the mapped file",
1049 ));
1050 }
1051 let dropped =
1052 transport_u64(contents, 24).ok_or_else(|| invalid_transport("drop counter is missing"))?;
1053 if dropped != 0 {
1054 return Err(PythonEvidenceError::DroppedRecords {
1055 file: name.into(),
1056 count: dropped,
1057 });
1058 }
1059 let transport_pid = transport_u64(contents, 32)
1060 .filter(|pid| *pid != 0)
1061 .ok_or_else(|| invalid_transport("process id is missing"))?;
1062 let mut records = Vec::new();
1063 let mut cursor = TRANSPORT_HEADER_SIZE;
1064 let mut record_index = 0;
1065 while cursor + TRANSPORT_RECORD_HEADER_SIZE <= contents.len() {
1066 let commit = contents[cursor];
1067 if commit == 0 {
1068 break;
1071 }
1072 record_index += 1;
1073 let line_number = record_index;
1074 let invalid = |reason: &str| PythonEvidenceError::InvalidRecord {
1075 file: name.into(),
1076 line: line_number,
1077 reason: reason.into(),
1078 };
1079 if commit != 1
1080 || contents[cursor + 1..cursor + 4] != [0, 0, 0]
1081 || contents[cursor + 12..cursor + 16] != [0, 0, 0, 0]
1082 {
1083 return Err(invalid("commit marker or reserved bytes are invalid"));
1084 }
1085 let length = transport_u32(contents, cursor + 4)
1086 .map(|value| value as usize)
1087 .ok_or_else(|| invalid("payload length is missing"))?;
1088 if length == 0 || length > TRANSPORT_MAX_RECORD_SIZE {
1089 return Err(invalid("payload length is outside the transport bound"));
1090 }
1091 let payload_start = cursor + TRANSPORT_RECORD_HEADER_SIZE;
1092 let payload_end = payload_start
1093 .checked_add(length)
1094 .filter(|end| *end <= contents.len())
1095 .ok_or_else(|| invalid("payload extends past the mapped file"))?;
1096 let next_cursor = align_transport(payload_end)
1097 .filter(|end| *end <= contents.len())
1098 .ok_or_else(|| invalid("aligned frame extends past the mapped file"))?;
1099 if contents[payload_end..next_cursor]
1100 .iter()
1101 .any(|byte| *byte != 0)
1102 {
1103 return Err(invalid("frame padding is not zero"));
1104 }
1105 let payload = &contents[payload_start..payload_end];
1106 let expected_checksum = transport_u32(contents, cursor + 8)
1107 .ok_or_else(|| invalid("payload checksum is missing"))?;
1108 if transport_checksum(payload, version) != expected_checksum {
1109 return Err(invalid("payload checksum does not match"));
1110 }
1111 let record: Record = serde_json::from_slice(payload).map_err(|error| {
1112 PythonEvidenceError::InvalidRecord {
1113 file: name.into(),
1114 line: line_number,
1115 reason: error.to_string(),
1116 }
1117 })?;
1118 if let Record::Runs { ctx, r } = record {
1119 let layout =
1120 layout.ok_or_else(|| invalid("slot runs arrived without the run's slot layout"))?;
1121 for record in layout.runs(ctx, &r).map_err(invalid)? {
1122 records.push((line_number, record));
1123 }
1124 } else {
1125 records.push((line_number, record));
1126 }
1127 cursor = next_cursor;
1128 }
1129 for (slot, slot_contents) in slots {
1132 let layout = layout.ok_or_else(|| PythonEvidenceError::InvalidTransport {
1133 file: slot.clone(),
1134 reason: "a slot was left without the run's slot layout".into(),
1135 })?;
1136 for record in layout.records(slot, slot_contents)? {
1137 record_index += 1;
1138 records.push((record_index, record));
1139 }
1140 }
1141 Ok(ParsedTransport {
1142 pid: transport_pid,
1143 records,
1144 })
1145}
1146
1147fn apply_transport(
1149 name: &str,
1150 parsed: ParsedTransport,
1151 run_id: &str,
1152 evidence: &mut Evidence,
1153) -> Result<(), PythonEvidenceError> {
1154 let transport_pid = parsed.pid;
1155 let mut contexts = BTreeMap::<u64, Identity>::new();
1156 let mut before_assertion = BTreeMap::<u64, Observations>::new();
1159 let mut process_worker: Option<String> = None;
1160 let mut apply = |line_number: usize, record: Record| -> Result<(), PythonEvidenceError> {
1161 let invalid = |reason: &str| PythonEvidenceError::InvalidRecord {
1162 file: name.into(),
1163 line: line_number,
1164 reason: reason.into(),
1165 };
1166 match record {
1167 Record::Process {
1168 v,
1169 run,
1170 pid,
1171 worker,
1172 python,
1173 ..
1174 } => {
1175 if v != PYTHON_EVIDENCE_VERSION {
1176 return Err(PythonEvidenceError::UnsupportedVersion(v));
1177 }
1178 if run != run_id {
1179 return Err(PythonEvidenceError::RunMismatch {
1180 expected: run_id.into(),
1181 actual: run,
1182 });
1183 }
1184 if pid != transport_pid {
1185 return Err(invalid("process record does not match the transport owner"));
1186 }
1187 let supported = python
1188 .split('.')
1189 .take(2)
1190 .map(|part| part.parse::<u32>().ok())
1191 .collect::<Option<Vec<_>>>()
1192 .is_some_and(|parts| parts.len() == 2 && (parts[0], parts[1]) >= (3, 9));
1195 if !supported {
1196 return Err(PythonEvidenceError::UnsupportedPython(python));
1197 }
1198 evidence.interpreters += 1;
1199 evidence.python_versions.insert(python);
1200 process_worker = Some(worker);
1201 }
1202 Record::Worker { worker } => process_worker = Some(worker),
1203 Record::Phase {
1204 ctx,
1205 worker,
1206 test,
1207 retry,
1208 phase,
1209 ..
1210 } => {
1211 if ctx == 0 {
1212 return Err(invalid("phase context 0 is reserved for background"));
1213 }
1214 if !matches!(phase.as_str(), "setup" | "call" | "teardown") {
1215 return Err(invalid("unknown pytest phase"));
1216 }
1217 if test.trim().is_empty() || worker.trim().is_empty() {
1218 return Err(invalid("phase identity must name a worker and test"));
1219 }
1220 if phase == "call" {
1221 before_assertion.insert(ctx, Observations::default());
1222 }
1223 contexts.insert(
1224 ctx,
1225 Identity {
1226 worker,
1227 test,
1228 retry,
1229 phase,
1230 },
1231 );
1232 }
1233 Record::Outcome {
1234 worker,
1235 test,
1236 retry,
1237 phase,
1238 outcome,
1239 xfail,
1240 runner,
1241 file,
1242 } => {
1243 if !matches!(phase.as_str(), "setup" | "call" | "teardown") {
1244 return Err(invalid("unknown test outcome phase"));
1245 }
1246 if !matches!(
1247 outcome.as_str(),
1248 "passed" | "failed" | "skipped" | "rerun" | "error"
1249 ) {
1250 return Err(invalid("unknown test outcome"));
1251 }
1252 if !matches!(runner.as_str(), PYTEST_RUNNER | UNITTEST_RUNNER) {
1253 return Err(invalid("unknown Python test runner"));
1254 }
1255 let key = (worker, test, retry);
1256 if let Some(previous) = evidence.runners.get(&key)
1257 && previous != &runner
1258 {
1259 return Err(invalid("one attempt was reported by two runners"));
1260 }
1261 evidence.runners.insert(key.clone(), runner);
1262 if let Some(file) = file.filter(|path| !path.is_empty()) {
1263 evidence.test_files.entry(key.clone()).or_insert(file);
1264 }
1265 fold_outcome(
1266 evidence.outcomes.entry(key).or_default(),
1267 phase,
1268 outcome,
1269 xfail,
1270 );
1271 }
1272 Record::Hit { ctx, id } => {
1273 if let Some(before) = before_assertion.get_mut(&ctx) {
1274 before.hits.insert(id.clone());
1275 }
1276 observations(
1277 evidence,
1278 &contexts,
1279 process_worker.as_deref(),
1280 ctx,
1281 name,
1282 line_number,
1283 )?
1284 .hits
1285 .insert(id);
1286 }
1287 Record::Hits { ids, .. } if ids.is_empty() => {}
1289 Record::SlotHits { positions, .. } if positions.is_empty() => {}
1290 Record::SlotHits { ctx, positions } => {
1291 if let Some(before) = before_assertion.get_mut(&ctx) {
1292 before.slot_hits.extend(&positions);
1293 }
1294 observations(
1295 evidence,
1296 &contexts,
1297 process_worker.as_deref(),
1298 ctx,
1299 name,
1300 line_number,
1301 )?
1302 .slot_hits
1303 .extend(positions);
1304 }
1305 Record::Decs { v, .. } if v.is_empty() => {}
1306 Record::Hits { ctx, ids } => {
1307 if let Some(before) = before_assertion.get_mut(&ctx) {
1308 before.hits.extend(ids.iter().cloned());
1309 }
1310 observations(
1312 evidence,
1313 &contexts,
1314 process_worker.as_deref(),
1315 ctx,
1316 name,
1317 line_number,
1318 )?
1319 .hits
1320 .extend(ids);
1321 }
1322 Record::Dec { ctx, id, v, o } => {
1323 if v.is_empty() || !v.bytes().all(|digit| matches!(digit, b'0' | b'1' | b'2')) {
1324 return Err(invalid("decision vector digits must be 0, 1 or 2"));
1325 }
1326 if o > 1 {
1327 return Err(invalid("decision outcome must be 0 or 1"));
1328 }
1329 let values = v
1330 .bytes()
1331 .map(|digit| match digit {
1332 b'0' => None,
1333 b'1' => Some(false),
1334 _ => Some(true),
1335 })
1336 .collect::<Vec<_>>();
1337 if let Some(before) = before_assertion.get_mut(&ctx) {
1338 before
1339 .vectors
1340 .entry(id.clone())
1341 .or_default()
1342 .insert((values.clone(), o == 1));
1343 }
1344 observations(
1345 evidence,
1346 &contexts,
1347 process_worker.as_deref(),
1348 ctx,
1349 name,
1350 line_number,
1351 )?
1352 .vectors
1353 .entry(id)
1354 .or_default()
1355 .insert((values, o == 1));
1356 }
1357 Record::Decs { ctx, v: vectors } => {
1358 let mut before = before_assertion.get_mut(&ctx);
1359 let observed = observations(
1360 evidence,
1361 &contexts,
1362 process_worker.as_deref(),
1363 ctx,
1364 name,
1365 line_number,
1366 )?;
1367 for (id, v, o) in vectors {
1368 if v.is_empty() || !v.bytes().all(|digit| matches!(digit, b'0' | b'1' | b'2')) {
1369 return Err(invalid("decision vector digits must be 0, 1 or 2"));
1370 }
1371 if o > 1 {
1372 return Err(invalid("decision outcome must be 0 or 1"));
1373 }
1374 let values = v
1375 .bytes()
1376 .map(|digit| match digit {
1377 b'0' => None,
1378 b'1' => Some(false),
1379 _ => Some(true),
1380 })
1381 .collect::<Vec<_>>();
1382 if let Some(before) = before.as_mut() {
1383 before
1384 .vectors
1385 .entry(id.clone())
1386 .or_default()
1387 .insert((values.clone(), o == 1));
1388 }
1389 observed
1390 .vectors
1391 .entry(id)
1392 .or_default()
1393 .insert((values, o == 1));
1394 }
1395 }
1396 Record::Assert { ctx } => {
1397 if let Some(before) = before_assertion.remove(&ctx) {
1400 let identity =
1401 contexts
1402 .get(&ctx)
1403 .ok_or(PythonEvidenceError::UnknownContext {
1404 file: name.into(),
1405 line: line_number,
1406 context: ctx,
1407 })?;
1408 let asserted = evidence
1409 .per_identity
1410 .entry(Identity {
1411 phase: "assertion".into(),
1412 ..identity.clone()
1413 })
1414 .or_default();
1415 asserted.hits.extend(before.hits);
1416 asserted.slot_hits.extend(before.slot_hits);
1417 for (id, vectors) in before.vectors {
1418 asserted.vectors.entry(id).or_default().extend(vectors);
1419 }
1420 }
1421 }
1422 Record::Asite { ctx, f, l } => {
1423 if f.is_empty() || l == 0 {
1424 return Err(invalid("assertion site needs a file and a line"));
1425 }
1426 let identity = contexts
1427 .get(&ctx)
1428 .ok_or(PythonEvidenceError::UnknownContext {
1429 file: name.into(),
1430 line: line_number,
1431 context: ctx,
1432 })?;
1433 if identity.phase == "call" {
1436 let key = (
1437 identity.worker.clone(),
1438 identity.test.clone(),
1439 identity.retry,
1440 );
1441 let sites = evidence.sites.entry(key).or_default();
1442 let site = (f, l);
1443 if !sites.contains(&site) {
1444 sites.push(site);
1445 }
1446 }
1447 }
1448 Record::Limitation {
1449 id,
1450 reason,
1451 file,
1452 obligation,
1453 } => {
1454 evidence
1458 .limitations
1459 .entry((id.clone(), file.clone(), obligation.clone()))
1460 .or_insert(RuntimeLimitation {
1461 id,
1462 reason,
1463 file,
1464 obligation,
1465 });
1466 }
1467 Record::Runs { .. } => return Err(invalid("slot runs were not expanded")),
1469 Record::Exit { .. } => {}
1470 }
1471 Ok(())
1472 };
1473 for (line_number, record) in parsed.records {
1474 apply(line_number, record)?;
1475 }
1476 Ok(())
1477}
1478
1479fn observations<'a>(
1480 evidence: &'a mut Evidence,
1481 contexts: &BTreeMap<u64, Identity>,
1482 process_worker: Option<&str>,
1483 context: u64,
1484 file: &str,
1485 line: usize,
1486) -> Result<&'a mut Observations, PythonEvidenceError> {
1487 if context == 0 {
1488 return Ok(evidence
1489 .background
1490 .entry(process_worker.unwrap_or("main").to_owned())
1491 .or_default());
1492 }
1493 let identity = contexts
1494 .get(&context)
1495 .ok_or(PythonEvidenceError::UnknownContext {
1496 file: file.into(),
1497 line,
1498 context,
1499 })?;
1500 Ok(evidence.per_identity.entry(identity.clone()).or_default())
1501}
1502
1503pub fn python_coverage_model() -> CoverageModelDeclaration {
1506 CoverageModelDeclaration {
1507 language: "python".into(),
1508 variant: "python-owned-probes".into(),
1509 name: PYTHON_FRONTEND_VERSION.into(),
1510 completeness_meaning: "Every statement, function, decision vector, loop, short-circuit, match and exception-flow obligation Supercov derived from the source was observed through probes compiled into the code at import, with exact test identity; the declared runtime limitations remain separate.".into(),
1511 measured: vec![
1512 "executable statements, each by a probe placed before it".into(),
1513 "function and lambda entry".into(),
1514 "boolean decision vectors with masking MC/DC, each condition's value taken as the program evaluated it".into(),
1515 "for-loop and comprehension zero-versus-entered iteration".into(),
1516 "logical and/or short-circuit alternatives".into(),
1517 "match case selection and guards".into(),
1518 "try completion, handler selection and exception propagation".into(),
1519 "pytest and unittest worker, test, retry and setup/call/teardown phase identity".into(),
1520 "evidence a test recorded before its first assertion, linked to that assertion when the test passes".into(),
1521 ],
1522 not_measured: vec![
1523 "causal linkage to individual actions, or to any assertion after a test's first".into(),
1524 "code compiled from strings at runtime".into(),
1525 "measured modules imported before Supercov installed, or compiled past the import system; the run names each as a limitation".into(),
1526 "causal test context for raw _thread or native-extension-created threads".into(),
1527 "child coverage outside subprocess.Popen and multiprocessing adapters".into(),
1528 "all input values, semantic partitions, paths, or concurrency interleavings".into(),
1529 "mutation score or assertion fault-detection strength".into(),
1530 ],
1531 }
1532}
1533
1534fn phase_id(run: &str, identity: &Identity) -> String {
1535 stable_id(
1536 "python-phase",
1537 &[
1538 run,
1539 &identity.worker,
1540 &identity.test,
1541 &identity.retry.to_string(),
1542 &identity.phase,
1543 ],
1544 )
1545}
1546
1547fn scope(run: &str, worker: &str, test: &str, retry: usize) -> ExecutionScope {
1548 ExecutionScope {
1549 version: 1,
1550 run_id: run.into(),
1551 worker_id: worker.into(),
1552 test_id: test.into(),
1553 test_key: stable_id("python-test", &[worker, test]),
1554 retry,
1555 attempt_id: stable_id("python-attempt", &[run, worker, test, &retry.to_string()]),
1556 }
1557}
1558
1559struct ManifestIndex<'a> {
1560 points: crate::interned::FastSet<&'a str>,
1561 alternatives: crate::interned::FastSet<&'a str>,
1562 decisions: crate::interned::FastMap<&'a str, &'a DecisionMeta>,
1563 lines: BTreeMap<&'a str, (&'a str, usize)>,
1564 files: BTreeMap<&'a str, Vec<&'a str>>,
1565}
1566
1567impl<'a> ManifestIndex<'a> {
1568 fn new(manifest: &'a CoverageManifest) -> Self {
1569 let mut lines = BTreeMap::new();
1570 for point in &manifest.points {
1571 lines.insert(point.id.as_str(), (point.file.as_str(), point.line));
1572 }
1573 for decision in &manifest.decisions {
1574 lines.insert(
1575 decision.id.as_str(),
1576 (decision.file.as_str(), decision.line),
1577 );
1578 }
1579 for branch in &manifest.branches {
1580 lines.insert(branch.id.as_str(), (branch.file.as_str(), branch.line));
1581 }
1582 let mut files = BTreeMap::<&str, Vec<&str>>::new();
1583 for (id, (file, _)) in &lines {
1584 files.entry(*file).or_default().push(*id);
1585 }
1586 Self {
1587 points: manifest
1588 .points
1589 .iter()
1590 .map(|point| point.id.as_str())
1591 .collect(),
1592 alternatives: manifest
1593 .branches
1594 .iter()
1595 .flat_map(|branch| branch.alternatives.iter().map(|alt| alt.id.as_str()))
1596 .collect(),
1597 decisions: manifest
1598 .decisions
1599 .iter()
1600 .map(|decision| (decision.id.as_str(), decision))
1601 .collect(),
1602 lines,
1603 files,
1604 }
1605 }
1606}
1607
1608fn snapshot(
1609 index: &ManifestIndex<'_>,
1610 observations: &Observations,
1611 slot_ids: &[String],
1612 phase: &str,
1613) -> Result<RuntimeSnapshot, PythonEvidenceError> {
1614 let mut positions = observations.slot_hits.clone();
1615 positions.sort_unstable();
1616 positions.dedup();
1617 let mut hits = BTreeSet::new();
1618 for id in observations.hits.iter().map(String::as_str).chain(
1619 positions
1620 .iter()
1621 .map(|position| slot_ids[*position as usize].as_str()),
1622 ) {
1623 if !index.points.contains(id) && !index.alternatives.contains(id) {
1624 return Err(PythonEvidenceError::UnknownObligation(id.to_owned()));
1625 }
1626 hits.insert(id);
1627 }
1628 let hits = hits.into_iter().map(str::to_owned).collect::<BTreeSet<_>>();
1629 let mut decisions = Vec::new();
1630 for (id, vectors) in &observations.vectors {
1631 let Some(meta) = index.decisions.get(id.as_str()) else {
1632 return Err(PythonEvidenceError::UnknownObligation(id.clone()));
1633 };
1634 let mut observed = Vec::new();
1635 for (values, outcome) in vectors {
1636 if values.len() != meta.conditions.len() {
1637 return Err(PythonEvidenceError::InvalidVector {
1638 id: id.clone(),
1639 expected: meta.conditions.len(),
1640 actual: values.len(),
1641 });
1642 }
1643 observed.push(McdcVector {
1644 values: values.clone(),
1645 outcome: *outcome,
1646 });
1647 }
1648 decisions.push(DecisionSnapshot {
1649 meta: (*meta).clone(),
1650 vectors: observed,
1651 });
1652 }
1653 Ok(RuntimeSnapshot {
1656 decisions,
1657 hits: hits.into_iter().collect(),
1658 events: Vec::new(),
1659 logicals: Vec::new(),
1660 phase_id: Some(phase.into()),
1661 })
1662}
1663
1664fn fold_outcome(
1673 outcomes: &mut Vec<(String, String, bool)>,
1674 phase: String,
1675 outcome: String,
1676 xfail: bool,
1677) {
1678 let Some(reported) = outcomes.iter_mut().find(|(name, _, _)| *name == phase) else {
1679 outcomes.push((phase, outcome, xfail));
1680 return;
1681 };
1682 if outcome_severity(&outcome) > outcome_severity(&reported.1) {
1683 reported.1 = outcome;
1684 }
1685 reported.2 |= xfail;
1686}
1687
1688fn outcome_severity(outcome: &str) -> u8 {
1690 match outcome {
1691 "failed" | "rerun" | "error" => 2,
1692 "skipped" => 1,
1693 _ => 0,
1694 }
1695}
1696
1697fn attempt_status(outcomes: &[(String, String, bool)]) -> String {
1698 if outcomes
1699 .iter()
1700 .any(|(_, outcome, _)| matches!(outcome.as_str(), "failed" | "rerun" | "error"))
1701 {
1702 "failed"
1703 } else if outcomes.iter().any(|(_, outcome, _)| outcome == "skipped") {
1704 "skipped"
1705 } else {
1706 "passed"
1707 }
1708 .into()
1709}
1710
1711#[derive(Debug, Clone, PartialEq)]
1712pub struct PythonFrontendRun {
1713 pub declaration: FrontendRunDeclaration,
1714 pub request: CoverageReportRequest,
1715 pub tests: usize,
1716 pub interpreters: usize,
1717 pub python_versions: Vec<String>,
1718}
1719
1720impl PythonFrontendRun {
1721 pub fn archive_entries(&self) -> Result<Vec<EvidenceArchiveEntry>, serde_json::Error> {
1722 let model = PersistedCoverageModel::from_declaration(
1723 self.request
1724 .coverage_model
1725 .as_ref()
1726 .expect("Python frontend always declares a coverage model"),
1727 )
1728 .expect("Python coverage model is contract-valid");
1729 let mut entries = vec![
1730 EvidenceArchiveEntry {
1731 path: "coverage-model.json".into(),
1732 contents: serde_json::to_vec(&model)?,
1733 },
1734 EvidenceArchiveEntry {
1735 path: "frontend.json".into(),
1736 contents: serde_json::to_vec(&self.declaration)?,
1737 },
1738 EvidenceArchiveEntry {
1739 path: "manifest.json".into(),
1740 contents: serde_json::to_vec(&self.request.manifest)?,
1741 },
1742 ];
1743 for (index, result) in self.request.raw_results.iter().enumerate() {
1744 entries.push(EvidenceArchiveEntry {
1745 path: format!("results/{index:08}/mcdc.json"),
1746 contents: serde_json::to_vec(result)?,
1747 });
1748 }
1749 Ok(entries)
1750 }
1751}
1752
1753pub fn build_python_frontend_run(
1756 manifest: &CoverageManifest,
1757 evidence_directory: &Path,
1758 run_id: &str,
1759 generated_at: &str,
1760 test_exit_code: i32,
1761 assertions: &PythonAssertionInventory,
1762) -> Result<PythonFrontendRun, PythonEvidenceError> {
1763 let evidence = read_evidence_directory(evidence_directory, run_id)?;
1764 if evidence.interpreters == 0 {
1765 return Err(PythonEvidenceError::NoInterpreter);
1766 }
1767 if evidence.outcomes.is_empty() {
1768 return Err(PythonEvidenceError::NoTests);
1769 }
1770 let Evidence {
1771 interpreters,
1772 python_versions,
1773 per_identity,
1774 background,
1775 outcomes,
1776 runners,
1777 test_files,
1778 sites,
1779 limitations,
1780 slot_ids,
1781 } = evidence;
1782 let mut manifest = manifest.clone();
1783 let index = ManifestIndex::new(&manifest);
1784
1785 let mut raw_results = Vec::new();
1786 let mut observed_runners = BTreeSet::new();
1787 let mut identities_by_attempt =
1788 BTreeMap::<(String, String, usize), Vec<(&Identity, &Observations)>>::new();
1789 for (identity, observations) in &per_identity {
1790 identities_by_attempt
1791 .entry((
1792 identity.worker.clone(),
1793 identity.test.clone(),
1794 identity.retry,
1795 ))
1796 .or_default()
1797 .push((identity, observations));
1798 }
1799 let phases_observed = per_identity.iter().collect::<Vec<_>>();
1802 let mut snapshots = phases_observed
1803 .iter()
1804 .map(|(identity, _)| *identity)
1805 .zip(parallel_map(
1806 &phases_observed,
1807 |(identity, observations)| {
1808 snapshot(&index, observations, &slot_ids, &phase_id(run_id, identity))
1809 },
1810 ))
1811 .collect::<BTreeMap<_, _>>();
1812 let mut take = |identity: &Identity| {
1813 snapshots
1814 .remove(identity)
1815 .expect("each phase's snapshot is taken once")
1816 };
1817 for ((worker, test, retry), mut outcomes) in outcomes {
1818 let runner = runners
1819 .get(&(worker.clone(), test.clone(), retry))
1820 .cloned()
1821 .unwrap_or_else(default_runner);
1822 let attempt_identities = identities_by_attempt
1823 .remove(&(worker.clone(), test.clone(), retry))
1824 .unwrap_or_default();
1825 observed_runners.insert(runner.clone());
1826 outcomes.sort_by_key(|(phase, _, _)| match phase.as_str() {
1827 "setup" => 0,
1828 "call" => 1,
1829 _ => 2,
1830 });
1831 let mut phases = Vec::new();
1832 let mut runtime = Vec::new();
1833 let mut observed_phases = BTreeSet::new();
1834 for (position, (phase_name, outcome, xfail)) in outcomes.iter().enumerate() {
1835 observed_phases.insert(phase_name.clone());
1836 let identity = Identity {
1837 worker: worker.clone(),
1838 test: test.clone(),
1839 retry,
1840 phase: phase_name.clone(),
1841 };
1842 let id = phase_id(run_id, &identity);
1843 phases.push(CoveragePhase {
1844 id: id.clone(),
1845 kind: match phase_name.as_str() {
1846 "call" => "test",
1847 value => value,
1848 }
1849 .into(),
1850 operation: format!("{runner} {phase_name}"),
1851 source: Some(test.clone()),
1852 caused_by_phase_id: None,
1853 started_at_ms: position as i64 * 2 + 1,
1854 ended_at_ms: Some(position as i64 * 2 + 2),
1855 status: Some(match outcome.as_str() {
1856 "rerun" | "error" => "failed".into(),
1857 value => value.into(),
1858 }),
1859 error: None,
1860 });
1861 if let Some((observed, _)) = attempt_identities
1862 .iter()
1863 .find(|(candidate, _)| candidate.phase == phase_name.as_str())
1864 {
1865 runtime.push(take(observed)?);
1866 }
1867 if phase_name != "call" {
1868 continue;
1869 }
1870 if let Some((identity, _)) = attempt_identities
1874 .iter()
1875 .find(|(candidate, _)| candidate.phase == "assertion")
1876 {
1877 observed_phases.insert("assertion".to_owned());
1878 let id = phase_id(run_id, identity);
1879 phases.push(CoveragePhase {
1880 id: id.clone(),
1881 kind: "assertion".into(),
1882 operation: format!("{runner} assertion"),
1883 source: Some(test.clone()),
1884 caused_by_phase_id: None,
1885 started_at_ms: position as i64 * 2 + 1,
1886 ended_at_ms: Some(position as i64 * 2 + 2),
1887 status: Some(
1888 if outcome == "passed" && !*xfail {
1889 "passed"
1890 } else {
1891 "failed"
1892 }
1893 .into(),
1894 ),
1895 error: None,
1896 });
1897 runtime.push(take(identity)?);
1898 let attempt = (worker.clone(), test.clone(), retry);
1904 let mut located = BTreeSet::new();
1908 for (path, line) in sites.get(&attempt).into_iter().flatten() {
1909 let Some(location) = assertions.locate(path, *line) else {
1910 continue;
1911 };
1912 if !located.insert(location.clone()) {
1913 continue;
1914 }
1915 phases.push(CoveragePhase {
1916 id: stable_id("python-assertion", &[run_id, &id, &location]),
1917 kind: "assertion".into(),
1918 operation: format!("{runner} assertion at {location}"),
1919 source: Some(location),
1920 caused_by_phase_id: Some(id.clone()),
1921 started_at_ms: position as i64 * 2 + 1,
1922 ended_at_ms: Some(position as i64 * 2 + 2),
1923 status: Some(
1924 if outcome == "passed" && !*xfail {
1925 "passed"
1926 } else {
1927 "failed"
1928 }
1929 .into(),
1930 ),
1931 error: None,
1932 });
1933 }
1934 }
1935 }
1936 for (identity, _) in attempt_identities {
1939 if !observed_phases.contains(&identity.phase) {
1940 let id = phase_id(run_id, identity);
1941 phases.push(CoveragePhase {
1942 id: id.clone(),
1943 kind: match identity.phase.as_str() {
1944 "call" => "test",
1945 value => value,
1946 }
1947 .into(),
1948 operation: format!("{runner} {}", identity.phase),
1949 source: Some(test.clone()),
1950 caused_by_phase_id: None,
1951 started_at_ms: phases.len() as i64 * 2 + 1,
1952 ended_at_ms: None,
1953 status: Some("failed".into()),
1954 error: Some("the phase started but the runner reported no outcome".into()),
1955 });
1956 runtime.push(take(identity)?);
1957 }
1958 }
1959 let status = if phases.iter().any(|phase| phase.error.is_some()) {
1960 "failed".into()
1961 } else {
1962 attempt_status(&outcomes)
1963 };
1964 raw_results.push(RawTestResult {
1965 test_id: Some(test.clone()),
1966 scope: Some(scope(run_id, &worker, &test, retry)),
1967 test: test.clone(),
1968 test_file: test_files
1972 .get(&(worker.clone(), test.clone(), retry))
1973 .and_then(|path| assertions.relative(path))
1974 .or_else(|| test.split("::").next().map(str::to_owned)),
1975 title: test.rsplit("::").next().map(str::to_owned),
1976 retry: Some(retry),
1977 status: Some(status),
1978 expected_status: Some(
1979 if outcomes.iter().any(|(_, _, xfail)| *xfail) {
1980 "failed"
1981 } else {
1982 "passed"
1983 }
1984 .into(),
1985 ),
1986 flaky: false,
1987 provenance: TestProvenance {
1988 runner: runner.clone(),
1989 kind: "unit".into(),
1990 project: None,
1991 source: PYTHON_FRONTEND_VERSION.into(),
1992 },
1993 role: "test".into(),
1994 attribution: crate::coverage_report::ATTRIBUTION_EXACT.into(),
1995 phases,
1996 runtime,
1997 browser: Vec::new(),
1998 server: Vec::new(),
1999 });
2000 }
2001 let default_observed = observed_runners
2004 .iter()
2005 .next()
2006 .cloned()
2007 .unwrap_or_else(default_runner);
2008 for ((worker, test, retry), identities) in identities_by_attempt {
2009 let runner = default_observed.clone();
2010 let mut phases = Vec::new();
2011 let mut runtime = Vec::new();
2012 for (position, (identity, _)) in identities.iter().enumerate() {
2013 let id = phase_id(run_id, identity);
2014 phases.push(CoveragePhase {
2015 id: id.clone(),
2016 kind: match identity.phase.as_str() {
2017 "call" => "test",
2018 value => value,
2019 }
2020 .into(),
2021 operation: format!("{runner} {}", identity.phase),
2022 source: Some(test.clone()),
2023 caused_by_phase_id: None,
2024 started_at_ms: position as i64 * 2 + 1,
2025 ended_at_ms: None,
2026 status: Some("failed".into()),
2027 error: Some("the phase started but the runner reported no outcome".into()),
2028 });
2029 runtime.push(take(identity)?);
2030 }
2031 raw_results.push(RawTestResult {
2032 test_id: Some(test.clone()),
2033 scope: Some(scope(run_id, &worker, &test, retry)),
2034 test: test.clone(),
2035 test_file: test_files
2039 .get(&(worker.clone(), test.clone(), retry))
2040 .and_then(|path| assertions.relative(path))
2041 .or_else(|| test.split("::").next().map(str::to_owned)),
2042 title: test.rsplit("::").next().map(str::to_owned),
2043 retry: Some(retry),
2044 status: Some("failed".into()),
2045 expected_status: Some("passed".into()),
2046 flaky: false,
2047 provenance: TestProvenance {
2048 runner: runner.clone(),
2049 kind: "unit".into(),
2050 project: None,
2051 source: PYTHON_FRONTEND_VERSION.into(),
2052 },
2053 role: "test".into(),
2054 attribution: crate::coverage_report::ATTRIBUTION_EXACT.into(),
2055 phases,
2056 runtime,
2057 browser: Vec::new(),
2058 server: Vec::new(),
2059 });
2060 }
2061 for (worker, observations) in &background {
2062 if observations.is_empty() {
2063 continue;
2064 }
2065 let test = format!("__supercov_background__:{worker}");
2066 let identity = Identity {
2067 worker: worker.clone(),
2068 test: test.clone(),
2069 retry: 0,
2070 phase: "background".into(),
2071 };
2072 let phase = phase_id(run_id, &identity);
2073 raw_results.push(RawTestResult {
2074 test_id: Some(test.clone()),
2075 scope: Some(scope(run_id, worker, &test, 0)),
2076 test: "Python import, collection and background execution".into(),
2077 test_file: None,
2078 title: None,
2079 retry: Some(0),
2080 status: Some("unknown".into()),
2081 expected_status: None,
2082 flaky: false,
2083 provenance: TestProvenance {
2084 runner: default_observed.clone(),
2085 kind: "unit".into(),
2086 project: None,
2087 source: PYTHON_FRONTEND_VERSION.into(),
2088 },
2089 role: "background".into(),
2090 attribution: crate::coverage_report::ATTRIBUTION_EXACT.into(),
2091 phases: vec![CoveragePhase {
2092 id: phase.clone(),
2093 kind: "background".into(),
2094 operation: "Python import and collection background".into(),
2095 source: None,
2096 caused_by_phase_id: None,
2097 started_at_ms: 0,
2098 ended_at_ms: Some(0),
2099 status: Some("passed".into()),
2100 error: None,
2101 }],
2102 runtime: vec![snapshot(&index, observations, &slot_ids, &phase)?],
2103 browser: Vec::new(),
2104 server: Vec::new(),
2105 });
2106 }
2107
2108 let mut limitation_ids = manifest
2112 .limitations
2113 .iter()
2114 .filter_map(|item| item.get("id").and_then(serde_json::Value::as_str))
2115 .map(str::to_owned)
2116 .collect::<BTreeSet<_>>();
2117 let mut unmeasured = manifest.unmeasured.iter().cloned().collect::<BTreeSet<_>>();
2118 let mut new_limitations = Vec::new();
2119 for limitation in limitations.values() {
2120 if let Some(obligation) = &limitation.obligation {
2121 if !index.lines.contains_key(obligation.as_str()) {
2122 return Err(PythonEvidenceError::UnknownObligation(obligation.clone()));
2123 }
2124 unmeasured.insert(obligation.clone());
2125 } else if let Some(file) = &limitation.file {
2126 if let Some(obligations) = index.files.get(file.as_str()) {
2131 unmeasured.extend(obligations.iter().map(|id| (*id).to_owned()));
2132 }
2133 }
2134 let id = if limitation.file.is_some() || limitation.obligation.is_some() {
2135 stable_id(
2136 &limitation.id,
2137 &[
2138 limitation.file.as_deref().unwrap_or_default(),
2139 limitation.obligation.as_deref().unwrap_or_default(),
2140 ],
2141 )
2142 } else {
2143 limitation.id.clone()
2144 };
2145 if limitation_ids.insert(id.clone()) {
2146 let (file, line) = limitation
2147 .obligation
2148 .as_deref()
2149 .and_then(|id| {
2150 index
2151 .lines
2152 .get(id)
2153 .map(|(file, line)| ((*file).to_owned(), *line))
2154 })
2155 .unwrap_or_else(|| {
2156 (
2157 limitation.file.clone().unwrap_or_else(|| {
2158 manifest
2159 .points
2160 .first()
2161 .map_or(".".into(), |point| point.file.clone())
2162 }),
2163 1,
2164 )
2165 });
2166 new_limitations.push(json!({
2167 "id": id,
2168 "code": limitation.id,
2169 "kind": "semantic-safety",
2170 "file": file,
2171 "line": line,
2172 "column": 0,
2173 "source": "",
2174 "reason": limitation.reason
2175 }));
2176 }
2177 }
2178 manifest.limitations.extend(new_limitations);
2179 manifest.unmeasured = unmeasured.into_iter().collect();
2180 let structural_limitations = limitation_ids.into_iter().collect::<Vec<_>>();
2181
2182 let tests = raw_results
2186 .iter()
2187 .filter(|raw| raw.role == "test")
2188 .map(|raw| raw.test.as_str())
2189 .collect::<BTreeSet<_>>()
2190 .len();
2191 Ok(PythonFrontendRun {
2192 declaration: FrontendRunDeclaration {
2193 protocol_version: LANGUAGE_FRONTEND_PROTOCOL_VERSION,
2194 frontend_id: "python".into(),
2195 frontend_version: PYTHON_FRONTEND_VERSION.into(),
2196 language: "python".into(),
2197 structural_source: StructuralSource::OwnedProbes,
2198 runners: observed_runners
2199 .iter()
2200 .map(|runner| FrontendRunnerDeclaration {
2201 runner: runner.clone(),
2202 execution_model: if runner == UNITTEST_RUNNER {
2203 ExecutionModel::SerialInProcess
2204 } else {
2205 ExecutionModel::ParallelContextPropagated
2206 },
2207 attribution: FrontendAttribution {
2208 run: AttributionPrecision::Exact,
2209 worker: AttributionPrecision::Exact,
2210 test: AttributionPrecision::Exact,
2211 retry: AttributionPrecision::Exact,
2212 phase: AttributionPrecision::Exact,
2213 action: AttributionPrecision::Unavailable,
2214 assertion: AttributionPrecision::Exact,
2215 },
2216 limitations: vec![FrontendLimitation {
2217 id: format!("python-{runner}-action-linkage"),
2218 scopes: vec![FrontendLimitationScope::Action],
2219 reason: format!("{runner} exposes no general action lifecycle"),
2220 }],
2221 })
2222 .collect(),
2223 structural_limitations,
2224 },
2225 request: CoverageReportRequest {
2226 run_id: run_id.into(),
2227 manifest,
2228 raw_results,
2229 generated_at: generated_at.into(),
2230 coverage_model: Some(python_coverage_model()),
2231 integrity: None,
2232 test_exit_code: ExitCodeInput::Present(Some(test_exit_code)),
2233 },
2234 tests,
2235 interpreters,
2236 python_versions: python_versions.into_iter().collect(),
2237 })
2238}
2239
2240#[cfg(test)]
2241mod tests {
2242 use std::time::{SystemTime, UNIX_EPOCH};
2243
2244 use super::*;
2245 use crate::{
2246 frontend_protocol::validate_frontend_report_request,
2247 python_instrumenter::build_python_obligations,
2248 };
2249
2250 fn temporary(name: &str) -> std::path::PathBuf {
2251 let nonce = SystemTime::now()
2252 .duration_since(UNIX_EPOCH)
2253 .unwrap()
2254 .as_nanos();
2255 let path = std::env::temp_dir().join(format!(
2256 "supercov-python-evidence-{}-{nonce}-{name}",
2257 std::process::id()
2258 ));
2259 fs::create_dir_all(&path).unwrap();
2260 path
2261 }
2262
2263 fn write_transport(path: &Path, records: &[serde_json::Value], dropped: u64) {
2264 write_transport_version(path, records, dropped, TRANSPORT_VERSION, None);
2265 }
2266
2267 fn write_transport_version(
2270 path: &Path,
2271 records: &[serde_json::Value],
2272 dropped: u64,
2273 version: u32,
2274 corrupt: Option<usize>,
2275 ) {
2276 let payloads = records
2277 .iter()
2278 .map(|record| serde_json::to_vec(record).unwrap())
2279 .collect::<Vec<_>>();
2280 let capacity = payloads
2281 .iter()
2282 .fold(TRANSPORT_HEADER_SIZE, |cursor, payload| {
2283 align_transport(cursor + TRANSPORT_RECORD_HEADER_SIZE + payload.len()).unwrap()
2284 })
2285 + 64;
2286 let mut bytes = vec![0_u8; capacity];
2287 bytes[..8].copy_from_slice(TRANSPORT_MAGIC);
2288 bytes[8..12].copy_from_slice(&version.to_le_bytes());
2289 bytes[12..16].copy_from_slice(&(TRANSPORT_HEADER_SIZE as u32).to_le_bytes());
2290 bytes[16..24].copy_from_slice(&(capacity as u64).to_le_bytes());
2291 bytes[24..32].copy_from_slice(&dropped.to_le_bytes());
2292 bytes[32..40].copy_from_slice(&1_u64.to_le_bytes());
2293 let mut cursor = TRANSPORT_HEADER_SIZE;
2294 for (index, payload) in payloads.into_iter().enumerate() {
2295 let payload_start = cursor + TRANSPORT_RECORD_HEADER_SIZE;
2296 let payload_end = payload_start + payload.len();
2297 bytes[payload_start..payload_end].copy_from_slice(&payload);
2298 bytes[cursor + 4..cursor + 8].copy_from_slice(&(payload.len() as u32).to_le_bytes());
2299 let expected = if version == TRANSPORT_VERSION_FNV {
2303 payload.iter().fold(0x811c_9dc5_u32, |value, byte| {
2304 (value ^ u32::from(*byte)).wrapping_mul(0x0100_0193)
2305 })
2306 } else {
2307 crc32fast::hash(&payload)
2308 };
2309 let checksum = expected ^ u32::from(corrupt == Some(index));
2310 bytes[cursor + 8..cursor + 12].copy_from_slice(&checksum.to_le_bytes());
2311 bytes[cursor] = 1;
2312 cursor = align_transport(payload_end).unwrap();
2313 }
2314 fs::write(path, bytes).unwrap();
2315 }
2316
2317 fn under(first: &str, rest: &str) -> String {
2322 let mut path = PathBuf::from(if cfg!(windows) {
2323 format!("C:\\{first}")
2324 } else {
2325 format!("/{first}")
2326 });
2327 for part in rest.split('/').filter(|part| !part.is_empty()) {
2328 path.push(part);
2329 }
2330 path.to_string_lossy().into_owned()
2331 }
2332
2333 fn inventory_of(root: &str, sites: &[(&str, usize, usize)]) -> PythonAssertionInventory {
2334 use crate::assertion_map::{Anchor, Files, Inputs, InventorySite};
2335 PythonAssertionInventory::new(
2336 Path::new(root),
2337 &Inputs {
2338 schema_version: 1,
2339 language: "python".into(),
2340 context_digest: "context".into(),
2341 files: Files::new(),
2342 assertions: sites
2343 .iter()
2344 .map(|(file, line, column)| InventorySite {
2345 at: Anchor {
2346 file: (*file).into(),
2347 line: *line,
2348 column: *column,
2349 text: "assert f(1) == 1".into(),
2350 },
2351 operation: "assert".into(),
2352 })
2353 .collect(),
2354 limitations: vec![],
2355 },
2356 )
2357 }
2358
2359 fn assertion_sources(run: &PythonFrontendRun) -> Vec<String> {
2360 run.request.raw_results[0]
2361 .phases
2362 .iter()
2363 .filter(|phase| phase.kind == "assertion")
2364 .filter_map(|phase| phase.source.clone())
2365 .collect()
2366 }
2367
2368 fn run_with_sites(
2369 name: &str,
2370 sites: &[serde_json::Value],
2371 outcome_file: Option<&str>,
2372 inventory: &PythonAssertionInventory,
2373 ) -> PythonFrontendRun {
2374 let source = "def f(a):\n return a\n";
2375 let obligations = build_python_obligations("m.py", source).unwrap();
2376 let mut outcome = json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call","outcome":"passed","xfail":false});
2377 if let Some(file) = outcome_file {
2378 outcome["file"] = json!(file);
2379 }
2380 let mut lines = vec![
2381 json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.14.4","executable":"python","argv":["pytest"]}),
2382 json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call"}),
2383 json!({"t":"assert","ctx":1}),
2384 ];
2385 lines.extend(sites.iter().cloned());
2386 lines.push(outcome);
2387 lines.push(json!({"t":"exit","at":9}));
2388 let directory = temporary(name);
2389 write_transport(&directory.join("main.1.mmap"), &lines, 0);
2390 let run = build_python_frontend_run(
2391 &obligations.manifest,
2392 &directory,
2393 "run-1",
2394 "now",
2395 0,
2396 inventory,
2397 )
2398 .unwrap();
2399 validate_frontend_report_request(&run.declaration, &run.request).unwrap();
2400 fs::remove_dir_all(directory).unwrap();
2401 run
2402 }
2403
2404 const SLOT_DIGEST: &str = "0123456789abcdef0123456789abcdef";
2405
2406 fn slot_layout() -> serde_json::Value {
2410 json!({
2411 "version": 2, "digest": SLOT_DIGEST, "header": 16, "bytes": 36,
2412 "ids": ["s1", "s2"],
2413 "decisions": [{
2414 "start": 18, "width": 2, "id": "d",
2415 "outcomeTrue": "d:true", "outcomeFalse": "d:false",
2416 "logical": [{"evaluated": "or:evaluated", "shortCircuit": "or:short",
2417 "previousLeaves": [0], "operandLeaves": [1]}]
2418 }]
2419 })
2420 }
2421
2422 fn slot_bytes(context: u64, tag: Option<&str>, set: &[usize]) -> Vec<u8> {
2424 let mut bytes = vec![0_u8; 36];
2425 bytes[..8].copy_from_slice(&context.to_le_bytes());
2426 if let Some(tag) = tag {
2427 for index in 0..8 {
2428 bytes[8 + index] = u8::from_str_radix(&tag[index * 2..index * 2 + 2], 16).unwrap();
2429 }
2430 }
2431 for index in set {
2432 bytes[*index] = 1;
2433 }
2434 bytes
2435 }
2436
2437 fn killed_process(name: &str, slot: &[u8]) -> PathBuf {
2440 let directory = temporary(name);
2441 write_transport(
2442 &directory.join("main.1.token.mmap"),
2443 &[
2444 json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.9.21","executable":"python","argv":["pytest"]}),
2445 json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call"}),
2446 ],
2447 0,
2448 );
2449 fs::write(directory.join("main.1.token.0.slot"), slot).unwrap();
2450 fs::write(
2451 directory.join("layout.json"),
2452 serde_json::to_vec(&slot_layout()).unwrap(),
2453 )
2454 .unwrap();
2455 directory
2456 }
2457
2458 fn call_observations(evidence: &Evidence) -> Observations {
2459 evidence
2460 .per_identity
2461 .iter()
2462 .find(|(identity, _)| identity.phase == "call")
2463 .map(|(_, observations)| Observations {
2464 hits: observations
2466 .hits
2467 .iter()
2468 .cloned()
2469 .chain(
2470 observations
2471 .slot_hits
2472 .iter()
2473 .map(|position| evidence.slot_ids[*position as usize].clone()),
2474 )
2475 .collect(),
2476 slot_hits: Vec::new(),
2477 vectors: observations.vectors.clone(),
2478 })
2479 .unwrap_or_default()
2480 }
2481
2482 #[test]
2483 fn a_killed_process_slot_is_read_as_its_last_records() {
2484 let directory = killed_process(
2487 "py-slot-killed",
2488 &slot_bytes(1, Some(SLOT_DIGEST), &[16, 18 + 15]),
2489 );
2490 let evidence = read_evidence_directory(&directory, "run-1").unwrap();
2491 let call = call_observations(&evidence);
2492 assert_eq!(
2493 call.hits.iter().cloned().collect::<Vec<_>>(),
2494 ["d:true", "or:evaluated", "s1"],
2495 "the obligation, and what the vector implies"
2496 );
2497 assert_eq!(
2498 call.vectors["d"],
2499 BTreeSet::from([(vec![Some(false), Some(true)], true)])
2500 );
2501 fs::remove_dir_all(directory).unwrap();
2502 }
2503
2504 #[test]
2505 fn a_head_byte_is_read_with_the_bytes_it_stands_for() {
2506 let directory = temporary("py-slot-implied");
2510 write_transport(
2511 &directory.join("main.1.token.mmap"),
2512 &[
2513 json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.9.21","executable":"python","argv":["pytest"]}),
2514 json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call"}),
2515 json!({"t":"runs","ctx":1,"r":[17, 1]}),
2516 ],
2517 0,
2518 );
2519 let mut layout = slot_layout();
2520 layout["implied"] = json!([[17, [16, 18 + 15]]]);
2521 fs::write(
2522 directory.join("layout.json"),
2523 serde_json::to_vec(&layout).unwrap(),
2524 )
2525 .unwrap();
2526 let evidence = read_evidence_directory(&directory, "run-1").unwrap();
2527 let call = call_observations(&evidence);
2528 assert_eq!(
2529 call.hits.iter().cloned().collect::<Vec<_>>(),
2530 ["d:true", "or:evaluated", "s1", "s2"]
2531 );
2532 assert_eq!(
2533 call.vectors["d"],
2534 BTreeSet::from([(vec![Some(false), Some(true)], true)])
2535 );
2536 for implied in [
2539 json!([[17, [17]]]),
2540 json!([[17, [36]]]),
2541 json!([[17, [16]], [16, [18]]]),
2542 ] {
2543 layout["implied"] = implied;
2544 fs::write(
2545 directory.join("layout.json"),
2546 serde_json::to_vec(&layout).unwrap(),
2547 )
2548 .unwrap();
2549 assert!(read_evidence_directory(&directory, "run-1").is_err());
2550 }
2551 fs::remove_dir_all(directory).unwrap();
2552 }
2553
2554 #[test]
2555 fn a_slot_gone_since_the_listing_is_skipped_and_nothing_else_is() {
2556 let directory = temporary("py-slot-vanished");
2561 let gone = directory.join("main.1.token.0.slot");
2562 assert!(
2563 entry_metadata("main.1.token.0.slot", &gone)
2564 .unwrap()
2565 .is_none()
2566 );
2567 assert!(map_slot(&gone).unwrap().is_none());
2568 assert!(entry_metadata("main.1.token.mmap", &directory.join("main.1.token.mmap")).is_err());
2569 assert!(entry_metadata("layout.json", &directory.join("layout.json")).is_err());
2570 fs::write(&gone, slot_bytes(1, Some(SLOT_DIGEST), &[16])).unwrap();
2571 assert!(
2572 entry_metadata("main.1.token.0.slot", &gone)
2573 .unwrap()
2574 .is_some()
2575 );
2576 assert!(map_slot(&gone).unwrap().is_some());
2577 fs::remove_dir_all(directory).unwrap();
2578 }
2579
2580 #[test]
2581 fn a_slot_its_process_closed_is_not_counted() {
2582 let directory = killed_process("py-slot-closed", &slot_bytes(1, None, &[16]));
2585 let evidence = read_evidence_directory(&directory, "run-1").unwrap();
2586 assert!(call_observations(&evidence).hits.is_empty());
2587 fs::remove_dir_all(directory).unwrap();
2588 }
2589
2590 #[test]
2591 fn a_slot_is_read_only_against_its_own_layout() {
2592 let other = "fedcba9876543210fedcba9876543210";
2593 let directory = killed_process("py-slot-foreign", &slot_bytes(1, Some(other), &[16]));
2594 assert!(matches!(
2595 read_evidence_directory(&directory, "run-1"),
2596 Err(PythonEvidenceError::InvalidTransport { .. })
2597 ));
2598 fs::remove_dir_all(directory).unwrap();
2599
2600 let directory = killed_process("py-slot-byte", &slot_bytes(1, Some(SLOT_DIGEST), &[]));
2602 let mut bytes = slot_bytes(1, Some(SLOT_DIGEST), &[]);
2603 bytes[17] = 7;
2604 fs::write(directory.join("main.1.token.0.slot"), bytes).unwrap();
2605 assert!(read_evidence_directory(&directory, "run-1").is_err());
2606 fs::remove_dir_all(directory).unwrap();
2607 }
2608
2609 #[test]
2610 fn a_slot_needs_its_transport_and_a_partial_layout_is_ignored() {
2611 let directory = killed_process("py-slot-orphan", &slot_bytes(1, Some(SLOT_DIGEST), &[16]));
2612 fs::write(directory.join("layout.json.42.token.partial"), b"{").unwrap();
2614 assert!(read_evidence_directory(&directory, "run-1").is_ok());
2615 fs::write(
2616 directory.join("other.9.token.0.slot"),
2617 slot_bytes(1, Some(SLOT_DIGEST), &[16]),
2618 )
2619 .unwrap();
2620 assert!(matches!(
2621 read_evidence_directory(&directory, "run-1"),
2622 Err(PythonEvidenceError::InvalidTransport { .. })
2623 ));
2624 fs::remove_dir_all(directory).unwrap();
2625 }
2626
2627 #[test]
2628 fn an_assertion_site_becomes_a_located_phase_when_the_inventory_names_one() {
2629 let inventory = inventory_of(&under("project", ""), &[("tests/test_m.py", 6, 5)]);
2634 let run = run_with_sites(
2635 "py-asite-located",
2636 &[json!({"t":"asite","ctx":1,"f":under("project", "tests/test_m.py"),"l":6})],
2637 None,
2638 &inventory,
2639 );
2640 assert!(
2641 assertion_sources(&run).contains(&"tests/test_m.py:6:4".to_string()),
2642 "expected a located assertion phase, got {:?}",
2643 assertion_sources(&run)
2644 );
2645 }
2646
2647 #[test]
2648 fn an_ambiguous_or_foreign_assertion_site_witnesses_nothing() {
2649 let ambiguous = inventory_of(
2653 &under("project", ""),
2654 &[("tests/test_m.py", 6, 5), ("tests/test_m.py", 6, 30)],
2655 );
2656 let run = run_with_sites(
2657 "py-asite-ambiguous",
2658 &[json!({"t":"asite","ctx":1,"f":under("project", "tests/test_m.py"),"l":6})],
2659 None,
2660 &ambiguous,
2661 );
2662 assert_eq!(
2663 assertion_sources(&run),
2664 vec!["tests/test_m.py::test_a".to_string()],
2665 "only the per-test assertion phase should remain"
2666 );
2667
2668 let known = inventory_of(&under("project", ""), &[("tests/test_m.py", 6, 5)]);
2669 let outside = run_with_sites(
2670 "py-asite-outside",
2671 &[json!({"t":"asite","ctx":1,"f":under("elsewhere", "tests/test_m.py"),"l":6})],
2672 None,
2673 &known,
2674 );
2675 assert_eq!(
2676 assertion_sources(&outside),
2677 vec!["tests/test_m.py::test_a".to_string()]
2678 );
2679 }
2680
2681 #[test]
2682 fn the_runner_names_the_test_file_in_either_path_form() {
2683 let inventory = inventory_of(&under("project", ""), &[("tests/test_m.py", 6, 5)]);
2688 for reported in [
2689 under("project", "tests/test_m.py"),
2690 "tests/test_m.py".to_owned(),
2691 "./tests/test_m.py".to_owned(),
2692 ] {
2693 let run = run_with_sites("py-asite-file", &[], Some(reported.as_str()), &inventory);
2694 assert_eq!(
2695 run.request.raw_results[0].test_file.as_deref(),
2696 Some("tests/test_m.py"),
2697 "{reported} should resolve to the project path"
2698 );
2699 }
2700 let without = run_with_sites("py-asite-nofile", &[], None, &inventory);
2701 assert_eq!(
2702 without.request.raw_results[0].test_file.as_deref(),
2703 Some("tests/test_m.py"),
2704 "a pytest node id already begins with the file"
2705 );
2706 }
2707
2708 fn phased_run(name: &str) -> PythonFrontendRun {
2712 let source = "def f(a, b):\n if a and b:\n return 1\n return 2\n";
2713 let obligations = build_python_obligations("m.py", source).unwrap();
2714 let statements = &obligations.plan.statements;
2715 let decision = &obligations.manifest.decisions[0].id;
2716 let test = "tests/test_m.py::test_a";
2717 let lines = [
2718 json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.14.4","executable":"python","argv":["pytest"]}),
2719 json!({"t":"hit","ctx":0,"id":statements[0].id}),
2720 json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":test,"retry":0,"phase":"setup"}),
2721 json!({"t":"hits","ctx":1,"ids":[statements[0].id, statements[1].id]}),
2722 json!({"t":"outcome","worker":"main","test":test,"retry":0,"phase":"setup","outcome":"passed","xfail":false}),
2723 json!({"t":"phase","ctx":2,"at":6,"worker":"main","test":test,"retry":0,"phase":"call"}),
2724 json!({"t":"hit","ctx":2,"id":statements[1].id}),
2725 json!({"t":"dec","ctx":2,"id":decision,"v":"22","o":1}),
2726 json!({"t":"assert","ctx":2}),
2727 json!({"t":"hit","ctx":2,"id":statements[2].id}),
2728 json!({"t":"dec","ctx":2,"id":decision,"v":"21","o":0}),
2729 json!({"t":"outcome","worker":"main","test":test,"retry":0,"phase":"call","outcome":"passed","xfail":false}),
2730 json!({"t":"phase","ctx":3,"at":7,"worker":"main","test":test,"retry":0,"phase":"teardown"}),
2731 json!({"t":"hit","ctx":3,"id":statements[2].id}),
2732 json!({"t":"outcome","worker":"main","test":test,"retry":0,"phase":"teardown","outcome":"passed","xfail":false}),
2733 json!({"t":"exit","at":9}),
2734 ];
2735 let directory = temporary(name);
2736 write_transport(&directory.join("main.1.mmap"), &lines, 0);
2737 let run = build_python_frontend_run(
2738 &obligations.manifest,
2739 &directory,
2740 "run-1",
2741 "now",
2742 0,
2743 &PythonAssertionInventory::empty(),
2744 )
2745 .unwrap();
2746 validate_frontend_report_request(&run.declaration, &run.request).unwrap();
2747 fs::remove_dir_all(directory).unwrap();
2748 run
2749 }
2750
2751 #[test]
2752 fn a_snapshot_that_names_its_phase_analyses_as_the_events_it_replaces() {
2753 use crate::coverage_report::{RuntimeEvent, analyze_coverage_results};
2757 let run = phased_run("py-phase-named");
2758 let named = &run.request;
2759 assert!(
2760 named
2761 .raw_results
2762 .iter()
2763 .flat_map(|raw| &raw.runtime)
2764 .filter(|snapshot| snapshot.phase_id.is_some())
2765 .count()
2766 >= 4,
2767 "setup, call, assertion and teardown each name their phase"
2768 );
2769 let mut events = named.clone();
2770 for snapshot in events
2771 .raw_results
2772 .iter_mut()
2773 .flat_map(|raw| raw.runtime.iter_mut())
2774 {
2775 let Some(phase) = snapshot.phase_id.take() else {
2776 continue;
2777 };
2778 let mut clock = 1;
2779 let mut event = |event_type: &str, id: &str, vector: Option<McdcVector>| {
2780 clock += 1;
2781 RuntimeEvent {
2782 event_type: event_type.into(),
2783 id: id.into(),
2784 vector,
2785 timestamp_ms: clock,
2786 phase_id: Some(phase.clone()),
2787 statement_id: None,
2788 environment: "python".into(),
2789 }
2790 };
2791 let mut written = snapshot
2792 .hits
2793 .iter()
2794 .map(|hit| event("hit", hit, None))
2795 .collect::<Vec<_>>();
2796 for decision in &snapshot.decisions {
2797 for vector in &decision.vectors {
2798 written.push(event("decision", &decision.meta.id, Some(vector.clone())));
2799 }
2800 }
2801 snapshot.events = written;
2802 }
2803 let from_names = analyze_coverage_results(named).unwrap();
2804 let from_events = analyze_coverage_results(&events).unwrap();
2805 assert!(
2806 from_names
2807 .view
2808 .phases
2809 .iter()
2810 .any(|phase| phase.explicit_events > 1),
2811 "the fixture's phases carry explicit evidence"
2812 );
2813 assert_eq!(
2814 serde_json::to_value(&from_names).unwrap(),
2815 serde_json::to_value(&from_events).unwrap()
2816 );
2817 }
2818
2819 #[test]
2820 fn analysing_the_archived_request_is_analysing_the_archive() {
2821 use crate::coverage_report::{
2824 ArchiveReportRequest, ExitCodeInput, TransportStats, analyze_coverage_archive,
2825 analyze_frontend_request,
2826 };
2827 let run = phased_run("py-archived");
2828 let directory = temporary("py-archived-archive");
2829 let archive = directory.join("evidence.raw.gz");
2830 crate::evidence_archive::write_archive(run.archive_entries().unwrap(), &archive).unwrap();
2831 let integrity = json!({"schemaVersion": 2});
2832 let from_archive = analyze_coverage_archive(&ArchiveReportRequest {
2833 archive_path: archive,
2834 run_id: "run-1".into(),
2835 generated_at: "then".into(),
2836 integrity: Some(integrity.clone()),
2837 test_exit_code: ExitCodeInput::Present(Some(0)),
2838 })
2839 .unwrap();
2840 let in_memory = analyze_frontend_request(
2841 &run.declaration,
2842 &CoverageReportRequest {
2843 run_id: "run-1".into(),
2844 generated_at: "then".into(),
2845 integrity: Some(integrity),
2846 test_exit_code: ExitCodeInput::Present(Some(0)),
2847 ..run.request.clone()
2848 },
2849 TransportStats::none(),
2850 )
2851 .unwrap();
2852 fs::remove_dir_all(directory).unwrap();
2853 assert_eq!(
2854 serde_json::to_value(&in_memory).unwrap(),
2855 serde_json::to_value(&from_archive).unwrap()
2856 );
2857 }
2858
2859 #[test]
2860 fn evidence_before_the_first_assertion_links_to_it_when_the_test_passes() {
2861 let source = "def f(a):\n return a\n\n\ndef g(b):\n return b\n";
2866 let obligations = build_python_obligations("m.py", source).unwrap();
2867 let before = &obligations.plan.statements[0].id;
2868 let after = &obligations.plan.statements[1].id;
2869 let run_with = |name: &str, outcome: &str| {
2870 let lines = [
2871 json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.14.4","executable":"python","argv":["pytest"]}),
2872 json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call"}),
2873 json!({"t":"hit","ctx":1,"id":before}),
2874 json!({"t":"assert","ctx":1}),
2875 json!({"t":"assert","ctx":1}),
2876 json!({"t":"hit","ctx":1,"id":after}),
2877 json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call","outcome":outcome,"xfail":false}),
2878 json!({"t":"exit","at":9}),
2879 ];
2880 let directory = temporary(name);
2881 write_transport(&directory.join("main.1.mmap"), &lines, 0);
2882 let run = build_python_frontend_run(
2883 &obligations.manifest,
2884 &directory,
2885 "run-1",
2886 "now",
2887 0,
2888 &PythonAssertionInventory::empty(),
2889 )
2890 .unwrap();
2891 validate_frontend_report_request(&run.declaration, &run.request).unwrap();
2892 fs::remove_dir_all(directory).unwrap();
2893 run
2894 };
2895 let events_of = |result: &RawTestResult, phase: &str| -> BTreeSet<String> {
2896 result
2897 .runtime
2898 .iter()
2899 .filter(|snapshot| snapshot.phase_id.as_deref() == Some(phase))
2900 .flat_map(|snapshot| snapshot.hits.iter().cloned())
2901 .collect()
2902 };
2903
2904 let run = run_with("asserted-passed", "passed");
2905 let passed = &run.request.raw_results[0];
2906 assert_eq!(passed.test, "tests/test_m.py::test_a");
2907 let assertion = passed
2908 .phases
2909 .iter()
2910 .find(|phase| phase.kind == "assertion")
2911 .expect("the asserting test carries an assertion phase");
2912 assert_eq!(assertion.status.as_deref(), Some("passed"));
2913 assert_eq!(
2914 events_of(passed, &assertion.id),
2915 BTreeSet::from([before.clone()]),
2916 "only what ran before the marker is the assertion's evidence"
2917 );
2918 let test_phase = passed
2919 .phases
2920 .iter()
2921 .find(|phase| phase.kind == "test")
2922 .unwrap();
2923 assert_eq!(
2924 events_of(passed, &test_phase.id),
2925 BTreeSet::from([before.clone(), after.clone()]),
2926 "the test phase keeps everything it ran"
2927 );
2928 assert_eq!(
2929 run.declaration.runners[0].attribution.assertion,
2930 AttributionPrecision::Exact
2931 );
2932
2933 let run = run_with("asserted-failed", "failed");
2934 let failed = &run.request.raw_results[0];
2935 let assertion = failed
2936 .phases
2937 .iter()
2938 .find(|phase| phase.kind == "assertion")
2939 .unwrap();
2940 assert_eq!(
2941 assertion.status.as_deref(),
2942 Some("failed"),
2943 "a failed test's assertion witnessed nothing"
2944 );
2945 }
2946
2947 #[test]
2948 fn repeated_runtime_limitations_keep_each_affected_file_once() {
2949 let mut manifest = build_python_obligations("a.py", "x = 1\n")
2950 .unwrap()
2951 .manifest;
2952 let second = build_python_obligations("b.py", "y = 2\n")
2953 .unwrap()
2954 .manifest;
2955 manifest.points.extend(second.points);
2956 let directory = temporary("repeated-limitations");
2957 let mut records = vec![
2958 json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.11","executable":"python","argv":[]}),
2959 json!({"t":"outcome","worker":"main","test":"test_a","retry":0,"phase":"call","outcome":"passed","xfail":false}),
2960 ];
2961 for _ in 0..1000 {
2962 for file in ["a.py", "b.py"] {
2963 records.push(json!({"t":"limitation","id":"python-probes-unobserved-module","file":file,"reason":"entry script"}));
2964 }
2965 }
2966 write_transport(&directory.join("main.1.mmap"), &records, 0);
2967 let evidence = read_evidence_directory(&directory, "run-1").unwrap();
2968 assert_eq!(
2969 evidence.limitations.len(),
2970 2,
2971 "deduplicate before manifest expansion"
2972 );
2973 let run = build_python_frontend_run(
2974 &manifest,
2975 &directory,
2976 "run-1",
2977 "now",
2978 0,
2979 &PythonAssertionInventory::empty(),
2980 )
2981 .unwrap();
2982 validate_frontend_report_request(&run.declaration, &run.request).unwrap();
2983 let limitations = &run.request.manifest.limitations;
2984 assert_eq!(limitations.len(), 2);
2985 assert_ne!(limitations[0]["id"], limitations[1]["id"]);
2986 assert_eq!(limitations[0]["file"], "a.py");
2987 assert_eq!(limitations[1]["file"], "b.py");
2988 assert_eq!(limitations[0]["code"], "python-probes-unobserved-module");
2989 assert_eq!(run.request.manifest.unmeasured.len(), 2);
2990 fs::remove_dir_all(directory).unwrap();
2991 }
2992
2993 #[test]
2994 fn joins_phases_outcomes_hits_and_vectors_into_exact_results() {
2995 let source = "def f(a, b):\n if a and b:\n return 1\n return 0\n";
2996 let obligations = build_python_obligations("m.py", source).unwrap();
2997 let decision = &obligations.plan.decisions[0];
2998 let statement = &obligations.plan.statements[0];
2999 let directory = temporary("join");
3000 let lines = [
3001 json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.14.4","executable":"python","argv":["pytest"]}),
3002 json!({"t":"hit","ctx":0,"id":statement.id}),
3003 json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call"}),
3004 json!({"t":"dec","ctx":1,"id":decision.id,"v":"22","o":1}),
3005 json!({"t":"hit","ctx":1,"id":decision.outcome_true}),
3006 json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"setup","outcome":"passed","xfail":false}),
3007 json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call","outcome":"passed","xfail":false}),
3008 json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"teardown","outcome":"passed","xfail":false}),
3009 json!({"t":"limitation","id":"python-decision-partially-mapped","reason":"folded","obligation":decision.id}),
3010 json!({"t":"exit","at":9}),
3011 ];
3012 write_transport(&directory.join("main.1.mmap"), &lines, 0);
3013 let run = build_python_frontend_run(
3014 &obligations.manifest,
3015 &directory,
3016 "run-1",
3017 "now",
3018 0,
3019 &PythonAssertionInventory::empty(),
3020 )
3021 .unwrap();
3022 validate_frontend_report_request(&run.declaration, &run.request).unwrap();
3023 assert_eq!(run.tests, 1);
3024 assert_eq!(run.request.raw_results.len(), 2);
3025 let test = &run.request.raw_results[0];
3026 assert_eq!(test.status.as_deref(), Some("passed"));
3027 assert_eq!(test.phases.len(), 3);
3028 assert_eq!(test.runtime.len(), 1);
3029 assert_eq!(test.runtime[0].decisions.len(), 1);
3030 assert!(test.runtime[0].phase_id.is_some());
3031 assert!(test.runtime[0].events.is_empty());
3032 let background = &run.request.raw_results[1];
3033 assert_eq!(background.role, "background");
3034 assert!(run.request.manifest.unmeasured.contains(&decision.id));
3035 assert!(run.declaration.structural_limitations.contains(&stable_id(
3036 "python-decision-partially-mapped",
3037 &["", &decision.id]
3038 )));
3039 fs::remove_dir_all(directory).unwrap();
3040 }
3041
3042 #[test]
3047 fn a_transport_reads_by_its_own_versions_checksum() {
3048 let obligations = build_python_obligations("m.py", "x = 1\n").unwrap();
3049 let process = json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.14.4","executable":"p","argv":[]});
3050 let read = |name: &str, version: u32, corrupt: Option<usize>| {
3051 let directory = temporary(name);
3052 write_transport_version(
3053 &directory.join("main.1.mmap"),
3054 std::slice::from_ref(&process),
3055 0,
3056 version,
3057 corrupt,
3058 );
3059 let outcome = build_python_frontend_run(
3060 &obligations.manifest,
3061 &directory,
3062 "run-1",
3063 "now",
3064 0,
3065 &PythonAssertionInventory::empty(),
3066 );
3067 fs::remove_dir_all(directory).unwrap();
3068 outcome.map(|_| ()).map_err(|error| error.to_string())
3069 };
3070 let current = read("checksum-v2", TRANSPORT_VERSION, None);
3071 let older = read("checksum-v1", TRANSPORT_VERSION_FNV, None);
3072 assert_eq!(
3073 older, current,
3074 "a version 1 transport reads as a version 2 one does"
3075 );
3076 assert!(
3077 !current
3078 .as_ref()
3079 .err()
3080 .is_some_and(|error| error.contains("checksum")),
3081 "{current:?}"
3082 );
3083 let corrupted = read("checksum-bad", TRANSPORT_VERSION, Some(0)).unwrap_err();
3084 assert!(corrupted.contains("checksum does not match"), "{corrupted}");
3085 let unknown = read("checksum-v3", TRANSPORT_VERSION + 1, None).unwrap_err();
3086 assert!(unknown.contains("version"), "{unknown}");
3087 }
3088
3089 #[test]
3090 fn mixed_pytest_and_unittest_runs_have_distinct_runner_limitations() {
3091 let obligations = build_python_obligations("m.py", "def f():\n return 1\n").unwrap();
3092 let directory = temporary("mixed-runners");
3093 let mut records = vec![json!({"t":"process","v":1,"run":"run-1","pid":1,
3094 "worker":"main","python":"3.11.11","executable":"python","argv":["pytest"]})];
3095 for (index, runner) in [PYTEST_RUNNER, UNITTEST_RUNNER].iter().enumerate() {
3096 let test = format!("tests/test_m.py::test_{runner}");
3097 records.push(
3098 json!({"t":"phase","ctx":index+1,"at":index+5,"worker":"main",
3099 "test":test,"retry":0,"phase":"call"}),
3100 );
3101 records.push(json!({"t":"hit","ctx":index+1,"id":obligations.plan.statements[0].id}));
3102 records.push(json!({"t":"outcome","worker":"main","test":test,"retry":0,
3103 "phase":"call","outcome":"passed","xfail":false,"runner":runner}));
3104 }
3105 write_transport(&directory.join("main.1.mmap"), &records, 0);
3106 let run = build_python_frontend_run(
3107 &obligations.manifest,
3108 &directory,
3109 "run-1",
3110 "now",
3111 0,
3112 &PythonAssertionInventory::empty(),
3113 )
3114 .unwrap();
3115 assert_eq!(run.tests, 2);
3116 assert_eq!(run.declaration.runners.len(), 2);
3117 validate_frontend_report_request(&run.declaration, &run.request).unwrap();
3118 for runner in &run.declaration.runners {
3119 assert_eq!(runner.limitations.len(), 1);
3120 assert_eq!(runner.attribution.action, AttributionPrecision::Unavailable);
3121 assert_eq!(
3122 runner.limitations[0].scopes,
3123 [FrontendLimitationScope::Action]
3124 );
3125 }
3126 fs::remove_dir_all(directory).unwrap();
3127 }
3128
3129 #[test]
3130 fn a_phase_its_runner_reported_more_than_once_is_one_phase() {
3131 let source = "def f(a):\n return a\n";
3139 let obligations = build_python_obligations("m.py", source).unwrap();
3140 let directory = temporary("subtests");
3141 let subtest = |outcome: &str| {
3142 json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_many","retry":0,
3143 "phase":"call","outcome":outcome,"xfail":false})
3144 };
3145 let lines = [
3146 json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.14.4","executable":"python","argv":["pytest"]}),
3147 json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"tests/test_m.py::test_many","retry":0,"phase":"call"}),
3148 json!({"t":"hit","ctx":1,"id":obligations.plan.statements[0].id}),
3149 json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_many","retry":0,"phase":"setup","outcome":"passed","xfail":false}),
3150 subtest("passed"),
3151 subtest("passed"),
3152 subtest("failed"),
3153 subtest("passed"),
3154 json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_many","retry":0,"phase":"teardown","outcome":"passed","xfail":false}),
3155 json!({"t":"exit","at":9}),
3156 ];
3157 write_transport(&directory.join("main.1.mmap"), &lines, 0);
3158 let run = build_python_frontend_run(
3159 &obligations.manifest,
3160 &directory,
3161 "run-1",
3162 "now",
3163 1,
3164 &PythonAssertionInventory::empty(),
3165 )
3166 .unwrap();
3167 validate_frontend_report_request(&run.declaration, &run.request).unwrap();
3170 let test = &run.request.raw_results[0];
3171 assert_eq!(
3172 test.phases.len(),
3173 3,
3174 "setup, one call, teardown: {:?}",
3175 test.phases
3176 );
3177 assert_eq!(
3178 test.phases
3179 .iter()
3180 .filter(|phase| phase.kind == "test")
3181 .count(),
3182 1,
3183 "the subtests share the call phase their coverage was recorded under"
3184 );
3185 assert_eq!(
3186 test.status.as_deref(),
3187 Some("failed"),
3188 "a subtest that failed fails its test"
3189 );
3190 assert_eq!(test.runtime.len(), 1);
3192 assert!(!test.runtime[0].hits.is_empty());
3193 fs::remove_dir_all(directory).unwrap();
3194 }
3195
3196 #[test]
3197 fn fails_closed_without_an_interpreter_or_tests() {
3198 let obligations = build_python_obligations("m.py", "x = 1\n").unwrap();
3199 let directory = temporary("empty");
3200 assert!(matches!(
3201 build_python_frontend_run(
3202 &obligations.manifest,
3203 &directory,
3204 "run-1",
3205 "now",
3206 0,
3207 &PythonAssertionInventory::empty()
3208 ),
3209 Err(PythonEvidenceError::NoInterpreter)
3210 ));
3211 let path = directory.join("main.1.mmap");
3212 write_transport(
3213 &path,
3214 &[
3215 json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.14.4","executable":"p","argv":[]}),
3216 ],
3217 0,
3218 );
3219 assert!(matches!(
3220 build_python_frontend_run(
3221 &obligations.manifest,
3222 &directory,
3223 "run-1",
3224 "now",
3225 0,
3226 &PythonAssertionInventory::empty()
3227 ),
3228 Err(PythonEvidenceError::NoTests)
3229 ));
3230 let mut torn = fs::read(&path).unwrap();
3234 let process_length = transport_u32(&torn, TRANSPORT_HEADER_SIZE + 4).unwrap() as usize;
3235 let torn_cursor =
3236 align_transport(TRANSPORT_HEADER_SIZE + TRANSPORT_RECORD_HEADER_SIZE + process_length)
3237 .unwrap();
3238 torn[torn_cursor + 4..torn_cursor + 8].copy_from_slice(&5_u32.to_le_bytes());
3239 torn[torn_cursor + TRANSPORT_RECORD_HEADER_SIZE
3240 ..torn_cursor + TRANSPORT_RECORD_HEADER_SIZE + 5]
3241 .copy_from_slice(b"{nope");
3242 fs::write(&path, torn).unwrap();
3243 assert!(matches!(
3244 build_python_frontend_run(
3245 &obligations.manifest,
3246 &directory,
3247 "run-1",
3248 "now",
3249 0,
3250 &PythonAssertionInventory::empty()
3251 ),
3252 Err(PythonEvidenceError::NoTests)
3253 ));
3254 write_transport(
3255 &path,
3256 &[
3257 json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.8.20","executable":"p","argv":[]}),
3258 ],
3259 0,
3260 );
3261 assert!(matches!(
3262 build_python_frontend_run(
3263 &obligations.manifest,
3264 &directory,
3265 "run-1",
3266 "now",
3267 0,
3268 &PythonAssertionInventory::empty()
3269 ),
3270 Err(PythonEvidenceError::UnsupportedPython(_))
3271 ));
3272 write_transport(
3273 &path,
3274 &[
3275 json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.14.4","executable":"p","argv":[]}),
3276 ],
3277 2,
3278 );
3279 assert!(matches!(
3280 build_python_frontend_run(
3281 &obligations.manifest,
3282 &directory,
3283 "run-1",
3284 "now",
3285 0,
3286 &PythonAssertionInventory::empty()
3287 ),
3288 Err(PythonEvidenceError::DroppedRecords { count: 2, .. })
3289 ));
3290 fs::remove_dir_all(directory).unwrap();
3291 }
3292}