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},
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    },
90    Hit {
91        ctx: u64,
92        id: String,
93    },
94    Dec {
95        ctx: u64,
96        id: String,
97        v: String,
98        o: u8,
99    },
100    Limitation {
101        id: String,
102        reason: String,
103        #[serde(default)]
104        file: Option<String>,
105        #[serde(default)]
106        obligation: Option<String>,
107    },
108    Exit {
109        at: i64,
110    },
111}
112
113#[derive(Debug)]
114pub enum RubyEvidenceError {
115    Io(String),
116    UnsafeEntry(String),
117    InvalidRecord {
118        file: String,
119        line: usize,
120        reason: String,
121    },
122    InvalidTransport {
123        file: String,
124        reason: String,
125    },
126    DroppedRecords {
127        file: String,
128        count: u64,
129    },
130    RunMismatch {
131        expected: String,
132        actual: String,
133    },
134    UnsupportedVersion(u32),
135    UnknownContext {
136        file: String,
137        line: usize,
138        context: u64,
139    },
140    UnknownObligation(String),
141    InvalidVector {
142        id: String,
143        expected: usize,
144        actual: usize,
145    },
146    NoInterpreter,
147    NoTests,
148    UnsupportedRuby(String),
149}
150
151impl std::fmt::Display for RubyEvidenceError {
152    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
153        match self {
154            Self::Io(reason) => write!(formatter, "could not read Ruby evidence: {reason}"),
155            Self::UnsafeEntry(name) => write!(formatter, "unsafe Ruby evidence entry: {name}"),
156            Self::InvalidRecord { file, line, reason } => {
157                write!(formatter, "invalid Ruby evidence record {file}:{line}: {reason}")
158            }
159            Self::InvalidTransport { file, reason } => {
160                write!(formatter, "invalid Ruby evidence transport {file}: {reason}")
161            }
162            Self::DroppedRecords { file, count } => write!(
163                formatter,
164                "Ruby evidence transport {file} exhausted its bounded capacity and dropped {count} record(s)"
165            ),
166            Self::RunMismatch { expected, actual } => write!(
167                formatter,
168                "Ruby evidence belongs to run {actual}, expected {expected}"
169            ),
170            Self::UnsupportedVersion(version) => {
171                write!(formatter, "unsupported Ruby evidence version {version}")
172            }
173            Self::UnknownContext { file, line, context } => write!(
174                formatter,
175                "Ruby evidence {file}:{line} references undeclared context {context}"
176            ),
177            Self::UnknownObligation(id) => {
178                write!(formatter, "Ruby runtime reported an unknown obligation: {id}")
179            }
180            Self::InvalidVector {
181                id,
182                expected,
183                actual,
184            } => write!(
185                formatter,
186                "Ruby decision {id} reported {actual} condition values, expected {expected}"
187            ),
188            Self::NoInterpreter => formatter.write_str(
189                "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)",
190            ),
191            Self::NoTests => formatter.write_str(
192                "the Ruby run produced no test outcomes; Supercov measures Ruby through RSpec, Minitest, test-unit and Cucumber",
193            ),
194            Self::UnsupportedRuby(version) => write!(
195                formatter,
196                "Supercov measures Ruby 3.3 or newer; the test command ran Ruby {version}"
197            ),
198        }
199    }
200}
201
202impl std::error::Error for RubyEvidenceError {}
203
204fn stable_id(prefix: &str, values: &[&str]) -> String {
205    let mut hash = Sha256::new();
206    for value in values {
207        hash.update(value.as_bytes());
208        hash.update([0]);
209    }
210    let digest = hash.finalize();
211    let mut encoded = String::with_capacity(prefix.len() + 25);
212    encoded.push_str(prefix);
213    encoded.push(':');
214    for byte in &digest[..12] {
215        use std::fmt::Write as _;
216        write!(&mut encoded, "{byte:02x}").expect("string formatting");
217    }
218    encoded
219}
220
221#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
222struct Identity {
223    worker: String,
224    test: String,
225    retry: usize,
226    phase: String,
227}
228
229type ObservedVectors = BTreeSet<(Vec<Option<bool>>, bool)>;
230/// (worker, test, retry) -> [(phase, outcome, xfail)]
231type OutcomesByAttempt = BTreeMap<(String, String, usize), Vec<(String, String, bool)>>;
232/// (worker, test, retry) -> runner that reported the attempt
233type RunnersByAttempt = BTreeMap<(String, String, usize), String>;
234
235#[derive(Debug, Default)]
236struct Observations {
237    hits: BTreeSet<String>,
238    vectors: BTreeMap<String, ObservedVectors>,
239}
240
241#[derive(Debug, Clone)]
242struct RuntimeLimitation {
243    id: String,
244    reason: String,
245    file: Option<String>,
246    obligation: Option<String>,
247}
248
249#[derive(Debug, Default)]
250struct Evidence {
251    interpreters: usize,
252    ruby_versions: BTreeSet<String>,
253    per_identity: BTreeMap<Identity, Observations>,
254    background: BTreeMap<String, Observations>,
255    outcomes: OutcomesByAttempt,
256    runners: RunnersByAttempt,
257    limitations: Vec<RuntimeLimitation>,
258}
259
260fn read_evidence_directory(directory: &Path, run_id: &str) -> Result<Evidence, RubyEvidenceError> {
261    let mut evidence = Evidence::default();
262    let mut files = match fs::read_dir(directory) {
263        Ok(entries) => entries
264            .collect::<Result<Vec<_>, _>>()
265            .map_err(|error| RubyEvidenceError::Io(error.to_string()))?,
266        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
267        Err(error) => return Err(RubyEvidenceError::Io(error.to_string())),
268    };
269    files.sort_by_key(|entry| entry.file_name());
270    for entry in files {
271        let name = entry
272            .file_name()
273            .into_string()
274            .map_err(|_| RubyEvidenceError::UnsafeEntry("<non-utf8>".into()))?;
275        if Path::new(&name)
276            .components()
277            .any(|component| !matches!(component, Component::Normal(_)))
278            || !name.ends_with(".mmap")
279        {
280            return Err(RubyEvidenceError::UnsafeEntry(name));
281        }
282        let metadata = fs::symlink_metadata(entry.path())
283            .map_err(|error| RubyEvidenceError::Io(error.to_string()))?;
284        if !metadata.file_type().is_file() {
285            return Err(RubyEvidenceError::UnsafeEntry(name));
286        }
287        let file =
288            File::open(entry.path()).map_err(|error| RubyEvidenceError::Io(error.to_string()))?;
289        // The file is immutable from Supercov's perspective after the wrapped
290        // interpreter has exited. No mutable alias is created while this map
291        // is alive.
292        let contents = unsafe { MmapOptions::new().map(&file) }
293            .map_err(|error| RubyEvidenceError::Io(error.to_string()))?;
294        read_evidence_file(&name, &contents, run_id, &mut evidence)?;
295    }
296    Ok(evidence)
297}
298
299fn transport_u32(bytes: &[u8], offset: usize) -> Option<u32> {
300    bytes
301        .get(offset..offset + 4)
302        .and_then(|value| value.try_into().ok())
303        .map(u32::from_le_bytes)
304}
305
306fn transport_u64(bytes: &[u8], offset: usize) -> Option<u64> {
307    bytes
308        .get(offset..offset + 8)
309        .and_then(|value| value.try_into().ok())
310        .map(u64::from_le_bytes)
311}
312
313fn transport_checksum(payload: &[u8]) -> u32 {
314    payload.iter().fold(0x811c_9dc5_u32, |value, byte| {
315        (value ^ u32::from(*byte)).wrapping_mul(0x0100_0193)
316    })
317}
318
319fn align_transport(value: usize) -> Option<usize> {
320    value.checked_add(7).map(|value| value & !7)
321}
322
323fn read_evidence_file(
324    name: &str,
325    contents: &Mmap,
326    run_id: &str,
327    evidence: &mut Evidence,
328) -> Result<(), RubyEvidenceError> {
329    let invalid_transport = |reason: &str| RubyEvidenceError::InvalidTransport {
330        file: name.into(),
331        reason: reason.into(),
332    };
333    if contents.len() < TRANSPORT_HEADER_SIZE
334        || contents.get(..8) != Some(TRANSPORT_MAGIC.as_slice())
335        || transport_u32(contents, 8) != Some(TRANSPORT_VERSION)
336        || transport_u32(contents, 12) != Some(TRANSPORT_HEADER_SIZE as u32)
337    {
338        return Err(invalid_transport("header or version does not match"));
339    }
340    let declared_capacity =
341        transport_u64(contents, 16).ok_or_else(|| invalid_transport("capacity is missing"))?;
342    if declared_capacity < TRANSPORT_HEADER_SIZE as u64 || declared_capacity > contents.len() as u64
343    {
344        return Err(invalid_transport(
345            "declared capacity is outside the mapped file",
346        ));
347    }
348    let dropped =
349        transport_u64(contents, 24).ok_or_else(|| invalid_transport("drop counter is missing"))?;
350    if dropped != 0 {
351        return Err(RubyEvidenceError::DroppedRecords {
352            file: name.into(),
353            count: dropped,
354        });
355    }
356    let transport_pid = transport_u64(contents, 32)
357        .filter(|pid| *pid != 0)
358        .ok_or_else(|| invalid_transport("process id is missing"))?;
359    let mut contexts = BTreeMap::<u64, Identity>::new();
360    let mut process_worker: Option<String> = None;
361    let mut process_started = false;
362    let mut process_reported = false;
363    let mut cursor = TRANSPORT_HEADER_SIZE;
364    let mut record_index = 0;
365    while cursor + TRANSPORT_RECORD_HEADER_SIZE <= contents.len() {
366        let commit = contents[cursor];
367        if commit == 0 {
368            // Payload bytes can exist after a killed writer, but an absent
369            // commit byte makes that frame and every later zeroed frame inert.
370            break;
371        }
372        record_index += 1;
373        let line_number = record_index;
374        let invalid = |reason: &str| RubyEvidenceError::InvalidRecord {
375            file: name.into(),
376            line: line_number,
377            reason: reason.into(),
378        };
379        if commit != 1
380            || contents[cursor + 1..cursor + 4] != [0, 0, 0]
381            || contents[cursor + 12..cursor + 16] != [0, 0, 0, 0]
382        {
383            return Err(invalid("commit marker or reserved bytes are invalid"));
384        }
385        let length = transport_u32(contents, cursor + 4)
386            .map(|value| value as usize)
387            .ok_or_else(|| invalid("payload length is missing"))?;
388        if length == 0 || length > TRANSPORT_MAX_RECORD_SIZE {
389            return Err(invalid("payload length is outside the transport bound"));
390        }
391        let payload_start = cursor + TRANSPORT_RECORD_HEADER_SIZE;
392        let payload_end = payload_start
393            .checked_add(length)
394            .filter(|end| *end <= contents.len())
395            .ok_or_else(|| invalid("payload extends past the mapped file"))?;
396        let next_cursor = align_transport(payload_end)
397            .filter(|end| *end <= contents.len())
398            .ok_or_else(|| invalid("aligned frame extends past the mapped file"))?;
399        if contents[payload_end..next_cursor]
400            .iter()
401            .any(|byte| *byte != 0)
402        {
403            return Err(invalid("frame padding is not zero"));
404        }
405        let payload = &contents[payload_start..payload_end];
406        let expected_checksum = transport_u32(contents, cursor + 8)
407            .ok_or_else(|| invalid("payload checksum is missing"))?;
408        if transport_checksum(payload) != expected_checksum {
409            return Err(invalid("payload checksum does not match"));
410        }
411        let record: Record =
412            serde_json::from_slice(payload).map_err(|error| RubyEvidenceError::InvalidRecord {
413                file: name.into(),
414                line: line_number,
415                reason: error.to_string(),
416            })?;
417        match record {
418            Record::Process {
419                v,
420                run,
421                pid,
422                worker,
423                ruby,
424                ..
425            } => {
426                if v != RUBY_EVIDENCE_VERSION {
427                    return Err(RubyEvidenceError::UnsupportedVersion(v));
428                }
429                if run != run_id {
430                    return Err(RubyEvidenceError::RunMismatch {
431                        expected: run_id.into(),
432                        actual: run,
433                    });
434                }
435                if pid != transport_pid {
436                    return Err(invalid("process record does not match the transport owner"));
437                }
438                let supported = ruby
439                    .split('.')
440                    .take(2)
441                    .map(|part| part.parse::<u32>().ok())
442                    .collect::<Option<Vec<_>>>()
443                    .is_some_and(|parts| parts.len() == 2 && (parts[0], parts[1]) >= (3, 3));
444                if !supported {
445                    return Err(RubyEvidenceError::UnsupportedRuby(ruby));
446                }
447                evidence.interpreters += 1;
448                evidence.ruby_versions.insert(ruby);
449                process_started = true;
450                process_worker = Some(worker);
451            }
452            Record::Worker { worker } => process_worker = Some(worker),
453            Record::Phase {
454                ctx,
455                worker,
456                test,
457                retry,
458                phase,
459                ..
460            } => {
461                if ctx == 0 {
462                    return Err(invalid("phase context 0 is reserved for background"));
463                }
464                if !matches!(phase.as_str(), "setup" | "call" | "teardown") {
465                    return Err(invalid("unknown test phase"));
466                }
467                if test.trim().is_empty() || worker.trim().is_empty() {
468                    return Err(invalid("phase identity must name a worker and test"));
469                }
470                contexts.insert(
471                    ctx,
472                    Identity {
473                        worker,
474                        test,
475                        retry,
476                        phase,
477                    },
478                );
479            }
480            Record::Outcome {
481                worker,
482                test,
483                retry,
484                phase,
485                outcome,
486                xfail,
487                runner,
488            } => {
489                if !matches!(phase.as_str(), "setup" | "call" | "teardown") {
490                    return Err(invalid("unknown test outcome phase"));
491                }
492                if !matches!(
493                    outcome.as_str(),
494                    "passed" | "failed" | "skipped" | "rerun" | "error"
495                ) {
496                    return Err(invalid("unknown test outcome"));
497                }
498                if !matches!(
499                    runner.as_str(),
500                    RSPEC_RUNNER | MINITEST_RUNNER | TEST_UNIT_RUNNER | CUCUMBER_RUNNER
501                ) {
502                    return Err(invalid("unknown Ruby test runner"));
503                }
504                let key = (worker, test, retry);
505                if let Some(previous) = evidence.runners.get(&key)
506                    && previous != &runner
507                {
508                    return Err(invalid("one attempt was reported by two runners"));
509                }
510                evidence.runners.insert(key.clone(), runner);
511                evidence
512                    .outcomes
513                    .entry(key)
514                    .or_default()
515                    .push((phase, outcome, xfail));
516            }
517            Record::Hit { ctx, id } => {
518                observations(
519                    evidence,
520                    &contexts,
521                    process_worker.as_deref(),
522                    ctx,
523                    name,
524                    line_number,
525                )?
526                .hits
527                .insert(id);
528            }
529            Record::Dec { ctx, id, v, o } => {
530                if v.is_empty() || !v.bytes().all(|digit| matches!(digit, b'0' | b'1' | b'2')) {
531                    return Err(invalid("decision vector digits must be 0, 1 or 2"));
532                }
533                if o > 1 {
534                    return Err(invalid("decision outcome must be 0 or 1"));
535                }
536                let values = v
537                    .bytes()
538                    .map(|digit| match digit {
539                        b'0' => None,
540                        b'1' => Some(false),
541                        _ => Some(true),
542                    })
543                    .collect::<Vec<_>>();
544                observations(
545                    evidence,
546                    &contexts,
547                    process_worker.as_deref(),
548                    ctx,
549                    name,
550                    line_number,
551                )?
552                .vectors
553                .entry(id)
554                .or_default()
555                .insert((values, o == 1));
556            }
557            Record::Limitation {
558                id,
559                reason,
560                file,
561                obligation,
562            } => evidence.limitations.push(RuntimeLimitation {
563                id,
564                reason,
565                file,
566                obligation,
567            }),
568            Record::Exit { .. } => process_reported = true,
569        }
570        cursor = next_cursor;
571    }
572    // Ruby reads Coverage's own result in the `at_exit` that writes this
573    // record, so a process that never wrote one -- killed with a signal it
574    // could not catch, or left through `exit!` -- took everything it had
575    // observed since its last test boundary with it. Nothing outside that
576    // process can recover the counters or say which lines they were, so the
577    // run declares the gap instead of reporting those lines as merely
578    // uncovered. A declared limitation blocks completeness, which is the
579    // honest answer: coverage measured here is a floor, not a total.
580    if process_started && !process_reported {
581        evidence.limitations.push(RuntimeLimitation {
582            id: "ruby-process-did-not-report".into(),
583            reason: format!(
584                "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"
585            ),
586            file: None,
587            obligation: None,
588        });
589    }
590    Ok(())
591}
592
593fn observations<'a>(
594    evidence: &'a mut Evidence,
595    contexts: &BTreeMap<u64, Identity>,
596    process_worker: Option<&str>,
597    context: u64,
598    file: &str,
599    line: usize,
600) -> Result<&'a mut Observations, RubyEvidenceError> {
601    if context == 0 {
602        return Ok(evidence
603            .background
604            .entry(process_worker.unwrap_or("main").to_owned())
605            .or_default());
606    }
607    let identity = contexts
608        .get(&context)
609        .ok_or(RubyEvidenceError::UnknownContext {
610            file: file.into(),
611            line,
612            context,
613        })?;
614    Ok(evidence.per_identity.entry(identity.clone()).or_default())
615}
616
617pub fn ruby_coverage_model() -> CoverageModelDeclaration {
618    CoverageModelDeclaration {
619        language: "ruby".into(),
620        variant: "ruby-owned-coverage".into(),
621        name: "ruby-coverage-probes-v1".into(),
622        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(),
623        measured: vec![
624            "executable statements proven by Ruby's line coverage, or a probe when a line holds several".into(),
625            "method definitions entered (Ruby's method coverage)".into(),
626            "if/unless/elsif/ternary/while/until decisions with masking MC/DC vectors from operand probes".into(),
627            "while, until, for and iterator-block (each, map, times, ...) zero-versus-entered iteration".into(),
628            "&&, ||, ||= and &&= short-circuit alternatives".into(),
629            "case/when and case/in clause selection, safe navigation".into(),
630            "begin/rescue completion, handler selection and exception propagation".into(),
631            "RSpec, Minitest, test-unit and Cucumber worker, test and setup/call/teardown phase identity".into(),
632        ],
633        not_measured: vec![
634            "blocks and lambdas as function entry points (they are statements inside their methods)".into(),
635            "blocks passed by reference (map(&:to_s)) as loops: they have no block body to observe".into(),
636            "line, branch and method observations made while test phases overlapped in threads (attributed to the run; probe observations stay per test)".into(),
637            "causal linkage to individual actions or passing assertions".into(),
638            "code compiled from strings at runtime (eval, instance_eval with strings)".into(),
639            "child coverage outside Process.spawn, Kernel#spawn, Kernel#system and fork".into(),
640            "all input values, semantic partitions, paths, or concurrency interleavings".into(),
641            "mutation score or assertion fault-detection strength".into(),
642        ],
643    }
644}
645
646fn phase_id(run: &str, identity: &Identity) -> String {
647    stable_id(
648        "ruby-phase",
649        &[
650            run,
651            &identity.worker,
652            &identity.test,
653            &identity.retry.to_string(),
654            &identity.phase,
655        ],
656    )
657}
658
659fn scope(run: &str, worker: &str, test: &str, retry: usize) -> ExecutionScope {
660    ExecutionScope {
661        version: 1,
662        run_id: run.into(),
663        worker_id: worker.into(),
664        test_id: test.into(),
665        test_key: stable_id("ruby-test", &[worker, test]),
666        retry,
667        attempt_id: stable_id("ruby-attempt", &[run, worker, test, &retry.to_string()]),
668    }
669}
670
671struct ManifestIndex<'a> {
672    points: BTreeSet<&'a str>,
673    alternatives: BTreeSet<&'a str>,
674    decisions: BTreeMap<&'a str, &'a DecisionMeta>,
675    lines: BTreeMap<&'a str, (String, usize)>,
676    sources: BTreeMap<&'a str, &'a str>,
677}
678
679impl<'a> ManifestIndex<'a> {
680    fn new(manifest: &'a CoverageManifest) -> Self {
681        let mut lines = BTreeMap::new();
682        let mut sources = BTreeMap::new();
683        for point in &manifest.points {
684            lines.insert(point.id.as_str(), (point.file.clone(), point.line));
685            sources.insert(point.id.as_str(), point.source.as_str());
686        }
687        for decision in &manifest.decisions {
688            lines.insert(decision.id.as_str(), (decision.file.clone(), decision.line));
689            sources.insert(decision.id.as_str(), decision.source.as_str());
690        }
691        for branch in &manifest.branches {
692            lines.insert(branch.id.as_str(), (branch.file.clone(), branch.line));
693            sources.insert(branch.id.as_str(), branch.source.as_str());
694        }
695        Self {
696            points: manifest
697                .points
698                .iter()
699                .map(|point| point.id.as_str())
700                .collect(),
701            alternatives: manifest
702                .branches
703                .iter()
704                .flat_map(|branch| branch.alternatives.iter().map(|alt| alt.id.as_str()))
705                .collect(),
706            decisions: manifest
707                .decisions
708                .iter()
709                .map(|decision| (decision.id.as_str(), decision))
710                .collect(),
711            lines,
712            sources,
713        }
714    }
715}
716
717fn snapshot(
718    index: &ManifestIndex<'_>,
719    observations: &Observations,
720    phase: &str,
721) -> Result<RuntimeSnapshot, RubyEvidenceError> {
722    let mut hits = BTreeSet::new();
723    for id in &observations.hits {
724        if !index.points.contains(id.as_str()) && !index.alternatives.contains(id.as_str()) {
725            return Err(RubyEvidenceError::UnknownObligation(id.clone()));
726        }
727        hits.insert(id.clone());
728    }
729    let mut decisions = Vec::new();
730    let mut events = Vec::new();
731    let mut clock = 1;
732    for id in &hits {
733        events.push(RuntimeEvent {
734            event_type: "hit".into(),
735            id: id.clone(),
736            vector: None,
737            timestamp_ms: clock,
738            phase_id: Some(phase.into()),
739            environment: "ruby".into(),
740        });
741        clock += 1;
742    }
743    for (id, vectors) in &observations.vectors {
744        let Some(meta) = index.decisions.get(id.as_str()) else {
745            return Err(RubyEvidenceError::UnknownObligation(id.clone()));
746        };
747        let mut observed = Vec::new();
748        for (values, outcome) in vectors {
749            if values.len() != meta.conditions.len() {
750                return Err(RubyEvidenceError::InvalidVector {
751                    id: id.clone(),
752                    expected: meta.conditions.len(),
753                    actual: values.len(),
754                });
755            }
756            let vector = McdcVector {
757                values: values.clone(),
758                outcome: *outcome,
759            };
760            events.push(RuntimeEvent {
761                event_type: "decision".into(),
762                id: id.clone(),
763                vector: Some(vector.clone()),
764                timestamp_ms: clock,
765                phase_id: Some(phase.into()),
766                environment: "ruby".into(),
767            });
768            clock += 1;
769            observed.push(vector);
770        }
771        decisions.push(DecisionSnapshot {
772            meta: (*meta).clone(),
773            vectors: observed,
774        });
775    }
776    Ok(RuntimeSnapshot {
777        decisions,
778        hits: hits.into_iter().collect(),
779        events,
780    })
781}
782
783fn attempt_status(outcomes: &[(String, String, bool)]) -> String {
784    if outcomes
785        .iter()
786        .any(|(_, outcome, _)| matches!(outcome.as_str(), "failed" | "rerun" | "error"))
787    {
788        "failed"
789    } else if outcomes.iter().any(|(_, outcome, _)| outcome == "skipped") {
790        "skipped"
791    } else {
792        "passed"
793    }
794    .into()
795}
796
797#[derive(Debug, Clone, PartialEq)]
798pub struct RubyFrontendRun {
799    pub declaration: FrontendRunDeclaration,
800    pub request: CoverageReportRequest,
801    pub tests: usize,
802    pub interpreters: usize,
803    pub ruby_versions: Vec<String>,
804}
805
806impl RubyFrontendRun {
807    pub fn archive_entries(&self) -> Result<Vec<EvidenceArchiveEntry>, serde_json::Error> {
808        let model = PersistedCoverageModel::from_declaration(
809            self.request
810                .coverage_model
811                .as_ref()
812                .expect("Ruby frontend always declares a coverage model"),
813        )
814        .expect("Ruby coverage model is contract-valid");
815        let mut entries = vec![
816            EvidenceArchiveEntry {
817                path: "coverage-model.json".into(),
818                contents: serde_json::to_vec(&model)?,
819            },
820            EvidenceArchiveEntry {
821                path: "frontend.json".into(),
822                contents: serde_json::to_vec(&self.declaration)?,
823            },
824            EvidenceArchiveEntry {
825                path: "manifest.json".into(),
826                contents: serde_json::to_vec(&self.request.manifest)?,
827            },
828        ];
829        for (index, result) in self.request.raw_results.iter().enumerate() {
830            entries.push(EvidenceArchiveEntry {
831                path: format!("results/{index:08}/mcdc.json"),
832                contents: serde_json::to_vec(result)?,
833            });
834        }
835        Ok(entries)
836    }
837}
838
839/// Join the runtime's evidence directory with the ahead-of-run manifest into
840/// a protocol-conformant frontend run.
841pub fn build_ruby_frontend_run(
842    manifest: &CoverageManifest,
843    evidence_directory: &Path,
844    run_id: &str,
845    generated_at: &str,
846    test_exit_code: i32,
847) -> Result<RubyFrontendRun, RubyEvidenceError> {
848    let evidence = read_evidence_directory(evidence_directory, run_id)?;
849    if evidence.interpreters == 0 {
850        return Err(RubyEvidenceError::NoInterpreter);
851    }
852    if evidence.outcomes.is_empty() {
853        return Err(RubyEvidenceError::NoTests);
854    }
855    let Evidence {
856        interpreters,
857        ruby_versions,
858        per_identity,
859        background,
860        outcomes,
861        runners,
862        limitations,
863    } = evidence;
864    let mut manifest = manifest.clone();
865    let index = ManifestIndex::new(&manifest);
866
867    let mut raw_results = Vec::new();
868    let mut observed_runners = BTreeSet::new();
869    let mut identities_by_attempt =
870        BTreeMap::<(String, String, usize), Vec<(&Identity, &Observations)>>::new();
871    for (identity, observations) in &per_identity {
872        identities_by_attempt
873            .entry((
874                identity.worker.clone(),
875                identity.test.clone(),
876                identity.retry,
877            ))
878            .or_default()
879            .push((identity, observations));
880    }
881    for ((worker, test, retry), mut outcomes) in outcomes {
882        let runner = runners
883            .get(&(worker.clone(), test.clone(), retry))
884            .cloned()
885            .unwrap_or_else(default_runner);
886        let attempt_identities = identities_by_attempt
887            .remove(&(worker.clone(), test.clone(), retry))
888            .unwrap_or_default();
889        observed_runners.insert(runner.clone());
890        outcomes.sort_by_key(|(phase, _, _)| match phase.as_str() {
891            "setup" => 0,
892            "call" => 1,
893            _ => 2,
894        });
895        let mut phases = Vec::new();
896        let mut runtime = Vec::new();
897        let mut observed_phases = BTreeSet::new();
898        for (position, (phase_name, outcome, _)) in outcomes.iter().enumerate() {
899            observed_phases.insert(phase_name.clone());
900            let identity = Identity {
901                worker: worker.clone(),
902                test: test.clone(),
903                retry,
904                phase: phase_name.clone(),
905            };
906            let id = phase_id(run_id, &identity);
907            phases.push(CoveragePhase {
908                id: id.clone(),
909                kind: match phase_name.as_str() {
910                    "call" => "test",
911                    value => value,
912                }
913                .into(),
914                operation: format!("{runner} {phase_name}"),
915                source: Some(test.clone()),
916                caused_by_phase_id: None,
917                started_at_ms: position as i64 * 2 + 1,
918                ended_at_ms: Some(position as i64 * 2 + 2),
919                status: Some(match outcome.as_str() {
920                    "rerun" | "error" => "failed".into(),
921                    value => value.into(),
922                }),
923                error: None,
924            });
925            if let Some((_, observations)) = attempt_identities
926                .iter()
927                .find(|(candidate, _)| candidate.phase == phase_name.as_str())
928            {
929                runtime.push(snapshot(&index, observations, &id)?);
930            }
931        }
932        // A phase the runtime entered but the runner never reported (the worker
933        // died inside it) is a failed phase with its evidence kept.
934        for (identity, observations) in attempt_identities {
935            if !observed_phases.contains(&identity.phase) {
936                let id = phase_id(run_id, identity);
937                phases.push(CoveragePhase {
938                    id: id.clone(),
939                    kind: match identity.phase.as_str() {
940                        "call" => "test",
941                        value => value,
942                    }
943                    .into(),
944                    operation: format!("{runner} {}", identity.phase),
945                    source: Some(test.clone()),
946                    caused_by_phase_id: None,
947                    started_at_ms: phases.len() as i64 * 2 + 1,
948                    ended_at_ms: None,
949                    status: Some("failed".into()),
950                    error: Some("the phase started but the runner reported no outcome".into()),
951                });
952                runtime.push(snapshot(&index, observations, &id)?);
953            }
954        }
955        let status = if phases.iter().any(|phase| phase.error.is_some()) {
956            "failed".into()
957        } else {
958            attempt_status(&outcomes)
959        };
960        raw_results.push(RawTestResult {
961            test_id: Some(test.clone()),
962            scope: Some(scope(run_id, &worker, &test, retry)),
963            test: test.clone(),
964            test_file: test.split("::").next().map(str::to_owned),
965            title: test.rsplit("::").next().map(str::to_owned),
966            retry: Some(retry),
967            status: Some(status),
968            expected_status: Some(
969                if outcomes.iter().any(|(_, _, xfail)| *xfail) {
970                    "failed"
971                } else {
972                    "passed"
973                }
974                .into(),
975            ),
976            flaky: false,
977            provenance: TestProvenance {
978                runner: runner.clone(),
979                kind: "unit".into(),
980                project: None,
981                source: RUBY_FRONTEND_VERSION.into(),
982            },
983            role: "test".into(),
984            phases,
985            runtime,
986            browser: Vec::new(),
987            server: Vec::new(),
988        });
989    }
990    // Phases with observations whose test never produced any outcome at all
991    // (for example a worker killed during its first phase).
992    let default_observed = observed_runners
993        .iter()
994        .next()
995        .cloned()
996        .unwrap_or_else(default_runner);
997    for ((worker, test, retry), identities) in identities_by_attempt {
998        let runner = default_observed.clone();
999        let mut phases = Vec::new();
1000        let mut runtime = Vec::new();
1001        for (position, (identity, observations)) in identities.iter().enumerate() {
1002            let id = phase_id(run_id, identity);
1003            phases.push(CoveragePhase {
1004                id: id.clone(),
1005                kind: match identity.phase.as_str() {
1006                    "call" => "test",
1007                    value => value,
1008                }
1009                .into(),
1010                operation: format!("{runner} {}", identity.phase),
1011                source: Some(test.clone()),
1012                caused_by_phase_id: None,
1013                started_at_ms: position as i64 * 2 + 1,
1014                ended_at_ms: None,
1015                status: Some("failed".into()),
1016                error: Some("the phase started but the runner reported no outcome".into()),
1017            });
1018            runtime.push(snapshot(&index, observations, &id)?);
1019        }
1020        raw_results.push(RawTestResult {
1021            test_id: Some(test.clone()),
1022            scope: Some(scope(run_id, &worker, &test, retry)),
1023            test: test.clone(),
1024            test_file: test.split("::").next().map(str::to_owned),
1025            title: test.rsplit("::").next().map(str::to_owned),
1026            retry: Some(retry),
1027            status: Some("failed".into()),
1028            expected_status: Some("passed".into()),
1029            flaky: false,
1030            provenance: TestProvenance {
1031                runner: runner.clone(),
1032                kind: "unit".into(),
1033                project: None,
1034                source: RUBY_FRONTEND_VERSION.into(),
1035            },
1036            role: "test".into(),
1037            phases,
1038            runtime,
1039            browser: Vec::new(),
1040            server: Vec::new(),
1041        });
1042    }
1043    for (worker, observations) in &background {
1044        if observations.hits.is_empty() && observations.vectors.is_empty() {
1045            continue;
1046        }
1047        let test = format!("__supercov_background__:{worker}");
1048        let identity = Identity {
1049            worker: worker.clone(),
1050            test: test.clone(),
1051            retry: 0,
1052            phase: "background".into(),
1053        };
1054        let phase = phase_id(run_id, &identity);
1055        raw_results.push(RawTestResult {
1056            test_id: Some(test.clone()),
1057            scope: Some(scope(run_id, worker, &test, 0)),
1058            test: "Ruby load and background execution".into(),
1059            test_file: None,
1060            title: None,
1061            retry: Some(0),
1062            status: Some("unknown".into()),
1063            expected_status: None,
1064            flaky: false,
1065            provenance: TestProvenance {
1066                runner: default_observed.clone(),
1067                kind: "unit".into(),
1068                project: None,
1069                source: RUBY_FRONTEND_VERSION.into(),
1070            },
1071            role: "background".into(),
1072            phases: vec![CoveragePhase {
1073                id: phase.clone(),
1074                kind: "background".into(),
1075                operation: "Ruby load background".into(),
1076                source: None,
1077                caused_by_phase_id: None,
1078                started_at_ms: 0,
1079                ended_at_ms: Some(0),
1080                status: Some("passed".into()),
1081                error: None,
1082            }],
1083            runtime: vec![snapshot(&index, observations, &phase)?],
1084            browser: Vec::new(),
1085            server: Vec::new(),
1086        });
1087    }
1088
1089    // Runtime-detected limitations: obligations the runtime could not map
1090    // become unmeasured, and every limitation ID joins the manifest so the
1091    // declaration and manifest agree.
1092    let mut limitation_ids = manifest
1093        .limitations
1094        .iter()
1095        .filter_map(|item| item.get("id").and_then(serde_json::Value::as_str))
1096        .map(str::to_owned)
1097        .collect::<BTreeSet<_>>();
1098    let mut unmeasured = manifest.unmeasured.iter().cloned().collect::<BTreeSet<_>>();
1099    let mut new_limitations = Vec::new();
1100    for limitation in &limitations {
1101        if let Some(obligation) = &limitation.obligation {
1102            if !index.lines.contains_key(obligation.as_str()) {
1103                return Err(RubyEvidenceError::UnknownObligation(obligation.clone()));
1104            }
1105            unmeasured.insert(obligation.clone());
1106        } else if let Some(file) = &limitation.file {
1107            // A code-object mapping failure or missing debug ranges prevents
1108            // every obligation in that source file from being observed. Mark
1109            // the whole file unmeasured instead of presenting its denominator
1110            // as ordinary uncovered code.
1111            unmeasured.extend(
1112                index
1113                    .lines
1114                    .iter()
1115                    .filter(|(_, (obligation_file, _))| obligation_file == file)
1116                    .map(|(id, _)| (*id).to_owned()),
1117            );
1118        }
1119        if limitation_ids.insert(limitation.id.clone()) {
1120            let (file, line) = limitation
1121                .obligation
1122                .as_deref()
1123                .and_then(|id| index.lines.get(id).cloned())
1124                .unwrap_or_else(|| {
1125                    (
1126                        limitation.file.clone().unwrap_or_else(|| {
1127                            manifest
1128                                .points
1129                                .first()
1130                                .map_or(".".into(), |point| point.file.clone())
1131                        }),
1132                        1,
1133                    )
1134                });
1135            let source = limitation
1136                .obligation
1137                .as_deref()
1138                .and_then(|id| index.sources.get(id))
1139                .map(|source| source.lines().next().unwrap_or_default().to_owned())
1140                .unwrap_or_default();
1141            new_limitations.push(json!({
1142                "id": limitation.id,
1143                "kind": "semantic-safety",
1144                "file": file,
1145                "line": line,
1146                "column": 0,
1147                "source": source,
1148                "reason": limitation.reason
1149            }));
1150        }
1151    }
1152    manifest.limitations.extend(new_limitations);
1153    manifest.unmeasured = unmeasured.into_iter().collect();
1154    let structural_limitations = limitation_ids.into_iter().collect::<Vec<_>>();
1155
1156    // Retries are separate raw results so their coverage remains attempt
1157    // exact, but the public lifecycle diagnostic reports logical tests rather
1158    // than inflating the count when a flaky test is rerun.
1159    let tests = raw_results
1160        .iter()
1161        .filter(|raw| raw.role == "test")
1162        .map(|raw| raw.test.as_str())
1163        .collect::<BTreeSet<_>>()
1164        .len();
1165    Ok(RubyFrontendRun {
1166        declaration: FrontendRunDeclaration {
1167            protocol_version: LANGUAGE_FRONTEND_PROTOCOL_VERSION,
1168            frontend_id: "ruby".into(),
1169            frontend_version: RUBY_FRONTEND_VERSION.into(),
1170            language: "ruby".into(),
1171            structural_source: StructuralSource::OwnedProbes,
1172            runners: observed_runners
1173                .iter()
1174                .map(|runner| FrontendRunnerDeclaration {
1175                    runner: runner.clone(),
1176                    execution_model: ExecutionModel::SerialInProcess,
1177                    attribution: FrontendAttribution {
1178                        run: AttributionPrecision::Exact,
1179                        worker: AttributionPrecision::Exact,
1180                        test: AttributionPrecision::Exact,
1181                        retry: AttributionPrecision::Exact,
1182                        phase: AttributionPrecision::Exact,
1183                        action: AttributionPrecision::Unavailable,
1184                        assertion: AttributionPrecision::Unavailable,
1185                    },
1186                    limitations: vec![
1187                        FrontendLimitation {
1188                            id: format!("ruby-{runner}-action-linkage"),
1189                            scopes: vec![FrontendLimitationScope::Action],
1190                            reason: format!("{runner} exposes no general action lifecycle"),
1191                        },
1192                        FrontendLimitation {
1193                            id: format!("ruby-{runner}-assertion-linkage"),
1194                            scopes: vec![FrontendLimitationScope::Assertion],
1195                            reason: format!("{runner} phase outcomes do not identify each passing assertion or the obligations caused by it"),
1196                        },
1197                    ],
1198                })
1199                .collect(),
1200            structural_limitations,
1201        },
1202        request: CoverageReportRequest {
1203            run_id: run_id.into(),
1204            manifest,
1205            raw_results,
1206            generated_at: generated_at.into(),
1207            coverage_model: Some(ruby_coverage_model()),
1208            integrity: None,
1209            test_exit_code: ExitCodeInput::Present(Some(test_exit_code)),
1210        },
1211        tests,
1212        interpreters,
1213        ruby_versions: ruby_versions.into_iter().collect(),
1214    })
1215}
1216
1217#[cfg(test)]
1218mod tests {
1219    use super::*;
1220    use crate::{
1221        coverage_analysis::PointKind, frontend_protocol::validate_frontend_report_request,
1222        ruby_instrumenter::build_ruby_obligations,
1223    };
1224
1225    fn frame(payload: &[u8]) -> Vec<u8> {
1226        let mut bytes = vec![1u8, 0, 0, 0];
1227        bytes.extend_from_slice(&(payload.len() as u32).to_le_bytes());
1228        bytes.extend_from_slice(&transport_checksum(payload).to_le_bytes());
1229        bytes.extend_from_slice(&[0, 0, 0, 0]);
1230        bytes.extend_from_slice(payload);
1231        while bytes.len() % 8 != 0 {
1232            bytes.push(0);
1233        }
1234        bytes
1235    }
1236
1237    fn transport(records: &[serde_json::Value]) -> Vec<u8> {
1238        let mut body = Vec::new();
1239        for record in records {
1240            body.extend(frame(record.to_string().as_bytes()));
1241        }
1242        let capacity = TRANSPORT_HEADER_SIZE + body.len();
1243        let mut bytes = vec![0u8; TRANSPORT_HEADER_SIZE];
1244        bytes[..8].copy_from_slice(TRANSPORT_MAGIC);
1245        bytes[8..12].copy_from_slice(&TRANSPORT_VERSION.to_le_bytes());
1246        bytes[12..16].copy_from_slice(&(TRANSPORT_HEADER_SIZE as u32).to_le_bytes());
1247        bytes[16..24].copy_from_slice(&(capacity as u64).to_le_bytes());
1248        bytes[32..40].copy_from_slice(&7u64.to_le_bytes());
1249        bytes.extend(body);
1250        bytes
1251    }
1252
1253    fn temporary(name: &str) -> std::path::PathBuf {
1254        let nonce = std::time::SystemTime::now()
1255            .duration_since(std::time::UNIX_EPOCH)
1256            .unwrap()
1257            .as_nanos();
1258        let path = std::env::temp_dir().join(format!(
1259            "supercov-ruby-evidence-{}-{nonce}-{name}",
1260            std::process::id()
1261        ));
1262        fs::create_dir_all(&path).unwrap();
1263        path
1264    }
1265
1266    #[test]
1267    fn declares_the_gap_when_a_process_never_reported() {
1268        // Coverage's own result is read in the `at_exit` that writes the exit
1269        // record, so a transport without one belongs to a process that was
1270        // killed or left through `exit!`. What it observed is unrecoverable and
1271        // unknowable, so the run says so rather than reporting those lines as
1272        // uncovered. The same records WITH an exit record must stay silent, or
1273        // every ordinary run would claim a gap it does not have.
1274        let source = "def f(a)\n  a\nend\n";
1275        let mut probe = 0;
1276        let obligations =
1277            build_ruby_obligations("lib/m.rb", source.as_bytes(), &mut probe).unwrap();
1278        let statement = obligations
1279            .manifest
1280            .points
1281            .iter()
1282            .find(|point| point.kind == PointKind::Statement)
1283            .unwrap();
1284        let base = [
1285            serde_json::json!({"t":"process","v":1,"run":"run-1","pid":7,"worker":"main","ruby":"4.0.6","executable":"ruby","argv":["child.rb"]}),
1286            serde_json::json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"MTest#test_x","retry":0,"phase":"call"}),
1287            serde_json::json!({"t":"hit","ctx":1,"id":statement.id}),
1288            serde_json::json!({"t":"outcome","worker":"main","test":"MTest#test_x","retry":0,"phase":"call","outcome":"passed","xfail":false,"runner":"minitest"}),
1289        ];
1290
1291        let killed = temporary("killed");
1292        fs::write(killed.join("main.7.a.mmap"), transport(&base)).unwrap();
1293        let run =
1294            build_ruby_frontend_run(&obligations.manifest, &killed, "run-1", "now", 0).unwrap();
1295        let declared = serde_json::to_string(&run.request.manifest.limitations).unwrap();
1296        assert!(
1297            declared.contains("ruby-process-did-not-report"),
1298            "a process that never reported must declare the gap: {declared}"
1299        );
1300        fs::remove_dir_all(&killed).unwrap();
1301
1302        let mut clean = base.to_vec();
1303        clean.push(serde_json::json!({"t":"exit","at":9}));
1304        let reported = temporary("reported");
1305        fs::write(reported.join("main.7.a.mmap"), transport(&clean)).unwrap();
1306        let run =
1307            build_ruby_frontend_run(&obligations.manifest, &reported, "run-1", "now", 0).unwrap();
1308        let declared = serde_json::to_string(&run.request.manifest.limitations).unwrap();
1309        assert!(
1310            !declared.contains("ruby-process-did-not-report"),
1311            "a process that reported cleanly must declare nothing: {declared}"
1312        );
1313        fs::remove_dir_all(&reported).unwrap();
1314    }
1315
1316    #[test]
1317    fn joins_rspec_and_minitest_outcomes_into_exact_results() {
1318        let source = "def f(a, b)\n  if a && b\n    1\n  else\n    0\n  end\nend\n";
1319        let mut probe = 0;
1320        let obligations =
1321            build_ruby_obligations("lib/m.rb", source.as_bytes(), &mut probe).unwrap();
1322        let decision = &obligations.manifest.decisions[0];
1323        let statement = obligations
1324            .manifest
1325            .points
1326            .iter()
1327            .find(|point| point.kind == PointKind::Statement)
1328            .unwrap();
1329        let directory = temporary("join");
1330        let records = [
1331            serde_json::json!({"t":"process","v":1,"run":"run-1","pid":7,"worker":"main","ruby":"4.0.6","executable":"ruby","argv":["rspec"]}),
1332            serde_json::json!({"t":"hit","ctx":0,"id":statement.id}),
1333            serde_json::json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"spec/m_spec.rb[1:1]","retry":0,"phase":"call"}),
1334            serde_json::json!({"t":"dec","ctx":1,"id":decision.id,"v":"22","o":1}),
1335            serde_json::json!({"t":"outcome","worker":"main","test":"spec/m_spec.rb[1:1]","retry":0,"phase":"setup","outcome":"passed","xfail":false,"runner":"rspec"}),
1336            serde_json::json!({"t":"outcome","worker":"main","test":"spec/m_spec.rb[1:1]","retry":0,"phase":"call","outcome":"passed","xfail":false,"runner":"rspec"}),
1337            serde_json::json!({"t":"outcome","worker":"main","test":"spec/m_spec.rb[1:1]","retry":0,"phase":"teardown","outcome":"passed","xfail":false,"runner":"rspec"}),
1338            serde_json::json!({"t":"phase","ctx":2,"at":6,"worker":"main","test":"MTest#test_x","retry":0,"phase":"call"}),
1339            serde_json::json!({"t":"outcome","worker":"main","test":"MTest#test_x","retry":0,"phase":"call","outcome":"skipped","xfail":false,"runner":"minitest"}),
1340            serde_json::json!({"t":"exit","at":9}),
1341        ];
1342        fs::write(directory.join("main.7.a.mmap"), transport(&records)).unwrap();
1343        let run =
1344            build_ruby_frontend_run(&obligations.manifest, &directory, "run-1", "now", 0).unwrap();
1345        validate_frontend_report_request(&run.declaration, &run.request).unwrap();
1346        assert_eq!(run.tests, 2);
1347        let runners = run
1348            .declaration
1349            .runners
1350            .iter()
1351            .map(|runner| runner.runner.clone())
1352            .collect::<Vec<_>>();
1353        assert_eq!(runners, ["minitest", "rspec"]);
1354        assert_eq!(run.ruby_versions, ["4.0.6"]);
1355        fs::remove_dir_all(directory).unwrap();
1356    }
1357
1358    #[test]
1359    fn fails_closed_without_an_interpreter() {
1360        let mut probe = 0;
1361        let obligations = build_ruby_obligations("m.rb", b"x = 1\n", &mut probe).unwrap();
1362        let directory = temporary("empty");
1363        assert!(matches!(
1364            build_ruby_frontend_run(&obligations.manifest, &directory, "run-1", "now", 0),
1365            Err(RubyEvidenceError::NoInterpreter)
1366        ));
1367        fs::remove_dir_all(directory).unwrap();
1368    }
1369}