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    // A path is only absolute in the platform's own spelling: "/project" is a
1481    // relative path on Windows, where an absolute one needs a drive. The
1482    // runtimes report whatever the interpreter loaded, so these fixtures have
1483    // to speak the host's dialect too.
1484    fn under(first: &str, rest: &str) -> String {
1485        let mut path = PathBuf::from(if cfg!(windows) {
1486            format!("C:\\{first}")
1487        } else {
1488            format!("/{first}")
1489        });
1490        for part in rest.split('/').filter(|part| !part.is_empty()) {
1491            path.push(part);
1492        }
1493        path.to_string_lossy().into_owned()
1494    }
1495
1496    fn inventory_of(root: &str, sites: &[(&str, usize, usize)]) -> PythonAssertionInventory {
1497        use crate::assertion_map::{Anchor, Files, Inputs, InventorySite};
1498        PythonAssertionInventory::new(
1499            Path::new(root),
1500            &Inputs {
1501                schema_version: 1,
1502                language: "python".into(),
1503                context_digest: "context".into(),
1504                files: Files::new(),
1505                assertions: sites
1506                    .iter()
1507                    .map(|(file, line, column)| InventorySite {
1508                        at: Anchor {
1509                            file: (*file).into(),
1510                            line: *line,
1511                            column: *column,
1512                            text: "assert f(1) == 1".into(),
1513                        },
1514                        operation: "assert".into(),
1515                    })
1516                    .collect(),
1517                limitations: vec![],
1518            },
1519        )
1520    }
1521
1522    fn assertion_sources(run: &PythonFrontendRun) -> Vec<String> {
1523        run.request.raw_results[0]
1524            .phases
1525            .iter()
1526            .filter(|phase| phase.kind == "assertion")
1527            .filter_map(|phase| phase.source.clone())
1528            .collect()
1529    }
1530
1531    fn run_with_sites(
1532        name: &str,
1533        sites: &[serde_json::Value],
1534        outcome_file: Option<&str>,
1535        inventory: &PythonAssertionInventory,
1536    ) -> PythonFrontendRun {
1537        let source = "def f(a):\n    return a\n";
1538        let obligations = build_python_obligations("m.py", source).unwrap();
1539        let mut outcome = json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call","outcome":"passed","xfail":false});
1540        if let Some(file) = outcome_file {
1541            outcome["file"] = json!(file);
1542        }
1543        let mut lines = vec![
1544            json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.14.4","executable":"python","argv":["pytest"]}),
1545            json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call"}),
1546            json!({"t":"assert","ctx":1}),
1547        ];
1548        lines.extend(sites.iter().cloned());
1549        lines.push(outcome);
1550        lines.push(json!({"t":"exit","at":9}));
1551        let directory = temporary(name);
1552        write_transport(&directory.join("main.1.mmap"), &lines, 0);
1553        let run = build_python_frontend_run(
1554            &obligations.manifest,
1555            &directory,
1556            "run-1",
1557            "now",
1558            0,
1559            inventory,
1560        )
1561        .unwrap();
1562        validate_frontend_report_request(&run.declaration, &run.request).unwrap();
1563        fs::remove_dir_all(directory).unwrap();
1564        run
1565    }
1566
1567    #[test]
1568    fn an_assertion_site_becomes_a_located_phase_when_the_inventory_names_one() {
1569        // Python reports a file and a line for an assertion, so a line is a
1570        // witness only when the inventory holds exactly one site on it. The
1571        // column reported is zero-based, which is what every native manifest
1572        // reports and what the assertion report adds one to.
1573        let inventory = inventory_of(&under("project", ""), &[("tests/test_m.py", 6, 5)]);
1574        let run = run_with_sites(
1575            "py-asite-located",
1576            &[json!({"t":"asite","ctx":1,"f":under("project", "tests/test_m.py"),"l":6})],
1577            None,
1578            &inventory,
1579        );
1580        assert!(
1581            assertion_sources(&run).contains(&"tests/test_m.py:6:4".to_string()),
1582            "expected a located assertion phase, got {:?}",
1583            assertion_sources(&run)
1584        );
1585    }
1586
1587    #[test]
1588    fn an_ambiguous_or_foreign_assertion_site_witnesses_nothing() {
1589        // Two sites on one line cannot be told apart from a line number, and a
1590        // frame outside the project names nothing. Both lose the witness
1591        // rather than guessing one.
1592        let ambiguous = inventory_of(
1593            &under("project", ""),
1594            &[("tests/test_m.py", 6, 5), ("tests/test_m.py", 6, 30)],
1595        );
1596        let run = run_with_sites(
1597            "py-asite-ambiguous",
1598            &[json!({"t":"asite","ctx":1,"f":under("project", "tests/test_m.py"),"l":6})],
1599            None,
1600            &ambiguous,
1601        );
1602        assert_eq!(
1603            assertion_sources(&run),
1604            vec!["tests/test_m.py::test_a".to_string()],
1605            "only the per-test assertion phase should remain"
1606        );
1607
1608        let known = inventory_of(&under("project", ""), &[("tests/test_m.py", 6, 5)]);
1609        let outside = run_with_sites(
1610            "py-asite-outside",
1611            &[json!({"t":"asite","ctx":1,"f":under("elsewhere", "tests/test_m.py"),"l":6})],
1612            None,
1613            &known,
1614        );
1615        assert_eq!(
1616            assertion_sources(&outside),
1617            vec!["tests/test_m.py::test_a".to_string()]
1618        );
1619    }
1620
1621    #[test]
1622    fn the_runner_names_the_test_file_in_either_path_form() {
1623        // pytest reports the file from the report's location and unittest from
1624        // the test's module; either may be absolute or relative. Both name the
1625        // same project file, and a runner that names none falls back to the
1626        // node id.
1627        let inventory = inventory_of(&under("project", ""), &[("tests/test_m.py", 6, 5)]);
1628        for reported in [
1629            under("project", "tests/test_m.py"),
1630            "tests/test_m.py".to_owned(),
1631            "./tests/test_m.py".to_owned(),
1632        ] {
1633            let run = run_with_sites("py-asite-file", &[], Some(reported.as_str()), &inventory);
1634            assert_eq!(
1635                run.request.raw_results[0].test_file.as_deref(),
1636                Some("tests/test_m.py"),
1637                "{reported} should resolve to the project path"
1638            );
1639        }
1640        let without = run_with_sites("py-asite-nofile", &[], None, &inventory);
1641        assert_eq!(
1642            without.request.raw_results[0].test_file.as_deref(),
1643            Some("tests/test_m.py"),
1644            "a pytest node id already begins with the file"
1645        );
1646    }
1647
1648    #[test]
1649    fn evidence_before_the_first_assertion_links_to_it_when_the_test_passes() {
1650        // The runtime's marker says everything the call phase recorded so far
1651        // ran before an assertion. That evidence carries an assertion phase
1652        // that passed with the test; what ran after the marker, and all of a
1653        // test that failed, stays execution only. A second marker is inert.
1654        let source = "def f(a):\n    return a\n\n\ndef g(b):\n    return b\n";
1655        let obligations = build_python_obligations("m.py", source).unwrap();
1656        let before = &obligations.plan.statements[0].id;
1657        let after = &obligations.plan.statements[1].id;
1658        let run_with = |name: &str, outcome: &str| {
1659            let lines = [
1660                json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.14.4","executable":"python","argv":["pytest"]}),
1661                json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call"}),
1662                json!({"t":"hit","ctx":1,"id":before}),
1663                json!({"t":"assert","ctx":1}),
1664                json!({"t":"assert","ctx":1}),
1665                json!({"t":"hit","ctx":1,"id":after}),
1666                json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call","outcome":outcome,"xfail":false}),
1667                json!({"t":"exit","at":9}),
1668            ];
1669            let directory = temporary(name);
1670            write_transport(&directory.join("main.1.mmap"), &lines, 0);
1671            let run = build_python_frontend_run(
1672                &obligations.manifest,
1673                &directory,
1674                "run-1",
1675                "now",
1676                0,
1677                &PythonAssertionInventory::empty(),
1678            )
1679            .unwrap();
1680            validate_frontend_report_request(&run.declaration, &run.request).unwrap();
1681            fs::remove_dir_all(directory).unwrap();
1682            run
1683        };
1684        let events_of = |result: &RawTestResult, phase: &str| -> BTreeSet<String> {
1685            result
1686                .runtime
1687                .iter()
1688                .flat_map(|snapshot| snapshot.events.iter())
1689                .filter(|event| event.phase_id.as_deref() == Some(phase))
1690                .map(|event| event.id.clone())
1691                .collect()
1692        };
1693
1694        let run = run_with("asserted-passed", "passed");
1695        let passed = &run.request.raw_results[0];
1696        assert_eq!(passed.test, "tests/test_m.py::test_a");
1697        let assertion = passed
1698            .phases
1699            .iter()
1700            .find(|phase| phase.kind == "assertion")
1701            .expect("the asserting test carries an assertion phase");
1702        assert_eq!(assertion.status.as_deref(), Some("passed"));
1703        assert_eq!(
1704            events_of(passed, &assertion.id),
1705            BTreeSet::from([before.clone()]),
1706            "only what ran before the marker is the assertion's evidence"
1707        );
1708        let test_phase = passed
1709            .phases
1710            .iter()
1711            .find(|phase| phase.kind == "test")
1712            .unwrap();
1713        assert_eq!(
1714            events_of(passed, &test_phase.id),
1715            BTreeSet::from([before.clone(), after.clone()]),
1716            "the test phase keeps everything it ran"
1717        );
1718        assert_eq!(
1719            run.declaration.runners[0].attribution.assertion,
1720            AttributionPrecision::Exact
1721        );
1722
1723        let run = run_with("asserted-failed", "failed");
1724        let failed = &run.request.raw_results[0];
1725        let assertion = failed
1726            .phases
1727            .iter()
1728            .find(|phase| phase.kind == "assertion")
1729            .unwrap();
1730        assert_eq!(
1731            assertion.status.as_deref(),
1732            Some("failed"),
1733            "a failed test's assertion witnessed nothing"
1734        );
1735    }
1736
1737    #[test]
1738    fn joins_phases_outcomes_hits_and_vectors_into_exact_results() {
1739        let source = "def f(a, b):\n    if a and b:\n        return 1\n    return 0\n";
1740        let obligations = build_python_obligations("m.py", source).unwrap();
1741        let decision = &obligations.plan.decisions[0];
1742        let statement = &obligations.plan.statements[0];
1743        let directory = temporary("join");
1744        let lines = [
1745            json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.14.4","executable":"python","argv":["pytest"]}),
1746            json!({"t":"hit","ctx":0,"id":statement.id}),
1747            json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call"}),
1748            json!({"t":"dec","ctx":1,"id":decision.id,"v":"22","o":1}),
1749            json!({"t":"hit","ctx":1,"id":decision.outcome_true}),
1750            json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"setup","outcome":"passed","xfail":false}),
1751            json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"call","outcome":"passed","xfail":false}),
1752            json!({"t":"outcome","worker":"main","test":"tests/test_m.py::test_a","retry":0,"phase":"teardown","outcome":"passed","xfail":false}),
1753            json!({"t":"limitation","id":"python-decision-partially-mapped","reason":"folded","obligation":decision.id}),
1754            json!({"t":"exit","at":9}),
1755        ];
1756        write_transport(&directory.join("main.1.mmap"), &lines, 0);
1757        let run = build_python_frontend_run(
1758            &obligations.manifest,
1759            &directory,
1760            "run-1",
1761            "now",
1762            0,
1763            &PythonAssertionInventory::empty(),
1764        )
1765        .unwrap();
1766        validate_frontend_report_request(&run.declaration, &run.request).unwrap();
1767        assert_eq!(run.tests, 1);
1768        assert_eq!(run.request.raw_results.len(), 2);
1769        let test = &run.request.raw_results[0];
1770        assert_eq!(test.status.as_deref(), Some("passed"));
1771        assert_eq!(test.phases.len(), 3);
1772        assert_eq!(test.runtime.len(), 1);
1773        assert_eq!(test.runtime[0].decisions.len(), 1);
1774        assert!(
1775            test.runtime[0]
1776                .events
1777                .iter()
1778                .all(|event| event.phase_id.is_some())
1779        );
1780        let background = &run.request.raw_results[1];
1781        assert_eq!(background.role, "background");
1782        assert!(run.request.manifest.unmeasured.contains(&decision.id));
1783        assert!(
1784            run.declaration
1785                .structural_limitations
1786                .contains(&"python-decision-partially-mapped".to_owned())
1787        );
1788        fs::remove_dir_all(directory).unwrap();
1789    }
1790
1791    #[test]
1792    fn fails_closed_without_an_interpreter_or_tests() {
1793        let obligations = build_python_obligations("m.py", "x = 1\n").unwrap();
1794        let directory = temporary("empty");
1795        assert!(matches!(
1796            build_python_frontend_run(
1797                &obligations.manifest,
1798                &directory,
1799                "run-1",
1800                "now",
1801                0,
1802                &PythonAssertionInventory::empty()
1803            ),
1804            Err(PythonEvidenceError::NoInterpreter)
1805        ));
1806        let path = directory.join("main.1.mmap");
1807        write_transport(
1808            &path,
1809            &[
1810                json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.14.4","executable":"p","argv":[]}),
1811            ],
1812            0,
1813        );
1814        assert!(matches!(
1815            build_python_frontend_run(
1816                &obligations.manifest,
1817                &directory,
1818                "run-1",
1819                "now",
1820                0,
1821                &PythonAssertionInventory::empty()
1822            ),
1823            Err(PythonEvidenceError::NoTests)
1824        ));
1825        // A killed writer may have copied part of its next payload without
1826        // publishing the commit byte. The reader must stop at that frame,
1827        // even when the tail would be invalid JSON if treated as committed.
1828        let mut torn = fs::read(&path).unwrap();
1829        let process_length = transport_u32(&torn, TRANSPORT_HEADER_SIZE + 4).unwrap() as usize;
1830        let torn_cursor =
1831            align_transport(TRANSPORT_HEADER_SIZE + TRANSPORT_RECORD_HEADER_SIZE + process_length)
1832                .unwrap();
1833        torn[torn_cursor + 4..torn_cursor + 8].copy_from_slice(&5_u32.to_le_bytes());
1834        torn[torn_cursor + TRANSPORT_RECORD_HEADER_SIZE
1835            ..torn_cursor + TRANSPORT_RECORD_HEADER_SIZE + 5]
1836            .copy_from_slice(b"{nope");
1837        fs::write(&path, torn).unwrap();
1838        assert!(matches!(
1839            build_python_frontend_run(
1840                &obligations.manifest,
1841                &directory,
1842                "run-1",
1843                "now",
1844                0,
1845                &PythonAssertionInventory::empty()
1846            ),
1847            Err(PythonEvidenceError::NoTests)
1848        ));
1849        write_transport(
1850            &path,
1851            &[
1852                json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.11.9","executable":"p","argv":[]}),
1853            ],
1854            0,
1855        );
1856        assert!(matches!(
1857            build_python_frontend_run(
1858                &obligations.manifest,
1859                &directory,
1860                "run-1",
1861                "now",
1862                0,
1863                &PythonAssertionInventory::empty()
1864            ),
1865            Err(PythonEvidenceError::UnsupportedPython(_))
1866        ));
1867        write_transport(
1868            &path,
1869            &[
1870                json!({"t":"process","v":1,"run":"run-1","pid":1,"worker":"main","python":"3.14.4","executable":"p","argv":[]}),
1871            ],
1872            2,
1873        );
1874        assert!(matches!(
1875            build_python_frontend_run(
1876                &obligations.manifest,
1877                &directory,
1878                "run-1",
1879                "now",
1880                0,
1881                &PythonAssertionInventory::empty()
1882            ),
1883            Err(PythonEvidenceError::DroppedRecords { count: 2, .. })
1884        ));
1885        fs::remove_dir_all(directory).unwrap();
1886    }
1887}