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                fold_outcome(
616                    evidence.outcomes.entry(key).or_default(),
617                    phase,
618                    outcome,
619                    xfail,
620                );
621            }
622            Record::Hit { ctx, id } => {
623                if let Some(before) = before_assertion.get_mut(&ctx) {
624                    before.hits.insert(id.clone());
625                }
626                observations(
627                    evidence,
628                    &contexts,
629                    process_worker.as_deref(),
630                    ctx,
631                    name,
632                    line_number,
633                )?
634                .hits
635                .insert(id);
636            }
637            Record::Dec { ctx, id, v, o } => {
638                if v.is_empty() || !v.bytes().all(|digit| matches!(digit, b'0' | b'1' | b'2')) {
639                    return Err(invalid("decision vector digits must be 0, 1 or 2"));
640                }
641                if o > 1 {
642                    return Err(invalid("decision outcome must be 0 or 1"));
643                }
644                let values = v
645                    .bytes()
646                    .map(|digit| match digit {
647                        b'0' => None,
648                        b'1' => Some(false),
649                        _ => Some(true),
650                    })
651                    .collect::<Vec<_>>();
652                if let Some(before) = before_assertion.get_mut(&ctx) {
653                    before
654                        .vectors
655                        .entry(id.clone())
656                        .or_default()
657                        .insert((values.clone(), o == 1));
658                }
659                observations(
660                    evidence,
661                    &contexts,
662                    process_worker.as_deref(),
663                    ctx,
664                    name,
665                    line_number,
666                )?
667                .vectors
668                .entry(id)
669                .or_default()
670                .insert((values, o == 1));
671            }
672            Record::Assert { ctx } => {
673                // Only the first marker of a call phase moves anything; a
674                // later one, or one outside a call phase, is inert.
675                if let Some(before) = before_assertion.remove(&ctx) {
676                    let identity = contexts
677                        .get(&ctx)
678                        .ok_or(RubyEvidenceError::UnknownContext {
679                            file: name.into(),
680                            line: line_number,
681                            context: ctx,
682                        })?;
683                    let asserted = evidence
684                        .per_identity
685                        .entry(Identity {
686                            phase: "assertion".into(),
687                            ..identity.clone()
688                        })
689                        .or_default();
690                    asserted.hits.extend(before.hits);
691                    for (id, vectors) in before.vectors {
692                        asserted.vectors.entry(id).or_default().extend(vectors);
693                    }
694                }
695            }
696            Record::Asite { ctx, f, l } => {
697                if f.is_empty() || l == 0 {
698                    return Err(invalid("assertion site needs a file and a line"));
699                }
700                let identity = contexts
701                    .get(&ctx)
702                    .ok_or(RubyEvidenceError::UnknownContext {
703                        file: name.into(),
704                        line: line_number,
705                        context: ctx,
706                    })?;
707                // Only the call phase witnesses a test's assertions; setup and
708                // teardown assertions belong to no single site under test.
709                if identity.phase == "call" {
710                    let key = (
711                        identity.worker.clone(),
712                        identity.test.clone(),
713                        identity.retry,
714                    );
715                    let sites = evidence.sites.entry(key).or_default();
716                    let site = (f, l);
717                    if !sites.contains(&site) {
718                        sites.push(site);
719                    }
720                }
721            }
722            Record::Limitation {
723                id,
724                reason,
725                file,
726                obligation,
727            } => evidence.limitations.push(RuntimeLimitation {
728                id,
729                reason,
730                file,
731                obligation,
732            }),
733            Record::Exit { .. } => process_reported = true,
734        }
735        cursor = next_cursor;
736    }
737    // Ruby reads Coverage's own result in the `at_exit` that writes this
738    // record, so a process that never wrote one -- killed with a signal it
739    // could not catch, or left through `exit!` -- took everything it had
740    // observed since its last test boundary with it. Nothing outside that
741    // process can recover the counters or say which lines they were, so the
742    // run declares the gap instead of reporting those lines as merely
743    // uncovered. A declared limitation blocks completeness, which is the
744    // honest answer: coverage measured here is a floor, not a total.
745    if process_started && !process_reported {
746        evidence.limitations.push(RuntimeLimitation {
747            id: "ruby-process-did-not-report".into(),
748            reason: format!(
749                "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"
750            ),
751            file: None,
752            obligation: None,
753        });
754    }
755    Ok(())
756}
757
758fn observations<'a>(
759    evidence: &'a mut Evidence,
760    contexts: &BTreeMap<u64, Identity>,
761    process_worker: Option<&str>,
762    context: u64,
763    file: &str,
764    line: usize,
765) -> Result<&'a mut Observations, RubyEvidenceError> {
766    if context == 0 {
767        return Ok(evidence
768            .background
769            .entry(process_worker.unwrap_or("main").to_owned())
770            .or_default());
771    }
772    let identity = contexts
773        .get(&context)
774        .ok_or(RubyEvidenceError::UnknownContext {
775            file: file.into(),
776            line,
777            context,
778        })?;
779    Ok(evidence.per_identity.entry(identity.clone()).or_default())
780}
781
782pub fn ruby_coverage_model() -> CoverageModelDeclaration {
783    CoverageModelDeclaration {
784        language: "ruby".into(),
785        variant: "ruby-owned-coverage".into(),
786        name: "ruby-coverage-probes-v1".into(),
787        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(),
788        measured: vec![
789            "executable statements proven by Ruby's line coverage, or a probe when a line holds several".into(),
790            "method definitions entered (Ruby's method coverage)".into(),
791            "if/unless/elsif/ternary/while/until decisions with masking MC/DC vectors from operand probes".into(),
792            "while, until, for and iterator-block (each, map, times, ...) zero-versus-entered iteration".into(),
793            "&&, ||, ||= and &&= short-circuit alternatives".into(),
794            "case/when and case/in clause selection, safe navigation".into(),
795            "begin/rescue completion, handler selection and exception propagation".into(),
796            "RSpec, Minitest, test-unit and Cucumber worker, test and setup/call/teardown phase identity".into(),
797            "evidence a test recorded before its first assertion, linked to that assertion when the test passes".into(),
798        ],
799        not_measured: vec![
800            "blocks and lambdas as function entry points (they are statements inside their methods)".into(),
801            "blocks passed by reference (map(&:to_s)) as loops: they have no block body to observe".into(),
802            "line, branch and method observations made while test phases overlapped in threads (attributed to the run; probe observations stay per test)".into(),
803            "causal linkage to individual actions, or to any assertion after a test's first".into(),
804            "code compiled from strings at runtime (eval, instance_eval with strings)".into(),
805            "child coverage outside Process.spawn, Kernel#spawn, Kernel#system and fork".into(),
806            "all input values, semantic partitions, paths, or concurrency interleavings".into(),
807            "mutation score or assertion fault-detection strength".into(),
808        ],
809    }
810}
811
812fn phase_id(run: &str, identity: &Identity) -> String {
813    stable_id(
814        "ruby-phase",
815        &[
816            run,
817            &identity.worker,
818            &identity.test,
819            &identity.retry.to_string(),
820            &identity.phase,
821        ],
822    )
823}
824
825fn scope(run: &str, worker: &str, test: &str, retry: usize) -> ExecutionScope {
826    ExecutionScope {
827        version: 1,
828        run_id: run.into(),
829        worker_id: worker.into(),
830        test_id: test.into(),
831        test_key: stable_id("ruby-test", &[worker, test]),
832        retry,
833        attempt_id: stable_id("ruby-attempt", &[run, worker, test, &retry.to_string()]),
834    }
835}
836
837struct ManifestIndex<'a> {
838    points: BTreeSet<&'a str>,
839    alternatives: BTreeSet<&'a str>,
840    decisions: BTreeMap<&'a str, &'a DecisionMeta>,
841    lines: BTreeMap<&'a str, (String, usize)>,
842    sources: BTreeMap<&'a str, &'a str>,
843}
844
845impl<'a> ManifestIndex<'a> {
846    fn new(manifest: &'a CoverageManifest) -> Self {
847        let mut lines = BTreeMap::new();
848        let mut sources = BTreeMap::new();
849        for point in &manifest.points {
850            lines.insert(point.id.as_str(), (point.file.clone(), point.line));
851            sources.insert(point.id.as_str(), point.source.as_str());
852        }
853        for decision in &manifest.decisions {
854            lines.insert(decision.id.as_str(), (decision.file.clone(), decision.line));
855            sources.insert(decision.id.as_str(), decision.source.as_str());
856        }
857        for branch in &manifest.branches {
858            lines.insert(branch.id.as_str(), (branch.file.clone(), branch.line));
859            sources.insert(branch.id.as_str(), branch.source.as_str());
860        }
861        Self {
862            points: manifest
863                .points
864                .iter()
865                .map(|point| point.id.as_str())
866                .collect(),
867            alternatives: manifest
868                .branches
869                .iter()
870                .flat_map(|branch| branch.alternatives.iter().map(|alt| alt.id.as_str()))
871                .collect(),
872            decisions: manifest
873                .decisions
874                .iter()
875                .map(|decision| (decision.id.as_str(), decision))
876                .collect(),
877            lines,
878            sources,
879        }
880    }
881}
882
883fn snapshot(
884    index: &ManifestIndex<'_>,
885    observations: &Observations,
886    phase: &str,
887) -> Result<RuntimeSnapshot, RubyEvidenceError> {
888    let mut hits = BTreeSet::new();
889    for id in &observations.hits {
890        if !index.points.contains(id.as_str()) && !index.alternatives.contains(id.as_str()) {
891            return Err(RubyEvidenceError::UnknownObligation(id.clone()));
892        }
893        hits.insert(id.clone());
894    }
895    let mut decisions = Vec::new();
896    let mut events = Vec::new();
897    let mut clock = 1;
898    for id in &hits {
899        events.push(RuntimeEvent {
900            event_type: "hit".into(),
901            id: id.clone(),
902            vector: None,
903            timestamp_ms: clock,
904            phase_id: Some(phase.into()),
905            statement_id: None,
906            environment: "ruby".into(),
907        });
908        clock += 1;
909    }
910    for (id, vectors) in &observations.vectors {
911        let Some(meta) = index.decisions.get(id.as_str()) else {
912            return Err(RubyEvidenceError::UnknownObligation(id.clone()));
913        };
914        let mut observed = Vec::new();
915        for (values, outcome) in vectors {
916            if values.len() != meta.conditions.len() {
917                return Err(RubyEvidenceError::InvalidVector {
918                    id: id.clone(),
919                    expected: meta.conditions.len(),
920                    actual: values.len(),
921                });
922            }
923            let vector = McdcVector {
924                values: values.clone(),
925                outcome: *outcome,
926            };
927            events.push(RuntimeEvent {
928                event_type: "decision".into(),
929                id: id.clone(),
930                vector: Some(vector.clone()),
931                timestamp_ms: clock,
932                phase_id: Some(phase.into()),
933                statement_id: None,
934                environment: "ruby".into(),
935            });
936            clock += 1;
937            observed.push(vector);
938        }
939        decisions.push(DecisionSnapshot {
940            meta: (*meta).clone(),
941            vectors: observed,
942        });
943    }
944    Ok(RuntimeSnapshot {
945        decisions,
946        hits: hits.into_iter().collect(),
947        events,
948        logicals: Vec::new(),
949        phase_id: None,
950    })
951}
952
953/// One phase, however many times its runner reported it, as on the Python
954/// path: a phase id is derived from an identity with no room for a repeat, so
955/// a second report of one phase duplicated an id and the run was refused
956/// whole. No Ruby runner reports twice today -- RSpec identifies an example by
957/// position, Minitest and test-unit by class and method, Cucumber by line --
958/// but losing a whole run is too much to leave resting on that.
959fn fold_outcome(
960    outcomes: &mut Vec<(String, String, bool)>,
961    phase: String,
962    outcome: String,
963    xfail: bool,
964) {
965    let Some(reported) = outcomes.iter_mut().find(|(name, _, _)| *name == phase) else {
966        outcomes.push((phase, outcome, xfail));
967        return;
968    };
969    if outcome_severity(&outcome) > outcome_severity(&reported.1) {
970        reported.1 = outcome;
971    }
972    reported.2 |= xfail;
973}
974
975/// The order `attempt_status` reads outcomes in, as one value.
976fn outcome_severity(outcome: &str) -> u8 {
977    match outcome {
978        "failed" | "rerun" | "error" => 2,
979        "skipped" => 1,
980        _ => 0,
981    }
982}
983
984fn attempt_status(outcomes: &[(String, String, bool)]) -> String {
985    if outcomes
986        .iter()
987        .any(|(_, outcome, _)| matches!(outcome.as_str(), "failed" | "rerun" | "error"))
988    {
989        "failed"
990    } else if outcomes.iter().any(|(_, outcome, _)| outcome == "skipped") {
991        "skipped"
992    } else {
993        "passed"
994    }
995    .into()
996}
997
998#[derive(Debug, Clone, PartialEq)]
999pub struct RubyFrontendRun {
1000    pub declaration: FrontendRunDeclaration,
1001    pub request: CoverageReportRequest,
1002    pub tests: usize,
1003    pub interpreters: usize,
1004    pub ruby_versions: Vec<String>,
1005}
1006
1007impl RubyFrontendRun {
1008    pub fn archive_entries(&self) -> Result<Vec<EvidenceArchiveEntry>, serde_json::Error> {
1009        let model = PersistedCoverageModel::from_declaration(
1010            self.request
1011                .coverage_model
1012                .as_ref()
1013                .expect("Ruby frontend always declares a coverage model"),
1014        )
1015        .expect("Ruby coverage model is contract-valid");
1016        let mut entries = vec![
1017            EvidenceArchiveEntry {
1018                path: "coverage-model.json".into(),
1019                contents: serde_json::to_vec(&model)?,
1020            },
1021            EvidenceArchiveEntry {
1022                path: "frontend.json".into(),
1023                contents: serde_json::to_vec(&self.declaration)?,
1024            },
1025            EvidenceArchiveEntry {
1026                path: "manifest.json".into(),
1027                contents: serde_json::to_vec(&self.request.manifest)?,
1028            },
1029        ];
1030        for (index, result) in self.request.raw_results.iter().enumerate() {
1031            entries.push(EvidenceArchiveEntry {
1032                path: format!("results/{index:08}/mcdc.json"),
1033                contents: serde_json::to_vec(result)?,
1034            });
1035        }
1036        Ok(entries)
1037    }
1038}
1039
1040/// Join the runtime's evidence directory with the ahead-of-run manifest into
1041/// a protocol-conformant frontend run.
1042pub fn build_ruby_frontend_run(
1043    manifest: &CoverageManifest,
1044    evidence_directory: &Path,
1045    run_id: &str,
1046    generated_at: &str,
1047    test_exit_code: i32,
1048    assertions: &RubyAssertionInventory,
1049) -> Result<RubyFrontendRun, RubyEvidenceError> {
1050    let evidence = read_evidence_directory(evidence_directory, run_id)?;
1051    if evidence.interpreters == 0 {
1052        return Err(RubyEvidenceError::NoInterpreter);
1053    }
1054    if evidence.outcomes.is_empty() {
1055        return Err(RubyEvidenceError::NoTests);
1056    }
1057    let Evidence {
1058        interpreters,
1059        ruby_versions,
1060        per_identity,
1061        background,
1062        outcomes,
1063        runners,
1064        test_files,
1065        sites,
1066        limitations,
1067    } = evidence;
1068    let mut manifest = manifest.clone();
1069    let index = ManifestIndex::new(&manifest);
1070
1071    let mut raw_results = Vec::new();
1072    let mut observed_runners = BTreeSet::new();
1073    let mut identities_by_attempt =
1074        BTreeMap::<(String, String, usize), Vec<(&Identity, &Observations)>>::new();
1075    for (identity, observations) in &per_identity {
1076        identities_by_attempt
1077            .entry((
1078                identity.worker.clone(),
1079                identity.test.clone(),
1080                identity.retry,
1081            ))
1082            .or_default()
1083            .push((identity, observations));
1084    }
1085    for ((worker, test, retry), mut outcomes) in outcomes {
1086        let runner = runners
1087            .get(&(worker.clone(), test.clone(), retry))
1088            .cloned()
1089            .unwrap_or_else(default_runner);
1090        let attempt_identities = identities_by_attempt
1091            .remove(&(worker.clone(), test.clone(), retry))
1092            .unwrap_or_default();
1093        observed_runners.insert(runner.clone());
1094        outcomes.sort_by_key(|(phase, _, _)| match phase.as_str() {
1095            "setup" => 0,
1096            "call" => 1,
1097            _ => 2,
1098        });
1099        let mut phases = Vec::new();
1100        let mut runtime = Vec::new();
1101        let mut observed_phases = BTreeSet::new();
1102        for (position, (phase_name, outcome, xfail)) in outcomes.iter().enumerate() {
1103            observed_phases.insert(phase_name.clone());
1104            let identity = Identity {
1105                worker: worker.clone(),
1106                test: test.clone(),
1107                retry,
1108                phase: phase_name.clone(),
1109            };
1110            let id = phase_id(run_id, &identity);
1111            phases.push(CoveragePhase {
1112                id: id.clone(),
1113                kind: match phase_name.as_str() {
1114                    "call" => "test",
1115                    value => value,
1116                }
1117                .into(),
1118                operation: format!("{runner} {phase_name}"),
1119                source: Some(test.clone()),
1120                caused_by_phase_id: None,
1121                started_at_ms: position as i64 * 2 + 1,
1122                ended_at_ms: Some(position as i64 * 2 + 2),
1123                status: Some(match outcome.as_str() {
1124                    "rerun" | "error" => "failed".into(),
1125                    value => value.into(),
1126                }),
1127                error: None,
1128            });
1129            if let Some((_, observations)) = attempt_identities
1130                .iter()
1131                .find(|(candidate, _)| candidate.phase == phase_name.as_str())
1132            {
1133                runtime.push(snapshot(&index, observations, &id)?);
1134            }
1135            if phase_name != "call" {
1136                continue;
1137            }
1138            // What the test recorded before its first assertion is that
1139            // assertion's evidence, linked when the phase passed outright:
1140            // a failed, skipped or expected-to-fail phase witnessed nothing.
1141            if let Some((identity, observations)) = attempt_identities
1142                .iter()
1143                .find(|(candidate, _)| candidate.phase == "assertion")
1144            {
1145                observed_phases.insert("assertion".to_owned());
1146                let id = phase_id(run_id, identity);
1147                phases.push(CoveragePhase {
1148                    id: id.clone(),
1149                    kind: "assertion".into(),
1150                    operation: format!("{runner} assertion"),
1151                    source: Some(test.clone()),
1152                    caused_by_phase_id: None,
1153                    started_at_ms: position as i64 * 2 + 1,
1154                    ended_at_ms: Some(position as i64 * 2 + 2),
1155                    status: Some(
1156                        if outcome == "passed" && !*xfail {
1157                            "passed"
1158                        } else {
1159                            "failed"
1160                        }
1161                        .into(),
1162                    ),
1163                    error: None,
1164                });
1165                runtime.push(snapshot(&index, observations, &id)?);
1166                // One phase per assertion site the call phase reached, so an
1167                // assertion map can tell the sites apart. The per-test phase
1168                // above keeps carrying the pre-assertion evidence; these are
1169                // witnesses only, and a site the inventory does not know is
1170                // skipped rather than guessed at.
1171                let attempt = (worker.clone(), test.clone(), retry);
1172                for (path, line) in sites.get(&attempt).into_iter().flatten() {
1173                    let Some(location) = assertions.locate(path, *line) else {
1174                        continue;
1175                    };
1176                    phases.push(CoveragePhase {
1177                        id: stable_id("ruby-assertion", &[run_id, &id, &location]),
1178                        kind: "assertion".into(),
1179                        operation: format!("{runner} assertion at {location}"),
1180                        source: Some(location),
1181                        caused_by_phase_id: Some(id.clone()),
1182                        started_at_ms: position as i64 * 2 + 1,
1183                        ended_at_ms: Some(position as i64 * 2 + 2),
1184                        status: Some(
1185                            if outcome == "passed" && !*xfail {
1186                                "passed"
1187                            } else {
1188                                "failed"
1189                            }
1190                            .into(),
1191                        ),
1192                        error: None,
1193                    });
1194                }
1195            }
1196        }
1197        // A phase the runtime entered but the runner never reported (the worker
1198        // died inside it) is a failed phase with its evidence kept.
1199        for (identity, observations) in attempt_identities {
1200            if !observed_phases.contains(&identity.phase) {
1201                let id = phase_id(run_id, identity);
1202                phases.push(CoveragePhase {
1203                    id: id.clone(),
1204                    kind: match identity.phase.as_str() {
1205                        "call" => "test",
1206                        value => value,
1207                    }
1208                    .into(),
1209                    operation: format!("{runner} {}", identity.phase),
1210                    source: Some(test.clone()),
1211                    caused_by_phase_id: None,
1212                    started_at_ms: phases.len() as i64 * 2 + 1,
1213                    ended_at_ms: None,
1214                    status: Some("failed".into()),
1215                    error: Some("the phase started but the runner reported no outcome".into()),
1216                });
1217                runtime.push(snapshot(&index, observations, &id)?);
1218            }
1219        }
1220        let status = if phases.iter().any(|phase| phase.error.is_some()) {
1221            "failed".into()
1222        } else {
1223            attempt_status(&outcomes)
1224        };
1225        raw_results.push(RawTestResult {
1226            test_id: Some(test.clone()),
1227            scope: Some(scope(run_id, &worker, &test, retry)),
1228            test: test.clone(),
1229            // What the runner named, as the project names it. A runner
1230            // identity is not a path, so the old derivation stays only as a
1231            // fallback for an adapter that cannot name the file.
1232            test_file: test_files
1233                .get(&(worker.clone(), test.clone(), retry))
1234                .and_then(|path| assertions.relative(path))
1235                .or_else(|| test.split("::").next().map(str::to_owned)),
1236            title: test.rsplit("::").next().map(str::to_owned),
1237            retry: Some(retry),
1238            status: Some(status),
1239            expected_status: Some(
1240                if outcomes.iter().any(|(_, _, xfail)| *xfail) {
1241                    "failed"
1242                } else {
1243                    "passed"
1244                }
1245                .into(),
1246            ),
1247            flaky: false,
1248            provenance: TestProvenance {
1249                runner: runner.clone(),
1250                kind: "unit".into(),
1251                project: None,
1252                source: RUBY_FRONTEND_VERSION.into(),
1253            },
1254            role: "test".into(),
1255            // What a Ruby test is recorded as reaching is its own and is not
1256            // all of it. The runtime asks Ruby's Coverage for one-shot lines,
1257            // which report a line the first time it executes in the process
1258            // and never again -- so the first test to reach a line is credited
1259            // with it and every later test that runs the same line is recorded
1260            // as having reached nothing there. Cheap to collect, and it makes
1261            // a test's hits a lower bound rather than a description.
1262            attribution: crate::coverage_report::ATTRIBUTION_PARTIAL.into(),
1263            phases,
1264            runtime,
1265            browser: Vec::new(),
1266            server: Vec::new(),
1267        });
1268    }
1269    // Phases with observations whose test never produced any outcome at all
1270    // (for example a worker killed during its first phase).
1271    let default_observed = observed_runners
1272        .iter()
1273        .next()
1274        .cloned()
1275        .unwrap_or_else(default_runner);
1276    for ((worker, test, retry), identities) in identities_by_attempt {
1277        let runner = default_observed.clone();
1278        let mut phases = Vec::new();
1279        let mut runtime = Vec::new();
1280        for (position, (identity, observations)) in identities.iter().enumerate() {
1281            let id = phase_id(run_id, identity);
1282            phases.push(CoveragePhase {
1283                id: id.clone(),
1284                kind: match identity.phase.as_str() {
1285                    "call" => "test",
1286                    value => value,
1287                }
1288                .into(),
1289                operation: format!("{runner} {}", identity.phase),
1290                source: Some(test.clone()),
1291                caused_by_phase_id: None,
1292                started_at_ms: position as i64 * 2 + 1,
1293                ended_at_ms: None,
1294                status: Some("failed".into()),
1295                error: Some("the phase started but the runner reported no outcome".into()),
1296            });
1297            runtime.push(snapshot(&index, observations, &id)?);
1298        }
1299        raw_results.push(RawTestResult {
1300            test_id: Some(test.clone()),
1301            scope: Some(scope(run_id, &worker, &test, retry)),
1302            test: test.clone(),
1303            // What the runner named, as the project names it. A runner
1304            // identity is not a path, so the old derivation stays only as a
1305            // fallback for an adapter that cannot name the file.
1306            test_file: test_files
1307                .get(&(worker.clone(), test.clone(), retry))
1308                .and_then(|path| assertions.relative(path))
1309                .or_else(|| test.split("::").next().map(str::to_owned)),
1310            title: test.rsplit("::").next().map(str::to_owned),
1311            retry: Some(retry),
1312            status: Some("failed".into()),
1313            expected_status: Some("passed".into()),
1314            flaky: false,
1315            provenance: TestProvenance {
1316                runner: runner.clone(),
1317                kind: "unit".into(),
1318                project: None,
1319                source: RUBY_FRONTEND_VERSION.into(),
1320            },
1321            role: "test".into(),
1322            // What a Ruby test is recorded as reaching is its own and is not
1323            // all of it. The runtime asks Ruby's Coverage for one-shot lines,
1324            // which report a line the first time it executes in the process
1325            // and never again -- so the first test to reach a line is credited
1326            // with it and every later test that runs the same line is recorded
1327            // as having reached nothing there. Cheap to collect, and it makes
1328            // a test's hits a lower bound rather than a description.
1329            attribution: crate::coverage_report::ATTRIBUTION_PARTIAL.into(),
1330            phases,
1331            runtime,
1332            browser: Vec::new(),
1333            server: Vec::new(),
1334        });
1335    }
1336    for (worker, observations) in &background {
1337        if observations.hits.is_empty() && observations.vectors.is_empty() {
1338            continue;
1339        }
1340        let test = format!("__supercov_background__:{worker}");
1341        let identity = Identity {
1342            worker: worker.clone(),
1343            test: test.clone(),
1344            retry: 0,
1345            phase: "background".into(),
1346        };
1347        let phase = phase_id(run_id, &identity);
1348        raw_results.push(RawTestResult {
1349            test_id: Some(test.clone()),
1350            scope: Some(scope(run_id, worker, &test, 0)),
1351            test: "Ruby load and background execution".into(),
1352            test_file: None,
1353            title: None,
1354            retry: Some(0),
1355            status: Some("unknown".into()),
1356            expected_status: None,
1357            flaky: false,
1358            provenance: TestProvenance {
1359                runner: default_observed.clone(),
1360                kind: "unit".into(),
1361                project: None,
1362                source: RUBY_FRONTEND_VERSION.into(),
1363            },
1364            role: "background".into(),
1365            attribution: crate::coverage_report::ATTRIBUTION_EXACT.into(),
1366            phases: vec![CoveragePhase {
1367                id: phase.clone(),
1368                kind: "background".into(),
1369                operation: "Ruby load background".into(),
1370                source: None,
1371                caused_by_phase_id: None,
1372                started_at_ms: 0,
1373                ended_at_ms: Some(0),
1374                status: Some("passed".into()),
1375                error: None,
1376            }],
1377            runtime: vec![snapshot(&index, observations, &phase)?],
1378            browser: Vec::new(),
1379            server: Vec::new(),
1380        });
1381    }
1382
1383    // Runtime-detected limitations: obligations the runtime could not map
1384    // become unmeasured, and every limitation ID joins the manifest so the
1385    // declaration and manifest agree.
1386    let mut limitation_ids = manifest
1387        .limitations
1388        .iter()
1389        .filter_map(|item| item.get("id").and_then(serde_json::Value::as_str))
1390        .map(str::to_owned)
1391        .collect::<BTreeSet<_>>();
1392    let mut unmeasured = manifest.unmeasured.iter().cloned().collect::<BTreeSet<_>>();
1393    let mut new_limitations = Vec::new();
1394    for limitation in &limitations {
1395        if let Some(obligation) = &limitation.obligation {
1396            if !index.lines.contains_key(obligation.as_str()) {
1397                return Err(RubyEvidenceError::UnknownObligation(obligation.clone()));
1398            }
1399            unmeasured.insert(obligation.clone());
1400        } else if let Some(file) = &limitation.file {
1401            // A code-object mapping failure or missing debug ranges prevents
1402            // every obligation in that source file from being observed. Mark
1403            // the whole file unmeasured instead of presenting its denominator
1404            // as ordinary uncovered code.
1405            unmeasured.extend(
1406                index
1407                    .lines
1408                    .iter()
1409                    .filter(|(_, (obligation_file, _))| obligation_file == file)
1410                    .map(|(id, _)| (*id).to_owned()),
1411            );
1412        }
1413        if limitation_ids.insert(limitation.id.clone()) {
1414            let (file, line) = limitation
1415                .obligation
1416                .as_deref()
1417                .and_then(|id| index.lines.get(id).cloned())
1418                .unwrap_or_else(|| {
1419                    (
1420                        limitation.file.clone().unwrap_or_else(|| {
1421                            manifest
1422                                .points
1423                                .first()
1424                                .map_or(".".into(), |point| point.file.clone())
1425                        }),
1426                        1,
1427                    )
1428                });
1429            let source = limitation
1430                .obligation
1431                .as_deref()
1432                .and_then(|id| index.sources.get(id))
1433                .map(|source| source.lines().next().unwrap_or_default().to_owned())
1434                .unwrap_or_default();
1435            new_limitations.push(json!({
1436                "id": limitation.id,
1437                "kind": "semantic-safety",
1438                "file": file,
1439                "line": line,
1440                "column": 0,
1441                "source": source,
1442                "reason": limitation.reason
1443            }));
1444        }
1445    }
1446    manifest.limitations.extend(new_limitations);
1447    manifest.unmeasured = unmeasured.into_iter().collect();
1448    let structural_limitations = limitation_ids.into_iter().collect::<Vec<_>>();
1449
1450    // Retries are separate raw results so their coverage remains attempt
1451    // exact, but the public lifecycle diagnostic reports logical tests rather
1452    // than inflating the count when a flaky test is rerun.
1453    let tests = raw_results
1454        .iter()
1455        .filter(|raw| raw.role == "test")
1456        .map(|raw| raw.test.as_str())
1457        .collect::<BTreeSet<_>>()
1458        .len();
1459    Ok(RubyFrontendRun {
1460        declaration: FrontendRunDeclaration {
1461            protocol_version: LANGUAGE_FRONTEND_PROTOCOL_VERSION,
1462            frontend_id: "ruby".into(),
1463            frontend_version: RUBY_FRONTEND_VERSION.into(),
1464            language: "ruby".into(),
1465            structural_source: StructuralSource::OwnedProbes,
1466            runners: observed_runners
1467                .iter()
1468                .map(|runner| FrontendRunnerDeclaration {
1469                    runner: runner.clone(),
1470                    execution_model: ExecutionModel::SerialInProcess,
1471                    attribution: FrontendAttribution {
1472                        run: AttributionPrecision::Exact,
1473                        worker: AttributionPrecision::Exact,
1474                        test: AttributionPrecision::Exact,
1475                        retry: AttributionPrecision::Exact,
1476                        phase: AttributionPrecision::Exact,
1477                        action: AttributionPrecision::Unavailable,
1478                        assertion: AttributionPrecision::Exact,
1479                    },
1480                    limitations: vec![
1481                        FrontendLimitation {
1482                            id: format!("ruby-{runner}-action-linkage"),
1483                            scopes: vec![FrontendLimitationScope::Action],
1484                            reason: format!("{runner} exposes no general action lifecycle"),
1485                        },
1486                        // Declared because it was not, and the declaration is
1487                        // what a reader checks a number against. Ruby's
1488                        // Coverage reports a line the first time it executes
1489                        // in the process and never again, which is what makes
1490                        // collecting it cheap: the first test to reach a line
1491                        // is credited with it and every later test that runs
1492                        // the same line is recorded against none of it. What a
1493                        // test is credited with is its own; what it is not
1494                        // credited with is not evidence it did not run.
1495                        FrontendLimitation {
1496                            id: format!("ruby-{runner}-first-sighting-lines"),
1497                            scopes: vec![FrontendLimitationScope::Test],
1498                            reason:
1499                                "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"
1500                                    .into(),
1501                        },
1502                    ],
1503                })
1504                .collect(),
1505            structural_limitations,
1506        },
1507        request: CoverageReportRequest {
1508            run_id: run_id.into(),
1509            manifest,
1510            raw_results,
1511            generated_at: generated_at.into(),
1512            coverage_model: Some(ruby_coverage_model()),
1513            integrity: None,
1514            test_exit_code: ExitCodeInput::Present(Some(test_exit_code)),
1515        },
1516        tests,
1517        interpreters,
1518        ruby_versions: ruby_versions.into_iter().collect(),
1519    })
1520}
1521
1522#[cfg(test)]
1523mod tests {
1524    use super::*;
1525    use crate::{
1526        coverage_analysis::PointKind, frontend_protocol::validate_frontend_report_request,
1527        ruby_instrumenter::build_ruby_obligations,
1528    };
1529
1530    fn frame(payload: &[u8]) -> Vec<u8> {
1531        let mut bytes = vec![1u8, 0, 0, 0];
1532        bytes.extend_from_slice(&(payload.len() as u32).to_le_bytes());
1533        bytes.extend_from_slice(&transport_checksum(payload).to_le_bytes());
1534        bytes.extend_from_slice(&[0, 0, 0, 0]);
1535        bytes.extend_from_slice(payload);
1536        while bytes.len() % 8 != 0 {
1537            bytes.push(0);
1538        }
1539        bytes
1540    }
1541
1542    fn transport(records: &[serde_json::Value]) -> Vec<u8> {
1543        let mut body = Vec::new();
1544        for record in records {
1545            body.extend(frame(record.to_string().as_bytes()));
1546        }
1547        let capacity = TRANSPORT_HEADER_SIZE + body.len();
1548        let mut bytes = vec![0u8; TRANSPORT_HEADER_SIZE];
1549        bytes[..8].copy_from_slice(TRANSPORT_MAGIC);
1550        bytes[8..12].copy_from_slice(&TRANSPORT_VERSION.to_le_bytes());
1551        bytes[12..16].copy_from_slice(&(TRANSPORT_HEADER_SIZE as u32).to_le_bytes());
1552        bytes[16..24].copy_from_slice(&(capacity as u64).to_le_bytes());
1553        bytes[32..40].copy_from_slice(&7u64.to_le_bytes());
1554        bytes.extend(body);
1555        bytes
1556    }
1557
1558    fn temporary(name: &str) -> std::path::PathBuf {
1559        let nonce = std::time::SystemTime::now()
1560            .duration_since(std::time::UNIX_EPOCH)
1561            .unwrap()
1562            .as_nanos();
1563        let path = std::env::temp_dir().join(format!(
1564            "supercov-ruby-evidence-{}-{nonce}-{name}",
1565            std::process::id()
1566        ));
1567        fs::create_dir_all(&path).unwrap();
1568        path
1569    }
1570
1571    #[test]
1572    fn declares_the_gap_when_a_process_never_reported() {
1573        // Coverage's own result is read in the `at_exit` that writes the exit
1574        // record, so a transport without one belongs to a process that was
1575        // killed or left through `exit!`. What it observed is unrecoverable and
1576        // unknowable, so the run says so rather than reporting those lines as
1577        // uncovered. The same records WITH an exit record must stay silent, or
1578        // every ordinary run would claim a gap it does not have.
1579        let source = "def f(a)\n  a\nend\n";
1580        let mut probe = 0;
1581        let obligations =
1582            build_ruby_obligations("lib/m.rb", source.as_bytes(), &mut probe).unwrap();
1583        let statement = obligations
1584            .manifest
1585            .points
1586            .iter()
1587            .find(|point| point.kind == PointKind::Statement)
1588            .unwrap();
1589        let base = [
1590            serde_json::json!({"t":"process","v":1,"run":"run-1","pid":7,"worker":"main","ruby":"4.0.6","executable":"ruby","argv":["child.rb"]}),
1591            serde_json::json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"MTest#test_x","retry":0,"phase":"call"}),
1592            serde_json::json!({"t":"hit","ctx":1,"id":statement.id}),
1593            serde_json::json!({"t":"outcome","worker":"main","test":"MTest#test_x","retry":0,"phase":"call","outcome":"passed","xfail":false,"runner":"minitest"}),
1594        ];
1595
1596        let killed = temporary("killed");
1597        fs::write(killed.join("main.7.a.mmap"), transport(&base)).unwrap();
1598        let run = build_ruby_frontend_run(
1599            &obligations.manifest,
1600            &killed,
1601            "run-1",
1602            "now",
1603            0,
1604            &RubyAssertionInventory::empty(),
1605        )
1606        .unwrap();
1607        let declared = serde_json::to_string(&run.request.manifest.limitations).unwrap();
1608        assert!(
1609            declared.contains("ruby-process-did-not-report"),
1610            "a process that never reported must declare the gap: {declared}"
1611        );
1612        fs::remove_dir_all(&killed).unwrap();
1613
1614        let mut clean = base.to_vec();
1615        clean.push(serde_json::json!({"t":"exit","at":9}));
1616        let reported = temporary("reported");
1617        fs::write(reported.join("main.7.a.mmap"), transport(&clean)).unwrap();
1618        let run = build_ruby_frontend_run(
1619            &obligations.manifest,
1620            &reported,
1621            "run-1",
1622            "now",
1623            0,
1624            &RubyAssertionInventory::empty(),
1625        )
1626        .unwrap();
1627        let declared = serde_json::to_string(&run.request.manifest.limitations).unwrap();
1628        assert!(
1629            !declared.contains("ruby-process-did-not-report"),
1630            "a process that reported cleanly must declare nothing: {declared}"
1631        );
1632        fs::remove_dir_all(&reported).unwrap();
1633    }
1634
1635    // A path is only absolute in the platform's own spelling: "/project" is a
1636    // relative path on Windows, where an absolute one needs a drive. The
1637    // runtimes report whatever the interpreter loaded, so these fixtures have
1638    // to speak the host's dialect too.
1639    fn under(first: &str, rest: &str) -> String {
1640        let mut path = PathBuf::from(if cfg!(windows) {
1641            format!("C:\\{first}")
1642        } else {
1643            format!("/{first}")
1644        });
1645        for part in rest.split('/').filter(|part| !part.is_empty()) {
1646            path.push(part);
1647        }
1648        path.to_string_lossy().into_owned()
1649    }
1650
1651    fn inventory_of(root: &str, sites: &[(&str, usize, usize)]) -> RubyAssertionInventory {
1652        use crate::assertion_map::{Anchor, Files, Inputs, InventorySite};
1653        RubyAssertionInventory::new(
1654            Path::new(root),
1655            &Inputs {
1656                schema_version: 1,
1657                language: "ruby".into(),
1658                context_digest: "context".into(),
1659                files: Files::new(),
1660                assertions: sites
1661                    .iter()
1662                    .map(|(file, line, column)| InventorySite {
1663                        at: Anchor {
1664                            file: (*file).into(),
1665                            line: *line,
1666                            column: *column,
1667                            text: "assert_equal 1, f(1)".into(),
1668                        },
1669                        operation: "assert".into(),
1670                    })
1671                    .collect(),
1672                limitations: vec![],
1673            },
1674        )
1675    }
1676
1677    fn assertion_sources(run: &RubyFrontendRun) -> Vec<String> {
1678        run.request.raw_results[0]
1679            .phases
1680            .iter()
1681            .filter(|phase| phase.kind == "assertion")
1682            .filter_map(|phase| phase.source.clone())
1683            .collect()
1684    }
1685
1686    fn run_with_sites(
1687        name: &str,
1688        sites: &[serde_json::Value],
1689        outcome_file: Option<&str>,
1690        inventory: &RubyAssertionInventory,
1691    ) -> RubyFrontendRun {
1692        let source = "def f(a)\n  a\nend\n";
1693        let mut probe = 0;
1694        let obligations =
1695            build_ruby_obligations("lib/m.rb", source.as_bytes(), &mut probe).unwrap();
1696        let mut outcome = serde_json::json!({"t":"outcome","worker":"main","test":"MTest#test_x","retry":0,"phase":"call","outcome":"passed","xfail":false,"runner":"minitest"});
1697        if let Some(file) = outcome_file {
1698            outcome["file"] = serde_json::json!(file);
1699        }
1700        let mut records = vec![
1701            serde_json::json!({"t":"process","v":1,"run":"run-1","pid":7,"worker":"main","ruby":"4.0.6","executable":"ruby","argv":["test.rb"]}),
1702            serde_json::json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"MTest#test_x","retry":0,"phase":"call"}),
1703            serde_json::json!({"t":"assert","ctx":1}),
1704        ];
1705        records.extend(sites.iter().cloned());
1706        records.push(outcome);
1707        records.push(serde_json::json!({"t":"exit","at":9}));
1708        let directory = temporary(name);
1709        fs::write(directory.join("main.7.a.mmap"), transport(&records)).unwrap();
1710        let run = build_ruby_frontend_run(
1711            &obligations.manifest,
1712            &directory,
1713            "run-1",
1714            "now",
1715            0,
1716            inventory,
1717        )
1718        .unwrap();
1719        validate_frontend_report_request(&run.declaration, &run.request).unwrap();
1720        fs::remove_dir_all(directory).unwrap();
1721        run
1722    }
1723
1724    #[test]
1725    fn an_assertion_site_becomes_a_located_phase_when_the_inventory_names_one() {
1726        // A Ruby backtrace carries no column, so a line is a witness only when
1727        // the inventory holds exactly one site on it. The column reported is
1728        // zero-based, which is what every native manifest reports and what the
1729        // assertion report adds one to.
1730        let inventory = inventory_of(&under("project", ""), &[("test/m_test.rb", 6, 5)]);
1731        let run = run_with_sites(
1732            "asite-located",
1733            &[
1734                serde_json::json!({"t":"asite","ctx":1,"f":under("project", "test/m_test.rb"),"l":6}),
1735            ],
1736            None,
1737            &inventory,
1738        );
1739        assert!(
1740            assertion_sources(&run).contains(&"test/m_test.rb:6:4".to_string()),
1741            "expected a located assertion phase, got {:?}",
1742            assertion_sources(&run)
1743        );
1744    }
1745
1746    #[test]
1747    fn an_ambiguous_or_foreign_assertion_site_witnesses_nothing() {
1748        // Two sites on one line cannot be told apart from a backtrace, and a
1749        // frame outside the project names nothing. Both lose the witness
1750        // rather than guessing one.
1751        let ambiguous = inventory_of(
1752            &under("project", ""),
1753            &[("test/m_test.rb", 6, 5), ("test/m_test.rb", 6, 30)],
1754        );
1755        let run = run_with_sites(
1756            "asite-ambiguous",
1757            &[
1758                serde_json::json!({"t":"asite","ctx":1,"f":under("project", "test/m_test.rb"),"l":6}),
1759            ],
1760            None,
1761            &ambiguous,
1762        );
1763        assert_eq!(
1764            assertion_sources(&run),
1765            vec!["MTest#test_x".to_string()],
1766            "only the per-test assertion phase should remain"
1767        );
1768
1769        let known = inventory_of(&under("project", ""), &[("test/m_test.rb", 6, 5)]);
1770        let outside = run_with_sites(
1771            "asite-outside",
1772            &[
1773                serde_json::json!({"t":"asite","ctx":1,"f":under("elsewhere", "test/m_test.rb"),"l":6}),
1774            ],
1775            None,
1776            &known,
1777        );
1778        assert_eq!(
1779            assertion_sources(&outside),
1780            vec!["MTest#test_x".to_string()]
1781        );
1782    }
1783
1784    #[test]
1785    fn the_runner_names_the_test_file_in_either_path_form() {
1786        // Minitest keeps the path the interpreter loaded, which may be
1787        // relative; a backtrace is absolute. Both name the same project file,
1788        // and an adapter that names none falls back to the identity.
1789        let inventory = inventory_of(&under("project", ""), &[("test/m_test.rb", 6, 5)]);
1790        for reported in [
1791            under("project", "test/m_test.rb"),
1792            "test/m_test.rb".to_owned(),
1793            "./test/m_test.rb".to_owned(),
1794        ] {
1795            let run = run_with_sites("asite-file", &[], Some(reported.as_str()), &inventory);
1796            assert_eq!(
1797                run.request.raw_results[0].test_file.as_deref(),
1798                Some("test/m_test.rb"),
1799                "{reported} should resolve to the project path"
1800            );
1801        }
1802        let without = run_with_sites("asite-nofile", &[], None, &inventory);
1803        assert_eq!(
1804            without.request.raw_results[0].test_file.as_deref(),
1805            Some("MTest#test_x"),
1806            "an adapter that cannot name a file keeps the identity fallback"
1807        );
1808    }
1809
1810    #[test]
1811    fn a_phase_a_runner_reported_more_than_once_is_one_phase() {
1812        // The Python path lost a suite's whole run to a repeated phase id
1813        // (#40). No Ruby runner reports a phase twice today, but the reader is
1814        // the same shape, and a run is too much to lose to it.
1815        let source = "def f(a)\n  a\nend\n";
1816        let mut probe = 0;
1817        let obligations =
1818            build_ruby_obligations("lib/m.rb", source.as_bytes(), &mut probe).unwrap();
1819        let statement = obligations
1820            .manifest
1821            .points
1822            .iter()
1823            .find(|point| point.kind == PointKind::Statement)
1824            .unwrap();
1825        let again = |outcome: &str| {
1826            serde_json::json!({"t":"outcome","worker":"main","test":"MTest#test_x","retry":0,
1827                               "phase":"call","outcome":outcome,"xfail":false,"runner":"minitest"})
1828        };
1829        let records = [
1830            serde_json::json!({"t":"process","v":1,"run":"run-1","pid":7,"worker":"main","ruby":"4.0.6","executable":"ruby","argv":["test.rb"]}),
1831            serde_json::json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"MTest#test_x","retry":0,"phase":"call"}),
1832            serde_json::json!({"t":"hit","ctx":1,"id":statement.id}),
1833            again("passed"),
1834            again("failed"),
1835            again("passed"),
1836            serde_json::json!({"t":"exit","at":9}),
1837        ];
1838        let directory = temporary("repeated-phase");
1839        fs::write(directory.join("main.7.a.mmap"), transport(&records)).unwrap();
1840        let run = build_ruby_frontend_run(
1841            &obligations.manifest,
1842            &directory,
1843            "run-1",
1844            "now",
1845            1,
1846            &RubyAssertionInventory::empty(),
1847        )
1848        .unwrap();
1849        validate_frontend_report_request(&run.declaration, &run.request).unwrap();
1850        let test = &run.request.raw_results[0];
1851        assert_eq!(test.phases.len(), 1, "{:?}", test.phases);
1852        assert_eq!(test.status.as_deref(), Some("failed"));
1853        fs::remove_dir_all(directory).unwrap();
1854    }
1855
1856    #[test]
1857    fn evidence_before_the_first_assertion_links_to_it_when_the_test_passes() {
1858        // The runtime's marker says everything the call phase recorded so far
1859        // ran before an assertion. That evidence carries an assertion phase
1860        // that passed with the test; what ran after the marker, and all of a
1861        // test that failed, stays execution only. A second marker is inert.
1862        let source = "def f(a)\n  a\nend\n\ndef g(b)\n  b\nend\n";
1863        let mut probe = 0;
1864        let obligations =
1865            build_ruby_obligations("lib/m.rb", source.as_bytes(), &mut probe).unwrap();
1866        let statements: Vec<_> = obligations
1867            .manifest
1868            .points
1869            .iter()
1870            .filter(|point| point.kind == PointKind::Statement)
1871            .collect();
1872        let (before, after) = (&statements[0].id, &statements[1].id);
1873        let run_with = |name: &str, outcome: &str| {
1874            let records = [
1875                serde_json::json!({"t":"process","v":1,"run":"run-1","pid":7,"worker":"main","ruby":"4.0.6","executable":"ruby","argv":["test.rb"]}),
1876                serde_json::json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"MTest#test_x","retry":0,"phase":"call"}),
1877                serde_json::json!({"t":"hit","ctx":1,"id":before}),
1878                serde_json::json!({"t":"assert","ctx":1}),
1879                serde_json::json!({"t":"assert","ctx":1}),
1880                serde_json::json!({"t":"hit","ctx":1,"id":after}),
1881                serde_json::json!({"t":"outcome","worker":"main","test":"MTest#test_x","retry":0,"phase":"call","outcome":outcome,"xfail":false,"runner":"minitest"}),
1882                serde_json::json!({"t":"exit","at":9}),
1883            ];
1884            let directory = temporary(name);
1885            fs::write(directory.join("main.7.a.mmap"), transport(&records)).unwrap();
1886            let run = build_ruby_frontend_run(
1887                &obligations.manifest,
1888                &directory,
1889                "run-1",
1890                "now",
1891                0,
1892                &RubyAssertionInventory::empty(),
1893            )
1894            .unwrap();
1895            validate_frontend_report_request(&run.declaration, &run.request).unwrap();
1896            fs::remove_dir_all(directory).unwrap();
1897            run
1898        };
1899        let events_of = |result: &RawTestResult, phase: &str| -> BTreeSet<String> {
1900            result
1901                .runtime
1902                .iter()
1903                .flat_map(|snapshot| snapshot.events.iter())
1904                .filter(|event| event.phase_id.as_deref() == Some(phase))
1905                .map(|event| event.id.clone())
1906                .collect()
1907        };
1908
1909        let run = run_with("asserted-passed", "passed");
1910        let passed = &run.request.raw_results[0];
1911        assert_eq!(passed.test, "MTest#test_x");
1912        let assertion = passed
1913            .phases
1914            .iter()
1915            .find(|phase| phase.kind == "assertion")
1916            .expect("the asserting test carries an assertion phase");
1917        assert_eq!(assertion.status.as_deref(), Some("passed"));
1918        assert_eq!(
1919            events_of(passed, &assertion.id),
1920            BTreeSet::from([before.clone()]),
1921            "only what ran before the marker is the assertion's evidence"
1922        );
1923        let test_phase = passed
1924            .phases
1925            .iter()
1926            .find(|phase| phase.kind == "test")
1927            .unwrap();
1928        assert_eq!(
1929            events_of(passed, &test_phase.id),
1930            BTreeSet::from([before.clone(), after.clone()]),
1931            "the test phase keeps everything it ran"
1932        );
1933        assert_eq!(
1934            run.declaration.runners[0].attribution.assertion,
1935            AttributionPrecision::Exact
1936        );
1937
1938        let run = run_with("asserted-failed", "failed");
1939        let failed = &run.request.raw_results[0];
1940        let assertion = failed
1941            .phases
1942            .iter()
1943            .find(|phase| phase.kind == "assertion")
1944            .unwrap();
1945        assert_eq!(
1946            assertion.status.as_deref(),
1947            Some("failed"),
1948            "a failed test's assertion witnessed nothing"
1949        );
1950    }
1951
1952    #[test]
1953    fn joins_rspec_and_minitest_outcomes_into_exact_results() {
1954        let source = "def f(a, b)\n  if a && b\n    1\n  else\n    0\n  end\nend\n";
1955        let mut probe = 0;
1956        let obligations =
1957            build_ruby_obligations("lib/m.rb", source.as_bytes(), &mut probe).unwrap();
1958        let decision = &obligations.manifest.decisions[0];
1959        let statement = obligations
1960            .manifest
1961            .points
1962            .iter()
1963            .find(|point| point.kind == PointKind::Statement)
1964            .unwrap();
1965        let directory = temporary("join");
1966        let records = [
1967            serde_json::json!({"t":"process","v":1,"run":"run-1","pid":7,"worker":"main","ruby":"4.0.6","executable":"ruby","argv":["rspec"]}),
1968            serde_json::json!({"t":"hit","ctx":0,"id":statement.id}),
1969            serde_json::json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"spec/m_spec.rb[1:1]","retry":0,"phase":"call"}),
1970            serde_json::json!({"t":"dec","ctx":1,"id":decision.id,"v":"22","o":1}),
1971            serde_json::json!({"t":"outcome","worker":"main","test":"spec/m_spec.rb[1:1]","retry":0,"phase":"setup","outcome":"passed","xfail":false,"runner":"rspec"}),
1972            serde_json::json!({"t":"outcome","worker":"main","test":"spec/m_spec.rb[1:1]","retry":0,"phase":"call","outcome":"passed","xfail":false,"runner":"rspec"}),
1973            serde_json::json!({"t":"outcome","worker":"main","test":"spec/m_spec.rb[1:1]","retry":0,"phase":"teardown","outcome":"passed","xfail":false,"runner":"rspec"}),
1974            serde_json::json!({"t":"phase","ctx":2,"at":6,"worker":"main","test":"MTest#test_x","retry":0,"phase":"call"}),
1975            serde_json::json!({"t":"outcome","worker":"main","test":"MTest#test_x","retry":0,"phase":"call","outcome":"skipped","xfail":false,"runner":"minitest"}),
1976            serde_json::json!({"t":"exit","at":9}),
1977        ];
1978        fs::write(directory.join("main.7.a.mmap"), transport(&records)).unwrap();
1979        let run = build_ruby_frontend_run(
1980            &obligations.manifest,
1981            &directory,
1982            "run-1",
1983            "now",
1984            0,
1985            &RubyAssertionInventory::empty(),
1986        )
1987        .unwrap();
1988        validate_frontend_report_request(&run.declaration, &run.request).unwrap();
1989        assert_eq!(run.tests, 2);
1990        let runners = run
1991            .declaration
1992            .runners
1993            .iter()
1994            .map(|runner| runner.runner.clone())
1995            .collect::<Vec<_>>();
1996        assert_eq!(runners, ["minitest", "rspec"]);
1997        assert_eq!(run.ruby_versions, ["4.0.6"]);
1998        fs::remove_dir_all(directory).unwrap();
1999    }
2000
2001    #[test]
2002    fn fails_closed_without_an_interpreter() {
2003        let mut probe = 0;
2004        let obligations = build_ruby_obligations("m.rb", b"x = 1\n", &mut probe).unwrap();
2005        let directory = temporary("empty");
2006        assert!(matches!(
2007            build_ruby_frontend_run(
2008                &obligations.manifest,
2009                &directory,
2010                "run-1",
2011                "now",
2012                0,
2013                &RubyAssertionInventory::empty()
2014            ),
2015            Err(RubyEvidenceError::NoInterpreter)
2016        ));
2017        fs::remove_dir_all(directory).unwrap();
2018    }
2019}