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