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