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