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