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            span: None,
1410            test_id: Some(test.clone()),
1411            scope: Some(scope(run_id, &worker, &test, retry)),
1412            test: test.clone(),
1413            // What the runner named, as the project names it. A runner
1414            // identity is not a path, so the old derivation stays only as a
1415            // fallback for an adapter that cannot name the file.
1416            test_file: test_file.clone(),
1417            title: test_titles
1418                .get(&(worker.clone(), test.clone(), retry))
1419                .cloned()
1420                .or_else(|| test.rsplit("::").next().map(str::to_owned)),
1421            retry: Some(retry),
1422            status: Some(status),
1423            expected_status: Some(
1424                if outcomes.iter().any(|(_, _, xfail)| *xfail) {
1425                    "failed"
1426                } else {
1427                    "passed"
1428                }
1429                .into(),
1430            ),
1431            flaky: false,
1432            provenance: {
1433                let tags = test_tags
1434                    .get(&(worker.clone(), test.clone(), retry))
1435                    .cloned()
1436                    .unwrap_or_default();
1437                TestProvenance {
1438                    runner: runner.clone(),
1439                    kind: crate::test_kind::with_tags(test_file.as_deref(), &tags).into(),
1440                    project: None,
1441                    source: RUBY_FRONTEND_VERSION.into(),
1442                    tags,
1443                }
1444            },
1445            role: "test".into(),
1446            // Ruby 3.3+ probes every statement, so what a test is recorded as
1447            // reaching is all of it. Unprobed lines come from Coverage's one-shot
1448            // lines, which report a line the first time it executes in the
1449            // process and never again: the first test to reach a line is
1450            // credited with it, and a test's hits are a lower bound.
1451            attribution: attribution.into(),
1452            phases,
1453            runtime,
1454            browser: Vec::new(),
1455            server: Vec::new(),
1456        });
1457    }
1458    // Phases with observations whose test never produced any outcome at all
1459    // (for example a worker killed during its first phase).
1460    let default_observed = observed_runners
1461        .iter()
1462        .next()
1463        .cloned()
1464        .unwrap_or_else(default_runner);
1465    for ((worker, test, retry), identities) in identities_by_attempt {
1466        let runner = default_observed.clone();
1467        let mut phases = Vec::new();
1468        let mut runtime = Vec::new();
1469        for (position, (identity, observations)) in identities.iter().enumerate() {
1470            let id = phase_id(run_id, identity);
1471            phases.push(CoveragePhase {
1472                id: id.clone(),
1473                kind: match identity.phase.as_str() {
1474                    "call" => "test",
1475                    value => value,
1476                }
1477                .into(),
1478                operation: format!("{runner} {}", identity.phase),
1479                source: Some(test.clone()),
1480                caused_by_phase_id: None,
1481                started_at_ms: position as i64 * 2 + 1,
1482                ended_at_ms: None,
1483                status: Some("failed".into()),
1484                error: Some("the phase started but the runner reported no outcome".into()),
1485            });
1486            runtime.push(snapshot(&index, observations, &id)?);
1487        }
1488        let test_file = test_files
1489            .get(&(worker.clone(), test.clone(), retry))
1490            .and_then(|path| relative(root, path))
1491            .or_else(|| test.split("::").next().map(str::to_owned));
1492        raw_results.push(RawTestResult {
1493            span: None,
1494            test_id: Some(test.clone()),
1495            scope: Some(scope(run_id, &worker, &test, retry)),
1496            test: test.clone(),
1497            // What the runner named, as the project names it. A runner
1498            // identity is not a path, so the old derivation stays only as a
1499            // fallback for an adapter that cannot name the file.
1500            test_file: test_file.clone(),
1501            title: test_titles
1502                .get(&(worker.clone(), test.clone(), retry))
1503                .cloned()
1504                .or_else(|| test.rsplit("::").next().map(str::to_owned)),
1505            retry: Some(retry),
1506            status: Some("failed".into()),
1507            expected_status: Some("passed".into()),
1508            flaky: false,
1509            provenance: {
1510                let tags = test_tags
1511                    .get(&(worker.clone(), test.clone(), retry))
1512                    .cloned()
1513                    .unwrap_or_default();
1514                TestProvenance {
1515                    runner: runner.clone(),
1516                    kind: crate::test_kind::with_tags(test_file.as_deref(), &tags).into(),
1517                    project: None,
1518                    source: RUBY_FRONTEND_VERSION.into(),
1519                    tags,
1520                }
1521            },
1522            role: "test".into(),
1523            // Ruby 3.3+ probes every statement, so what a test is recorded as
1524            // reaching is all of it. Unprobed lines come from Coverage's one-shot
1525            // lines, which report a line the first time it executes in the
1526            // process and never again: the first test to reach a line is
1527            // credited with it, and a test's hits are a lower bound.
1528            attribution: attribution.into(),
1529            phases,
1530            runtime,
1531            browser: Vec::new(),
1532            server: Vec::new(),
1533        });
1534    }
1535    for (worker, observations) in &background {
1536        if observations.hits.is_empty() && observations.vectors.is_empty() {
1537            continue;
1538        }
1539        let test = format!("__supercov_background__:{worker}");
1540        let identity = Identity {
1541            worker: worker.clone(),
1542            test: test.clone(),
1543            retry: 0,
1544            phase: "background".into(),
1545        };
1546        let phase = phase_id(run_id, &identity);
1547        raw_results.push(RawTestResult {
1548            span: None,
1549            test_id: Some(test.clone()),
1550            scope: Some(scope(run_id, worker, &test, 0)),
1551            test: "Ruby load and background execution".into(),
1552            test_file: None,
1553            title: None,
1554            retry: Some(0),
1555            status: Some("unknown".into()),
1556            expected_status: None,
1557            flaky: false,
1558            provenance: TestProvenance {
1559                runner: default_observed.clone(),
1560                kind: "background".into(),
1561                project: None,
1562                source: RUBY_FRONTEND_VERSION.into(),
1563                tags: Vec::new(),
1564            },
1565            role: "background".into(),
1566            attribution: crate::coverage_report::ATTRIBUTION_EXACT.into(),
1567            phases: vec![CoveragePhase {
1568                id: phase.clone(),
1569                kind: "background".into(),
1570                operation: "Ruby load background".into(),
1571                source: None,
1572                caused_by_phase_id: None,
1573                started_at_ms: 0,
1574                ended_at_ms: Some(0),
1575                status: Some("passed".into()),
1576                error: None,
1577            }],
1578            runtime: vec![snapshot(&index, observations, &phase)?],
1579            browser: Vec::new(),
1580            server: Vec::new(),
1581        });
1582    }
1583
1584    // Runtime-detected limitations: obligations the runtime could not map
1585    // become unmeasured, and every limitation ID joins the manifest so the
1586    // declaration and manifest agree.
1587    let mut limitation_ids = manifest
1588        .limitations
1589        .iter()
1590        .filter_map(|item| item.get("id").and_then(serde_json::Value::as_str))
1591        .map(str::to_owned)
1592        .collect::<BTreeSet<_>>();
1593    let mut unmeasured = manifest.unmeasured.iter().cloned().collect::<BTreeSet<_>>();
1594    let mut new_limitations = Vec::new();
1595    for limitation in &limitations {
1596        if let Some(obligation) = &limitation.obligation {
1597            if !index.lines.contains_key(obligation.as_str()) {
1598                return Err(RubyEvidenceError::UnknownObligation(obligation.clone()));
1599            }
1600            unmeasured.insert(obligation.clone());
1601        } else if let Some(file) = &limitation.file {
1602            // A code-object mapping failure or missing debug ranges prevents
1603            // every obligation in that source file from being observed. Mark
1604            // the whole file unmeasured instead of presenting its denominator
1605            // as ordinary uncovered code.
1606            unmeasured.extend(
1607                index
1608                    .lines
1609                    .iter()
1610                    .filter(|(_, (obligation_file, _))| obligation_file == file)
1611                    .map(|(id, _)| (*id).to_owned()),
1612            );
1613        }
1614        if limitation_ids.insert(limitation.id.clone()) {
1615            let (file, line) = limitation
1616                .obligation
1617                .as_deref()
1618                .and_then(|id| index.lines.get(id).cloned())
1619                .unwrap_or_else(|| {
1620                    (
1621                        limitation.file.clone().unwrap_or_else(|| {
1622                            manifest
1623                                .points
1624                                .first()
1625                                .map_or(".".into(), |point| point.file.clone())
1626                        }),
1627                        1,
1628                    )
1629                });
1630            let source = limitation
1631                .obligation
1632                .as_deref()
1633                .and_then(|id| index.sources.get(id))
1634                .map(|source| source.lines().next().unwrap_or_default().to_owned())
1635                .unwrap_or_default();
1636            new_limitations.push(json!({
1637                "id": limitation.id,
1638                "kind": "semantic-safety",
1639                "file": file,
1640                "line": line,
1641                "column": 0,
1642                "source": source,
1643                "reason": limitation.reason
1644            }));
1645        }
1646    }
1647    manifest.limitations.extend(new_limitations);
1648    manifest.unmeasured = unmeasured.into_iter().collect();
1649    let structural_limitations = limitation_ids.into_iter().collect::<Vec<_>>();
1650
1651    // Retries are separate raw results so their coverage remains attempt
1652    // exact, but the public lifecycle diagnostic reports logical tests rather
1653    // than inflating the count when a flaky test is rerun.
1654    let tests = raw_results
1655        .iter()
1656        .filter(|raw| raw.role == "test")
1657        .map(|raw| raw.test.as_str())
1658        .collect::<BTreeSet<_>>()
1659        .len();
1660    Ok(RubyFrontendRun {
1661        declaration: FrontendRunDeclaration {
1662            protocol_version: LANGUAGE_FRONTEND_PROTOCOL_VERSION,
1663            frontend_id: "ruby".into(),
1664            frontend_version: RUBY_FRONTEND_VERSION.into(),
1665            language: "ruby".into(),
1666            structural_source: StructuralSource::OwnedProbes,
1667            runners: observed_runners
1668                .iter()
1669                .map(|runner| FrontendRunnerDeclaration {
1670                    runner: runner.clone(),
1671                    execution_model: ExecutionModel::SerialInProcess,
1672                    attribution: FrontendAttribution {
1673                        run: AttributionPrecision::Exact,
1674                        worker: AttributionPrecision::Exact,
1675                        test: AttributionPrecision::Exact,
1676                        retry: AttributionPrecision::Exact,
1677                        phase: AttributionPrecision::Exact,
1678                        action: AttributionPrecision::Unavailable,
1679                        // A server sees a browser test's requests, never
1680                        // its assertions, which run in the browser runner.
1681                        assertion: if ruby_runner(runner) {
1682                            AttributionPrecision::Exact
1683                        } else {
1684                            AttributionPrecision::Unavailable
1685                        },
1686                    },
1687                    limitations: vec![if ruby_runner(runner) {
1688                        FrontendLimitation {
1689                            id: format!("ruby-{runner}-action-linkage"),
1690                            scopes: vec![FrontendLimitationScope::Action],
1691                            reason: format!("{runner} exposes no general action lifecycle"),
1692                        }
1693                    } else {
1694                        FrontendLimitation {
1695                            id: format!("ruby-server-{runner}-linkage"),
1696                            scopes: vec![
1697                                FrontendLimitationScope::Action,
1698                                FrontendLimitationScope::Assertion,
1699                            ],
1700                            reason: format!(
1701                                "the server sees each {runner} test's requests, not its actions or assertions"
1702                            ),
1703                        }
1704                    }]
1705                    .into_iter()
1706                    // Declared because the declaration is what a reader checks
1707                    // a number against. Where lines come from Ruby's one-shot
1708                    // Coverage (a file loaded without probes, a Ractor block),
1709                    // a line is reported the first time it executes in the
1710                    // process and never again: what a test is credited with is
1711                    // its own; what it is not credited with is not evidence it
1712                    // did not run.
1713                    .chain(first_sighting.then(|| FrontendLimitation {
1714                        id: format!("ruby-{runner}-first-sighting-lines"),
1715                        scopes: vec![FrontendLimitationScope::Test],
1716                        reason:
1717                            "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"
1718                                .into(),
1719                    }))
1720                    .collect(),
1721                })
1722                .collect(),
1723            structural_limitations,
1724        },
1725        request: CoverageReportRequest {
1726            run_id: run_id.into(),
1727            manifest,
1728            raw_results,
1729            generated_at: generated_at.into(),
1730            coverage_model: Some(ruby_coverage_model()),
1731            integrity: None,
1732            test_exit_code: ExitCodeInput::Present(Some(test_exit_code)),
1733        },
1734        tests,
1735        interpreters,
1736        ruby_versions: ruby_versions.into_iter().collect(),
1737    })
1738}
1739
1740#[cfg(test)]
1741mod tests {
1742    use super::*;
1743    use crate::{
1744        coverage_analysis::PointKind, frontend_protocol::validate_frontend_report_request,
1745        ruby_instrumenter::build_ruby_obligations,
1746    };
1747
1748    fn frame(payload: &[u8]) -> Vec<u8> {
1749        let mut bytes = vec![1u8, 0, 0, 0];
1750        bytes.extend_from_slice(&(payload.len() as u32).to_le_bytes());
1751        bytes.extend_from_slice(&transport_checksum(payload).to_le_bytes());
1752        bytes.extend_from_slice(&[0, 0, 0, 0]);
1753        bytes.extend_from_slice(payload);
1754        while bytes.len() % 8 != 0 {
1755            bytes.push(0);
1756        }
1757        bytes
1758    }
1759
1760    fn transport(records: &[serde_json::Value]) -> Vec<u8> {
1761        let mut body = Vec::new();
1762        for record in records {
1763            body.extend(frame(record.to_string().as_bytes()));
1764        }
1765        let capacity = TRANSPORT_HEADER_SIZE + body.len();
1766        let mut bytes = vec![0u8; TRANSPORT_HEADER_SIZE];
1767        bytes[..8].copy_from_slice(TRANSPORT_MAGIC);
1768        bytes[8..12].copy_from_slice(&TRANSPORT_VERSION.to_le_bytes());
1769        bytes[12..16].copy_from_slice(&(TRANSPORT_HEADER_SIZE as u32).to_le_bytes());
1770        bytes[16..24].copy_from_slice(&(capacity as u64).to_le_bytes());
1771        bytes[32..40].copy_from_slice(&7u64.to_le_bytes());
1772        bytes.extend(body);
1773        bytes
1774    }
1775
1776    #[test]
1777    fn a_damaged_transport_is_refused_with_what_is_wrong() {
1778        // A killed interpreter, a full disk or another process writing into
1779        // the evidence directory each leave a transport like one of these.
1780        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"]});
1781        let exit = serde_json::json!({"t":"exit","at":9});
1782        let valid = transport(&[process.clone(), exit.clone()]);
1783        let read = |bytes: &[u8]| match read_evidence_file(
1784            "main.7.a.mmap",
1785            bytes,
1786            "run-1",
1787            &mut Evidence::default(),
1788        ) {
1789            Ok(()) => "accepted".to_owned(),
1790            Err(error) => error.to_string(),
1791        };
1792        assert_eq!(read(&valid), "accepted");
1793        // The first frame's header is at 64 and its payload at 80.
1794        let payload_end = 80 + process.to_string().len();
1795        assert_ne!(payload_end % 8, 0, "the frame has padding to corrupt");
1796        let set = |at: usize, value: &[u8]| {
1797            let mut bytes = valid.clone();
1798            bytes[at..at + value.len()].copy_from_slice(value);
1799            bytes
1800        };
1801        let mut truncated = valid[..payload_end].to_vec();
1802        truncated[16..24].copy_from_slice(&(payload_end as u64).to_le_bytes());
1803        let other_owner = transport(&[
1804            serde_json::json!({"t":"process","v":1,"run":"run-1","pid":8,"worker":"main","ruby":"4.0.6","executable":"ruby","argv":["x.rb"]}),
1805        ]);
1806        let other_run = transport(&[
1807            serde_json::json!({"t":"process","v":1,"run":"run-2","pid":7,"worker":"main","ruby":"4.0.6","executable":"ruby","argv":["x.rb"]}),
1808        ]);
1809        for (bytes, reason) in [
1810            (set(0, b"X"), "header or version does not match"),
1811            (
1812                set(16, &8_u64.to_le_bytes()),
1813                "declared capacity is outside the mapped file",
1814            ),
1815            (set(24, &3_u64.to_le_bytes()), "3"),
1816            (set(32, &0_u64.to_le_bytes()), "process id is missing"),
1817            (set(64, &[2]), "commit marker or reserved bytes are invalid"),
1818            (
1819                set(68, &0_u32.to_le_bytes()),
1820                "payload length is outside the transport bound",
1821            ),
1822            (
1823                set(68, &4096_u32.to_le_bytes()),
1824                "payload extends past the mapped file",
1825            ),
1826            (truncated, "aligned frame extends past the mapped file"),
1827            (set(payload_end, &[1]), "frame padding is not zero"),
1828            (
1829                set(72, &0_u32.to_le_bytes()),
1830                "payload checksum does not match",
1831            ),
1832            (other_owner, "does not match the transport owner"),
1833            (other_run, "run-2"),
1834        ] {
1835            let said = read(&bytes);
1836            assert!(said.contains(reason), "{reason}: {said}");
1837        }
1838    }
1839
1840    fn temporary(name: &str) -> std::path::PathBuf {
1841        let nonce = std::time::SystemTime::now()
1842            .duration_since(std::time::UNIX_EPOCH)
1843            .unwrap()
1844            .as_nanos();
1845        let path = std::env::temp_dir().join(format!(
1846            "supercov-ruby-evidence-{}-{nonce}-{name}",
1847            std::process::id()
1848        ));
1849        fs::create_dir_all(&path).unwrap();
1850        path
1851    }
1852
1853    #[test]
1854    fn declares_the_gap_when_a_process_never_reported() {
1855        // Coverage's own result is read in the `at_exit` that writes the exit
1856        // record, so a transport without one belongs to a process that was
1857        // killed or left through `exit!`. What it observed is unrecoverable and
1858        // unknowable, so the run says so rather than reporting those lines as
1859        // uncovered. The same records WITH an exit record must stay silent, or
1860        // every ordinary run would claim a gap it does not have.
1861        let source = "def f(a)\n  a\nend\n";
1862        let mut probe = 0;
1863        let obligations =
1864            build_ruby_obligations("lib/m.rb", source.as_bytes(), &mut probe).unwrap();
1865        let statement = obligations
1866            .manifest
1867            .points
1868            .iter()
1869            .find(|point| point.kind == PointKind::Statement)
1870            .unwrap();
1871        let base = [
1872            serde_json::json!({"t":"process","v":1,"run":"run-1","pid":7,"worker":"main","ruby":"4.0.6","executable":"ruby","argv":["child.rb"]}),
1873            serde_json::json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"MTest#test_x","retry":0,"phase":"call"}),
1874            serde_json::json!({"t":"hit","ctx":1,"id":statement.id}),
1875            serde_json::json!({"t":"outcome","worker":"main","test":"MTest#test_x","retry":0,"phase":"call","outcome":"passed","xfail":false,"runner":"minitest"}),
1876        ];
1877
1878        let killed = temporary("killed");
1879        fs::write(killed.join("main.7.a.mmap"), transport(&base)).unwrap();
1880        let run = build_ruby_frontend_run(
1881            &obligations.manifest,
1882            &killed,
1883            "run-1",
1884            "now",
1885            0,
1886            Path::new(""),
1887        )
1888        .unwrap();
1889        let declared = serde_json::to_string(&run.request.manifest.limitations).unwrap();
1890        assert!(
1891            declared.contains("ruby-process-did-not-report"),
1892            "a process that never reported must declare the gap: {declared}"
1893        );
1894        fs::remove_dir_all(&killed).unwrap();
1895
1896        let mut clean = base.to_vec();
1897        clean.push(serde_json::json!({"t":"exit","at":9}));
1898        let reported = temporary("reported");
1899        fs::write(reported.join("main.7.a.mmap"), transport(&clean)).unwrap();
1900        let run = build_ruby_frontend_run(
1901            &obligations.manifest,
1902            &reported,
1903            "run-1",
1904            "now",
1905            0,
1906            Path::new(""),
1907        )
1908        .unwrap();
1909        let declared = serde_json::to_string(&run.request.manifest.limitations).unwrap();
1910        assert!(
1911            !declared.contains("ruby-process-did-not-report"),
1912            "a process that reported cleanly must declare nothing: {declared}"
1913        );
1914        fs::remove_dir_all(&reported).unwrap();
1915    }
1916
1917    // A path is only absolute in the platform's own spelling: "/project" is a
1918    // relative path on Windows, where an absolute one needs a drive. The
1919    // runtimes report whatever the interpreter loaded, so these fixtures have
1920    // to speak the host's dialect too.
1921    fn under(first: &str, rest: &str) -> String {
1922        let mut path = std::path::PathBuf::from(if cfg!(windows) {
1923            format!("C:\\{first}")
1924        } else {
1925            format!("/{first}")
1926        });
1927        for part in rest.split('/').filter(|part| !part.is_empty()) {
1928            path.push(part);
1929        }
1930        path.to_string_lossy().into_owned()
1931    }
1932
1933    fn assertion_sources(run: &RubyFrontendRun) -> Vec<String> {
1934        run.request.raw_results[0]
1935            .phases
1936            .iter()
1937            .filter(|phase| phase.kind == "assertion")
1938            .filter_map(|phase| phase.source.clone())
1939            .collect()
1940    }
1941
1942    fn run_with_sites(
1943        name: &str,
1944        sites: &[serde_json::Value],
1945        outcome_file: Option<&str>,
1946        root: &str,
1947    ) -> RubyFrontendRun {
1948        let source = "def f(a)\n  a\nend\n";
1949        let mut probe = 0;
1950        let obligations =
1951            build_ruby_obligations("lib/m.rb", source.as_bytes(), &mut probe).unwrap();
1952        let mut outcome = serde_json::json!({"t":"outcome","worker":"main","test":"MTest#test_x","retry":0,"phase":"call","outcome":"passed","xfail":false,"runner":"minitest"});
1953        if let Some(file) = outcome_file {
1954            outcome["file"] = serde_json::json!(file);
1955        }
1956        let mut records = vec![
1957            serde_json::json!({"t":"process","v":1,"run":"run-1","pid":7,"worker":"main","ruby":"4.0.6","executable":"ruby","argv":["test.rb"]}),
1958            serde_json::json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"MTest#test_x","retry":0,"phase":"call"}),
1959            serde_json::json!({"t":"assert","ctx":1}),
1960        ];
1961        records.extend(sites.iter().cloned());
1962        records.push(outcome);
1963        records.push(serde_json::json!({"t":"exit","at":9}));
1964        let directory = temporary(name);
1965        fs::write(directory.join("main.7.a.mmap"), transport(&records)).unwrap();
1966        let run = build_ruby_frontend_run(
1967            &obligations.manifest,
1968            &directory,
1969            "run-1",
1970            "now",
1971            0,
1972            Path::new(root),
1973        )
1974        .unwrap();
1975        validate_frontend_report_request(&run.declaration, &run.request).unwrap();
1976        fs::remove_dir_all(directory).unwrap();
1977        run
1978    }
1979
1980    #[test]
1981    fn a_test_s_assertions_are_its_one_assertion_phase() {
1982        // The per-test assertion phase carries the evidence from the first
1983        // assertion on; there is no separate phase per assertion line.
1984        let run = run_with_sites("ruby-assertion-phase", &[], None, &under("project", ""));
1985        assert_eq!(assertion_sources(&run), vec!["MTest#test_x".to_string()]);
1986    }
1987
1988    #[test]
1989    fn the_runner_names_the_test_file_in_either_path_form() {
1990        // Minitest keeps the path the interpreter loaded, which may be
1991        // relative; a backtrace is absolute. Both name the same project file,
1992        // and an adapter that names none falls back to the identity.
1993        let root = under("project", "");
1994        for reported in [
1995            under("project", "test/m_test.rb"),
1996            "test/m_test.rb".to_owned(),
1997            "./test/m_test.rb".to_owned(),
1998        ] {
1999            let run = run_with_sites("asite-file", &[], Some(reported.as_str()), &root);
2000            assert_eq!(
2001                run.request.raw_results[0].test_file.as_deref(),
2002                Some("test/m_test.rb"),
2003                "{reported} should resolve to the project path"
2004            );
2005        }
2006        let without = run_with_sites("asite-nofile", &[], None, &root);
2007        assert_eq!(
2008            without.request.raw_results[0].test_file.as_deref(),
2009            Some("MTest#test_x"),
2010            "an adapter that cannot name a file keeps the identity fallback"
2011        );
2012    }
2013
2014    #[test]
2015    fn a_phase_a_runner_reported_more_than_once_is_one_phase() {
2016        // The Python path lost a suite's whole run to a repeated phase id
2017        // (#40). No Ruby runner reports a phase twice today, but the reader is
2018        // the same shape, and a run is too much to lose to it.
2019        let source = "def f(a)\n  a\nend\n";
2020        let mut probe = 0;
2021        let obligations =
2022            build_ruby_obligations("lib/m.rb", source.as_bytes(), &mut probe).unwrap();
2023        let statement = obligations
2024            .manifest
2025            .points
2026            .iter()
2027            .find(|point| point.kind == PointKind::Statement)
2028            .unwrap();
2029        let again = |outcome: &str| {
2030            serde_json::json!({"t":"outcome","worker":"main","test":"MTest#test_x","retry":0,
2031                               "phase":"call","outcome":outcome,"xfail":false,"runner":"minitest"})
2032        };
2033        let records = [
2034            serde_json::json!({"t":"process","v":1,"run":"run-1","pid":7,"worker":"main","ruby":"4.0.6","executable":"ruby","argv":["test.rb"]}),
2035            serde_json::json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"MTest#test_x","retry":0,"phase":"call"}),
2036            serde_json::json!({"t":"hit","ctx":1,"id":statement.id}),
2037            again("passed"),
2038            again("failed"),
2039            again("passed"),
2040            serde_json::json!({"t":"exit","at":9}),
2041        ];
2042        let directory = temporary("repeated-phase");
2043        fs::write(directory.join("main.7.a.mmap"), transport(&records)).unwrap();
2044        let run = build_ruby_frontend_run(
2045            &obligations.manifest,
2046            &directory,
2047            "run-1",
2048            "now",
2049            1,
2050            Path::new(""),
2051        )
2052        .unwrap();
2053        validate_frontend_report_request(&run.declaration, &run.request).unwrap();
2054        let test = &run.request.raw_results[0];
2055        assert_eq!(test.phases.len(), 1, "{:?}", test.phases);
2056        assert_eq!(test.status.as_deref(), Some("failed"));
2057        fs::remove_dir_all(directory).unwrap();
2058    }
2059
2060    #[test]
2061    fn evidence_before_the_first_assertion_links_to_it_when_the_test_passes() {
2062        // The runtime's marker says everything the call phase recorded so far
2063        // ran before an assertion. That evidence carries an assertion phase
2064        // that passed with the test; what ran after the marker, and all of a
2065        // test that failed, stays execution only. A second marker is inert.
2066        let source = "def f(a)\n  a\nend\n\ndef g(b)\n  b\nend\n";
2067        let mut probe = 0;
2068        let obligations =
2069            build_ruby_obligations("lib/m.rb", source.as_bytes(), &mut probe).unwrap();
2070        let statements: Vec<_> = obligations
2071            .manifest
2072            .points
2073            .iter()
2074            .filter(|point| point.kind == PointKind::Statement)
2075            .collect();
2076        let (before, after) = (&statements[0].id, &statements[1].id);
2077        let run_with = |name: &str, outcome: &str| {
2078            let records = [
2079                serde_json::json!({"t":"process","v":1,"run":"run-1","pid":7,"worker":"main","ruby":"4.0.6","executable":"ruby","argv":["test.rb"]}),
2080                serde_json::json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"MTest#test_x","retry":0,"phase":"call"}),
2081                serde_json::json!({"t":"hit","ctx":1,"id":before}),
2082                serde_json::json!({"t":"assert","ctx":1}),
2083                serde_json::json!({"t":"assert","ctx":1}),
2084                serde_json::json!({"t":"hit","ctx":1,"id":after}),
2085                serde_json::json!({"t":"outcome","worker":"main","test":"MTest#test_x","retry":0,"phase":"call","outcome":outcome,"xfail":false,"runner":"minitest"}),
2086                serde_json::json!({"t":"exit","at":9}),
2087            ];
2088            let directory = temporary(name);
2089            fs::write(directory.join("main.7.a.mmap"), transport(&records)).unwrap();
2090            let run = build_ruby_frontend_run(
2091                &obligations.manifest,
2092                &directory,
2093                "run-1",
2094                "now",
2095                0,
2096                Path::new(""),
2097            )
2098            .unwrap();
2099            validate_frontend_report_request(&run.declaration, &run.request).unwrap();
2100            fs::remove_dir_all(directory).unwrap();
2101            run
2102        };
2103        let events_of = |result: &RawTestResult, phase: &str| -> BTreeSet<String> {
2104            result
2105                .runtime
2106                .iter()
2107                .flat_map(|snapshot| snapshot.events.iter())
2108                .filter(|event| event.phase_id.as_deref() == Some(phase))
2109                .map(|event| event.id.clone())
2110                .collect()
2111        };
2112
2113        let run = run_with("asserted-passed", "passed");
2114        let passed = &run.request.raw_results[0];
2115        assert_eq!(passed.test, "MTest#test_x");
2116        let assertion = passed
2117            .phases
2118            .iter()
2119            .find(|phase| phase.kind == "assertion")
2120            .expect("the asserting test carries an assertion phase");
2121        assert_eq!(assertion.status.as_deref(), Some("passed"));
2122        assert_eq!(
2123            events_of(passed, &assertion.id),
2124            BTreeSet::from([before.clone()]),
2125            "only what ran before the marker is the assertion's evidence"
2126        );
2127        let test_phase = passed
2128            .phases
2129            .iter()
2130            .find(|phase| phase.kind == "test")
2131            .unwrap();
2132        assert_eq!(
2133            events_of(passed, &test_phase.id),
2134            BTreeSet::from([before.clone(), after.clone()]),
2135            "the test phase keeps everything it ran"
2136        );
2137        assert_eq!(
2138            run.declaration.runners[0].attribution.assertion,
2139            AttributionPrecision::Exact
2140        );
2141
2142        let run = run_with("asserted-failed", "failed");
2143        let failed = &run.request.raw_results[0];
2144        let assertion = failed
2145            .phases
2146            .iter()
2147            .find(|phase| phase.kind == "assertion")
2148            .unwrap();
2149        assert_eq!(
2150            assertion.status.as_deref(),
2151            Some("failed"),
2152            "a failed test's assertion witnessed nothing"
2153        );
2154    }
2155
2156    #[test]
2157    fn joins_rspec_and_minitest_outcomes_into_exact_results() {
2158        let source = "def f(a, b)\n  if a && b\n    1\n  else\n    0\n  end\nend\n";
2159        let mut probe = 0;
2160        let obligations =
2161            build_ruby_obligations("lib/m.rb", source.as_bytes(), &mut probe).unwrap();
2162        let decision = &obligations.manifest.decisions[0];
2163        let statement = obligations
2164            .manifest
2165            .points
2166            .iter()
2167            .find(|point| point.kind == PointKind::Statement)
2168            .unwrap();
2169        let directory = temporary("join");
2170        let records = [
2171            serde_json::json!({"t":"process","v":1,"run":"run-1","pid":7,"worker":"main","ruby":"4.0.6","executable":"ruby","argv":["rspec"]}),
2172            serde_json::json!({"t":"hit","ctx":0,"id":statement.id}),
2173            serde_json::json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"spec/m_spec.rb[1:1]","retry":0,"phase":"call"}),
2174            serde_json::json!({"t":"dec","ctx":1,"id":decision.id,"v":"22","o":1}),
2175            serde_json::json!({"t":"outcome","worker":"main","test":"spec/m_spec.rb[1:1]","retry":0,"phase":"setup","outcome":"passed","xfail":false,"runner":"rspec"}),
2176            serde_json::json!({"t":"outcome","worker":"main","test":"spec/m_spec.rb[1:1]","retry":0,"phase":"call","outcome":"passed","xfail":false,"runner":"rspec"}),
2177            serde_json::json!({"t":"outcome","worker":"main","test":"spec/m_spec.rb[1:1]","retry":0,"phase":"teardown","outcome":"passed","xfail":false,"runner":"rspec"}),
2178            serde_json::json!({"t":"phase","ctx":2,"at":6,"worker":"main","test":"MTest#test_x","retry":0,"phase":"call"}),
2179            serde_json::json!({"t":"outcome","worker":"main","test":"MTest#test_x","retry":0,"phase":"call","outcome":"skipped","xfail":false,"runner":"minitest"}),
2180            serde_json::json!({"t":"exit","at":9}),
2181        ];
2182        fs::write(directory.join("main.7.a.mmap"), transport(&records)).unwrap();
2183        let run = build_ruby_frontend_run(
2184            &obligations.manifest,
2185            &directory,
2186            "run-1",
2187            "now",
2188            0,
2189            Path::new(""),
2190        )
2191        .unwrap();
2192        validate_frontend_report_request(&run.declaration, &run.request).unwrap();
2193        assert_eq!(run.tests, 2);
2194        let runners = run
2195            .declaration
2196            .runners
2197            .iter()
2198            .map(|runner| runner.runner.clone())
2199            .collect::<Vec<_>>();
2200        assert_eq!(runners, ["minitest", "rspec"]);
2201        assert_eq!(run.ruby_versions, ["4.0.6"]);
2202        // What ran while loading is no test's, so it is in no kind's row.
2203        let background = run
2204            .request
2205            .raw_results
2206            .iter()
2207            .find(|raw| raw.role == "background")
2208            .unwrap();
2209        assert_eq!(background.provenance.kind, "background");
2210        fs::remove_dir_all(directory).unwrap();
2211    }
2212
2213    #[test]
2214    fn an_rspec_example_carries_its_tags_and_description_and_a_level_tag_is_its_kind() {
2215        let source = "def f(a)\n  a ? 1 : 0\nend\n";
2216        let mut probe = 0;
2217        let obligations =
2218            build_ruby_obligations("lib/m.rb", source.as_bytes(), &mut probe).unwrap();
2219        let directory = temporary("tags");
2220        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"});
2221        let records = [
2222            serde_json::json!({"t":"process","v":1,"run":"run-1","pid":7,"worker":"main","ruby":"3.4.10","executable":"ruby","argv":["rspec"]}),
2223            serde_json::json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"spec/m_spec.rb[1:1]","retry":0,"phase":"call"}),
2224            outcome("setup"),
2225            outcome("call"),
2226            outcome("teardown"),
2227            serde_json::json!({"t":"exit","at":9}),
2228        ];
2229        fs::write(directory.join("main.7.a.mmap"), transport(&records)).unwrap();
2230        let run = build_ruby_frontend_run(
2231            &obligations.manifest,
2232            &directory,
2233            "run-1",
2234            "now",
2235            0,
2236            Path::new(""),
2237        )
2238        .unwrap();
2239        let test = run
2240            .request
2241            .raw_results
2242            .iter()
2243            .find(|raw| raw.role == "test")
2244            .unwrap();
2245        assert_eq!(test.provenance.tags, ["api", "e2e"]);
2246        assert_eq!(test.provenance.kind, "e2e");
2247        assert_eq!(test.title.as_deref(), Some("M pays a member"));
2248        fs::remove_dir_all(directory).unwrap();
2249    }
2250
2251    #[test]
2252    fn fails_closed_without_an_interpreter() {
2253        let mut probe = 0;
2254        let obligations = build_ruby_obligations("m.rb", b"x = 1\n", &mut probe).unwrap();
2255        let directory = temporary("empty");
2256        assert!(matches!(
2257            build_ruby_frontend_run(
2258                &obligations.manifest,
2259                &directory,
2260                "run-1",
2261                "now",
2262                0,
2263                Path::new("")
2264            ),
2265            Err(RubyEvidenceError::NoInterpreter)
2266        ));
2267        fs::remove_dir_all(directory).unwrap();
2268    }
2269}