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 phase_id: None,
950 })
951}
952
953fn fold_outcome(
960 outcomes: &mut Vec<(String, String, bool)>,
961 phase: String,
962 outcome: String,
963 xfail: bool,
964) {
965 let Some(reported) = outcomes.iter_mut().find(|(name, _, _)| *name == phase) else {
966 outcomes.push((phase, outcome, xfail));
967 return;
968 };
969 if outcome_severity(&outcome) > outcome_severity(&reported.1) {
970 reported.1 = outcome;
971 }
972 reported.2 |= xfail;
973}
974
975fn outcome_severity(outcome: &str) -> u8 {
977 match outcome {
978 "failed" | "rerun" | "error" => 2,
979 "skipped" => 1,
980 _ => 0,
981 }
982}
983
984fn attempt_status(outcomes: &[(String, String, bool)]) -> String {
985 if outcomes
986 .iter()
987 .any(|(_, outcome, _)| matches!(outcome.as_str(), "failed" | "rerun" | "error"))
988 {
989 "failed"
990 } else if outcomes.iter().any(|(_, outcome, _)| outcome == "skipped") {
991 "skipped"
992 } else {
993 "passed"
994 }
995 .into()
996}
997
998#[derive(Debug, Clone, PartialEq)]
999pub struct RubyFrontendRun {
1000 pub declaration: FrontendRunDeclaration,
1001 pub request: CoverageReportRequest,
1002 pub tests: usize,
1003 pub interpreters: usize,
1004 pub ruby_versions: Vec<String>,
1005}
1006
1007impl RubyFrontendRun {
1008 pub fn archive_entries(&self) -> Result<Vec<EvidenceArchiveEntry>, serde_json::Error> {
1009 let model = PersistedCoverageModel::from_declaration(
1010 self.request
1011 .coverage_model
1012 .as_ref()
1013 .expect("Ruby frontend always declares a coverage model"),
1014 )
1015 .expect("Ruby coverage model is contract-valid");
1016 let mut entries = vec![
1017 EvidenceArchiveEntry {
1018 path: "coverage-model.json".into(),
1019 contents: serde_json::to_vec(&model)?,
1020 },
1021 EvidenceArchiveEntry {
1022 path: "frontend.json".into(),
1023 contents: serde_json::to_vec(&self.declaration)?,
1024 },
1025 EvidenceArchiveEntry {
1026 path: "manifest.json".into(),
1027 contents: serde_json::to_vec(&self.request.manifest)?,
1028 },
1029 ];
1030 for (index, result) in self.request.raw_results.iter().enumerate() {
1031 entries.push(EvidenceArchiveEntry {
1032 path: format!("results/{index:08}/mcdc.json"),
1033 contents: serde_json::to_vec(result)?,
1034 });
1035 }
1036 Ok(entries)
1037 }
1038}
1039
1040pub fn build_ruby_frontend_run(
1043 manifest: &CoverageManifest,
1044 evidence_directory: &Path,
1045 run_id: &str,
1046 generated_at: &str,
1047 test_exit_code: i32,
1048 assertions: &RubyAssertionInventory,
1049) -> Result<RubyFrontendRun, RubyEvidenceError> {
1050 let evidence = read_evidence_directory(evidence_directory, run_id)?;
1051 if evidence.interpreters == 0 {
1052 return Err(RubyEvidenceError::NoInterpreter);
1053 }
1054 if evidence.outcomes.is_empty() {
1055 return Err(RubyEvidenceError::NoTests);
1056 }
1057 let Evidence {
1058 interpreters,
1059 ruby_versions,
1060 per_identity,
1061 background,
1062 outcomes,
1063 runners,
1064 test_files,
1065 sites,
1066 limitations,
1067 } = evidence;
1068 let mut manifest = manifest.clone();
1069 let index = ManifestIndex::new(&manifest);
1070
1071 let mut raw_results = Vec::new();
1072 let mut observed_runners = BTreeSet::new();
1073 let mut identities_by_attempt =
1074 BTreeMap::<(String, String, usize), Vec<(&Identity, &Observations)>>::new();
1075 for (identity, observations) in &per_identity {
1076 identities_by_attempt
1077 .entry((
1078 identity.worker.clone(),
1079 identity.test.clone(),
1080 identity.retry,
1081 ))
1082 .or_default()
1083 .push((identity, observations));
1084 }
1085 for ((worker, test, retry), mut outcomes) in outcomes {
1086 let runner = runners
1087 .get(&(worker.clone(), test.clone(), retry))
1088 .cloned()
1089 .unwrap_or_else(default_runner);
1090 let attempt_identities = identities_by_attempt
1091 .remove(&(worker.clone(), test.clone(), retry))
1092 .unwrap_or_default();
1093 observed_runners.insert(runner.clone());
1094 outcomes.sort_by_key(|(phase, _, _)| match phase.as_str() {
1095 "setup" => 0,
1096 "call" => 1,
1097 _ => 2,
1098 });
1099 let mut phases = Vec::new();
1100 let mut runtime = Vec::new();
1101 let mut observed_phases = BTreeSet::new();
1102 for (position, (phase_name, outcome, xfail)) in outcomes.iter().enumerate() {
1103 observed_phases.insert(phase_name.clone());
1104 let identity = Identity {
1105 worker: worker.clone(),
1106 test: test.clone(),
1107 retry,
1108 phase: phase_name.clone(),
1109 };
1110 let id = phase_id(run_id, &identity);
1111 phases.push(CoveragePhase {
1112 id: id.clone(),
1113 kind: match phase_name.as_str() {
1114 "call" => "test",
1115 value => value,
1116 }
1117 .into(),
1118 operation: format!("{runner} {phase_name}"),
1119 source: Some(test.clone()),
1120 caused_by_phase_id: None,
1121 started_at_ms: position as i64 * 2 + 1,
1122 ended_at_ms: Some(position as i64 * 2 + 2),
1123 status: Some(match outcome.as_str() {
1124 "rerun" | "error" => "failed".into(),
1125 value => value.into(),
1126 }),
1127 error: None,
1128 });
1129 if let Some((_, observations)) = attempt_identities
1130 .iter()
1131 .find(|(candidate, _)| candidate.phase == phase_name.as_str())
1132 {
1133 runtime.push(snapshot(&index, observations, &id)?);
1134 }
1135 if phase_name != "call" {
1136 continue;
1137 }
1138 if let Some((identity, observations)) = attempt_identities
1142 .iter()
1143 .find(|(candidate, _)| candidate.phase == "assertion")
1144 {
1145 observed_phases.insert("assertion".to_owned());
1146 let id = phase_id(run_id, identity);
1147 phases.push(CoveragePhase {
1148 id: id.clone(),
1149 kind: "assertion".into(),
1150 operation: format!("{runner} assertion"),
1151 source: Some(test.clone()),
1152 caused_by_phase_id: None,
1153 started_at_ms: position as i64 * 2 + 1,
1154 ended_at_ms: Some(position as i64 * 2 + 2),
1155 status: Some(
1156 if outcome == "passed" && !*xfail {
1157 "passed"
1158 } else {
1159 "failed"
1160 }
1161 .into(),
1162 ),
1163 error: None,
1164 });
1165 runtime.push(snapshot(&index, observations, &id)?);
1166 let attempt = (worker.clone(), test.clone(), retry);
1172 for (path, line) in sites.get(&attempt).into_iter().flatten() {
1173 let Some(location) = assertions.locate(path, *line) else {
1174 continue;
1175 };
1176 phases.push(CoveragePhase {
1177 id: stable_id("ruby-assertion", &[run_id, &id, &location]),
1178 kind: "assertion".into(),
1179 operation: format!("{runner} assertion at {location}"),
1180 source: Some(location),
1181 caused_by_phase_id: Some(id.clone()),
1182 started_at_ms: position as i64 * 2 + 1,
1183 ended_at_ms: Some(position as i64 * 2 + 2),
1184 status: Some(
1185 if outcome == "passed" && !*xfail {
1186 "passed"
1187 } else {
1188 "failed"
1189 }
1190 .into(),
1191 ),
1192 error: None,
1193 });
1194 }
1195 }
1196 }
1197 for (identity, observations) in attempt_identities {
1200 if !observed_phases.contains(&identity.phase) {
1201 let id = phase_id(run_id, identity);
1202 phases.push(CoveragePhase {
1203 id: id.clone(),
1204 kind: match identity.phase.as_str() {
1205 "call" => "test",
1206 value => value,
1207 }
1208 .into(),
1209 operation: format!("{runner} {}", identity.phase),
1210 source: Some(test.clone()),
1211 caused_by_phase_id: None,
1212 started_at_ms: phases.len() as i64 * 2 + 1,
1213 ended_at_ms: None,
1214 status: Some("failed".into()),
1215 error: Some("the phase started but the runner reported no outcome".into()),
1216 });
1217 runtime.push(snapshot(&index, observations, &id)?);
1218 }
1219 }
1220 let status = if phases.iter().any(|phase| phase.error.is_some()) {
1221 "failed".into()
1222 } else {
1223 attempt_status(&outcomes)
1224 };
1225 raw_results.push(RawTestResult {
1226 test_id: Some(test.clone()),
1227 scope: Some(scope(run_id, &worker, &test, retry)),
1228 test: test.clone(),
1229 test_file: test_files
1233 .get(&(worker.clone(), test.clone(), retry))
1234 .and_then(|path| assertions.relative(path))
1235 .or_else(|| test.split("::").next().map(str::to_owned)),
1236 title: test.rsplit("::").next().map(str::to_owned),
1237 retry: Some(retry),
1238 status: Some(status),
1239 expected_status: Some(
1240 if outcomes.iter().any(|(_, _, xfail)| *xfail) {
1241 "failed"
1242 } else {
1243 "passed"
1244 }
1245 .into(),
1246 ),
1247 flaky: false,
1248 provenance: TestProvenance {
1249 runner: runner.clone(),
1250 kind: "unit".into(),
1251 project: None,
1252 source: RUBY_FRONTEND_VERSION.into(),
1253 },
1254 role: "test".into(),
1255 attribution: crate::coverage_report::ATTRIBUTION_PARTIAL.into(),
1263 phases,
1264 runtime,
1265 browser: Vec::new(),
1266 server: Vec::new(),
1267 });
1268 }
1269 let default_observed = observed_runners
1272 .iter()
1273 .next()
1274 .cloned()
1275 .unwrap_or_else(default_runner);
1276 for ((worker, test, retry), identities) in identities_by_attempt {
1277 let runner = default_observed.clone();
1278 let mut phases = Vec::new();
1279 let mut runtime = Vec::new();
1280 for (position, (identity, observations)) in identities.iter().enumerate() {
1281 let id = phase_id(run_id, identity);
1282 phases.push(CoveragePhase {
1283 id: id.clone(),
1284 kind: match identity.phase.as_str() {
1285 "call" => "test",
1286 value => value,
1287 }
1288 .into(),
1289 operation: format!("{runner} {}", identity.phase),
1290 source: Some(test.clone()),
1291 caused_by_phase_id: None,
1292 started_at_ms: position as i64 * 2 + 1,
1293 ended_at_ms: None,
1294 status: Some("failed".into()),
1295 error: Some("the phase started but the runner reported no outcome".into()),
1296 });
1297 runtime.push(snapshot(&index, observations, &id)?);
1298 }
1299 raw_results.push(RawTestResult {
1300 test_id: Some(test.clone()),
1301 scope: Some(scope(run_id, &worker, &test, retry)),
1302 test: test.clone(),
1303 test_file: test_files
1307 .get(&(worker.clone(), test.clone(), retry))
1308 .and_then(|path| assertions.relative(path))
1309 .or_else(|| test.split("::").next().map(str::to_owned)),
1310 title: test.rsplit("::").next().map(str::to_owned),
1311 retry: Some(retry),
1312 status: Some("failed".into()),
1313 expected_status: Some("passed".into()),
1314 flaky: false,
1315 provenance: TestProvenance {
1316 runner: runner.clone(),
1317 kind: "unit".into(),
1318 project: None,
1319 source: RUBY_FRONTEND_VERSION.into(),
1320 },
1321 role: "test".into(),
1322 attribution: crate::coverage_report::ATTRIBUTION_PARTIAL.into(),
1330 phases,
1331 runtime,
1332 browser: Vec::new(),
1333 server: Vec::new(),
1334 });
1335 }
1336 for (worker, observations) in &background {
1337 if observations.hits.is_empty() && observations.vectors.is_empty() {
1338 continue;
1339 }
1340 let test = format!("__supercov_background__:{worker}");
1341 let identity = Identity {
1342 worker: worker.clone(),
1343 test: test.clone(),
1344 retry: 0,
1345 phase: "background".into(),
1346 };
1347 let phase = phase_id(run_id, &identity);
1348 raw_results.push(RawTestResult {
1349 test_id: Some(test.clone()),
1350 scope: Some(scope(run_id, worker, &test, 0)),
1351 test: "Ruby load and background execution".into(),
1352 test_file: None,
1353 title: None,
1354 retry: Some(0),
1355 status: Some("unknown".into()),
1356 expected_status: None,
1357 flaky: false,
1358 provenance: TestProvenance {
1359 runner: default_observed.clone(),
1360 kind: "unit".into(),
1361 project: None,
1362 source: RUBY_FRONTEND_VERSION.into(),
1363 },
1364 role: "background".into(),
1365 attribution: crate::coverage_report::ATTRIBUTION_EXACT.into(),
1366 phases: vec![CoveragePhase {
1367 id: phase.clone(),
1368 kind: "background".into(),
1369 operation: "Ruby load background".into(),
1370 source: None,
1371 caused_by_phase_id: None,
1372 started_at_ms: 0,
1373 ended_at_ms: Some(0),
1374 status: Some("passed".into()),
1375 error: None,
1376 }],
1377 runtime: vec![snapshot(&index, observations, &phase)?],
1378 browser: Vec::new(),
1379 server: Vec::new(),
1380 });
1381 }
1382
1383 let mut limitation_ids = manifest
1387 .limitations
1388 .iter()
1389 .filter_map(|item| item.get("id").and_then(serde_json::Value::as_str))
1390 .map(str::to_owned)
1391 .collect::<BTreeSet<_>>();
1392 let mut unmeasured = manifest.unmeasured.iter().cloned().collect::<BTreeSet<_>>();
1393 let mut new_limitations = Vec::new();
1394 for limitation in &limitations {
1395 if let Some(obligation) = &limitation.obligation {
1396 if !index.lines.contains_key(obligation.as_str()) {
1397 return Err(RubyEvidenceError::UnknownObligation(obligation.clone()));
1398 }
1399 unmeasured.insert(obligation.clone());
1400 } else if let Some(file) = &limitation.file {
1401 unmeasured.extend(
1406 index
1407 .lines
1408 .iter()
1409 .filter(|(_, (obligation_file, _))| obligation_file == file)
1410 .map(|(id, _)| (*id).to_owned()),
1411 );
1412 }
1413 if limitation_ids.insert(limitation.id.clone()) {
1414 let (file, line) = limitation
1415 .obligation
1416 .as_deref()
1417 .and_then(|id| index.lines.get(id).cloned())
1418 .unwrap_or_else(|| {
1419 (
1420 limitation.file.clone().unwrap_or_else(|| {
1421 manifest
1422 .points
1423 .first()
1424 .map_or(".".into(), |point| point.file.clone())
1425 }),
1426 1,
1427 )
1428 });
1429 let source = limitation
1430 .obligation
1431 .as_deref()
1432 .and_then(|id| index.sources.get(id))
1433 .map(|source| source.lines().next().unwrap_or_default().to_owned())
1434 .unwrap_or_default();
1435 new_limitations.push(json!({
1436 "id": limitation.id,
1437 "kind": "semantic-safety",
1438 "file": file,
1439 "line": line,
1440 "column": 0,
1441 "source": source,
1442 "reason": limitation.reason
1443 }));
1444 }
1445 }
1446 manifest.limitations.extend(new_limitations);
1447 manifest.unmeasured = unmeasured.into_iter().collect();
1448 let structural_limitations = limitation_ids.into_iter().collect::<Vec<_>>();
1449
1450 let tests = raw_results
1454 .iter()
1455 .filter(|raw| raw.role == "test")
1456 .map(|raw| raw.test.as_str())
1457 .collect::<BTreeSet<_>>()
1458 .len();
1459 Ok(RubyFrontendRun {
1460 declaration: FrontendRunDeclaration {
1461 protocol_version: LANGUAGE_FRONTEND_PROTOCOL_VERSION,
1462 frontend_id: "ruby".into(),
1463 frontend_version: RUBY_FRONTEND_VERSION.into(),
1464 language: "ruby".into(),
1465 structural_source: StructuralSource::OwnedProbes,
1466 runners: observed_runners
1467 .iter()
1468 .map(|runner| FrontendRunnerDeclaration {
1469 runner: runner.clone(),
1470 execution_model: ExecutionModel::SerialInProcess,
1471 attribution: FrontendAttribution {
1472 run: AttributionPrecision::Exact,
1473 worker: AttributionPrecision::Exact,
1474 test: AttributionPrecision::Exact,
1475 retry: AttributionPrecision::Exact,
1476 phase: AttributionPrecision::Exact,
1477 action: AttributionPrecision::Unavailable,
1478 assertion: AttributionPrecision::Exact,
1479 },
1480 limitations: vec![
1481 FrontendLimitation {
1482 id: format!("ruby-{runner}-action-linkage"),
1483 scopes: vec![FrontendLimitationScope::Action],
1484 reason: format!("{runner} exposes no general action lifecycle"),
1485 },
1486 FrontendLimitation {
1496 id: format!("ruby-{runner}-first-sighting-lines"),
1497 scopes: vec![FrontendLimitationScope::Test],
1498 reason:
1499 "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"
1500 .into(),
1501 },
1502 ],
1503 })
1504 .collect(),
1505 structural_limitations,
1506 },
1507 request: CoverageReportRequest {
1508 run_id: run_id.into(),
1509 manifest,
1510 raw_results,
1511 generated_at: generated_at.into(),
1512 coverage_model: Some(ruby_coverage_model()),
1513 integrity: None,
1514 test_exit_code: ExitCodeInput::Present(Some(test_exit_code)),
1515 },
1516 tests,
1517 interpreters,
1518 ruby_versions: ruby_versions.into_iter().collect(),
1519 })
1520}
1521
1522#[cfg(test)]
1523mod tests {
1524 use super::*;
1525 use crate::{
1526 coverage_analysis::PointKind, frontend_protocol::validate_frontend_report_request,
1527 ruby_instrumenter::build_ruby_obligations,
1528 };
1529
1530 fn frame(payload: &[u8]) -> Vec<u8> {
1531 let mut bytes = vec![1u8, 0, 0, 0];
1532 bytes.extend_from_slice(&(payload.len() as u32).to_le_bytes());
1533 bytes.extend_from_slice(&transport_checksum(payload).to_le_bytes());
1534 bytes.extend_from_slice(&[0, 0, 0, 0]);
1535 bytes.extend_from_slice(payload);
1536 while bytes.len() % 8 != 0 {
1537 bytes.push(0);
1538 }
1539 bytes
1540 }
1541
1542 fn transport(records: &[serde_json::Value]) -> Vec<u8> {
1543 let mut body = Vec::new();
1544 for record in records {
1545 body.extend(frame(record.to_string().as_bytes()));
1546 }
1547 let capacity = TRANSPORT_HEADER_SIZE + body.len();
1548 let mut bytes = vec![0u8; TRANSPORT_HEADER_SIZE];
1549 bytes[..8].copy_from_slice(TRANSPORT_MAGIC);
1550 bytes[8..12].copy_from_slice(&TRANSPORT_VERSION.to_le_bytes());
1551 bytes[12..16].copy_from_slice(&(TRANSPORT_HEADER_SIZE as u32).to_le_bytes());
1552 bytes[16..24].copy_from_slice(&(capacity as u64).to_le_bytes());
1553 bytes[32..40].copy_from_slice(&7u64.to_le_bytes());
1554 bytes.extend(body);
1555 bytes
1556 }
1557
1558 fn temporary(name: &str) -> std::path::PathBuf {
1559 let nonce = std::time::SystemTime::now()
1560 .duration_since(std::time::UNIX_EPOCH)
1561 .unwrap()
1562 .as_nanos();
1563 let path = std::env::temp_dir().join(format!(
1564 "supercov-ruby-evidence-{}-{nonce}-{name}",
1565 std::process::id()
1566 ));
1567 fs::create_dir_all(&path).unwrap();
1568 path
1569 }
1570
1571 #[test]
1572 fn declares_the_gap_when_a_process_never_reported() {
1573 let source = "def f(a)\n a\nend\n";
1580 let mut probe = 0;
1581 let obligations =
1582 build_ruby_obligations("lib/m.rb", source.as_bytes(), &mut probe).unwrap();
1583 let statement = obligations
1584 .manifest
1585 .points
1586 .iter()
1587 .find(|point| point.kind == PointKind::Statement)
1588 .unwrap();
1589 let base = [
1590 serde_json::json!({"t":"process","v":1,"run":"run-1","pid":7,"worker":"main","ruby":"4.0.6","executable":"ruby","argv":["child.rb"]}),
1591 serde_json::json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"MTest#test_x","retry":0,"phase":"call"}),
1592 serde_json::json!({"t":"hit","ctx":1,"id":statement.id}),
1593 serde_json::json!({"t":"outcome","worker":"main","test":"MTest#test_x","retry":0,"phase":"call","outcome":"passed","xfail":false,"runner":"minitest"}),
1594 ];
1595
1596 let killed = temporary("killed");
1597 fs::write(killed.join("main.7.a.mmap"), transport(&base)).unwrap();
1598 let run = build_ruby_frontend_run(
1599 &obligations.manifest,
1600 &killed,
1601 "run-1",
1602 "now",
1603 0,
1604 &RubyAssertionInventory::empty(),
1605 )
1606 .unwrap();
1607 let declared = serde_json::to_string(&run.request.manifest.limitations).unwrap();
1608 assert!(
1609 declared.contains("ruby-process-did-not-report"),
1610 "a process that never reported must declare the gap: {declared}"
1611 );
1612 fs::remove_dir_all(&killed).unwrap();
1613
1614 let mut clean = base.to_vec();
1615 clean.push(serde_json::json!({"t":"exit","at":9}));
1616 let reported = temporary("reported");
1617 fs::write(reported.join("main.7.a.mmap"), transport(&clean)).unwrap();
1618 let run = build_ruby_frontend_run(
1619 &obligations.manifest,
1620 &reported,
1621 "run-1",
1622 "now",
1623 0,
1624 &RubyAssertionInventory::empty(),
1625 )
1626 .unwrap();
1627 let declared = serde_json::to_string(&run.request.manifest.limitations).unwrap();
1628 assert!(
1629 !declared.contains("ruby-process-did-not-report"),
1630 "a process that reported cleanly must declare nothing: {declared}"
1631 );
1632 fs::remove_dir_all(&reported).unwrap();
1633 }
1634
1635 fn under(first: &str, rest: &str) -> String {
1640 let mut path = PathBuf::from(if cfg!(windows) {
1641 format!("C:\\{first}")
1642 } else {
1643 format!("/{first}")
1644 });
1645 for part in rest.split('/').filter(|part| !part.is_empty()) {
1646 path.push(part);
1647 }
1648 path.to_string_lossy().into_owned()
1649 }
1650
1651 fn inventory_of(root: &str, sites: &[(&str, usize, usize)]) -> RubyAssertionInventory {
1652 use crate::assertion_map::{Anchor, Files, Inputs, InventorySite};
1653 RubyAssertionInventory::new(
1654 Path::new(root),
1655 &Inputs {
1656 schema_version: 1,
1657 language: "ruby".into(),
1658 context_digest: "context".into(),
1659 files: Files::new(),
1660 assertions: sites
1661 .iter()
1662 .map(|(file, line, column)| InventorySite {
1663 at: Anchor {
1664 file: (*file).into(),
1665 line: *line,
1666 column: *column,
1667 text: "assert_equal 1, f(1)".into(),
1668 },
1669 operation: "assert".into(),
1670 })
1671 .collect(),
1672 limitations: vec![],
1673 },
1674 )
1675 }
1676
1677 fn assertion_sources(run: &RubyFrontendRun) -> Vec<String> {
1678 run.request.raw_results[0]
1679 .phases
1680 .iter()
1681 .filter(|phase| phase.kind == "assertion")
1682 .filter_map(|phase| phase.source.clone())
1683 .collect()
1684 }
1685
1686 fn run_with_sites(
1687 name: &str,
1688 sites: &[serde_json::Value],
1689 outcome_file: Option<&str>,
1690 inventory: &RubyAssertionInventory,
1691 ) -> RubyFrontendRun {
1692 let source = "def f(a)\n a\nend\n";
1693 let mut probe = 0;
1694 let obligations =
1695 build_ruby_obligations("lib/m.rb", source.as_bytes(), &mut probe).unwrap();
1696 let mut outcome = serde_json::json!({"t":"outcome","worker":"main","test":"MTest#test_x","retry":0,"phase":"call","outcome":"passed","xfail":false,"runner":"minitest"});
1697 if let Some(file) = outcome_file {
1698 outcome["file"] = serde_json::json!(file);
1699 }
1700 let mut records = vec![
1701 serde_json::json!({"t":"process","v":1,"run":"run-1","pid":7,"worker":"main","ruby":"4.0.6","executable":"ruby","argv":["test.rb"]}),
1702 serde_json::json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"MTest#test_x","retry":0,"phase":"call"}),
1703 serde_json::json!({"t":"assert","ctx":1}),
1704 ];
1705 records.extend(sites.iter().cloned());
1706 records.push(outcome);
1707 records.push(serde_json::json!({"t":"exit","at":9}));
1708 let directory = temporary(name);
1709 fs::write(directory.join("main.7.a.mmap"), transport(&records)).unwrap();
1710 let run = build_ruby_frontend_run(
1711 &obligations.manifest,
1712 &directory,
1713 "run-1",
1714 "now",
1715 0,
1716 inventory,
1717 )
1718 .unwrap();
1719 validate_frontend_report_request(&run.declaration, &run.request).unwrap();
1720 fs::remove_dir_all(directory).unwrap();
1721 run
1722 }
1723
1724 #[test]
1725 fn an_assertion_site_becomes_a_located_phase_when_the_inventory_names_one() {
1726 let inventory = inventory_of(&under("project", ""), &[("test/m_test.rb", 6, 5)]);
1731 let run = run_with_sites(
1732 "asite-located",
1733 &[
1734 serde_json::json!({"t":"asite","ctx":1,"f":under("project", "test/m_test.rb"),"l":6}),
1735 ],
1736 None,
1737 &inventory,
1738 );
1739 assert!(
1740 assertion_sources(&run).contains(&"test/m_test.rb:6:4".to_string()),
1741 "expected a located assertion phase, got {:?}",
1742 assertion_sources(&run)
1743 );
1744 }
1745
1746 #[test]
1747 fn an_ambiguous_or_foreign_assertion_site_witnesses_nothing() {
1748 let ambiguous = inventory_of(
1752 &under("project", ""),
1753 &[("test/m_test.rb", 6, 5), ("test/m_test.rb", 6, 30)],
1754 );
1755 let run = run_with_sites(
1756 "asite-ambiguous",
1757 &[
1758 serde_json::json!({"t":"asite","ctx":1,"f":under("project", "test/m_test.rb"),"l":6}),
1759 ],
1760 None,
1761 &ambiguous,
1762 );
1763 assert_eq!(
1764 assertion_sources(&run),
1765 vec!["MTest#test_x".to_string()],
1766 "only the per-test assertion phase should remain"
1767 );
1768
1769 let known = inventory_of(&under("project", ""), &[("test/m_test.rb", 6, 5)]);
1770 let outside = run_with_sites(
1771 "asite-outside",
1772 &[
1773 serde_json::json!({"t":"asite","ctx":1,"f":under("elsewhere", "test/m_test.rb"),"l":6}),
1774 ],
1775 None,
1776 &known,
1777 );
1778 assert_eq!(
1779 assertion_sources(&outside),
1780 vec!["MTest#test_x".to_string()]
1781 );
1782 }
1783
1784 #[test]
1785 fn the_runner_names_the_test_file_in_either_path_form() {
1786 let inventory = inventory_of(&under("project", ""), &[("test/m_test.rb", 6, 5)]);
1790 for reported in [
1791 under("project", "test/m_test.rb"),
1792 "test/m_test.rb".to_owned(),
1793 "./test/m_test.rb".to_owned(),
1794 ] {
1795 let run = run_with_sites("asite-file", &[], Some(reported.as_str()), &inventory);
1796 assert_eq!(
1797 run.request.raw_results[0].test_file.as_deref(),
1798 Some("test/m_test.rb"),
1799 "{reported} should resolve to the project path"
1800 );
1801 }
1802 let without = run_with_sites("asite-nofile", &[], None, &inventory);
1803 assert_eq!(
1804 without.request.raw_results[0].test_file.as_deref(),
1805 Some("MTest#test_x"),
1806 "an adapter that cannot name a file keeps the identity fallback"
1807 );
1808 }
1809
1810 #[test]
1811 fn a_phase_a_runner_reported_more_than_once_is_one_phase() {
1812 let source = "def f(a)\n a\nend\n";
1816 let mut probe = 0;
1817 let obligations =
1818 build_ruby_obligations("lib/m.rb", source.as_bytes(), &mut probe).unwrap();
1819 let statement = obligations
1820 .manifest
1821 .points
1822 .iter()
1823 .find(|point| point.kind == PointKind::Statement)
1824 .unwrap();
1825 let again = |outcome: &str| {
1826 serde_json::json!({"t":"outcome","worker":"main","test":"MTest#test_x","retry":0,
1827 "phase":"call","outcome":outcome,"xfail":false,"runner":"minitest"})
1828 };
1829 let records = [
1830 serde_json::json!({"t":"process","v":1,"run":"run-1","pid":7,"worker":"main","ruby":"4.0.6","executable":"ruby","argv":["test.rb"]}),
1831 serde_json::json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"MTest#test_x","retry":0,"phase":"call"}),
1832 serde_json::json!({"t":"hit","ctx":1,"id":statement.id}),
1833 again("passed"),
1834 again("failed"),
1835 again("passed"),
1836 serde_json::json!({"t":"exit","at":9}),
1837 ];
1838 let directory = temporary("repeated-phase");
1839 fs::write(directory.join("main.7.a.mmap"), transport(&records)).unwrap();
1840 let run = build_ruby_frontend_run(
1841 &obligations.manifest,
1842 &directory,
1843 "run-1",
1844 "now",
1845 1,
1846 &RubyAssertionInventory::empty(),
1847 )
1848 .unwrap();
1849 validate_frontend_report_request(&run.declaration, &run.request).unwrap();
1850 let test = &run.request.raw_results[0];
1851 assert_eq!(test.phases.len(), 1, "{:?}", test.phases);
1852 assert_eq!(test.status.as_deref(), Some("failed"));
1853 fs::remove_dir_all(directory).unwrap();
1854 }
1855
1856 #[test]
1857 fn evidence_before_the_first_assertion_links_to_it_when_the_test_passes() {
1858 let source = "def f(a)\n a\nend\n\ndef g(b)\n b\nend\n";
1863 let mut probe = 0;
1864 let obligations =
1865 build_ruby_obligations("lib/m.rb", source.as_bytes(), &mut probe).unwrap();
1866 let statements: Vec<_> = obligations
1867 .manifest
1868 .points
1869 .iter()
1870 .filter(|point| point.kind == PointKind::Statement)
1871 .collect();
1872 let (before, after) = (&statements[0].id, &statements[1].id);
1873 let run_with = |name: &str, outcome: &str| {
1874 let records = [
1875 serde_json::json!({"t":"process","v":1,"run":"run-1","pid":7,"worker":"main","ruby":"4.0.6","executable":"ruby","argv":["test.rb"]}),
1876 serde_json::json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"MTest#test_x","retry":0,"phase":"call"}),
1877 serde_json::json!({"t":"hit","ctx":1,"id":before}),
1878 serde_json::json!({"t":"assert","ctx":1}),
1879 serde_json::json!({"t":"assert","ctx":1}),
1880 serde_json::json!({"t":"hit","ctx":1,"id":after}),
1881 serde_json::json!({"t":"outcome","worker":"main","test":"MTest#test_x","retry":0,"phase":"call","outcome":outcome,"xfail":false,"runner":"minitest"}),
1882 serde_json::json!({"t":"exit","at":9}),
1883 ];
1884 let directory = temporary(name);
1885 fs::write(directory.join("main.7.a.mmap"), transport(&records)).unwrap();
1886 let run = build_ruby_frontend_run(
1887 &obligations.manifest,
1888 &directory,
1889 "run-1",
1890 "now",
1891 0,
1892 &RubyAssertionInventory::empty(),
1893 )
1894 .unwrap();
1895 validate_frontend_report_request(&run.declaration, &run.request).unwrap();
1896 fs::remove_dir_all(directory).unwrap();
1897 run
1898 };
1899 let events_of = |result: &RawTestResult, phase: &str| -> BTreeSet<String> {
1900 result
1901 .runtime
1902 .iter()
1903 .flat_map(|snapshot| snapshot.events.iter())
1904 .filter(|event| event.phase_id.as_deref() == Some(phase))
1905 .map(|event| event.id.clone())
1906 .collect()
1907 };
1908
1909 let run = run_with("asserted-passed", "passed");
1910 let passed = &run.request.raw_results[0];
1911 assert_eq!(passed.test, "MTest#test_x");
1912 let assertion = passed
1913 .phases
1914 .iter()
1915 .find(|phase| phase.kind == "assertion")
1916 .expect("the asserting test carries an assertion phase");
1917 assert_eq!(assertion.status.as_deref(), Some("passed"));
1918 assert_eq!(
1919 events_of(passed, &assertion.id),
1920 BTreeSet::from([before.clone()]),
1921 "only what ran before the marker is the assertion's evidence"
1922 );
1923 let test_phase = passed
1924 .phases
1925 .iter()
1926 .find(|phase| phase.kind == "test")
1927 .unwrap();
1928 assert_eq!(
1929 events_of(passed, &test_phase.id),
1930 BTreeSet::from([before.clone(), after.clone()]),
1931 "the test phase keeps everything it ran"
1932 );
1933 assert_eq!(
1934 run.declaration.runners[0].attribution.assertion,
1935 AttributionPrecision::Exact
1936 );
1937
1938 let run = run_with("asserted-failed", "failed");
1939 let failed = &run.request.raw_results[0];
1940 let assertion = failed
1941 .phases
1942 .iter()
1943 .find(|phase| phase.kind == "assertion")
1944 .unwrap();
1945 assert_eq!(
1946 assertion.status.as_deref(),
1947 Some("failed"),
1948 "a failed test's assertion witnessed nothing"
1949 );
1950 }
1951
1952 #[test]
1953 fn joins_rspec_and_minitest_outcomes_into_exact_results() {
1954 let source = "def f(a, b)\n if a && b\n 1\n else\n 0\n end\nend\n";
1955 let mut probe = 0;
1956 let obligations =
1957 build_ruby_obligations("lib/m.rb", source.as_bytes(), &mut probe).unwrap();
1958 let decision = &obligations.manifest.decisions[0];
1959 let statement = obligations
1960 .manifest
1961 .points
1962 .iter()
1963 .find(|point| point.kind == PointKind::Statement)
1964 .unwrap();
1965 let directory = temporary("join");
1966 let records = [
1967 serde_json::json!({"t":"process","v":1,"run":"run-1","pid":7,"worker":"main","ruby":"4.0.6","executable":"ruby","argv":["rspec"]}),
1968 serde_json::json!({"t":"hit","ctx":0,"id":statement.id}),
1969 serde_json::json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"spec/m_spec.rb[1:1]","retry":0,"phase":"call"}),
1970 serde_json::json!({"t":"dec","ctx":1,"id":decision.id,"v":"22","o":1}),
1971 serde_json::json!({"t":"outcome","worker":"main","test":"spec/m_spec.rb[1:1]","retry":0,"phase":"setup","outcome":"passed","xfail":false,"runner":"rspec"}),
1972 serde_json::json!({"t":"outcome","worker":"main","test":"spec/m_spec.rb[1:1]","retry":0,"phase":"call","outcome":"passed","xfail":false,"runner":"rspec"}),
1973 serde_json::json!({"t":"outcome","worker":"main","test":"spec/m_spec.rb[1:1]","retry":0,"phase":"teardown","outcome":"passed","xfail":false,"runner":"rspec"}),
1974 serde_json::json!({"t":"phase","ctx":2,"at":6,"worker":"main","test":"MTest#test_x","retry":0,"phase":"call"}),
1975 serde_json::json!({"t":"outcome","worker":"main","test":"MTest#test_x","retry":0,"phase":"call","outcome":"skipped","xfail":false,"runner":"minitest"}),
1976 serde_json::json!({"t":"exit","at":9}),
1977 ];
1978 fs::write(directory.join("main.7.a.mmap"), transport(&records)).unwrap();
1979 let run = build_ruby_frontend_run(
1980 &obligations.manifest,
1981 &directory,
1982 "run-1",
1983 "now",
1984 0,
1985 &RubyAssertionInventory::empty(),
1986 )
1987 .unwrap();
1988 validate_frontend_report_request(&run.declaration, &run.request).unwrap();
1989 assert_eq!(run.tests, 2);
1990 let runners = run
1991 .declaration
1992 .runners
1993 .iter()
1994 .map(|runner| runner.runner.clone())
1995 .collect::<Vec<_>>();
1996 assert_eq!(runners, ["minitest", "rspec"]);
1997 assert_eq!(run.ruby_versions, ["4.0.6"]);
1998 fs::remove_dir_all(directory).unwrap();
1999 }
2000
2001 #[test]
2002 fn fails_closed_without_an_interpreter() {
2003 let mut probe = 0;
2004 let obligations = build_ruby_obligations("m.rb", b"x = 1\n", &mut probe).unwrap();
2005 let directory = temporary("empty");
2006 assert!(matches!(
2007 build_ruby_frontend_run(
2008 &obligations.manifest,
2009 &directory,
2010 "run-1",
2011 "now",
2012 0,
2013 &RubyAssertionInventory::empty()
2014 ),
2015 Err(RubyEvidenceError::NoInterpreter)
2016 ));
2017 fs::remove_dir_all(directory).unwrap();
2018 }
2019}