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::{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    },
90    Hit {
91        ctx: u64,
92        id: String,
93    },
94    Dec {
95        ctx: u64,
96        id: String,
97        v: String,
98        o: u8,
99    },
100    /// The first assertion of a call phase: what the context recorded before
101    /// this record is the assertion's evidence too.
102    Assert {
103        ctx: u64,
104    },
105    Limitation {
106        id: String,
107        reason: String,
108        #[serde(default)]
109        file: Option<String>,
110        #[serde(default)]
111        obligation: Option<String>,
112    },
113    Exit {
114        at: i64,
115    },
116}
117
118#[derive(Debug)]
119pub enum RubyEvidenceError {
120    Io(String),
121    UnsafeEntry(String),
122    InvalidRecord {
123        file: String,
124        line: usize,
125        reason: String,
126    },
127    InvalidTransport {
128        file: String,
129        reason: String,
130    },
131    DroppedRecords {
132        file: String,
133        count: u64,
134    },
135    RunMismatch {
136        expected: String,
137        actual: String,
138    },
139    UnsupportedVersion(u32),
140    UnknownContext {
141        file: String,
142        line: usize,
143        context: u64,
144    },
145    UnknownObligation(String),
146    InvalidVector {
147        id: String,
148        expected: usize,
149        actual: usize,
150    },
151    NoInterpreter,
152    NoTests,
153    UnsupportedRuby(String),
154}
155
156impl std::fmt::Display for RubyEvidenceError {
157    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
158        match self {
159            Self::Io(reason) => write!(formatter, "could not read Ruby evidence: {reason}"),
160            Self::UnsafeEntry(name) => write!(formatter, "unsafe Ruby evidence entry: {name}"),
161            Self::InvalidRecord { file, line, reason } => {
162                write!(formatter, "invalid Ruby evidence record {file}:{line}: {reason}")
163            }
164            Self::InvalidTransport { file, reason } => {
165                write!(formatter, "invalid Ruby evidence transport {file}: {reason}")
166            }
167            Self::DroppedRecords { file, count } => write!(
168                formatter,
169                "Ruby evidence transport {file} exhausted its bounded capacity and dropped {count} record(s)"
170            ),
171            Self::RunMismatch { expected, actual } => write!(
172                formatter,
173                "Ruby evidence belongs to run {actual}, expected {expected}"
174            ),
175            Self::UnsupportedVersion(version) => {
176                write!(formatter, "unsupported Ruby evidence version {version}")
177            }
178            Self::UnknownContext { file, line, context } => write!(
179                formatter,
180                "Ruby evidence {file}:{line} references undeclared context {context}"
181            ),
182            Self::UnknownObligation(id) => {
183                write!(formatter, "Ruby runtime reported an unknown obligation: {id}")
184            }
185            Self::InvalidVector {
186                id,
187                expected,
188                actual,
189            } => write!(
190                formatter,
191                "Ruby decision {id} reported {actual} condition values, expected {expected}"
192            ),
193            Self::NoInterpreter => formatter.write_str(
194                "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)",
195            ),
196            Self::NoTests => formatter.write_str(
197                "the Ruby run produced no test outcomes; Supercov measures Ruby through RSpec, Minitest, test-unit and Cucumber",
198            ),
199            Self::UnsupportedRuby(version) => write!(
200                formatter,
201                "Supercov measures Ruby 3.3 or newer; the test command ran Ruby {version}"
202            ),
203        }
204    }
205}
206
207impl std::error::Error for RubyEvidenceError {}
208
209fn stable_id(prefix: &str, values: &[&str]) -> String {
210    let mut hash = Sha256::new();
211    for value in values {
212        hash.update(value.as_bytes());
213        hash.update([0]);
214    }
215    let digest = hash.finalize();
216    let mut encoded = String::with_capacity(prefix.len() + 25);
217    encoded.push_str(prefix);
218    encoded.push(':');
219    for byte in &digest[..12] {
220        use std::fmt::Write as _;
221        write!(&mut encoded, "{byte:02x}").expect("string formatting");
222    }
223    encoded
224}
225
226#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
227struct Identity {
228    worker: String,
229    test: String,
230    retry: usize,
231    phase: String,
232}
233
234type ObservedVectors = BTreeSet<(Vec<Option<bool>>, bool)>;
235/// (worker, test, retry) -> [(phase, outcome, xfail)]
236type OutcomesByAttempt = BTreeMap<(String, String, usize), Vec<(String, String, bool)>>;
237/// (worker, test, retry) -> runner that reported the attempt
238type RunnersByAttempt = BTreeMap<(String, String, usize), String>;
239
240#[derive(Debug, Default)]
241struct Observations {
242    hits: BTreeSet<String>,
243    vectors: BTreeMap<String, ObservedVectors>,
244}
245
246#[derive(Debug, Clone)]
247struct RuntimeLimitation {
248    id: String,
249    reason: String,
250    file: Option<String>,
251    obligation: Option<String>,
252}
253
254#[derive(Debug, Default)]
255struct Evidence {
256    interpreters: usize,
257    ruby_versions: BTreeSet<String>,
258    per_identity: BTreeMap<Identity, Observations>,
259    background: BTreeMap<String, Observations>,
260    outcomes: OutcomesByAttempt,
261    runners: RunnersByAttempt,
262    limitations: Vec<RuntimeLimitation>,
263}
264
265fn read_evidence_directory(directory: &Path, run_id: &str) -> Result<Evidence, RubyEvidenceError> {
266    let mut evidence = Evidence::default();
267    let mut files = match fs::read_dir(directory) {
268        Ok(entries) => entries
269            .collect::<Result<Vec<_>, _>>()
270            .map_err(|error| RubyEvidenceError::Io(error.to_string()))?,
271        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
272        Err(error) => return Err(RubyEvidenceError::Io(error.to_string())),
273    };
274    files.sort_by_key(|entry| entry.file_name());
275    for entry in files {
276        let name = entry
277            .file_name()
278            .into_string()
279            .map_err(|_| RubyEvidenceError::UnsafeEntry("<non-utf8>".into()))?;
280        if Path::new(&name)
281            .components()
282            .any(|component| !matches!(component, Component::Normal(_)))
283            || !name.ends_with(".mmap")
284        {
285            return Err(RubyEvidenceError::UnsafeEntry(name));
286        }
287        let metadata = fs::symlink_metadata(entry.path())
288            .map_err(|error| RubyEvidenceError::Io(error.to_string()))?;
289        if !metadata.file_type().is_file() {
290            return Err(RubyEvidenceError::UnsafeEntry(name));
291        }
292        let file =
293            File::open(entry.path()).map_err(|error| RubyEvidenceError::Io(error.to_string()))?;
294        // The file is immutable from Supercov's perspective after the wrapped
295        // interpreter has exited. No mutable alias is created while this map
296        // is alive.
297        let contents = unsafe { MmapOptions::new().map(&file) }
298            .map_err(|error| RubyEvidenceError::Io(error.to_string()))?;
299        read_evidence_file(&name, &contents, run_id, &mut evidence)?;
300    }
301    Ok(evidence)
302}
303
304fn transport_u32(bytes: &[u8], offset: usize) -> Option<u32> {
305    bytes
306        .get(offset..offset + 4)
307        .and_then(|value| value.try_into().ok())
308        .map(u32::from_le_bytes)
309}
310
311fn transport_u64(bytes: &[u8], offset: usize) -> Option<u64> {
312    bytes
313        .get(offset..offset + 8)
314        .and_then(|value| value.try_into().ok())
315        .map(u64::from_le_bytes)
316}
317
318fn transport_checksum(payload: &[u8]) -> u32 {
319    payload.iter().fold(0x811c_9dc5_u32, |value, byte| {
320        (value ^ u32::from(*byte)).wrapping_mul(0x0100_0193)
321    })
322}
323
324fn align_transport(value: usize) -> Option<usize> {
325    value.checked_add(7).map(|value| value & !7)
326}
327
328fn read_evidence_file(
329    name: &str,
330    contents: &Mmap,
331    run_id: &str,
332    evidence: &mut Evidence,
333) -> Result<(), RubyEvidenceError> {
334    let invalid_transport = |reason: &str| RubyEvidenceError::InvalidTransport {
335        file: name.into(),
336        reason: reason.into(),
337    };
338    if contents.len() < TRANSPORT_HEADER_SIZE
339        || contents.get(..8) != Some(TRANSPORT_MAGIC.as_slice())
340        || transport_u32(contents, 8) != Some(TRANSPORT_VERSION)
341        || transport_u32(contents, 12) != Some(TRANSPORT_HEADER_SIZE as u32)
342    {
343        return Err(invalid_transport("header or version does not match"));
344    }
345    let declared_capacity =
346        transport_u64(contents, 16).ok_or_else(|| invalid_transport("capacity is missing"))?;
347    if declared_capacity < TRANSPORT_HEADER_SIZE as u64 || declared_capacity > contents.len() as u64
348    {
349        return Err(invalid_transport(
350            "declared capacity is outside the mapped file",
351        ));
352    }
353    let dropped =
354        transport_u64(contents, 24).ok_or_else(|| invalid_transport("drop counter is missing"))?;
355    if dropped != 0 {
356        return Err(RubyEvidenceError::DroppedRecords {
357            file: name.into(),
358            count: dropped,
359        });
360    }
361    let transport_pid = transport_u64(contents, 32)
362        .filter(|pid| *pid != 0)
363        .ok_or_else(|| invalid_transport("process id is missing"))?;
364    let mut contexts = BTreeMap::<u64, Identity>::new();
365    // What each call phase recorded so far, kept until its first assertion
366    // marker moves it to the phase's assertion identity.
367    let mut before_assertion = BTreeMap::<u64, Observations>::new();
368    let mut process_worker: Option<String> = None;
369    let mut process_started = false;
370    let mut process_reported = false;
371    let mut cursor = TRANSPORT_HEADER_SIZE;
372    let mut record_index = 0;
373    while cursor + TRANSPORT_RECORD_HEADER_SIZE <= contents.len() {
374        let commit = contents[cursor];
375        if commit == 0 {
376            // Payload bytes can exist after a killed writer, but an absent
377            // commit byte makes that frame and every later zeroed frame inert.
378            break;
379        }
380        record_index += 1;
381        let line_number = record_index;
382        let invalid = |reason: &str| RubyEvidenceError::InvalidRecord {
383            file: name.into(),
384            line: line_number,
385            reason: reason.into(),
386        };
387        if commit != 1
388            || contents[cursor + 1..cursor + 4] != [0, 0, 0]
389            || contents[cursor + 12..cursor + 16] != [0, 0, 0, 0]
390        {
391            return Err(invalid("commit marker or reserved bytes are invalid"));
392        }
393        let length = transport_u32(contents, cursor + 4)
394            .map(|value| value as usize)
395            .ok_or_else(|| invalid("payload length is missing"))?;
396        if length == 0 || length > TRANSPORT_MAX_RECORD_SIZE {
397            return Err(invalid("payload length is outside the transport bound"));
398        }
399        let payload_start = cursor + TRANSPORT_RECORD_HEADER_SIZE;
400        let payload_end = payload_start
401            .checked_add(length)
402            .filter(|end| *end <= contents.len())
403            .ok_or_else(|| invalid("payload extends past the mapped file"))?;
404        let next_cursor = align_transport(payload_end)
405            .filter(|end| *end <= contents.len())
406            .ok_or_else(|| invalid("aligned frame extends past the mapped file"))?;
407        if contents[payload_end..next_cursor]
408            .iter()
409            .any(|byte| *byte != 0)
410        {
411            return Err(invalid("frame padding is not zero"));
412        }
413        let payload = &contents[payload_start..payload_end];
414        let expected_checksum = transport_u32(contents, cursor + 8)
415            .ok_or_else(|| invalid("payload checksum is missing"))?;
416        if transport_checksum(payload) != expected_checksum {
417            return Err(invalid("payload checksum does not match"));
418        }
419        let record: Record =
420            serde_json::from_slice(payload).map_err(|error| RubyEvidenceError::InvalidRecord {
421                file: name.into(),
422                line: line_number,
423                reason: error.to_string(),
424            })?;
425        match record {
426            Record::Process {
427                v,
428                run,
429                pid,
430                worker,
431                ruby,
432                ..
433            } => {
434                if v != RUBY_EVIDENCE_VERSION {
435                    return Err(RubyEvidenceError::UnsupportedVersion(v));
436                }
437                if run != run_id {
438                    return Err(RubyEvidenceError::RunMismatch {
439                        expected: run_id.into(),
440                        actual: run,
441                    });
442                }
443                if pid != transport_pid {
444                    return Err(invalid("process record does not match the transport owner"));
445                }
446                let supported = ruby
447                    .split('.')
448                    .take(2)
449                    .map(|part| part.parse::<u32>().ok())
450                    .collect::<Option<Vec<_>>>()
451                    .is_some_and(|parts| parts.len() == 2 && (parts[0], parts[1]) >= (3, 3));
452                if !supported {
453                    return Err(RubyEvidenceError::UnsupportedRuby(ruby));
454                }
455                evidence.interpreters += 1;
456                evidence.ruby_versions.insert(ruby);
457                process_started = true;
458                process_worker = Some(worker);
459            }
460            Record::Worker { worker } => process_worker = Some(worker),
461            Record::Phase {
462                ctx,
463                worker,
464                test,
465                retry,
466                phase,
467                ..
468            } => {
469                if ctx == 0 {
470                    return Err(invalid("phase context 0 is reserved for background"));
471                }
472                if !matches!(phase.as_str(), "setup" | "call" | "teardown") {
473                    return Err(invalid("unknown test phase"));
474                }
475                if test.trim().is_empty() || worker.trim().is_empty() {
476                    return Err(invalid("phase identity must name a worker and test"));
477                }
478                if phase == "call" {
479                    before_assertion.insert(ctx, Observations::default());
480                }
481                contexts.insert(
482                    ctx,
483                    Identity {
484                        worker,
485                        test,
486                        retry,
487                        phase,
488                    },
489                );
490            }
491            Record::Outcome {
492                worker,
493                test,
494                retry,
495                phase,
496                outcome,
497                xfail,
498                runner,
499            } => {
500                if !matches!(phase.as_str(), "setup" | "call" | "teardown") {
501                    return Err(invalid("unknown test outcome phase"));
502                }
503                if !matches!(
504                    outcome.as_str(),
505                    "passed" | "failed" | "skipped" | "rerun" | "error"
506                ) {
507                    return Err(invalid("unknown test outcome"));
508                }
509                if !matches!(
510                    runner.as_str(),
511                    RSPEC_RUNNER | MINITEST_RUNNER | TEST_UNIT_RUNNER | CUCUMBER_RUNNER
512                ) {
513                    return Err(invalid("unknown Ruby test runner"));
514                }
515                let key = (worker, test, retry);
516                if let Some(previous) = evidence.runners.get(&key)
517                    && previous != &runner
518                {
519                    return Err(invalid("one attempt was reported by two runners"));
520                }
521                evidence.runners.insert(key.clone(), runner);
522                evidence
523                    .outcomes
524                    .entry(key)
525                    .or_default()
526                    .push((phase, outcome, xfail));
527            }
528            Record::Hit { ctx, id } => {
529                if let Some(before) = before_assertion.get_mut(&ctx) {
530                    before.hits.insert(id.clone());
531                }
532                observations(
533                    evidence,
534                    &contexts,
535                    process_worker.as_deref(),
536                    ctx,
537                    name,
538                    line_number,
539                )?
540                .hits
541                .insert(id);
542            }
543            Record::Dec { ctx, id, v, o } => {
544                if v.is_empty() || !v.bytes().all(|digit| matches!(digit, b'0' | b'1' | b'2')) {
545                    return Err(invalid("decision vector digits must be 0, 1 or 2"));
546                }
547                if o > 1 {
548                    return Err(invalid("decision outcome must be 0 or 1"));
549                }
550                let values = v
551                    .bytes()
552                    .map(|digit| match digit {
553                        b'0' => None,
554                        b'1' => Some(false),
555                        _ => Some(true),
556                    })
557                    .collect::<Vec<_>>();
558                if let Some(before) = before_assertion.get_mut(&ctx) {
559                    before
560                        .vectors
561                        .entry(id.clone())
562                        .or_default()
563                        .insert((values.clone(), o == 1));
564                }
565                observations(
566                    evidence,
567                    &contexts,
568                    process_worker.as_deref(),
569                    ctx,
570                    name,
571                    line_number,
572                )?
573                .vectors
574                .entry(id)
575                .or_default()
576                .insert((values, o == 1));
577            }
578            Record::Assert { ctx } => {
579                // Only the first marker of a call phase moves anything; a
580                // later one, or one outside a call phase, is inert.
581                if let Some(before) = before_assertion.remove(&ctx) {
582                    let identity = contexts
583                        .get(&ctx)
584                        .ok_or(RubyEvidenceError::UnknownContext {
585                            file: name.into(),
586                            line: line_number,
587                            context: ctx,
588                        })?;
589                    let asserted = evidence
590                        .per_identity
591                        .entry(Identity {
592                            phase: "assertion".into(),
593                            ..identity.clone()
594                        })
595                        .or_default();
596                    asserted.hits.extend(before.hits);
597                    for (id, vectors) in before.vectors {
598                        asserted.vectors.entry(id).or_default().extend(vectors);
599                    }
600                }
601            }
602            Record::Limitation {
603                id,
604                reason,
605                file,
606                obligation,
607            } => evidence.limitations.push(RuntimeLimitation {
608                id,
609                reason,
610                file,
611                obligation,
612            }),
613            Record::Exit { .. } => process_reported = true,
614        }
615        cursor = next_cursor;
616    }
617    // Ruby reads Coverage's own result in the `at_exit` that writes this
618    // record, so a process that never wrote one -- killed with a signal it
619    // could not catch, or left through `exit!` -- took everything it had
620    // observed since its last test boundary with it. Nothing outside that
621    // process can recover the counters or say which lines they were, so the
622    // run declares the gap instead of reporting those lines as merely
623    // uncovered. A declared limitation blocks completeness, which is the
624    // honest answer: coverage measured here is a floor, not a total.
625    if process_started && !process_reported {
626        evidence.limitations.push(RuntimeLimitation {
627            id: "ruby-process-did-not-report".into(),
628            reason: format!(
629                "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"
630            ),
631            file: None,
632            obligation: None,
633        });
634    }
635    Ok(())
636}
637
638fn observations<'a>(
639    evidence: &'a mut Evidence,
640    contexts: &BTreeMap<u64, Identity>,
641    process_worker: Option<&str>,
642    context: u64,
643    file: &str,
644    line: usize,
645) -> Result<&'a mut Observations, RubyEvidenceError> {
646    if context == 0 {
647        return Ok(evidence
648            .background
649            .entry(process_worker.unwrap_or("main").to_owned())
650            .or_default());
651    }
652    let identity = contexts
653        .get(&context)
654        .ok_or(RubyEvidenceError::UnknownContext {
655            file: file.into(),
656            line,
657            context,
658        })?;
659    Ok(evidence.per_identity.entry(identity.clone()).or_default())
660}
661
662pub fn ruby_coverage_model() -> CoverageModelDeclaration {
663    CoverageModelDeclaration {
664        language: "ruby".into(),
665        variant: "ruby-owned-coverage".into(),
666        name: "ruby-coverage-probes-v1".into(),
667        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(),
668        measured: vec![
669            "executable statements proven by Ruby's line coverage, or a probe when a line holds several".into(),
670            "method definitions entered (Ruby's method coverage)".into(),
671            "if/unless/elsif/ternary/while/until decisions with masking MC/DC vectors from operand probes".into(),
672            "while, until, for and iterator-block (each, map, times, ...) zero-versus-entered iteration".into(),
673            "&&, ||, ||= and &&= short-circuit alternatives".into(),
674            "case/when and case/in clause selection, safe navigation".into(),
675            "begin/rescue completion, handler selection and exception propagation".into(),
676            "RSpec, Minitest, test-unit and Cucumber worker, test and setup/call/teardown phase identity".into(),
677            "evidence a test recorded before its first assertion, linked to that assertion when the test passes".into(),
678        ],
679        not_measured: vec![
680            "blocks and lambdas as function entry points (they are statements inside their methods)".into(),
681            "blocks passed by reference (map(&:to_s)) as loops: they have no block body to observe".into(),
682            "line, branch and method observations made while test phases overlapped in threads (attributed to the run; probe observations stay per test)".into(),
683            "causal linkage to individual actions, or to any assertion after a test's first".into(),
684            "code compiled from strings at runtime (eval, instance_eval with strings)".into(),
685            "child coverage outside Process.spawn, Kernel#spawn, Kernel#system and fork".into(),
686            "all input values, semantic partitions, paths, or concurrency interleavings".into(),
687            "mutation score or assertion fault-detection strength".into(),
688        ],
689    }
690}
691
692fn phase_id(run: &str, identity: &Identity) -> String {
693    stable_id(
694        "ruby-phase",
695        &[
696            run,
697            &identity.worker,
698            &identity.test,
699            &identity.retry.to_string(),
700            &identity.phase,
701        ],
702    )
703}
704
705fn scope(run: &str, worker: &str, test: &str, retry: usize) -> ExecutionScope {
706    ExecutionScope {
707        version: 1,
708        run_id: run.into(),
709        worker_id: worker.into(),
710        test_id: test.into(),
711        test_key: stable_id("ruby-test", &[worker, test]),
712        retry,
713        attempt_id: stable_id("ruby-attempt", &[run, worker, test, &retry.to_string()]),
714    }
715}
716
717struct ManifestIndex<'a> {
718    points: BTreeSet<&'a str>,
719    alternatives: BTreeSet<&'a str>,
720    decisions: BTreeMap<&'a str, &'a DecisionMeta>,
721    lines: BTreeMap<&'a str, (String, usize)>,
722    sources: BTreeMap<&'a str, &'a str>,
723}
724
725impl<'a> ManifestIndex<'a> {
726    fn new(manifest: &'a CoverageManifest) -> Self {
727        let mut lines = BTreeMap::new();
728        let mut sources = BTreeMap::new();
729        for point in &manifest.points {
730            lines.insert(point.id.as_str(), (point.file.clone(), point.line));
731            sources.insert(point.id.as_str(), point.source.as_str());
732        }
733        for decision in &manifest.decisions {
734            lines.insert(decision.id.as_str(), (decision.file.clone(), decision.line));
735            sources.insert(decision.id.as_str(), decision.source.as_str());
736        }
737        for branch in &manifest.branches {
738            lines.insert(branch.id.as_str(), (branch.file.clone(), branch.line));
739            sources.insert(branch.id.as_str(), branch.source.as_str());
740        }
741        Self {
742            points: manifest
743                .points
744                .iter()
745                .map(|point| point.id.as_str())
746                .collect(),
747            alternatives: manifest
748                .branches
749                .iter()
750                .flat_map(|branch| branch.alternatives.iter().map(|alt| alt.id.as_str()))
751                .collect(),
752            decisions: manifest
753                .decisions
754                .iter()
755                .map(|decision| (decision.id.as_str(), decision))
756                .collect(),
757            lines,
758            sources,
759        }
760    }
761}
762
763fn snapshot(
764    index: &ManifestIndex<'_>,
765    observations: &Observations,
766    phase: &str,
767) -> Result<RuntimeSnapshot, RubyEvidenceError> {
768    let mut hits = BTreeSet::new();
769    for id in &observations.hits {
770        if !index.points.contains(id.as_str()) && !index.alternatives.contains(id.as_str()) {
771            return Err(RubyEvidenceError::UnknownObligation(id.clone()));
772        }
773        hits.insert(id.clone());
774    }
775    let mut decisions = Vec::new();
776    let mut events = Vec::new();
777    let mut clock = 1;
778    for id in &hits {
779        events.push(RuntimeEvent {
780            event_type: "hit".into(),
781            id: id.clone(),
782            vector: None,
783            timestamp_ms: clock,
784            phase_id: Some(phase.into()),
785            statement_id: None,
786            environment: "ruby".into(),
787        });
788        clock += 1;
789    }
790    for (id, vectors) in &observations.vectors {
791        let Some(meta) = index.decisions.get(id.as_str()) else {
792            return Err(RubyEvidenceError::UnknownObligation(id.clone()));
793        };
794        let mut observed = Vec::new();
795        for (values, outcome) in vectors {
796            if values.len() != meta.conditions.len() {
797                return Err(RubyEvidenceError::InvalidVector {
798                    id: id.clone(),
799                    expected: meta.conditions.len(),
800                    actual: values.len(),
801                });
802            }
803            let vector = McdcVector {
804                values: values.clone(),
805                outcome: *outcome,
806            };
807            events.push(RuntimeEvent {
808                event_type: "decision".into(),
809                id: id.clone(),
810                vector: Some(vector.clone()),
811                timestamp_ms: clock,
812                phase_id: Some(phase.into()),
813                statement_id: None,
814                environment: "ruby".into(),
815            });
816            clock += 1;
817            observed.push(vector);
818        }
819        decisions.push(DecisionSnapshot {
820            meta: (*meta).clone(),
821            vectors: observed,
822        });
823    }
824    Ok(RuntimeSnapshot {
825        decisions,
826        hits: hits.into_iter().collect(),
827        events,
828        logicals: Vec::new(),
829    })
830}
831
832fn attempt_status(outcomes: &[(String, String, bool)]) -> String {
833    if outcomes
834        .iter()
835        .any(|(_, outcome, _)| matches!(outcome.as_str(), "failed" | "rerun" | "error"))
836    {
837        "failed"
838    } else if outcomes.iter().any(|(_, outcome, _)| outcome == "skipped") {
839        "skipped"
840    } else {
841        "passed"
842    }
843    .into()
844}
845
846#[derive(Debug, Clone, PartialEq)]
847pub struct RubyFrontendRun {
848    pub declaration: FrontendRunDeclaration,
849    pub request: CoverageReportRequest,
850    pub tests: usize,
851    pub interpreters: usize,
852    pub ruby_versions: Vec<String>,
853}
854
855impl RubyFrontendRun {
856    pub fn archive_entries(&self) -> Result<Vec<EvidenceArchiveEntry>, serde_json::Error> {
857        let model = PersistedCoverageModel::from_declaration(
858            self.request
859                .coverage_model
860                .as_ref()
861                .expect("Ruby frontend always declares a coverage model"),
862        )
863        .expect("Ruby coverage model is contract-valid");
864        let mut entries = vec![
865            EvidenceArchiveEntry {
866                path: "coverage-model.json".into(),
867                contents: serde_json::to_vec(&model)?,
868            },
869            EvidenceArchiveEntry {
870                path: "frontend.json".into(),
871                contents: serde_json::to_vec(&self.declaration)?,
872            },
873            EvidenceArchiveEntry {
874                path: "manifest.json".into(),
875                contents: serde_json::to_vec(&self.request.manifest)?,
876            },
877        ];
878        for (index, result) in self.request.raw_results.iter().enumerate() {
879            entries.push(EvidenceArchiveEntry {
880                path: format!("results/{index:08}/mcdc.json"),
881                contents: serde_json::to_vec(result)?,
882            });
883        }
884        Ok(entries)
885    }
886}
887
888/// Join the runtime's evidence directory with the ahead-of-run manifest into
889/// a protocol-conformant frontend run.
890pub fn build_ruby_frontend_run(
891    manifest: &CoverageManifest,
892    evidence_directory: &Path,
893    run_id: &str,
894    generated_at: &str,
895    test_exit_code: i32,
896) -> Result<RubyFrontendRun, RubyEvidenceError> {
897    let evidence = read_evidence_directory(evidence_directory, run_id)?;
898    if evidence.interpreters == 0 {
899        return Err(RubyEvidenceError::NoInterpreter);
900    }
901    if evidence.outcomes.is_empty() {
902        return Err(RubyEvidenceError::NoTests);
903    }
904    let Evidence {
905        interpreters,
906        ruby_versions,
907        per_identity,
908        background,
909        outcomes,
910        runners,
911        limitations,
912    } = evidence;
913    let mut manifest = manifest.clone();
914    let index = ManifestIndex::new(&manifest);
915
916    let mut raw_results = Vec::new();
917    let mut observed_runners = BTreeSet::new();
918    let mut identities_by_attempt =
919        BTreeMap::<(String, String, usize), Vec<(&Identity, &Observations)>>::new();
920    for (identity, observations) in &per_identity {
921        identities_by_attempt
922            .entry((
923                identity.worker.clone(),
924                identity.test.clone(),
925                identity.retry,
926            ))
927            .or_default()
928            .push((identity, observations));
929    }
930    for ((worker, test, retry), mut outcomes) in outcomes {
931        let runner = runners
932            .get(&(worker.clone(), test.clone(), retry))
933            .cloned()
934            .unwrap_or_else(default_runner);
935        let attempt_identities = identities_by_attempt
936            .remove(&(worker.clone(), test.clone(), retry))
937            .unwrap_or_default();
938        observed_runners.insert(runner.clone());
939        outcomes.sort_by_key(|(phase, _, _)| match phase.as_str() {
940            "setup" => 0,
941            "call" => 1,
942            _ => 2,
943        });
944        let mut phases = Vec::new();
945        let mut runtime = Vec::new();
946        let mut observed_phases = BTreeSet::new();
947        for (position, (phase_name, outcome, xfail)) in outcomes.iter().enumerate() {
948            observed_phases.insert(phase_name.clone());
949            let identity = Identity {
950                worker: worker.clone(),
951                test: test.clone(),
952                retry,
953                phase: phase_name.clone(),
954            };
955            let id = phase_id(run_id, &identity);
956            phases.push(CoveragePhase {
957                id: id.clone(),
958                kind: match phase_name.as_str() {
959                    "call" => "test",
960                    value => value,
961                }
962                .into(),
963                operation: format!("{runner} {phase_name}"),
964                source: Some(test.clone()),
965                caused_by_phase_id: None,
966                started_at_ms: position as i64 * 2 + 1,
967                ended_at_ms: Some(position as i64 * 2 + 2),
968                status: Some(match outcome.as_str() {
969                    "rerun" | "error" => "failed".into(),
970                    value => value.into(),
971                }),
972                error: None,
973            });
974            if let Some((_, observations)) = attempt_identities
975                .iter()
976                .find(|(candidate, _)| candidate.phase == phase_name.as_str())
977            {
978                runtime.push(snapshot(&index, observations, &id)?);
979            }
980            if phase_name != "call" {
981                continue;
982            }
983            // What the test recorded before its first assertion is that
984            // assertion's evidence, linked when the phase passed outright:
985            // a failed, skipped or expected-to-fail phase witnessed nothing.
986            if let Some((identity, observations)) = attempt_identities
987                .iter()
988                .find(|(candidate, _)| candidate.phase == "assertion")
989            {
990                observed_phases.insert("assertion".to_owned());
991                let id = phase_id(run_id, identity);
992                phases.push(CoveragePhase {
993                    id: id.clone(),
994                    kind: "assertion".into(),
995                    operation: format!("{runner} assertion"),
996                    source: Some(test.clone()),
997                    caused_by_phase_id: None,
998                    started_at_ms: position as i64 * 2 + 1,
999                    ended_at_ms: Some(position as i64 * 2 + 2),
1000                    status: Some(
1001                        if outcome == "passed" && !*xfail {
1002                            "passed"
1003                        } else {
1004                            "failed"
1005                        }
1006                        .into(),
1007                    ),
1008                    error: None,
1009                });
1010                runtime.push(snapshot(&index, observations, &id)?);
1011            }
1012        }
1013        // A phase the runtime entered but the runner never reported (the worker
1014        // died inside it) is a failed phase with its evidence kept.
1015        for (identity, observations) in attempt_identities {
1016            if !observed_phases.contains(&identity.phase) {
1017                let id = phase_id(run_id, identity);
1018                phases.push(CoveragePhase {
1019                    id: id.clone(),
1020                    kind: match identity.phase.as_str() {
1021                        "call" => "test",
1022                        value => value,
1023                    }
1024                    .into(),
1025                    operation: format!("{runner} {}", identity.phase),
1026                    source: Some(test.clone()),
1027                    caused_by_phase_id: None,
1028                    started_at_ms: phases.len() as i64 * 2 + 1,
1029                    ended_at_ms: None,
1030                    status: Some("failed".into()),
1031                    error: Some("the phase started but the runner reported no outcome".into()),
1032                });
1033                runtime.push(snapshot(&index, observations, &id)?);
1034            }
1035        }
1036        let status = if phases.iter().any(|phase| phase.error.is_some()) {
1037            "failed".into()
1038        } else {
1039            attempt_status(&outcomes)
1040        };
1041        raw_results.push(RawTestResult {
1042            test_id: Some(test.clone()),
1043            scope: Some(scope(run_id, &worker, &test, retry)),
1044            test: test.clone(),
1045            test_file: test.split("::").next().map(str::to_owned),
1046            title: test.rsplit("::").next().map(str::to_owned),
1047            retry: Some(retry),
1048            status: Some(status),
1049            expected_status: Some(
1050                if outcomes.iter().any(|(_, _, xfail)| *xfail) {
1051                    "failed"
1052                } else {
1053                    "passed"
1054                }
1055                .into(),
1056            ),
1057            flaky: false,
1058            provenance: TestProvenance {
1059                runner: runner.clone(),
1060                kind: "unit".into(),
1061                project: None,
1062                source: RUBY_FRONTEND_VERSION.into(),
1063            },
1064            role: "test".into(),
1065            phases,
1066            runtime,
1067            browser: Vec::new(),
1068            server: Vec::new(),
1069        });
1070    }
1071    // Phases with observations whose test never produced any outcome at all
1072    // (for example a worker killed during its first phase).
1073    let default_observed = observed_runners
1074        .iter()
1075        .next()
1076        .cloned()
1077        .unwrap_or_else(default_runner);
1078    for ((worker, test, retry), identities) in identities_by_attempt {
1079        let runner = default_observed.clone();
1080        let mut phases = Vec::new();
1081        let mut runtime = Vec::new();
1082        for (position, (identity, observations)) in identities.iter().enumerate() {
1083            let id = phase_id(run_id, identity);
1084            phases.push(CoveragePhase {
1085                id: id.clone(),
1086                kind: match identity.phase.as_str() {
1087                    "call" => "test",
1088                    value => value,
1089                }
1090                .into(),
1091                operation: format!("{runner} {}", identity.phase),
1092                source: Some(test.clone()),
1093                caused_by_phase_id: None,
1094                started_at_ms: position as i64 * 2 + 1,
1095                ended_at_ms: None,
1096                status: Some("failed".into()),
1097                error: Some("the phase started but the runner reported no outcome".into()),
1098            });
1099            runtime.push(snapshot(&index, observations, &id)?);
1100        }
1101        raw_results.push(RawTestResult {
1102            test_id: Some(test.clone()),
1103            scope: Some(scope(run_id, &worker, &test, retry)),
1104            test: test.clone(),
1105            test_file: test.split("::").next().map(str::to_owned),
1106            title: test.rsplit("::").next().map(str::to_owned),
1107            retry: Some(retry),
1108            status: Some("failed".into()),
1109            expected_status: Some("passed".into()),
1110            flaky: false,
1111            provenance: TestProvenance {
1112                runner: runner.clone(),
1113                kind: "unit".into(),
1114                project: None,
1115                source: RUBY_FRONTEND_VERSION.into(),
1116            },
1117            role: "test".into(),
1118            phases,
1119            runtime,
1120            browser: Vec::new(),
1121            server: Vec::new(),
1122        });
1123    }
1124    for (worker, observations) in &background {
1125        if observations.hits.is_empty() && observations.vectors.is_empty() {
1126            continue;
1127        }
1128        let test = format!("__supercov_background__:{worker}");
1129        let identity = Identity {
1130            worker: worker.clone(),
1131            test: test.clone(),
1132            retry: 0,
1133            phase: "background".into(),
1134        };
1135        let phase = phase_id(run_id, &identity);
1136        raw_results.push(RawTestResult {
1137            test_id: Some(test.clone()),
1138            scope: Some(scope(run_id, worker, &test, 0)),
1139            test: "Ruby load and background execution".into(),
1140            test_file: None,
1141            title: None,
1142            retry: Some(0),
1143            status: Some("unknown".into()),
1144            expected_status: None,
1145            flaky: false,
1146            provenance: TestProvenance {
1147                runner: default_observed.clone(),
1148                kind: "unit".into(),
1149                project: None,
1150                source: RUBY_FRONTEND_VERSION.into(),
1151            },
1152            role: "background".into(),
1153            phases: vec![CoveragePhase {
1154                id: phase.clone(),
1155                kind: "background".into(),
1156                operation: "Ruby load background".into(),
1157                source: None,
1158                caused_by_phase_id: None,
1159                started_at_ms: 0,
1160                ended_at_ms: Some(0),
1161                status: Some("passed".into()),
1162                error: None,
1163            }],
1164            runtime: vec![snapshot(&index, observations, &phase)?],
1165            browser: Vec::new(),
1166            server: Vec::new(),
1167        });
1168    }
1169
1170    // Runtime-detected limitations: obligations the runtime could not map
1171    // become unmeasured, and every limitation ID joins the manifest so the
1172    // declaration and manifest agree.
1173    let mut limitation_ids = manifest
1174        .limitations
1175        .iter()
1176        .filter_map(|item| item.get("id").and_then(serde_json::Value::as_str))
1177        .map(str::to_owned)
1178        .collect::<BTreeSet<_>>();
1179    let mut unmeasured = manifest.unmeasured.iter().cloned().collect::<BTreeSet<_>>();
1180    let mut new_limitations = Vec::new();
1181    for limitation in &limitations {
1182        if let Some(obligation) = &limitation.obligation {
1183            if !index.lines.contains_key(obligation.as_str()) {
1184                return Err(RubyEvidenceError::UnknownObligation(obligation.clone()));
1185            }
1186            unmeasured.insert(obligation.clone());
1187        } else if let Some(file) = &limitation.file {
1188            // A code-object mapping failure or missing debug ranges prevents
1189            // every obligation in that source file from being observed. Mark
1190            // the whole file unmeasured instead of presenting its denominator
1191            // as ordinary uncovered code.
1192            unmeasured.extend(
1193                index
1194                    .lines
1195                    .iter()
1196                    .filter(|(_, (obligation_file, _))| obligation_file == file)
1197                    .map(|(id, _)| (*id).to_owned()),
1198            );
1199        }
1200        if limitation_ids.insert(limitation.id.clone()) {
1201            let (file, line) = limitation
1202                .obligation
1203                .as_deref()
1204                .and_then(|id| index.lines.get(id).cloned())
1205                .unwrap_or_else(|| {
1206                    (
1207                        limitation.file.clone().unwrap_or_else(|| {
1208                            manifest
1209                                .points
1210                                .first()
1211                                .map_or(".".into(), |point| point.file.clone())
1212                        }),
1213                        1,
1214                    )
1215                });
1216            let source = limitation
1217                .obligation
1218                .as_deref()
1219                .and_then(|id| index.sources.get(id))
1220                .map(|source| source.lines().next().unwrap_or_default().to_owned())
1221                .unwrap_or_default();
1222            new_limitations.push(json!({
1223                "id": limitation.id,
1224                "kind": "semantic-safety",
1225                "file": file,
1226                "line": line,
1227                "column": 0,
1228                "source": source,
1229                "reason": limitation.reason
1230            }));
1231        }
1232    }
1233    manifest.limitations.extend(new_limitations);
1234    manifest.unmeasured = unmeasured.into_iter().collect();
1235    let structural_limitations = limitation_ids.into_iter().collect::<Vec<_>>();
1236
1237    // Retries are separate raw results so their coverage remains attempt
1238    // exact, but the public lifecycle diagnostic reports logical tests rather
1239    // than inflating the count when a flaky test is rerun.
1240    let tests = raw_results
1241        .iter()
1242        .filter(|raw| raw.role == "test")
1243        .map(|raw| raw.test.as_str())
1244        .collect::<BTreeSet<_>>()
1245        .len();
1246    Ok(RubyFrontendRun {
1247        declaration: FrontendRunDeclaration {
1248            protocol_version: LANGUAGE_FRONTEND_PROTOCOL_VERSION,
1249            frontend_id: "ruby".into(),
1250            frontend_version: RUBY_FRONTEND_VERSION.into(),
1251            language: "ruby".into(),
1252            structural_source: StructuralSource::OwnedProbes,
1253            runners: observed_runners
1254                .iter()
1255                .map(|runner| FrontendRunnerDeclaration {
1256                    runner: runner.clone(),
1257                    execution_model: ExecutionModel::SerialInProcess,
1258                    attribution: FrontendAttribution {
1259                        run: AttributionPrecision::Exact,
1260                        worker: AttributionPrecision::Exact,
1261                        test: AttributionPrecision::Exact,
1262                        retry: AttributionPrecision::Exact,
1263                        phase: AttributionPrecision::Exact,
1264                        action: AttributionPrecision::Unavailable,
1265                        assertion: AttributionPrecision::Exact,
1266                    },
1267                    limitations: vec![FrontendLimitation {
1268                        id: format!("ruby-{runner}-action-linkage"),
1269                        scopes: vec![FrontendLimitationScope::Action],
1270                        reason: format!("{runner} exposes no general action lifecycle"),
1271                    }],
1272                })
1273                .collect(),
1274            structural_limitations,
1275        },
1276        request: CoverageReportRequest {
1277            run_id: run_id.into(),
1278            manifest,
1279            raw_results,
1280            generated_at: generated_at.into(),
1281            coverage_model: Some(ruby_coverage_model()),
1282            integrity: None,
1283            test_exit_code: ExitCodeInput::Present(Some(test_exit_code)),
1284        },
1285        tests,
1286        interpreters,
1287        ruby_versions: ruby_versions.into_iter().collect(),
1288    })
1289}
1290
1291#[cfg(test)]
1292mod tests {
1293    use super::*;
1294    use crate::{
1295        coverage_analysis::PointKind, frontend_protocol::validate_frontend_report_request,
1296        ruby_instrumenter::build_ruby_obligations,
1297    };
1298
1299    fn frame(payload: &[u8]) -> Vec<u8> {
1300        let mut bytes = vec![1u8, 0, 0, 0];
1301        bytes.extend_from_slice(&(payload.len() as u32).to_le_bytes());
1302        bytes.extend_from_slice(&transport_checksum(payload).to_le_bytes());
1303        bytes.extend_from_slice(&[0, 0, 0, 0]);
1304        bytes.extend_from_slice(payload);
1305        while bytes.len() % 8 != 0 {
1306            bytes.push(0);
1307        }
1308        bytes
1309    }
1310
1311    fn transport(records: &[serde_json::Value]) -> Vec<u8> {
1312        let mut body = Vec::new();
1313        for record in records {
1314            body.extend(frame(record.to_string().as_bytes()));
1315        }
1316        let capacity = TRANSPORT_HEADER_SIZE + body.len();
1317        let mut bytes = vec![0u8; TRANSPORT_HEADER_SIZE];
1318        bytes[..8].copy_from_slice(TRANSPORT_MAGIC);
1319        bytes[8..12].copy_from_slice(&TRANSPORT_VERSION.to_le_bytes());
1320        bytes[12..16].copy_from_slice(&(TRANSPORT_HEADER_SIZE as u32).to_le_bytes());
1321        bytes[16..24].copy_from_slice(&(capacity as u64).to_le_bytes());
1322        bytes[32..40].copy_from_slice(&7u64.to_le_bytes());
1323        bytes.extend(body);
1324        bytes
1325    }
1326
1327    fn temporary(name: &str) -> std::path::PathBuf {
1328        let nonce = std::time::SystemTime::now()
1329            .duration_since(std::time::UNIX_EPOCH)
1330            .unwrap()
1331            .as_nanos();
1332        let path = std::env::temp_dir().join(format!(
1333            "supercov-ruby-evidence-{}-{nonce}-{name}",
1334            std::process::id()
1335        ));
1336        fs::create_dir_all(&path).unwrap();
1337        path
1338    }
1339
1340    #[test]
1341    fn declares_the_gap_when_a_process_never_reported() {
1342        // Coverage's own result is read in the `at_exit` that writes the exit
1343        // record, so a transport without one belongs to a process that was
1344        // killed or left through `exit!`. What it observed is unrecoverable and
1345        // unknowable, so the run says so rather than reporting those lines as
1346        // uncovered. The same records WITH an exit record must stay silent, or
1347        // every ordinary run would claim a gap it does not have.
1348        let source = "def f(a)\n  a\nend\n";
1349        let mut probe = 0;
1350        let obligations =
1351            build_ruby_obligations("lib/m.rb", source.as_bytes(), &mut probe).unwrap();
1352        let statement = obligations
1353            .manifest
1354            .points
1355            .iter()
1356            .find(|point| point.kind == PointKind::Statement)
1357            .unwrap();
1358        let base = [
1359            serde_json::json!({"t":"process","v":1,"run":"run-1","pid":7,"worker":"main","ruby":"4.0.6","executable":"ruby","argv":["child.rb"]}),
1360            serde_json::json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"MTest#test_x","retry":0,"phase":"call"}),
1361            serde_json::json!({"t":"hit","ctx":1,"id":statement.id}),
1362            serde_json::json!({"t":"outcome","worker":"main","test":"MTest#test_x","retry":0,"phase":"call","outcome":"passed","xfail":false,"runner":"minitest"}),
1363        ];
1364
1365        let killed = temporary("killed");
1366        fs::write(killed.join("main.7.a.mmap"), transport(&base)).unwrap();
1367        let run =
1368            build_ruby_frontend_run(&obligations.manifest, &killed, "run-1", "now", 0).unwrap();
1369        let declared = serde_json::to_string(&run.request.manifest.limitations).unwrap();
1370        assert!(
1371            declared.contains("ruby-process-did-not-report"),
1372            "a process that never reported must declare the gap: {declared}"
1373        );
1374        fs::remove_dir_all(&killed).unwrap();
1375
1376        let mut clean = base.to_vec();
1377        clean.push(serde_json::json!({"t":"exit","at":9}));
1378        let reported = temporary("reported");
1379        fs::write(reported.join("main.7.a.mmap"), transport(&clean)).unwrap();
1380        let run =
1381            build_ruby_frontend_run(&obligations.manifest, &reported, "run-1", "now", 0).unwrap();
1382        let declared = serde_json::to_string(&run.request.manifest.limitations).unwrap();
1383        assert!(
1384            !declared.contains("ruby-process-did-not-report"),
1385            "a process that reported cleanly must declare nothing: {declared}"
1386        );
1387        fs::remove_dir_all(&reported).unwrap();
1388    }
1389
1390    #[test]
1391    fn evidence_before_the_first_assertion_links_to_it_when_the_test_passes() {
1392        // The runtime's marker says everything the call phase recorded so far
1393        // ran before an assertion. That evidence carries an assertion phase
1394        // that passed with the test; what ran after the marker, and all of a
1395        // test that failed, stays execution only. A second marker is inert.
1396        let source = "def f(a)\n  a\nend\n\ndef g(b)\n  b\nend\n";
1397        let mut probe = 0;
1398        let obligations =
1399            build_ruby_obligations("lib/m.rb", source.as_bytes(), &mut probe).unwrap();
1400        let statements: Vec<_> = obligations
1401            .manifest
1402            .points
1403            .iter()
1404            .filter(|point| point.kind == PointKind::Statement)
1405            .collect();
1406        let (before, after) = (&statements[0].id, &statements[1].id);
1407        let run_with = |name: &str, outcome: &str| {
1408            let records = [
1409                serde_json::json!({"t":"process","v":1,"run":"run-1","pid":7,"worker":"main","ruby":"4.0.6","executable":"ruby","argv":["test.rb"]}),
1410                serde_json::json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"MTest#test_x","retry":0,"phase":"call"}),
1411                serde_json::json!({"t":"hit","ctx":1,"id":before}),
1412                serde_json::json!({"t":"assert","ctx":1}),
1413                serde_json::json!({"t":"assert","ctx":1}),
1414                serde_json::json!({"t":"hit","ctx":1,"id":after}),
1415                serde_json::json!({"t":"outcome","worker":"main","test":"MTest#test_x","retry":0,"phase":"call","outcome":outcome,"xfail":false,"runner":"minitest"}),
1416                serde_json::json!({"t":"exit","at":9}),
1417            ];
1418            let directory = temporary(name);
1419            fs::write(directory.join("main.7.a.mmap"), transport(&records)).unwrap();
1420            let run = build_ruby_frontend_run(&obligations.manifest, &directory, "run-1", "now", 0)
1421                .unwrap();
1422            validate_frontend_report_request(&run.declaration, &run.request).unwrap();
1423            fs::remove_dir_all(directory).unwrap();
1424            run
1425        };
1426        let events_of = |result: &RawTestResult, phase: &str| -> BTreeSet<String> {
1427            result
1428                .runtime
1429                .iter()
1430                .flat_map(|snapshot| snapshot.events.iter())
1431                .filter(|event| event.phase_id.as_deref() == Some(phase))
1432                .map(|event| event.id.clone())
1433                .collect()
1434        };
1435
1436        let run = run_with("asserted-passed", "passed");
1437        let passed = &run.request.raw_results[0];
1438        assert_eq!(passed.test, "MTest#test_x");
1439        let assertion = passed
1440            .phases
1441            .iter()
1442            .find(|phase| phase.kind == "assertion")
1443            .expect("the asserting test carries an assertion phase");
1444        assert_eq!(assertion.status.as_deref(), Some("passed"));
1445        assert_eq!(
1446            events_of(passed, &assertion.id),
1447            BTreeSet::from([before.clone()]),
1448            "only what ran before the marker is the assertion's evidence"
1449        );
1450        let test_phase = passed
1451            .phases
1452            .iter()
1453            .find(|phase| phase.kind == "test")
1454            .unwrap();
1455        assert_eq!(
1456            events_of(passed, &test_phase.id),
1457            BTreeSet::from([before.clone(), after.clone()]),
1458            "the test phase keeps everything it ran"
1459        );
1460        assert_eq!(
1461            run.declaration.runners[0].attribution.assertion,
1462            AttributionPrecision::Exact
1463        );
1464
1465        let run = run_with("asserted-failed", "failed");
1466        let failed = &run.request.raw_results[0];
1467        let assertion = failed
1468            .phases
1469            .iter()
1470            .find(|phase| phase.kind == "assertion")
1471            .unwrap();
1472        assert_eq!(
1473            assertion.status.as_deref(),
1474            Some("failed"),
1475            "a failed test's assertion witnessed nothing"
1476        );
1477    }
1478
1479    #[test]
1480    fn joins_rspec_and_minitest_outcomes_into_exact_results() {
1481        let source = "def f(a, b)\n  if a && b\n    1\n  else\n    0\n  end\nend\n";
1482        let mut probe = 0;
1483        let obligations =
1484            build_ruby_obligations("lib/m.rb", source.as_bytes(), &mut probe).unwrap();
1485        let decision = &obligations.manifest.decisions[0];
1486        let statement = obligations
1487            .manifest
1488            .points
1489            .iter()
1490            .find(|point| point.kind == PointKind::Statement)
1491            .unwrap();
1492        let directory = temporary("join");
1493        let records = [
1494            serde_json::json!({"t":"process","v":1,"run":"run-1","pid":7,"worker":"main","ruby":"4.0.6","executable":"ruby","argv":["rspec"]}),
1495            serde_json::json!({"t":"hit","ctx":0,"id":statement.id}),
1496            serde_json::json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"spec/m_spec.rb[1:1]","retry":0,"phase":"call"}),
1497            serde_json::json!({"t":"dec","ctx":1,"id":decision.id,"v":"22","o":1}),
1498            serde_json::json!({"t":"outcome","worker":"main","test":"spec/m_spec.rb[1:1]","retry":0,"phase":"setup","outcome":"passed","xfail":false,"runner":"rspec"}),
1499            serde_json::json!({"t":"outcome","worker":"main","test":"spec/m_spec.rb[1:1]","retry":0,"phase":"call","outcome":"passed","xfail":false,"runner":"rspec"}),
1500            serde_json::json!({"t":"outcome","worker":"main","test":"spec/m_spec.rb[1:1]","retry":0,"phase":"teardown","outcome":"passed","xfail":false,"runner":"rspec"}),
1501            serde_json::json!({"t":"phase","ctx":2,"at":6,"worker":"main","test":"MTest#test_x","retry":0,"phase":"call"}),
1502            serde_json::json!({"t":"outcome","worker":"main","test":"MTest#test_x","retry":0,"phase":"call","outcome":"skipped","xfail":false,"runner":"minitest"}),
1503            serde_json::json!({"t":"exit","at":9}),
1504        ];
1505        fs::write(directory.join("main.7.a.mmap"), transport(&records)).unwrap();
1506        let run =
1507            build_ruby_frontend_run(&obligations.manifest, &directory, "run-1", "now", 0).unwrap();
1508        validate_frontend_report_request(&run.declaration, &run.request).unwrap();
1509        assert_eq!(run.tests, 2);
1510        let runners = run
1511            .declaration
1512            .runners
1513            .iter()
1514            .map(|runner| runner.runner.clone())
1515            .collect::<Vec<_>>();
1516        assert_eq!(runners, ["minitest", "rspec"]);
1517        assert_eq!(run.ruby_versions, ["4.0.6"]);
1518        fs::remove_dir_all(directory).unwrap();
1519    }
1520
1521    #[test]
1522    fn fails_closed_without_an_interpreter() {
1523        let mut probe = 0;
1524        let obligations = build_ruby_obligations("m.rb", b"x = 1\n", &mut probe).unwrap();
1525        let directory = temporary("empty");
1526        assert!(matches!(
1527            build_ruby_frontend_run(&obligations.manifest, &directory, "run-1", "now", 0),
1528            Err(RubyEvidenceError::NoInterpreter)
1529        ));
1530        fs::remove_dir_all(directory).unwrap();
1531    }
1532}