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