Skip to main content

supercov_engine/
ruby_evidence.rs

1//! Validation and normalization of the Ruby runtime's evidence records.
2//!
3//! Each Supercov-hooked Ruby 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 RUBY_EVIDENCE_VERSION: u32 = 1;
38pub const RUBY_FRONTEND_VERSION: &str = "ruby-coverage-v1";
39pub const RSPEC_RUNNER: &str = "rspec";
40pub const MINITEST_RUNNER: &str = "minitest";
41pub const TEST_UNIT_RUNNER: &str = "test-unit";
42pub const CUCUMBER_RUNNER: &str = "cucumber";
43
44const TRANSPORT_MAGIC: &[u8; 8] = b"SCVRUBY1";
45const TRANSPORT_VERSION: u32 = 1;
46const TRANSPORT_HEADER_SIZE: usize = 64;
47const TRANSPORT_RECORD_HEADER_SIZE: usize = 16;
48const TRANSPORT_MAX_RECORD_SIZE: usize = 4 * 1024 * 1024;
49
50fn default_runner() -> String {
51    RSPEC_RUNNER.into()
52}
53
54/// Every field the runtime writes is named so `deny_unknown_fields` keeps
55/// the record shape frozen, even where Rust does not read the value yet.
56#[derive(Debug, Deserialize)]
57#[allow(dead_code)]
58#[serde(tag = "t", rename_all = "lowercase", deny_unknown_fields)]
59enum Record {
60    Process {
61        v: u32,
62        run: String,
63        pid: u64,
64        worker: String,
65        ruby: String,
66        executable: String,
67        argv: Vec<String>,
68    },
69    Worker {
70        worker: String,
71    },
72    Phase {
73        ctx: u64,
74        at: i64,
75        worker: String,
76        test: String,
77        retry: usize,
78        phase: String,
79    },
80    Outcome {
81        worker: String,
82        test: String,
83        retry: usize,
84        phase: String,
85        outcome: String,
86        xfail: bool,
87        #[serde(default = "default_runner")]
88        runner: String,
89        /// Where the runner says the test is defined. Absent for adapters or
90        /// synthesised methods that cannot name a file.
91        #[serde(default)]
92        file: Option<String>,
93    },
94    Hit {
95        ctx: u64,
96        id: String,
97    },
98    Dec {
99        ctx: u64,
100        id: String,
101        v: String,
102        o: u8,
103    },
104    /// The first assertion of a call phase: what the context recorded before
105    /// this record is the assertion's evidence too.
106    Assert {
107        ctx: u64,
108    },
109    /// One assertion site a call phase reached, once per site per test. Ruby
110    /// backtraces carry no column, so the line is resolved against the syntax
111    /// inventory; a frame that names no inventoried site witnesses nothing.
112    Asite {
113        ctx: u64,
114        f: String,
115        l: usize,
116    },
117    Limitation {
118        id: String,
119        reason: String,
120        #[serde(default)]
121        file: Option<String>,
122        #[serde(default)]
123        obligation: Option<String>,
124    },
125    Exit {
126        at: i64,
127    },
128}
129
130#[derive(Debug)]
131pub enum RubyEvidenceError {
132    Io(String),
133    UnsafeEntry(String),
134    InvalidRecord {
135        file: String,
136        line: usize,
137        reason: String,
138    },
139    InvalidTransport {
140        file: String,
141        reason: String,
142    },
143    DroppedRecords {
144        file: String,
145        count: u64,
146    },
147    RunMismatch {
148        expected: String,
149        actual: String,
150    },
151    UnsupportedVersion(u32),
152    UnknownContext {
153        file: String,
154        line: usize,
155        context: u64,
156    },
157    UnknownObligation(String),
158    InvalidVector {
159        id: String,
160        expected: usize,
161        actual: usize,
162    },
163    NoInterpreter,
164    NoTests,
165    UnsupportedRuby(String),
166}
167
168impl std::fmt::Display for RubyEvidenceError {
169    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
170        match self {
171            Self::Io(reason) => write!(formatter, "could not read Ruby evidence: {reason}"),
172            Self::UnsafeEntry(name) => write!(formatter, "unsafe Ruby evidence entry: {name}"),
173            Self::InvalidRecord { file, line, reason } => {
174                write!(formatter, "invalid Ruby evidence record {file}:{line}: {reason}")
175            }
176            Self::InvalidTransport { file, reason } => {
177                write!(formatter, "invalid Ruby evidence transport {file}: {reason}")
178            }
179            Self::DroppedRecords { file, count } => write!(
180                formatter,
181                "Ruby evidence transport {file} exhausted its bounded capacity and dropped {count} record(s)"
182            ),
183            Self::RunMismatch { expected, actual } => write!(
184                formatter,
185                "Ruby evidence belongs to run {actual}, expected {expected}"
186            ),
187            Self::UnsupportedVersion(version) => {
188                write!(formatter, "unsupported Ruby evidence version {version}")
189            }
190            Self::UnknownContext { file, line, context } => write!(
191                formatter,
192                "Ruby evidence {file}:{line} references undeclared context {context}"
193            ),
194            Self::UnknownObligation(id) => {
195                write!(formatter, "Ruby runtime reported an unknown obligation: {id}")
196            }
197            Self::InvalidVector {
198                id,
199                expected,
200                actual,
201            } => write!(
202                formatter,
203                "Ruby decision {id} reported {actual} condition values, expected {expected}"
204            ),
205            Self::NoInterpreter => formatter.write_str(
206                "no Supercov-hooked Ruby interpreter ran: the test command did not start Ruby 3.3+ with Supercov's RUBYOPT hook (RUBYOPT may be cleared by the command, or the runner is not Ruby)",
207            ),
208            Self::NoTests => formatter.write_str(
209                "the Ruby run produced no test outcomes; Supercov measures Ruby through RSpec, Minitest, test-unit and Cucumber",
210            ),
211            Self::UnsupportedRuby(version) => write!(
212                formatter,
213                "Supercov measures Ruby 3.3 or newer; the test command ran Ruby {version}"
214            ),
215        }
216    }
217}
218
219impl std::error::Error for RubyEvidenceError {}
220
221fn stable_id(prefix: &str, values: &[&str]) -> String {
222    let mut hash = Sha256::new();
223    for value in values {
224        hash.update(value.as_bytes());
225        hash.update([0]);
226    }
227    let digest = hash.finalize();
228    let mut encoded = String::with_capacity(prefix.len() + 25);
229    encoded.push_str(prefix);
230    encoded.push(':');
231    for byte in &digest[..12] {
232        use std::fmt::Write as _;
233        write!(&mut encoded, "{byte:02x}").expect("string formatting");
234    }
235    encoded
236}
237
238#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
239struct Identity {
240    worker: String,
241    test: String,
242    retry: usize,
243    phase: String,
244}
245
246type ObservedVectors = BTreeSet<(Vec<Option<bool>>, bool)>;
247/// (worker, test, retry) -> [(phase, outcome, xfail)]
248type OutcomesByAttempt = BTreeMap<(String, String, usize), Vec<(String, String, bool)>>;
249/// (worker, test, retry) -> runner that reported the attempt
250type RunnersByAttempt = BTreeMap<(String, String, usize), String>;
251/// (worker, test, retry) -> the source file the runner named for the test
252type TestFilesByAttempt = BTreeMap<(String, String, usize), String>;
253/// (worker, test, retry) -> assertion sites the call phase reached, in the
254/// order they were first seen, as the runtime reported them: (path, line)
255type SitesByAttempt = BTreeMap<(String, String, usize), Vec<(String, usize)>>;
256
257/// The assertion sites Supercov inventoried from source before the run,
258/// indexed so a runtime backtrace frame can name one exactly.
259///
260/// Ruby backtraces carry a file and a line but no column, while an assertion
261/// anchor is a file, line and column. The inventory supplies the missing
262/// column. It is also the validator: a frame that names no inventoried site
263/// witnesses nothing, so a runtime that reports the wrong frame loses a
264/// witness rather than inventing one.
265pub struct RubyAssertionInventory {
266    root: PathBuf,
267    /// (project-relative file, line) -> the sites on that line
268    columns: BTreeMap<(String, usize), Vec<usize>>,
269}
270
271impl RubyAssertionInventory {
272    pub fn new(root: &Path, inputs: &crate::assertion_map::Inputs) -> Self {
273        let mut columns = BTreeMap::<(String, usize), Vec<usize>>::new();
274        for site in &inputs.assertions {
275            columns
276                .entry((site.at.file.clone(), site.at.line))
277                .or_default()
278                // Every native manifest reports a zero-based byte column and
279                // the report adds one to reach the anchor's own column.
280                .push(site.at.column.saturating_sub(1));
281        }
282        for sites in columns.values_mut() {
283            sites.sort_unstable();
284            sites.dedup();
285        }
286        Self {
287            root: root.to_path_buf(),
288            columns,
289        }
290    }
291
292    /// An inventory with no sites: every frame names nothing, which is what a
293    /// run with no assertion inputs should see.
294    pub fn empty() -> Self {
295        Self {
296            root: PathBuf::new(),
297            columns: BTreeMap::new(),
298        }
299    }
300
301    /// Ruby reports both forms: a backtrace frame is absolute, while a method
302    /// defined by a file the interpreter loaded by a relative path keeps that
303    /// path. A path outside the project names nothing here.
304    pub fn relative(&self, path: &str) -> Option<String> {
305        let candidate = Path::new(path);
306        let relative = if candidate.is_absolute() {
307            candidate.strip_prefix(&self.root).ok()?
308        } else {
309            candidate.strip_prefix("./").unwrap_or(candidate)
310        };
311        let text = relative.to_string_lossy().replace('\\', "/");
312        (!text.is_empty() && !text.starts_with("../")).then_some(text)
313    }
314
315    /// `file:line:column` when that line holds exactly one inventoried site.
316    /// Two assertions on one line cannot be told apart from a backtrace, so
317    /// the frame names neither rather than guessing between them.
318    pub fn locate(&self, path: &str, line: usize) -> Option<String> {
319        let file = self.relative(path)?;
320        match self.columns.get(&(file.clone(), line))?.as_slice() {
321            [column] => Some(format!("{file}:{line}:{column}")),
322            _ => None,
323        }
324    }
325}
326
327#[derive(Debug, Default)]
328struct Observations {
329    hits: BTreeSet<String>,
330    vectors: BTreeMap<String, ObservedVectors>,
331}
332
333#[derive(Debug, Clone)]
334struct RuntimeLimitation {
335    id: String,
336    reason: String,
337    file: Option<String>,
338    obligation: Option<String>,
339}
340
341#[derive(Debug, Default)]
342struct Evidence {
343    interpreters: usize,
344    ruby_versions: BTreeSet<String>,
345    per_identity: BTreeMap<Identity, Observations>,
346    background: BTreeMap<String, Observations>,
347    outcomes: OutcomesByAttempt,
348    runners: RunnersByAttempt,
349    test_files: TestFilesByAttempt,
350    sites: SitesByAttempt,
351    limitations: Vec<RuntimeLimitation>,
352}
353
354fn read_evidence_directory(directory: &Path, run_id: &str) -> Result<Evidence, RubyEvidenceError> {
355    let mut evidence = Evidence::default();
356    let mut files = match fs::read_dir(directory) {
357        Ok(entries) => entries
358            .collect::<Result<Vec<_>, _>>()
359            .map_err(|error| RubyEvidenceError::Io(error.to_string()))?,
360        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
361        Err(error) => return Err(RubyEvidenceError::Io(error.to_string())),
362    };
363    files.sort_by_key(|entry| entry.file_name());
364    for entry in files {
365        let name = entry
366            .file_name()
367            .into_string()
368            .map_err(|_| RubyEvidenceError::UnsafeEntry("<non-utf8>".into()))?;
369        if Path::new(&name)
370            .components()
371            .any(|component| !matches!(component, Component::Normal(_)))
372            || !name.ends_with(".mmap")
373        {
374            return Err(RubyEvidenceError::UnsafeEntry(name));
375        }
376        let metadata = fs::symlink_metadata(entry.path())
377            .map_err(|error| RubyEvidenceError::Io(error.to_string()))?;
378        if !metadata.file_type().is_file() {
379            return Err(RubyEvidenceError::UnsafeEntry(name));
380        }
381        let file =
382            File::open(entry.path()).map_err(|error| RubyEvidenceError::Io(error.to_string()))?;
383        // The file is immutable from Supercov's perspective after the wrapped
384        // interpreter has exited. No mutable alias is created while this map
385        // is alive.
386        let contents = unsafe { MmapOptions::new().map(&file) }
387            .map_err(|error| RubyEvidenceError::Io(error.to_string()))?;
388        read_evidence_file(&name, &contents, run_id, &mut evidence)?;
389    }
390    Ok(evidence)
391}
392
393fn transport_u32(bytes: &[u8], offset: usize) -> Option<u32> {
394    bytes
395        .get(offset..offset + 4)
396        .and_then(|value| value.try_into().ok())
397        .map(u32::from_le_bytes)
398}
399
400fn transport_u64(bytes: &[u8], offset: usize) -> Option<u64> {
401    bytes
402        .get(offset..offset + 8)
403        .and_then(|value| value.try_into().ok())
404        .map(u64::from_le_bytes)
405}
406
407fn transport_checksum(payload: &[u8]) -> u32 {
408    payload.iter().fold(0x811c_9dc5_u32, |value, byte| {
409        (value ^ u32::from(*byte)).wrapping_mul(0x0100_0193)
410    })
411}
412
413fn align_transport(value: usize) -> Option<usize> {
414    value.checked_add(7).map(|value| value & !7)
415}
416
417fn read_evidence_file(
418    name: &str,
419    contents: &Mmap,
420    run_id: &str,
421    evidence: &mut Evidence,
422) -> Result<(), RubyEvidenceError> {
423    let invalid_transport = |reason: &str| RubyEvidenceError::InvalidTransport {
424        file: name.into(),
425        reason: reason.into(),
426    };
427    if contents.len() < TRANSPORT_HEADER_SIZE
428        || contents.get(..8) != Some(TRANSPORT_MAGIC.as_slice())
429        || transport_u32(contents, 8) != Some(TRANSPORT_VERSION)
430        || transport_u32(contents, 12) != Some(TRANSPORT_HEADER_SIZE as u32)
431    {
432        return Err(invalid_transport("header or version does not match"));
433    }
434    let declared_capacity =
435        transport_u64(contents, 16).ok_or_else(|| invalid_transport("capacity is missing"))?;
436    if declared_capacity < TRANSPORT_HEADER_SIZE as u64 || declared_capacity > contents.len() as u64
437    {
438        return Err(invalid_transport(
439            "declared capacity is outside the mapped file",
440        ));
441    }
442    let dropped =
443        transport_u64(contents, 24).ok_or_else(|| invalid_transport("drop counter is missing"))?;
444    if dropped != 0 {
445        return Err(RubyEvidenceError::DroppedRecords {
446            file: name.into(),
447            count: dropped,
448        });
449    }
450    let transport_pid = transport_u64(contents, 32)
451        .filter(|pid| *pid != 0)
452        .ok_or_else(|| invalid_transport("process id is missing"))?;
453    let mut contexts = BTreeMap::<u64, Identity>::new();
454    // What each call phase recorded so far, kept until its first assertion
455    // marker moves it to the phase's assertion identity.
456    let mut before_assertion = BTreeMap::<u64, Observations>::new();
457    let mut process_worker: Option<String> = None;
458    let mut process_started = false;
459    let mut process_reported = false;
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| RubyEvidenceError::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 =
509            serde_json::from_slice(payload).map_err(|error| RubyEvidenceError::InvalidRecord {
510                file: name.into(),
511                line: line_number,
512                reason: error.to_string(),
513            })?;
514        match record {
515            Record::Process {
516                v,
517                run,
518                pid,
519                worker,
520                ruby,
521                ..
522            } => {
523                if v != RUBY_EVIDENCE_VERSION {
524                    return Err(RubyEvidenceError::UnsupportedVersion(v));
525                }
526                if run != run_id {
527                    return Err(RubyEvidenceError::RunMismatch {
528                        expected: run_id.into(),
529                        actual: run,
530                    });
531                }
532                if pid != transport_pid {
533                    return Err(invalid("process record does not match the transport owner"));
534                }
535                let supported = ruby
536                    .split('.')
537                    .take(2)
538                    .map(|part| part.parse::<u32>().ok())
539                    .collect::<Option<Vec<_>>>()
540                    .is_some_and(|parts| parts.len() == 2 && (parts[0], parts[1]) >= (3, 3));
541                if !supported {
542                    return Err(RubyEvidenceError::UnsupportedRuby(ruby));
543                }
544                evidence.interpreters += 1;
545                evidence.ruby_versions.insert(ruby);
546                process_started = true;
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 test 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!(
600                    runner.as_str(),
601                    RSPEC_RUNNER | MINITEST_RUNNER | TEST_UNIT_RUNNER | CUCUMBER_RUNNER
602                ) {
603                    return Err(invalid("unknown Ruby test runner"));
604                }
605                let key = (worker, test, retry);
606                if let Some(previous) = evidence.runners.get(&key)
607                    && previous != &runner
608                {
609                    return Err(invalid("one attempt was reported by two runners"));
610                }
611                evidence.runners.insert(key.clone(), runner);
612                if let Some(file) = file.filter(|path| !path.is_empty()) {
613                    evidence.test_files.entry(key.clone()).or_insert(file);
614                }
615                evidence
616                    .outcomes
617                    .entry(key)
618                    .or_default()
619                    .push((phase, outcome, xfail));
620            }
621            Record::Hit { ctx, id } => {
622                if let Some(before) = before_assertion.get_mut(&ctx) {
623                    before.hits.insert(id.clone());
624                }
625                observations(
626                    evidence,
627                    &contexts,
628                    process_worker.as_deref(),
629                    ctx,
630                    name,
631                    line_number,
632                )?
633                .hits
634                .insert(id);
635            }
636            Record::Dec { ctx, id, v, o } => {
637                if v.is_empty() || !v.bytes().all(|digit| matches!(digit, b'0' | b'1' | b'2')) {
638                    return Err(invalid("decision vector digits must be 0, 1 or 2"));
639                }
640                if o > 1 {
641                    return Err(invalid("decision outcome must be 0 or 1"));
642                }
643                let values = v
644                    .bytes()
645                    .map(|digit| match digit {
646                        b'0' => None,
647                        b'1' => Some(false),
648                        _ => Some(true),
649                    })
650                    .collect::<Vec<_>>();
651                if let Some(before) = before_assertion.get_mut(&ctx) {
652                    before
653                        .vectors
654                        .entry(id.clone())
655                        .or_default()
656                        .insert((values.clone(), o == 1));
657                }
658                observations(
659                    evidence,
660                    &contexts,
661                    process_worker.as_deref(),
662                    ctx,
663                    name,
664                    line_number,
665                )?
666                .vectors
667                .entry(id)
668                .or_default()
669                .insert((values, o == 1));
670            }
671            Record::Assert { ctx } => {
672                // Only the first marker of a call phase moves anything; a
673                // later one, or one outside a call phase, is inert.
674                if let Some(before) = before_assertion.remove(&ctx) {
675                    let identity = contexts
676                        .get(&ctx)
677                        .ok_or(RubyEvidenceError::UnknownContext {
678                            file: name.into(),
679                            line: line_number,
680                            context: ctx,
681                        })?;
682                    let asserted = evidence
683                        .per_identity
684                        .entry(Identity {
685                            phase: "assertion".into(),
686                            ..identity.clone()
687                        })
688                        .or_default();
689                    asserted.hits.extend(before.hits);
690                    for (id, vectors) in before.vectors {
691                        asserted.vectors.entry(id).or_default().extend(vectors);
692                    }
693                }
694            }
695            Record::Asite { ctx, f, l } => {
696                if f.is_empty() || l == 0 {
697                    return Err(invalid("assertion site needs a file and a line"));
698                }
699                let identity = contexts
700                    .get(&ctx)
701                    .ok_or(RubyEvidenceError::UnknownContext {
702                        file: name.into(),
703                        line: line_number,
704                        context: ctx,
705                    })?;
706                // Only the call phase witnesses a test's assertions; setup and
707                // teardown assertions belong to no single site under test.
708                if identity.phase == "call" {
709                    let key = (
710                        identity.worker.clone(),
711                        identity.test.clone(),
712                        identity.retry,
713                    );
714                    let sites = evidence.sites.entry(key).or_default();
715                    let site = (f, l);
716                    if !sites.contains(&site) {
717                        sites.push(site);
718                    }
719                }
720            }
721            Record::Limitation {
722                id,
723                reason,
724                file,
725                obligation,
726            } => evidence.limitations.push(RuntimeLimitation {
727                id,
728                reason,
729                file,
730                obligation,
731            }),
732            Record::Exit { .. } => process_reported = true,
733        }
734        cursor = next_cursor;
735    }
736    // Ruby reads Coverage's own result in the `at_exit` that writes this
737    // record, so a process that never wrote one -- killed with a signal it
738    // could not catch, or left through `exit!` -- took everything it had
739    // observed since its last test boundary with it. Nothing outside that
740    // process can recover the counters or say which lines they were, so the
741    // run declares the gap instead of reporting those lines as merely
742    // uncovered. A declared limitation blocks completeness, which is the
743    // honest answer: coverage measured here is a floor, not a total.
744    if process_started && !process_reported {
745        evidence.limitations.push(RuntimeLimitation {
746            id: "ruby-process-did-not-report".into(),
747            reason: format!(
748                "an interpreter process (pid {transport_pid}) ended without reporting, so line, branch and method observations it made after its last test boundary are missing; a process killed with SIGKILL, or one that left through exit!, cannot flush them"
749            ),
750            file: None,
751            obligation: None,
752        });
753    }
754    Ok(())
755}
756
757fn observations<'a>(
758    evidence: &'a mut Evidence,
759    contexts: &BTreeMap<u64, Identity>,
760    process_worker: Option<&str>,
761    context: u64,
762    file: &str,
763    line: usize,
764) -> Result<&'a mut Observations, RubyEvidenceError> {
765    if context == 0 {
766        return Ok(evidence
767            .background
768            .entry(process_worker.unwrap_or("main").to_owned())
769            .or_default());
770    }
771    let identity = contexts
772        .get(&context)
773        .ok_or(RubyEvidenceError::UnknownContext {
774            file: file.into(),
775            line,
776            context,
777        })?;
778    Ok(evidence.per_identity.entry(identity.clone()).or_default())
779}
780
781pub fn ruby_coverage_model() -> CoverageModelDeclaration {
782    CoverageModelDeclaration {
783        language: "ruby".into(),
784        variant: "ruby-owned-coverage".into(),
785        name: "ruby-coverage-probes-v1".into(),
786        completeness_meaning: "Every statement, method, decision vector, loop, short-circuit, case and rescue obligation Supercov derived from the source was observed through Ruby's Coverage module or a load-time probe with exact test identity; the declared limitations remain separate.".into(),
787        measured: vec![
788            "executable statements proven by Ruby's line coverage, or a probe when a line holds several".into(),
789            "method definitions entered (Ruby's method coverage)".into(),
790            "if/unless/elsif/ternary/while/until decisions with masking MC/DC vectors from operand probes".into(),
791            "while, until, for and iterator-block (each, map, times, ...) zero-versus-entered iteration".into(),
792            "&&, ||, ||= and &&= short-circuit alternatives".into(),
793            "case/when and case/in clause selection, safe navigation".into(),
794            "begin/rescue completion, handler selection and exception propagation".into(),
795            "RSpec, Minitest, test-unit and Cucumber worker, test and setup/call/teardown phase identity".into(),
796            "evidence a test recorded before its first assertion, linked to that assertion when the test passes".into(),
797        ],
798        not_measured: vec![
799            "blocks and lambdas as function entry points (they are statements inside their methods)".into(),
800            "blocks passed by reference (map(&:to_s)) as loops: they have no block body to observe".into(),
801            "line, branch and method observations made while test phases overlapped in threads (attributed to the run; probe observations stay per test)".into(),
802            "causal linkage to individual actions, or to any assertion after a test's first".into(),
803            "code compiled from strings at runtime (eval, instance_eval with strings)".into(),
804            "child coverage outside Process.spawn, Kernel#spawn, Kernel#system and fork".into(),
805            "all input values, semantic partitions, paths, or concurrency interleavings".into(),
806            "mutation score or assertion fault-detection strength".into(),
807        ],
808    }
809}
810
811fn phase_id(run: &str, identity: &Identity) -> String {
812    stable_id(
813        "ruby-phase",
814        &[
815            run,
816            &identity.worker,
817            &identity.test,
818            &identity.retry.to_string(),
819            &identity.phase,
820        ],
821    )
822}
823
824fn scope(run: &str, worker: &str, test: &str, retry: usize) -> ExecutionScope {
825    ExecutionScope {
826        version: 1,
827        run_id: run.into(),
828        worker_id: worker.into(),
829        test_id: test.into(),
830        test_key: stable_id("ruby-test", &[worker, test]),
831        retry,
832        attempt_id: stable_id("ruby-attempt", &[run, worker, test, &retry.to_string()]),
833    }
834}
835
836struct ManifestIndex<'a> {
837    points: BTreeSet<&'a str>,
838    alternatives: BTreeSet<&'a str>,
839    decisions: BTreeMap<&'a str, &'a DecisionMeta>,
840    lines: BTreeMap<&'a str, (String, usize)>,
841    sources: BTreeMap<&'a str, &'a str>,
842}
843
844impl<'a> ManifestIndex<'a> {
845    fn new(manifest: &'a CoverageManifest) -> Self {
846        let mut lines = BTreeMap::new();
847        let mut sources = BTreeMap::new();
848        for point in &manifest.points {
849            lines.insert(point.id.as_str(), (point.file.clone(), point.line));
850            sources.insert(point.id.as_str(), point.source.as_str());
851        }
852        for decision in &manifest.decisions {
853            lines.insert(decision.id.as_str(), (decision.file.clone(), decision.line));
854            sources.insert(decision.id.as_str(), decision.source.as_str());
855        }
856        for branch in &manifest.branches {
857            lines.insert(branch.id.as_str(), (branch.file.clone(), branch.line));
858            sources.insert(branch.id.as_str(), branch.source.as_str());
859        }
860        Self {
861            points: manifest
862                .points
863                .iter()
864                .map(|point| point.id.as_str())
865                .collect(),
866            alternatives: manifest
867                .branches
868                .iter()
869                .flat_map(|branch| branch.alternatives.iter().map(|alt| alt.id.as_str()))
870                .collect(),
871            decisions: manifest
872                .decisions
873                .iter()
874                .map(|decision| (decision.id.as_str(), decision))
875                .collect(),
876            lines,
877            sources,
878        }
879    }
880}
881
882fn snapshot(
883    index: &ManifestIndex<'_>,
884    observations: &Observations,
885    phase: &str,
886) -> Result<RuntimeSnapshot, RubyEvidenceError> {
887    let mut hits = BTreeSet::new();
888    for id in &observations.hits {
889        if !index.points.contains(id.as_str()) && !index.alternatives.contains(id.as_str()) {
890            return Err(RubyEvidenceError::UnknownObligation(id.clone()));
891        }
892        hits.insert(id.clone());
893    }
894    let mut decisions = Vec::new();
895    let mut events = Vec::new();
896    let mut clock = 1;
897    for id in &hits {
898        events.push(RuntimeEvent {
899            event_type: "hit".into(),
900            id: id.clone(),
901            vector: None,
902            timestamp_ms: clock,
903            phase_id: Some(phase.into()),
904            statement_id: None,
905            environment: "ruby".into(),
906        });
907        clock += 1;
908    }
909    for (id, vectors) in &observations.vectors {
910        let Some(meta) = index.decisions.get(id.as_str()) else {
911            return Err(RubyEvidenceError::UnknownObligation(id.clone()));
912        };
913        let mut observed = Vec::new();
914        for (values, outcome) in vectors {
915            if values.len() != meta.conditions.len() {
916                return Err(RubyEvidenceError::InvalidVector {
917                    id: id.clone(),
918                    expected: meta.conditions.len(),
919                    actual: values.len(),
920                });
921            }
922            let vector = McdcVector {
923                values: values.clone(),
924                outcome: *outcome,
925            };
926            events.push(RuntimeEvent {
927                event_type: "decision".into(),
928                id: id.clone(),
929                vector: Some(vector.clone()),
930                timestamp_ms: clock,
931                phase_id: Some(phase.into()),
932                statement_id: None,
933                environment: "ruby".into(),
934            });
935            clock += 1;
936            observed.push(vector);
937        }
938        decisions.push(DecisionSnapshot {
939            meta: (*meta).clone(),
940            vectors: observed,
941        });
942    }
943    Ok(RuntimeSnapshot {
944        decisions,
945        hits: hits.into_iter().collect(),
946        events,
947        logicals: Vec::new(),
948    })
949}
950
951fn attempt_status(outcomes: &[(String, String, bool)]) -> String {
952    if outcomes
953        .iter()
954        .any(|(_, outcome, _)| matches!(outcome.as_str(), "failed" | "rerun" | "error"))
955    {
956        "failed"
957    } else if outcomes.iter().any(|(_, outcome, _)| outcome == "skipped") {
958        "skipped"
959    } else {
960        "passed"
961    }
962    .into()
963}
964
965#[derive(Debug, Clone, PartialEq)]
966pub struct RubyFrontendRun {
967    pub declaration: FrontendRunDeclaration,
968    pub request: CoverageReportRequest,
969    pub tests: usize,
970    pub interpreters: usize,
971    pub ruby_versions: Vec<String>,
972}
973
974impl RubyFrontendRun {
975    pub fn archive_entries(&self) -> Result<Vec<EvidenceArchiveEntry>, serde_json::Error> {
976        let model = PersistedCoverageModel::from_declaration(
977            self.request
978                .coverage_model
979                .as_ref()
980                .expect("Ruby frontend always declares a coverage model"),
981        )
982        .expect("Ruby coverage model is contract-valid");
983        let mut entries = vec![
984            EvidenceArchiveEntry {
985                path: "coverage-model.json".into(),
986                contents: serde_json::to_vec(&model)?,
987            },
988            EvidenceArchiveEntry {
989                path: "frontend.json".into(),
990                contents: serde_json::to_vec(&self.declaration)?,
991            },
992            EvidenceArchiveEntry {
993                path: "manifest.json".into(),
994                contents: serde_json::to_vec(&self.request.manifest)?,
995            },
996        ];
997        for (index, result) in self.request.raw_results.iter().enumerate() {
998            entries.push(EvidenceArchiveEntry {
999                path: format!("results/{index:08}/mcdc.json"),
1000                contents: serde_json::to_vec(result)?,
1001            });
1002        }
1003        Ok(entries)
1004    }
1005}
1006
1007/// Join the runtime's evidence directory with the ahead-of-run manifest into
1008/// a protocol-conformant frontend run.
1009pub fn build_ruby_frontend_run(
1010    manifest: &CoverageManifest,
1011    evidence_directory: &Path,
1012    run_id: &str,
1013    generated_at: &str,
1014    test_exit_code: i32,
1015    assertions: &RubyAssertionInventory,
1016) -> Result<RubyFrontendRun, RubyEvidenceError> {
1017    let evidence = read_evidence_directory(evidence_directory, run_id)?;
1018    if evidence.interpreters == 0 {
1019        return Err(RubyEvidenceError::NoInterpreter);
1020    }
1021    if evidence.outcomes.is_empty() {
1022        return Err(RubyEvidenceError::NoTests);
1023    }
1024    let Evidence {
1025        interpreters,
1026        ruby_versions,
1027        per_identity,
1028        background,
1029        outcomes,
1030        runners,
1031        test_files,
1032        sites,
1033        limitations,
1034    } = evidence;
1035    let mut manifest = manifest.clone();
1036    let index = ManifestIndex::new(&manifest);
1037
1038    let mut raw_results = Vec::new();
1039    let mut observed_runners = BTreeSet::new();
1040    let mut identities_by_attempt =
1041        BTreeMap::<(String, String, usize), Vec<(&Identity, &Observations)>>::new();
1042    for (identity, observations) in &per_identity {
1043        identities_by_attempt
1044            .entry((
1045                identity.worker.clone(),
1046                identity.test.clone(),
1047                identity.retry,
1048            ))
1049            .or_default()
1050            .push((identity, observations));
1051    }
1052    for ((worker, test, retry), mut outcomes) in outcomes {
1053        let runner = runners
1054            .get(&(worker.clone(), test.clone(), retry))
1055            .cloned()
1056            .unwrap_or_else(default_runner);
1057        let attempt_identities = identities_by_attempt
1058            .remove(&(worker.clone(), test.clone(), retry))
1059            .unwrap_or_default();
1060        observed_runners.insert(runner.clone());
1061        outcomes.sort_by_key(|(phase, _, _)| match phase.as_str() {
1062            "setup" => 0,
1063            "call" => 1,
1064            _ => 2,
1065        });
1066        let mut phases = Vec::new();
1067        let mut runtime = Vec::new();
1068        let mut observed_phases = BTreeSet::new();
1069        for (position, (phase_name, outcome, xfail)) in outcomes.iter().enumerate() {
1070            observed_phases.insert(phase_name.clone());
1071            let identity = Identity {
1072                worker: worker.clone(),
1073                test: test.clone(),
1074                retry,
1075                phase: phase_name.clone(),
1076            };
1077            let id = phase_id(run_id, &identity);
1078            phases.push(CoveragePhase {
1079                id: id.clone(),
1080                kind: match phase_name.as_str() {
1081                    "call" => "test",
1082                    value => value,
1083                }
1084                .into(),
1085                operation: format!("{runner} {phase_name}"),
1086                source: Some(test.clone()),
1087                caused_by_phase_id: None,
1088                started_at_ms: position as i64 * 2 + 1,
1089                ended_at_ms: Some(position as i64 * 2 + 2),
1090                status: Some(match outcome.as_str() {
1091                    "rerun" | "error" => "failed".into(),
1092                    value => value.into(),
1093                }),
1094                error: None,
1095            });
1096            if let Some((_, observations)) = attempt_identities
1097                .iter()
1098                .find(|(candidate, _)| candidate.phase == phase_name.as_str())
1099            {
1100                runtime.push(snapshot(&index, observations, &id)?);
1101            }
1102            if phase_name != "call" {
1103                continue;
1104            }
1105            // What the test recorded before its first assertion is that
1106            // assertion's evidence, linked when the phase passed outright:
1107            // a failed, skipped or expected-to-fail phase witnessed nothing.
1108            if let Some((identity, observations)) = attempt_identities
1109                .iter()
1110                .find(|(candidate, _)| candidate.phase == "assertion")
1111            {
1112                observed_phases.insert("assertion".to_owned());
1113                let id = phase_id(run_id, identity);
1114                phases.push(CoveragePhase {
1115                    id: id.clone(),
1116                    kind: "assertion".into(),
1117                    operation: format!("{runner} assertion"),
1118                    source: Some(test.clone()),
1119                    caused_by_phase_id: None,
1120                    started_at_ms: position as i64 * 2 + 1,
1121                    ended_at_ms: Some(position as i64 * 2 + 2),
1122                    status: Some(
1123                        if outcome == "passed" && !*xfail {
1124                            "passed"
1125                        } else {
1126                            "failed"
1127                        }
1128                        .into(),
1129                    ),
1130                    error: None,
1131                });
1132                runtime.push(snapshot(&index, observations, &id)?);
1133                // One phase per assertion site the call phase reached, so an
1134                // assertion map can tell the sites apart. The per-test phase
1135                // above keeps carrying the pre-assertion evidence; these are
1136                // witnesses only, and a site the inventory does not know is
1137                // skipped rather than guessed at.
1138                let attempt = (worker.clone(), test.clone(), retry);
1139                for (path, line) in sites.get(&attempt).into_iter().flatten() {
1140                    let Some(location) = assertions.locate(path, *line) else {
1141                        continue;
1142                    };
1143                    phases.push(CoveragePhase {
1144                        id: stable_id("ruby-assertion", &[run_id, &id, &location]),
1145                        kind: "assertion".into(),
1146                        operation: format!("{runner} assertion at {location}"),
1147                        source: Some(location),
1148                        caused_by_phase_id: Some(id.clone()),
1149                        started_at_ms: position as i64 * 2 + 1,
1150                        ended_at_ms: Some(position as i64 * 2 + 2),
1151                        status: Some(
1152                            if outcome == "passed" && !*xfail {
1153                                "passed"
1154                            } else {
1155                                "failed"
1156                            }
1157                            .into(),
1158                        ),
1159                        error: None,
1160                    });
1161                }
1162            }
1163        }
1164        // A phase the runtime entered but the runner never reported (the worker
1165        // died inside it) is a failed phase with its evidence kept.
1166        for (identity, observations) in attempt_identities {
1167            if !observed_phases.contains(&identity.phase) {
1168                let id = phase_id(run_id, identity);
1169                phases.push(CoveragePhase {
1170                    id: id.clone(),
1171                    kind: match identity.phase.as_str() {
1172                        "call" => "test",
1173                        value => value,
1174                    }
1175                    .into(),
1176                    operation: format!("{runner} {}", identity.phase),
1177                    source: Some(test.clone()),
1178                    caused_by_phase_id: None,
1179                    started_at_ms: phases.len() as i64 * 2 + 1,
1180                    ended_at_ms: None,
1181                    status: Some("failed".into()),
1182                    error: Some("the phase started but the runner reported no outcome".into()),
1183                });
1184                runtime.push(snapshot(&index, observations, &id)?);
1185            }
1186        }
1187        let status = if phases.iter().any(|phase| phase.error.is_some()) {
1188            "failed".into()
1189        } else {
1190            attempt_status(&outcomes)
1191        };
1192        raw_results.push(RawTestResult {
1193            test_id: Some(test.clone()),
1194            scope: Some(scope(run_id, &worker, &test, retry)),
1195            test: test.clone(),
1196            // What the runner named, as the project names it. A runner
1197            // identity is not a path, so the old derivation stays only as a
1198            // fallback for an adapter that cannot name the file.
1199            test_file: test_files
1200                .get(&(worker.clone(), test.clone(), retry))
1201                .and_then(|path| assertions.relative(path))
1202                .or_else(|| test.split("::").next().map(str::to_owned)),
1203            title: test.rsplit("::").next().map(str::to_owned),
1204            retry: Some(retry),
1205            status: Some(status),
1206            expected_status: Some(
1207                if outcomes.iter().any(|(_, _, xfail)| *xfail) {
1208                    "failed"
1209                } else {
1210                    "passed"
1211                }
1212                .into(),
1213            ),
1214            flaky: false,
1215            provenance: TestProvenance {
1216                runner: runner.clone(),
1217                kind: "unit".into(),
1218                project: None,
1219                source: RUBY_FRONTEND_VERSION.into(),
1220            },
1221            role: "test".into(),
1222            // What a Ruby test is recorded as reaching is its own and is not
1223            // all of it. The runtime asks Ruby's Coverage for one-shot lines,
1224            // which report a line the first time it executes in the process
1225            // and never again -- so the first test to reach a line is credited
1226            // with it and every later test that runs the same line is recorded
1227            // as having reached nothing there. Cheap to collect, and it makes
1228            // a test's hits a lower bound rather than a description.
1229            attribution: crate::coverage_report::ATTRIBUTION_PARTIAL.into(),
1230            phases,
1231            runtime,
1232            browser: Vec::new(),
1233            server: Vec::new(),
1234        });
1235    }
1236    // Phases with observations whose test never produced any outcome at all
1237    // (for example a worker killed during its first phase).
1238    let default_observed = observed_runners
1239        .iter()
1240        .next()
1241        .cloned()
1242        .unwrap_or_else(default_runner);
1243    for ((worker, test, retry), identities) in identities_by_attempt {
1244        let runner = default_observed.clone();
1245        let mut phases = Vec::new();
1246        let mut runtime = Vec::new();
1247        for (position, (identity, observations)) in identities.iter().enumerate() {
1248            let id = phase_id(run_id, identity);
1249            phases.push(CoveragePhase {
1250                id: id.clone(),
1251                kind: match identity.phase.as_str() {
1252                    "call" => "test",
1253                    value => value,
1254                }
1255                .into(),
1256                operation: format!("{runner} {}", identity.phase),
1257                source: Some(test.clone()),
1258                caused_by_phase_id: None,
1259                started_at_ms: position as i64 * 2 + 1,
1260                ended_at_ms: None,
1261                status: Some("failed".into()),
1262                error: Some("the phase started but the runner reported no outcome".into()),
1263            });
1264            runtime.push(snapshot(&index, observations, &id)?);
1265        }
1266        raw_results.push(RawTestResult {
1267            test_id: Some(test.clone()),
1268            scope: Some(scope(run_id, &worker, &test, retry)),
1269            test: test.clone(),
1270            // What the runner named, as the project names it. A runner
1271            // identity is not a path, so the old derivation stays only as a
1272            // fallback for an adapter that cannot name the file.
1273            test_file: test_files
1274                .get(&(worker.clone(), test.clone(), retry))
1275                .and_then(|path| assertions.relative(path))
1276                .or_else(|| test.split("::").next().map(str::to_owned)),
1277            title: test.rsplit("::").next().map(str::to_owned),
1278            retry: Some(retry),
1279            status: Some("failed".into()),
1280            expected_status: Some("passed".into()),
1281            flaky: false,
1282            provenance: TestProvenance {
1283                runner: runner.clone(),
1284                kind: "unit".into(),
1285                project: None,
1286                source: RUBY_FRONTEND_VERSION.into(),
1287            },
1288            role: "test".into(),
1289            // What a Ruby test is recorded as reaching is its own and is not
1290            // all of it. The runtime asks Ruby's Coverage for one-shot lines,
1291            // which report a line the first time it executes in the process
1292            // and never again -- so the first test to reach a line is credited
1293            // with it and every later test that runs the same line is recorded
1294            // as having reached nothing there. Cheap to collect, and it makes
1295            // a test's hits a lower bound rather than a description.
1296            attribution: crate::coverage_report::ATTRIBUTION_PARTIAL.into(),
1297            phases,
1298            runtime,
1299            browser: Vec::new(),
1300            server: Vec::new(),
1301        });
1302    }
1303    for (worker, observations) in &background {
1304        if observations.hits.is_empty() && observations.vectors.is_empty() {
1305            continue;
1306        }
1307        let test = format!("__supercov_background__:{worker}");
1308        let identity = Identity {
1309            worker: worker.clone(),
1310            test: test.clone(),
1311            retry: 0,
1312            phase: "background".into(),
1313        };
1314        let phase = phase_id(run_id, &identity);
1315        raw_results.push(RawTestResult {
1316            test_id: Some(test.clone()),
1317            scope: Some(scope(run_id, worker, &test, 0)),
1318            test: "Ruby load and background execution".into(),
1319            test_file: None,
1320            title: None,
1321            retry: Some(0),
1322            status: Some("unknown".into()),
1323            expected_status: None,
1324            flaky: false,
1325            provenance: TestProvenance {
1326                runner: default_observed.clone(),
1327                kind: "unit".into(),
1328                project: None,
1329                source: RUBY_FRONTEND_VERSION.into(),
1330            },
1331            role: "background".into(),
1332            attribution: crate::coverage_report::ATTRIBUTION_EXACT.into(),
1333            phases: vec![CoveragePhase {
1334                id: phase.clone(),
1335                kind: "background".into(),
1336                operation: "Ruby load background".into(),
1337                source: None,
1338                caused_by_phase_id: None,
1339                started_at_ms: 0,
1340                ended_at_ms: Some(0),
1341                status: Some("passed".into()),
1342                error: None,
1343            }],
1344            runtime: vec![snapshot(&index, observations, &phase)?],
1345            browser: Vec::new(),
1346            server: Vec::new(),
1347        });
1348    }
1349
1350    // Runtime-detected limitations: obligations the runtime could not map
1351    // become unmeasured, and every limitation ID joins the manifest so the
1352    // declaration and manifest agree.
1353    let mut limitation_ids = manifest
1354        .limitations
1355        .iter()
1356        .filter_map(|item| item.get("id").and_then(serde_json::Value::as_str))
1357        .map(str::to_owned)
1358        .collect::<BTreeSet<_>>();
1359    let mut unmeasured = manifest.unmeasured.iter().cloned().collect::<BTreeSet<_>>();
1360    let mut new_limitations = Vec::new();
1361    for limitation in &limitations {
1362        if let Some(obligation) = &limitation.obligation {
1363            if !index.lines.contains_key(obligation.as_str()) {
1364                return Err(RubyEvidenceError::UnknownObligation(obligation.clone()));
1365            }
1366            unmeasured.insert(obligation.clone());
1367        } else if let Some(file) = &limitation.file {
1368            // A code-object mapping failure or missing debug ranges prevents
1369            // every obligation in that source file from being observed. Mark
1370            // the whole file unmeasured instead of presenting its denominator
1371            // as ordinary uncovered code.
1372            unmeasured.extend(
1373                index
1374                    .lines
1375                    .iter()
1376                    .filter(|(_, (obligation_file, _))| obligation_file == file)
1377                    .map(|(id, _)| (*id).to_owned()),
1378            );
1379        }
1380        if limitation_ids.insert(limitation.id.clone()) {
1381            let (file, line) = limitation
1382                .obligation
1383                .as_deref()
1384                .and_then(|id| index.lines.get(id).cloned())
1385                .unwrap_or_else(|| {
1386                    (
1387                        limitation.file.clone().unwrap_or_else(|| {
1388                            manifest
1389                                .points
1390                                .first()
1391                                .map_or(".".into(), |point| point.file.clone())
1392                        }),
1393                        1,
1394                    )
1395                });
1396            let source = limitation
1397                .obligation
1398                .as_deref()
1399                .and_then(|id| index.sources.get(id))
1400                .map(|source| source.lines().next().unwrap_or_default().to_owned())
1401                .unwrap_or_default();
1402            new_limitations.push(json!({
1403                "id": limitation.id,
1404                "kind": "semantic-safety",
1405                "file": file,
1406                "line": line,
1407                "column": 0,
1408                "source": source,
1409                "reason": limitation.reason
1410            }));
1411        }
1412    }
1413    manifest.limitations.extend(new_limitations);
1414    manifest.unmeasured = unmeasured.into_iter().collect();
1415    let structural_limitations = limitation_ids.into_iter().collect::<Vec<_>>();
1416
1417    // Retries are separate raw results so their coverage remains attempt
1418    // exact, but the public lifecycle diagnostic reports logical tests rather
1419    // than inflating the count when a flaky test is rerun.
1420    let tests = raw_results
1421        .iter()
1422        .filter(|raw| raw.role == "test")
1423        .map(|raw| raw.test.as_str())
1424        .collect::<BTreeSet<_>>()
1425        .len();
1426    Ok(RubyFrontendRun {
1427        declaration: FrontendRunDeclaration {
1428            protocol_version: LANGUAGE_FRONTEND_PROTOCOL_VERSION,
1429            frontend_id: "ruby".into(),
1430            frontend_version: RUBY_FRONTEND_VERSION.into(),
1431            language: "ruby".into(),
1432            structural_source: StructuralSource::OwnedProbes,
1433            runners: observed_runners
1434                .iter()
1435                .map(|runner| FrontendRunnerDeclaration {
1436                    runner: runner.clone(),
1437                    execution_model: ExecutionModel::SerialInProcess,
1438                    attribution: FrontendAttribution {
1439                        run: AttributionPrecision::Exact,
1440                        worker: AttributionPrecision::Exact,
1441                        test: AttributionPrecision::Exact,
1442                        retry: AttributionPrecision::Exact,
1443                        phase: AttributionPrecision::Exact,
1444                        action: AttributionPrecision::Unavailable,
1445                        assertion: AttributionPrecision::Exact,
1446                    },
1447                    limitations: vec![
1448                        FrontendLimitation {
1449                            id: format!("ruby-{runner}-action-linkage"),
1450                            scopes: vec![FrontendLimitationScope::Action],
1451                            reason: format!("{runner} exposes no general action lifecycle"),
1452                        },
1453                        // Declared because it was not, and the declaration is
1454                        // what a reader checks a number against. Ruby's
1455                        // Coverage reports a line the first time it executes
1456                        // in the process and never again, which is what makes
1457                        // collecting it cheap: the first test to reach a line
1458                        // is credited with it and every later test that runs
1459                        // the same line is recorded against none of it. What a
1460                        // test is credited with is its own; what it is not
1461                        // credited with is not evidence it did not run.
1462                        FrontendLimitation {
1463                            id: format!("ruby-{runner}-first-sighting-lines"),
1464                            scopes: vec![FrontendLimitationScope::Test],
1465                            reason:
1466                                "Ruby records a line for the first test that reaches it, so a test's coverage is a lower bound and the run's is its upper one"
1467                                    .into(),
1468                        },
1469                    ],
1470                })
1471                .collect(),
1472            structural_limitations,
1473        },
1474        request: CoverageReportRequest {
1475            run_id: run_id.into(),
1476            manifest,
1477            raw_results,
1478            generated_at: generated_at.into(),
1479            coverage_model: Some(ruby_coverage_model()),
1480            integrity: None,
1481            test_exit_code: ExitCodeInput::Present(Some(test_exit_code)),
1482        },
1483        tests,
1484        interpreters,
1485        ruby_versions: ruby_versions.into_iter().collect(),
1486    })
1487}
1488
1489#[cfg(test)]
1490mod tests {
1491    use super::*;
1492    use crate::{
1493        coverage_analysis::PointKind, frontend_protocol::validate_frontend_report_request,
1494        ruby_instrumenter::build_ruby_obligations,
1495    };
1496
1497    fn frame(payload: &[u8]) -> Vec<u8> {
1498        let mut bytes = vec![1u8, 0, 0, 0];
1499        bytes.extend_from_slice(&(payload.len() as u32).to_le_bytes());
1500        bytes.extend_from_slice(&transport_checksum(payload).to_le_bytes());
1501        bytes.extend_from_slice(&[0, 0, 0, 0]);
1502        bytes.extend_from_slice(payload);
1503        while bytes.len() % 8 != 0 {
1504            bytes.push(0);
1505        }
1506        bytes
1507    }
1508
1509    fn transport(records: &[serde_json::Value]) -> Vec<u8> {
1510        let mut body = Vec::new();
1511        for record in records {
1512            body.extend(frame(record.to_string().as_bytes()));
1513        }
1514        let capacity = TRANSPORT_HEADER_SIZE + body.len();
1515        let mut bytes = vec![0u8; TRANSPORT_HEADER_SIZE];
1516        bytes[..8].copy_from_slice(TRANSPORT_MAGIC);
1517        bytes[8..12].copy_from_slice(&TRANSPORT_VERSION.to_le_bytes());
1518        bytes[12..16].copy_from_slice(&(TRANSPORT_HEADER_SIZE as u32).to_le_bytes());
1519        bytes[16..24].copy_from_slice(&(capacity as u64).to_le_bytes());
1520        bytes[32..40].copy_from_slice(&7u64.to_le_bytes());
1521        bytes.extend(body);
1522        bytes
1523    }
1524
1525    fn temporary(name: &str) -> std::path::PathBuf {
1526        let nonce = std::time::SystemTime::now()
1527            .duration_since(std::time::UNIX_EPOCH)
1528            .unwrap()
1529            .as_nanos();
1530        let path = std::env::temp_dir().join(format!(
1531            "supercov-ruby-evidence-{}-{nonce}-{name}",
1532            std::process::id()
1533        ));
1534        fs::create_dir_all(&path).unwrap();
1535        path
1536    }
1537
1538    #[test]
1539    fn declares_the_gap_when_a_process_never_reported() {
1540        // Coverage's own result is read in the `at_exit` that writes the exit
1541        // record, so a transport without one belongs to a process that was
1542        // killed or left through `exit!`. What it observed is unrecoverable and
1543        // unknowable, so the run says so rather than reporting those lines as
1544        // uncovered. The same records WITH an exit record must stay silent, or
1545        // every ordinary run would claim a gap it does not have.
1546        let source = "def f(a)\n  a\nend\n";
1547        let mut probe = 0;
1548        let obligations =
1549            build_ruby_obligations("lib/m.rb", source.as_bytes(), &mut probe).unwrap();
1550        let statement = obligations
1551            .manifest
1552            .points
1553            .iter()
1554            .find(|point| point.kind == PointKind::Statement)
1555            .unwrap();
1556        let base = [
1557            serde_json::json!({"t":"process","v":1,"run":"run-1","pid":7,"worker":"main","ruby":"4.0.6","executable":"ruby","argv":["child.rb"]}),
1558            serde_json::json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"MTest#test_x","retry":0,"phase":"call"}),
1559            serde_json::json!({"t":"hit","ctx":1,"id":statement.id}),
1560            serde_json::json!({"t":"outcome","worker":"main","test":"MTest#test_x","retry":0,"phase":"call","outcome":"passed","xfail":false,"runner":"minitest"}),
1561        ];
1562
1563        let killed = temporary("killed");
1564        fs::write(killed.join("main.7.a.mmap"), transport(&base)).unwrap();
1565        let run = build_ruby_frontend_run(
1566            &obligations.manifest,
1567            &killed,
1568            "run-1",
1569            "now",
1570            0,
1571            &RubyAssertionInventory::empty(),
1572        )
1573        .unwrap();
1574        let declared = serde_json::to_string(&run.request.manifest.limitations).unwrap();
1575        assert!(
1576            declared.contains("ruby-process-did-not-report"),
1577            "a process that never reported must declare the gap: {declared}"
1578        );
1579        fs::remove_dir_all(&killed).unwrap();
1580
1581        let mut clean = base.to_vec();
1582        clean.push(serde_json::json!({"t":"exit","at":9}));
1583        let reported = temporary("reported");
1584        fs::write(reported.join("main.7.a.mmap"), transport(&clean)).unwrap();
1585        let run = build_ruby_frontend_run(
1586            &obligations.manifest,
1587            &reported,
1588            "run-1",
1589            "now",
1590            0,
1591            &RubyAssertionInventory::empty(),
1592        )
1593        .unwrap();
1594        let declared = serde_json::to_string(&run.request.manifest.limitations).unwrap();
1595        assert!(
1596            !declared.contains("ruby-process-did-not-report"),
1597            "a process that reported cleanly must declare nothing: {declared}"
1598        );
1599        fs::remove_dir_all(&reported).unwrap();
1600    }
1601
1602    // A path is only absolute in the platform's own spelling: "/project" is a
1603    // relative path on Windows, where an absolute one needs a drive. The
1604    // runtimes report whatever the interpreter loaded, so these fixtures have
1605    // to speak the host's dialect too.
1606    fn under(first: &str, rest: &str) -> String {
1607        let mut path = PathBuf::from(if cfg!(windows) {
1608            format!("C:\\{first}")
1609        } else {
1610            format!("/{first}")
1611        });
1612        for part in rest.split('/').filter(|part| !part.is_empty()) {
1613            path.push(part);
1614        }
1615        path.to_string_lossy().into_owned()
1616    }
1617
1618    fn inventory_of(root: &str, sites: &[(&str, usize, usize)]) -> RubyAssertionInventory {
1619        use crate::assertion_map::{Anchor, Files, Inputs, InventorySite};
1620        RubyAssertionInventory::new(
1621            Path::new(root),
1622            &Inputs {
1623                schema_version: 1,
1624                language: "ruby".into(),
1625                context_digest: "context".into(),
1626                files: Files::new(),
1627                assertions: sites
1628                    .iter()
1629                    .map(|(file, line, column)| InventorySite {
1630                        at: Anchor {
1631                            file: (*file).into(),
1632                            line: *line,
1633                            column: *column,
1634                            text: "assert_equal 1, f(1)".into(),
1635                        },
1636                        operation: "assert".into(),
1637                    })
1638                    .collect(),
1639                limitations: vec![],
1640            },
1641        )
1642    }
1643
1644    fn assertion_sources(run: &RubyFrontendRun) -> Vec<String> {
1645        run.request.raw_results[0]
1646            .phases
1647            .iter()
1648            .filter(|phase| phase.kind == "assertion")
1649            .filter_map(|phase| phase.source.clone())
1650            .collect()
1651    }
1652
1653    fn run_with_sites(
1654        name: &str,
1655        sites: &[serde_json::Value],
1656        outcome_file: Option<&str>,
1657        inventory: &RubyAssertionInventory,
1658    ) -> RubyFrontendRun {
1659        let source = "def f(a)\n  a\nend\n";
1660        let mut probe = 0;
1661        let obligations =
1662            build_ruby_obligations("lib/m.rb", source.as_bytes(), &mut probe).unwrap();
1663        let mut outcome = serde_json::json!({"t":"outcome","worker":"main","test":"MTest#test_x","retry":0,"phase":"call","outcome":"passed","xfail":false,"runner":"minitest"});
1664        if let Some(file) = outcome_file {
1665            outcome["file"] = serde_json::json!(file);
1666        }
1667        let mut records = vec![
1668            serde_json::json!({"t":"process","v":1,"run":"run-1","pid":7,"worker":"main","ruby":"4.0.6","executable":"ruby","argv":["test.rb"]}),
1669            serde_json::json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"MTest#test_x","retry":0,"phase":"call"}),
1670            serde_json::json!({"t":"assert","ctx":1}),
1671        ];
1672        records.extend(sites.iter().cloned());
1673        records.push(outcome);
1674        records.push(serde_json::json!({"t":"exit","at":9}));
1675        let directory = temporary(name);
1676        fs::write(directory.join("main.7.a.mmap"), transport(&records)).unwrap();
1677        let run = build_ruby_frontend_run(
1678            &obligations.manifest,
1679            &directory,
1680            "run-1",
1681            "now",
1682            0,
1683            inventory,
1684        )
1685        .unwrap();
1686        validate_frontend_report_request(&run.declaration, &run.request).unwrap();
1687        fs::remove_dir_all(directory).unwrap();
1688        run
1689    }
1690
1691    #[test]
1692    fn an_assertion_site_becomes_a_located_phase_when_the_inventory_names_one() {
1693        // A Ruby backtrace carries no column, so a line is a witness only when
1694        // the inventory holds exactly one site on it. The column reported is
1695        // zero-based, which is what every native manifest reports and what the
1696        // assertion report adds one to.
1697        let inventory = inventory_of(&under("project", ""), &[("test/m_test.rb", 6, 5)]);
1698        let run = run_with_sites(
1699            "asite-located",
1700            &[
1701                serde_json::json!({"t":"asite","ctx":1,"f":under("project", "test/m_test.rb"),"l":6}),
1702            ],
1703            None,
1704            &inventory,
1705        );
1706        assert!(
1707            assertion_sources(&run).contains(&"test/m_test.rb:6:4".to_string()),
1708            "expected a located assertion phase, got {:?}",
1709            assertion_sources(&run)
1710        );
1711    }
1712
1713    #[test]
1714    fn an_ambiguous_or_foreign_assertion_site_witnesses_nothing() {
1715        // Two sites on one line cannot be told apart from a backtrace, and a
1716        // frame outside the project names nothing. Both lose the witness
1717        // rather than guessing one.
1718        let ambiguous = inventory_of(
1719            &under("project", ""),
1720            &[("test/m_test.rb", 6, 5), ("test/m_test.rb", 6, 30)],
1721        );
1722        let run = run_with_sites(
1723            "asite-ambiguous",
1724            &[
1725                serde_json::json!({"t":"asite","ctx":1,"f":under("project", "test/m_test.rb"),"l":6}),
1726            ],
1727            None,
1728            &ambiguous,
1729        );
1730        assert_eq!(
1731            assertion_sources(&run),
1732            vec!["MTest#test_x".to_string()],
1733            "only the per-test assertion phase should remain"
1734        );
1735
1736        let known = inventory_of(&under("project", ""), &[("test/m_test.rb", 6, 5)]);
1737        let outside = run_with_sites(
1738            "asite-outside",
1739            &[
1740                serde_json::json!({"t":"asite","ctx":1,"f":under("elsewhere", "test/m_test.rb"),"l":6}),
1741            ],
1742            None,
1743            &known,
1744        );
1745        assert_eq!(
1746            assertion_sources(&outside),
1747            vec!["MTest#test_x".to_string()]
1748        );
1749    }
1750
1751    #[test]
1752    fn the_runner_names_the_test_file_in_either_path_form() {
1753        // Minitest keeps the path the interpreter loaded, which may be
1754        // relative; a backtrace is absolute. Both name the same project file,
1755        // and an adapter that names none falls back to the identity.
1756        let inventory = inventory_of(&under("project", ""), &[("test/m_test.rb", 6, 5)]);
1757        for reported in [
1758            under("project", "test/m_test.rb"),
1759            "test/m_test.rb".to_owned(),
1760            "./test/m_test.rb".to_owned(),
1761        ] {
1762            let run = run_with_sites("asite-file", &[], Some(reported.as_str()), &inventory);
1763            assert_eq!(
1764                run.request.raw_results[0].test_file.as_deref(),
1765                Some("test/m_test.rb"),
1766                "{reported} should resolve to the project path"
1767            );
1768        }
1769        let without = run_with_sites("asite-nofile", &[], None, &inventory);
1770        assert_eq!(
1771            without.request.raw_results[0].test_file.as_deref(),
1772            Some("MTest#test_x"),
1773            "an adapter that cannot name a file keeps the identity fallback"
1774        );
1775    }
1776
1777    #[test]
1778    fn evidence_before_the_first_assertion_links_to_it_when_the_test_passes() {
1779        // The runtime's marker says everything the call phase recorded so far
1780        // ran before an assertion. That evidence carries an assertion phase
1781        // that passed with the test; what ran after the marker, and all of a
1782        // test that failed, stays execution only. A second marker is inert.
1783        let source = "def f(a)\n  a\nend\n\ndef g(b)\n  b\nend\n";
1784        let mut probe = 0;
1785        let obligations =
1786            build_ruby_obligations("lib/m.rb", source.as_bytes(), &mut probe).unwrap();
1787        let statements: Vec<_> = obligations
1788            .manifest
1789            .points
1790            .iter()
1791            .filter(|point| point.kind == PointKind::Statement)
1792            .collect();
1793        let (before, after) = (&statements[0].id, &statements[1].id);
1794        let run_with = |name: &str, outcome: &str| {
1795            let records = [
1796                serde_json::json!({"t":"process","v":1,"run":"run-1","pid":7,"worker":"main","ruby":"4.0.6","executable":"ruby","argv":["test.rb"]}),
1797                serde_json::json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"MTest#test_x","retry":0,"phase":"call"}),
1798                serde_json::json!({"t":"hit","ctx":1,"id":before}),
1799                serde_json::json!({"t":"assert","ctx":1}),
1800                serde_json::json!({"t":"assert","ctx":1}),
1801                serde_json::json!({"t":"hit","ctx":1,"id":after}),
1802                serde_json::json!({"t":"outcome","worker":"main","test":"MTest#test_x","retry":0,"phase":"call","outcome":outcome,"xfail":false,"runner":"minitest"}),
1803                serde_json::json!({"t":"exit","at":9}),
1804            ];
1805            let directory = temporary(name);
1806            fs::write(directory.join("main.7.a.mmap"), transport(&records)).unwrap();
1807            let run = build_ruby_frontend_run(
1808                &obligations.manifest,
1809                &directory,
1810                "run-1",
1811                "now",
1812                0,
1813                &RubyAssertionInventory::empty(),
1814            )
1815            .unwrap();
1816            validate_frontend_report_request(&run.declaration, &run.request).unwrap();
1817            fs::remove_dir_all(directory).unwrap();
1818            run
1819        };
1820        let events_of = |result: &RawTestResult, phase: &str| -> BTreeSet<String> {
1821            result
1822                .runtime
1823                .iter()
1824                .flat_map(|snapshot| snapshot.events.iter())
1825                .filter(|event| event.phase_id.as_deref() == Some(phase))
1826                .map(|event| event.id.clone())
1827                .collect()
1828        };
1829
1830        let run = run_with("asserted-passed", "passed");
1831        let passed = &run.request.raw_results[0];
1832        assert_eq!(passed.test, "MTest#test_x");
1833        let assertion = passed
1834            .phases
1835            .iter()
1836            .find(|phase| phase.kind == "assertion")
1837            .expect("the asserting test carries an assertion phase");
1838        assert_eq!(assertion.status.as_deref(), Some("passed"));
1839        assert_eq!(
1840            events_of(passed, &assertion.id),
1841            BTreeSet::from([before.clone()]),
1842            "only what ran before the marker is the assertion's evidence"
1843        );
1844        let test_phase = passed
1845            .phases
1846            .iter()
1847            .find(|phase| phase.kind == "test")
1848            .unwrap();
1849        assert_eq!(
1850            events_of(passed, &test_phase.id),
1851            BTreeSet::from([before.clone(), after.clone()]),
1852            "the test phase keeps everything it ran"
1853        );
1854        assert_eq!(
1855            run.declaration.runners[0].attribution.assertion,
1856            AttributionPrecision::Exact
1857        );
1858
1859        let run = run_with("asserted-failed", "failed");
1860        let failed = &run.request.raw_results[0];
1861        let assertion = failed
1862            .phases
1863            .iter()
1864            .find(|phase| phase.kind == "assertion")
1865            .unwrap();
1866        assert_eq!(
1867            assertion.status.as_deref(),
1868            Some("failed"),
1869            "a failed test's assertion witnessed nothing"
1870        );
1871    }
1872
1873    #[test]
1874    fn joins_rspec_and_minitest_outcomes_into_exact_results() {
1875        let source = "def f(a, b)\n  if a && b\n    1\n  else\n    0\n  end\nend\n";
1876        let mut probe = 0;
1877        let obligations =
1878            build_ruby_obligations("lib/m.rb", source.as_bytes(), &mut probe).unwrap();
1879        let decision = &obligations.manifest.decisions[0];
1880        let statement = obligations
1881            .manifest
1882            .points
1883            .iter()
1884            .find(|point| point.kind == PointKind::Statement)
1885            .unwrap();
1886        let directory = temporary("join");
1887        let records = [
1888            serde_json::json!({"t":"process","v":1,"run":"run-1","pid":7,"worker":"main","ruby":"4.0.6","executable":"ruby","argv":["rspec"]}),
1889            serde_json::json!({"t":"hit","ctx":0,"id":statement.id}),
1890            serde_json::json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"spec/m_spec.rb[1:1]","retry":0,"phase":"call"}),
1891            serde_json::json!({"t":"dec","ctx":1,"id":decision.id,"v":"22","o":1}),
1892            serde_json::json!({"t":"outcome","worker":"main","test":"spec/m_spec.rb[1:1]","retry":0,"phase":"setup","outcome":"passed","xfail":false,"runner":"rspec"}),
1893            serde_json::json!({"t":"outcome","worker":"main","test":"spec/m_spec.rb[1:1]","retry":0,"phase":"call","outcome":"passed","xfail":false,"runner":"rspec"}),
1894            serde_json::json!({"t":"outcome","worker":"main","test":"spec/m_spec.rb[1:1]","retry":0,"phase":"teardown","outcome":"passed","xfail":false,"runner":"rspec"}),
1895            serde_json::json!({"t":"phase","ctx":2,"at":6,"worker":"main","test":"MTest#test_x","retry":0,"phase":"call"}),
1896            serde_json::json!({"t":"outcome","worker":"main","test":"MTest#test_x","retry":0,"phase":"call","outcome":"skipped","xfail":false,"runner":"minitest"}),
1897            serde_json::json!({"t":"exit","at":9}),
1898        ];
1899        fs::write(directory.join("main.7.a.mmap"), transport(&records)).unwrap();
1900        let run = build_ruby_frontend_run(
1901            &obligations.manifest,
1902            &directory,
1903            "run-1",
1904            "now",
1905            0,
1906            &RubyAssertionInventory::empty(),
1907        )
1908        .unwrap();
1909        validate_frontend_report_request(&run.declaration, &run.request).unwrap();
1910        assert_eq!(run.tests, 2);
1911        let runners = run
1912            .declaration
1913            .runners
1914            .iter()
1915            .map(|runner| runner.runner.clone())
1916            .collect::<Vec<_>>();
1917        assert_eq!(runners, ["minitest", "rspec"]);
1918        assert_eq!(run.ruby_versions, ["4.0.6"]);
1919        fs::remove_dir_all(directory).unwrap();
1920    }
1921
1922    #[test]
1923    fn fails_closed_without_an_interpreter() {
1924        let mut probe = 0;
1925        let obligations = build_ruby_obligations("m.rb", b"x = 1\n", &mut probe).unwrap();
1926        let directory = temporary("empty");
1927        assert!(matches!(
1928            build_ruby_frontend_run(
1929                &obligations.manifest,
1930                &directory,
1931                "run-1",
1932                "now",
1933                0,
1934                &RubyAssertionInventory::empty()
1935            ),
1936            Err(RubyEvidenceError::NoInterpreter)
1937        ));
1938        fs::remove_dir_all(directory).unwrap();
1939    }
1940}