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