Skip to main content

supercov_engine/
python_evidence.rs

1//! Validation and normalization of the Python runtime's evidence records.
2//!
3//! Each Supercov-hooked interpreter publishes commit-framed JSON records into
4//! its own mmap: the process identity, every phase it entered (with the exact
5//! test identity that phase stands for), runner outcomes, first-sighting hits,
6//! decision vectors and any measurement limitation the runtime detected. The
7//! one-byte commit marker is written last, so records completed before a hard
8//! kill remain readable while a torn tail stays inert. Rust joins those records
9//! into the shared frontend protocol; the runtime never computes a verdict.
10
11use 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/// Every field the runtime writes is named so `deny_unknown_fields` keeps
53/// the record shape frozen, even where Rust does not read the value yet.
54#[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        /// Where the runner says the test is defined. Absent for adapters or
88        /// synthesised tests that cannot name a file.
89        #[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    /// The first assertion of a call phase: what the context recorded before
103    /// this record is the assertion's evidence too.
104    Assert {
105        ctx: u64,
106    },
107    /// One assertion site a call phase reached, once per site per test.
108    /// `unittest` reports the caller's frame and pytest reports the line its
109    /// rewriter recorded; both are resolved against the syntax inventory, and
110    /// a frame naming no inventoried site witnesses nothing.
111    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)>;
246/// (worker, test, retry) -> [(phase, outcome, xfail)]
247type OutcomesByAttempt = BTreeMap<(String, String, usize), Vec<(String, String, bool)>>;
248/// (worker, test, retry) -> runner that reported the attempt
249type RunnersByAttempt = BTreeMap<(String, String, usize), String>;
250/// (worker, test, retry) -> the source file the runner named for the test
251type TestFilesByAttempt = BTreeMap<(String, String, usize), String>;
252/// (worker, test, retry) -> assertion sites the call phase reached, in the
253/// order they were first seen, as the runtime reported them: (path, line)
254type SitesByAttempt = BTreeMap<(String, String, usize), Vec<(String, usize)>>;
255
256/// The assertion sites Supercov inventoried from source before the run,
257/// indexed so a runtime frame can name one exactly.
258///
259/// Python reports a file and a line for an assertion, while an assertion
260/// anchor is a file, line and column. The inventory supplies the missing
261/// column and validates the frame: one that names no inventoried site
262/// witnesses nothing, so a wrong frame loses a witness rather than inventing
263/// one.
264pub struct PythonAssertionInventory {
265    root: PathBuf,
266    /// (project-relative file, line) -> the sites on that line
267    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                // Every native manifest reports a zero-based byte column and
278                // the report adds one to reach the anchor's own column.
279                .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    /// An inventory with no sites: every frame names nothing, which is what a
292    /// run with no assertion inputs should see.
293    pub fn empty() -> Self {
294        Self {
295            root: PathBuf::new(),
296            columns: BTreeMap::new(),
297        }
298    }
299
300    /// Python reports both forms: a frame's `co_filename` is whatever the
301    /// interpreter loaded, absolute or relative. A path outside the project
302    /// names nothing here.
303    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    /// `file:line:column` when that line holds exactly one inventoried site.
315    /// Two assertions on one line cannot be told apart from a line number, so
316    /// the frame names neither rather than guessing between them.
317    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        // The file is immutable from Supercov's perspective after the wrapped
386        // interpreter has exited. No mutable alias is created while this map
387        // is alive.
388        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    // What each call phase recorded so far, kept until its first assertion
457    // marker moves it to the phase's assertion identity.
458    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            // Payload bytes can exist after a killed writer, but an absent
466            // commit byte makes that frame and every later zeroed frame inert.
467            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                // Only the first marker of a call phase moves anything; a
670                // later one, or one outside a call phase, is inert.
671                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                // Only the call phase witnesses a test's assertions; setup and
705                // teardown assertions belong to no single site under test.
706                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
980/// Join the runtime's evidence directory with the ahead-of-run manifest into
981/// a protocol-conformant frontend run.
982pub 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            // What the test recorded before its first assertion is that
1079            // assertion's evidence, linked when the phase passed outright:
1080            // a failed, skipped or expected-to-fail phase witnessed nothing.
1081            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                // One phase per assertion site the call phase reached, so an
1107                // assertion map can tell the sites apart. The per-test phase
1108                // above keeps carrying the pre-assertion evidence; these are
1109                // witnesses only, and a site the inventory does not know is
1110                // skipped rather than guessed at.
1111                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        // A phase the runtime entered but pytest never reported (the worker
1138        // died inside it) is a failed phase with its evidence kept.
1139        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            // What the runner named, as the project names it. A dotted
1170            // module path or a pytest node id is not a path, so the old
1171            // derivation stays only as a fallback.
1172            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    // Phases with observations whose test never produced any outcome at all
1203    // (for example a worker killed during its first phase).
1204    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            // What the runner named, as the project names it. A dotted
1237            // module path or a pytest node id is not a path, so the old
1238            // derivation stays only as a fallback.
1239            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    // Runtime-detected limitations: obligations the runtime could not map
1310    // become unmeasured, and every limitation ID joins the manifest so the
1311    // declaration and manifest agree.
1312    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            // A code-object mapping failure or missing debug ranges prevents
1328            // every obligation in that source file from being observed. Mark
1329            // the whole file unmeasured instead of presenting its denominator
1330            // as ordinary uncovered code.
1331            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    // Retries are separate raw results so their coverage remains attempt
1371    // exact, but the public lifecycle diagnostic reports logical tests rather
1372    // than inflating the count when a flaky test is rerun.
1373    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    // A path is only absolute in the platform's own spelling: "/project" is a
1484    // relative path on Windows, where an absolute one needs a drive. The
1485    // runtimes report whatever the interpreter loaded, so these fixtures have
1486    // to speak the host's dialect too.
1487    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        // Python reports a file and a line for an assertion, so a line is a
1573        // witness only when the inventory holds exactly one site on it. The
1574        // column reported is zero-based, which is what every native manifest
1575        // reports and what the assertion report adds one to.
1576        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        // Two sites on one line cannot be told apart from a line number, and a
1593        // frame outside the project names nothing. Both lose the witness
1594        // rather than guessing one.
1595        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        // pytest reports the file from the report's location and unittest from
1627        // the test's module; either may be absolute or relative. Both name the
1628        // same project file, and a runner that names none falls back to the
1629        // node id.
1630        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        // The runtime's marker says everything the call phase recorded so far
1654        // ran before an assertion. That evidence carries an assertion phase
1655        // that passed with the test; what ran after the marker, and all of a
1656        // test that failed, stays execution only. A second marker is inert.
1657        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        // A killed writer may have copied part of its next payload without
1829        // publishing the commit byte. The reader must stop at that frame,
1830        // even when the tail would be invalid JSON if treated as committed.
1831        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}