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