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