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