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    })
950}
951
952/// One phase, however many times its runner reported it, as on the Python
953/// path: a phase id is derived from an identity with no room for a repeat, so
954/// a second report of one phase duplicated an id and the run was refused
955/// whole. No Ruby runner reports twice today -- RSpec identifies an example by
956/// position, Minitest and test-unit by class and method, Cucumber by line --
957/// but losing a whole run is too much to leave resting on that.
958fn fold_outcome(
959    outcomes: &mut Vec<(String, String, bool)>,
960    phase: String,
961    outcome: String,
962    xfail: bool,
963) {
964    let Some(reported) = outcomes.iter_mut().find(|(name, _, _)| *name == phase) else {
965        outcomes.push((phase, outcome, xfail));
966        return;
967    };
968    if outcome_severity(&outcome) > outcome_severity(&reported.1) {
969        reported.1 = outcome;
970    }
971    reported.2 |= xfail;
972}
973
974/// The order `attempt_status` reads outcomes in, as one value.
975fn outcome_severity(outcome: &str) -> u8 {
976    match outcome {
977        "failed" | "rerun" | "error" => 2,
978        "skipped" => 1,
979        _ => 0,
980    }
981}
982
983fn attempt_status(outcomes: &[(String, String, bool)]) -> String {
984    if outcomes
985        .iter()
986        .any(|(_, outcome, _)| matches!(outcome.as_str(), "failed" | "rerun" | "error"))
987    {
988        "failed"
989    } else if outcomes.iter().any(|(_, outcome, _)| outcome == "skipped") {
990        "skipped"
991    } else {
992        "passed"
993    }
994    .into()
995}
996
997#[derive(Debug, Clone, PartialEq)]
998pub struct RubyFrontendRun {
999    pub declaration: FrontendRunDeclaration,
1000    pub request: CoverageReportRequest,
1001    pub tests: usize,
1002    pub interpreters: usize,
1003    pub ruby_versions: Vec<String>,
1004}
1005
1006impl RubyFrontendRun {
1007    pub fn archive_entries(&self) -> Result<Vec<EvidenceArchiveEntry>, serde_json::Error> {
1008        let model = PersistedCoverageModel::from_declaration(
1009            self.request
1010                .coverage_model
1011                .as_ref()
1012                .expect("Ruby frontend always declares a coverage model"),
1013        )
1014        .expect("Ruby coverage model is contract-valid");
1015        let mut entries = vec![
1016            EvidenceArchiveEntry {
1017                path: "coverage-model.json".into(),
1018                contents: serde_json::to_vec(&model)?,
1019            },
1020            EvidenceArchiveEntry {
1021                path: "frontend.json".into(),
1022                contents: serde_json::to_vec(&self.declaration)?,
1023            },
1024            EvidenceArchiveEntry {
1025                path: "manifest.json".into(),
1026                contents: serde_json::to_vec(&self.request.manifest)?,
1027            },
1028        ];
1029        for (index, result) in self.request.raw_results.iter().enumerate() {
1030            entries.push(EvidenceArchiveEntry {
1031                path: format!("results/{index:08}/mcdc.json"),
1032                contents: serde_json::to_vec(result)?,
1033            });
1034        }
1035        Ok(entries)
1036    }
1037}
1038
1039/// Join the runtime's evidence directory with the ahead-of-run manifest into
1040/// a protocol-conformant frontend run.
1041pub fn build_ruby_frontend_run(
1042    manifest: &CoverageManifest,
1043    evidence_directory: &Path,
1044    run_id: &str,
1045    generated_at: &str,
1046    test_exit_code: i32,
1047    assertions: &RubyAssertionInventory,
1048) -> Result<RubyFrontendRun, RubyEvidenceError> {
1049    let evidence = read_evidence_directory(evidence_directory, run_id)?;
1050    if evidence.interpreters == 0 {
1051        return Err(RubyEvidenceError::NoInterpreter);
1052    }
1053    if evidence.outcomes.is_empty() {
1054        return Err(RubyEvidenceError::NoTests);
1055    }
1056    let Evidence {
1057        interpreters,
1058        ruby_versions,
1059        per_identity,
1060        background,
1061        outcomes,
1062        runners,
1063        test_files,
1064        sites,
1065        limitations,
1066    } = evidence;
1067    let mut manifest = manifest.clone();
1068    let index = ManifestIndex::new(&manifest);
1069
1070    let mut raw_results = Vec::new();
1071    let mut observed_runners = BTreeSet::new();
1072    let mut identities_by_attempt =
1073        BTreeMap::<(String, String, usize), Vec<(&Identity, &Observations)>>::new();
1074    for (identity, observations) in &per_identity {
1075        identities_by_attempt
1076            .entry((
1077                identity.worker.clone(),
1078                identity.test.clone(),
1079                identity.retry,
1080            ))
1081            .or_default()
1082            .push((identity, observations));
1083    }
1084    for ((worker, test, retry), mut outcomes) in outcomes {
1085        let runner = runners
1086            .get(&(worker.clone(), test.clone(), retry))
1087            .cloned()
1088            .unwrap_or_else(default_runner);
1089        let attempt_identities = identities_by_attempt
1090            .remove(&(worker.clone(), test.clone(), retry))
1091            .unwrap_or_default();
1092        observed_runners.insert(runner.clone());
1093        outcomes.sort_by_key(|(phase, _, _)| match phase.as_str() {
1094            "setup" => 0,
1095            "call" => 1,
1096            _ => 2,
1097        });
1098        let mut phases = Vec::new();
1099        let mut runtime = Vec::new();
1100        let mut observed_phases = BTreeSet::new();
1101        for (position, (phase_name, outcome, xfail)) in outcomes.iter().enumerate() {
1102            observed_phases.insert(phase_name.clone());
1103            let identity = Identity {
1104                worker: worker.clone(),
1105                test: test.clone(),
1106                retry,
1107                phase: phase_name.clone(),
1108            };
1109            let id = phase_id(run_id, &identity);
1110            phases.push(CoveragePhase {
1111                id: id.clone(),
1112                kind: match phase_name.as_str() {
1113                    "call" => "test",
1114                    value => value,
1115                }
1116                .into(),
1117                operation: format!("{runner} {phase_name}"),
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(match outcome.as_str() {
1123                    "rerun" | "error" => "failed".into(),
1124                    value => value.into(),
1125                }),
1126                error: None,
1127            });
1128            if let Some((_, observations)) = attempt_identities
1129                .iter()
1130                .find(|(candidate, _)| candidate.phase == phase_name.as_str())
1131            {
1132                runtime.push(snapshot(&index, observations, &id)?);
1133            }
1134            if phase_name != "call" {
1135                continue;
1136            }
1137            // What the test recorded before its first assertion is that
1138            // assertion's evidence, linked when the phase passed outright:
1139            // a failed, skipped or expected-to-fail phase witnessed nothing.
1140            if let Some((identity, observations)) = attempt_identities
1141                .iter()
1142                .find(|(candidate, _)| candidate.phase == "assertion")
1143            {
1144                observed_phases.insert("assertion".to_owned());
1145                let id = phase_id(run_id, identity);
1146                phases.push(CoveragePhase {
1147                    id: id.clone(),
1148                    kind: "assertion".into(),
1149                    operation: format!("{runner} assertion"),
1150                    source: Some(test.clone()),
1151                    caused_by_phase_id: None,
1152                    started_at_ms: position as i64 * 2 + 1,
1153                    ended_at_ms: Some(position as i64 * 2 + 2),
1154                    status: Some(
1155                        if outcome == "passed" && !*xfail {
1156                            "passed"
1157                        } else {
1158                            "failed"
1159                        }
1160                        .into(),
1161                    ),
1162                    error: None,
1163                });
1164                runtime.push(snapshot(&index, observations, &id)?);
1165                // One phase per assertion site the call phase reached, so an
1166                // assertion map can tell the sites apart. The per-test phase
1167                // above keeps carrying the pre-assertion evidence; these are
1168                // witnesses only, and a site the inventory does not know is
1169                // skipped rather than guessed at.
1170                let attempt = (worker.clone(), test.clone(), retry);
1171                for (path, line) in sites.get(&attempt).into_iter().flatten() {
1172                    let Some(location) = assertions.locate(path, *line) else {
1173                        continue;
1174                    };
1175                    phases.push(CoveragePhase {
1176                        id: stable_id("ruby-assertion", &[run_id, &id, &location]),
1177                        kind: "assertion".into(),
1178                        operation: format!("{runner} assertion at {location}"),
1179                        source: Some(location),
1180                        caused_by_phase_id: Some(id.clone()),
1181                        started_at_ms: position as i64 * 2 + 1,
1182                        ended_at_ms: Some(position as i64 * 2 + 2),
1183                        status: Some(
1184                            if outcome == "passed" && !*xfail {
1185                                "passed"
1186                            } else {
1187                                "failed"
1188                            }
1189                            .into(),
1190                        ),
1191                        error: None,
1192                    });
1193                }
1194            }
1195        }
1196        // A phase the runtime entered but the runner never reported (the worker
1197        // died inside it) is a failed phase with its evidence kept.
1198        for (identity, observations) in attempt_identities {
1199            if !observed_phases.contains(&identity.phase) {
1200                let id = phase_id(run_id, identity);
1201                phases.push(CoveragePhase {
1202                    id: id.clone(),
1203                    kind: match identity.phase.as_str() {
1204                        "call" => "test",
1205                        value => value,
1206                    }
1207                    .into(),
1208                    operation: format!("{runner} {}", identity.phase),
1209                    source: Some(test.clone()),
1210                    caused_by_phase_id: None,
1211                    started_at_ms: phases.len() as i64 * 2 + 1,
1212                    ended_at_ms: None,
1213                    status: Some("failed".into()),
1214                    error: Some("the phase started but the runner reported no outcome".into()),
1215                });
1216                runtime.push(snapshot(&index, observations, &id)?);
1217            }
1218        }
1219        let status = if phases.iter().any(|phase| phase.error.is_some()) {
1220            "failed".into()
1221        } else {
1222            attempt_status(&outcomes)
1223        };
1224        raw_results.push(RawTestResult {
1225            test_id: Some(test.clone()),
1226            scope: Some(scope(run_id, &worker, &test, retry)),
1227            test: test.clone(),
1228            // What the runner named, as the project names it. A runner
1229            // identity is not a path, so the old derivation stays only as a
1230            // fallback for an adapter that cannot name the file.
1231            test_file: test_files
1232                .get(&(worker.clone(), test.clone(), retry))
1233                .and_then(|path| assertions.relative(path))
1234                .or_else(|| test.split("::").next().map(str::to_owned)),
1235            title: test.rsplit("::").next().map(str::to_owned),
1236            retry: Some(retry),
1237            status: Some(status),
1238            expected_status: Some(
1239                if outcomes.iter().any(|(_, _, xfail)| *xfail) {
1240                    "failed"
1241                } else {
1242                    "passed"
1243                }
1244                .into(),
1245            ),
1246            flaky: false,
1247            provenance: TestProvenance {
1248                runner: runner.clone(),
1249                kind: "unit".into(),
1250                project: None,
1251                source: RUBY_FRONTEND_VERSION.into(),
1252            },
1253            role: "test".into(),
1254            // What a Ruby test is recorded as reaching is its own and is not
1255            // all of it. The runtime asks Ruby's Coverage for one-shot lines,
1256            // which report a line the first time it executes in the process
1257            // and never again -- so the first test to reach a line is credited
1258            // with it and every later test that runs the same line is recorded
1259            // as having reached nothing there. Cheap to collect, and it makes
1260            // a test's hits a lower bound rather than a description.
1261            attribution: crate::coverage_report::ATTRIBUTION_PARTIAL.into(),
1262            phases,
1263            runtime,
1264            browser: Vec::new(),
1265            server: Vec::new(),
1266        });
1267    }
1268    // Phases with observations whose test never produced any outcome at all
1269    // (for example a worker killed during its first phase).
1270    let default_observed = observed_runners
1271        .iter()
1272        .next()
1273        .cloned()
1274        .unwrap_or_else(default_runner);
1275    for ((worker, test, retry), identities) in identities_by_attempt {
1276        let runner = default_observed.clone();
1277        let mut phases = Vec::new();
1278        let mut runtime = Vec::new();
1279        for (position, (identity, observations)) in identities.iter().enumerate() {
1280            let id = phase_id(run_id, identity);
1281            phases.push(CoveragePhase {
1282                id: id.clone(),
1283                kind: match identity.phase.as_str() {
1284                    "call" => "test",
1285                    value => value,
1286                }
1287                .into(),
1288                operation: format!("{runner} {}", identity.phase),
1289                source: Some(test.clone()),
1290                caused_by_phase_id: None,
1291                started_at_ms: position as i64 * 2 + 1,
1292                ended_at_ms: None,
1293                status: Some("failed".into()),
1294                error: Some("the phase started but the runner reported no outcome".into()),
1295            });
1296            runtime.push(snapshot(&index, observations, &id)?);
1297        }
1298        raw_results.push(RawTestResult {
1299            test_id: Some(test.clone()),
1300            scope: Some(scope(run_id, &worker, &test, retry)),
1301            test: test.clone(),
1302            // What the runner named, as the project names it. A runner
1303            // identity is not a path, so the old derivation stays only as a
1304            // fallback for an adapter that cannot name the file.
1305            test_file: test_files
1306                .get(&(worker.clone(), test.clone(), retry))
1307                .and_then(|path| assertions.relative(path))
1308                .or_else(|| test.split("::").next().map(str::to_owned)),
1309            title: test.rsplit("::").next().map(str::to_owned),
1310            retry: Some(retry),
1311            status: Some("failed".into()),
1312            expected_status: Some("passed".into()),
1313            flaky: false,
1314            provenance: TestProvenance {
1315                runner: runner.clone(),
1316                kind: "unit".into(),
1317                project: None,
1318                source: RUBY_FRONTEND_VERSION.into(),
1319            },
1320            role: "test".into(),
1321            // What a Ruby test is recorded as reaching is its own and is not
1322            // all of it. The runtime asks Ruby's Coverage for one-shot lines,
1323            // which report a line the first time it executes in the process
1324            // and never again -- so the first test to reach a line is credited
1325            // with it and every later test that runs the same line is recorded
1326            // as having reached nothing there. Cheap to collect, and it makes
1327            // a test's hits a lower bound rather than a description.
1328            attribution: crate::coverage_report::ATTRIBUTION_PARTIAL.into(),
1329            phases,
1330            runtime,
1331            browser: Vec::new(),
1332            server: Vec::new(),
1333        });
1334    }
1335    for (worker, observations) in &background {
1336        if observations.hits.is_empty() && observations.vectors.is_empty() {
1337            continue;
1338        }
1339        let test = format!("__supercov_background__:{worker}");
1340        let identity = Identity {
1341            worker: worker.clone(),
1342            test: test.clone(),
1343            retry: 0,
1344            phase: "background".into(),
1345        };
1346        let phase = phase_id(run_id, &identity);
1347        raw_results.push(RawTestResult {
1348            test_id: Some(test.clone()),
1349            scope: Some(scope(run_id, worker, &test, 0)),
1350            test: "Ruby load and background execution".into(),
1351            test_file: None,
1352            title: None,
1353            retry: Some(0),
1354            status: Some("unknown".into()),
1355            expected_status: None,
1356            flaky: false,
1357            provenance: TestProvenance {
1358                runner: default_observed.clone(),
1359                kind: "unit".into(),
1360                project: None,
1361                source: RUBY_FRONTEND_VERSION.into(),
1362            },
1363            role: "background".into(),
1364            attribution: crate::coverage_report::ATTRIBUTION_EXACT.into(),
1365            phases: vec![CoveragePhase {
1366                id: phase.clone(),
1367                kind: "background".into(),
1368                operation: "Ruby load background".into(),
1369                source: None,
1370                caused_by_phase_id: None,
1371                started_at_ms: 0,
1372                ended_at_ms: Some(0),
1373                status: Some("passed".into()),
1374                error: None,
1375            }],
1376            runtime: vec![snapshot(&index, observations, &phase)?],
1377            browser: Vec::new(),
1378            server: Vec::new(),
1379        });
1380    }
1381
1382    // Runtime-detected limitations: obligations the runtime could not map
1383    // become unmeasured, and every limitation ID joins the manifest so the
1384    // declaration and manifest agree.
1385    let mut limitation_ids = manifest
1386        .limitations
1387        .iter()
1388        .filter_map(|item| item.get("id").and_then(serde_json::Value::as_str))
1389        .map(str::to_owned)
1390        .collect::<BTreeSet<_>>();
1391    let mut unmeasured = manifest.unmeasured.iter().cloned().collect::<BTreeSet<_>>();
1392    let mut new_limitations = Vec::new();
1393    for limitation in &limitations {
1394        if let Some(obligation) = &limitation.obligation {
1395            if !index.lines.contains_key(obligation.as_str()) {
1396                return Err(RubyEvidenceError::UnknownObligation(obligation.clone()));
1397            }
1398            unmeasured.insert(obligation.clone());
1399        } else if let Some(file) = &limitation.file {
1400            // A code-object mapping failure or missing debug ranges prevents
1401            // every obligation in that source file from being observed. Mark
1402            // the whole file unmeasured instead of presenting its denominator
1403            // as ordinary uncovered code.
1404            unmeasured.extend(
1405                index
1406                    .lines
1407                    .iter()
1408                    .filter(|(_, (obligation_file, _))| obligation_file == file)
1409                    .map(|(id, _)| (*id).to_owned()),
1410            );
1411        }
1412        if limitation_ids.insert(limitation.id.clone()) {
1413            let (file, line) = limitation
1414                .obligation
1415                .as_deref()
1416                .and_then(|id| index.lines.get(id).cloned())
1417                .unwrap_or_else(|| {
1418                    (
1419                        limitation.file.clone().unwrap_or_else(|| {
1420                            manifest
1421                                .points
1422                                .first()
1423                                .map_or(".".into(), |point| point.file.clone())
1424                        }),
1425                        1,
1426                    )
1427                });
1428            let source = limitation
1429                .obligation
1430                .as_deref()
1431                .and_then(|id| index.sources.get(id))
1432                .map(|source| source.lines().next().unwrap_or_default().to_owned())
1433                .unwrap_or_default();
1434            new_limitations.push(json!({
1435                "id": limitation.id,
1436                "kind": "semantic-safety",
1437                "file": file,
1438                "line": line,
1439                "column": 0,
1440                "source": source,
1441                "reason": limitation.reason
1442            }));
1443        }
1444    }
1445    manifest.limitations.extend(new_limitations);
1446    manifest.unmeasured = unmeasured.into_iter().collect();
1447    let structural_limitations = limitation_ids.into_iter().collect::<Vec<_>>();
1448
1449    // Retries are separate raw results so their coverage remains attempt
1450    // exact, but the public lifecycle diagnostic reports logical tests rather
1451    // than inflating the count when a flaky test is rerun.
1452    let tests = raw_results
1453        .iter()
1454        .filter(|raw| raw.role == "test")
1455        .map(|raw| raw.test.as_str())
1456        .collect::<BTreeSet<_>>()
1457        .len();
1458    Ok(RubyFrontendRun {
1459        declaration: FrontendRunDeclaration {
1460            protocol_version: LANGUAGE_FRONTEND_PROTOCOL_VERSION,
1461            frontend_id: "ruby".into(),
1462            frontend_version: RUBY_FRONTEND_VERSION.into(),
1463            language: "ruby".into(),
1464            structural_source: StructuralSource::OwnedProbes,
1465            runners: observed_runners
1466                .iter()
1467                .map(|runner| FrontendRunnerDeclaration {
1468                    runner: runner.clone(),
1469                    execution_model: ExecutionModel::SerialInProcess,
1470                    attribution: FrontendAttribution {
1471                        run: AttributionPrecision::Exact,
1472                        worker: AttributionPrecision::Exact,
1473                        test: AttributionPrecision::Exact,
1474                        retry: AttributionPrecision::Exact,
1475                        phase: AttributionPrecision::Exact,
1476                        action: AttributionPrecision::Unavailable,
1477                        assertion: AttributionPrecision::Exact,
1478                    },
1479                    limitations: vec![
1480                        FrontendLimitation {
1481                            id: format!("ruby-{runner}-action-linkage"),
1482                            scopes: vec![FrontendLimitationScope::Action],
1483                            reason: format!("{runner} exposes no general action lifecycle"),
1484                        },
1485                        // Declared because it was not, and the declaration is
1486                        // what a reader checks a number against. Ruby's
1487                        // Coverage reports a line the first time it executes
1488                        // in the process and never again, which is what makes
1489                        // collecting it cheap: the first test to reach a line
1490                        // is credited with it and every later test that runs
1491                        // the same line is recorded against none of it. What a
1492                        // test is credited with is its own; what it is not
1493                        // credited with is not evidence it did not run.
1494                        FrontendLimitation {
1495                            id: format!("ruby-{runner}-first-sighting-lines"),
1496                            scopes: vec![FrontendLimitationScope::Test],
1497                            reason:
1498                                "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"
1499                                    .into(),
1500                        },
1501                    ],
1502                })
1503                .collect(),
1504            structural_limitations,
1505        },
1506        request: CoverageReportRequest {
1507            run_id: run_id.into(),
1508            manifest,
1509            raw_results,
1510            generated_at: generated_at.into(),
1511            coverage_model: Some(ruby_coverage_model()),
1512            integrity: None,
1513            test_exit_code: ExitCodeInput::Present(Some(test_exit_code)),
1514        },
1515        tests,
1516        interpreters,
1517        ruby_versions: ruby_versions.into_iter().collect(),
1518    })
1519}
1520
1521#[cfg(test)]
1522mod tests {
1523    use super::*;
1524    use crate::{
1525        coverage_analysis::PointKind, frontend_protocol::validate_frontend_report_request,
1526        ruby_instrumenter::build_ruby_obligations,
1527    };
1528
1529    fn frame(payload: &[u8]) -> Vec<u8> {
1530        let mut bytes = vec![1u8, 0, 0, 0];
1531        bytes.extend_from_slice(&(payload.len() as u32).to_le_bytes());
1532        bytes.extend_from_slice(&transport_checksum(payload).to_le_bytes());
1533        bytes.extend_from_slice(&[0, 0, 0, 0]);
1534        bytes.extend_from_slice(payload);
1535        while bytes.len() % 8 != 0 {
1536            bytes.push(0);
1537        }
1538        bytes
1539    }
1540
1541    fn transport(records: &[serde_json::Value]) -> Vec<u8> {
1542        let mut body = Vec::new();
1543        for record in records {
1544            body.extend(frame(record.to_string().as_bytes()));
1545        }
1546        let capacity = TRANSPORT_HEADER_SIZE + body.len();
1547        let mut bytes = vec![0u8; TRANSPORT_HEADER_SIZE];
1548        bytes[..8].copy_from_slice(TRANSPORT_MAGIC);
1549        bytes[8..12].copy_from_slice(&TRANSPORT_VERSION.to_le_bytes());
1550        bytes[12..16].copy_from_slice(&(TRANSPORT_HEADER_SIZE as u32).to_le_bytes());
1551        bytes[16..24].copy_from_slice(&(capacity as u64).to_le_bytes());
1552        bytes[32..40].copy_from_slice(&7u64.to_le_bytes());
1553        bytes.extend(body);
1554        bytes
1555    }
1556
1557    fn temporary(name: &str) -> std::path::PathBuf {
1558        let nonce = std::time::SystemTime::now()
1559            .duration_since(std::time::UNIX_EPOCH)
1560            .unwrap()
1561            .as_nanos();
1562        let path = std::env::temp_dir().join(format!(
1563            "supercov-ruby-evidence-{}-{nonce}-{name}",
1564            std::process::id()
1565        ));
1566        fs::create_dir_all(&path).unwrap();
1567        path
1568    }
1569
1570    #[test]
1571    fn declares_the_gap_when_a_process_never_reported() {
1572        // Coverage's own result is read in the `at_exit` that writes the exit
1573        // record, so a transport without one belongs to a process that was
1574        // killed or left through `exit!`. What it observed is unrecoverable and
1575        // unknowable, so the run says so rather than reporting those lines as
1576        // uncovered. The same records WITH an exit record must stay silent, or
1577        // every ordinary run would claim a gap it does not have.
1578        let source = "def f(a)\n  a\nend\n";
1579        let mut probe = 0;
1580        let obligations =
1581            build_ruby_obligations("lib/m.rb", source.as_bytes(), &mut probe).unwrap();
1582        let statement = obligations
1583            .manifest
1584            .points
1585            .iter()
1586            .find(|point| point.kind == PointKind::Statement)
1587            .unwrap();
1588        let base = [
1589            serde_json::json!({"t":"process","v":1,"run":"run-1","pid":7,"worker":"main","ruby":"4.0.6","executable":"ruby","argv":["child.rb"]}),
1590            serde_json::json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"MTest#test_x","retry":0,"phase":"call"}),
1591            serde_json::json!({"t":"hit","ctx":1,"id":statement.id}),
1592            serde_json::json!({"t":"outcome","worker":"main","test":"MTest#test_x","retry":0,"phase":"call","outcome":"passed","xfail":false,"runner":"minitest"}),
1593        ];
1594
1595        let killed = temporary("killed");
1596        fs::write(killed.join("main.7.a.mmap"), transport(&base)).unwrap();
1597        let run = build_ruby_frontend_run(
1598            &obligations.manifest,
1599            &killed,
1600            "run-1",
1601            "now",
1602            0,
1603            &RubyAssertionInventory::empty(),
1604        )
1605        .unwrap();
1606        let declared = serde_json::to_string(&run.request.manifest.limitations).unwrap();
1607        assert!(
1608            declared.contains("ruby-process-did-not-report"),
1609            "a process that never reported must declare the gap: {declared}"
1610        );
1611        fs::remove_dir_all(&killed).unwrap();
1612
1613        let mut clean = base.to_vec();
1614        clean.push(serde_json::json!({"t":"exit","at":9}));
1615        let reported = temporary("reported");
1616        fs::write(reported.join("main.7.a.mmap"), transport(&clean)).unwrap();
1617        let run = build_ruby_frontend_run(
1618            &obligations.manifest,
1619            &reported,
1620            "run-1",
1621            "now",
1622            0,
1623            &RubyAssertionInventory::empty(),
1624        )
1625        .unwrap();
1626        let declared = serde_json::to_string(&run.request.manifest.limitations).unwrap();
1627        assert!(
1628            !declared.contains("ruby-process-did-not-report"),
1629            "a process that reported cleanly must declare nothing: {declared}"
1630        );
1631        fs::remove_dir_all(&reported).unwrap();
1632    }
1633
1634    // A path is only absolute in the platform's own spelling: "/project" is a
1635    // relative path on Windows, where an absolute one needs a drive. The
1636    // runtimes report whatever the interpreter loaded, so these fixtures have
1637    // to speak the host's dialect too.
1638    fn under(first: &str, rest: &str) -> String {
1639        let mut path = PathBuf::from(if cfg!(windows) {
1640            format!("C:\\{first}")
1641        } else {
1642            format!("/{first}")
1643        });
1644        for part in rest.split('/').filter(|part| !part.is_empty()) {
1645            path.push(part);
1646        }
1647        path.to_string_lossy().into_owned()
1648    }
1649
1650    fn inventory_of(root: &str, sites: &[(&str, usize, usize)]) -> RubyAssertionInventory {
1651        use crate::assertion_map::{Anchor, Files, Inputs, InventorySite};
1652        RubyAssertionInventory::new(
1653            Path::new(root),
1654            &Inputs {
1655                schema_version: 1,
1656                language: "ruby".into(),
1657                context_digest: "context".into(),
1658                files: Files::new(),
1659                assertions: sites
1660                    .iter()
1661                    .map(|(file, line, column)| InventorySite {
1662                        at: Anchor {
1663                            file: (*file).into(),
1664                            line: *line,
1665                            column: *column,
1666                            text: "assert_equal 1, f(1)".into(),
1667                        },
1668                        operation: "assert".into(),
1669                    })
1670                    .collect(),
1671                limitations: vec![],
1672            },
1673        )
1674    }
1675
1676    fn assertion_sources(run: &RubyFrontendRun) -> Vec<String> {
1677        run.request.raw_results[0]
1678            .phases
1679            .iter()
1680            .filter(|phase| phase.kind == "assertion")
1681            .filter_map(|phase| phase.source.clone())
1682            .collect()
1683    }
1684
1685    fn run_with_sites(
1686        name: &str,
1687        sites: &[serde_json::Value],
1688        outcome_file: Option<&str>,
1689        inventory: &RubyAssertionInventory,
1690    ) -> RubyFrontendRun {
1691        let source = "def f(a)\n  a\nend\n";
1692        let mut probe = 0;
1693        let obligations =
1694            build_ruby_obligations("lib/m.rb", source.as_bytes(), &mut probe).unwrap();
1695        let mut outcome = serde_json::json!({"t":"outcome","worker":"main","test":"MTest#test_x","retry":0,"phase":"call","outcome":"passed","xfail":false,"runner":"minitest"});
1696        if let Some(file) = outcome_file {
1697            outcome["file"] = serde_json::json!(file);
1698        }
1699        let mut records = vec![
1700            serde_json::json!({"t":"process","v":1,"run":"run-1","pid":7,"worker":"main","ruby":"4.0.6","executable":"ruby","argv":["test.rb"]}),
1701            serde_json::json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"MTest#test_x","retry":0,"phase":"call"}),
1702            serde_json::json!({"t":"assert","ctx":1}),
1703        ];
1704        records.extend(sites.iter().cloned());
1705        records.push(outcome);
1706        records.push(serde_json::json!({"t":"exit","at":9}));
1707        let directory = temporary(name);
1708        fs::write(directory.join("main.7.a.mmap"), transport(&records)).unwrap();
1709        let run = build_ruby_frontend_run(
1710            &obligations.manifest,
1711            &directory,
1712            "run-1",
1713            "now",
1714            0,
1715            inventory,
1716        )
1717        .unwrap();
1718        validate_frontend_report_request(&run.declaration, &run.request).unwrap();
1719        fs::remove_dir_all(directory).unwrap();
1720        run
1721    }
1722
1723    #[test]
1724    fn an_assertion_site_becomes_a_located_phase_when_the_inventory_names_one() {
1725        // A Ruby backtrace carries no column, so a line is a witness only when
1726        // the inventory holds exactly one site on it. The column reported is
1727        // zero-based, which is what every native manifest reports and what the
1728        // assertion report adds one to.
1729        let inventory = inventory_of(&under("project", ""), &[("test/m_test.rb", 6, 5)]);
1730        let run = run_with_sites(
1731            "asite-located",
1732            &[
1733                serde_json::json!({"t":"asite","ctx":1,"f":under("project", "test/m_test.rb"),"l":6}),
1734            ],
1735            None,
1736            &inventory,
1737        );
1738        assert!(
1739            assertion_sources(&run).contains(&"test/m_test.rb:6:4".to_string()),
1740            "expected a located assertion phase, got {:?}",
1741            assertion_sources(&run)
1742        );
1743    }
1744
1745    #[test]
1746    fn an_ambiguous_or_foreign_assertion_site_witnesses_nothing() {
1747        // Two sites on one line cannot be told apart from a backtrace, and a
1748        // frame outside the project names nothing. Both lose the witness
1749        // rather than guessing one.
1750        let ambiguous = inventory_of(
1751            &under("project", ""),
1752            &[("test/m_test.rb", 6, 5), ("test/m_test.rb", 6, 30)],
1753        );
1754        let run = run_with_sites(
1755            "asite-ambiguous",
1756            &[
1757                serde_json::json!({"t":"asite","ctx":1,"f":under("project", "test/m_test.rb"),"l":6}),
1758            ],
1759            None,
1760            &ambiguous,
1761        );
1762        assert_eq!(
1763            assertion_sources(&run),
1764            vec!["MTest#test_x".to_string()],
1765            "only the per-test assertion phase should remain"
1766        );
1767
1768        let known = inventory_of(&under("project", ""), &[("test/m_test.rb", 6, 5)]);
1769        let outside = run_with_sites(
1770            "asite-outside",
1771            &[
1772                serde_json::json!({"t":"asite","ctx":1,"f":under("elsewhere", "test/m_test.rb"),"l":6}),
1773            ],
1774            None,
1775            &known,
1776        );
1777        assert_eq!(
1778            assertion_sources(&outside),
1779            vec!["MTest#test_x".to_string()]
1780        );
1781    }
1782
1783    #[test]
1784    fn the_runner_names_the_test_file_in_either_path_form() {
1785        // Minitest keeps the path the interpreter loaded, which may be
1786        // relative; a backtrace is absolute. Both name the same project file,
1787        // and an adapter that names none falls back to the identity.
1788        let inventory = inventory_of(&under("project", ""), &[("test/m_test.rb", 6, 5)]);
1789        for reported in [
1790            under("project", "test/m_test.rb"),
1791            "test/m_test.rb".to_owned(),
1792            "./test/m_test.rb".to_owned(),
1793        ] {
1794            let run = run_with_sites("asite-file", &[], Some(reported.as_str()), &inventory);
1795            assert_eq!(
1796                run.request.raw_results[0].test_file.as_deref(),
1797                Some("test/m_test.rb"),
1798                "{reported} should resolve to the project path"
1799            );
1800        }
1801        let without = run_with_sites("asite-nofile", &[], None, &inventory);
1802        assert_eq!(
1803            without.request.raw_results[0].test_file.as_deref(),
1804            Some("MTest#test_x"),
1805            "an adapter that cannot name a file keeps the identity fallback"
1806        );
1807    }
1808
1809    #[test]
1810    fn a_phase_a_runner_reported_more_than_once_is_one_phase() {
1811        // The Python path lost a suite's whole run to a repeated phase id
1812        // (#40). No Ruby runner reports a phase twice today, but the reader is
1813        // the same shape, and a run is too much to lose to it.
1814        let source = "def f(a)\n  a\nend\n";
1815        let mut probe = 0;
1816        let obligations =
1817            build_ruby_obligations("lib/m.rb", source.as_bytes(), &mut probe).unwrap();
1818        let statement = obligations
1819            .manifest
1820            .points
1821            .iter()
1822            .find(|point| point.kind == PointKind::Statement)
1823            .unwrap();
1824        let again = |outcome: &str| {
1825            serde_json::json!({"t":"outcome","worker":"main","test":"MTest#test_x","retry":0,
1826                               "phase":"call","outcome":outcome,"xfail":false,"runner":"minitest"})
1827        };
1828        let records = [
1829            serde_json::json!({"t":"process","v":1,"run":"run-1","pid":7,"worker":"main","ruby":"4.0.6","executable":"ruby","argv":["test.rb"]}),
1830            serde_json::json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"MTest#test_x","retry":0,"phase":"call"}),
1831            serde_json::json!({"t":"hit","ctx":1,"id":statement.id}),
1832            again("passed"),
1833            again("failed"),
1834            again("passed"),
1835            serde_json::json!({"t":"exit","at":9}),
1836        ];
1837        let directory = temporary("repeated-phase");
1838        fs::write(directory.join("main.7.a.mmap"), transport(&records)).unwrap();
1839        let run = build_ruby_frontend_run(
1840            &obligations.manifest,
1841            &directory,
1842            "run-1",
1843            "now",
1844            1,
1845            &RubyAssertionInventory::empty(),
1846        )
1847        .unwrap();
1848        validate_frontend_report_request(&run.declaration, &run.request).unwrap();
1849        let test = &run.request.raw_results[0];
1850        assert_eq!(test.phases.len(), 1, "{:?}", test.phases);
1851        assert_eq!(test.status.as_deref(), Some("failed"));
1852        fs::remove_dir_all(directory).unwrap();
1853    }
1854
1855    #[test]
1856    fn evidence_before_the_first_assertion_links_to_it_when_the_test_passes() {
1857        // The runtime's marker says everything the call phase recorded so far
1858        // ran before an assertion. That evidence carries an assertion phase
1859        // that passed with the test; what ran after the marker, and all of a
1860        // test that failed, stays execution only. A second marker is inert.
1861        let source = "def f(a)\n  a\nend\n\ndef g(b)\n  b\nend\n";
1862        let mut probe = 0;
1863        let obligations =
1864            build_ruby_obligations("lib/m.rb", source.as_bytes(), &mut probe).unwrap();
1865        let statements: Vec<_> = obligations
1866            .manifest
1867            .points
1868            .iter()
1869            .filter(|point| point.kind == PointKind::Statement)
1870            .collect();
1871        let (before, after) = (&statements[0].id, &statements[1].id);
1872        let run_with = |name: &str, outcome: &str| {
1873            let records = [
1874                serde_json::json!({"t":"process","v":1,"run":"run-1","pid":7,"worker":"main","ruby":"4.0.6","executable":"ruby","argv":["test.rb"]}),
1875                serde_json::json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"MTest#test_x","retry":0,"phase":"call"}),
1876                serde_json::json!({"t":"hit","ctx":1,"id":before}),
1877                serde_json::json!({"t":"assert","ctx":1}),
1878                serde_json::json!({"t":"assert","ctx":1}),
1879                serde_json::json!({"t":"hit","ctx":1,"id":after}),
1880                serde_json::json!({"t":"outcome","worker":"main","test":"MTest#test_x","retry":0,"phase":"call","outcome":outcome,"xfail":false,"runner":"minitest"}),
1881                serde_json::json!({"t":"exit","at":9}),
1882            ];
1883            let directory = temporary(name);
1884            fs::write(directory.join("main.7.a.mmap"), transport(&records)).unwrap();
1885            let run = build_ruby_frontend_run(
1886                &obligations.manifest,
1887                &directory,
1888                "run-1",
1889                "now",
1890                0,
1891                &RubyAssertionInventory::empty(),
1892            )
1893            .unwrap();
1894            validate_frontend_report_request(&run.declaration, &run.request).unwrap();
1895            fs::remove_dir_all(directory).unwrap();
1896            run
1897        };
1898        let events_of = |result: &RawTestResult, phase: &str| -> BTreeSet<String> {
1899            result
1900                .runtime
1901                .iter()
1902                .flat_map(|snapshot| snapshot.events.iter())
1903                .filter(|event| event.phase_id.as_deref() == Some(phase))
1904                .map(|event| event.id.clone())
1905                .collect()
1906        };
1907
1908        let run = run_with("asserted-passed", "passed");
1909        let passed = &run.request.raw_results[0];
1910        assert_eq!(passed.test, "MTest#test_x");
1911        let assertion = passed
1912            .phases
1913            .iter()
1914            .find(|phase| phase.kind == "assertion")
1915            .expect("the asserting test carries an assertion phase");
1916        assert_eq!(assertion.status.as_deref(), Some("passed"));
1917        assert_eq!(
1918            events_of(passed, &assertion.id),
1919            BTreeSet::from([before.clone()]),
1920            "only what ran before the marker is the assertion's evidence"
1921        );
1922        let test_phase = passed
1923            .phases
1924            .iter()
1925            .find(|phase| phase.kind == "test")
1926            .unwrap();
1927        assert_eq!(
1928            events_of(passed, &test_phase.id),
1929            BTreeSet::from([before.clone(), after.clone()]),
1930            "the test phase keeps everything it ran"
1931        );
1932        assert_eq!(
1933            run.declaration.runners[0].attribution.assertion,
1934            AttributionPrecision::Exact
1935        );
1936
1937        let run = run_with("asserted-failed", "failed");
1938        let failed = &run.request.raw_results[0];
1939        let assertion = failed
1940            .phases
1941            .iter()
1942            .find(|phase| phase.kind == "assertion")
1943            .unwrap();
1944        assert_eq!(
1945            assertion.status.as_deref(),
1946            Some("failed"),
1947            "a failed test's assertion witnessed nothing"
1948        );
1949    }
1950
1951    #[test]
1952    fn joins_rspec_and_minitest_outcomes_into_exact_results() {
1953        let source = "def f(a, b)\n  if a && b\n    1\n  else\n    0\n  end\nend\n";
1954        let mut probe = 0;
1955        let obligations =
1956            build_ruby_obligations("lib/m.rb", source.as_bytes(), &mut probe).unwrap();
1957        let decision = &obligations.manifest.decisions[0];
1958        let statement = obligations
1959            .manifest
1960            .points
1961            .iter()
1962            .find(|point| point.kind == PointKind::Statement)
1963            .unwrap();
1964        let directory = temporary("join");
1965        let records = [
1966            serde_json::json!({"t":"process","v":1,"run":"run-1","pid":7,"worker":"main","ruby":"4.0.6","executable":"ruby","argv":["rspec"]}),
1967            serde_json::json!({"t":"hit","ctx":0,"id":statement.id}),
1968            serde_json::json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"spec/m_spec.rb[1:1]","retry":0,"phase":"call"}),
1969            serde_json::json!({"t":"dec","ctx":1,"id":decision.id,"v":"22","o":1}),
1970            serde_json::json!({"t":"outcome","worker":"main","test":"spec/m_spec.rb[1:1]","retry":0,"phase":"setup","outcome":"passed","xfail":false,"runner":"rspec"}),
1971            serde_json::json!({"t":"outcome","worker":"main","test":"spec/m_spec.rb[1:1]","retry":0,"phase":"call","outcome":"passed","xfail":false,"runner":"rspec"}),
1972            serde_json::json!({"t":"outcome","worker":"main","test":"spec/m_spec.rb[1:1]","retry":0,"phase":"teardown","outcome":"passed","xfail":false,"runner":"rspec"}),
1973            serde_json::json!({"t":"phase","ctx":2,"at":6,"worker":"main","test":"MTest#test_x","retry":0,"phase":"call"}),
1974            serde_json::json!({"t":"outcome","worker":"main","test":"MTest#test_x","retry":0,"phase":"call","outcome":"skipped","xfail":false,"runner":"minitest"}),
1975            serde_json::json!({"t":"exit","at":9}),
1976        ];
1977        fs::write(directory.join("main.7.a.mmap"), transport(&records)).unwrap();
1978        let run = build_ruby_frontend_run(
1979            &obligations.manifest,
1980            &directory,
1981            "run-1",
1982            "now",
1983            0,
1984            &RubyAssertionInventory::empty(),
1985        )
1986        .unwrap();
1987        validate_frontend_report_request(&run.declaration, &run.request).unwrap();
1988        assert_eq!(run.tests, 2);
1989        let runners = run
1990            .declaration
1991            .runners
1992            .iter()
1993            .map(|runner| runner.runner.clone())
1994            .collect::<Vec<_>>();
1995        assert_eq!(runners, ["minitest", "rspec"]);
1996        assert_eq!(run.ruby_versions, ["4.0.6"]);
1997        fs::remove_dir_all(directory).unwrap();
1998    }
1999
2000    #[test]
2001    fn fails_closed_without_an_interpreter() {
2002        let mut probe = 0;
2003        let obligations = build_ruby_obligations("m.rb", b"x = 1\n", &mut probe).unwrap();
2004        let directory = temporary("empty");
2005        assert!(matches!(
2006            build_ruby_frontend_run(
2007                &obligations.manifest,
2008                &directory,
2009                "run-1",
2010                "now",
2011                0,
2012                &RubyAssertionInventory::empty()
2013            ),
2014            Err(RubyEvidenceError::NoInterpreter)
2015        ));
2016        fs::remove_dir_all(directory).unwrap();
2017    }
2018}