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 evidence
616 .outcomes
617 .entry(key)
618 .or_default()
619 .push((phase, outcome, xfail));
620 }
621 Record::Hit { ctx, id } => {
622 if let Some(before) = before_assertion.get_mut(&ctx) {
623 before.hits.insert(id.clone());
624 }
625 observations(
626 evidence,
627 &contexts,
628 process_worker.as_deref(),
629 ctx,
630 name,
631 line_number,
632 )?
633 .hits
634 .insert(id);
635 }
636 Record::Dec { ctx, id, v, o } => {
637 if v.is_empty() || !v.bytes().all(|digit| matches!(digit, b'0' | b'1' | b'2')) {
638 return Err(invalid("decision vector digits must be 0, 1 or 2"));
639 }
640 if o > 1 {
641 return Err(invalid("decision outcome must be 0 or 1"));
642 }
643 let values = v
644 .bytes()
645 .map(|digit| match digit {
646 b'0' => None,
647 b'1' => Some(false),
648 _ => Some(true),
649 })
650 .collect::<Vec<_>>();
651 if let Some(before) = before_assertion.get_mut(&ctx) {
652 before
653 .vectors
654 .entry(id.clone())
655 .or_default()
656 .insert((values.clone(), o == 1));
657 }
658 observations(
659 evidence,
660 &contexts,
661 process_worker.as_deref(),
662 ctx,
663 name,
664 line_number,
665 )?
666 .vectors
667 .entry(id)
668 .or_default()
669 .insert((values, o == 1));
670 }
671 Record::Assert { ctx } => {
672 if let Some(before) = before_assertion.remove(&ctx) {
675 let identity = contexts
676 .get(&ctx)
677 .ok_or(RubyEvidenceError::UnknownContext {
678 file: name.into(),
679 line: line_number,
680 context: ctx,
681 })?;
682 let asserted = evidence
683 .per_identity
684 .entry(Identity {
685 phase: "assertion".into(),
686 ..identity.clone()
687 })
688 .or_default();
689 asserted.hits.extend(before.hits);
690 for (id, vectors) in before.vectors {
691 asserted.vectors.entry(id).or_default().extend(vectors);
692 }
693 }
694 }
695 Record::Asite { ctx, f, l } => {
696 if f.is_empty() || l == 0 {
697 return Err(invalid("assertion site needs a file and a line"));
698 }
699 let identity = contexts
700 .get(&ctx)
701 .ok_or(RubyEvidenceError::UnknownContext {
702 file: name.into(),
703 line: line_number,
704 context: ctx,
705 })?;
706 if identity.phase == "call" {
709 let key = (
710 identity.worker.clone(),
711 identity.test.clone(),
712 identity.retry,
713 );
714 let sites = evidence.sites.entry(key).or_default();
715 let site = (f, l);
716 if !sites.contains(&site) {
717 sites.push(site);
718 }
719 }
720 }
721 Record::Limitation {
722 id,
723 reason,
724 file,
725 obligation,
726 } => evidence.limitations.push(RuntimeLimitation {
727 id,
728 reason,
729 file,
730 obligation,
731 }),
732 Record::Exit { .. } => process_reported = true,
733 }
734 cursor = next_cursor;
735 }
736 if process_started && !process_reported {
745 evidence.limitations.push(RuntimeLimitation {
746 id: "ruby-process-did-not-report".into(),
747 reason: format!(
748 "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"
749 ),
750 file: None,
751 obligation: None,
752 });
753 }
754 Ok(())
755}
756
757fn observations<'a>(
758 evidence: &'a mut Evidence,
759 contexts: &BTreeMap<u64, Identity>,
760 process_worker: Option<&str>,
761 context: u64,
762 file: &str,
763 line: usize,
764) -> Result<&'a mut Observations, RubyEvidenceError> {
765 if context == 0 {
766 return Ok(evidence
767 .background
768 .entry(process_worker.unwrap_or("main").to_owned())
769 .or_default());
770 }
771 let identity = contexts
772 .get(&context)
773 .ok_or(RubyEvidenceError::UnknownContext {
774 file: file.into(),
775 line,
776 context,
777 })?;
778 Ok(evidence.per_identity.entry(identity.clone()).or_default())
779}
780
781pub fn ruby_coverage_model() -> CoverageModelDeclaration {
782 CoverageModelDeclaration {
783 language: "ruby".into(),
784 variant: "ruby-owned-coverage".into(),
785 name: "ruby-coverage-probes-v1".into(),
786 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(),
787 measured: vec![
788 "executable statements proven by Ruby's line coverage, or a probe when a line holds several".into(),
789 "method definitions entered (Ruby's method coverage)".into(),
790 "if/unless/elsif/ternary/while/until decisions with masking MC/DC vectors from operand probes".into(),
791 "while, until, for and iterator-block (each, map, times, ...) zero-versus-entered iteration".into(),
792 "&&, ||, ||= and &&= short-circuit alternatives".into(),
793 "case/when and case/in clause selection, safe navigation".into(),
794 "begin/rescue completion, handler selection and exception propagation".into(),
795 "RSpec, Minitest, test-unit and Cucumber worker, test and setup/call/teardown phase identity".into(),
796 "evidence a test recorded before its first assertion, linked to that assertion when the test passes".into(),
797 ],
798 not_measured: vec![
799 "blocks and lambdas as function entry points (they are statements inside their methods)".into(),
800 "blocks passed by reference (map(&:to_s)) as loops: they have no block body to observe".into(),
801 "line, branch and method observations made while test phases overlapped in threads (attributed to the run; probe observations stay per test)".into(),
802 "causal linkage to individual actions, or to any assertion after a test's first".into(),
803 "code compiled from strings at runtime (eval, instance_eval with strings)".into(),
804 "child coverage outside Process.spawn, Kernel#spawn, Kernel#system and fork".into(),
805 "all input values, semantic partitions, paths, or concurrency interleavings".into(),
806 "mutation score or assertion fault-detection strength".into(),
807 ],
808 }
809}
810
811fn phase_id(run: &str, identity: &Identity) -> String {
812 stable_id(
813 "ruby-phase",
814 &[
815 run,
816 &identity.worker,
817 &identity.test,
818 &identity.retry.to_string(),
819 &identity.phase,
820 ],
821 )
822}
823
824fn scope(run: &str, worker: &str, test: &str, retry: usize) -> ExecutionScope {
825 ExecutionScope {
826 version: 1,
827 run_id: run.into(),
828 worker_id: worker.into(),
829 test_id: test.into(),
830 test_key: stable_id("ruby-test", &[worker, test]),
831 retry,
832 attempt_id: stable_id("ruby-attempt", &[run, worker, test, &retry.to_string()]),
833 }
834}
835
836struct ManifestIndex<'a> {
837 points: BTreeSet<&'a str>,
838 alternatives: BTreeSet<&'a str>,
839 decisions: BTreeMap<&'a str, &'a DecisionMeta>,
840 lines: BTreeMap<&'a str, (String, usize)>,
841 sources: BTreeMap<&'a str, &'a str>,
842}
843
844impl<'a> ManifestIndex<'a> {
845 fn new(manifest: &'a CoverageManifest) -> Self {
846 let mut lines = BTreeMap::new();
847 let mut sources = BTreeMap::new();
848 for point in &manifest.points {
849 lines.insert(point.id.as_str(), (point.file.clone(), point.line));
850 sources.insert(point.id.as_str(), point.source.as_str());
851 }
852 for decision in &manifest.decisions {
853 lines.insert(decision.id.as_str(), (decision.file.clone(), decision.line));
854 sources.insert(decision.id.as_str(), decision.source.as_str());
855 }
856 for branch in &manifest.branches {
857 lines.insert(branch.id.as_str(), (branch.file.clone(), branch.line));
858 sources.insert(branch.id.as_str(), branch.source.as_str());
859 }
860 Self {
861 points: manifest
862 .points
863 .iter()
864 .map(|point| point.id.as_str())
865 .collect(),
866 alternatives: manifest
867 .branches
868 .iter()
869 .flat_map(|branch| branch.alternatives.iter().map(|alt| alt.id.as_str()))
870 .collect(),
871 decisions: manifest
872 .decisions
873 .iter()
874 .map(|decision| (decision.id.as_str(), decision))
875 .collect(),
876 lines,
877 sources,
878 }
879 }
880}
881
882fn snapshot(
883 index: &ManifestIndex<'_>,
884 observations: &Observations,
885 phase: &str,
886) -> Result<RuntimeSnapshot, RubyEvidenceError> {
887 let mut hits = BTreeSet::new();
888 for id in &observations.hits {
889 if !index.points.contains(id.as_str()) && !index.alternatives.contains(id.as_str()) {
890 return Err(RubyEvidenceError::UnknownObligation(id.clone()));
891 }
892 hits.insert(id.clone());
893 }
894 let mut decisions = Vec::new();
895 let mut events = Vec::new();
896 let mut clock = 1;
897 for id in &hits {
898 events.push(RuntimeEvent {
899 event_type: "hit".into(),
900 id: id.clone(),
901 vector: None,
902 timestamp_ms: clock,
903 phase_id: Some(phase.into()),
904 statement_id: None,
905 environment: "ruby".into(),
906 });
907 clock += 1;
908 }
909 for (id, vectors) in &observations.vectors {
910 let Some(meta) = index.decisions.get(id.as_str()) else {
911 return Err(RubyEvidenceError::UnknownObligation(id.clone()));
912 };
913 let mut observed = Vec::new();
914 for (values, outcome) in vectors {
915 if values.len() != meta.conditions.len() {
916 return Err(RubyEvidenceError::InvalidVector {
917 id: id.clone(),
918 expected: meta.conditions.len(),
919 actual: values.len(),
920 });
921 }
922 let vector = McdcVector {
923 values: values.clone(),
924 outcome: *outcome,
925 };
926 events.push(RuntimeEvent {
927 event_type: "decision".into(),
928 id: id.clone(),
929 vector: Some(vector.clone()),
930 timestamp_ms: clock,
931 phase_id: Some(phase.into()),
932 statement_id: None,
933 environment: "ruby".into(),
934 });
935 clock += 1;
936 observed.push(vector);
937 }
938 decisions.push(DecisionSnapshot {
939 meta: (*meta).clone(),
940 vectors: observed,
941 });
942 }
943 Ok(RuntimeSnapshot {
944 decisions,
945 hits: hits.into_iter().collect(),
946 events,
947 logicals: Vec::new(),
948 })
949}
950
951fn attempt_status(outcomes: &[(String, String, bool)]) -> String {
952 if outcomes
953 .iter()
954 .any(|(_, outcome, _)| matches!(outcome.as_str(), "failed" | "rerun" | "error"))
955 {
956 "failed"
957 } else if outcomes.iter().any(|(_, outcome, _)| outcome == "skipped") {
958 "skipped"
959 } else {
960 "passed"
961 }
962 .into()
963}
964
965#[derive(Debug, Clone, PartialEq)]
966pub struct RubyFrontendRun {
967 pub declaration: FrontendRunDeclaration,
968 pub request: CoverageReportRequest,
969 pub tests: usize,
970 pub interpreters: usize,
971 pub ruby_versions: Vec<String>,
972}
973
974impl RubyFrontendRun {
975 pub fn archive_entries(&self) -> Result<Vec<EvidenceArchiveEntry>, serde_json::Error> {
976 let model = PersistedCoverageModel::from_declaration(
977 self.request
978 .coverage_model
979 .as_ref()
980 .expect("Ruby frontend always declares a coverage model"),
981 )
982 .expect("Ruby coverage model is contract-valid");
983 let mut entries = vec![
984 EvidenceArchiveEntry {
985 path: "coverage-model.json".into(),
986 contents: serde_json::to_vec(&model)?,
987 },
988 EvidenceArchiveEntry {
989 path: "frontend.json".into(),
990 contents: serde_json::to_vec(&self.declaration)?,
991 },
992 EvidenceArchiveEntry {
993 path: "manifest.json".into(),
994 contents: serde_json::to_vec(&self.request.manifest)?,
995 },
996 ];
997 for (index, result) in self.request.raw_results.iter().enumerate() {
998 entries.push(EvidenceArchiveEntry {
999 path: format!("results/{index:08}/mcdc.json"),
1000 contents: serde_json::to_vec(result)?,
1001 });
1002 }
1003 Ok(entries)
1004 }
1005}
1006
1007pub fn build_ruby_frontend_run(
1010 manifest: &CoverageManifest,
1011 evidence_directory: &Path,
1012 run_id: &str,
1013 generated_at: &str,
1014 test_exit_code: i32,
1015 assertions: &RubyAssertionInventory,
1016) -> Result<RubyFrontendRun, RubyEvidenceError> {
1017 let evidence = read_evidence_directory(evidence_directory, run_id)?;
1018 if evidence.interpreters == 0 {
1019 return Err(RubyEvidenceError::NoInterpreter);
1020 }
1021 if evidence.outcomes.is_empty() {
1022 return Err(RubyEvidenceError::NoTests);
1023 }
1024 let Evidence {
1025 interpreters,
1026 ruby_versions,
1027 per_identity,
1028 background,
1029 outcomes,
1030 runners,
1031 test_files,
1032 sites,
1033 limitations,
1034 } = evidence;
1035 let mut manifest = manifest.clone();
1036 let index = ManifestIndex::new(&manifest);
1037
1038 let mut raw_results = Vec::new();
1039 let mut observed_runners = BTreeSet::new();
1040 let mut identities_by_attempt =
1041 BTreeMap::<(String, String, usize), Vec<(&Identity, &Observations)>>::new();
1042 for (identity, observations) in &per_identity {
1043 identities_by_attempt
1044 .entry((
1045 identity.worker.clone(),
1046 identity.test.clone(),
1047 identity.retry,
1048 ))
1049 .or_default()
1050 .push((identity, observations));
1051 }
1052 for ((worker, test, retry), mut outcomes) in outcomes {
1053 let runner = runners
1054 .get(&(worker.clone(), test.clone(), retry))
1055 .cloned()
1056 .unwrap_or_else(default_runner);
1057 let attempt_identities = identities_by_attempt
1058 .remove(&(worker.clone(), test.clone(), retry))
1059 .unwrap_or_default();
1060 observed_runners.insert(runner.clone());
1061 outcomes.sort_by_key(|(phase, _, _)| match phase.as_str() {
1062 "setup" => 0,
1063 "call" => 1,
1064 _ => 2,
1065 });
1066 let mut phases = Vec::new();
1067 let mut runtime = Vec::new();
1068 let mut observed_phases = BTreeSet::new();
1069 for (position, (phase_name, outcome, xfail)) in outcomes.iter().enumerate() {
1070 observed_phases.insert(phase_name.clone());
1071 let identity = Identity {
1072 worker: worker.clone(),
1073 test: test.clone(),
1074 retry,
1075 phase: phase_name.clone(),
1076 };
1077 let id = phase_id(run_id, &identity);
1078 phases.push(CoveragePhase {
1079 id: id.clone(),
1080 kind: match phase_name.as_str() {
1081 "call" => "test",
1082 value => value,
1083 }
1084 .into(),
1085 operation: format!("{runner} {phase_name}"),
1086 source: Some(test.clone()),
1087 caused_by_phase_id: None,
1088 started_at_ms: position as i64 * 2 + 1,
1089 ended_at_ms: Some(position as i64 * 2 + 2),
1090 status: Some(match outcome.as_str() {
1091 "rerun" | "error" => "failed".into(),
1092 value => value.into(),
1093 }),
1094 error: None,
1095 });
1096 if let Some((_, observations)) = attempt_identities
1097 .iter()
1098 .find(|(candidate, _)| candidate.phase == phase_name.as_str())
1099 {
1100 runtime.push(snapshot(&index, observations, &id)?);
1101 }
1102 if phase_name != "call" {
1103 continue;
1104 }
1105 if let Some((identity, observations)) = attempt_identities
1109 .iter()
1110 .find(|(candidate, _)| candidate.phase == "assertion")
1111 {
1112 observed_phases.insert("assertion".to_owned());
1113 let id = phase_id(run_id, identity);
1114 phases.push(CoveragePhase {
1115 id: id.clone(),
1116 kind: "assertion".into(),
1117 operation: format!("{runner} assertion"),
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(
1123 if outcome == "passed" && !*xfail {
1124 "passed"
1125 } else {
1126 "failed"
1127 }
1128 .into(),
1129 ),
1130 error: None,
1131 });
1132 runtime.push(snapshot(&index, observations, &id)?);
1133 let attempt = (worker.clone(), test.clone(), retry);
1139 for (path, line) in sites.get(&attempt).into_iter().flatten() {
1140 let Some(location) = assertions.locate(path, *line) else {
1141 continue;
1142 };
1143 phases.push(CoveragePhase {
1144 id: stable_id("ruby-assertion", &[run_id, &id, &location]),
1145 kind: "assertion".into(),
1146 operation: format!("{runner} assertion at {location}"),
1147 source: Some(location),
1148 caused_by_phase_id: Some(id.clone()),
1149 started_at_ms: position as i64 * 2 + 1,
1150 ended_at_ms: Some(position as i64 * 2 + 2),
1151 status: Some(
1152 if outcome == "passed" && !*xfail {
1153 "passed"
1154 } else {
1155 "failed"
1156 }
1157 .into(),
1158 ),
1159 error: None,
1160 });
1161 }
1162 }
1163 }
1164 for (identity, observations) in attempt_identities {
1167 if !observed_phases.contains(&identity.phase) {
1168 let id = phase_id(run_id, identity);
1169 phases.push(CoveragePhase {
1170 id: id.clone(),
1171 kind: match identity.phase.as_str() {
1172 "call" => "test",
1173 value => value,
1174 }
1175 .into(),
1176 operation: format!("{runner} {}", identity.phase),
1177 source: Some(test.clone()),
1178 caused_by_phase_id: None,
1179 started_at_ms: phases.len() as i64 * 2 + 1,
1180 ended_at_ms: None,
1181 status: Some("failed".into()),
1182 error: Some("the phase started but the runner reported no outcome".into()),
1183 });
1184 runtime.push(snapshot(&index, observations, &id)?);
1185 }
1186 }
1187 let status = if phases.iter().any(|phase| phase.error.is_some()) {
1188 "failed".into()
1189 } else {
1190 attempt_status(&outcomes)
1191 };
1192 raw_results.push(RawTestResult {
1193 test_id: Some(test.clone()),
1194 scope: Some(scope(run_id, &worker, &test, retry)),
1195 test: test.clone(),
1196 test_file: test_files
1200 .get(&(worker.clone(), test.clone(), retry))
1201 .and_then(|path| assertions.relative(path))
1202 .or_else(|| test.split("::").next().map(str::to_owned)),
1203 title: test.rsplit("::").next().map(str::to_owned),
1204 retry: Some(retry),
1205 status: Some(status),
1206 expected_status: Some(
1207 if outcomes.iter().any(|(_, _, xfail)| *xfail) {
1208 "failed"
1209 } else {
1210 "passed"
1211 }
1212 .into(),
1213 ),
1214 flaky: false,
1215 provenance: TestProvenance {
1216 runner: runner.clone(),
1217 kind: "unit".into(),
1218 project: None,
1219 source: RUBY_FRONTEND_VERSION.into(),
1220 },
1221 role: "test".into(),
1222 phases,
1223 runtime,
1224 browser: Vec::new(),
1225 server: Vec::new(),
1226 });
1227 }
1228 let default_observed = observed_runners
1231 .iter()
1232 .next()
1233 .cloned()
1234 .unwrap_or_else(default_runner);
1235 for ((worker, test, retry), identities) in identities_by_attempt {
1236 let runner = default_observed.clone();
1237 let mut phases = Vec::new();
1238 let mut runtime = Vec::new();
1239 for (position, (identity, observations)) in identities.iter().enumerate() {
1240 let id = phase_id(run_id, identity);
1241 phases.push(CoveragePhase {
1242 id: id.clone(),
1243 kind: match identity.phase.as_str() {
1244 "call" => "test",
1245 value => value,
1246 }
1247 .into(),
1248 operation: format!("{runner} {}", identity.phase),
1249 source: Some(test.clone()),
1250 caused_by_phase_id: None,
1251 started_at_ms: position as i64 * 2 + 1,
1252 ended_at_ms: None,
1253 status: Some("failed".into()),
1254 error: Some("the phase started but the runner reported no outcome".into()),
1255 });
1256 runtime.push(snapshot(&index, observations, &id)?);
1257 }
1258 raw_results.push(RawTestResult {
1259 test_id: Some(test.clone()),
1260 scope: Some(scope(run_id, &worker, &test, retry)),
1261 test: test.clone(),
1262 test_file: test_files
1266 .get(&(worker.clone(), test.clone(), retry))
1267 .and_then(|path| assertions.relative(path))
1268 .or_else(|| test.split("::").next().map(str::to_owned)),
1269 title: test.rsplit("::").next().map(str::to_owned),
1270 retry: Some(retry),
1271 status: Some("failed".into()),
1272 expected_status: Some("passed".into()),
1273 flaky: false,
1274 provenance: TestProvenance {
1275 runner: runner.clone(),
1276 kind: "unit".into(),
1277 project: None,
1278 source: RUBY_FRONTEND_VERSION.into(),
1279 },
1280 role: "test".into(),
1281 phases,
1282 runtime,
1283 browser: Vec::new(),
1284 server: Vec::new(),
1285 });
1286 }
1287 for (worker, observations) in &background {
1288 if observations.hits.is_empty() && observations.vectors.is_empty() {
1289 continue;
1290 }
1291 let test = format!("__supercov_background__:{worker}");
1292 let identity = Identity {
1293 worker: worker.clone(),
1294 test: test.clone(),
1295 retry: 0,
1296 phase: "background".into(),
1297 };
1298 let phase = phase_id(run_id, &identity);
1299 raw_results.push(RawTestResult {
1300 test_id: Some(test.clone()),
1301 scope: Some(scope(run_id, worker, &test, 0)),
1302 test: "Ruby load and background execution".into(),
1303 test_file: None,
1304 title: None,
1305 retry: Some(0),
1306 status: Some("unknown".into()),
1307 expected_status: None,
1308 flaky: false,
1309 provenance: TestProvenance {
1310 runner: default_observed.clone(),
1311 kind: "unit".into(),
1312 project: None,
1313 source: RUBY_FRONTEND_VERSION.into(),
1314 },
1315 role: "background".into(),
1316 phases: vec![CoveragePhase {
1317 id: phase.clone(),
1318 kind: "background".into(),
1319 operation: "Ruby load background".into(),
1320 source: None,
1321 caused_by_phase_id: None,
1322 started_at_ms: 0,
1323 ended_at_ms: Some(0),
1324 status: Some("passed".into()),
1325 error: None,
1326 }],
1327 runtime: vec![snapshot(&index, observations, &phase)?],
1328 browser: Vec::new(),
1329 server: Vec::new(),
1330 });
1331 }
1332
1333 let mut limitation_ids = manifest
1337 .limitations
1338 .iter()
1339 .filter_map(|item| item.get("id").and_then(serde_json::Value::as_str))
1340 .map(str::to_owned)
1341 .collect::<BTreeSet<_>>();
1342 let mut unmeasured = manifest.unmeasured.iter().cloned().collect::<BTreeSet<_>>();
1343 let mut new_limitations = Vec::new();
1344 for limitation in &limitations {
1345 if let Some(obligation) = &limitation.obligation {
1346 if !index.lines.contains_key(obligation.as_str()) {
1347 return Err(RubyEvidenceError::UnknownObligation(obligation.clone()));
1348 }
1349 unmeasured.insert(obligation.clone());
1350 } else if let Some(file) = &limitation.file {
1351 unmeasured.extend(
1356 index
1357 .lines
1358 .iter()
1359 .filter(|(_, (obligation_file, _))| obligation_file == file)
1360 .map(|(id, _)| (*id).to_owned()),
1361 );
1362 }
1363 if limitation_ids.insert(limitation.id.clone()) {
1364 let (file, line) = limitation
1365 .obligation
1366 .as_deref()
1367 .and_then(|id| index.lines.get(id).cloned())
1368 .unwrap_or_else(|| {
1369 (
1370 limitation.file.clone().unwrap_or_else(|| {
1371 manifest
1372 .points
1373 .first()
1374 .map_or(".".into(), |point| point.file.clone())
1375 }),
1376 1,
1377 )
1378 });
1379 let source = limitation
1380 .obligation
1381 .as_deref()
1382 .and_then(|id| index.sources.get(id))
1383 .map(|source| source.lines().next().unwrap_or_default().to_owned())
1384 .unwrap_or_default();
1385 new_limitations.push(json!({
1386 "id": limitation.id,
1387 "kind": "semantic-safety",
1388 "file": file,
1389 "line": line,
1390 "column": 0,
1391 "source": source,
1392 "reason": limitation.reason
1393 }));
1394 }
1395 }
1396 manifest.limitations.extend(new_limitations);
1397 manifest.unmeasured = unmeasured.into_iter().collect();
1398 let structural_limitations = limitation_ids.into_iter().collect::<Vec<_>>();
1399
1400 let tests = raw_results
1404 .iter()
1405 .filter(|raw| raw.role == "test")
1406 .map(|raw| raw.test.as_str())
1407 .collect::<BTreeSet<_>>()
1408 .len();
1409 Ok(RubyFrontendRun {
1410 declaration: FrontendRunDeclaration {
1411 protocol_version: LANGUAGE_FRONTEND_PROTOCOL_VERSION,
1412 frontend_id: "ruby".into(),
1413 frontend_version: RUBY_FRONTEND_VERSION.into(),
1414 language: "ruby".into(),
1415 structural_source: StructuralSource::OwnedProbes,
1416 runners: observed_runners
1417 .iter()
1418 .map(|runner| FrontendRunnerDeclaration {
1419 runner: runner.clone(),
1420 execution_model: ExecutionModel::SerialInProcess,
1421 attribution: FrontendAttribution {
1422 run: AttributionPrecision::Exact,
1423 worker: AttributionPrecision::Exact,
1424 test: AttributionPrecision::Exact,
1425 retry: AttributionPrecision::Exact,
1426 phase: AttributionPrecision::Exact,
1427 action: AttributionPrecision::Unavailable,
1428 assertion: AttributionPrecision::Exact,
1429 },
1430 limitations: vec![FrontendLimitation {
1431 id: format!("ruby-{runner}-action-linkage"),
1432 scopes: vec![FrontendLimitationScope::Action],
1433 reason: format!("{runner} exposes no general action lifecycle"),
1434 }],
1435 })
1436 .collect(),
1437 structural_limitations,
1438 },
1439 request: CoverageReportRequest {
1440 run_id: run_id.into(),
1441 manifest,
1442 raw_results,
1443 generated_at: generated_at.into(),
1444 coverage_model: Some(ruby_coverage_model()),
1445 integrity: None,
1446 test_exit_code: ExitCodeInput::Present(Some(test_exit_code)),
1447 },
1448 tests,
1449 interpreters,
1450 ruby_versions: ruby_versions.into_iter().collect(),
1451 })
1452}
1453
1454#[cfg(test)]
1455mod tests {
1456 use super::*;
1457 use crate::{
1458 coverage_analysis::PointKind, frontend_protocol::validate_frontend_report_request,
1459 ruby_instrumenter::build_ruby_obligations,
1460 };
1461
1462 fn frame(payload: &[u8]) -> Vec<u8> {
1463 let mut bytes = vec![1u8, 0, 0, 0];
1464 bytes.extend_from_slice(&(payload.len() as u32).to_le_bytes());
1465 bytes.extend_from_slice(&transport_checksum(payload).to_le_bytes());
1466 bytes.extend_from_slice(&[0, 0, 0, 0]);
1467 bytes.extend_from_slice(payload);
1468 while bytes.len() % 8 != 0 {
1469 bytes.push(0);
1470 }
1471 bytes
1472 }
1473
1474 fn transport(records: &[serde_json::Value]) -> Vec<u8> {
1475 let mut body = Vec::new();
1476 for record in records {
1477 body.extend(frame(record.to_string().as_bytes()));
1478 }
1479 let capacity = TRANSPORT_HEADER_SIZE + body.len();
1480 let mut bytes = vec![0u8; TRANSPORT_HEADER_SIZE];
1481 bytes[..8].copy_from_slice(TRANSPORT_MAGIC);
1482 bytes[8..12].copy_from_slice(&TRANSPORT_VERSION.to_le_bytes());
1483 bytes[12..16].copy_from_slice(&(TRANSPORT_HEADER_SIZE as u32).to_le_bytes());
1484 bytes[16..24].copy_from_slice(&(capacity as u64).to_le_bytes());
1485 bytes[32..40].copy_from_slice(&7u64.to_le_bytes());
1486 bytes.extend(body);
1487 bytes
1488 }
1489
1490 fn temporary(name: &str) -> std::path::PathBuf {
1491 let nonce = std::time::SystemTime::now()
1492 .duration_since(std::time::UNIX_EPOCH)
1493 .unwrap()
1494 .as_nanos();
1495 let path = std::env::temp_dir().join(format!(
1496 "supercov-ruby-evidence-{}-{nonce}-{name}",
1497 std::process::id()
1498 ));
1499 fs::create_dir_all(&path).unwrap();
1500 path
1501 }
1502
1503 #[test]
1504 fn declares_the_gap_when_a_process_never_reported() {
1505 let source = "def f(a)\n a\nend\n";
1512 let mut probe = 0;
1513 let obligations =
1514 build_ruby_obligations("lib/m.rb", source.as_bytes(), &mut probe).unwrap();
1515 let statement = obligations
1516 .manifest
1517 .points
1518 .iter()
1519 .find(|point| point.kind == PointKind::Statement)
1520 .unwrap();
1521 let base = [
1522 serde_json::json!({"t":"process","v":1,"run":"run-1","pid":7,"worker":"main","ruby":"4.0.6","executable":"ruby","argv":["child.rb"]}),
1523 serde_json::json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"MTest#test_x","retry":0,"phase":"call"}),
1524 serde_json::json!({"t":"hit","ctx":1,"id":statement.id}),
1525 serde_json::json!({"t":"outcome","worker":"main","test":"MTest#test_x","retry":0,"phase":"call","outcome":"passed","xfail":false,"runner":"minitest"}),
1526 ];
1527
1528 let killed = temporary("killed");
1529 fs::write(killed.join("main.7.a.mmap"), transport(&base)).unwrap();
1530 let run = build_ruby_frontend_run(
1531 &obligations.manifest,
1532 &killed,
1533 "run-1",
1534 "now",
1535 0,
1536 &RubyAssertionInventory::empty(),
1537 )
1538 .unwrap();
1539 let declared = serde_json::to_string(&run.request.manifest.limitations).unwrap();
1540 assert!(
1541 declared.contains("ruby-process-did-not-report"),
1542 "a process that never reported must declare the gap: {declared}"
1543 );
1544 fs::remove_dir_all(&killed).unwrap();
1545
1546 let mut clean = base.to_vec();
1547 clean.push(serde_json::json!({"t":"exit","at":9}));
1548 let reported = temporary("reported");
1549 fs::write(reported.join("main.7.a.mmap"), transport(&clean)).unwrap();
1550 let run = build_ruby_frontend_run(
1551 &obligations.manifest,
1552 &reported,
1553 "run-1",
1554 "now",
1555 0,
1556 &RubyAssertionInventory::empty(),
1557 )
1558 .unwrap();
1559 let declared = serde_json::to_string(&run.request.manifest.limitations).unwrap();
1560 assert!(
1561 !declared.contains("ruby-process-did-not-report"),
1562 "a process that reported cleanly must declare nothing: {declared}"
1563 );
1564 fs::remove_dir_all(&reported).unwrap();
1565 }
1566
1567 fn under(first: &str, rest: &str) -> String {
1572 let mut path = PathBuf::from(if cfg!(windows) {
1573 format!("C:\\{first}")
1574 } else {
1575 format!("/{first}")
1576 });
1577 for part in rest.split('/').filter(|part| !part.is_empty()) {
1578 path.push(part);
1579 }
1580 path.to_string_lossy().into_owned()
1581 }
1582
1583 fn inventory_of(root: &str, sites: &[(&str, usize, usize)]) -> RubyAssertionInventory {
1584 use crate::assertion_map::{Anchor, Files, Inputs, InventorySite};
1585 RubyAssertionInventory::new(
1586 Path::new(root),
1587 &Inputs {
1588 schema_version: 1,
1589 language: "ruby".into(),
1590 context_digest: "context".into(),
1591 files: Files::new(),
1592 assertions: sites
1593 .iter()
1594 .map(|(file, line, column)| InventorySite {
1595 at: Anchor {
1596 file: (*file).into(),
1597 line: *line,
1598 column: *column,
1599 text: "assert_equal 1, f(1)".into(),
1600 },
1601 operation: "assert".into(),
1602 })
1603 .collect(),
1604 limitations: vec![],
1605 },
1606 )
1607 }
1608
1609 fn assertion_sources(run: &RubyFrontendRun) -> Vec<String> {
1610 run.request.raw_results[0]
1611 .phases
1612 .iter()
1613 .filter(|phase| phase.kind == "assertion")
1614 .filter_map(|phase| phase.source.clone())
1615 .collect()
1616 }
1617
1618 fn run_with_sites(
1619 name: &str,
1620 sites: &[serde_json::Value],
1621 outcome_file: Option<&str>,
1622 inventory: &RubyAssertionInventory,
1623 ) -> RubyFrontendRun {
1624 let source = "def f(a)\n a\nend\n";
1625 let mut probe = 0;
1626 let obligations =
1627 build_ruby_obligations("lib/m.rb", source.as_bytes(), &mut probe).unwrap();
1628 let mut outcome = serde_json::json!({"t":"outcome","worker":"main","test":"MTest#test_x","retry":0,"phase":"call","outcome":"passed","xfail":false,"runner":"minitest"});
1629 if let Some(file) = outcome_file {
1630 outcome["file"] = serde_json::json!(file);
1631 }
1632 let mut records = vec![
1633 serde_json::json!({"t":"process","v":1,"run":"run-1","pid":7,"worker":"main","ruby":"4.0.6","executable":"ruby","argv":["test.rb"]}),
1634 serde_json::json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"MTest#test_x","retry":0,"phase":"call"}),
1635 serde_json::json!({"t":"assert","ctx":1}),
1636 ];
1637 records.extend(sites.iter().cloned());
1638 records.push(outcome);
1639 records.push(serde_json::json!({"t":"exit","at":9}));
1640 let directory = temporary(name);
1641 fs::write(directory.join("main.7.a.mmap"), transport(&records)).unwrap();
1642 let run = build_ruby_frontend_run(
1643 &obligations.manifest,
1644 &directory,
1645 "run-1",
1646 "now",
1647 0,
1648 inventory,
1649 )
1650 .unwrap();
1651 validate_frontend_report_request(&run.declaration, &run.request).unwrap();
1652 fs::remove_dir_all(directory).unwrap();
1653 run
1654 }
1655
1656 #[test]
1657 fn an_assertion_site_becomes_a_located_phase_when_the_inventory_names_one() {
1658 let inventory = inventory_of(&under("project", ""), &[("test/m_test.rb", 6, 5)]);
1663 let run = run_with_sites(
1664 "asite-located",
1665 &[
1666 serde_json::json!({"t":"asite","ctx":1,"f":under("project", "test/m_test.rb"),"l":6}),
1667 ],
1668 None,
1669 &inventory,
1670 );
1671 assert!(
1672 assertion_sources(&run).contains(&"test/m_test.rb:6:4".to_string()),
1673 "expected a located assertion phase, got {:?}",
1674 assertion_sources(&run)
1675 );
1676 }
1677
1678 #[test]
1679 fn an_ambiguous_or_foreign_assertion_site_witnesses_nothing() {
1680 let ambiguous = inventory_of(
1684 &under("project", ""),
1685 &[("test/m_test.rb", 6, 5), ("test/m_test.rb", 6, 30)],
1686 );
1687 let run = run_with_sites(
1688 "asite-ambiguous",
1689 &[
1690 serde_json::json!({"t":"asite","ctx":1,"f":under("project", "test/m_test.rb"),"l":6}),
1691 ],
1692 None,
1693 &ambiguous,
1694 );
1695 assert_eq!(
1696 assertion_sources(&run),
1697 vec!["MTest#test_x".to_string()],
1698 "only the per-test assertion phase should remain"
1699 );
1700
1701 let known = inventory_of(&under("project", ""), &[("test/m_test.rb", 6, 5)]);
1702 let outside = run_with_sites(
1703 "asite-outside",
1704 &[
1705 serde_json::json!({"t":"asite","ctx":1,"f":under("elsewhere", "test/m_test.rb"),"l":6}),
1706 ],
1707 None,
1708 &known,
1709 );
1710 assert_eq!(
1711 assertion_sources(&outside),
1712 vec!["MTest#test_x".to_string()]
1713 );
1714 }
1715
1716 #[test]
1717 fn the_runner_names_the_test_file_in_either_path_form() {
1718 let inventory = inventory_of(&under("project", ""), &[("test/m_test.rb", 6, 5)]);
1722 for reported in [
1723 under("project", "test/m_test.rb"),
1724 "test/m_test.rb".to_owned(),
1725 "./test/m_test.rb".to_owned(),
1726 ] {
1727 let run = run_with_sites("asite-file", &[], Some(reported.as_str()), &inventory);
1728 assert_eq!(
1729 run.request.raw_results[0].test_file.as_deref(),
1730 Some("test/m_test.rb"),
1731 "{reported} should resolve to the project path"
1732 );
1733 }
1734 let without = run_with_sites("asite-nofile", &[], None, &inventory);
1735 assert_eq!(
1736 without.request.raw_results[0].test_file.as_deref(),
1737 Some("MTest#test_x"),
1738 "an adapter that cannot name a file keeps the identity fallback"
1739 );
1740 }
1741
1742 #[test]
1743 fn evidence_before_the_first_assertion_links_to_it_when_the_test_passes() {
1744 let source = "def f(a)\n a\nend\n\ndef g(b)\n b\nend\n";
1749 let mut probe = 0;
1750 let obligations =
1751 build_ruby_obligations("lib/m.rb", source.as_bytes(), &mut probe).unwrap();
1752 let statements: Vec<_> = obligations
1753 .manifest
1754 .points
1755 .iter()
1756 .filter(|point| point.kind == PointKind::Statement)
1757 .collect();
1758 let (before, after) = (&statements[0].id, &statements[1].id);
1759 let run_with = |name: &str, outcome: &str| {
1760 let records = [
1761 serde_json::json!({"t":"process","v":1,"run":"run-1","pid":7,"worker":"main","ruby":"4.0.6","executable":"ruby","argv":["test.rb"]}),
1762 serde_json::json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"MTest#test_x","retry":0,"phase":"call"}),
1763 serde_json::json!({"t":"hit","ctx":1,"id":before}),
1764 serde_json::json!({"t":"assert","ctx":1}),
1765 serde_json::json!({"t":"assert","ctx":1}),
1766 serde_json::json!({"t":"hit","ctx":1,"id":after}),
1767 serde_json::json!({"t":"outcome","worker":"main","test":"MTest#test_x","retry":0,"phase":"call","outcome":outcome,"xfail":false,"runner":"minitest"}),
1768 serde_json::json!({"t":"exit","at":9}),
1769 ];
1770 let directory = temporary(name);
1771 fs::write(directory.join("main.7.a.mmap"), transport(&records)).unwrap();
1772 let run = build_ruby_frontend_run(
1773 &obligations.manifest,
1774 &directory,
1775 "run-1",
1776 "now",
1777 0,
1778 &RubyAssertionInventory::empty(),
1779 )
1780 .unwrap();
1781 validate_frontend_report_request(&run.declaration, &run.request).unwrap();
1782 fs::remove_dir_all(directory).unwrap();
1783 run
1784 };
1785 let events_of = |result: &RawTestResult, phase: &str| -> BTreeSet<String> {
1786 result
1787 .runtime
1788 .iter()
1789 .flat_map(|snapshot| snapshot.events.iter())
1790 .filter(|event| event.phase_id.as_deref() == Some(phase))
1791 .map(|event| event.id.clone())
1792 .collect()
1793 };
1794
1795 let run = run_with("asserted-passed", "passed");
1796 let passed = &run.request.raw_results[0];
1797 assert_eq!(passed.test, "MTest#test_x");
1798 let assertion = passed
1799 .phases
1800 .iter()
1801 .find(|phase| phase.kind == "assertion")
1802 .expect("the asserting test carries an assertion phase");
1803 assert_eq!(assertion.status.as_deref(), Some("passed"));
1804 assert_eq!(
1805 events_of(passed, &assertion.id),
1806 BTreeSet::from([before.clone()]),
1807 "only what ran before the marker is the assertion's evidence"
1808 );
1809 let test_phase = passed
1810 .phases
1811 .iter()
1812 .find(|phase| phase.kind == "test")
1813 .unwrap();
1814 assert_eq!(
1815 events_of(passed, &test_phase.id),
1816 BTreeSet::from([before.clone(), after.clone()]),
1817 "the test phase keeps everything it ran"
1818 );
1819 assert_eq!(
1820 run.declaration.runners[0].attribution.assertion,
1821 AttributionPrecision::Exact
1822 );
1823
1824 let run = run_with("asserted-failed", "failed");
1825 let failed = &run.request.raw_results[0];
1826 let assertion = failed
1827 .phases
1828 .iter()
1829 .find(|phase| phase.kind == "assertion")
1830 .unwrap();
1831 assert_eq!(
1832 assertion.status.as_deref(),
1833 Some("failed"),
1834 "a failed test's assertion witnessed nothing"
1835 );
1836 }
1837
1838 #[test]
1839 fn joins_rspec_and_minitest_outcomes_into_exact_results() {
1840 let source = "def f(a, b)\n if a && b\n 1\n else\n 0\n end\nend\n";
1841 let mut probe = 0;
1842 let obligations =
1843 build_ruby_obligations("lib/m.rb", source.as_bytes(), &mut probe).unwrap();
1844 let decision = &obligations.manifest.decisions[0];
1845 let statement = obligations
1846 .manifest
1847 .points
1848 .iter()
1849 .find(|point| point.kind == PointKind::Statement)
1850 .unwrap();
1851 let directory = temporary("join");
1852 let records = [
1853 serde_json::json!({"t":"process","v":1,"run":"run-1","pid":7,"worker":"main","ruby":"4.0.6","executable":"ruby","argv":["rspec"]}),
1854 serde_json::json!({"t":"hit","ctx":0,"id":statement.id}),
1855 serde_json::json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"spec/m_spec.rb[1:1]","retry":0,"phase":"call"}),
1856 serde_json::json!({"t":"dec","ctx":1,"id":decision.id,"v":"22","o":1}),
1857 serde_json::json!({"t":"outcome","worker":"main","test":"spec/m_spec.rb[1:1]","retry":0,"phase":"setup","outcome":"passed","xfail":false,"runner":"rspec"}),
1858 serde_json::json!({"t":"outcome","worker":"main","test":"spec/m_spec.rb[1:1]","retry":0,"phase":"call","outcome":"passed","xfail":false,"runner":"rspec"}),
1859 serde_json::json!({"t":"outcome","worker":"main","test":"spec/m_spec.rb[1:1]","retry":0,"phase":"teardown","outcome":"passed","xfail":false,"runner":"rspec"}),
1860 serde_json::json!({"t":"phase","ctx":2,"at":6,"worker":"main","test":"MTest#test_x","retry":0,"phase":"call"}),
1861 serde_json::json!({"t":"outcome","worker":"main","test":"MTest#test_x","retry":0,"phase":"call","outcome":"skipped","xfail":false,"runner":"minitest"}),
1862 serde_json::json!({"t":"exit","at":9}),
1863 ];
1864 fs::write(directory.join("main.7.a.mmap"), transport(&records)).unwrap();
1865 let run = build_ruby_frontend_run(
1866 &obligations.manifest,
1867 &directory,
1868 "run-1",
1869 "now",
1870 0,
1871 &RubyAssertionInventory::empty(),
1872 )
1873 .unwrap();
1874 validate_frontend_report_request(&run.declaration, &run.request).unwrap();
1875 assert_eq!(run.tests, 2);
1876 let runners = run
1877 .declaration
1878 .runners
1879 .iter()
1880 .map(|runner| runner.runner.clone())
1881 .collect::<Vec<_>>();
1882 assert_eq!(runners, ["minitest", "rspec"]);
1883 assert_eq!(run.ruby_versions, ["4.0.6"]);
1884 fs::remove_dir_all(directory).unwrap();
1885 }
1886
1887 #[test]
1888 fn fails_closed_without_an_interpreter() {
1889 let mut probe = 0;
1890 let obligations = build_ruby_obligations("m.rb", b"x = 1\n", &mut probe).unwrap();
1891 let directory = temporary("empty");
1892 assert!(matches!(
1893 build_ruby_frontend_run(
1894 &obligations.manifest,
1895 &directory,
1896 "run-1",
1897 "now",
1898 0,
1899 &RubyAssertionInventory::empty()
1900 ),
1901 Err(RubyEvidenceError::NoInterpreter)
1902 ));
1903 fs::remove_dir_all(directory).unwrap();
1904 }
1905}