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, RuntimeEvent, 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 Dec {
120 ctx: u64,
121 id: String,
122 v: String,
123 o: u8,
124 },
125 Decs {
127 ctx: u64,
128 v: Vec<(String, String, u8)>,
129 },
130 Assert {
133 ctx: u64,
134 },
135 Asite {
140 ctx: u64,
141 f: String,
142 l: usize,
143 },
144 Limitation {
145 id: String,
146 reason: String,
147 #[serde(default)]
148 file: Option<String>,
149 #[serde(default)]
150 obligation: Option<String>,
151 },
152 Exit {
153 at: i64,
154 },
155}
156
157#[derive(Debug)]
158pub enum PythonEvidenceError {
159 Io(String),
160 UnsafeEntry(String),
161 InvalidRecord {
162 file: String,
163 line: usize,
164 reason: String,
165 },
166 InvalidTransport {
167 file: String,
168 reason: String,
169 },
170 DroppedRecords {
171 file: String,
172 count: u64,
173 },
174 RunMismatch {
175 expected: String,
176 actual: String,
177 },
178 UnsupportedVersion(u32),
179 UnknownContext {
180 file: String,
181 line: usize,
182 context: u64,
183 },
184 UnknownObligation(String),
185 InvalidVector {
186 id: String,
187 expected: usize,
188 actual: usize,
189 },
190 NoInterpreter,
191 NoTests,
192 UnsupportedPython(String),
193}
194
195impl std::fmt::Display for PythonEvidenceError {
196 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
197 match self {
198 Self::Io(reason) => write!(formatter, "could not read Python evidence: {reason}"),
199 Self::UnsafeEntry(name) => write!(formatter, "unsafe Python evidence entry: {name}"),
200 Self::InvalidRecord { file, line, reason } => {
201 write!(formatter, "invalid Python evidence record {file}:{line}: {reason}")
202 }
203 Self::InvalidTransport { file, reason } => {
204 write!(formatter, "invalid Python evidence transport {file}: {reason}")
205 }
206 Self::DroppedRecords { file, count } => write!(
207 formatter,
208 "Python evidence transport {file} exhausted its bounded capacity and dropped {count} record(s)"
209 ),
210 Self::RunMismatch { expected, actual } => write!(
211 formatter,
212 "Python evidence belongs to run {actual}, expected {expected}"
213 ),
214 Self::UnsupportedVersion(version) => {
215 write!(formatter, "unsupported Python evidence version {version}")
216 }
217 Self::UnknownContext { file, line, context } => write!(
218 formatter,
219 "Python evidence {file}:{line} references undeclared context {context}"
220 ),
221 Self::UnknownObligation(id) => {
222 write!(formatter, "Python runtime reported an unknown obligation: {id}")
223 }
224 Self::InvalidVector {
225 id,
226 expected,
227 actual,
228 } => write!(
229 formatter,
230 "Python decision {id} reported {actual} condition values, expected {expected}"
231 ),
232 Self::NoInterpreter => formatter.write_str(
233 "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)",
234 ),
235 Self::NoTests => formatter.write_str(
236 "the Python run produced no test outcomes; Supercov measures Python through pytest and unittest",
237 ),
238 Self::UnsupportedPython(version) => write!(
239 formatter,
240 "Supercov measures CPython 3.9 or newer; the test command ran Python {version}"
241 ),
242 }
243 }
244}
245
246impl std::error::Error for PythonEvidenceError {}
247
248fn stable_id(prefix: &str, values: &[&str]) -> String {
249 let mut hash = Sha256::new();
250 for value in values {
251 hash.update(value.as_bytes());
252 hash.update([0]);
253 }
254 let digest = hash.finalize();
255 let mut encoded = String::with_capacity(prefix.len() + 25);
256 encoded.push_str(prefix);
257 encoded.push(':');
258 for byte in &digest[..12] {
259 use std::fmt::Write as _;
260 write!(&mut encoded, "{byte:02x}").expect("string formatting");
261 }
262 encoded
263}
264
265#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
266struct Identity {
267 worker: String,
268 test: String,
269 retry: usize,
270 phase: String,
271}
272
273type ObservedVectors = BTreeSet<(Vec<Option<bool>>, bool)>;
274type OutcomesByAttempt = BTreeMap<(String, String, usize), Vec<(String, String, bool)>>;
276type RunnersByAttempt = BTreeMap<(String, String, usize), String>;
278type TestFilesByAttempt = BTreeMap<(String, String, usize), String>;
280type SitesByAttempt = BTreeMap<(String, String, usize), Vec<(String, usize)>>;
283
284pub struct PythonAssertionInventory {
293 root: PathBuf,
294 columns: BTreeMap<(String, usize), Vec<usize>>,
296}
297
298impl PythonAssertionInventory {
299 pub fn new(root: &Path, inputs: &crate::assertion_map::Inputs) -> Self {
300 let mut columns = BTreeMap::<(String, usize), Vec<usize>>::new();
301 for site in &inputs.assertions {
302 columns
303 .entry((site.at.file.clone(), site.at.line))
304 .or_default()
305 .push(site.at.column.saturating_sub(1));
308 }
309 for sites in columns.values_mut() {
310 sites.sort_unstable();
311 sites.dedup();
312 }
313 Self {
314 root: root.to_path_buf(),
315 columns,
316 }
317 }
318
319 pub fn empty() -> Self {
322 Self {
323 root: PathBuf::new(),
324 columns: BTreeMap::new(),
325 }
326 }
327
328 pub fn relative(&self, path: &str) -> Option<String> {
332 let candidate = Path::new(path);
333 let relative = if candidate.is_absolute() {
334 candidate.strip_prefix(&self.root).ok()?
335 } else {
336 candidate.strip_prefix("./").unwrap_or(candidate)
337 };
338 let text = relative.to_string_lossy().replace('\\', "/");
339 (!text.is_empty() && !text.starts_with("../")).then_some(text)
340 }
341
342 pub fn locate(&self, path: &str, line: usize) -> Option<String> {
346 let file = self.relative(path)?;
347 match self.columns.get(&(file.clone(), line))?.as_slice() {
348 [column] => Some(format!("{file}:{line}:{column}")),
349 _ => None,
350 }
351 }
352}
353
354#[derive(Debug, Default)]
355struct Observations {
356 hits: BTreeSet<String>,
357 vectors: BTreeMap<String, ObservedVectors>,
358}
359
360#[derive(Debug, Clone)]
361struct RuntimeLimitation {
362 id: String,
363 reason: String,
364 file: Option<String>,
365 obligation: Option<String>,
366}
367
368#[derive(Debug, Default)]
369struct Evidence {
370 interpreters: usize,
371 python_versions: BTreeSet<String>,
372 per_identity: BTreeMap<Identity, Observations>,
373 background: BTreeMap<String, Observations>,
374 outcomes: OutcomesByAttempt,
375 runners: RunnersByAttempt,
376 test_files: TestFilesByAttempt,
377 sites: SitesByAttempt,
378 limitations: Vec<RuntimeLimitation>,
379}
380
381fn read_evidence_directory(
382 directory: &Path,
383 run_id: &str,
384) -> Result<Evidence, PythonEvidenceError> {
385 let mut evidence = Evidence::default();
386 let mut files = match fs::read_dir(directory) {
387 Ok(entries) => entries
388 .collect::<Result<Vec<_>, _>>()
389 .map_err(|error| PythonEvidenceError::Io(error.to_string()))?,
390 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
391 Err(error) => return Err(PythonEvidenceError::Io(error.to_string())),
392 };
393 files.sort_by_key(|entry| entry.file_name());
394 let mut transports = Vec::new();
395 let mut slots = BTreeMap::<String, Vec<(String, PathBuf)>>::new();
396 let mut layout_path = None;
397 for entry in files {
398 let name = entry
399 .file_name()
400 .into_string()
401 .map_err(|_| PythonEvidenceError::UnsafeEntry("<non-utf8>".into()))?;
402 if Path::new(&name)
403 .components()
404 .any(|component| !matches!(component, Component::Normal(_)))
405 {
406 return Err(PythonEvidenceError::UnsafeEntry(name));
407 }
408 if name.starts_with(SLOT_LAYOUT_NAME) && name.ends_with(".partial") {
411 continue;
412 }
413 let metadata = fs::symlink_metadata(entry.path())
414 .map_err(|error| PythonEvidenceError::Io(error.to_string()))?;
415 if !metadata.file_type().is_file() {
416 return Err(PythonEvidenceError::UnsafeEntry(name));
417 }
418 if name == SLOT_LAYOUT_NAME {
419 layout_path = Some(entry.path());
420 } else if name.ends_with(".mmap") {
421 transports.push((name, entry.path()));
422 } else if let Some(transport) = name
423 .strip_suffix(".slot")
424 .and_then(|stem| stem.rsplit_once('.'))
425 .filter(|(_, number)| number.parse::<u64>().is_ok())
426 .map(|(transport, _)| format!("{transport}.mmap"))
427 {
428 if metadata.len() > 0 {
430 slots
431 .entry(transport)
432 .or_default()
433 .push((name, entry.path()));
434 }
435 } else {
436 return Err(PythonEvidenceError::UnsafeEntry(name));
437 }
438 }
439 let layout = match layout_path {
440 Some(path) => Some(SlotLayout::read(&path)?),
441 None => None,
442 };
443 for (name, path) in transports {
444 let contents = map_evidence(&path)?;
445 let slot_contents = slots
446 .remove(&name)
447 .unwrap_or_default()
448 .into_iter()
449 .map(|(slot, path)| map_evidence(&path).map(|contents| (slot, contents)))
450 .collect::<Result<Vec<_>, _>>()?;
451 read_evidence_file(
452 &name,
453 &contents,
454 run_id,
455 &mut evidence,
456 &slot_contents,
457 layout.as_ref(),
458 )?;
459 }
460 if let Some((_, orphans)) = slots.into_iter().next() {
461 return Err(PythonEvidenceError::InvalidTransport {
462 file: orphans[0].0.clone(),
463 reason: "a slot was left without its process's transport".into(),
464 });
465 }
466 Ok(evidence)
467}
468
469fn map_evidence(path: &Path) -> Result<Mmap, PythonEvidenceError> {
470 let file = File::open(path).map_err(|error| PythonEvidenceError::Io(error.to_string()))?;
471 unsafe { MmapOptions::new().map(&file) }
475 .map_err(|error| PythonEvidenceError::Io(error.to_string()))
476}
477
478const SLOT_LAYOUT_NAME: &str = "layout.json";
479const SLOT_MAX_REGION_WIDTH: usize = 6;
482
483#[derive(Debug, Deserialize)]
486#[serde(rename_all = "camelCase", deny_unknown_fields)]
487struct SlotLayout {
488 #[allow(dead_code)]
489 version: u32,
490 digest: String,
491 header: usize,
492 bytes: usize,
493 ids: Vec<String>,
494 decisions: Vec<SlotDecision>,
495}
496
497#[derive(Debug, Deserialize)]
498#[serde(rename_all = "camelCase", deny_unknown_fields)]
499struct SlotDecision {
500 start: usize,
501 width: usize,
502 id: String,
503 outcome_true: String,
504 outcome_false: String,
505 logical: Vec<SlotLogical>,
506}
507
508#[derive(Debug, Deserialize)]
509#[serde(rename_all = "camelCase", deny_unknown_fields)]
510struct SlotLogical {
511 evaluated: String,
512 short_circuit: String,
513 previous_leaves: Vec<usize>,
514 operand_leaves: Vec<usize>,
515}
516
517impl SlotDecision {
518 fn region_bytes(&self) -> usize {
521 if self.width == 1 {
522 2
523 } else {
524 2 * 3_usize.pow(self.width as u32)
525 }
526 }
527
528 fn vector(&self, offset: usize) -> (String, bool) {
530 if self.width == 1 {
531 let outcome = offset == 1;
532 return ((if outcome { "2" } else { "1" }).into(), outcome);
533 }
534 let mut mask = offset >> 1;
535 let digits = (0..self.width)
536 .map(|_| {
537 let digit = b"012"[mask % 3] as char;
538 mask /= 3;
539 digit
540 })
541 .collect();
542 (digits, offset & 1 == 1)
543 }
544
545 fn implied(&self, digits: &str, outcome: bool, into: &mut Vec<String>) {
548 into.push(
549 if outcome {
550 &self.outcome_true
551 } else {
552 &self.outcome_false
553 }
554 .clone(),
555 );
556 let evaluated = |leaves: &[usize]| {
557 leaves.iter().any(|index| {
558 digits
559 .as_bytes()
560 .get(*index)
561 .is_some_and(|digit| *digit != b'0')
562 })
563 };
564 for logical in &self.logical {
565 if evaluated(&logical.operand_leaves) {
566 into.push(logical.evaluated.clone());
567 } else if evaluated(&logical.previous_leaves) {
568 into.push(logical.short_circuit.clone());
569 }
570 }
571 }
572}
573
574impl SlotLayout {
575 fn read(path: &Path) -> Result<Self, PythonEvidenceError> {
576 let invalid = |reason: String| PythonEvidenceError::InvalidTransport {
577 file: SLOT_LAYOUT_NAME.into(),
578 reason,
579 };
580 let bytes = fs::read(path).map_err(|error| PythonEvidenceError::Io(error.to_string()))?;
581 let layout: Self =
582 serde_json::from_slice(&bytes).map_err(|error| invalid(error.to_string()))?;
583 if layout.digest.len() < 16 || !layout.digest.bytes().all(|byte| byte.is_ascii_hexdigit()) {
584 return Err(invalid("the layout digest is not hex".into()));
585 }
586 let mut cursor = layout.header + layout.ids.len();
589 for decision in &layout.decisions {
590 if decision.start != cursor
591 || decision.width == 0
592 || decision.width > SLOT_MAX_REGION_WIDTH
593 {
594 return Err(invalid(format!(
595 "decision {} has no valid region",
596 decision.id
597 )));
598 }
599 if decision
600 .logical
601 .iter()
602 .flat_map(|logical| {
603 logical
604 .previous_leaves
605 .iter()
606 .chain(&logical.operand_leaves)
607 })
608 .any(|leaf| *leaf >= decision.width)
609 {
610 return Err(invalid(format!(
611 "decision {} names a condition it lacks",
612 decision.id
613 )));
614 }
615 cursor += decision.region_bytes();
616 }
617 if cursor != layout.bytes || layout.header < 16 {
618 return Err(invalid("the regions do not fill the slot".into()));
619 }
620 Ok(layout)
621 }
622
623 fn tag(&self) -> Vec<u8> {
624 (0..8)
625 .map(|index| {
626 u8::from_str_radix(&self.digest[index * 2..index * 2 + 2], 16).unwrap_or(0)
627 })
628 .collect()
629 }
630
631 fn records(&self, name: &str, contents: &[u8]) -> Result<Vec<Record>, PythonEvidenceError> {
633 let invalid = |reason: &str| PythonEvidenceError::InvalidTransport {
634 file: name.into(),
635 reason: reason.into(),
636 };
637 let tag = contents
638 .get(8..16)
639 .ok_or_else(|| invalid("slot header is missing"))?;
640 if tag.iter().all(|byte| *byte == 0) {
643 return Ok(Vec::new());
644 }
645 if tag != self.tag().as_slice() {
646 return Err(invalid("slot was written for another layout"));
647 }
648 if contents.len() != self.bytes {
649 return Err(invalid("slot size does not match the layout"));
650 }
651 let context =
652 transport_u64(contents, 0).ok_or_else(|| invalid("slot context is missing"))?;
653 let id_end = self.header + self.ids.len();
654 let mut ids = Vec::new();
655 let mut vectors = Vec::new();
656 for (index, byte) in contents.iter().enumerate().skip(self.header) {
657 match byte {
658 0 => continue,
659 1 => {}
660 _ => return Err(invalid("slot byte is neither 0 nor 1")),
661 }
662 if index < id_end {
663 ids.push(self.ids[index - self.header].clone());
664 continue;
665 }
666 let decision = &self.decisions[self
667 .decisions
668 .partition_point(|decision| decision.start <= index)
669 - 1];
670 let (digits, outcome) = decision.vector(index - decision.start);
671 decision.implied(&digits, outcome, &mut ids);
672 vectors.push((decision.id.clone(), digits, u8::from(outcome)));
673 }
674 let mut records = Vec::new();
675 if !ids.is_empty() {
676 records.push(Record::Hits { ctx: context, ids });
677 }
678 if !vectors.is_empty() {
679 records.push(Record::Decs {
680 ctx: context,
681 v: vectors,
682 });
683 }
684 Ok(records)
685 }
686}
687
688fn transport_u32(bytes: &[u8], offset: usize) -> Option<u32> {
689 bytes
690 .get(offset..offset + 4)
691 .and_then(|value| value.try_into().ok())
692 .map(u32::from_le_bytes)
693}
694
695fn transport_u64(bytes: &[u8], offset: usize) -> Option<u64> {
696 bytes
697 .get(offset..offset + 8)
698 .and_then(|value| value.try_into().ok())
699 .map(u64::from_le_bytes)
700}
701
702fn transport_checksum(payload: &[u8], version: u32) -> u32 {
703 if version == TRANSPORT_VERSION_FNV {
704 return payload.iter().fold(0x811c_9dc5_u32, |value, byte| {
705 (value ^ u32::from(*byte)).wrapping_mul(0x0100_0193)
706 });
707 }
708 crc32fast::hash(payload)
709}
710
711fn align_transport(value: usize) -> Option<usize> {
712 value.checked_add(7).map(|value| value & !7)
713}
714
715fn read_evidence_file(
716 name: &str,
717 contents: &Mmap,
718 run_id: &str,
719 evidence: &mut Evidence,
720 slots: &[(String, Mmap)],
721 layout: Option<&SlotLayout>,
722) -> Result<(), PythonEvidenceError> {
723 let invalid_transport = |reason: &str| PythonEvidenceError::InvalidTransport {
724 file: name.into(),
725 reason: reason.into(),
726 };
727 let version = transport_u32(contents, 8);
728 if contents.len() < TRANSPORT_HEADER_SIZE
729 || contents.get(..8) != Some(TRANSPORT_MAGIC.as_slice())
730 || !matches!(version, Some(TRANSPORT_VERSION | TRANSPORT_VERSION_FNV))
731 || transport_u32(contents, 12) != Some(TRANSPORT_HEADER_SIZE as u32)
732 {
733 return Err(invalid_transport("header or version does not match"));
734 }
735 let version = version.unwrap_or(TRANSPORT_VERSION);
736 let declared_capacity =
737 transport_u64(contents, 16).ok_or_else(|| invalid_transport("capacity is missing"))?;
738 if declared_capacity < TRANSPORT_HEADER_SIZE as u64 || declared_capacity > contents.len() as u64
739 {
740 return Err(invalid_transport(
741 "declared capacity is outside the mapped file",
742 ));
743 }
744 let dropped =
745 transport_u64(contents, 24).ok_or_else(|| invalid_transport("drop counter is missing"))?;
746 if dropped != 0 {
747 return Err(PythonEvidenceError::DroppedRecords {
748 file: name.into(),
749 count: dropped,
750 });
751 }
752 let transport_pid = transport_u64(contents, 32)
753 .filter(|pid| *pid != 0)
754 .ok_or_else(|| invalid_transport("process id is missing"))?;
755 let mut contexts = BTreeMap::<u64, Identity>::new();
756 let mut before_assertion = BTreeMap::<u64, Observations>::new();
759 let mut process_worker: Option<String> = None;
760 let mut apply = |line_number: usize, record: Record| -> Result<(), PythonEvidenceError> {
761 let invalid = |reason: &str| PythonEvidenceError::InvalidRecord {
762 file: name.into(),
763 line: line_number,
764 reason: reason.into(),
765 };
766 match record {
767 Record::Process {
768 v,
769 run,
770 pid,
771 worker,
772 python,
773 ..
774 } => {
775 if v != PYTHON_EVIDENCE_VERSION {
776 return Err(PythonEvidenceError::UnsupportedVersion(v));
777 }
778 if run != run_id {
779 return Err(PythonEvidenceError::RunMismatch {
780 expected: run_id.into(),
781 actual: run,
782 });
783 }
784 if pid != transport_pid {
785 return Err(invalid("process record does not match the transport owner"));
786 }
787 let supported = python
788 .split('.')
789 .take(2)
790 .map(|part| part.parse::<u32>().ok())
791 .collect::<Option<Vec<_>>>()
792 .is_some_and(|parts| parts.len() == 2 && (parts[0], parts[1]) >= (3, 9));
795 if !supported {
796 return Err(PythonEvidenceError::UnsupportedPython(python));
797 }
798 evidence.interpreters += 1;
799 evidence.python_versions.insert(python);
800 process_worker = Some(worker);
801 }
802 Record::Worker { worker } => process_worker = Some(worker),
803 Record::Phase {
804 ctx,
805 worker,
806 test,
807 retry,
808 phase,
809 ..
810 } => {
811 if ctx == 0 {
812 return Err(invalid("phase context 0 is reserved for background"));
813 }
814 if !matches!(phase.as_str(), "setup" | "call" | "teardown") {
815 return Err(invalid("unknown pytest phase"));
816 }
817 if test.trim().is_empty() || worker.trim().is_empty() {
818 return Err(invalid("phase identity must name a worker and test"));
819 }
820 if phase == "call" {
821 before_assertion.insert(ctx, Observations::default());
822 }
823 contexts.insert(
824 ctx,
825 Identity {
826 worker,
827 test,
828 retry,
829 phase,
830 },
831 );
832 }
833 Record::Outcome {
834 worker,
835 test,
836 retry,
837 phase,
838 outcome,
839 xfail,
840 runner,
841 file,
842 } => {
843 if !matches!(phase.as_str(), "setup" | "call" | "teardown") {
844 return Err(invalid("unknown test outcome phase"));
845 }
846 if !matches!(
847 outcome.as_str(),
848 "passed" | "failed" | "skipped" | "rerun" | "error"
849 ) {
850 return Err(invalid("unknown test outcome"));
851 }
852 if !matches!(runner.as_str(), PYTEST_RUNNER | UNITTEST_RUNNER) {
853 return Err(invalid("unknown Python test runner"));
854 }
855 let key = (worker, test, retry);
856 if let Some(previous) = evidence.runners.get(&key)
857 && previous != &runner
858 {
859 return Err(invalid("one attempt was reported by two runners"));
860 }
861 evidence.runners.insert(key.clone(), runner);
862 if let Some(file) = file.filter(|path| !path.is_empty()) {
863 evidence.test_files.entry(key.clone()).or_insert(file);
864 }
865 fold_outcome(
866 evidence.outcomes.entry(key).or_default(),
867 phase,
868 outcome,
869 xfail,
870 );
871 }
872 Record::Hit { ctx, id } => {
873 if let Some(before) = before_assertion.get_mut(&ctx) {
874 before.hits.insert(id.clone());
875 }
876 observations(
877 evidence,
878 &contexts,
879 process_worker.as_deref(),
880 ctx,
881 name,
882 line_number,
883 )?
884 .hits
885 .insert(id);
886 }
887 Record::Hits { ctx, ids } => {
888 for id in ids {
889 if let Some(before) = before_assertion.get_mut(&ctx) {
890 before.hits.insert(id.clone());
891 }
892 observations(
893 evidence,
894 &contexts,
895 process_worker.as_deref(),
896 ctx,
897 name,
898 line_number,
899 )?
900 .hits
901 .insert(id);
902 }
903 }
904 Record::Dec { ctx, id, v, o } => {
905 if v.is_empty() || !v.bytes().all(|digit| matches!(digit, b'0' | b'1' | b'2')) {
906 return Err(invalid("decision vector digits must be 0, 1 or 2"));
907 }
908 if o > 1 {
909 return Err(invalid("decision outcome must be 0 or 1"));
910 }
911 let values = v
912 .bytes()
913 .map(|digit| match digit {
914 b'0' => None,
915 b'1' => Some(false),
916 _ => Some(true),
917 })
918 .collect::<Vec<_>>();
919 if let Some(before) = before_assertion.get_mut(&ctx) {
920 before
921 .vectors
922 .entry(id.clone())
923 .or_default()
924 .insert((values.clone(), o == 1));
925 }
926 observations(
927 evidence,
928 &contexts,
929 process_worker.as_deref(),
930 ctx,
931 name,
932 line_number,
933 )?
934 .vectors
935 .entry(id)
936 .or_default()
937 .insert((values, o == 1));
938 }
939 Record::Decs { ctx, v: vectors } => {
940 for (id, v, o) in vectors {
941 if v.is_empty() || !v.bytes().all(|digit| matches!(digit, b'0' | b'1' | b'2')) {
942 return Err(invalid("decision vector digits must be 0, 1 or 2"));
943 }
944 if o > 1 {
945 return Err(invalid("decision outcome must be 0 or 1"));
946 }
947 let values = v
948 .bytes()
949 .map(|digit| match digit {
950 b'0' => None,
951 b'1' => Some(false),
952 _ => Some(true),
953 })
954 .collect::<Vec<_>>();
955 if let Some(before) = before_assertion.get_mut(&ctx) {
956 before
957 .vectors
958 .entry(id.clone())
959 .or_default()
960 .insert((values.clone(), o == 1));
961 }
962 observations(
963 evidence,
964 &contexts,
965 process_worker.as_deref(),
966 ctx,
967 name,
968 line_number,
969 )?
970 .vectors
971 .entry(id)
972 .or_default()
973 .insert((values, o == 1));
974 }
975 }
976 Record::Assert { ctx } => {
977 if let Some(before) = before_assertion.remove(&ctx) {
980 let identity =
981 contexts
982 .get(&ctx)
983 .ok_or(PythonEvidenceError::UnknownContext {
984 file: name.into(),
985 line: line_number,
986 context: ctx,
987 })?;
988 let asserted = evidence
989 .per_identity
990 .entry(Identity {
991 phase: "assertion".into(),
992 ..identity.clone()
993 })
994 .or_default();
995 asserted.hits.extend(before.hits);
996 for (id, vectors) in before.vectors {
997 asserted.vectors.entry(id).or_default().extend(vectors);
998 }
999 }
1000 }
1001 Record::Asite { ctx, f, l } => {
1002 if f.is_empty() || l == 0 {
1003 return Err(invalid("assertion site needs a file and a line"));
1004 }
1005 let identity = contexts
1006 .get(&ctx)
1007 .ok_or(PythonEvidenceError::UnknownContext {
1008 file: name.into(),
1009 line: line_number,
1010 context: ctx,
1011 })?;
1012 if identity.phase == "call" {
1015 let key = (
1016 identity.worker.clone(),
1017 identity.test.clone(),
1018 identity.retry,
1019 );
1020 let sites = evidence.sites.entry(key).or_default();
1021 let site = (f, l);
1022 if !sites.contains(&site) {
1023 sites.push(site);
1024 }
1025 }
1026 }
1027 Record::Limitation {
1028 id,
1029 reason,
1030 file,
1031 obligation,
1032 } => evidence.limitations.push(RuntimeLimitation {
1033 id,
1034 reason,
1035 file,
1036 obligation,
1037 }),
1038 Record::Exit { .. } => {}
1039 }
1040 Ok(())
1041 };
1042 let mut cursor = TRANSPORT_HEADER_SIZE;
1043 let mut record_index = 0;
1044 while cursor + TRANSPORT_RECORD_HEADER_SIZE <= contents.len() {
1045 let commit = contents[cursor];
1046 if commit == 0 {
1047 break;
1050 }
1051 record_index += 1;
1052 let line_number = record_index;
1053 let invalid = |reason: &str| PythonEvidenceError::InvalidRecord {
1054 file: name.into(),
1055 line: line_number,
1056 reason: reason.into(),
1057 };
1058 if commit != 1
1059 || contents[cursor + 1..cursor + 4] != [0, 0, 0]
1060 || contents[cursor + 12..cursor + 16] != [0, 0, 0, 0]
1061 {
1062 return Err(invalid("commit marker or reserved bytes are invalid"));
1063 }
1064 let length = transport_u32(contents, cursor + 4)
1065 .map(|value| value as usize)
1066 .ok_or_else(|| invalid("payload length is missing"))?;
1067 if length == 0 || length > TRANSPORT_MAX_RECORD_SIZE {
1068 return Err(invalid("payload length is outside the transport bound"));
1069 }
1070 let payload_start = cursor + TRANSPORT_RECORD_HEADER_SIZE;
1071 let payload_end = payload_start
1072 .checked_add(length)
1073 .filter(|end| *end <= contents.len())
1074 .ok_or_else(|| invalid("payload extends past the mapped file"))?;
1075 let next_cursor = align_transport(payload_end)
1076 .filter(|end| *end <= contents.len())
1077 .ok_or_else(|| invalid("aligned frame extends past the mapped file"))?;
1078 if contents[payload_end..next_cursor]
1079 .iter()
1080 .any(|byte| *byte != 0)
1081 {
1082 return Err(invalid("frame padding is not zero"));
1083 }
1084 let payload = &contents[payload_start..payload_end];
1085 let expected_checksum = transport_u32(contents, cursor + 8)
1086 .ok_or_else(|| invalid("payload checksum is missing"))?;
1087 if transport_checksum(payload, version) != expected_checksum {
1088 return Err(invalid("payload checksum does not match"));
1089 }
1090 let record: Record = serde_json::from_slice(payload).map_err(|error| {
1091 PythonEvidenceError::InvalidRecord {
1092 file: name.into(),
1093 line: line_number,
1094 reason: error.to_string(),
1095 }
1096 })?;
1097 apply(line_number, record)?;
1098 cursor = next_cursor;
1099 }
1100 for (slot, slot_contents) in slots {
1103 let layout = layout.ok_or_else(|| PythonEvidenceError::InvalidTransport {
1104 file: slot.clone(),
1105 reason: "a slot was left without the run's slot layout".into(),
1106 })?;
1107 for record in layout.records(slot, slot_contents)? {
1108 record_index += 1;
1109 apply(record_index, record)?;
1110 }
1111 }
1112 Ok(())
1113}
1114
1115fn observations<'a>(
1116 evidence: &'a mut Evidence,
1117 contexts: &BTreeMap<u64, Identity>,
1118 process_worker: Option<&str>,
1119 context: u64,
1120 file: &str,
1121 line: usize,
1122) -> Result<&'a mut Observations, PythonEvidenceError> {
1123 if context == 0 {
1124 return Ok(evidence
1125 .background
1126 .entry(process_worker.unwrap_or("main").to_owned())
1127 .or_default());
1128 }
1129 let identity = contexts
1130 .get(&context)
1131 .ok_or(PythonEvidenceError::UnknownContext {
1132 file: file.into(),
1133 line,
1134 context,
1135 })?;
1136 Ok(evidence.per_identity.entry(identity.clone()).or_default())
1137}
1138
1139pub fn python_coverage_model() -> CoverageModelDeclaration {
1142 CoverageModelDeclaration {
1143 language: "python".into(),
1144 variant: "python-owned-probes".into(),
1145 name: PYTHON_FRONTEND_VERSION.into(),
1146 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(),
1147 measured: vec![
1148 "executable statements, each by a probe placed before it".into(),
1149 "function and lambda entry".into(),
1150 "boolean decision vectors with masking MC/DC, each condition's value taken as the program evaluated it".into(),
1151 "for-loop and comprehension zero-versus-entered iteration".into(),
1152 "logical and/or short-circuit alternatives".into(),
1153 "match case selection and guards".into(),
1154 "try completion, handler selection and exception propagation".into(),
1155 "pytest and unittest worker, test, retry and setup/call/teardown phase identity".into(),
1156 "evidence a test recorded before its first assertion, linked to that assertion when the test passes".into(),
1157 ],
1158 not_measured: vec![
1159 "causal linkage to individual actions, or to any assertion after a test's first".into(),
1160 "code compiled from strings at runtime".into(),
1161 "measured modules imported before Supercov installed, or compiled past the import system; the run names each as a limitation".into(),
1162 "causal test context for raw _thread or native-extension-created threads".into(),
1163 "child coverage outside subprocess.Popen and multiprocessing adapters".into(),
1164 "all input values, semantic partitions, paths, or concurrency interleavings".into(),
1165 "mutation score or assertion fault-detection strength".into(),
1166 ],
1167 }
1168}
1169
1170fn phase_id(run: &str, identity: &Identity) -> String {
1171 stable_id(
1172 "python-phase",
1173 &[
1174 run,
1175 &identity.worker,
1176 &identity.test,
1177 &identity.retry.to_string(),
1178 &identity.phase,
1179 ],
1180 )
1181}
1182
1183fn scope(run: &str, worker: &str, test: &str, retry: usize) -> ExecutionScope {
1184 ExecutionScope {
1185 version: 1,
1186 run_id: run.into(),
1187 worker_id: worker.into(),
1188 test_id: test.into(),
1189 test_key: stable_id("python-test", &[worker, test]),
1190 retry,
1191 attempt_id: stable_id("python-attempt", &[run, worker, test, &retry.to_string()]),
1192 }
1193}
1194
1195struct ManifestIndex<'a> {
1196 points: BTreeSet<&'a str>,
1197 alternatives: BTreeSet<&'a str>,
1198 decisions: BTreeMap<&'a str, &'a DecisionMeta>,
1199 lines: BTreeMap<&'a str, (String, usize)>,
1200}
1201
1202impl<'a> ManifestIndex<'a> {
1203 fn new(manifest: &'a CoverageManifest) -> Self {
1204 let mut lines = BTreeMap::new();
1205 for point in &manifest.points {
1206 lines.insert(point.id.as_str(), (point.file.clone(), point.line));
1207 }
1208 for decision in &manifest.decisions {
1209 lines.insert(decision.id.as_str(), (decision.file.clone(), decision.line));
1210 }
1211 for branch in &manifest.branches {
1212 lines.insert(branch.id.as_str(), (branch.file.clone(), branch.line));
1213 }
1214 Self {
1215 points: manifest
1216 .points
1217 .iter()
1218 .map(|point| point.id.as_str())
1219 .collect(),
1220 alternatives: manifest
1221 .branches
1222 .iter()
1223 .flat_map(|branch| branch.alternatives.iter().map(|alt| alt.id.as_str()))
1224 .collect(),
1225 decisions: manifest
1226 .decisions
1227 .iter()
1228 .map(|decision| (decision.id.as_str(), decision))
1229 .collect(),
1230 lines,
1231 }
1232 }
1233}
1234
1235fn snapshot(
1236 index: &ManifestIndex<'_>,
1237 observations: &Observations,
1238 phase: &str,
1239) -> Result<RuntimeSnapshot, PythonEvidenceError> {
1240 let mut hits = BTreeSet::new();
1241 for id in &observations.hits {
1242 if !index.points.contains(id.as_str()) && !index.alternatives.contains(id.as_str()) {
1243 return Err(PythonEvidenceError::UnknownObligation(id.clone()));
1244 }
1245 hits.insert(id.clone());
1246 }
1247 let mut decisions = Vec::new();
1248 let mut events = Vec::new();
1249 let mut clock = 1;
1250 for id in &hits {
1251 events.push(RuntimeEvent {
1252 event_type: "hit".into(),
1253 id: id.clone(),
1254 vector: None,
1255 timestamp_ms: clock,
1256 phase_id: Some(phase.into()),
1257 statement_id: None,
1258 environment: "python".into(),
1259 });
1260 clock += 1;
1261 }
1262 for (id, vectors) in &observations.vectors {
1263 let Some(meta) = index.decisions.get(id.as_str()) else {
1264 return Err(PythonEvidenceError::UnknownObligation(id.clone()));
1265 };
1266 let mut observed = Vec::new();
1267 for (values, outcome) in vectors {
1268 if values.len() != meta.conditions.len() {
1269 return Err(PythonEvidenceError::InvalidVector {
1270 id: id.clone(),
1271 expected: meta.conditions.len(),
1272 actual: values.len(),
1273 });
1274 }
1275 let vector = McdcVector {
1276 values: values.clone(),
1277 outcome: *outcome,
1278 };
1279 events.push(RuntimeEvent {
1280 event_type: "decision".into(),
1281 id: id.clone(),
1282 vector: Some(vector.clone()),
1283 timestamp_ms: clock,
1284 phase_id: Some(phase.into()),
1285 statement_id: None,
1286 environment: "python".into(),
1287 });
1288 clock += 1;
1289 observed.push(vector);
1290 }
1291 decisions.push(DecisionSnapshot {
1292 meta: (*meta).clone(),
1293 vectors: observed,
1294 });
1295 }
1296 Ok(RuntimeSnapshot {
1297 decisions,
1298 hits: hits.into_iter().collect(),
1299 events,
1300 logicals: Vec::new(),
1301 })
1302}
1303
1304fn fold_outcome(
1313 outcomes: &mut Vec<(String, String, bool)>,
1314 phase: String,
1315 outcome: String,
1316 xfail: bool,
1317) {
1318 let Some(reported) = outcomes.iter_mut().find(|(name, _, _)| *name == phase) else {
1319 outcomes.push((phase, outcome, xfail));
1320 return;
1321 };
1322 if outcome_severity(&outcome) > outcome_severity(&reported.1) {
1323 reported.1 = outcome;
1324 }
1325 reported.2 |= xfail;
1326}
1327
1328fn outcome_severity(outcome: &str) -> u8 {
1330 match outcome {
1331 "failed" | "rerun" | "error" => 2,
1332 "skipped" => 1,
1333 _ => 0,
1334 }
1335}
1336
1337fn attempt_status(outcomes: &[(String, String, bool)]) -> String {
1338 if outcomes
1339 .iter()
1340 .any(|(_, outcome, _)| matches!(outcome.as_str(), "failed" | "rerun" | "error"))
1341 {
1342 "failed"
1343 } else if outcomes.iter().any(|(_, outcome, _)| outcome == "skipped") {
1344 "skipped"
1345 } else {
1346 "passed"
1347 }
1348 .into()
1349}
1350
1351#[derive(Debug, Clone, PartialEq)]
1352pub struct PythonFrontendRun {
1353 pub declaration: FrontendRunDeclaration,
1354 pub request: CoverageReportRequest,
1355 pub tests: usize,
1356 pub interpreters: usize,
1357 pub python_versions: Vec<String>,
1358}
1359
1360impl PythonFrontendRun {
1361 pub fn archive_entries(&self) -> Result<Vec<EvidenceArchiveEntry>, serde_json::Error> {
1362 let model = PersistedCoverageModel::from_declaration(
1363 self.request
1364 .coverage_model
1365 .as_ref()
1366 .expect("Python frontend always declares a coverage model"),
1367 )
1368 .expect("Python coverage model is contract-valid");
1369 let mut entries = vec![
1370 EvidenceArchiveEntry {
1371 path: "coverage-model.json".into(),
1372 contents: serde_json::to_vec(&model)?,
1373 },
1374 EvidenceArchiveEntry {
1375 path: "frontend.json".into(),
1376 contents: serde_json::to_vec(&self.declaration)?,
1377 },
1378 EvidenceArchiveEntry {
1379 path: "manifest.json".into(),
1380 contents: serde_json::to_vec(&self.request.manifest)?,
1381 },
1382 ];
1383 for (index, result) in self.request.raw_results.iter().enumerate() {
1384 entries.push(EvidenceArchiveEntry {
1385 path: format!("results/{index:08}/mcdc.json"),
1386 contents: serde_json::to_vec(result)?,
1387 });
1388 }
1389 Ok(entries)
1390 }
1391}
1392
1393pub fn build_python_frontend_run(
1396 manifest: &CoverageManifest,
1397 evidence_directory: &Path,
1398 run_id: &str,
1399 generated_at: &str,
1400 test_exit_code: i32,
1401 assertions: &PythonAssertionInventory,
1402) -> Result<PythonFrontendRun, PythonEvidenceError> {
1403 let evidence = read_evidence_directory(evidence_directory, run_id)?;
1404 if evidence.interpreters == 0 {
1405 return Err(PythonEvidenceError::NoInterpreter);
1406 }
1407 if evidence.outcomes.is_empty() {
1408 return Err(PythonEvidenceError::NoTests);
1409 }
1410 let Evidence {
1411 interpreters,
1412 python_versions,
1413 per_identity,
1414 background,
1415 outcomes,
1416 runners,
1417 test_files,
1418 sites,
1419 limitations,
1420 } = evidence;
1421 let mut manifest = manifest.clone();
1422 let index = ManifestIndex::new(&manifest);
1423
1424 let mut raw_results = Vec::new();
1425 let mut observed_runners = BTreeSet::new();
1426 let mut identities_by_attempt =
1427 BTreeMap::<(String, String, usize), Vec<(&Identity, &Observations)>>::new();
1428 for (identity, observations) in &per_identity {
1429 identities_by_attempt
1430 .entry((
1431 identity.worker.clone(),
1432 identity.test.clone(),
1433 identity.retry,
1434 ))
1435 .or_default()
1436 .push((identity, observations));
1437 }
1438 for ((worker, test, retry), mut outcomes) in outcomes {
1439 let runner = runners
1440 .get(&(worker.clone(), test.clone(), retry))
1441 .cloned()
1442 .unwrap_or_else(default_runner);
1443 let attempt_identities = identities_by_attempt
1444 .remove(&(worker.clone(), test.clone(), retry))
1445 .unwrap_or_default();
1446 observed_runners.insert(runner.clone());
1447 outcomes.sort_by_key(|(phase, _, _)| match phase.as_str() {
1448 "setup" => 0,
1449 "call" => 1,
1450 _ => 2,
1451 });
1452 let mut phases = Vec::new();
1453 let mut runtime = Vec::new();
1454 let mut observed_phases = BTreeSet::new();
1455 for (position, (phase_name, outcome, xfail)) in outcomes.iter().enumerate() {
1456 observed_phases.insert(phase_name.clone());
1457 let identity = Identity {
1458 worker: worker.clone(),
1459 test: test.clone(),
1460 retry,
1461 phase: phase_name.clone(),
1462 };
1463 let id = phase_id(run_id, &identity);
1464 phases.push(CoveragePhase {
1465 id: id.clone(),
1466 kind: match phase_name.as_str() {
1467 "call" => "test",
1468 value => value,
1469 }
1470 .into(),
1471 operation: format!("{runner} {phase_name}"),
1472 source: Some(test.clone()),
1473 caused_by_phase_id: None,
1474 started_at_ms: position as i64 * 2 + 1,
1475 ended_at_ms: Some(position as i64 * 2 + 2),
1476 status: Some(match outcome.as_str() {
1477 "rerun" | "error" => "failed".into(),
1478 value => value.into(),
1479 }),
1480 error: None,
1481 });
1482 if let Some((_, observations)) = attempt_identities
1483 .iter()
1484 .find(|(candidate, _)| candidate.phase == phase_name.as_str())
1485 {
1486 runtime.push(snapshot(&index, observations, &id)?);
1487 }
1488 if phase_name != "call" {
1489 continue;
1490 }
1491 if let Some((identity, observations)) = attempt_identities
1495 .iter()
1496 .find(|(candidate, _)| candidate.phase == "assertion")
1497 {
1498 observed_phases.insert("assertion".to_owned());
1499 let id = phase_id(run_id, identity);
1500 phases.push(CoveragePhase {
1501 id: id.clone(),
1502 kind: "assertion".into(),
1503 operation: format!("{runner} assertion"),
1504 source: Some(test.clone()),
1505 caused_by_phase_id: None,
1506 started_at_ms: position as i64 * 2 + 1,
1507 ended_at_ms: Some(position as i64 * 2 + 2),
1508 status: Some(
1509 if outcome == "passed" && !*xfail {
1510 "passed"
1511 } else {
1512 "failed"
1513 }
1514 .into(),
1515 ),
1516 error: None,
1517 });
1518 runtime.push(snapshot(&index, observations, &id)?);
1519 let attempt = (worker.clone(), test.clone(), retry);
1525 let mut located = BTreeSet::new();
1529 for (path, line) in sites.get(&attempt).into_iter().flatten() {
1530 let Some(location) = assertions.locate(path, *line) else {
1531 continue;
1532 };
1533 if !located.insert(location.clone()) {
1534 continue;
1535 }
1536 phases.push(CoveragePhase {
1537 id: stable_id("python-assertion", &[run_id, &id, &location]),
1538 kind: "assertion".into(),
1539 operation: format!("{runner} assertion at {location}"),
1540 source: Some(location),
1541 caused_by_phase_id: Some(id.clone()),
1542 started_at_ms: position as i64 * 2 + 1,
1543 ended_at_ms: Some(position as i64 * 2 + 2),
1544 status: Some(
1545 if outcome == "passed" && !*xfail {
1546 "passed"
1547 } else {
1548 "failed"
1549 }
1550 .into(),
1551 ),
1552 error: None,
1553 });
1554 }
1555 }
1556 }
1557 for (identity, observations) in attempt_identities {
1560 if !observed_phases.contains(&identity.phase) {
1561 let id = phase_id(run_id, identity);
1562 phases.push(CoveragePhase {
1563 id: id.clone(),
1564 kind: match identity.phase.as_str() {
1565 "call" => "test",
1566 value => value,
1567 }
1568 .into(),
1569 operation: format!("{runner} {}", identity.phase),
1570 source: Some(test.clone()),
1571 caused_by_phase_id: None,
1572 started_at_ms: phases.len() as i64 * 2 + 1,
1573 ended_at_ms: None,
1574 status: Some("failed".into()),
1575 error: Some("the phase started but the runner reported no outcome".into()),
1576 });
1577 runtime.push(snapshot(&index, observations, &id)?);
1578 }
1579 }
1580 let status = if phases.iter().any(|phase| phase.error.is_some()) {
1581 "failed".into()
1582 } else {
1583 attempt_status(&outcomes)
1584 };
1585 raw_results.push(RawTestResult {
1586 test_id: Some(test.clone()),
1587 scope: Some(scope(run_id, &worker, &test, retry)),
1588 test: test.clone(),
1589 test_file: test_files
1593 .get(&(worker.clone(), test.clone(), retry))
1594 .and_then(|path| assertions.relative(path))
1595 .or_else(|| test.split("::").next().map(str::to_owned)),
1596 title: test.rsplit("::").next().map(str::to_owned),
1597 retry: Some(retry),
1598 status: Some(status),
1599 expected_status: Some(
1600 if outcomes.iter().any(|(_, _, xfail)| *xfail) {
1601 "failed"
1602 } else {
1603 "passed"
1604 }
1605 .into(),
1606 ),
1607 flaky: false,
1608 provenance: TestProvenance {
1609 runner: runner.clone(),
1610 kind: "unit".into(),
1611 project: None,
1612 source: PYTHON_FRONTEND_VERSION.into(),
1613 },
1614 role: "test".into(),
1615 attribution: crate::coverage_report::ATTRIBUTION_EXACT.into(),
1616 phases,
1617 runtime,
1618 browser: Vec::new(),
1619 server: Vec::new(),
1620 });
1621 }
1622 let default_observed = observed_runners
1625 .iter()
1626 .next()
1627 .cloned()
1628 .unwrap_or_else(default_runner);
1629 for ((worker, test, retry), identities) in identities_by_attempt {
1630 let runner = default_observed.clone();
1631 let mut phases = Vec::new();
1632 let mut runtime = Vec::new();
1633 for (position, (identity, observations)) in identities.iter().enumerate() {
1634 let id = phase_id(run_id, identity);
1635 phases.push(CoveragePhase {
1636 id: id.clone(),
1637 kind: match identity.phase.as_str() {
1638 "call" => "test",
1639 value => value,
1640 }
1641 .into(),
1642 operation: format!("{runner} {}", identity.phase),
1643 source: Some(test.clone()),
1644 caused_by_phase_id: None,
1645 started_at_ms: position as i64 * 2 + 1,
1646 ended_at_ms: None,
1647 status: Some("failed".into()),
1648 error: Some("the phase started but the runner reported no outcome".into()),
1649 });
1650 runtime.push(snapshot(&index, observations, &id)?);
1651 }
1652 raw_results.push(RawTestResult {
1653 test_id: Some(test.clone()),
1654 scope: Some(scope(run_id, &worker, &test, retry)),
1655 test: test.clone(),
1656 test_file: test_files
1660 .get(&(worker.clone(), test.clone(), retry))
1661 .and_then(|path| assertions.relative(path))
1662 .or_else(|| test.split("::").next().map(str::to_owned)),
1663 title: test.rsplit("::").next().map(str::to_owned),
1664 retry: Some(retry),
1665 status: Some("failed".into()),
1666 expected_status: Some("passed".into()),
1667 flaky: false,
1668 provenance: TestProvenance {
1669 runner: runner.clone(),
1670 kind: "unit".into(),
1671 project: None,
1672 source: PYTHON_FRONTEND_VERSION.into(),
1673 },
1674 role: "test".into(),
1675 attribution: crate::coverage_report::ATTRIBUTION_EXACT.into(),
1676 phases,
1677 runtime,
1678 browser: Vec::new(),
1679 server: Vec::new(),
1680 });
1681 }
1682 for (worker, observations) in &background {
1683 if observations.hits.is_empty() && observations.vectors.is_empty() {
1684 continue;
1685 }
1686 let test = format!("__supercov_background__:{worker}");
1687 let identity = Identity {
1688 worker: worker.clone(),
1689 test: test.clone(),
1690 retry: 0,
1691 phase: "background".into(),
1692 };
1693 let phase = phase_id(run_id, &identity);
1694 raw_results.push(RawTestResult {
1695 test_id: Some(test.clone()),
1696 scope: Some(scope(run_id, worker, &test, 0)),
1697 test: "Python import, collection and background execution".into(),
1698 test_file: None,
1699 title: None,
1700 retry: Some(0),
1701 status: Some("unknown".into()),
1702 expected_status: None,
1703 flaky: false,
1704 provenance: TestProvenance {
1705 runner: default_observed.clone(),
1706 kind: "unit".into(),
1707 project: None,
1708 source: PYTHON_FRONTEND_VERSION.into(),
1709 },
1710 role: "background".into(),
1711 attribution: crate::coverage_report::ATTRIBUTION_EXACT.into(),
1712 phases: vec![CoveragePhase {
1713 id: phase.clone(),
1714 kind: "background".into(),
1715 operation: "Python import and collection background".into(),
1716 source: None,
1717 caused_by_phase_id: None,
1718 started_at_ms: 0,
1719 ended_at_ms: Some(0),
1720 status: Some("passed".into()),
1721 error: None,
1722 }],
1723 runtime: vec![snapshot(&index, observations, &phase)?],
1724 browser: Vec::new(),
1725 server: Vec::new(),
1726 });
1727 }
1728
1729 let mut limitation_ids = manifest
1733 .limitations
1734 .iter()
1735 .filter_map(|item| item.get("id").and_then(serde_json::Value::as_str))
1736 .map(str::to_owned)
1737 .collect::<BTreeSet<_>>();
1738 let mut unmeasured = manifest.unmeasured.iter().cloned().collect::<BTreeSet<_>>();
1739 let mut new_limitations = Vec::new();
1740 for limitation in &limitations {
1741 if let Some(obligation) = &limitation.obligation {
1742 if !index.lines.contains_key(obligation.as_str()) {
1743 return Err(PythonEvidenceError::UnknownObligation(obligation.clone()));
1744 }
1745 unmeasured.insert(obligation.clone());
1746 } else if let Some(file) = &limitation.file {
1747 unmeasured.extend(
1752 index
1753 .lines
1754 .iter()
1755 .filter(|(_, (obligation_file, _))| obligation_file == file)
1756 .map(|(id, _)| (*id).to_owned()),
1757 );
1758 }
1759 if limitation_ids.insert(limitation.id.clone()) {
1760 let (file, line) = limitation
1761 .obligation
1762 .as_deref()
1763 .and_then(|id| index.lines.get(id).cloned())
1764 .unwrap_or_else(|| {
1765 (
1766 limitation.file.clone().unwrap_or_else(|| {
1767 manifest
1768 .points
1769 .first()
1770 .map_or(".".into(), |point| point.file.clone())
1771 }),
1772 1,
1773 )
1774 });
1775 new_limitations.push(json!({
1776 "id": limitation.id,
1777 "kind": "semantic-safety",
1778 "file": file,
1779 "line": line,
1780 "column": 0,
1781 "source": "",
1782 "reason": limitation.reason
1783 }));
1784 }
1785 }
1786 manifest.limitations.extend(new_limitations);
1787 manifest.unmeasured = unmeasured.into_iter().collect();
1788 let structural_limitations = limitation_ids.into_iter().collect::<Vec<_>>();
1789
1790 let tests = raw_results
1794 .iter()
1795 .filter(|raw| raw.role == "test")
1796 .map(|raw| raw.test.as_str())
1797 .collect::<BTreeSet<_>>()
1798 .len();
1799 Ok(PythonFrontendRun {
1800 declaration: FrontendRunDeclaration {
1801 protocol_version: LANGUAGE_FRONTEND_PROTOCOL_VERSION,
1802 frontend_id: "python".into(),
1803 frontend_version: PYTHON_FRONTEND_VERSION.into(),
1804 language: "python".into(),
1805 structural_source: StructuralSource::OwnedProbes,
1806 runners: observed_runners
1807 .iter()
1808 .map(|runner| FrontendRunnerDeclaration {
1809 runner: runner.clone(),
1810 execution_model: if runner == UNITTEST_RUNNER {
1811 ExecutionModel::SerialInProcess
1812 } else {
1813 ExecutionModel::ParallelContextPropagated
1814 },
1815 attribution: FrontendAttribution {
1816 run: AttributionPrecision::Exact,
1817 worker: AttributionPrecision::Exact,
1818 test: AttributionPrecision::Exact,
1819 retry: AttributionPrecision::Exact,
1820 phase: AttributionPrecision::Exact,
1821 action: AttributionPrecision::Unavailable,
1822 assertion: AttributionPrecision::Exact,
1823 },
1824 limitations: vec![FrontendLimitation {
1825 id: "python-action-linkage".into(),
1826 scopes: vec![FrontendLimitationScope::Action],
1827 reason: format!("{runner} exposes no general action lifecycle"),
1828 }],
1829 })
1830 .collect(),
1831 structural_limitations,
1832 },
1833 request: CoverageReportRequest {
1834 run_id: run_id.into(),
1835 manifest,
1836 raw_results,
1837 generated_at: generated_at.into(),
1838 coverage_model: Some(python_coverage_model()),
1839 integrity: None,
1840 test_exit_code: ExitCodeInput::Present(Some(test_exit_code)),
1841 },
1842 tests,
1843 interpreters,
1844 python_versions: python_versions.into_iter().collect(),
1845 })
1846}
1847
1848#[cfg(test)]
1849mod tests {
1850 use std::time::{SystemTime, UNIX_EPOCH};
1851
1852 use super::*;
1853 use crate::{
1854 frontend_protocol::validate_frontend_report_request,
1855 python_instrumenter::build_python_obligations,
1856 };
1857
1858 fn temporary(name: &str) -> std::path::PathBuf {
1859 let nonce = SystemTime::now()
1860 .duration_since(UNIX_EPOCH)
1861 .unwrap()
1862 .as_nanos();
1863 let path = std::env::temp_dir().join(format!(
1864 "supercov-python-evidence-{}-{nonce}-{name}",
1865 std::process::id()
1866 ));
1867 fs::create_dir_all(&path).unwrap();
1868 path
1869 }
1870
1871 fn write_transport(path: &Path, records: &[serde_json::Value], dropped: u64) {
1872 write_transport_version(path, records, dropped, TRANSPORT_VERSION, None);
1873 }
1874
1875 fn write_transport_version(
1878 path: &Path,
1879 records: &[serde_json::Value],
1880 dropped: u64,
1881 version: u32,
1882 corrupt: Option<usize>,
1883 ) {
1884 let payloads = records
1885 .iter()
1886 .map(|record| serde_json::to_vec(record).unwrap())
1887 .collect::<Vec<_>>();
1888 let capacity = payloads
1889 .iter()
1890 .fold(TRANSPORT_HEADER_SIZE, |cursor, payload| {
1891 align_transport(cursor + TRANSPORT_RECORD_HEADER_SIZE + payload.len()).unwrap()
1892 })
1893 + 64;
1894 let mut bytes = vec![0_u8; capacity];
1895 bytes[..8].copy_from_slice(TRANSPORT_MAGIC);
1896 bytes[8..12].copy_from_slice(&version.to_le_bytes());
1897 bytes[12..16].copy_from_slice(&(TRANSPORT_HEADER_SIZE as u32).to_le_bytes());
1898 bytes[16..24].copy_from_slice(&(capacity as u64).to_le_bytes());
1899 bytes[24..32].copy_from_slice(&dropped.to_le_bytes());
1900 bytes[32..40].copy_from_slice(&1_u64.to_le_bytes());
1901 let mut cursor = TRANSPORT_HEADER_SIZE;
1902 for (index, payload) in payloads.into_iter().enumerate() {
1903 let payload_start = cursor + TRANSPORT_RECORD_HEADER_SIZE;
1904 let payload_end = payload_start + payload.len();
1905 bytes[payload_start..payload_end].copy_from_slice(&payload);
1906 bytes[cursor + 4..cursor + 8].copy_from_slice(&(payload.len() as u32).to_le_bytes());
1907 let expected = if version == TRANSPORT_VERSION_FNV {
1911 payload.iter().fold(0x811c_9dc5_u32, |value, byte| {
1912 (value ^ u32::from(*byte)).wrapping_mul(0x0100_0193)
1913 })
1914 } else {
1915 crc32fast::hash(&payload)
1916 };
1917 let checksum = expected ^ u32::from(corrupt == Some(index));
1918 bytes[cursor + 8..cursor + 12].copy_from_slice(&checksum.to_le_bytes());
1919 bytes[cursor] = 1;
1920 cursor = align_transport(payload_end).unwrap();
1921 }
1922 fs::write(path, bytes).unwrap();
1923 }
1924
1925 fn under(first: &str, rest: &str) -> String {
1930 let mut path = PathBuf::from(if cfg!(windows) {
1931 format!("C:\\{first}")
1932 } else {
1933 format!("/{first}")
1934 });
1935 for part in rest.split('/').filter(|part| !part.is_empty()) {
1936 path.push(part);
1937 }
1938 path.to_string_lossy().into_owned()
1939 }
1940
1941 fn inventory_of(root: &str, sites: &[(&str, usize, usize)]) -> PythonAssertionInventory {
1942 use crate::assertion_map::{Anchor, Files, Inputs, InventorySite};
1943 PythonAssertionInventory::new(
1944 Path::new(root),
1945 &Inputs {
1946 schema_version: 1,
1947 language: "python".into(),
1948 context_digest: "context".into(),
1949 files: Files::new(),
1950 assertions: sites
1951 .iter()
1952 .map(|(file, line, column)| InventorySite {
1953 at: Anchor {
1954 file: (*file).into(),
1955 line: *line,
1956 column: *column,
1957 text: "assert f(1) == 1".into(),
1958 },
1959 operation: "assert".into(),
1960 })
1961 .collect(),
1962 limitations: vec![],
1963 },
1964 )
1965 }
1966
1967 fn assertion_sources(run: &PythonFrontendRun) -> Vec<String> {
1968 run.request.raw_results[0]
1969 .phases
1970 .iter()
1971 .filter(|phase| phase.kind == "assertion")
1972 .filter_map(|phase| phase.source.clone())
1973 .collect()
1974 }
1975
1976 fn run_with_sites(
1977 name: &str,
1978 sites: &[serde_json::Value],
1979 outcome_file: Option<&str>,
1980 inventory: &PythonAssertionInventory,
1981 ) -> PythonFrontendRun {
1982 let source = "def f(a):\n return a\n";
1983 let obligations = build_python_obligations("m.py", source).unwrap();
1984 let mut outcome = json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call","outcome":"passed","xfail":false});
1985 if let Some(file) = outcome_file {
1986 outcome["file"] = json!(file);
1987 }
1988 let mut lines = vec![
1989 json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.14.4","executable":"python","argv":["pytest"]}),
1990 json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call"}),
1991 json!({"t":"assert","ctx":1}),
1992 ];
1993 lines.extend(sites.iter().cloned());
1994 lines.push(outcome);
1995 lines.push(json!({"t":"exit","at":9}));
1996 let directory = temporary(name);
1997 write_transport(&directory.join("main.1.mmap"), &lines, 0);
1998 let run = build_python_frontend_run(
1999 &obligations.manifest,
2000 &directory,
2001 "run-1",
2002 "now",
2003 0,
2004 inventory,
2005 )
2006 .unwrap();
2007 validate_frontend_report_request(&run.declaration, &run.request).unwrap();
2008 fs::remove_dir_all(directory).unwrap();
2009 run
2010 }
2011
2012 const SLOT_DIGEST: &str = "0123456789abcdef0123456789abcdef";
2013
2014 fn slot_layout() -> serde_json::Value {
2018 json!({
2019 "version": 2, "digest": SLOT_DIGEST, "header": 16, "bytes": 36,
2020 "ids": ["s1", "s2"],
2021 "decisions": [{
2022 "start": 18, "width": 2, "id": "d",
2023 "outcomeTrue": "d:true", "outcomeFalse": "d:false",
2024 "logical": [{"evaluated": "or:evaluated", "shortCircuit": "or:short",
2025 "previousLeaves": [0], "operandLeaves": [1]}]
2026 }]
2027 })
2028 }
2029
2030 fn slot_bytes(context: u64, tag: Option<&str>, set: &[usize]) -> Vec<u8> {
2032 let mut bytes = vec![0_u8; 36];
2033 bytes[..8].copy_from_slice(&context.to_le_bytes());
2034 if let Some(tag) = tag {
2035 for index in 0..8 {
2036 bytes[8 + index] = u8::from_str_radix(&tag[index * 2..index * 2 + 2], 16).unwrap();
2037 }
2038 }
2039 for index in set {
2040 bytes[*index] = 1;
2041 }
2042 bytes
2043 }
2044
2045 fn killed_process(name: &str, slot: &[u8]) -> PathBuf {
2048 let directory = temporary(name);
2049 write_transport(
2050 &directory.join("main.1.token.mmap"),
2051 &[
2052 json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.9.21","executable":"python","argv":["pytest"]}),
2053 json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call"}),
2054 ],
2055 0,
2056 );
2057 fs::write(directory.join("main.1.token.0.slot"), slot).unwrap();
2058 fs::write(
2059 directory.join("layout.json"),
2060 serde_json::to_vec(&slot_layout()).unwrap(),
2061 )
2062 .unwrap();
2063 directory
2064 }
2065
2066 fn call_observations(evidence: &Evidence) -> Observations {
2067 evidence
2068 .per_identity
2069 .iter()
2070 .find(|(identity, _)| identity.phase == "call")
2071 .map(|(_, observations)| Observations {
2072 hits: observations.hits.clone(),
2073 vectors: observations.vectors.clone(),
2074 })
2075 .unwrap_or_default()
2076 }
2077
2078 #[test]
2079 fn a_killed_process_slot_is_read_as_its_last_records() {
2080 let directory = killed_process(
2083 "py-slot-killed",
2084 &slot_bytes(1, Some(SLOT_DIGEST), &[16, 18 + 15]),
2085 );
2086 let evidence = read_evidence_directory(&directory, "run-1").unwrap();
2087 let call = call_observations(&evidence);
2088 assert_eq!(
2089 call.hits.iter().cloned().collect::<Vec<_>>(),
2090 ["d:true", "or:evaluated", "s1"],
2091 "the obligation, and what the vector implies"
2092 );
2093 assert_eq!(
2094 call.vectors["d"],
2095 BTreeSet::from([(vec![Some(false), Some(true)], true)])
2096 );
2097 fs::remove_dir_all(directory).unwrap();
2098 }
2099
2100 #[test]
2101 fn a_slot_its_process_closed_is_not_counted() {
2102 let directory = killed_process("py-slot-closed", &slot_bytes(1, None, &[16]));
2105 let evidence = read_evidence_directory(&directory, "run-1").unwrap();
2106 assert!(call_observations(&evidence).hits.is_empty());
2107 fs::remove_dir_all(directory).unwrap();
2108 }
2109
2110 #[test]
2111 fn a_slot_is_read_only_against_its_own_layout() {
2112 let other = "fedcba9876543210fedcba9876543210";
2113 let directory = killed_process("py-slot-foreign", &slot_bytes(1, Some(other), &[16]));
2114 assert!(matches!(
2115 read_evidence_directory(&directory, "run-1"),
2116 Err(PythonEvidenceError::InvalidTransport { .. })
2117 ));
2118 fs::remove_dir_all(directory).unwrap();
2119
2120 let directory = killed_process("py-slot-byte", &slot_bytes(1, Some(SLOT_DIGEST), &[]));
2122 let mut bytes = slot_bytes(1, Some(SLOT_DIGEST), &[]);
2123 bytes[17] = 7;
2124 fs::write(directory.join("main.1.token.0.slot"), bytes).unwrap();
2125 assert!(read_evidence_directory(&directory, "run-1").is_err());
2126 fs::remove_dir_all(directory).unwrap();
2127 }
2128
2129 #[test]
2130 fn a_slot_needs_its_transport_and_a_partial_layout_is_ignored() {
2131 let directory = killed_process("py-slot-orphan", &slot_bytes(1, Some(SLOT_DIGEST), &[16]));
2132 fs::write(directory.join("layout.json.42.token.partial"), b"{").unwrap();
2134 assert!(read_evidence_directory(&directory, "run-1").is_ok());
2135 fs::write(
2136 directory.join("other.9.token.0.slot"),
2137 slot_bytes(1, Some(SLOT_DIGEST), &[16]),
2138 )
2139 .unwrap();
2140 assert!(matches!(
2141 read_evidence_directory(&directory, "run-1"),
2142 Err(PythonEvidenceError::InvalidTransport { .. })
2143 ));
2144 fs::remove_dir_all(directory).unwrap();
2145 }
2146
2147 #[test]
2148 fn an_assertion_site_becomes_a_located_phase_when_the_inventory_names_one() {
2149 let inventory = inventory_of(&under("project", ""), &[("tests/test_m.py", 6, 5)]);
2154 let run = run_with_sites(
2155 "py-asite-located",
2156 &[json!({"t":"asite","ctx":1,"f":under("project", "tests/test_m.py"),"l":6})],
2157 None,
2158 &inventory,
2159 );
2160 assert!(
2161 assertion_sources(&run).contains(&"tests/test_m.py:6:4".to_string()),
2162 "expected a located assertion phase, got {:?}",
2163 assertion_sources(&run)
2164 );
2165 }
2166
2167 #[test]
2168 fn an_ambiguous_or_foreign_assertion_site_witnesses_nothing() {
2169 let ambiguous = inventory_of(
2173 &under("project", ""),
2174 &[("tests/test_m.py", 6, 5), ("tests/test_m.py", 6, 30)],
2175 );
2176 let run = run_with_sites(
2177 "py-asite-ambiguous",
2178 &[json!({"t":"asite","ctx":1,"f":under("project", "tests/test_m.py"),"l":6})],
2179 None,
2180 &ambiguous,
2181 );
2182 assert_eq!(
2183 assertion_sources(&run),
2184 vec!["tests/test_m.py::test_a".to_string()],
2185 "only the per-test assertion phase should remain"
2186 );
2187
2188 let known = inventory_of(&under("project", ""), &[("tests/test_m.py", 6, 5)]);
2189 let outside = run_with_sites(
2190 "py-asite-outside",
2191 &[json!({"t":"asite","ctx":1,"f":under("elsewhere", "tests/test_m.py"),"l":6})],
2192 None,
2193 &known,
2194 );
2195 assert_eq!(
2196 assertion_sources(&outside),
2197 vec!["tests/test_m.py::test_a".to_string()]
2198 );
2199 }
2200
2201 #[test]
2202 fn the_runner_names_the_test_file_in_either_path_form() {
2203 let inventory = inventory_of(&under("project", ""), &[("tests/test_m.py", 6, 5)]);
2208 for reported in [
2209 under("project", "tests/test_m.py"),
2210 "tests/test_m.py".to_owned(),
2211 "./tests/test_m.py".to_owned(),
2212 ] {
2213 let run = run_with_sites("py-asite-file", &[], Some(reported.as_str()), &inventory);
2214 assert_eq!(
2215 run.request.raw_results[0].test_file.as_deref(),
2216 Some("tests/test_m.py"),
2217 "{reported} should resolve to the project path"
2218 );
2219 }
2220 let without = run_with_sites("py-asite-nofile", &[], None, &inventory);
2221 assert_eq!(
2222 without.request.raw_results[0].test_file.as_deref(),
2223 Some("tests/test_m.py"),
2224 "a pytest node id already begins with the file"
2225 );
2226 }
2227
2228 #[test]
2229 fn evidence_before_the_first_assertion_links_to_it_when_the_test_passes() {
2230 let source = "def f(a):\n return a\n\n\ndef g(b):\n return b\n";
2235 let obligations = build_python_obligations("m.py", source).unwrap();
2236 let before = &obligations.plan.statements[0].id;
2237 let after = &obligations.plan.statements[1].id;
2238 let run_with = |name: &str, outcome: &str| {
2239 let lines = [
2240 json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.14.4","executable":"python","argv":["pytest"]}),
2241 json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call"}),
2242 json!({"t":"hit","ctx":1,"id":before}),
2243 json!({"t":"assert","ctx":1}),
2244 json!({"t":"assert","ctx":1}),
2245 json!({"t":"hit","ctx":1,"id":after}),
2246 json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call","outcome":outcome,"xfail":false}),
2247 json!({"t":"exit","at":9}),
2248 ];
2249 let directory = temporary(name);
2250 write_transport(&directory.join("main.1.mmap"), &lines, 0);
2251 let run = build_python_frontend_run(
2252 &obligations.manifest,
2253 &directory,
2254 "run-1",
2255 "now",
2256 0,
2257 &PythonAssertionInventory::empty(),
2258 )
2259 .unwrap();
2260 validate_frontend_report_request(&run.declaration, &run.request).unwrap();
2261 fs::remove_dir_all(directory).unwrap();
2262 run
2263 };
2264 let events_of = |result: &RawTestResult, phase: &str| -> BTreeSet<String> {
2265 result
2266 .runtime
2267 .iter()
2268 .flat_map(|snapshot| snapshot.events.iter())
2269 .filter(|event| event.phase_id.as_deref() == Some(phase))
2270 .map(|event| event.id.clone())
2271 .collect()
2272 };
2273
2274 let run = run_with("asserted-passed", "passed");
2275 let passed = &run.request.raw_results[0];
2276 assert_eq!(passed.test, "tests/test_m.py::test_a");
2277 let assertion = passed
2278 .phases
2279 .iter()
2280 .find(|phase| phase.kind == "assertion")
2281 .expect("the asserting test carries an assertion phase");
2282 assert_eq!(assertion.status.as_deref(), Some("passed"));
2283 assert_eq!(
2284 events_of(passed, &assertion.id),
2285 BTreeSet::from([before.clone()]),
2286 "only what ran before the marker is the assertion's evidence"
2287 );
2288 let test_phase = passed
2289 .phases
2290 .iter()
2291 .find(|phase| phase.kind == "test")
2292 .unwrap();
2293 assert_eq!(
2294 events_of(passed, &test_phase.id),
2295 BTreeSet::from([before.clone(), after.clone()]),
2296 "the test phase keeps everything it ran"
2297 );
2298 assert_eq!(
2299 run.declaration.runners[0].attribution.assertion,
2300 AttributionPrecision::Exact
2301 );
2302
2303 let run = run_with("asserted-failed", "failed");
2304 let failed = &run.request.raw_results[0];
2305 let assertion = failed
2306 .phases
2307 .iter()
2308 .find(|phase| phase.kind == "assertion")
2309 .unwrap();
2310 assert_eq!(
2311 assertion.status.as_deref(),
2312 Some("failed"),
2313 "a failed test's assertion witnessed nothing"
2314 );
2315 }
2316
2317 #[test]
2318 fn joins_phases_outcomes_hits_and_vectors_into_exact_results() {
2319 let source = "def f(a, b):\n if a and b:\n return 1\n return 0\n";
2320 let obligations = build_python_obligations("m.py", source).unwrap();
2321 let decision = &obligations.plan.decisions[0];
2322 let statement = &obligations.plan.statements[0];
2323 let directory = temporary("join");
2324 let lines = [
2325 json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.14.4","executable":"python","argv":["pytest"]}),
2326 json!({"t":"hit","ctx":0,"id":statement.id}),
2327 json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call"}),
2328 json!({"t":"dec","ctx":1,"id":decision.id,"v":"22","o":1}),
2329 json!({"t":"hit","ctx":1,"id":decision.outcome_true}),
2330 json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"setup","outcome":"passed","xfail":false}),
2331 json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call","outcome":"passed","xfail":false}),
2332 json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"teardown","outcome":"passed","xfail":false}),
2333 json!({"t":"limitation","id":"python-decision-partially-mapped","reason":"folded","obligation":decision.id}),
2334 json!({"t":"exit","at":9}),
2335 ];
2336 write_transport(&directory.join("main.1.mmap"), &lines, 0);
2337 let run = build_python_frontend_run(
2338 &obligations.manifest,
2339 &directory,
2340 "run-1",
2341 "now",
2342 0,
2343 &PythonAssertionInventory::empty(),
2344 )
2345 .unwrap();
2346 validate_frontend_report_request(&run.declaration, &run.request).unwrap();
2347 assert_eq!(run.tests, 1);
2348 assert_eq!(run.request.raw_results.len(), 2);
2349 let test = &run.request.raw_results[0];
2350 assert_eq!(test.status.as_deref(), Some("passed"));
2351 assert_eq!(test.phases.len(), 3);
2352 assert_eq!(test.runtime.len(), 1);
2353 assert_eq!(test.runtime[0].decisions.len(), 1);
2354 assert!(
2355 test.runtime[0]
2356 .events
2357 .iter()
2358 .all(|event| event.phase_id.is_some())
2359 );
2360 let background = &run.request.raw_results[1];
2361 assert_eq!(background.role, "background");
2362 assert!(run.request.manifest.unmeasured.contains(&decision.id));
2363 assert!(
2364 run.declaration
2365 .structural_limitations
2366 .contains(&"python-decision-partially-mapped".to_owned())
2367 );
2368 fs::remove_dir_all(directory).unwrap();
2369 }
2370
2371 #[test]
2376 fn a_transport_reads_by_its_own_versions_checksum() {
2377 let obligations = build_python_obligations("m.py", "x = 1\n").unwrap();
2378 let process = json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.14.4","executable":"p","argv":[]});
2379 let read = |name: &str, version: u32, corrupt: Option<usize>| {
2380 let directory = temporary(name);
2381 write_transport_version(
2382 &directory.join("main.1.mmap"),
2383 std::slice::from_ref(&process),
2384 0,
2385 version,
2386 corrupt,
2387 );
2388 let outcome = build_python_frontend_run(
2389 &obligations.manifest,
2390 &directory,
2391 "run-1",
2392 "now",
2393 0,
2394 &PythonAssertionInventory::empty(),
2395 );
2396 fs::remove_dir_all(directory).unwrap();
2397 outcome.map(|_| ()).map_err(|error| error.to_string())
2398 };
2399 let current = read("checksum-v2", TRANSPORT_VERSION, None);
2400 let older = read("checksum-v1", TRANSPORT_VERSION_FNV, None);
2401 assert_eq!(
2402 older, current,
2403 "a version 1 transport reads as a version 2 one does"
2404 );
2405 assert!(
2406 !current
2407 .as_ref()
2408 .err()
2409 .is_some_and(|error| error.contains("checksum")),
2410 "{current:?}"
2411 );
2412 let corrupted = read("checksum-bad", TRANSPORT_VERSION, Some(0)).unwrap_err();
2413 assert!(corrupted.contains("checksum does not match"), "{corrupted}");
2414 let unknown = read("checksum-v3", TRANSPORT_VERSION + 1, None).unwrap_err();
2415 assert!(unknown.contains("version"), "{unknown}");
2416 }
2417
2418 #[test]
2419 fn a_phase_its_runner_reported_more_than_once_is_one_phase() {
2420 let source = "def f(a):\n return a\n";
2428 let obligations = build_python_obligations("m.py", source).unwrap();
2429 let directory = temporary("subtests");
2430 let subtest = |outcome: &str| {
2431 json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_many","retry":0,
2432 "phase":"call","outcome":outcome,"xfail":false})
2433 };
2434 let lines = [
2435 json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.14.4","executable":"python","argv":["pytest"]}),
2436 json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"tests/test_m.py::test_many","retry":0,"phase":"call"}),
2437 json!({"t":"hit","ctx":1,"id":obligations.plan.statements[0].id}),
2438 json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_many","retry":0,"phase":"setup","outcome":"passed","xfail":false}),
2439 subtest("passed"),
2440 subtest("passed"),
2441 subtest("failed"),
2442 subtest("passed"),
2443 json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_many","retry":0,"phase":"teardown","outcome":"passed","xfail":false}),
2444 json!({"t":"exit","at":9}),
2445 ];
2446 write_transport(&directory.join("main.1.mmap"), &lines, 0);
2447 let run = build_python_frontend_run(
2448 &obligations.manifest,
2449 &directory,
2450 "run-1",
2451 "now",
2452 1,
2453 &PythonAssertionInventory::empty(),
2454 )
2455 .unwrap();
2456 validate_frontend_report_request(&run.declaration, &run.request).unwrap();
2459 let test = &run.request.raw_results[0];
2460 assert_eq!(
2461 test.phases.len(),
2462 3,
2463 "setup, one call, teardown: {:?}",
2464 test.phases
2465 );
2466 assert_eq!(
2467 test.phases
2468 .iter()
2469 .filter(|phase| phase.kind == "test")
2470 .count(),
2471 1,
2472 "the subtests share the call phase their coverage was recorded under"
2473 );
2474 assert_eq!(
2475 test.status.as_deref(),
2476 Some("failed"),
2477 "a subtest that failed fails its test"
2478 );
2479 assert_eq!(test.runtime.len(), 1);
2481 assert!(!test.runtime[0].hits.is_empty());
2482 fs::remove_dir_all(directory).unwrap();
2483 }
2484
2485 #[test]
2486 fn fails_closed_without_an_interpreter_or_tests() {
2487 let obligations = build_python_obligations("m.py", "x = 1\n").unwrap();
2488 let directory = temporary("empty");
2489 assert!(matches!(
2490 build_python_frontend_run(
2491 &obligations.manifest,
2492 &directory,
2493 "run-1",
2494 "now",
2495 0,
2496 &PythonAssertionInventory::empty()
2497 ),
2498 Err(PythonEvidenceError::NoInterpreter)
2499 ));
2500 let path = directory.join("main.1.mmap");
2501 write_transport(
2502 &path,
2503 &[
2504 json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.14.4","executable":"p","argv":[]}),
2505 ],
2506 0,
2507 );
2508 assert!(matches!(
2509 build_python_frontend_run(
2510 &obligations.manifest,
2511 &directory,
2512 "run-1",
2513 "now",
2514 0,
2515 &PythonAssertionInventory::empty()
2516 ),
2517 Err(PythonEvidenceError::NoTests)
2518 ));
2519 let mut torn = fs::read(&path).unwrap();
2523 let process_length = transport_u32(&torn, TRANSPORT_HEADER_SIZE + 4).unwrap() as usize;
2524 let torn_cursor =
2525 align_transport(TRANSPORT_HEADER_SIZE + TRANSPORT_RECORD_HEADER_SIZE + process_length)
2526 .unwrap();
2527 torn[torn_cursor + 4..torn_cursor + 8].copy_from_slice(&5_u32.to_le_bytes());
2528 torn[torn_cursor + TRANSPORT_RECORD_HEADER_SIZE
2529 ..torn_cursor + TRANSPORT_RECORD_HEADER_SIZE + 5]
2530 .copy_from_slice(b"{nope");
2531 fs::write(&path, torn).unwrap();
2532 assert!(matches!(
2533 build_python_frontend_run(
2534 &obligations.manifest,
2535 &directory,
2536 "run-1",
2537 "now",
2538 0,
2539 &PythonAssertionInventory::empty()
2540 ),
2541 Err(PythonEvidenceError::NoTests)
2542 ));
2543 write_transport(
2544 &path,
2545 &[
2546 json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.8.20","executable":"p","argv":[]}),
2547 ],
2548 0,
2549 );
2550 assert!(matches!(
2551 build_python_frontend_run(
2552 &obligations.manifest,
2553 &directory,
2554 "run-1",
2555 "now",
2556 0,
2557 &PythonAssertionInventory::empty()
2558 ),
2559 Err(PythonEvidenceError::UnsupportedPython(_))
2560 ));
2561 write_transport(
2562 &path,
2563 &[
2564 json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.14.4","executable":"p","argv":[]}),
2565 ],
2566 2,
2567 );
2568 assert!(matches!(
2569 build_python_frontend_run(
2570 &obligations.manifest,
2571 &directory,
2572 "run-1",
2573 "now",
2574 0,
2575 &PythonAssertionInventory::empty()
2576 ),
2577 Err(PythonEvidenceError::DroppedRecords { count: 2, .. })
2578 ));
2579 fs::remove_dir_all(directory).unwrap();
2580 }
2581}