1use std::{
12 collections::{BTreeMap, BTreeSet},
13 fs::{self, File},
14 path::{Component, Path},
15};
16
17use memmap2::{Mmap, MmapOptions};
18use serde::Deserialize;
19use serde_json::json;
20use sha2::{Digest, Sha256};
21use supercov_contracts::{
22 AttributionPrecision, ExecutionModel, FrontendAttribution, FrontendLimitation,
23 FrontendLimitationScope, FrontendRunDeclaration, FrontendRunnerDeclaration,
24 LANGUAGE_FRONTEND_PROTOCOL_VERSION, StructuralSource,
25};
26
27use crate::{
28 coverage_analysis::McdcVector,
29 coverage_report::{
30 CoverageManifest, CoverageModelDeclaration, CoveragePhase, CoverageReportRequest,
31 DecisionMeta, DecisionSnapshot, ExecutionScope, ExitCodeInput, PersistedCoverageModel,
32 RawTestResult, RuntimeEvent, RuntimeSnapshot, TestProvenance,
33 },
34 evidence_archive::EvidenceArchiveEntry,
35};
36
37pub const PYTHON_EVIDENCE_VERSION: u32 = 1;
38pub const PYTHON_FRONTEND_VERSION: &str = "python-monitoring-v1";
39pub const PYTEST_RUNNER: &str = "pytest";
40pub const UNITTEST_RUNNER: &str = "unittest";
41
42const TRANSPORT_MAGIC: &[u8; 8] = b"SCVPYTH1";
43const TRANSPORT_VERSION: u32 = 1;
44const TRANSPORT_HEADER_SIZE: usize = 64;
45const TRANSPORT_RECORD_HEADER_SIZE: usize = 16;
46const TRANSPORT_MAX_RECORD_SIZE: usize = 4 * 1024 * 1024;
47
48fn default_runner() -> String {
49 PYTEST_RUNNER.into()
50}
51
52#[derive(Debug, Deserialize)]
55#[allow(dead_code)]
56#[serde(tag = "t", rename_all = "lowercase", deny_unknown_fields)]
57enum Record {
58 Process {
59 v: u32,
60 run: String,
61 pid: u64,
62 worker: String,
63 python: String,
64 executable: String,
65 argv: Vec<String>,
66 },
67 Worker {
68 worker: String,
69 },
70 Phase {
71 ctx: u64,
72 at: i64,
73 worker: String,
74 test: String,
75 retry: usize,
76 phase: String,
77 },
78 Outcome {
79 worker: String,
80 test: String,
81 retry: usize,
82 phase: String,
83 outcome: String,
84 xfail: bool,
85 #[serde(default = "default_runner")]
86 runner: String,
87 },
88 Hit {
89 ctx: u64,
90 id: String,
91 },
92 Dec {
93 ctx: u64,
94 id: String,
95 v: String,
96 o: u8,
97 },
98 Limitation {
99 id: String,
100 reason: String,
101 #[serde(default)]
102 file: Option<String>,
103 #[serde(default)]
104 obligation: Option<String>,
105 },
106 Exit {
107 at: i64,
108 },
109}
110
111#[derive(Debug)]
112pub enum PythonEvidenceError {
113 Io(String),
114 UnsafeEntry(String),
115 InvalidRecord {
116 file: String,
117 line: usize,
118 reason: String,
119 },
120 InvalidTransport {
121 file: String,
122 reason: String,
123 },
124 DroppedRecords {
125 file: String,
126 count: u64,
127 },
128 RunMismatch {
129 expected: String,
130 actual: String,
131 },
132 UnsupportedVersion(u32),
133 UnknownContext {
134 file: String,
135 line: usize,
136 context: u64,
137 },
138 UnknownObligation(String),
139 InvalidVector {
140 id: String,
141 expected: usize,
142 actual: usize,
143 },
144 NoInterpreter,
145 NoTests,
146 UnsupportedPython(String),
147}
148
149impl std::fmt::Display for PythonEvidenceError {
150 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
151 match self {
152 Self::Io(reason) => write!(formatter, "could not read Python evidence: {reason}"),
153 Self::UnsafeEntry(name) => write!(formatter, "unsafe Python evidence entry: {name}"),
154 Self::InvalidRecord { file, line, reason } => {
155 write!(formatter, "invalid Python evidence record {file}:{line}: {reason}")
156 }
157 Self::InvalidTransport { file, reason } => {
158 write!(formatter, "invalid Python evidence transport {file}: {reason}")
159 }
160 Self::DroppedRecords { file, count } => write!(
161 formatter,
162 "Python evidence transport {file} exhausted its bounded capacity and dropped {count} record(s)"
163 ),
164 Self::RunMismatch { expected, actual } => write!(
165 formatter,
166 "Python evidence belongs to run {actual}, expected {expected}"
167 ),
168 Self::UnsupportedVersion(version) => {
169 write!(formatter, "unsupported Python evidence version {version}")
170 }
171 Self::UnknownContext { file, line, context } => write!(
172 formatter,
173 "Python evidence {file}:{line} references undeclared context {context}"
174 ),
175 Self::UnknownObligation(id) => {
176 write!(formatter, "Python runtime reported an unknown obligation: {id}")
177 }
178 Self::InvalidVector {
179 id,
180 expected,
181 actual,
182 } => write!(
183 formatter,
184 "Python decision {id} reported {actual} condition values, expected {expected}"
185 ),
186 Self::NoInterpreter => formatter.write_str(
187 "no Supercov-hooked Python interpreter ran: the test command did not start CPython 3.12+ with Supercov's start-up hook (PYTHONPATH may be ignored by -I/-E/-S, or the runner is not Python)",
188 ),
189 Self::NoTests => formatter.write_str(
190 "the Python run produced no test outcomes; Supercov measures Python through pytest and unittest",
191 ),
192 Self::UnsupportedPython(version) => write!(
193 formatter,
194 "Supercov measures CPython 3.12 or newer; the test command ran Python {version}"
195 ),
196 }
197 }
198}
199
200impl std::error::Error for PythonEvidenceError {}
201
202fn stable_id(prefix: &str, values: &[&str]) -> String {
203 let mut hash = Sha256::new();
204 for value in values {
205 hash.update(value.as_bytes());
206 hash.update([0]);
207 }
208 let digest = hash.finalize();
209 let mut encoded = String::with_capacity(prefix.len() + 25);
210 encoded.push_str(prefix);
211 encoded.push(':');
212 for byte in &digest[..12] {
213 use std::fmt::Write as _;
214 write!(&mut encoded, "{byte:02x}").expect("string formatting");
215 }
216 encoded
217}
218
219#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
220struct Identity {
221 worker: String,
222 test: String,
223 retry: usize,
224 phase: String,
225}
226
227type ObservedVectors = BTreeSet<(Vec<Option<bool>>, bool)>;
228type OutcomesByAttempt = BTreeMap<(String, String, usize), Vec<(String, String, bool)>>;
230type RunnersByAttempt = BTreeMap<(String, String, usize), String>;
232
233#[derive(Debug, Default)]
234struct Observations {
235 hits: BTreeSet<String>,
236 vectors: BTreeMap<String, ObservedVectors>,
237}
238
239#[derive(Debug, Clone)]
240struct RuntimeLimitation {
241 id: String,
242 reason: String,
243 file: Option<String>,
244 obligation: Option<String>,
245}
246
247#[derive(Debug, Default)]
248struct Evidence {
249 interpreters: usize,
250 python_versions: BTreeSet<String>,
251 per_identity: BTreeMap<Identity, Observations>,
252 background: BTreeMap<String, Observations>,
253 outcomes: OutcomesByAttempt,
254 runners: RunnersByAttempt,
255 limitations: Vec<RuntimeLimitation>,
256}
257
258fn read_evidence_directory(
259 directory: &Path,
260 run_id: &str,
261) -> Result<Evidence, PythonEvidenceError> {
262 let mut evidence = Evidence::default();
263 let mut files = match fs::read_dir(directory) {
264 Ok(entries) => entries
265 .collect::<Result<Vec<_>, _>>()
266 .map_err(|error| PythonEvidenceError::Io(error.to_string()))?,
267 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
268 Err(error) => return Err(PythonEvidenceError::Io(error.to_string())),
269 };
270 files.sort_by_key(|entry| entry.file_name());
271 for entry in files {
272 let name = entry
273 .file_name()
274 .into_string()
275 .map_err(|_| PythonEvidenceError::UnsafeEntry("<non-utf8>".into()))?;
276 if Path::new(&name)
277 .components()
278 .any(|component| !matches!(component, Component::Normal(_)))
279 || !name.ends_with(".mmap")
280 {
281 return Err(PythonEvidenceError::UnsafeEntry(name));
282 }
283 let metadata = fs::symlink_metadata(entry.path())
284 .map_err(|error| PythonEvidenceError::Io(error.to_string()))?;
285 if !metadata.file_type().is_file() {
286 return Err(PythonEvidenceError::UnsafeEntry(name));
287 }
288 let file =
289 File::open(entry.path()).map_err(|error| PythonEvidenceError::Io(error.to_string()))?;
290 let contents = unsafe { MmapOptions::new().map(&file) }
294 .map_err(|error| PythonEvidenceError::Io(error.to_string()))?;
295 read_evidence_file(&name, &contents, run_id, &mut evidence)?;
296 }
297 Ok(evidence)
298}
299
300fn transport_u32(bytes: &[u8], offset: usize) -> Option<u32> {
301 bytes
302 .get(offset..offset + 4)
303 .and_then(|value| value.try_into().ok())
304 .map(u32::from_le_bytes)
305}
306
307fn transport_u64(bytes: &[u8], offset: usize) -> Option<u64> {
308 bytes
309 .get(offset..offset + 8)
310 .and_then(|value| value.try_into().ok())
311 .map(u64::from_le_bytes)
312}
313
314fn transport_checksum(payload: &[u8]) -> u32 {
315 payload.iter().fold(0x811c_9dc5_u32, |value, byte| {
316 (value ^ u32::from(*byte)).wrapping_mul(0x0100_0193)
317 })
318}
319
320fn align_transport(value: usize) -> Option<usize> {
321 value.checked_add(7).map(|value| value & !7)
322}
323
324fn read_evidence_file(
325 name: &str,
326 contents: &Mmap,
327 run_id: &str,
328 evidence: &mut Evidence,
329) -> Result<(), PythonEvidenceError> {
330 let invalid_transport = |reason: &str| PythonEvidenceError::InvalidTransport {
331 file: name.into(),
332 reason: reason.into(),
333 };
334 if contents.len() < TRANSPORT_HEADER_SIZE
335 || contents.get(..8) != Some(TRANSPORT_MAGIC.as_slice())
336 || transport_u32(contents, 8) != Some(TRANSPORT_VERSION)
337 || transport_u32(contents, 12) != Some(TRANSPORT_HEADER_SIZE as u32)
338 {
339 return Err(invalid_transport("header or version does not match"));
340 }
341 let declared_capacity =
342 transport_u64(contents, 16).ok_or_else(|| invalid_transport("capacity is missing"))?;
343 if declared_capacity < TRANSPORT_HEADER_SIZE as u64 || declared_capacity > contents.len() as u64
344 {
345 return Err(invalid_transport(
346 "declared capacity is outside the mapped file",
347 ));
348 }
349 let dropped =
350 transport_u64(contents, 24).ok_or_else(|| invalid_transport("drop counter is missing"))?;
351 if dropped != 0 {
352 return Err(PythonEvidenceError::DroppedRecords {
353 file: name.into(),
354 count: dropped,
355 });
356 }
357 let transport_pid = transport_u64(contents, 32)
358 .filter(|pid| *pid != 0)
359 .ok_or_else(|| invalid_transport("process id is missing"))?;
360 let mut contexts = BTreeMap::<u64, Identity>::new();
361 let mut process_worker: Option<String> = None;
362 let mut cursor = TRANSPORT_HEADER_SIZE;
363 let mut record_index = 0;
364 while cursor + TRANSPORT_RECORD_HEADER_SIZE <= contents.len() {
365 let commit = contents[cursor];
366 if commit == 0 {
367 break;
370 }
371 record_index += 1;
372 let line_number = record_index;
373 let invalid = |reason: &str| PythonEvidenceError::InvalidRecord {
374 file: name.into(),
375 line: line_number,
376 reason: reason.into(),
377 };
378 if commit != 1
379 || contents[cursor + 1..cursor + 4] != [0, 0, 0]
380 || contents[cursor + 12..cursor + 16] != [0, 0, 0, 0]
381 {
382 return Err(invalid("commit marker or reserved bytes are invalid"));
383 }
384 let length = transport_u32(contents, cursor + 4)
385 .map(|value| value as usize)
386 .ok_or_else(|| invalid("payload length is missing"))?;
387 if length == 0 || length > TRANSPORT_MAX_RECORD_SIZE {
388 return Err(invalid("payload length is outside the transport bound"));
389 }
390 let payload_start = cursor + TRANSPORT_RECORD_HEADER_SIZE;
391 let payload_end = payload_start
392 .checked_add(length)
393 .filter(|end| *end <= contents.len())
394 .ok_or_else(|| invalid("payload extends past the mapped file"))?;
395 let next_cursor = align_transport(payload_end)
396 .filter(|end| *end <= contents.len())
397 .ok_or_else(|| invalid("aligned frame extends past the mapped file"))?;
398 if contents[payload_end..next_cursor]
399 .iter()
400 .any(|byte| *byte != 0)
401 {
402 return Err(invalid("frame padding is not zero"));
403 }
404 let payload = &contents[payload_start..payload_end];
405 let expected_checksum = transport_u32(contents, cursor + 8)
406 .ok_or_else(|| invalid("payload checksum is missing"))?;
407 if transport_checksum(payload) != expected_checksum {
408 return Err(invalid("payload checksum does not match"));
409 }
410 let record: Record = serde_json::from_slice(payload).map_err(|error| {
411 PythonEvidenceError::InvalidRecord {
412 file: name.into(),
413 line: line_number,
414 reason: error.to_string(),
415 }
416 })?;
417 match record {
418 Record::Process {
419 v,
420 run,
421 pid,
422 worker,
423 python,
424 ..
425 } => {
426 if v != PYTHON_EVIDENCE_VERSION {
427 return Err(PythonEvidenceError::UnsupportedVersion(v));
428 }
429 if run != run_id {
430 return Err(PythonEvidenceError::RunMismatch {
431 expected: run_id.into(),
432 actual: run,
433 });
434 }
435 if pid != transport_pid {
436 return Err(invalid("process record does not match the transport owner"));
437 }
438 let supported = python
439 .split('.')
440 .take(2)
441 .map(|part| part.parse::<u32>().ok())
442 .collect::<Option<Vec<_>>>()
443 .is_some_and(|parts| parts.len() == 2 && (parts[0], parts[1]) >= (3, 12));
444 if !supported {
445 return Err(PythonEvidenceError::UnsupportedPython(python));
446 }
447 evidence.interpreters += 1;
448 evidence.python_versions.insert(python);
449 process_worker = Some(worker);
450 }
451 Record::Worker { worker } => process_worker = Some(worker),
452 Record::Phase {
453 ctx,
454 worker,
455 test,
456 retry,
457 phase,
458 ..
459 } => {
460 if ctx == 0 {
461 return Err(invalid("phase context 0 is reserved for background"));
462 }
463 if !matches!(phase.as_str(), "setup" | "call" | "teardown") {
464 return Err(invalid("unknown pytest phase"));
465 }
466 if test.trim().is_empty() || worker.trim().is_empty() {
467 return Err(invalid("phase identity must name a worker and test"));
468 }
469 contexts.insert(
470 ctx,
471 Identity {
472 worker,
473 test,
474 retry,
475 phase,
476 },
477 );
478 }
479 Record::Outcome {
480 worker,
481 test,
482 retry,
483 phase,
484 outcome,
485 xfail,
486 runner,
487 } => {
488 if !matches!(phase.as_str(), "setup" | "call" | "teardown") {
489 return Err(invalid("unknown test outcome phase"));
490 }
491 if !matches!(
492 outcome.as_str(),
493 "passed" | "failed" | "skipped" | "rerun" | "error"
494 ) {
495 return Err(invalid("unknown test outcome"));
496 }
497 if !matches!(runner.as_str(), PYTEST_RUNNER | UNITTEST_RUNNER) {
498 return Err(invalid("unknown Python test runner"));
499 }
500 let key = (worker, test, retry);
501 if let Some(previous) = evidence.runners.get(&key)
502 && previous != &runner
503 {
504 return Err(invalid("one attempt was reported by two runners"));
505 }
506 evidence.runners.insert(key.clone(), runner);
507 evidence
508 .outcomes
509 .entry(key)
510 .or_default()
511 .push((phase, outcome, xfail));
512 }
513 Record::Hit { ctx, id } => {
514 observations(
515 evidence,
516 &contexts,
517 process_worker.as_deref(),
518 ctx,
519 name,
520 line_number,
521 )?
522 .hits
523 .insert(id);
524 }
525 Record::Dec { ctx, id, v, o } => {
526 if v.is_empty() || !v.bytes().all(|digit| matches!(digit, b'0' | b'1' | b'2')) {
527 return Err(invalid("decision vector digits must be 0, 1 or 2"));
528 }
529 if o > 1 {
530 return Err(invalid("decision outcome must be 0 or 1"));
531 }
532 let values = v
533 .bytes()
534 .map(|digit| match digit {
535 b'0' => None,
536 b'1' => Some(false),
537 _ => Some(true),
538 })
539 .collect::<Vec<_>>();
540 observations(
541 evidence,
542 &contexts,
543 process_worker.as_deref(),
544 ctx,
545 name,
546 line_number,
547 )?
548 .vectors
549 .entry(id)
550 .or_default()
551 .insert((values, o == 1));
552 }
553 Record::Limitation {
554 id,
555 reason,
556 file,
557 obligation,
558 } => evidence.limitations.push(RuntimeLimitation {
559 id,
560 reason,
561 file,
562 obligation,
563 }),
564 Record::Exit { .. } => {}
565 }
566 cursor = next_cursor;
567 }
568 Ok(())
569}
570
571fn observations<'a>(
572 evidence: &'a mut Evidence,
573 contexts: &BTreeMap<u64, Identity>,
574 process_worker: Option<&str>,
575 context: u64,
576 file: &str,
577 line: usize,
578) -> Result<&'a mut Observations, PythonEvidenceError> {
579 if context == 0 {
580 return Ok(evidence
581 .background
582 .entry(process_worker.unwrap_or("main").to_owned())
583 .or_default());
584 }
585 let identity = contexts
586 .get(&context)
587 .ok_or(PythonEvidenceError::UnknownContext {
588 file: file.into(),
589 line,
590 context,
591 })?;
592 Ok(evidence.per_identity.entry(identity.clone()).or_default())
593}
594
595pub fn python_coverage_model() -> CoverageModelDeclaration {
596 CoverageModelDeclaration {
597 language: "python".into(),
598 variant: "python-owned-monitoring".into(),
599 name: "python-sys-monitoring-v1".into(),
600 completeness_meaning: "Every statement, function, decision vector, loop, short-circuit, match and exception-flow obligation Supercov derived from the source was observed through CPython's monitoring events with exact test identity; the declared runtime limitations remain separate.".into(),
601 measured: vec![
602 "executable statements proven by CPython LINE events on their header lines, or INSTRUCTION events when they share a line".into(),
603 "function and lambda entry".into(),
604 "boolean decision vectors with masking MC/DC from conditional-jump events".into(),
605 "for-loop and comprehension zero-versus-entered iteration".into(),
606 "logical and/or short-circuit alternatives".into(),
607 "match case selection and guards".into(),
608 "try completion, handler selection and exception propagation".into(),
609 "pytest and unittest worker, test, retry and setup/call/teardown phase identity".into(),
610 ],
611 not_measured: vec![
612 "zero-iteration executions of a loop after it has run and exited 16 times within one test phase on CPython 3.14".into(),
613 "causal linkage to individual actions or passing assertions".into(),
614 "code compiled from strings at runtime".into(),
615 "causal test context for raw _thread or native-extension-created threads".into(),
616 "child coverage outside subprocess.Popen and multiprocessing adapters".into(),
617 "all input values, semantic partitions, paths, or concurrency interleavings".into(),
618 "mutation score or assertion fault-detection strength".into(),
619 ],
620 }
621}
622
623fn phase_id(run: &str, identity: &Identity) -> String {
624 stable_id(
625 "python-phase",
626 &[
627 run,
628 &identity.worker,
629 &identity.test,
630 &identity.retry.to_string(),
631 &identity.phase,
632 ],
633 )
634}
635
636fn scope(run: &str, worker: &str, test: &str, retry: usize) -> ExecutionScope {
637 ExecutionScope {
638 version: 1,
639 run_id: run.into(),
640 worker_id: worker.into(),
641 test_id: test.into(),
642 test_key: stable_id("python-test", &[worker, test]),
643 retry,
644 attempt_id: stable_id("python-attempt", &[run, worker, test, &retry.to_string()]),
645 }
646}
647
648struct ManifestIndex<'a> {
649 points: BTreeSet<&'a str>,
650 alternatives: BTreeSet<&'a str>,
651 decisions: BTreeMap<&'a str, &'a DecisionMeta>,
652 lines: BTreeMap<&'a str, (String, usize)>,
653}
654
655impl<'a> ManifestIndex<'a> {
656 fn new(manifest: &'a CoverageManifest) -> Self {
657 let mut lines = BTreeMap::new();
658 for point in &manifest.points {
659 lines.insert(point.id.as_str(), (point.file.clone(), point.line));
660 }
661 for decision in &manifest.decisions {
662 lines.insert(decision.id.as_str(), (decision.file.clone(), decision.line));
663 }
664 for branch in &manifest.branches {
665 lines.insert(branch.id.as_str(), (branch.file.clone(), branch.line));
666 }
667 Self {
668 points: manifest
669 .points
670 .iter()
671 .map(|point| point.id.as_str())
672 .collect(),
673 alternatives: manifest
674 .branches
675 .iter()
676 .flat_map(|branch| branch.alternatives.iter().map(|alt| alt.id.as_str()))
677 .collect(),
678 decisions: manifest
679 .decisions
680 .iter()
681 .map(|decision| (decision.id.as_str(), decision))
682 .collect(),
683 lines,
684 }
685 }
686}
687
688fn snapshot(
689 index: &ManifestIndex<'_>,
690 observations: &Observations,
691 phase: &str,
692) -> Result<RuntimeSnapshot, PythonEvidenceError> {
693 let mut hits = BTreeSet::new();
694 for id in &observations.hits {
695 if !index.points.contains(id.as_str()) && !index.alternatives.contains(id.as_str()) {
696 return Err(PythonEvidenceError::UnknownObligation(id.clone()));
697 }
698 hits.insert(id.clone());
699 }
700 let mut decisions = Vec::new();
701 let mut events = Vec::new();
702 let mut clock = 1;
703 for id in &hits {
704 events.push(RuntimeEvent {
705 event_type: "hit".into(),
706 id: id.clone(),
707 vector: None,
708 timestamp_ms: clock,
709 phase_id: Some(phase.into()),
710 environment: "python".into(),
711 });
712 clock += 1;
713 }
714 for (id, vectors) in &observations.vectors {
715 let Some(meta) = index.decisions.get(id.as_str()) else {
716 return Err(PythonEvidenceError::UnknownObligation(id.clone()));
717 };
718 let mut observed = Vec::new();
719 for (values, outcome) in vectors {
720 if values.len() != meta.conditions.len() {
721 return Err(PythonEvidenceError::InvalidVector {
722 id: id.clone(),
723 expected: meta.conditions.len(),
724 actual: values.len(),
725 });
726 }
727 let vector = McdcVector {
728 values: values.clone(),
729 outcome: *outcome,
730 };
731 events.push(RuntimeEvent {
732 event_type: "decision".into(),
733 id: id.clone(),
734 vector: Some(vector.clone()),
735 timestamp_ms: clock,
736 phase_id: Some(phase.into()),
737 environment: "python".into(),
738 });
739 clock += 1;
740 observed.push(vector);
741 }
742 decisions.push(DecisionSnapshot {
743 meta: (*meta).clone(),
744 vectors: observed,
745 });
746 }
747 Ok(RuntimeSnapshot {
748 decisions,
749 hits: hits.into_iter().collect(),
750 events,
751 })
752}
753
754fn attempt_status(outcomes: &[(String, String, bool)]) -> String {
755 if outcomes
756 .iter()
757 .any(|(_, outcome, _)| matches!(outcome.as_str(), "failed" | "rerun" | "error"))
758 {
759 "failed"
760 } else if outcomes.iter().any(|(_, outcome, _)| outcome == "skipped") {
761 "skipped"
762 } else {
763 "passed"
764 }
765 .into()
766}
767
768#[derive(Debug, Clone, PartialEq)]
769pub struct PythonFrontendRun {
770 pub declaration: FrontendRunDeclaration,
771 pub request: CoverageReportRequest,
772 pub tests: usize,
773 pub interpreters: usize,
774 pub python_versions: Vec<String>,
775}
776
777impl PythonFrontendRun {
778 pub fn archive_entries(&self) -> Result<Vec<EvidenceArchiveEntry>, serde_json::Error> {
779 let model = PersistedCoverageModel::from_declaration(
780 self.request
781 .coverage_model
782 .as_ref()
783 .expect("Python frontend always declares a coverage model"),
784 )
785 .expect("Python coverage model is contract-valid");
786 let mut entries = vec![
787 EvidenceArchiveEntry {
788 path: "coverage-model.json".into(),
789 contents: serde_json::to_vec(&model)?,
790 },
791 EvidenceArchiveEntry {
792 path: "frontend.json".into(),
793 contents: serde_json::to_vec(&self.declaration)?,
794 },
795 EvidenceArchiveEntry {
796 path: "manifest.json".into(),
797 contents: serde_json::to_vec(&self.request.manifest)?,
798 },
799 ];
800 for (index, result) in self.request.raw_results.iter().enumerate() {
801 entries.push(EvidenceArchiveEntry {
802 path: format!("results/{index:08}/mcdc.json"),
803 contents: serde_json::to_vec(result)?,
804 });
805 }
806 Ok(entries)
807 }
808}
809
810pub fn build_python_frontend_run(
813 manifest: &CoverageManifest,
814 evidence_directory: &Path,
815 run_id: &str,
816 generated_at: &str,
817 test_exit_code: i32,
818) -> Result<PythonFrontendRun, PythonEvidenceError> {
819 let evidence = read_evidence_directory(evidence_directory, run_id)?;
820 if evidence.interpreters == 0 {
821 return Err(PythonEvidenceError::NoInterpreter);
822 }
823 if evidence.outcomes.is_empty() {
824 return Err(PythonEvidenceError::NoTests);
825 }
826 let Evidence {
827 interpreters,
828 python_versions,
829 per_identity,
830 background,
831 outcomes,
832 runners,
833 limitations,
834 } = evidence;
835 let mut manifest = manifest.clone();
836 let index = ManifestIndex::new(&manifest);
837
838 let mut raw_results = Vec::new();
839 let mut observed_runners = BTreeSet::new();
840 let mut identities_by_attempt =
841 BTreeMap::<(String, String, usize), Vec<(&Identity, &Observations)>>::new();
842 for (identity, observations) in &per_identity {
843 identities_by_attempt
844 .entry((
845 identity.worker.clone(),
846 identity.test.clone(),
847 identity.retry,
848 ))
849 .or_default()
850 .push((identity, observations));
851 }
852 for ((worker, test, retry), mut outcomes) in outcomes {
853 let runner = runners
854 .get(&(worker.clone(), test.clone(), retry))
855 .cloned()
856 .unwrap_or_else(default_runner);
857 let attempt_identities = identities_by_attempt
858 .remove(&(worker.clone(), test.clone(), retry))
859 .unwrap_or_default();
860 observed_runners.insert(runner.clone());
861 outcomes.sort_by_key(|(phase, _, _)| match phase.as_str() {
862 "setup" => 0,
863 "call" => 1,
864 _ => 2,
865 });
866 let mut phases = Vec::new();
867 let mut runtime = Vec::new();
868 let mut observed_phases = BTreeSet::new();
869 for (position, (phase_name, outcome, _)) in outcomes.iter().enumerate() {
870 observed_phases.insert(phase_name.clone());
871 let identity = Identity {
872 worker: worker.clone(),
873 test: test.clone(),
874 retry,
875 phase: phase_name.clone(),
876 };
877 let id = phase_id(run_id, &identity);
878 phases.push(CoveragePhase {
879 id: id.clone(),
880 kind: match phase_name.as_str() {
881 "call" => "test",
882 value => value,
883 }
884 .into(),
885 operation: format!("{runner} {phase_name}"),
886 source: Some(test.clone()),
887 caused_by_phase_id: None,
888 started_at_ms: position as i64 * 2 + 1,
889 ended_at_ms: Some(position as i64 * 2 + 2),
890 status: Some(match outcome.as_str() {
891 "rerun" | "error" => "failed".into(),
892 value => value.into(),
893 }),
894 error: None,
895 });
896 if let Some((_, observations)) = attempt_identities
897 .iter()
898 .find(|(candidate, _)| candidate.phase == phase_name.as_str())
899 {
900 runtime.push(snapshot(&index, observations, &id)?);
901 }
902 }
903 for (identity, observations) in attempt_identities {
906 if !observed_phases.contains(&identity.phase) {
907 let id = phase_id(run_id, identity);
908 phases.push(CoveragePhase {
909 id: id.clone(),
910 kind: match identity.phase.as_str() {
911 "call" => "test",
912 value => value,
913 }
914 .into(),
915 operation: format!("{runner} {}", identity.phase),
916 source: Some(test.clone()),
917 caused_by_phase_id: None,
918 started_at_ms: phases.len() as i64 * 2 + 1,
919 ended_at_ms: None,
920 status: Some("failed".into()),
921 error: Some("the phase started but the runner reported no outcome".into()),
922 });
923 runtime.push(snapshot(&index, observations, &id)?);
924 }
925 }
926 let status = if phases.iter().any(|phase| phase.error.is_some()) {
927 "failed".into()
928 } else {
929 attempt_status(&outcomes)
930 };
931 raw_results.push(RawTestResult {
932 test_id: Some(test.clone()),
933 scope: Some(scope(run_id, &worker, &test, retry)),
934 test: test.clone(),
935 test_file: test.split("::").next().map(str::to_owned),
936 title: test.rsplit("::").next().map(str::to_owned),
937 retry: Some(retry),
938 status: Some(status),
939 expected_status: Some(
940 if outcomes.iter().any(|(_, _, xfail)| *xfail) {
941 "failed"
942 } else {
943 "passed"
944 }
945 .into(),
946 ),
947 flaky: false,
948 provenance: TestProvenance {
949 runner: runner.clone(),
950 kind: "unit".into(),
951 project: None,
952 source: PYTHON_FRONTEND_VERSION.into(),
953 },
954 role: "test".into(),
955 phases,
956 runtime,
957 browser: Vec::new(),
958 server: Vec::new(),
959 });
960 }
961 let default_observed = observed_runners
964 .iter()
965 .next()
966 .cloned()
967 .unwrap_or_else(default_runner);
968 for ((worker, test, retry), identities) in identities_by_attempt {
969 let runner = default_observed.clone();
970 let mut phases = Vec::new();
971 let mut runtime = Vec::new();
972 for (position, (identity, observations)) in identities.iter().enumerate() {
973 let id = phase_id(run_id, identity);
974 phases.push(CoveragePhase {
975 id: id.clone(),
976 kind: match identity.phase.as_str() {
977 "call" => "test",
978 value => value,
979 }
980 .into(),
981 operation: format!("{runner} {}", identity.phase),
982 source: Some(test.clone()),
983 caused_by_phase_id: None,
984 started_at_ms: position as i64 * 2 + 1,
985 ended_at_ms: None,
986 status: Some("failed".into()),
987 error: Some("the phase started but the runner reported no outcome".into()),
988 });
989 runtime.push(snapshot(&index, observations, &id)?);
990 }
991 raw_results.push(RawTestResult {
992 test_id: Some(test.clone()),
993 scope: Some(scope(run_id, &worker, &test, retry)),
994 test: test.clone(),
995 test_file: test.split("::").next().map(str::to_owned),
996 title: test.rsplit("::").next().map(str::to_owned),
997 retry: Some(retry),
998 status: Some("failed".into()),
999 expected_status: Some("passed".into()),
1000 flaky: false,
1001 provenance: TestProvenance {
1002 runner: runner.clone(),
1003 kind: "unit".into(),
1004 project: None,
1005 source: PYTHON_FRONTEND_VERSION.into(),
1006 },
1007 role: "test".into(),
1008 phases,
1009 runtime,
1010 browser: Vec::new(),
1011 server: Vec::new(),
1012 });
1013 }
1014 for (worker, observations) in &background {
1015 if observations.hits.is_empty() && observations.vectors.is_empty() {
1016 continue;
1017 }
1018 let test = format!("__supercov_background__:{worker}");
1019 let identity = Identity {
1020 worker: worker.clone(),
1021 test: test.clone(),
1022 retry: 0,
1023 phase: "background".into(),
1024 };
1025 let phase = phase_id(run_id, &identity);
1026 raw_results.push(RawTestResult {
1027 test_id: Some(test.clone()),
1028 scope: Some(scope(run_id, worker, &test, 0)),
1029 test: "Python import, collection and background execution".into(),
1030 test_file: None,
1031 title: None,
1032 retry: Some(0),
1033 status: Some("unknown".into()),
1034 expected_status: None,
1035 flaky: false,
1036 provenance: TestProvenance {
1037 runner: default_observed.clone(),
1038 kind: "unit".into(),
1039 project: None,
1040 source: PYTHON_FRONTEND_VERSION.into(),
1041 },
1042 role: "background".into(),
1043 phases: vec![CoveragePhase {
1044 id: phase.clone(),
1045 kind: "background".into(),
1046 operation: "Python import and collection background".into(),
1047 source: None,
1048 caused_by_phase_id: None,
1049 started_at_ms: 0,
1050 ended_at_ms: Some(0),
1051 status: Some("passed".into()),
1052 error: None,
1053 }],
1054 runtime: vec![snapshot(&index, observations, &phase)?],
1055 browser: Vec::new(),
1056 server: Vec::new(),
1057 });
1058 }
1059
1060 let mut limitation_ids = manifest
1064 .limitations
1065 .iter()
1066 .filter_map(|item| item.get("id").and_then(serde_json::Value::as_str))
1067 .map(str::to_owned)
1068 .collect::<BTreeSet<_>>();
1069 let mut unmeasured = manifest.unmeasured.iter().cloned().collect::<BTreeSet<_>>();
1070 let mut new_limitations = Vec::new();
1071 for limitation in &limitations {
1072 if let Some(obligation) = &limitation.obligation {
1073 if !index.lines.contains_key(obligation.as_str()) {
1074 return Err(PythonEvidenceError::UnknownObligation(obligation.clone()));
1075 }
1076 unmeasured.insert(obligation.clone());
1077 } else if let Some(file) = &limitation.file {
1078 unmeasured.extend(
1083 index
1084 .lines
1085 .iter()
1086 .filter(|(_, (obligation_file, _))| obligation_file == file)
1087 .map(|(id, _)| (*id).to_owned()),
1088 );
1089 }
1090 if limitation_ids.insert(limitation.id.clone()) {
1091 let (file, line) = limitation
1092 .obligation
1093 .as_deref()
1094 .and_then(|id| index.lines.get(id).cloned())
1095 .unwrap_or_else(|| {
1096 (
1097 limitation.file.clone().unwrap_or_else(|| {
1098 manifest
1099 .points
1100 .first()
1101 .map_or(".".into(), |point| point.file.clone())
1102 }),
1103 1,
1104 )
1105 });
1106 new_limitations.push(json!({
1107 "id": limitation.id,
1108 "kind": "semantic-safety",
1109 "file": file,
1110 "line": line,
1111 "column": 0,
1112 "source": "",
1113 "reason": limitation.reason
1114 }));
1115 }
1116 }
1117 manifest.limitations.extend(new_limitations);
1118 manifest.unmeasured = unmeasured.into_iter().collect();
1119 let structural_limitations = limitation_ids.into_iter().collect::<Vec<_>>();
1120
1121 let tests = raw_results
1125 .iter()
1126 .filter(|raw| raw.role == "test")
1127 .map(|raw| raw.test.as_str())
1128 .collect::<BTreeSet<_>>()
1129 .len();
1130 Ok(PythonFrontendRun {
1131 declaration: FrontendRunDeclaration {
1132 protocol_version: LANGUAGE_FRONTEND_PROTOCOL_VERSION,
1133 frontend_id: "python".into(),
1134 frontend_version: PYTHON_FRONTEND_VERSION.into(),
1135 language: "python".into(),
1136 structural_source: StructuralSource::OwnedProbes,
1137 runners: observed_runners
1138 .iter()
1139 .map(|runner| FrontendRunnerDeclaration {
1140 runner: runner.clone(),
1141 execution_model: if runner == UNITTEST_RUNNER {
1142 ExecutionModel::SerialInProcess
1143 } else {
1144 ExecutionModel::ParallelContextPropagated
1145 },
1146 attribution: FrontendAttribution {
1147 run: AttributionPrecision::Exact,
1148 worker: AttributionPrecision::Exact,
1149 test: AttributionPrecision::Exact,
1150 retry: AttributionPrecision::Exact,
1151 phase: AttributionPrecision::Exact,
1152 action: AttributionPrecision::Unavailable,
1153 assertion: AttributionPrecision::Unavailable,
1154 },
1155 limitations: vec![
1156 FrontendLimitation {
1157 id: "python-action-linkage".into(),
1158 scopes: vec![FrontendLimitationScope::Action],
1159 reason: format!("{runner} exposes no general action lifecycle"),
1160 },
1161 FrontendLimitation {
1162 id: "python-assertion-linkage".into(),
1163 scopes: vec![FrontendLimitationScope::Assertion],
1164 reason: format!("{runner} phase outcomes do not identify each passing assertion or the obligations caused by it"),
1165 },
1166 ],
1167 })
1168 .collect(),
1169 structural_limitations,
1170 },
1171 request: CoverageReportRequest {
1172 run_id: run_id.into(),
1173 manifest,
1174 raw_results,
1175 generated_at: generated_at.into(),
1176 coverage_model: Some(python_coverage_model()),
1177 integrity: None,
1178 test_exit_code: ExitCodeInput::Present(Some(test_exit_code)),
1179 },
1180 tests,
1181 interpreters,
1182 python_versions: python_versions.into_iter().collect(),
1183 })
1184}
1185
1186#[cfg(test)]
1187mod tests {
1188 use std::time::{SystemTime, UNIX_EPOCH};
1189
1190 use super::*;
1191 use crate::{
1192 frontend_protocol::validate_frontend_report_request,
1193 python_instrumenter::build_python_obligations,
1194 };
1195
1196 fn temporary(name: &str) -> std::path::PathBuf {
1197 let nonce = SystemTime::now()
1198 .duration_since(UNIX_EPOCH)
1199 .unwrap()
1200 .as_nanos();
1201 let path = std::env::temp_dir().join(format!(
1202 "supercov-python-evidence-{}-{nonce}-{name}",
1203 std::process::id()
1204 ));
1205 fs::create_dir_all(&path).unwrap();
1206 path
1207 }
1208
1209 fn write_transport(path: &Path, records: &[serde_json::Value], dropped: u64) {
1210 let payloads = records
1211 .iter()
1212 .map(|record| serde_json::to_vec(record).unwrap())
1213 .collect::<Vec<_>>();
1214 let capacity = payloads
1215 .iter()
1216 .fold(TRANSPORT_HEADER_SIZE, |cursor, payload| {
1217 align_transport(cursor + TRANSPORT_RECORD_HEADER_SIZE + payload.len()).unwrap()
1218 })
1219 + 64;
1220 let mut bytes = vec![0_u8; capacity];
1221 bytes[..8].copy_from_slice(TRANSPORT_MAGIC);
1222 bytes[8..12].copy_from_slice(&TRANSPORT_VERSION.to_le_bytes());
1223 bytes[12..16].copy_from_slice(&(TRANSPORT_HEADER_SIZE as u32).to_le_bytes());
1224 bytes[16..24].copy_from_slice(&(capacity as u64).to_le_bytes());
1225 bytes[24..32].copy_from_slice(&dropped.to_le_bytes());
1226 bytes[32..40].copy_from_slice(&1_u64.to_le_bytes());
1227 let mut cursor = TRANSPORT_HEADER_SIZE;
1228 for payload in payloads {
1229 let payload_start = cursor + TRANSPORT_RECORD_HEADER_SIZE;
1230 let payload_end = payload_start + payload.len();
1231 bytes[payload_start..payload_end].copy_from_slice(&payload);
1232 bytes[cursor + 4..cursor + 8].copy_from_slice(&(payload.len() as u32).to_le_bytes());
1233 bytes[cursor + 8..cursor + 12]
1234 .copy_from_slice(&transport_checksum(&payload).to_le_bytes());
1235 bytes[cursor] = 1;
1236 cursor = align_transport(payload_end).unwrap();
1237 }
1238 fs::write(path, bytes).unwrap();
1239 }
1240
1241 #[test]
1242 fn joins_phases_outcomes_hits_and_vectors_into_exact_results() {
1243 let source = "def f(a, b):\n if a and b:\n return 1\n return 0\n";
1244 let obligations = build_python_obligations("m.py", source).unwrap();
1245 let decision = &obligations.plan.decisions[0];
1246 let statement = &obligations.plan.statements[0];
1247 let directory = temporary("join");
1248 let lines = [
1249 json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.14.4","executable":"python","argv":["pytest"]}),
1250 json!({"t":"hit","ctx":0,"id":statement.id}),
1251 json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call"}),
1252 json!({"t":"dec","ctx":1,"id":decision.id,"v":"22","o":1}),
1253 json!({"t":"hit","ctx":1,"id":decision.outcome_true}),
1254 json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"setup","outcome":"passed","xfail":false}),
1255 json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call","outcome":"passed","xfail":false}),
1256 json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"teardown","outcome":"passed","xfail":false}),
1257 json!({"t":"limitation","id":"python-decision-partially-mapped","reason":"folded","obligation":decision.id}),
1258 json!({"t":"exit","at":9}),
1259 ];
1260 write_transport(&directory.join("main.1.mmap"), &lines, 0);
1261 let run = build_python_frontend_run(&obligations.manifest, &directory, "run-1", "now", 0)
1262 .unwrap();
1263 validate_frontend_report_request(&run.declaration, &run.request).unwrap();
1264 assert_eq!(run.tests, 1);
1265 assert_eq!(run.request.raw_results.len(), 2);
1266 let test = &run.request.raw_results[0];
1267 assert_eq!(test.status.as_deref(), Some("passed"));
1268 assert_eq!(test.phases.len(), 3);
1269 assert_eq!(test.runtime.len(), 1);
1270 assert_eq!(test.runtime[0].decisions.len(), 1);
1271 assert!(
1272 test.runtime[0]
1273 .events
1274 .iter()
1275 .all(|event| event.phase_id.is_some())
1276 );
1277 let background = &run.request.raw_results[1];
1278 assert_eq!(background.role, "background");
1279 assert!(run.request.manifest.unmeasured.contains(&decision.id));
1280 assert!(
1281 run.declaration
1282 .structural_limitations
1283 .contains(&"python-decision-partially-mapped".to_owned())
1284 );
1285 fs::remove_dir_all(directory).unwrap();
1286 }
1287
1288 #[test]
1289 fn fails_closed_without_an_interpreter_or_tests() {
1290 let obligations = build_python_obligations("m.py", "x = 1\n").unwrap();
1291 let directory = temporary("empty");
1292 assert!(matches!(
1293 build_python_frontend_run(&obligations.manifest, &directory, "run-1", "now", 0),
1294 Err(PythonEvidenceError::NoInterpreter)
1295 ));
1296 let path = directory.join("main.1.mmap");
1297 write_transport(
1298 &path,
1299 &[
1300 json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.14.4","executable":"p","argv":[]}),
1301 ],
1302 0,
1303 );
1304 assert!(matches!(
1305 build_python_frontend_run(&obligations.manifest, &directory, "run-1", "now", 0),
1306 Err(PythonEvidenceError::NoTests)
1307 ));
1308 let mut torn = fs::read(&path).unwrap();
1312 let process_length = transport_u32(&torn, TRANSPORT_HEADER_SIZE + 4).unwrap() as usize;
1313 let torn_cursor =
1314 align_transport(TRANSPORT_HEADER_SIZE + TRANSPORT_RECORD_HEADER_SIZE + process_length)
1315 .unwrap();
1316 torn[torn_cursor + 4..torn_cursor + 8].copy_from_slice(&5_u32.to_le_bytes());
1317 torn[torn_cursor + TRANSPORT_RECORD_HEADER_SIZE
1318 ..torn_cursor + TRANSPORT_RECORD_HEADER_SIZE + 5]
1319 .copy_from_slice(b"{nope");
1320 fs::write(&path, torn).unwrap();
1321 assert!(matches!(
1322 build_python_frontend_run(&obligations.manifest, &directory, "run-1", "now", 0),
1323 Err(PythonEvidenceError::NoTests)
1324 ));
1325 write_transport(
1326 &path,
1327 &[
1328 json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.11.9","executable":"p","argv":[]}),
1329 ],
1330 0,
1331 );
1332 assert!(matches!(
1333 build_python_frontend_run(&obligations.manifest, &directory, "run-1", "now", 0),
1334 Err(PythonEvidenceError::UnsupportedPython(_))
1335 ));
1336 write_transport(
1337 &path,
1338 &[
1339 json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.14.4","executable":"p","argv":[]}),
1340 ],
1341 2,
1342 );
1343 assert!(matches!(
1344 build_python_frontend_run(&obligations.manifest, &directory, "run-1", "now", 0),
1345 Err(PythonEvidenceError::DroppedRecords { count: 2, .. })
1346 ));
1347 fs::remove_dir_all(directory).unwrap();
1348 }
1349}