1use std::{
12 collections::{BTreeMap, BTreeSet},
13 fs::{self, File},
14 path::{Component, Path, PathBuf},
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 #[serde(default)]
90 file: Option<String>,
91 },
92 Hit {
93 ctx: u64,
94 id: String,
95 },
96 Dec {
97 ctx: u64,
98 id: String,
99 v: String,
100 o: u8,
101 },
102 Assert {
105 ctx: u64,
106 },
107 Asite {
112 ctx: u64,
113 f: String,
114 l: usize,
115 },
116 Limitation {
117 id: String,
118 reason: String,
119 #[serde(default)]
120 file: Option<String>,
121 #[serde(default)]
122 obligation: Option<String>,
123 },
124 Exit {
125 at: i64,
126 },
127}
128
129#[derive(Debug)]
130pub enum PythonEvidenceError {
131 Io(String),
132 UnsafeEntry(String),
133 InvalidRecord {
134 file: String,
135 line: usize,
136 reason: String,
137 },
138 InvalidTransport {
139 file: String,
140 reason: String,
141 },
142 DroppedRecords {
143 file: String,
144 count: u64,
145 },
146 RunMismatch {
147 expected: String,
148 actual: String,
149 },
150 UnsupportedVersion(u32),
151 UnknownContext {
152 file: String,
153 line: usize,
154 context: u64,
155 },
156 UnknownObligation(String),
157 InvalidVector {
158 id: String,
159 expected: usize,
160 actual: usize,
161 },
162 NoInterpreter,
163 NoTests,
164 UnsupportedPython(String),
165}
166
167impl std::fmt::Display for PythonEvidenceError {
168 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
169 match self {
170 Self::Io(reason) => write!(formatter, "could not read Python evidence: {reason}"),
171 Self::UnsafeEntry(name) => write!(formatter, "unsafe Python evidence entry: {name}"),
172 Self::InvalidRecord { file, line, reason } => {
173 write!(formatter, "invalid Python evidence record {file}:{line}: {reason}")
174 }
175 Self::InvalidTransport { file, reason } => {
176 write!(formatter, "invalid Python evidence transport {file}: {reason}")
177 }
178 Self::DroppedRecords { file, count } => write!(
179 formatter,
180 "Python evidence transport {file} exhausted its bounded capacity and dropped {count} record(s)"
181 ),
182 Self::RunMismatch { expected, actual } => write!(
183 formatter,
184 "Python evidence belongs to run {actual}, expected {expected}"
185 ),
186 Self::UnsupportedVersion(version) => {
187 write!(formatter, "unsupported Python evidence version {version}")
188 }
189 Self::UnknownContext { file, line, context } => write!(
190 formatter,
191 "Python evidence {file}:{line} references undeclared context {context}"
192 ),
193 Self::UnknownObligation(id) => {
194 write!(formatter, "Python runtime reported an unknown obligation: {id}")
195 }
196 Self::InvalidVector {
197 id,
198 expected,
199 actual,
200 } => write!(
201 formatter,
202 "Python decision {id} reported {actual} condition values, expected {expected}"
203 ),
204 Self::NoInterpreter => formatter.write_str(
205 "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)",
206 ),
207 Self::NoTests => formatter.write_str(
208 "the Python run produced no test outcomes; Supercov measures Python through pytest and unittest",
209 ),
210 Self::UnsupportedPython(version) => write!(
211 formatter,
212 "Supercov measures CPython 3.12 or newer; the test command ran Python {version}"
213 ),
214 }
215 }
216}
217
218impl std::error::Error for PythonEvidenceError {}
219
220fn stable_id(prefix: &str, values: &[&str]) -> String {
221 let mut hash = Sha256::new();
222 for value in values {
223 hash.update(value.as_bytes());
224 hash.update([0]);
225 }
226 let digest = hash.finalize();
227 let mut encoded = String::with_capacity(prefix.len() + 25);
228 encoded.push_str(prefix);
229 encoded.push(':');
230 for byte in &digest[..12] {
231 use std::fmt::Write as _;
232 write!(&mut encoded, "{byte:02x}").expect("string formatting");
233 }
234 encoded
235}
236
237#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
238struct Identity {
239 worker: String,
240 test: String,
241 retry: usize,
242 phase: String,
243}
244
245type ObservedVectors = BTreeSet<(Vec<Option<bool>>, bool)>;
246type OutcomesByAttempt = BTreeMap<(String, String, usize), Vec<(String, String, bool)>>;
248type RunnersByAttempt = BTreeMap<(String, String, usize), String>;
250type TestFilesByAttempt = BTreeMap<(String, String, usize), String>;
252type SitesByAttempt = BTreeMap<(String, String, usize), Vec<(String, usize)>>;
255
256pub struct PythonAssertionInventory {
265 root: PathBuf,
266 columns: BTreeMap<(String, usize), Vec<usize>>,
268}
269
270impl PythonAssertionInventory {
271 pub fn new(root: &Path, inputs: &crate::assertion_map::Inputs) -> Self {
272 let mut columns = BTreeMap::<(String, usize), Vec<usize>>::new();
273 for site in &inputs.assertions {
274 columns
275 .entry((site.at.file.clone(), site.at.line))
276 .or_default()
277 .push(site.at.column.saturating_sub(1));
280 }
281 for sites in columns.values_mut() {
282 sites.sort_unstable();
283 sites.dedup();
284 }
285 Self {
286 root: root.to_path_buf(),
287 columns,
288 }
289 }
290
291 pub fn empty() -> Self {
294 Self {
295 root: PathBuf::new(),
296 columns: BTreeMap::new(),
297 }
298 }
299
300 pub fn relative(&self, path: &str) -> Option<String> {
304 let candidate = Path::new(path);
305 let relative = if candidate.is_absolute() {
306 candidate.strip_prefix(&self.root).ok()?
307 } else {
308 candidate.strip_prefix("./").unwrap_or(candidate)
309 };
310 let text = relative.to_string_lossy().replace('\\', "/");
311 (!text.is_empty() && !text.starts_with("../")).then_some(text)
312 }
313
314 pub fn locate(&self, path: &str, line: usize) -> Option<String> {
318 let file = self.relative(path)?;
319 match self.columns.get(&(file.clone(), line))?.as_slice() {
320 [column] => Some(format!("{file}:{line}:{column}")),
321 _ => None,
322 }
323 }
324}
325
326#[derive(Debug, Default)]
327struct Observations {
328 hits: BTreeSet<String>,
329 vectors: BTreeMap<String, ObservedVectors>,
330}
331
332#[derive(Debug, Clone)]
333struct RuntimeLimitation {
334 id: String,
335 reason: String,
336 file: Option<String>,
337 obligation: Option<String>,
338}
339
340#[derive(Debug, Default)]
341struct Evidence {
342 interpreters: usize,
343 python_versions: BTreeSet<String>,
344 per_identity: BTreeMap<Identity, Observations>,
345 background: BTreeMap<String, Observations>,
346 outcomes: OutcomesByAttempt,
347 runners: RunnersByAttempt,
348 test_files: TestFilesByAttempt,
349 sites: SitesByAttempt,
350 limitations: Vec<RuntimeLimitation>,
351}
352
353fn read_evidence_directory(
354 directory: &Path,
355 run_id: &str,
356) -> Result<Evidence, PythonEvidenceError> {
357 let mut evidence = Evidence::default();
358 let mut files = match fs::read_dir(directory) {
359 Ok(entries) => entries
360 .collect::<Result<Vec<_>, _>>()
361 .map_err(|error| PythonEvidenceError::Io(error.to_string()))?,
362 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
363 Err(error) => return Err(PythonEvidenceError::Io(error.to_string())),
364 };
365 files.sort_by_key(|entry| entry.file_name());
366 for entry in files {
367 let name = entry
368 .file_name()
369 .into_string()
370 .map_err(|_| PythonEvidenceError::UnsafeEntry("<non-utf8>".into()))?;
371 if Path::new(&name)
372 .components()
373 .any(|component| !matches!(component, Component::Normal(_)))
374 || !name.ends_with(".mmap")
375 {
376 return Err(PythonEvidenceError::UnsafeEntry(name));
377 }
378 let metadata = fs::symlink_metadata(entry.path())
379 .map_err(|error| PythonEvidenceError::Io(error.to_string()))?;
380 if !metadata.file_type().is_file() {
381 return Err(PythonEvidenceError::UnsafeEntry(name));
382 }
383 let file =
384 File::open(entry.path()).map_err(|error| PythonEvidenceError::Io(error.to_string()))?;
385 let contents = unsafe { MmapOptions::new().map(&file) }
389 .map_err(|error| PythonEvidenceError::Io(error.to_string()))?;
390 read_evidence_file(&name, &contents, run_id, &mut evidence)?;
391 }
392 Ok(evidence)
393}
394
395fn transport_u32(bytes: &[u8], offset: usize) -> Option<u32> {
396 bytes
397 .get(offset..offset + 4)
398 .and_then(|value| value.try_into().ok())
399 .map(u32::from_le_bytes)
400}
401
402fn transport_u64(bytes: &[u8], offset: usize) -> Option<u64> {
403 bytes
404 .get(offset..offset + 8)
405 .and_then(|value| value.try_into().ok())
406 .map(u64::from_le_bytes)
407}
408
409fn transport_checksum(payload: &[u8]) -> u32 {
410 payload.iter().fold(0x811c_9dc5_u32, |value, byte| {
411 (value ^ u32::from(*byte)).wrapping_mul(0x0100_0193)
412 })
413}
414
415fn align_transport(value: usize) -> Option<usize> {
416 value.checked_add(7).map(|value| value & !7)
417}
418
419fn read_evidence_file(
420 name: &str,
421 contents: &Mmap,
422 run_id: &str,
423 evidence: &mut Evidence,
424) -> Result<(), PythonEvidenceError> {
425 let invalid_transport = |reason: &str| PythonEvidenceError::InvalidTransport {
426 file: name.into(),
427 reason: reason.into(),
428 };
429 if contents.len() < TRANSPORT_HEADER_SIZE
430 || contents.get(..8) != Some(TRANSPORT_MAGIC.as_slice())
431 || transport_u32(contents, 8) != Some(TRANSPORT_VERSION)
432 || transport_u32(contents, 12) != Some(TRANSPORT_HEADER_SIZE as u32)
433 {
434 return Err(invalid_transport("header or version does not match"));
435 }
436 let declared_capacity =
437 transport_u64(contents, 16).ok_or_else(|| invalid_transport("capacity is missing"))?;
438 if declared_capacity < TRANSPORT_HEADER_SIZE as u64 || declared_capacity > contents.len() as u64
439 {
440 return Err(invalid_transport(
441 "declared capacity is outside the mapped file",
442 ));
443 }
444 let dropped =
445 transport_u64(contents, 24).ok_or_else(|| invalid_transport("drop counter is missing"))?;
446 if dropped != 0 {
447 return Err(PythonEvidenceError::DroppedRecords {
448 file: name.into(),
449 count: dropped,
450 });
451 }
452 let transport_pid = transport_u64(contents, 32)
453 .filter(|pid| *pid != 0)
454 .ok_or_else(|| invalid_transport("process id is missing"))?;
455 let mut contexts = BTreeMap::<u64, Identity>::new();
456 let mut before_assertion = BTreeMap::<u64, Observations>::new();
459 let mut process_worker: Option<String> = None;
460 let mut cursor = TRANSPORT_HEADER_SIZE;
461 let mut record_index = 0;
462 while cursor + TRANSPORT_RECORD_HEADER_SIZE <= contents.len() {
463 let commit = contents[cursor];
464 if commit == 0 {
465 break;
468 }
469 record_index += 1;
470 let line_number = record_index;
471 let invalid = |reason: &str| PythonEvidenceError::InvalidRecord {
472 file: name.into(),
473 line: line_number,
474 reason: reason.into(),
475 };
476 if commit != 1
477 || contents[cursor + 1..cursor + 4] != [0, 0, 0]
478 || contents[cursor + 12..cursor + 16] != [0, 0, 0, 0]
479 {
480 return Err(invalid("commit marker or reserved bytes are invalid"));
481 }
482 let length = transport_u32(contents, cursor + 4)
483 .map(|value| value as usize)
484 .ok_or_else(|| invalid("payload length is missing"))?;
485 if length == 0 || length > TRANSPORT_MAX_RECORD_SIZE {
486 return Err(invalid("payload length is outside the transport bound"));
487 }
488 let payload_start = cursor + TRANSPORT_RECORD_HEADER_SIZE;
489 let payload_end = payload_start
490 .checked_add(length)
491 .filter(|end| *end <= contents.len())
492 .ok_or_else(|| invalid("payload extends past the mapped file"))?;
493 let next_cursor = align_transport(payload_end)
494 .filter(|end| *end <= contents.len())
495 .ok_or_else(|| invalid("aligned frame extends past the mapped file"))?;
496 if contents[payload_end..next_cursor]
497 .iter()
498 .any(|byte| *byte != 0)
499 {
500 return Err(invalid("frame padding is not zero"));
501 }
502 let payload = &contents[payload_start..payload_end];
503 let expected_checksum = transport_u32(contents, cursor + 8)
504 .ok_or_else(|| invalid("payload checksum is missing"))?;
505 if transport_checksum(payload) != expected_checksum {
506 return Err(invalid("payload checksum does not match"));
507 }
508 let record: Record = serde_json::from_slice(payload).map_err(|error| {
509 PythonEvidenceError::InvalidRecord {
510 file: name.into(),
511 line: line_number,
512 reason: error.to_string(),
513 }
514 })?;
515 match record {
516 Record::Process {
517 v,
518 run,
519 pid,
520 worker,
521 python,
522 ..
523 } => {
524 if v != PYTHON_EVIDENCE_VERSION {
525 return Err(PythonEvidenceError::UnsupportedVersion(v));
526 }
527 if run != run_id {
528 return Err(PythonEvidenceError::RunMismatch {
529 expected: run_id.into(),
530 actual: run,
531 });
532 }
533 if pid != transport_pid {
534 return Err(invalid("process record does not match the transport owner"));
535 }
536 let supported = python
537 .split('.')
538 .take(2)
539 .map(|part| part.parse::<u32>().ok())
540 .collect::<Option<Vec<_>>>()
541 .is_some_and(|parts| parts.len() == 2 && (parts[0], parts[1]) >= (3, 12));
542 if !supported {
543 return Err(PythonEvidenceError::UnsupportedPython(python));
544 }
545 evidence.interpreters += 1;
546 evidence.python_versions.insert(python);
547 process_worker = Some(worker);
548 }
549 Record::Worker { worker } => process_worker = Some(worker),
550 Record::Phase {
551 ctx,
552 worker,
553 test,
554 retry,
555 phase,
556 ..
557 } => {
558 if ctx == 0 {
559 return Err(invalid("phase context 0 is reserved for background"));
560 }
561 if !matches!(phase.as_str(), "setup" | "call" | "teardown") {
562 return Err(invalid("unknown pytest phase"));
563 }
564 if test.trim().is_empty() || worker.trim().is_empty() {
565 return Err(invalid("phase identity must name a worker and test"));
566 }
567 if phase == "call" {
568 before_assertion.insert(ctx, Observations::default());
569 }
570 contexts.insert(
571 ctx,
572 Identity {
573 worker,
574 test,
575 retry,
576 phase,
577 },
578 );
579 }
580 Record::Outcome {
581 worker,
582 test,
583 retry,
584 phase,
585 outcome,
586 xfail,
587 runner,
588 file,
589 } => {
590 if !matches!(phase.as_str(), "setup" | "call" | "teardown") {
591 return Err(invalid("unknown test outcome phase"));
592 }
593 if !matches!(
594 outcome.as_str(),
595 "passed" | "failed" | "skipped" | "rerun" | "error"
596 ) {
597 return Err(invalid("unknown test outcome"));
598 }
599 if !matches!(runner.as_str(), PYTEST_RUNNER | UNITTEST_RUNNER) {
600 return Err(invalid("unknown Python test runner"));
601 }
602 let key = (worker, test, retry);
603 if let Some(previous) = evidence.runners.get(&key)
604 && previous != &runner
605 {
606 return Err(invalid("one attempt was reported by two runners"));
607 }
608 evidence.runners.insert(key.clone(), runner);
609 if let Some(file) = file.filter(|path| !path.is_empty()) {
610 evidence.test_files.entry(key.clone()).or_insert(file);
611 }
612 evidence
613 .outcomes
614 .entry(key)
615 .or_default()
616 .push((phase, outcome, xfail));
617 }
618 Record::Hit { ctx, id } => {
619 if let Some(before) = before_assertion.get_mut(&ctx) {
620 before.hits.insert(id.clone());
621 }
622 observations(
623 evidence,
624 &contexts,
625 process_worker.as_deref(),
626 ctx,
627 name,
628 line_number,
629 )?
630 .hits
631 .insert(id);
632 }
633 Record::Dec { ctx, id, v, o } => {
634 if v.is_empty() || !v.bytes().all(|digit| matches!(digit, b'0' | b'1' | b'2')) {
635 return Err(invalid("decision vector digits must be 0, 1 or 2"));
636 }
637 if o > 1 {
638 return Err(invalid("decision outcome must be 0 or 1"));
639 }
640 let values = v
641 .bytes()
642 .map(|digit| match digit {
643 b'0' => None,
644 b'1' => Some(false),
645 _ => Some(true),
646 })
647 .collect::<Vec<_>>();
648 if let Some(before) = before_assertion.get_mut(&ctx) {
649 before
650 .vectors
651 .entry(id.clone())
652 .or_default()
653 .insert((values.clone(), o == 1));
654 }
655 observations(
656 evidence,
657 &contexts,
658 process_worker.as_deref(),
659 ctx,
660 name,
661 line_number,
662 )?
663 .vectors
664 .entry(id)
665 .or_default()
666 .insert((values, o == 1));
667 }
668 Record::Assert { ctx } => {
669 if let Some(before) = before_assertion.remove(&ctx) {
672 let identity =
673 contexts
674 .get(&ctx)
675 .ok_or(PythonEvidenceError::UnknownContext {
676 file: name.into(),
677 line: line_number,
678 context: ctx,
679 })?;
680 let asserted = evidence
681 .per_identity
682 .entry(Identity {
683 phase: "assertion".into(),
684 ..identity.clone()
685 })
686 .or_default();
687 asserted.hits.extend(before.hits);
688 for (id, vectors) in before.vectors {
689 asserted.vectors.entry(id).or_default().extend(vectors);
690 }
691 }
692 }
693 Record::Asite { ctx, f, l } => {
694 if f.is_empty() || l == 0 {
695 return Err(invalid("assertion site needs a file and a line"));
696 }
697 let identity = contexts
698 .get(&ctx)
699 .ok_or(PythonEvidenceError::UnknownContext {
700 file: name.into(),
701 line: line_number,
702 context: ctx,
703 })?;
704 if identity.phase == "call" {
707 let key = (
708 identity.worker.clone(),
709 identity.test.clone(),
710 identity.retry,
711 );
712 let sites = evidence.sites.entry(key).or_default();
713 let site = (f, l);
714 if !sites.contains(&site) {
715 sites.push(site);
716 }
717 }
718 }
719 Record::Limitation {
720 id,
721 reason,
722 file,
723 obligation,
724 } => evidence.limitations.push(RuntimeLimitation {
725 id,
726 reason,
727 file,
728 obligation,
729 }),
730 Record::Exit { .. } => {}
731 }
732 cursor = next_cursor;
733 }
734 Ok(())
735}
736
737fn observations<'a>(
738 evidence: &'a mut Evidence,
739 contexts: &BTreeMap<u64, Identity>,
740 process_worker: Option<&str>,
741 context: u64,
742 file: &str,
743 line: usize,
744) -> Result<&'a mut Observations, PythonEvidenceError> {
745 if context == 0 {
746 return Ok(evidence
747 .background
748 .entry(process_worker.unwrap_or("main").to_owned())
749 .or_default());
750 }
751 let identity = contexts
752 .get(&context)
753 .ok_or(PythonEvidenceError::UnknownContext {
754 file: file.into(),
755 line,
756 context,
757 })?;
758 Ok(evidence.per_identity.entry(identity.clone()).or_default())
759}
760
761pub fn python_coverage_model() -> CoverageModelDeclaration {
762 CoverageModelDeclaration {
763 language: "python".into(),
764 variant: "python-owned-monitoring".into(),
765 name: "python-sys-monitoring-v1".into(),
766 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(),
767 measured: vec![
768 "executable statements proven by CPython LINE events on their header lines, or INSTRUCTION events when they share a line".into(),
769 "function and lambda entry".into(),
770 "boolean decision vectors with masking MC/DC from conditional-jump events".into(),
771 "for-loop and comprehension zero-versus-entered iteration".into(),
772 "logical and/or short-circuit alternatives".into(),
773 "match case selection and guards".into(),
774 "try completion, handler selection and exception propagation".into(),
775 "pytest and unittest worker, test, retry and setup/call/teardown phase identity".into(),
776 "evidence a test recorded before its first assertion, linked to that assertion when the test passes".into(),
777 ],
778 not_measured: vec![
779 "zero-iteration executions of a loop after it has run and exited 16 times within one test phase on CPython 3.14".into(),
780 "causal linkage to individual actions, or to any assertion after a test's first".into(),
781 "code compiled from strings at runtime".into(),
782 "causal test context for raw _thread or native-extension-created threads".into(),
783 "child coverage outside subprocess.Popen and multiprocessing adapters".into(),
784 "all input values, semantic partitions, paths, or concurrency interleavings".into(),
785 "mutation score or assertion fault-detection strength".into(),
786 ],
787 }
788}
789
790fn phase_id(run: &str, identity: &Identity) -> String {
791 stable_id(
792 "python-phase",
793 &[
794 run,
795 &identity.worker,
796 &identity.test,
797 &identity.retry.to_string(),
798 &identity.phase,
799 ],
800 )
801}
802
803fn scope(run: &str, worker: &str, test: &str, retry: usize) -> ExecutionScope {
804 ExecutionScope {
805 version: 1,
806 run_id: run.into(),
807 worker_id: worker.into(),
808 test_id: test.into(),
809 test_key: stable_id("python-test", &[worker, test]),
810 retry,
811 attempt_id: stable_id("python-attempt", &[run, worker, test, &retry.to_string()]),
812 }
813}
814
815struct ManifestIndex<'a> {
816 points: BTreeSet<&'a str>,
817 alternatives: BTreeSet<&'a str>,
818 decisions: BTreeMap<&'a str, &'a DecisionMeta>,
819 lines: BTreeMap<&'a str, (String, usize)>,
820}
821
822impl<'a> ManifestIndex<'a> {
823 fn new(manifest: &'a CoverageManifest) -> Self {
824 let mut lines = BTreeMap::new();
825 for point in &manifest.points {
826 lines.insert(point.id.as_str(), (point.file.clone(), point.line));
827 }
828 for decision in &manifest.decisions {
829 lines.insert(decision.id.as_str(), (decision.file.clone(), decision.line));
830 }
831 for branch in &manifest.branches {
832 lines.insert(branch.id.as_str(), (branch.file.clone(), branch.line));
833 }
834 Self {
835 points: manifest
836 .points
837 .iter()
838 .map(|point| point.id.as_str())
839 .collect(),
840 alternatives: manifest
841 .branches
842 .iter()
843 .flat_map(|branch| branch.alternatives.iter().map(|alt| alt.id.as_str()))
844 .collect(),
845 decisions: manifest
846 .decisions
847 .iter()
848 .map(|decision| (decision.id.as_str(), decision))
849 .collect(),
850 lines,
851 }
852 }
853}
854
855fn snapshot(
856 index: &ManifestIndex<'_>,
857 observations: &Observations,
858 phase: &str,
859) -> Result<RuntimeSnapshot, PythonEvidenceError> {
860 let mut hits = BTreeSet::new();
861 for id in &observations.hits {
862 if !index.points.contains(id.as_str()) && !index.alternatives.contains(id.as_str()) {
863 return Err(PythonEvidenceError::UnknownObligation(id.clone()));
864 }
865 hits.insert(id.clone());
866 }
867 let mut decisions = Vec::new();
868 let mut events = Vec::new();
869 let mut clock = 1;
870 for id in &hits {
871 events.push(RuntimeEvent {
872 event_type: "hit".into(),
873 id: id.clone(),
874 vector: None,
875 timestamp_ms: clock,
876 phase_id: Some(phase.into()),
877 statement_id: None,
878 environment: "python".into(),
879 });
880 clock += 1;
881 }
882 for (id, vectors) in &observations.vectors {
883 let Some(meta) = index.decisions.get(id.as_str()) else {
884 return Err(PythonEvidenceError::UnknownObligation(id.clone()));
885 };
886 let mut observed = Vec::new();
887 for (values, outcome) in vectors {
888 if values.len() != meta.conditions.len() {
889 return Err(PythonEvidenceError::InvalidVector {
890 id: id.clone(),
891 expected: meta.conditions.len(),
892 actual: values.len(),
893 });
894 }
895 let vector = McdcVector {
896 values: values.clone(),
897 outcome: *outcome,
898 };
899 events.push(RuntimeEvent {
900 event_type: "decision".into(),
901 id: id.clone(),
902 vector: Some(vector.clone()),
903 timestamp_ms: clock,
904 phase_id: Some(phase.into()),
905 statement_id: None,
906 environment: "python".into(),
907 });
908 clock += 1;
909 observed.push(vector);
910 }
911 decisions.push(DecisionSnapshot {
912 meta: (*meta).clone(),
913 vectors: observed,
914 });
915 }
916 Ok(RuntimeSnapshot {
917 decisions,
918 hits: hits.into_iter().collect(),
919 events,
920 logicals: Vec::new(),
921 })
922}
923
924fn attempt_status(outcomes: &[(String, String, bool)]) -> String {
925 if outcomes
926 .iter()
927 .any(|(_, outcome, _)| matches!(outcome.as_str(), "failed" | "rerun" | "error"))
928 {
929 "failed"
930 } else if outcomes.iter().any(|(_, outcome, _)| outcome == "skipped") {
931 "skipped"
932 } else {
933 "passed"
934 }
935 .into()
936}
937
938#[derive(Debug, Clone, PartialEq)]
939pub struct PythonFrontendRun {
940 pub declaration: FrontendRunDeclaration,
941 pub request: CoverageReportRequest,
942 pub tests: usize,
943 pub interpreters: usize,
944 pub python_versions: Vec<String>,
945}
946
947impl PythonFrontendRun {
948 pub fn archive_entries(&self) -> Result<Vec<EvidenceArchiveEntry>, serde_json::Error> {
949 let model = PersistedCoverageModel::from_declaration(
950 self.request
951 .coverage_model
952 .as_ref()
953 .expect("Python frontend always declares a coverage model"),
954 )
955 .expect("Python coverage model is contract-valid");
956 let mut entries = vec![
957 EvidenceArchiveEntry {
958 path: "coverage-model.json".into(),
959 contents: serde_json::to_vec(&model)?,
960 },
961 EvidenceArchiveEntry {
962 path: "frontend.json".into(),
963 contents: serde_json::to_vec(&self.declaration)?,
964 },
965 EvidenceArchiveEntry {
966 path: "manifest.json".into(),
967 contents: serde_json::to_vec(&self.request.manifest)?,
968 },
969 ];
970 for (index, result) in self.request.raw_results.iter().enumerate() {
971 entries.push(EvidenceArchiveEntry {
972 path: format!("results/{index:08}/mcdc.json"),
973 contents: serde_json::to_vec(result)?,
974 });
975 }
976 Ok(entries)
977 }
978}
979
980pub fn build_python_frontend_run(
983 manifest: &CoverageManifest,
984 evidence_directory: &Path,
985 run_id: &str,
986 generated_at: &str,
987 test_exit_code: i32,
988 assertions: &PythonAssertionInventory,
989) -> Result<PythonFrontendRun, PythonEvidenceError> {
990 let evidence = read_evidence_directory(evidence_directory, run_id)?;
991 if evidence.interpreters == 0 {
992 return Err(PythonEvidenceError::NoInterpreter);
993 }
994 if evidence.outcomes.is_empty() {
995 return Err(PythonEvidenceError::NoTests);
996 }
997 let Evidence {
998 interpreters,
999 python_versions,
1000 per_identity,
1001 background,
1002 outcomes,
1003 runners,
1004 test_files,
1005 sites,
1006 limitations,
1007 } = evidence;
1008 let mut manifest = manifest.clone();
1009 let index = ManifestIndex::new(&manifest);
1010
1011 let mut raw_results = Vec::new();
1012 let mut observed_runners = BTreeSet::new();
1013 let mut identities_by_attempt =
1014 BTreeMap::<(String, String, usize), Vec<(&Identity, &Observations)>>::new();
1015 for (identity, observations) in &per_identity {
1016 identities_by_attempt
1017 .entry((
1018 identity.worker.clone(),
1019 identity.test.clone(),
1020 identity.retry,
1021 ))
1022 .or_default()
1023 .push((identity, observations));
1024 }
1025 for ((worker, test, retry), mut outcomes) in outcomes {
1026 let runner = runners
1027 .get(&(worker.clone(), test.clone(), retry))
1028 .cloned()
1029 .unwrap_or_else(default_runner);
1030 let attempt_identities = identities_by_attempt
1031 .remove(&(worker.clone(), test.clone(), retry))
1032 .unwrap_or_default();
1033 observed_runners.insert(runner.clone());
1034 outcomes.sort_by_key(|(phase, _, _)| match phase.as_str() {
1035 "setup" => 0,
1036 "call" => 1,
1037 _ => 2,
1038 });
1039 let mut phases = Vec::new();
1040 let mut runtime = Vec::new();
1041 let mut observed_phases = BTreeSet::new();
1042 for (position, (phase_name, outcome, xfail)) in outcomes.iter().enumerate() {
1043 observed_phases.insert(phase_name.clone());
1044 let identity = Identity {
1045 worker: worker.clone(),
1046 test: test.clone(),
1047 retry,
1048 phase: phase_name.clone(),
1049 };
1050 let id = phase_id(run_id, &identity);
1051 phases.push(CoveragePhase {
1052 id: id.clone(),
1053 kind: match phase_name.as_str() {
1054 "call" => "test",
1055 value => value,
1056 }
1057 .into(),
1058 operation: format!("{runner} {phase_name}"),
1059 source: Some(test.clone()),
1060 caused_by_phase_id: None,
1061 started_at_ms: position as i64 * 2 + 1,
1062 ended_at_ms: Some(position as i64 * 2 + 2),
1063 status: Some(match outcome.as_str() {
1064 "rerun" | "error" => "failed".into(),
1065 value => value.into(),
1066 }),
1067 error: None,
1068 });
1069 if let Some((_, observations)) = attempt_identities
1070 .iter()
1071 .find(|(candidate, _)| candidate.phase == phase_name.as_str())
1072 {
1073 runtime.push(snapshot(&index, observations, &id)?);
1074 }
1075 if phase_name != "call" {
1076 continue;
1077 }
1078 if let Some((identity, observations)) = attempt_identities
1082 .iter()
1083 .find(|(candidate, _)| candidate.phase == "assertion")
1084 {
1085 observed_phases.insert("assertion".to_owned());
1086 let id = phase_id(run_id, identity);
1087 phases.push(CoveragePhase {
1088 id: id.clone(),
1089 kind: "assertion".into(),
1090 operation: format!("{runner} assertion"),
1091 source: Some(test.clone()),
1092 caused_by_phase_id: None,
1093 started_at_ms: position as i64 * 2 + 1,
1094 ended_at_ms: Some(position as i64 * 2 + 2),
1095 status: Some(
1096 if outcome == "passed" && !*xfail {
1097 "passed"
1098 } else {
1099 "failed"
1100 }
1101 .into(),
1102 ),
1103 error: None,
1104 });
1105 runtime.push(snapshot(&index, observations, &id)?);
1106 let attempt = (worker.clone(), test.clone(), retry);
1112 for (path, line) in sites.get(&attempt).into_iter().flatten() {
1113 let Some(location) = assertions.locate(path, *line) else {
1114 continue;
1115 };
1116 phases.push(CoveragePhase {
1117 id: stable_id("python-assertion", &[run_id, &id, &location]),
1118 kind: "assertion".into(),
1119 operation: format!("{runner} assertion at {location}"),
1120 source: Some(location),
1121 caused_by_phase_id: Some(id.clone()),
1122 started_at_ms: position as i64 * 2 + 1,
1123 ended_at_ms: Some(position as i64 * 2 + 2),
1124 status: Some(
1125 if outcome == "passed" && !*xfail {
1126 "passed"
1127 } else {
1128 "failed"
1129 }
1130 .into(),
1131 ),
1132 error: None,
1133 });
1134 }
1135 }
1136 }
1137 for (identity, observations) in attempt_identities {
1140 if !observed_phases.contains(&identity.phase) {
1141 let id = phase_id(run_id, identity);
1142 phases.push(CoveragePhase {
1143 id: id.clone(),
1144 kind: match identity.phase.as_str() {
1145 "call" => "test",
1146 value => value,
1147 }
1148 .into(),
1149 operation: format!("{runner} {}", identity.phase),
1150 source: Some(test.clone()),
1151 caused_by_phase_id: None,
1152 started_at_ms: phases.len() as i64 * 2 + 1,
1153 ended_at_ms: None,
1154 status: Some("failed".into()),
1155 error: Some("the phase started but the runner reported no outcome".into()),
1156 });
1157 runtime.push(snapshot(&index, observations, &id)?);
1158 }
1159 }
1160 let status = if phases.iter().any(|phase| phase.error.is_some()) {
1161 "failed".into()
1162 } else {
1163 attempt_status(&outcomes)
1164 };
1165 raw_results.push(RawTestResult {
1166 test_id: Some(test.clone()),
1167 scope: Some(scope(run_id, &worker, &test, retry)),
1168 test: test.clone(),
1169 test_file: test_files
1173 .get(&(worker.clone(), test.clone(), retry))
1174 .and_then(|path| assertions.relative(path))
1175 .or_else(|| test.split("::").next().map(str::to_owned)),
1176 title: test.rsplit("::").next().map(str::to_owned),
1177 retry: Some(retry),
1178 status: Some(status),
1179 expected_status: Some(
1180 if outcomes.iter().any(|(_, _, xfail)| *xfail) {
1181 "failed"
1182 } else {
1183 "passed"
1184 }
1185 .into(),
1186 ),
1187 flaky: false,
1188 provenance: TestProvenance {
1189 runner: runner.clone(),
1190 kind: "unit".into(),
1191 project: None,
1192 source: PYTHON_FRONTEND_VERSION.into(),
1193 },
1194 role: "test".into(),
1195 phases,
1196 runtime,
1197 browser: Vec::new(),
1198 server: Vec::new(),
1199 });
1200 }
1201 let default_observed = observed_runners
1204 .iter()
1205 .next()
1206 .cloned()
1207 .unwrap_or_else(default_runner);
1208 for ((worker, test, retry), identities) in identities_by_attempt {
1209 let runner = default_observed.clone();
1210 let mut phases = Vec::new();
1211 let mut runtime = Vec::new();
1212 for (position, (identity, observations)) in identities.iter().enumerate() {
1213 let id = phase_id(run_id, identity);
1214 phases.push(CoveragePhase {
1215 id: id.clone(),
1216 kind: match identity.phase.as_str() {
1217 "call" => "test",
1218 value => value,
1219 }
1220 .into(),
1221 operation: format!("{runner} {}", identity.phase),
1222 source: Some(test.clone()),
1223 caused_by_phase_id: None,
1224 started_at_ms: position as i64 * 2 + 1,
1225 ended_at_ms: None,
1226 status: Some("failed".into()),
1227 error: Some("the phase started but the runner reported no outcome".into()),
1228 });
1229 runtime.push(snapshot(&index, observations, &id)?);
1230 }
1231 raw_results.push(RawTestResult {
1232 test_id: Some(test.clone()),
1233 scope: Some(scope(run_id, &worker, &test, retry)),
1234 test: test.clone(),
1235 test_file: test_files
1239 .get(&(worker.clone(), test.clone(), retry))
1240 .and_then(|path| assertions.relative(path))
1241 .or_else(|| test.split("::").next().map(str::to_owned)),
1242 title: test.rsplit("::").next().map(str::to_owned),
1243 retry: Some(retry),
1244 status: Some("failed".into()),
1245 expected_status: Some("passed".into()),
1246 flaky: false,
1247 provenance: TestProvenance {
1248 runner: runner.clone(),
1249 kind: "unit".into(),
1250 project: None,
1251 source: PYTHON_FRONTEND_VERSION.into(),
1252 },
1253 role: "test".into(),
1254 phases,
1255 runtime,
1256 browser: Vec::new(),
1257 server: Vec::new(),
1258 });
1259 }
1260 for (worker, observations) in &background {
1261 if observations.hits.is_empty() && observations.vectors.is_empty() {
1262 continue;
1263 }
1264 let test = format!("__supercov_background__:{worker}");
1265 let identity = Identity {
1266 worker: worker.clone(),
1267 test: test.clone(),
1268 retry: 0,
1269 phase: "background".into(),
1270 };
1271 let phase = phase_id(run_id, &identity);
1272 raw_results.push(RawTestResult {
1273 test_id: Some(test.clone()),
1274 scope: Some(scope(run_id, worker, &test, 0)),
1275 test: "Python import, collection and background execution".into(),
1276 test_file: None,
1277 title: None,
1278 retry: Some(0),
1279 status: Some("unknown".into()),
1280 expected_status: None,
1281 flaky: false,
1282 provenance: TestProvenance {
1283 runner: default_observed.clone(),
1284 kind: "unit".into(),
1285 project: None,
1286 source: PYTHON_FRONTEND_VERSION.into(),
1287 },
1288 role: "background".into(),
1289 phases: vec![CoveragePhase {
1290 id: phase.clone(),
1291 kind: "background".into(),
1292 operation: "Python import and collection background".into(),
1293 source: None,
1294 caused_by_phase_id: None,
1295 started_at_ms: 0,
1296 ended_at_ms: Some(0),
1297 status: Some("passed".into()),
1298 error: None,
1299 }],
1300 runtime: vec![snapshot(&index, observations, &phase)?],
1301 browser: Vec::new(),
1302 server: Vec::new(),
1303 });
1304 }
1305
1306 let mut limitation_ids = manifest
1310 .limitations
1311 .iter()
1312 .filter_map(|item| item.get("id").and_then(serde_json::Value::as_str))
1313 .map(str::to_owned)
1314 .collect::<BTreeSet<_>>();
1315 let mut unmeasured = manifest.unmeasured.iter().cloned().collect::<BTreeSet<_>>();
1316 let mut new_limitations = Vec::new();
1317 for limitation in &limitations {
1318 if let Some(obligation) = &limitation.obligation {
1319 if !index.lines.contains_key(obligation.as_str()) {
1320 return Err(PythonEvidenceError::UnknownObligation(obligation.clone()));
1321 }
1322 unmeasured.insert(obligation.clone());
1323 } else if let Some(file) = &limitation.file {
1324 unmeasured.extend(
1329 index
1330 .lines
1331 .iter()
1332 .filter(|(_, (obligation_file, _))| obligation_file == file)
1333 .map(|(id, _)| (*id).to_owned()),
1334 );
1335 }
1336 if limitation_ids.insert(limitation.id.clone()) {
1337 let (file, line) = limitation
1338 .obligation
1339 .as_deref()
1340 .and_then(|id| index.lines.get(id).cloned())
1341 .unwrap_or_else(|| {
1342 (
1343 limitation.file.clone().unwrap_or_else(|| {
1344 manifest
1345 .points
1346 .first()
1347 .map_or(".".into(), |point| point.file.clone())
1348 }),
1349 1,
1350 )
1351 });
1352 new_limitations.push(json!({
1353 "id": limitation.id,
1354 "kind": "semantic-safety",
1355 "file": file,
1356 "line": line,
1357 "column": 0,
1358 "source": "",
1359 "reason": limitation.reason
1360 }));
1361 }
1362 }
1363 manifest.limitations.extend(new_limitations);
1364 manifest.unmeasured = unmeasured.into_iter().collect();
1365 let structural_limitations = limitation_ids.into_iter().collect::<Vec<_>>();
1366
1367 let tests = raw_results
1371 .iter()
1372 .filter(|raw| raw.role == "test")
1373 .map(|raw| raw.test.as_str())
1374 .collect::<BTreeSet<_>>()
1375 .len();
1376 Ok(PythonFrontendRun {
1377 declaration: FrontendRunDeclaration {
1378 protocol_version: LANGUAGE_FRONTEND_PROTOCOL_VERSION,
1379 frontend_id: "python".into(),
1380 frontend_version: PYTHON_FRONTEND_VERSION.into(),
1381 language: "python".into(),
1382 structural_source: StructuralSource::OwnedProbes,
1383 runners: observed_runners
1384 .iter()
1385 .map(|runner| FrontendRunnerDeclaration {
1386 runner: runner.clone(),
1387 execution_model: if runner == UNITTEST_RUNNER {
1388 ExecutionModel::SerialInProcess
1389 } else {
1390 ExecutionModel::ParallelContextPropagated
1391 },
1392 attribution: FrontendAttribution {
1393 run: AttributionPrecision::Exact,
1394 worker: AttributionPrecision::Exact,
1395 test: AttributionPrecision::Exact,
1396 retry: AttributionPrecision::Exact,
1397 phase: AttributionPrecision::Exact,
1398 action: AttributionPrecision::Unavailable,
1399 assertion: AttributionPrecision::Exact,
1400 },
1401 limitations: vec![FrontendLimitation {
1402 id: "python-action-linkage".into(),
1403 scopes: vec![FrontendLimitationScope::Action],
1404 reason: format!("{runner} exposes no general action lifecycle"),
1405 }],
1406 })
1407 .collect(),
1408 structural_limitations,
1409 },
1410 request: CoverageReportRequest {
1411 run_id: run_id.into(),
1412 manifest,
1413 raw_results,
1414 generated_at: generated_at.into(),
1415 coverage_model: Some(python_coverage_model()),
1416 integrity: None,
1417 test_exit_code: ExitCodeInput::Present(Some(test_exit_code)),
1418 },
1419 tests,
1420 interpreters,
1421 python_versions: python_versions.into_iter().collect(),
1422 })
1423}
1424
1425#[cfg(test)]
1426mod tests {
1427 use std::time::{SystemTime, UNIX_EPOCH};
1428
1429 use super::*;
1430 use crate::{
1431 frontend_protocol::validate_frontend_report_request,
1432 python_instrumenter::build_python_obligations,
1433 };
1434
1435 fn temporary(name: &str) -> std::path::PathBuf {
1436 let nonce = SystemTime::now()
1437 .duration_since(UNIX_EPOCH)
1438 .unwrap()
1439 .as_nanos();
1440 let path = std::env::temp_dir().join(format!(
1441 "supercov-python-evidence-{}-{nonce}-{name}",
1442 std::process::id()
1443 ));
1444 fs::create_dir_all(&path).unwrap();
1445 path
1446 }
1447
1448 fn write_transport(path: &Path, records: &[serde_json::Value], dropped: u64) {
1449 let payloads = records
1450 .iter()
1451 .map(|record| serde_json::to_vec(record).unwrap())
1452 .collect::<Vec<_>>();
1453 let capacity = payloads
1454 .iter()
1455 .fold(TRANSPORT_HEADER_SIZE, |cursor, payload| {
1456 align_transport(cursor + TRANSPORT_RECORD_HEADER_SIZE + payload.len()).unwrap()
1457 })
1458 + 64;
1459 let mut bytes = vec![0_u8; capacity];
1460 bytes[..8].copy_from_slice(TRANSPORT_MAGIC);
1461 bytes[8..12].copy_from_slice(&TRANSPORT_VERSION.to_le_bytes());
1462 bytes[12..16].copy_from_slice(&(TRANSPORT_HEADER_SIZE as u32).to_le_bytes());
1463 bytes[16..24].copy_from_slice(&(capacity as u64).to_le_bytes());
1464 bytes[24..32].copy_from_slice(&dropped.to_le_bytes());
1465 bytes[32..40].copy_from_slice(&1_u64.to_le_bytes());
1466 let mut cursor = TRANSPORT_HEADER_SIZE;
1467 for payload in payloads {
1468 let payload_start = cursor + TRANSPORT_RECORD_HEADER_SIZE;
1469 let payload_end = payload_start + payload.len();
1470 bytes[payload_start..payload_end].copy_from_slice(&payload);
1471 bytes[cursor + 4..cursor + 8].copy_from_slice(&(payload.len() as u32).to_le_bytes());
1472 bytes[cursor + 8..cursor + 12]
1473 .copy_from_slice(&transport_checksum(&payload).to_le_bytes());
1474 bytes[cursor] = 1;
1475 cursor = align_transport(payload_end).unwrap();
1476 }
1477 fs::write(path, bytes).unwrap();
1478 }
1479
1480 fn inventory_of(root: &str, sites: &[(&str, usize, usize)]) -> PythonAssertionInventory {
1481 use crate::assertion_map::{Anchor, Files, Inputs, InventorySite};
1482 PythonAssertionInventory::new(
1483 Path::new(root),
1484 &Inputs {
1485 schema_version: 1,
1486 language: "python".into(),
1487 context_digest: "context".into(),
1488 files: Files::new(),
1489 assertions: sites
1490 .iter()
1491 .map(|(file, line, column)| InventorySite {
1492 at: Anchor {
1493 file: (*file).into(),
1494 line: *line,
1495 column: *column,
1496 text: "assert f(1) == 1".into(),
1497 },
1498 operation: "assert".into(),
1499 })
1500 .collect(),
1501 limitations: vec![],
1502 },
1503 )
1504 }
1505
1506 fn assertion_sources(run: &PythonFrontendRun) -> Vec<String> {
1507 run.request.raw_results[0]
1508 .phases
1509 .iter()
1510 .filter(|phase| phase.kind == "assertion")
1511 .filter_map(|phase| phase.source.clone())
1512 .collect()
1513 }
1514
1515 fn run_with_sites(
1516 name: &str,
1517 sites: &[serde_json::Value],
1518 outcome_file: Option<&str>,
1519 inventory: &PythonAssertionInventory,
1520 ) -> PythonFrontendRun {
1521 let source = "def f(a):\n return a\n";
1522 let obligations = build_python_obligations("m.py", source).unwrap();
1523 let mut outcome = json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call","outcome":"passed","xfail":false});
1524 if let Some(file) = outcome_file {
1525 outcome["file"] = json!(file);
1526 }
1527 let mut lines = vec![
1528 json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.14.4","executable":"python","argv":["pytest"]}),
1529 json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call"}),
1530 json!({"t":"assert","ctx":1}),
1531 ];
1532 lines.extend(sites.iter().cloned());
1533 lines.push(outcome);
1534 lines.push(json!({"t":"exit","at":9}));
1535 let directory = temporary(name);
1536 write_transport(&directory.join("main.1.mmap"), &lines, 0);
1537 let run = build_python_frontend_run(
1538 &obligations.manifest,
1539 &directory,
1540 "run-1",
1541 "now",
1542 0,
1543 inventory,
1544 )
1545 .unwrap();
1546 validate_frontend_report_request(&run.declaration, &run.request).unwrap();
1547 fs::remove_dir_all(directory).unwrap();
1548 run
1549 }
1550
1551 #[test]
1552 fn an_assertion_site_becomes_a_located_phase_when_the_inventory_names_one() {
1553 let inventory = inventory_of("/project", &[("tests/test_m.py", 6, 5)]);
1558 let run = run_with_sites(
1559 "py-asite-located",
1560 &[json!({"t":"asite","ctx":1,"f":"/project/tests/test_m.py","l":6})],
1561 None,
1562 &inventory,
1563 );
1564 assert!(
1565 assertion_sources(&run).contains(&"tests/test_m.py:6:4".to_string()),
1566 "expected a located assertion phase, got {:?}",
1567 assertion_sources(&run)
1568 );
1569 }
1570
1571 #[test]
1572 fn an_ambiguous_or_foreign_assertion_site_witnesses_nothing() {
1573 let ambiguous = inventory_of(
1577 "/project",
1578 &[("tests/test_m.py", 6, 5), ("tests/test_m.py", 6, 30)],
1579 );
1580 let run = run_with_sites(
1581 "py-asite-ambiguous",
1582 &[json!({"t":"asite","ctx":1,"f":"/project/tests/test_m.py","l":6})],
1583 None,
1584 &ambiguous,
1585 );
1586 assert_eq!(
1587 assertion_sources(&run),
1588 vec!["tests/test_m.py::test_a".to_string()],
1589 "only the per-test assertion phase should remain"
1590 );
1591
1592 let known = inventory_of("/project", &[("tests/test_m.py", 6, 5)]);
1593 let outside = run_with_sites(
1594 "py-asite-outside",
1595 &[json!({"t":"asite","ctx":1,"f":"/elsewhere/tests/test_m.py","l":6})],
1596 None,
1597 &known,
1598 );
1599 assert_eq!(
1600 assertion_sources(&outside),
1601 vec!["tests/test_m.py::test_a".to_string()]
1602 );
1603 }
1604
1605 #[test]
1606 fn the_runner_names_the_test_file_in_either_path_form() {
1607 let inventory = inventory_of("/project", &[("tests/test_m.py", 6, 5)]);
1612 for reported in [
1613 "/project/tests/test_m.py",
1614 "tests/test_m.py",
1615 "./tests/test_m.py",
1616 ] {
1617 let run = run_with_sites("py-asite-file", &[], Some(reported), &inventory);
1618 assert_eq!(
1619 run.request.raw_results[0].test_file.as_deref(),
1620 Some("tests/test_m.py"),
1621 "{reported} should resolve to the project path"
1622 );
1623 }
1624 let without = run_with_sites("py-asite-nofile", &[], None, &inventory);
1625 assert_eq!(
1626 without.request.raw_results[0].test_file.as_deref(),
1627 Some("tests/test_m.py"),
1628 "a pytest node id already begins with the file"
1629 );
1630 }
1631
1632 #[test]
1633 fn evidence_before_the_first_assertion_links_to_it_when_the_test_passes() {
1634 let source = "def f(a):\n return a\n\n\ndef g(b):\n return b\n";
1639 let obligations = build_python_obligations("m.py", source).unwrap();
1640 let before = &obligations.plan.statements[0].id;
1641 let after = &obligations.plan.statements[1].id;
1642 let run_with = |name: &str, outcome: &str| {
1643 let lines = [
1644 json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.14.4","executable":"python","argv":["pytest"]}),
1645 json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call"}),
1646 json!({"t":"hit","ctx":1,"id":before}),
1647 json!({"t":"assert","ctx":1}),
1648 json!({"t":"assert","ctx":1}),
1649 json!({"t":"hit","ctx":1,"id":after}),
1650 json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call","outcome":outcome,"xfail":false}),
1651 json!({"t":"exit","at":9}),
1652 ];
1653 let directory = temporary(name);
1654 write_transport(&directory.join("main.1.mmap"), &lines, 0);
1655 let run = build_python_frontend_run(
1656 &obligations.manifest,
1657 &directory,
1658 "run-1",
1659 "now",
1660 0,
1661 &PythonAssertionInventory::empty(),
1662 )
1663 .unwrap();
1664 validate_frontend_report_request(&run.declaration, &run.request).unwrap();
1665 fs::remove_dir_all(directory).unwrap();
1666 run
1667 };
1668 let events_of = |result: &RawTestResult, phase: &str| -> BTreeSet<String> {
1669 result
1670 .runtime
1671 .iter()
1672 .flat_map(|snapshot| snapshot.events.iter())
1673 .filter(|event| event.phase_id.as_deref() == Some(phase))
1674 .map(|event| event.id.clone())
1675 .collect()
1676 };
1677
1678 let run = run_with("asserted-passed", "passed");
1679 let passed = &run.request.raw_results[0];
1680 assert_eq!(passed.test, "tests/test_m.py::test_a");
1681 let assertion = passed
1682 .phases
1683 .iter()
1684 .find(|phase| phase.kind == "assertion")
1685 .expect("the asserting test carries an assertion phase");
1686 assert_eq!(assertion.status.as_deref(), Some("passed"));
1687 assert_eq!(
1688 events_of(passed, &assertion.id),
1689 BTreeSet::from([before.clone()]),
1690 "only what ran before the marker is the assertion's evidence"
1691 );
1692 let test_phase = passed
1693 .phases
1694 .iter()
1695 .find(|phase| phase.kind == "test")
1696 .unwrap();
1697 assert_eq!(
1698 events_of(passed, &test_phase.id),
1699 BTreeSet::from([before.clone(), after.clone()]),
1700 "the test phase keeps everything it ran"
1701 );
1702 assert_eq!(
1703 run.declaration.runners[0].attribution.assertion,
1704 AttributionPrecision::Exact
1705 );
1706
1707 let run = run_with("asserted-failed", "failed");
1708 let failed = &run.request.raw_results[0];
1709 let assertion = failed
1710 .phases
1711 .iter()
1712 .find(|phase| phase.kind == "assertion")
1713 .unwrap();
1714 assert_eq!(
1715 assertion.status.as_deref(),
1716 Some("failed"),
1717 "a failed test's assertion witnessed nothing"
1718 );
1719 }
1720
1721 #[test]
1722 fn joins_phases_outcomes_hits_and_vectors_into_exact_results() {
1723 let source = "def f(a, b):\n if a and b:\n return 1\n return 0\n";
1724 let obligations = build_python_obligations("m.py", source).unwrap();
1725 let decision = &obligations.plan.decisions[0];
1726 let statement = &obligations.plan.statements[0];
1727 let directory = temporary("join");
1728 let lines = [
1729 json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.14.4","executable":"python","argv":["pytest"]}),
1730 json!({"t":"hit","ctx":0,"id":statement.id}),
1731 json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call"}),
1732 json!({"t":"dec","ctx":1,"id":decision.id,"v":"22","o":1}),
1733 json!({"t":"hit","ctx":1,"id":decision.outcome_true}),
1734 json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"setup","outcome":"passed","xfail":false}),
1735 json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call","outcome":"passed","xfail":false}),
1736 json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"teardown","outcome":"passed","xfail":false}),
1737 json!({"t":"limitation","id":"python-decision-partially-mapped","reason":"folded","obligation":decision.id}),
1738 json!({"t":"exit","at":9}),
1739 ];
1740 write_transport(&directory.join("main.1.mmap"), &lines, 0);
1741 let run = build_python_frontend_run(
1742 &obligations.manifest,
1743 &directory,
1744 "run-1",
1745 "now",
1746 0,
1747 &PythonAssertionInventory::empty(),
1748 )
1749 .unwrap();
1750 validate_frontend_report_request(&run.declaration, &run.request).unwrap();
1751 assert_eq!(run.tests, 1);
1752 assert_eq!(run.request.raw_results.len(), 2);
1753 let test = &run.request.raw_results[0];
1754 assert_eq!(test.status.as_deref(), Some("passed"));
1755 assert_eq!(test.phases.len(), 3);
1756 assert_eq!(test.runtime.len(), 1);
1757 assert_eq!(test.runtime[0].decisions.len(), 1);
1758 assert!(
1759 test.runtime[0]
1760 .events
1761 .iter()
1762 .all(|event| event.phase_id.is_some())
1763 );
1764 let background = &run.request.raw_results[1];
1765 assert_eq!(background.role, "background");
1766 assert!(run.request.manifest.unmeasured.contains(&decision.id));
1767 assert!(
1768 run.declaration
1769 .structural_limitations
1770 .contains(&"python-decision-partially-mapped".to_owned())
1771 );
1772 fs::remove_dir_all(directory).unwrap();
1773 }
1774
1775 #[test]
1776 fn fails_closed_without_an_interpreter_or_tests() {
1777 let obligations = build_python_obligations("m.py", "x = 1\n").unwrap();
1778 let directory = temporary("empty");
1779 assert!(matches!(
1780 build_python_frontend_run(
1781 &obligations.manifest,
1782 &directory,
1783 "run-1",
1784 "now",
1785 0,
1786 &PythonAssertionInventory::empty()
1787 ),
1788 Err(PythonEvidenceError::NoInterpreter)
1789 ));
1790 let path = directory.join("main.1.mmap");
1791 write_transport(
1792 &path,
1793 &[
1794 json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.14.4","executable":"p","argv":[]}),
1795 ],
1796 0,
1797 );
1798 assert!(matches!(
1799 build_python_frontend_run(
1800 &obligations.manifest,
1801 &directory,
1802 "run-1",
1803 "now",
1804 0,
1805 &PythonAssertionInventory::empty()
1806 ),
1807 Err(PythonEvidenceError::NoTests)
1808 ));
1809 let mut torn = fs::read(&path).unwrap();
1813 let process_length = transport_u32(&torn, TRANSPORT_HEADER_SIZE + 4).unwrap() as usize;
1814 let torn_cursor =
1815 align_transport(TRANSPORT_HEADER_SIZE + TRANSPORT_RECORD_HEADER_SIZE + process_length)
1816 .unwrap();
1817 torn[torn_cursor + 4..torn_cursor + 8].copy_from_slice(&5_u32.to_le_bytes());
1818 torn[torn_cursor + TRANSPORT_RECORD_HEADER_SIZE
1819 ..torn_cursor + TRANSPORT_RECORD_HEADER_SIZE + 5]
1820 .copy_from_slice(b"{nope");
1821 fs::write(&path, torn).unwrap();
1822 assert!(matches!(
1823 build_python_frontend_run(
1824 &obligations.manifest,
1825 &directory,
1826 "run-1",
1827 "now",
1828 0,
1829 &PythonAssertionInventory::empty()
1830 ),
1831 Err(PythonEvidenceError::NoTests)
1832 ));
1833 write_transport(
1834 &path,
1835 &[
1836 json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.11.9","executable":"p","argv":[]}),
1837 ],
1838 0,
1839 );
1840 assert!(matches!(
1841 build_python_frontend_run(
1842 &obligations.manifest,
1843 &directory,
1844 "run-1",
1845 "now",
1846 0,
1847 &PythonAssertionInventory::empty()
1848 ),
1849 Err(PythonEvidenceError::UnsupportedPython(_))
1850 ));
1851 write_transport(
1852 &path,
1853 &[
1854 json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.14.4","executable":"p","argv":[]}),
1855 ],
1856 2,
1857 );
1858 assert!(matches!(
1859 build_python_frontend_run(
1860 &obligations.manifest,
1861 &directory,
1862 "run-1",
1863 "now",
1864 0,
1865 &PythonAssertionInventory::empty()
1866 ),
1867 Err(PythonEvidenceError::DroppedRecords { count: 2, .. })
1868 ));
1869 fs::remove_dir_all(directory).unwrap();
1870 }
1871}