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 attribution: crate::coverage_report::ATTRIBUTION_EXACT.into(),
1196 phases,
1197 runtime,
1198 browser: Vec::new(),
1199 server: Vec::new(),
1200 });
1201 }
1202 let default_observed = observed_runners
1205 .iter()
1206 .next()
1207 .cloned()
1208 .unwrap_or_else(default_runner);
1209 for ((worker, test, retry), identities) in identities_by_attempt {
1210 let runner = default_observed.clone();
1211 let mut phases = Vec::new();
1212 let mut runtime = Vec::new();
1213 for (position, (identity, observations)) in identities.iter().enumerate() {
1214 let id = phase_id(run_id, identity);
1215 phases.push(CoveragePhase {
1216 id: id.clone(),
1217 kind: match identity.phase.as_str() {
1218 "call" => "test",
1219 value => value,
1220 }
1221 .into(),
1222 operation: format!("{runner} {}", identity.phase),
1223 source: Some(test.clone()),
1224 caused_by_phase_id: None,
1225 started_at_ms: position as i64 * 2 + 1,
1226 ended_at_ms: None,
1227 status: Some("failed".into()),
1228 error: Some("the phase started but the runner reported no outcome".into()),
1229 });
1230 runtime.push(snapshot(&index, observations, &id)?);
1231 }
1232 raw_results.push(RawTestResult {
1233 test_id: Some(test.clone()),
1234 scope: Some(scope(run_id, &worker, &test, retry)),
1235 test: test.clone(),
1236 test_file: test_files
1240 .get(&(worker.clone(), test.clone(), retry))
1241 .and_then(|path| assertions.relative(path))
1242 .or_else(|| test.split("::").next().map(str::to_owned)),
1243 title: test.rsplit("::").next().map(str::to_owned),
1244 retry: Some(retry),
1245 status: Some("failed".into()),
1246 expected_status: Some("passed".into()),
1247 flaky: false,
1248 provenance: TestProvenance {
1249 runner: runner.clone(),
1250 kind: "unit".into(),
1251 project: None,
1252 source: PYTHON_FRONTEND_VERSION.into(),
1253 },
1254 role: "test".into(),
1255 attribution: crate::coverage_report::ATTRIBUTION_EXACT.into(),
1256 phases,
1257 runtime,
1258 browser: Vec::new(),
1259 server: Vec::new(),
1260 });
1261 }
1262 for (worker, observations) in &background {
1263 if observations.hits.is_empty() && observations.vectors.is_empty() {
1264 continue;
1265 }
1266 let test = format!("__supercov_background__:{worker}");
1267 let identity = Identity {
1268 worker: worker.clone(),
1269 test: test.clone(),
1270 retry: 0,
1271 phase: "background".into(),
1272 };
1273 let phase = phase_id(run_id, &identity);
1274 raw_results.push(RawTestResult {
1275 test_id: Some(test.clone()),
1276 scope: Some(scope(run_id, worker, &test, 0)),
1277 test: "Python import, collection and background execution".into(),
1278 test_file: None,
1279 title: None,
1280 retry: Some(0),
1281 status: Some("unknown".into()),
1282 expected_status: None,
1283 flaky: false,
1284 provenance: TestProvenance {
1285 runner: default_observed.clone(),
1286 kind: "unit".into(),
1287 project: None,
1288 source: PYTHON_FRONTEND_VERSION.into(),
1289 },
1290 role: "background".into(),
1291 attribution: crate::coverage_report::ATTRIBUTION_EXACT.into(),
1292 phases: vec![CoveragePhase {
1293 id: phase.clone(),
1294 kind: "background".into(),
1295 operation: "Python import and collection background".into(),
1296 source: None,
1297 caused_by_phase_id: None,
1298 started_at_ms: 0,
1299 ended_at_ms: Some(0),
1300 status: Some("passed".into()),
1301 error: None,
1302 }],
1303 runtime: vec![snapshot(&index, observations, &phase)?],
1304 browser: Vec::new(),
1305 server: Vec::new(),
1306 });
1307 }
1308
1309 let mut limitation_ids = manifest
1313 .limitations
1314 .iter()
1315 .filter_map(|item| item.get("id").and_then(serde_json::Value::as_str))
1316 .map(str::to_owned)
1317 .collect::<BTreeSet<_>>();
1318 let mut unmeasured = manifest.unmeasured.iter().cloned().collect::<BTreeSet<_>>();
1319 let mut new_limitations = Vec::new();
1320 for limitation in &limitations {
1321 if let Some(obligation) = &limitation.obligation {
1322 if !index.lines.contains_key(obligation.as_str()) {
1323 return Err(PythonEvidenceError::UnknownObligation(obligation.clone()));
1324 }
1325 unmeasured.insert(obligation.clone());
1326 } else if let Some(file) = &limitation.file {
1327 unmeasured.extend(
1332 index
1333 .lines
1334 .iter()
1335 .filter(|(_, (obligation_file, _))| obligation_file == file)
1336 .map(|(id, _)| (*id).to_owned()),
1337 );
1338 }
1339 if limitation_ids.insert(limitation.id.clone()) {
1340 let (file, line) = limitation
1341 .obligation
1342 .as_deref()
1343 .and_then(|id| index.lines.get(id).cloned())
1344 .unwrap_or_else(|| {
1345 (
1346 limitation.file.clone().unwrap_or_else(|| {
1347 manifest
1348 .points
1349 .first()
1350 .map_or(".".into(), |point| point.file.clone())
1351 }),
1352 1,
1353 )
1354 });
1355 new_limitations.push(json!({
1356 "id": limitation.id,
1357 "kind": "semantic-safety",
1358 "file": file,
1359 "line": line,
1360 "column": 0,
1361 "source": "",
1362 "reason": limitation.reason
1363 }));
1364 }
1365 }
1366 manifest.limitations.extend(new_limitations);
1367 manifest.unmeasured = unmeasured.into_iter().collect();
1368 let structural_limitations = limitation_ids.into_iter().collect::<Vec<_>>();
1369
1370 let tests = raw_results
1374 .iter()
1375 .filter(|raw| raw.role == "test")
1376 .map(|raw| raw.test.as_str())
1377 .collect::<BTreeSet<_>>()
1378 .len();
1379 Ok(PythonFrontendRun {
1380 declaration: FrontendRunDeclaration {
1381 protocol_version: LANGUAGE_FRONTEND_PROTOCOL_VERSION,
1382 frontend_id: "python".into(),
1383 frontend_version: PYTHON_FRONTEND_VERSION.into(),
1384 language: "python".into(),
1385 structural_source: StructuralSource::OwnedProbes,
1386 runners: observed_runners
1387 .iter()
1388 .map(|runner| FrontendRunnerDeclaration {
1389 runner: runner.clone(),
1390 execution_model: if runner == UNITTEST_RUNNER {
1391 ExecutionModel::SerialInProcess
1392 } else {
1393 ExecutionModel::ParallelContextPropagated
1394 },
1395 attribution: FrontendAttribution {
1396 run: AttributionPrecision::Exact,
1397 worker: AttributionPrecision::Exact,
1398 test: AttributionPrecision::Exact,
1399 retry: AttributionPrecision::Exact,
1400 phase: AttributionPrecision::Exact,
1401 action: AttributionPrecision::Unavailable,
1402 assertion: AttributionPrecision::Exact,
1403 },
1404 limitations: vec![FrontendLimitation {
1405 id: "python-action-linkage".into(),
1406 scopes: vec![FrontendLimitationScope::Action],
1407 reason: format!("{runner} exposes no general action lifecycle"),
1408 }],
1409 })
1410 .collect(),
1411 structural_limitations,
1412 },
1413 request: CoverageReportRequest {
1414 run_id: run_id.into(),
1415 manifest,
1416 raw_results,
1417 generated_at: generated_at.into(),
1418 coverage_model: Some(python_coverage_model()),
1419 integrity: None,
1420 test_exit_code: ExitCodeInput::Present(Some(test_exit_code)),
1421 },
1422 tests,
1423 interpreters,
1424 python_versions: python_versions.into_iter().collect(),
1425 })
1426}
1427
1428#[cfg(test)]
1429mod tests {
1430 use std::time::{SystemTime, UNIX_EPOCH};
1431
1432 use super::*;
1433 use crate::{
1434 frontend_protocol::validate_frontend_report_request,
1435 python_instrumenter::build_python_obligations,
1436 };
1437
1438 fn temporary(name: &str) -> std::path::PathBuf {
1439 let nonce = SystemTime::now()
1440 .duration_since(UNIX_EPOCH)
1441 .unwrap()
1442 .as_nanos();
1443 let path = std::env::temp_dir().join(format!(
1444 "supercov-python-evidence-{}-{nonce}-{name}",
1445 std::process::id()
1446 ));
1447 fs::create_dir_all(&path).unwrap();
1448 path
1449 }
1450
1451 fn write_transport(path: &Path, records: &[serde_json::Value], dropped: u64) {
1452 let payloads = records
1453 .iter()
1454 .map(|record| serde_json::to_vec(record).unwrap())
1455 .collect::<Vec<_>>();
1456 let capacity = payloads
1457 .iter()
1458 .fold(TRANSPORT_HEADER_SIZE, |cursor, payload| {
1459 align_transport(cursor + TRANSPORT_RECORD_HEADER_SIZE + payload.len()).unwrap()
1460 })
1461 + 64;
1462 let mut bytes = vec![0_u8; capacity];
1463 bytes[..8].copy_from_slice(TRANSPORT_MAGIC);
1464 bytes[8..12].copy_from_slice(&TRANSPORT_VERSION.to_le_bytes());
1465 bytes[12..16].copy_from_slice(&(TRANSPORT_HEADER_SIZE as u32).to_le_bytes());
1466 bytes[16..24].copy_from_slice(&(capacity as u64).to_le_bytes());
1467 bytes[24..32].copy_from_slice(&dropped.to_le_bytes());
1468 bytes[32..40].copy_from_slice(&1_u64.to_le_bytes());
1469 let mut cursor = TRANSPORT_HEADER_SIZE;
1470 for payload in payloads {
1471 let payload_start = cursor + TRANSPORT_RECORD_HEADER_SIZE;
1472 let payload_end = payload_start + payload.len();
1473 bytes[payload_start..payload_end].copy_from_slice(&payload);
1474 bytes[cursor + 4..cursor + 8].copy_from_slice(&(payload.len() as u32).to_le_bytes());
1475 bytes[cursor + 8..cursor + 12]
1476 .copy_from_slice(&transport_checksum(&payload).to_le_bytes());
1477 bytes[cursor] = 1;
1478 cursor = align_transport(payload_end).unwrap();
1479 }
1480 fs::write(path, bytes).unwrap();
1481 }
1482
1483 fn under(first: &str, rest: &str) -> String {
1488 let mut path = PathBuf::from(if cfg!(windows) {
1489 format!("C:\\{first}")
1490 } else {
1491 format!("/{first}")
1492 });
1493 for part in rest.split('/').filter(|part| !part.is_empty()) {
1494 path.push(part);
1495 }
1496 path.to_string_lossy().into_owned()
1497 }
1498
1499 fn inventory_of(root: &str, sites: &[(&str, usize, usize)]) -> PythonAssertionInventory {
1500 use crate::assertion_map::{Anchor, Files, Inputs, InventorySite};
1501 PythonAssertionInventory::new(
1502 Path::new(root),
1503 &Inputs {
1504 schema_version: 1,
1505 language: "python".into(),
1506 context_digest: "context".into(),
1507 files: Files::new(),
1508 assertions: sites
1509 .iter()
1510 .map(|(file, line, column)| InventorySite {
1511 at: Anchor {
1512 file: (*file).into(),
1513 line: *line,
1514 column: *column,
1515 text: "assert f(1) == 1".into(),
1516 },
1517 operation: "assert".into(),
1518 })
1519 .collect(),
1520 limitations: vec![],
1521 },
1522 )
1523 }
1524
1525 fn assertion_sources(run: &PythonFrontendRun) -> Vec<String> {
1526 run.request.raw_results[0]
1527 .phases
1528 .iter()
1529 .filter(|phase| phase.kind == "assertion")
1530 .filter_map(|phase| phase.source.clone())
1531 .collect()
1532 }
1533
1534 fn run_with_sites(
1535 name: &str,
1536 sites: &[serde_json::Value],
1537 outcome_file: Option<&str>,
1538 inventory: &PythonAssertionInventory,
1539 ) -> PythonFrontendRun {
1540 let source = "def f(a):\n return a\n";
1541 let obligations = build_python_obligations("m.py", source).unwrap();
1542 let mut outcome = json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call","outcome":"passed","xfail":false});
1543 if let Some(file) = outcome_file {
1544 outcome["file"] = json!(file);
1545 }
1546 let mut lines = vec![
1547 json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.14.4","executable":"python","argv":["pytest"]}),
1548 json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call"}),
1549 json!({"t":"assert","ctx":1}),
1550 ];
1551 lines.extend(sites.iter().cloned());
1552 lines.push(outcome);
1553 lines.push(json!({"t":"exit","at":9}));
1554 let directory = temporary(name);
1555 write_transport(&directory.join("main.1.mmap"), &lines, 0);
1556 let run = build_python_frontend_run(
1557 &obligations.manifest,
1558 &directory,
1559 "run-1",
1560 "now",
1561 0,
1562 inventory,
1563 )
1564 .unwrap();
1565 validate_frontend_report_request(&run.declaration, &run.request).unwrap();
1566 fs::remove_dir_all(directory).unwrap();
1567 run
1568 }
1569
1570 #[test]
1571 fn an_assertion_site_becomes_a_located_phase_when_the_inventory_names_one() {
1572 let inventory = inventory_of(&under("project", ""), &[("tests/test_m.py", 6, 5)]);
1577 let run = run_with_sites(
1578 "py-asite-located",
1579 &[json!({"t":"asite","ctx":1,"f":under("project", "tests/test_m.py"),"l":6})],
1580 None,
1581 &inventory,
1582 );
1583 assert!(
1584 assertion_sources(&run).contains(&"tests/test_m.py:6:4".to_string()),
1585 "expected a located assertion phase, got {:?}",
1586 assertion_sources(&run)
1587 );
1588 }
1589
1590 #[test]
1591 fn an_ambiguous_or_foreign_assertion_site_witnesses_nothing() {
1592 let ambiguous = inventory_of(
1596 &under("project", ""),
1597 &[("tests/test_m.py", 6, 5), ("tests/test_m.py", 6, 30)],
1598 );
1599 let run = run_with_sites(
1600 "py-asite-ambiguous",
1601 &[json!({"t":"asite","ctx":1,"f":under("project", "tests/test_m.py"),"l":6})],
1602 None,
1603 &ambiguous,
1604 );
1605 assert_eq!(
1606 assertion_sources(&run),
1607 vec!["tests/test_m.py::test_a".to_string()],
1608 "only the per-test assertion phase should remain"
1609 );
1610
1611 let known = inventory_of(&under("project", ""), &[("tests/test_m.py", 6, 5)]);
1612 let outside = run_with_sites(
1613 "py-asite-outside",
1614 &[json!({"t":"asite","ctx":1,"f":under("elsewhere", "tests/test_m.py"),"l":6})],
1615 None,
1616 &known,
1617 );
1618 assert_eq!(
1619 assertion_sources(&outside),
1620 vec!["tests/test_m.py::test_a".to_string()]
1621 );
1622 }
1623
1624 #[test]
1625 fn the_runner_names_the_test_file_in_either_path_form() {
1626 let inventory = inventory_of(&under("project", ""), &[("tests/test_m.py", 6, 5)]);
1631 for reported in [
1632 under("project", "tests/test_m.py"),
1633 "tests/test_m.py".to_owned(),
1634 "./tests/test_m.py".to_owned(),
1635 ] {
1636 let run = run_with_sites("py-asite-file", &[], Some(reported.as_str()), &inventory);
1637 assert_eq!(
1638 run.request.raw_results[0].test_file.as_deref(),
1639 Some("tests/test_m.py"),
1640 "{reported} should resolve to the project path"
1641 );
1642 }
1643 let without = run_with_sites("py-asite-nofile", &[], None, &inventory);
1644 assert_eq!(
1645 without.request.raw_results[0].test_file.as_deref(),
1646 Some("tests/test_m.py"),
1647 "a pytest node id already begins with the file"
1648 );
1649 }
1650
1651 #[test]
1652 fn evidence_before_the_first_assertion_links_to_it_when_the_test_passes() {
1653 let source = "def f(a):\n return a\n\n\ndef g(b):\n return b\n";
1658 let obligations = build_python_obligations("m.py", source).unwrap();
1659 let before = &obligations.plan.statements[0].id;
1660 let after = &obligations.plan.statements[1].id;
1661 let run_with = |name: &str, outcome: &str| {
1662 let lines = [
1663 json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.14.4","executable":"python","argv":["pytest"]}),
1664 json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call"}),
1665 json!({"t":"hit","ctx":1,"id":before}),
1666 json!({"t":"assert","ctx":1}),
1667 json!({"t":"assert","ctx":1}),
1668 json!({"t":"hit","ctx":1,"id":after}),
1669 json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call","outcome":outcome,"xfail":false}),
1670 json!({"t":"exit","at":9}),
1671 ];
1672 let directory = temporary(name);
1673 write_transport(&directory.join("main.1.mmap"), &lines, 0);
1674 let run = build_python_frontend_run(
1675 &obligations.manifest,
1676 &directory,
1677 "run-1",
1678 "now",
1679 0,
1680 &PythonAssertionInventory::empty(),
1681 )
1682 .unwrap();
1683 validate_frontend_report_request(&run.declaration, &run.request).unwrap();
1684 fs::remove_dir_all(directory).unwrap();
1685 run
1686 };
1687 let events_of = |result: &RawTestResult, phase: &str| -> BTreeSet<String> {
1688 result
1689 .runtime
1690 .iter()
1691 .flat_map(|snapshot| snapshot.events.iter())
1692 .filter(|event| event.phase_id.as_deref() == Some(phase))
1693 .map(|event| event.id.clone())
1694 .collect()
1695 };
1696
1697 let run = run_with("asserted-passed", "passed");
1698 let passed = &run.request.raw_results[0];
1699 assert_eq!(passed.test, "tests/test_m.py::test_a");
1700 let assertion = passed
1701 .phases
1702 .iter()
1703 .find(|phase| phase.kind == "assertion")
1704 .expect("the asserting test carries an assertion phase");
1705 assert_eq!(assertion.status.as_deref(), Some("passed"));
1706 assert_eq!(
1707 events_of(passed, &assertion.id),
1708 BTreeSet::from([before.clone()]),
1709 "only what ran before the marker is the assertion's evidence"
1710 );
1711 let test_phase = passed
1712 .phases
1713 .iter()
1714 .find(|phase| phase.kind == "test")
1715 .unwrap();
1716 assert_eq!(
1717 events_of(passed, &test_phase.id),
1718 BTreeSet::from([before.clone(), after.clone()]),
1719 "the test phase keeps everything it ran"
1720 );
1721 assert_eq!(
1722 run.declaration.runners[0].attribution.assertion,
1723 AttributionPrecision::Exact
1724 );
1725
1726 let run = run_with("asserted-failed", "failed");
1727 let failed = &run.request.raw_results[0];
1728 let assertion = failed
1729 .phases
1730 .iter()
1731 .find(|phase| phase.kind == "assertion")
1732 .unwrap();
1733 assert_eq!(
1734 assertion.status.as_deref(),
1735 Some("failed"),
1736 "a failed test's assertion witnessed nothing"
1737 );
1738 }
1739
1740 #[test]
1741 fn joins_phases_outcomes_hits_and_vectors_into_exact_results() {
1742 let source = "def f(a, b):\n if a and b:\n return 1\n return 0\n";
1743 let obligations = build_python_obligations("m.py", source).unwrap();
1744 let decision = &obligations.plan.decisions[0];
1745 let statement = &obligations.plan.statements[0];
1746 let directory = temporary("join");
1747 let lines = [
1748 json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.14.4","executable":"python","argv":["pytest"]}),
1749 json!({"t":"hit","ctx":0,"id":statement.id}),
1750 json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call"}),
1751 json!({"t":"dec","ctx":1,"id":decision.id,"v":"22","o":1}),
1752 json!({"t":"hit","ctx":1,"id":decision.outcome_true}),
1753 json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"setup","outcome":"passed","xfail":false}),
1754 json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call","outcome":"passed","xfail":false}),
1755 json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"teardown","outcome":"passed","xfail":false}),
1756 json!({"t":"limitation","id":"python-decision-partially-mapped","reason":"folded","obligation":decision.id}),
1757 json!({"t":"exit","at":9}),
1758 ];
1759 write_transport(&directory.join("main.1.mmap"), &lines, 0);
1760 let run = build_python_frontend_run(
1761 &obligations.manifest,
1762 &directory,
1763 "run-1",
1764 "now",
1765 0,
1766 &PythonAssertionInventory::empty(),
1767 )
1768 .unwrap();
1769 validate_frontend_report_request(&run.declaration, &run.request).unwrap();
1770 assert_eq!(run.tests, 1);
1771 assert_eq!(run.request.raw_results.len(), 2);
1772 let test = &run.request.raw_results[0];
1773 assert_eq!(test.status.as_deref(), Some("passed"));
1774 assert_eq!(test.phases.len(), 3);
1775 assert_eq!(test.runtime.len(), 1);
1776 assert_eq!(test.runtime[0].decisions.len(), 1);
1777 assert!(
1778 test.runtime[0]
1779 .events
1780 .iter()
1781 .all(|event| event.phase_id.is_some())
1782 );
1783 let background = &run.request.raw_results[1];
1784 assert_eq!(background.role, "background");
1785 assert!(run.request.manifest.unmeasured.contains(&decision.id));
1786 assert!(
1787 run.declaration
1788 .structural_limitations
1789 .contains(&"python-decision-partially-mapped".to_owned())
1790 );
1791 fs::remove_dir_all(directory).unwrap();
1792 }
1793
1794 #[test]
1795 fn fails_closed_without_an_interpreter_or_tests() {
1796 let obligations = build_python_obligations("m.py", "x = 1\n").unwrap();
1797 let directory = temporary("empty");
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::NoInterpreter)
1808 ));
1809 let path = directory.join("main.1.mmap");
1810 write_transport(
1811 &path,
1812 &[
1813 json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.14.4","executable":"p","argv":[]}),
1814 ],
1815 0,
1816 );
1817 assert!(matches!(
1818 build_python_frontend_run(
1819 &obligations.manifest,
1820 &directory,
1821 "run-1",
1822 "now",
1823 0,
1824 &PythonAssertionInventory::empty()
1825 ),
1826 Err(PythonEvidenceError::NoTests)
1827 ));
1828 let mut torn = fs::read(&path).unwrap();
1832 let process_length = transport_u32(&torn, TRANSPORT_HEADER_SIZE + 4).unwrap() as usize;
1833 let torn_cursor =
1834 align_transport(TRANSPORT_HEADER_SIZE + TRANSPORT_RECORD_HEADER_SIZE + process_length)
1835 .unwrap();
1836 torn[torn_cursor + 4..torn_cursor + 8].copy_from_slice(&5_u32.to_le_bytes());
1837 torn[torn_cursor + TRANSPORT_RECORD_HEADER_SIZE
1838 ..torn_cursor + TRANSPORT_RECORD_HEADER_SIZE + 5]
1839 .copy_from_slice(b"{nope");
1840 fs::write(&path, torn).unwrap();
1841 assert!(matches!(
1842 build_python_frontend_run(
1843 &obligations.manifest,
1844 &directory,
1845 "run-1",
1846 "now",
1847 0,
1848 &PythonAssertionInventory::empty()
1849 ),
1850 Err(PythonEvidenceError::NoTests)
1851 ));
1852 write_transport(
1853 &path,
1854 &[
1855 json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.11.9","executable":"p","argv":[]}),
1856 ],
1857 0,
1858 );
1859 assert!(matches!(
1860 build_python_frontend_run(
1861 &obligations.manifest,
1862 &directory,
1863 "run-1",
1864 "now",
1865 0,
1866 &PythonAssertionInventory::empty()
1867 ),
1868 Err(PythonEvidenceError::UnsupportedPython(_))
1869 ));
1870 write_transport(
1871 &path,
1872 &[
1873 json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.14.4","executable":"p","argv":[]}),
1874 ],
1875 2,
1876 );
1877 assert!(matches!(
1878 build_python_frontend_run(
1879 &obligations.manifest,
1880 &directory,
1881 "run-1",
1882 "now",
1883 0,
1884 &PythonAssertionInventory::empty()
1885 ),
1886 Err(PythonEvidenceError::DroppedRecords { count: 2, .. })
1887 ));
1888 fs::remove_dir_all(directory).unwrap();
1889 }
1890}