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