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 raw_results.push(RawTestResult {
1376 test_id: Some(test.clone()),
1377 scope: Some(scope(run_id, &worker, &test, retry)),
1378 test: test.clone(),
1379 test_file: test_files
1383 .get(&(worker.clone(), test.clone(), retry))
1384 .and_then(|path| relative(root, path))
1385 .or_else(|| test.split("::").next().map(str::to_owned)),
1386 title: test.rsplit("::").next().map(str::to_owned),
1387 retry: Some(retry),
1388 status: Some(status),
1389 expected_status: Some(
1390 if outcomes.iter().any(|(_, _, xfail)| *xfail) {
1391 "failed"
1392 } else {
1393 "passed"
1394 }
1395 .into(),
1396 ),
1397 flaky: false,
1398 provenance: TestProvenance {
1399 runner: runner.clone(),
1400 kind: "unit".into(),
1401 project: None,
1402 source: RUBY_FRONTEND_VERSION.into(),
1403 },
1404 role: "test".into(),
1405 attribution: attribution.into(),
1411 phases,
1412 runtime,
1413 browser: Vec::new(),
1414 server: Vec::new(),
1415 });
1416 }
1417 let default_observed = observed_runners
1420 .iter()
1421 .next()
1422 .cloned()
1423 .unwrap_or_else(default_runner);
1424 for ((worker, test, retry), identities) in identities_by_attempt {
1425 let runner = default_observed.clone();
1426 let mut phases = Vec::new();
1427 let mut runtime = Vec::new();
1428 for (position, (identity, observations)) in identities.iter().enumerate() {
1429 let id = phase_id(run_id, identity);
1430 phases.push(CoveragePhase {
1431 id: id.clone(),
1432 kind: match identity.phase.as_str() {
1433 "call" => "test",
1434 value => value,
1435 }
1436 .into(),
1437 operation: format!("{runner} {}", identity.phase),
1438 source: Some(test.clone()),
1439 caused_by_phase_id: None,
1440 started_at_ms: position as i64 * 2 + 1,
1441 ended_at_ms: None,
1442 status: Some("failed".into()),
1443 error: Some("the phase started but the runner reported no outcome".into()),
1444 });
1445 runtime.push(snapshot(&index, observations, &id)?);
1446 }
1447 raw_results.push(RawTestResult {
1448 test_id: Some(test.clone()),
1449 scope: Some(scope(run_id, &worker, &test, retry)),
1450 test: test.clone(),
1451 test_file: test_files
1455 .get(&(worker.clone(), test.clone(), retry))
1456 .and_then(|path| relative(root, path))
1457 .or_else(|| test.split("::").next().map(str::to_owned)),
1458 title: test.rsplit("::").next().map(str::to_owned),
1459 retry: Some(retry),
1460 status: Some("failed".into()),
1461 expected_status: Some("passed".into()),
1462 flaky: false,
1463 provenance: TestProvenance {
1464 runner: runner.clone(),
1465 kind: "unit".into(),
1466 project: None,
1467 source: RUBY_FRONTEND_VERSION.into(),
1468 },
1469 role: "test".into(),
1470 attribution: attribution.into(),
1476 phases,
1477 runtime,
1478 browser: Vec::new(),
1479 server: Vec::new(),
1480 });
1481 }
1482 for (worker, observations) in &background {
1483 if observations.hits.is_empty() && observations.vectors.is_empty() {
1484 continue;
1485 }
1486 let test = format!("__supercov_background__:{worker}");
1487 let identity = Identity {
1488 worker: worker.clone(),
1489 test: test.clone(),
1490 retry: 0,
1491 phase: "background".into(),
1492 };
1493 let phase = phase_id(run_id, &identity);
1494 raw_results.push(RawTestResult {
1495 test_id: Some(test.clone()),
1496 scope: Some(scope(run_id, worker, &test, 0)),
1497 test: "Ruby load and background execution".into(),
1498 test_file: None,
1499 title: None,
1500 retry: Some(0),
1501 status: Some("unknown".into()),
1502 expected_status: None,
1503 flaky: false,
1504 provenance: TestProvenance {
1505 runner: default_observed.clone(),
1506 kind: "unit".into(),
1507 project: None,
1508 source: RUBY_FRONTEND_VERSION.into(),
1509 },
1510 role: "background".into(),
1511 attribution: crate::coverage_report::ATTRIBUTION_EXACT.into(),
1512 phases: vec![CoveragePhase {
1513 id: phase.clone(),
1514 kind: "background".into(),
1515 operation: "Ruby load background".into(),
1516 source: None,
1517 caused_by_phase_id: None,
1518 started_at_ms: 0,
1519 ended_at_ms: Some(0),
1520 status: Some("passed".into()),
1521 error: None,
1522 }],
1523 runtime: vec![snapshot(&index, observations, &phase)?],
1524 browser: Vec::new(),
1525 server: Vec::new(),
1526 });
1527 }
1528
1529 let mut limitation_ids = manifest
1533 .limitations
1534 .iter()
1535 .filter_map(|item| item.get("id").and_then(serde_json::Value::as_str))
1536 .map(str::to_owned)
1537 .collect::<BTreeSet<_>>();
1538 let mut unmeasured = manifest.unmeasured.iter().cloned().collect::<BTreeSet<_>>();
1539 let mut new_limitations = Vec::new();
1540 for limitation in &limitations {
1541 if let Some(obligation) = &limitation.obligation {
1542 if !index.lines.contains_key(obligation.as_str()) {
1543 return Err(RubyEvidenceError::UnknownObligation(obligation.clone()));
1544 }
1545 unmeasured.insert(obligation.clone());
1546 } else if let Some(file) = &limitation.file {
1547 unmeasured.extend(
1552 index
1553 .lines
1554 .iter()
1555 .filter(|(_, (obligation_file, _))| obligation_file == file)
1556 .map(|(id, _)| (*id).to_owned()),
1557 );
1558 }
1559 if limitation_ids.insert(limitation.id.clone()) {
1560 let (file, line) = limitation
1561 .obligation
1562 .as_deref()
1563 .and_then(|id| index.lines.get(id).cloned())
1564 .unwrap_or_else(|| {
1565 (
1566 limitation.file.clone().unwrap_or_else(|| {
1567 manifest
1568 .points
1569 .first()
1570 .map_or(".".into(), |point| point.file.clone())
1571 }),
1572 1,
1573 )
1574 });
1575 let source = limitation
1576 .obligation
1577 .as_deref()
1578 .and_then(|id| index.sources.get(id))
1579 .map(|source| source.lines().next().unwrap_or_default().to_owned())
1580 .unwrap_or_default();
1581 new_limitations.push(json!({
1582 "id": limitation.id,
1583 "kind": "semantic-safety",
1584 "file": file,
1585 "line": line,
1586 "column": 0,
1587 "source": source,
1588 "reason": limitation.reason
1589 }));
1590 }
1591 }
1592 manifest.limitations.extend(new_limitations);
1593 manifest.unmeasured = unmeasured.into_iter().collect();
1594 let structural_limitations = limitation_ids.into_iter().collect::<Vec<_>>();
1595
1596 let tests = raw_results
1600 .iter()
1601 .filter(|raw| raw.role == "test")
1602 .map(|raw| raw.test.as_str())
1603 .collect::<BTreeSet<_>>()
1604 .len();
1605 Ok(RubyFrontendRun {
1606 declaration: FrontendRunDeclaration {
1607 protocol_version: LANGUAGE_FRONTEND_PROTOCOL_VERSION,
1608 frontend_id: "ruby".into(),
1609 frontend_version: RUBY_FRONTEND_VERSION.into(),
1610 language: "ruby".into(),
1611 structural_source: StructuralSource::OwnedProbes,
1612 runners: observed_runners
1613 .iter()
1614 .map(|runner| FrontendRunnerDeclaration {
1615 runner: runner.clone(),
1616 execution_model: ExecutionModel::SerialInProcess,
1617 attribution: FrontendAttribution {
1618 run: AttributionPrecision::Exact,
1619 worker: AttributionPrecision::Exact,
1620 test: AttributionPrecision::Exact,
1621 retry: AttributionPrecision::Exact,
1622 phase: AttributionPrecision::Exact,
1623 action: AttributionPrecision::Unavailable,
1624 assertion: if ruby_runner(runner) {
1627 AttributionPrecision::Exact
1628 } else {
1629 AttributionPrecision::Unavailable
1630 },
1631 },
1632 limitations: vec![if ruby_runner(runner) {
1633 FrontendLimitation {
1634 id: format!("ruby-{runner}-action-linkage"),
1635 scopes: vec![FrontendLimitationScope::Action],
1636 reason: format!("{runner} exposes no general action lifecycle"),
1637 }
1638 } else {
1639 FrontendLimitation {
1640 id: format!("ruby-server-{runner}-linkage"),
1641 scopes: vec![
1642 FrontendLimitationScope::Action,
1643 FrontendLimitationScope::Assertion,
1644 ],
1645 reason: format!(
1646 "the server sees each {runner} test's requests, not its actions or assertions"
1647 ),
1648 }
1649 }]
1650 .into_iter()
1651 .chain(first_sighting.then(|| FrontendLimitation {
1659 id: format!("ruby-{runner}-first-sighting-lines"),
1660 scopes: vec![FrontendLimitationScope::Test],
1661 reason:
1662 "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"
1663 .into(),
1664 }))
1665 .collect(),
1666 })
1667 .collect(),
1668 structural_limitations,
1669 },
1670 request: CoverageReportRequest {
1671 run_id: run_id.into(),
1672 manifest,
1673 raw_results,
1674 generated_at: generated_at.into(),
1675 coverage_model: Some(ruby_coverage_model()),
1676 integrity: None,
1677 test_exit_code: ExitCodeInput::Present(Some(test_exit_code)),
1678 },
1679 tests,
1680 interpreters,
1681 ruby_versions: ruby_versions.into_iter().collect(),
1682 })
1683}
1684
1685#[cfg(test)]
1686mod tests {
1687 use super::*;
1688 use crate::{
1689 coverage_analysis::PointKind, frontend_protocol::validate_frontend_report_request,
1690 ruby_instrumenter::build_ruby_obligations,
1691 };
1692
1693 fn frame(payload: &[u8]) -> Vec<u8> {
1694 let mut bytes = vec![1u8, 0, 0, 0];
1695 bytes.extend_from_slice(&(payload.len() as u32).to_le_bytes());
1696 bytes.extend_from_slice(&transport_checksum(payload).to_le_bytes());
1697 bytes.extend_from_slice(&[0, 0, 0, 0]);
1698 bytes.extend_from_slice(payload);
1699 while bytes.len() % 8 != 0 {
1700 bytes.push(0);
1701 }
1702 bytes
1703 }
1704
1705 fn transport(records: &[serde_json::Value]) -> Vec<u8> {
1706 let mut body = Vec::new();
1707 for record in records {
1708 body.extend(frame(record.to_string().as_bytes()));
1709 }
1710 let capacity = TRANSPORT_HEADER_SIZE + body.len();
1711 let mut bytes = vec![0u8; TRANSPORT_HEADER_SIZE];
1712 bytes[..8].copy_from_slice(TRANSPORT_MAGIC);
1713 bytes[8..12].copy_from_slice(&TRANSPORT_VERSION.to_le_bytes());
1714 bytes[12..16].copy_from_slice(&(TRANSPORT_HEADER_SIZE as u32).to_le_bytes());
1715 bytes[16..24].copy_from_slice(&(capacity as u64).to_le_bytes());
1716 bytes[32..40].copy_from_slice(&7u64.to_le_bytes());
1717 bytes.extend(body);
1718 bytes
1719 }
1720
1721 #[test]
1722 fn a_damaged_transport_is_refused_with_what_is_wrong() {
1723 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"]});
1726 let exit = serde_json::json!({"t":"exit","at":9});
1727 let valid = transport(&[process.clone(), exit.clone()]);
1728 let read = |bytes: &[u8]| match read_evidence_file(
1729 "main.7.a.mmap",
1730 bytes,
1731 "run-1",
1732 &mut Evidence::default(),
1733 ) {
1734 Ok(()) => "accepted".to_owned(),
1735 Err(error) => error.to_string(),
1736 };
1737 assert_eq!(read(&valid), "accepted");
1738 let payload_end = 80 + process.to_string().len();
1740 assert_ne!(payload_end % 8, 0, "the frame has padding to corrupt");
1741 let set = |at: usize, value: &[u8]| {
1742 let mut bytes = valid.clone();
1743 bytes[at..at + value.len()].copy_from_slice(value);
1744 bytes
1745 };
1746 let mut truncated = valid[..payload_end].to_vec();
1747 truncated[16..24].copy_from_slice(&(payload_end as u64).to_le_bytes());
1748 let other_owner = transport(&[
1749 serde_json::json!({"t":"process","v":1,"run":"run-1","pid":8,"worker":"main","ruby":"4.0.6","executable":"ruby","argv":["x.rb"]}),
1750 ]);
1751 let other_run = transport(&[
1752 serde_json::json!({"t":"process","v":1,"run":"run-2","pid":7,"worker":"main","ruby":"4.0.6","executable":"ruby","argv":["x.rb"]}),
1753 ]);
1754 for (bytes, reason) in [
1755 (set(0, b"X"), "header or version does not match"),
1756 (
1757 set(16, &8_u64.to_le_bytes()),
1758 "declared capacity is outside the mapped file",
1759 ),
1760 (set(24, &3_u64.to_le_bytes()), "3"),
1761 (set(32, &0_u64.to_le_bytes()), "process id is missing"),
1762 (set(64, &[2]), "commit marker or reserved bytes are invalid"),
1763 (
1764 set(68, &0_u32.to_le_bytes()),
1765 "payload length is outside the transport bound",
1766 ),
1767 (
1768 set(68, &4096_u32.to_le_bytes()),
1769 "payload extends past the mapped file",
1770 ),
1771 (truncated, "aligned frame extends past the mapped file"),
1772 (set(payload_end, &[1]), "frame padding is not zero"),
1773 (
1774 set(72, &0_u32.to_le_bytes()),
1775 "payload checksum does not match",
1776 ),
1777 (other_owner, "does not match the transport owner"),
1778 (other_run, "run-2"),
1779 ] {
1780 let said = read(&bytes);
1781 assert!(said.contains(reason), "{reason}: {said}");
1782 }
1783 }
1784
1785 fn temporary(name: &str) -> std::path::PathBuf {
1786 let nonce = std::time::SystemTime::now()
1787 .duration_since(std::time::UNIX_EPOCH)
1788 .unwrap()
1789 .as_nanos();
1790 let path = std::env::temp_dir().join(format!(
1791 "supercov-ruby-evidence-{}-{nonce}-{name}",
1792 std::process::id()
1793 ));
1794 fs::create_dir_all(&path).unwrap();
1795 path
1796 }
1797
1798 #[test]
1799 fn declares_the_gap_when_a_process_never_reported() {
1800 let source = "def f(a)\n a\nend\n";
1807 let mut probe = 0;
1808 let obligations =
1809 build_ruby_obligations("lib/m.rb", source.as_bytes(), &mut probe).unwrap();
1810 let statement = obligations
1811 .manifest
1812 .points
1813 .iter()
1814 .find(|point| point.kind == PointKind::Statement)
1815 .unwrap();
1816 let base = [
1817 serde_json::json!({"t":"process","v":1,"run":"run-1","pid":7,"worker":"main","ruby":"4.0.6","executable":"ruby","argv":["child.rb"]}),
1818 serde_json::json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"MTest#test_x","retry":0,"phase":"call"}),
1819 serde_json::json!({"t":"hit","ctx":1,"id":statement.id}),
1820 serde_json::json!({"t":"outcome","worker":"main","test":"MTest#test_x","retry":0,"phase":"call","outcome":"passed","xfail":false,"runner":"minitest"}),
1821 ];
1822
1823 let killed = temporary("killed");
1824 fs::write(killed.join("main.7.a.mmap"), transport(&base)).unwrap();
1825 let run = build_ruby_frontend_run(
1826 &obligations.manifest,
1827 &killed,
1828 "run-1",
1829 "now",
1830 0,
1831 Path::new(""),
1832 )
1833 .unwrap();
1834 let declared = serde_json::to_string(&run.request.manifest.limitations).unwrap();
1835 assert!(
1836 declared.contains("ruby-process-did-not-report"),
1837 "a process that never reported must declare the gap: {declared}"
1838 );
1839 fs::remove_dir_all(&killed).unwrap();
1840
1841 let mut clean = base.to_vec();
1842 clean.push(serde_json::json!({"t":"exit","at":9}));
1843 let reported = temporary("reported");
1844 fs::write(reported.join("main.7.a.mmap"), transport(&clean)).unwrap();
1845 let run = build_ruby_frontend_run(
1846 &obligations.manifest,
1847 &reported,
1848 "run-1",
1849 "now",
1850 0,
1851 Path::new(""),
1852 )
1853 .unwrap();
1854 let declared = serde_json::to_string(&run.request.manifest.limitations).unwrap();
1855 assert!(
1856 !declared.contains("ruby-process-did-not-report"),
1857 "a process that reported cleanly must declare nothing: {declared}"
1858 );
1859 fs::remove_dir_all(&reported).unwrap();
1860 }
1861
1862 fn under(first: &str, rest: &str) -> String {
1867 let mut path = std::path::PathBuf::from(if cfg!(windows) {
1868 format!("C:\\{first}")
1869 } else {
1870 format!("/{first}")
1871 });
1872 for part in rest.split('/').filter(|part| !part.is_empty()) {
1873 path.push(part);
1874 }
1875 path.to_string_lossy().into_owned()
1876 }
1877
1878 fn assertion_sources(run: &RubyFrontendRun) -> Vec<String> {
1879 run.request.raw_results[0]
1880 .phases
1881 .iter()
1882 .filter(|phase| phase.kind == "assertion")
1883 .filter_map(|phase| phase.source.clone())
1884 .collect()
1885 }
1886
1887 fn run_with_sites(
1888 name: &str,
1889 sites: &[serde_json::Value],
1890 outcome_file: Option<&str>,
1891 root: &str,
1892 ) -> RubyFrontendRun {
1893 let source = "def f(a)\n a\nend\n";
1894 let mut probe = 0;
1895 let obligations =
1896 build_ruby_obligations("lib/m.rb", source.as_bytes(), &mut probe).unwrap();
1897 let mut outcome = serde_json::json!({"t":"outcome","worker":"main","test":"MTest#test_x","retry":0,"phase":"call","outcome":"passed","xfail":false,"runner":"minitest"});
1898 if let Some(file) = outcome_file {
1899 outcome["file"] = serde_json::json!(file);
1900 }
1901 let mut records = vec![
1902 serde_json::json!({"t":"process","v":1,"run":"run-1","pid":7,"worker":"main","ruby":"4.0.6","executable":"ruby","argv":["test.rb"]}),
1903 serde_json::json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"MTest#test_x","retry":0,"phase":"call"}),
1904 serde_json::json!({"t":"assert","ctx":1}),
1905 ];
1906 records.extend(sites.iter().cloned());
1907 records.push(outcome);
1908 records.push(serde_json::json!({"t":"exit","at":9}));
1909 let directory = temporary(name);
1910 fs::write(directory.join("main.7.a.mmap"), transport(&records)).unwrap();
1911 let run = build_ruby_frontend_run(
1912 &obligations.manifest,
1913 &directory,
1914 "run-1",
1915 "now",
1916 0,
1917 Path::new(root),
1918 )
1919 .unwrap();
1920 validate_frontend_report_request(&run.declaration, &run.request).unwrap();
1921 fs::remove_dir_all(directory).unwrap();
1922 run
1923 }
1924
1925 #[test]
1926 fn a_test_s_assertions_are_its_one_assertion_phase() {
1927 let run = run_with_sites("ruby-assertion-phase", &[], None, &under("project", ""));
1930 assert_eq!(assertion_sources(&run), vec!["MTest#test_x".to_string()]);
1931 }
1932
1933 #[test]
1934 fn the_runner_names_the_test_file_in_either_path_form() {
1935 let root = under("project", "");
1939 for reported in [
1940 under("project", "test/m_test.rb"),
1941 "test/m_test.rb".to_owned(),
1942 "./test/m_test.rb".to_owned(),
1943 ] {
1944 let run = run_with_sites("asite-file", &[], Some(reported.as_str()), &root);
1945 assert_eq!(
1946 run.request.raw_results[0].test_file.as_deref(),
1947 Some("test/m_test.rb"),
1948 "{reported} should resolve to the project path"
1949 );
1950 }
1951 let without = run_with_sites("asite-nofile", &[], None, &root);
1952 assert_eq!(
1953 without.request.raw_results[0].test_file.as_deref(),
1954 Some("MTest#test_x"),
1955 "an adapter that cannot name a file keeps the identity fallback"
1956 );
1957 }
1958
1959 #[test]
1960 fn a_phase_a_runner_reported_more_than_once_is_one_phase() {
1961 let source = "def f(a)\n a\nend\n";
1965 let mut probe = 0;
1966 let obligations =
1967 build_ruby_obligations("lib/m.rb", source.as_bytes(), &mut probe).unwrap();
1968 let statement = obligations
1969 .manifest
1970 .points
1971 .iter()
1972 .find(|point| point.kind == PointKind::Statement)
1973 .unwrap();
1974 let again = |outcome: &str| {
1975 serde_json::json!({"t":"outcome","worker":"main","test":"MTest#test_x","retry":0,
1976 "phase":"call","outcome":outcome,"xfail":false,"runner":"minitest"})
1977 };
1978 let records = [
1979 serde_json::json!({"t":"process","v":1,"run":"run-1","pid":7,"worker":"main","ruby":"4.0.6","executable":"ruby","argv":["test.rb"]}),
1980 serde_json::json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"MTest#test_x","retry":0,"phase":"call"}),
1981 serde_json::json!({"t":"hit","ctx":1,"id":statement.id}),
1982 again("passed"),
1983 again("failed"),
1984 again("passed"),
1985 serde_json::json!({"t":"exit","at":9}),
1986 ];
1987 let directory = temporary("repeated-phase");
1988 fs::write(directory.join("main.7.a.mmap"), transport(&records)).unwrap();
1989 let run = build_ruby_frontend_run(
1990 &obligations.manifest,
1991 &directory,
1992 "run-1",
1993 "now",
1994 1,
1995 Path::new(""),
1996 )
1997 .unwrap();
1998 validate_frontend_report_request(&run.declaration, &run.request).unwrap();
1999 let test = &run.request.raw_results[0];
2000 assert_eq!(test.phases.len(), 1, "{:?}", test.phases);
2001 assert_eq!(test.status.as_deref(), Some("failed"));
2002 fs::remove_dir_all(directory).unwrap();
2003 }
2004
2005 #[test]
2006 fn evidence_before_the_first_assertion_links_to_it_when_the_test_passes() {
2007 let source = "def f(a)\n a\nend\n\ndef g(b)\n b\nend\n";
2012 let mut probe = 0;
2013 let obligations =
2014 build_ruby_obligations("lib/m.rb", source.as_bytes(), &mut probe).unwrap();
2015 let statements: Vec<_> = obligations
2016 .manifest
2017 .points
2018 .iter()
2019 .filter(|point| point.kind == PointKind::Statement)
2020 .collect();
2021 let (before, after) = (&statements[0].id, &statements[1].id);
2022 let run_with = |name: &str, outcome: &str| {
2023 let records = [
2024 serde_json::json!({"t":"process","v":1,"run":"run-1","pid":7,"worker":"main","ruby":"4.0.6","executable":"ruby","argv":["test.rb"]}),
2025 serde_json::json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"MTest#test_x","retry":0,"phase":"call"}),
2026 serde_json::json!({"t":"hit","ctx":1,"id":before}),
2027 serde_json::json!({"t":"assert","ctx":1}),
2028 serde_json::json!({"t":"assert","ctx":1}),
2029 serde_json::json!({"t":"hit","ctx":1,"id":after}),
2030 serde_json::json!({"t":"outcome","worker":"main","test":"MTest#test_x","retry":0,"phase":"call","outcome":outcome,"xfail":false,"runner":"minitest"}),
2031 serde_json::json!({"t":"exit","at":9}),
2032 ];
2033 let directory = temporary(name);
2034 fs::write(directory.join("main.7.a.mmap"), transport(&records)).unwrap();
2035 let run = build_ruby_frontend_run(
2036 &obligations.manifest,
2037 &directory,
2038 "run-1",
2039 "now",
2040 0,
2041 Path::new(""),
2042 )
2043 .unwrap();
2044 validate_frontend_report_request(&run.declaration, &run.request).unwrap();
2045 fs::remove_dir_all(directory).unwrap();
2046 run
2047 };
2048 let events_of = |result: &RawTestResult, phase: &str| -> BTreeSet<String> {
2049 result
2050 .runtime
2051 .iter()
2052 .flat_map(|snapshot| snapshot.events.iter())
2053 .filter(|event| event.phase_id.as_deref() == Some(phase))
2054 .map(|event| event.id.clone())
2055 .collect()
2056 };
2057
2058 let run = run_with("asserted-passed", "passed");
2059 let passed = &run.request.raw_results[0];
2060 assert_eq!(passed.test, "MTest#test_x");
2061 let assertion = passed
2062 .phases
2063 .iter()
2064 .find(|phase| phase.kind == "assertion")
2065 .expect("the asserting test carries an assertion phase");
2066 assert_eq!(assertion.status.as_deref(), Some("passed"));
2067 assert_eq!(
2068 events_of(passed, &assertion.id),
2069 BTreeSet::from([before.clone()]),
2070 "only what ran before the marker is the assertion's evidence"
2071 );
2072 let test_phase = passed
2073 .phases
2074 .iter()
2075 .find(|phase| phase.kind == "test")
2076 .unwrap();
2077 assert_eq!(
2078 events_of(passed, &test_phase.id),
2079 BTreeSet::from([before.clone(), after.clone()]),
2080 "the test phase keeps everything it ran"
2081 );
2082 assert_eq!(
2083 run.declaration.runners[0].attribution.assertion,
2084 AttributionPrecision::Exact
2085 );
2086
2087 let run = run_with("asserted-failed", "failed");
2088 let failed = &run.request.raw_results[0];
2089 let assertion = failed
2090 .phases
2091 .iter()
2092 .find(|phase| phase.kind == "assertion")
2093 .unwrap();
2094 assert_eq!(
2095 assertion.status.as_deref(),
2096 Some("failed"),
2097 "a failed test's assertion witnessed nothing"
2098 );
2099 }
2100
2101 #[test]
2102 fn joins_rspec_and_minitest_outcomes_into_exact_results() {
2103 let source = "def f(a, b)\n if a && b\n 1\n else\n 0\n end\nend\n";
2104 let mut probe = 0;
2105 let obligations =
2106 build_ruby_obligations("lib/m.rb", source.as_bytes(), &mut probe).unwrap();
2107 let decision = &obligations.manifest.decisions[0];
2108 let statement = obligations
2109 .manifest
2110 .points
2111 .iter()
2112 .find(|point| point.kind == PointKind::Statement)
2113 .unwrap();
2114 let directory = temporary("join");
2115 let records = [
2116 serde_json::json!({"t":"process","v":1,"run":"run-1","pid":7,"worker":"main","ruby":"4.0.6","executable":"ruby","argv":["rspec"]}),
2117 serde_json::json!({"t":"hit","ctx":0,"id":statement.id}),
2118 serde_json::json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"spec/m_spec.rb[1:1]","retry":0,"phase":"call"}),
2119 serde_json::json!({"t":"dec","ctx":1,"id":decision.id,"v":"22","o":1}),
2120 serde_json::json!({"t":"outcome","worker":"main","test":"spec/m_spec.rb[1:1]","retry":0,"phase":"setup","outcome":"passed","xfail":false,"runner":"rspec"}),
2121 serde_json::json!({"t":"outcome","worker":"main","test":"spec/m_spec.rb[1:1]","retry":0,"phase":"call","outcome":"passed","xfail":false,"runner":"rspec"}),
2122 serde_json::json!({"t":"outcome","worker":"main","test":"spec/m_spec.rb[1:1]","retry":0,"phase":"teardown","outcome":"passed","xfail":false,"runner":"rspec"}),
2123 serde_json::json!({"t":"phase","ctx":2,"at":6,"worker":"main","test":"MTest#test_x","retry":0,"phase":"call"}),
2124 serde_json::json!({"t":"outcome","worker":"main","test":"MTest#test_x","retry":0,"phase":"call","outcome":"skipped","xfail":false,"runner":"minitest"}),
2125 serde_json::json!({"t":"exit","at":9}),
2126 ];
2127 fs::write(directory.join("main.7.a.mmap"), transport(&records)).unwrap();
2128 let run = build_ruby_frontend_run(
2129 &obligations.manifest,
2130 &directory,
2131 "run-1",
2132 "now",
2133 0,
2134 Path::new(""),
2135 )
2136 .unwrap();
2137 validate_frontend_report_request(&run.declaration, &run.request).unwrap();
2138 assert_eq!(run.tests, 2);
2139 let runners = run
2140 .declaration
2141 .runners
2142 .iter()
2143 .map(|runner| runner.runner.clone())
2144 .collect::<Vec<_>>();
2145 assert_eq!(runners, ["minitest", "rspec"]);
2146 assert_eq!(run.ruby_versions, ["4.0.6"]);
2147 fs::remove_dir_all(directory).unwrap();
2148 }
2149
2150 #[test]
2151 fn fails_closed_without_an_interpreter() {
2152 let mut probe = 0;
2153 let obligations = build_ruby_obligations("m.rb", b"x = 1\n", &mut probe).unwrap();
2154 let directory = temporary("empty");
2155 assert!(matches!(
2156 build_ruby_frontend_run(
2157 &obligations.manifest,
2158 &directory,
2159 "run-1",
2160 "now",
2161 0,
2162 Path::new("")
2163 ),
2164 Err(RubyEvidenceError::NoInterpreter)
2165 ));
2166 fs::remove_dir_all(directory).unwrap();
2167 }
2168}