1use std::{
12 collections::{BTreeMap, BTreeSet},
13 fs::{self, File},
14 path::{Component, Path},
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 },
90 Hit {
91 ctx: u64,
92 id: String,
93 },
94 Dec {
95 ctx: u64,
96 id: String,
97 v: String,
98 o: u8,
99 },
100 Limitation {
101 id: String,
102 reason: String,
103 #[serde(default)]
104 file: Option<String>,
105 #[serde(default)]
106 obligation: Option<String>,
107 },
108 Exit {
109 at: i64,
110 },
111}
112
113#[derive(Debug)]
114pub enum RubyEvidenceError {
115 Io(String),
116 UnsafeEntry(String),
117 InvalidRecord {
118 file: String,
119 line: usize,
120 reason: String,
121 },
122 InvalidTransport {
123 file: String,
124 reason: String,
125 },
126 DroppedRecords {
127 file: String,
128 count: u64,
129 },
130 RunMismatch {
131 expected: String,
132 actual: String,
133 },
134 UnsupportedVersion(u32),
135 UnknownContext {
136 file: String,
137 line: usize,
138 context: u64,
139 },
140 UnknownObligation(String),
141 InvalidVector {
142 id: String,
143 expected: usize,
144 actual: usize,
145 },
146 NoInterpreter,
147 NoTests,
148 UnsupportedRuby(String),
149}
150
151impl std::fmt::Display for RubyEvidenceError {
152 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
153 match self {
154 Self::Io(reason) => write!(formatter, "could not read Ruby evidence: {reason}"),
155 Self::UnsafeEntry(name) => write!(formatter, "unsafe Ruby evidence entry: {name}"),
156 Self::InvalidRecord { file, line, reason } => {
157 write!(formatter, "invalid Ruby evidence record {file}:{line}: {reason}")
158 }
159 Self::InvalidTransport { file, reason } => {
160 write!(formatter, "invalid Ruby evidence transport {file}: {reason}")
161 }
162 Self::DroppedRecords { file, count } => write!(
163 formatter,
164 "Ruby evidence transport {file} exhausted its bounded capacity and dropped {count} record(s)"
165 ),
166 Self::RunMismatch { expected, actual } => write!(
167 formatter,
168 "Ruby evidence belongs to run {actual}, expected {expected}"
169 ),
170 Self::UnsupportedVersion(version) => {
171 write!(formatter, "unsupported Ruby evidence version {version}")
172 }
173 Self::UnknownContext { file, line, context } => write!(
174 formatter,
175 "Ruby evidence {file}:{line} references undeclared context {context}"
176 ),
177 Self::UnknownObligation(id) => {
178 write!(formatter, "Ruby runtime reported an unknown obligation: {id}")
179 }
180 Self::InvalidVector {
181 id,
182 expected,
183 actual,
184 } => write!(
185 formatter,
186 "Ruby decision {id} reported {actual} condition values, expected {expected}"
187 ),
188 Self::NoInterpreter => formatter.write_str(
189 "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)",
190 ),
191 Self::NoTests => formatter.write_str(
192 "the Ruby run produced no test outcomes; Supercov measures Ruby through RSpec, Minitest, test-unit and Cucumber",
193 ),
194 Self::UnsupportedRuby(version) => write!(
195 formatter,
196 "Supercov measures Ruby 3.3 or newer; the test command ran Ruby {version}"
197 ),
198 }
199 }
200}
201
202impl std::error::Error for RubyEvidenceError {}
203
204fn stable_id(prefix: &str, values: &[&str]) -> String {
205 let mut hash = Sha256::new();
206 for value in values {
207 hash.update(value.as_bytes());
208 hash.update([0]);
209 }
210 let digest = hash.finalize();
211 let mut encoded = String::with_capacity(prefix.len() + 25);
212 encoded.push_str(prefix);
213 encoded.push(':');
214 for byte in &digest[..12] {
215 use std::fmt::Write as _;
216 write!(&mut encoded, "{byte:02x}").expect("string formatting");
217 }
218 encoded
219}
220
221#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
222struct Identity {
223 worker: String,
224 test: String,
225 retry: usize,
226 phase: String,
227}
228
229type ObservedVectors = BTreeSet<(Vec<Option<bool>>, bool)>;
230type OutcomesByAttempt = BTreeMap<(String, String, usize), Vec<(String, String, bool)>>;
232type RunnersByAttempt = BTreeMap<(String, String, usize), String>;
234
235#[derive(Debug, Default)]
236struct Observations {
237 hits: BTreeSet<String>,
238 vectors: BTreeMap<String, ObservedVectors>,
239}
240
241#[derive(Debug, Clone)]
242struct RuntimeLimitation {
243 id: String,
244 reason: String,
245 file: Option<String>,
246 obligation: Option<String>,
247}
248
249#[derive(Debug, Default)]
250struct Evidence {
251 interpreters: usize,
252 ruby_versions: BTreeSet<String>,
253 per_identity: BTreeMap<Identity, Observations>,
254 background: BTreeMap<String, Observations>,
255 outcomes: OutcomesByAttempt,
256 runners: RunnersByAttempt,
257 limitations: Vec<RuntimeLimitation>,
258}
259
260fn read_evidence_directory(directory: &Path, run_id: &str) -> Result<Evidence, RubyEvidenceError> {
261 let mut evidence = Evidence::default();
262 let mut files = match fs::read_dir(directory) {
263 Ok(entries) => entries
264 .collect::<Result<Vec<_>, _>>()
265 .map_err(|error| RubyEvidenceError::Io(error.to_string()))?,
266 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
267 Err(error) => return Err(RubyEvidenceError::Io(error.to_string())),
268 };
269 files.sort_by_key(|entry| entry.file_name());
270 for entry in files {
271 let name = entry
272 .file_name()
273 .into_string()
274 .map_err(|_| RubyEvidenceError::UnsafeEntry("<non-utf8>".into()))?;
275 if Path::new(&name)
276 .components()
277 .any(|component| !matches!(component, Component::Normal(_)))
278 || !name.ends_with(".mmap")
279 {
280 return Err(RubyEvidenceError::UnsafeEntry(name));
281 }
282 let metadata = fs::symlink_metadata(entry.path())
283 .map_err(|error| RubyEvidenceError::Io(error.to_string()))?;
284 if !metadata.file_type().is_file() {
285 return Err(RubyEvidenceError::UnsafeEntry(name));
286 }
287 let file =
288 File::open(entry.path()).map_err(|error| RubyEvidenceError::Io(error.to_string()))?;
289 let contents = unsafe { MmapOptions::new().map(&file) }
293 .map_err(|error| RubyEvidenceError::Io(error.to_string()))?;
294 read_evidence_file(&name, &contents, run_id, &mut evidence)?;
295 }
296 Ok(evidence)
297}
298
299fn transport_u32(bytes: &[u8], offset: usize) -> Option<u32> {
300 bytes
301 .get(offset..offset + 4)
302 .and_then(|value| value.try_into().ok())
303 .map(u32::from_le_bytes)
304}
305
306fn transport_u64(bytes: &[u8], offset: usize) -> Option<u64> {
307 bytes
308 .get(offset..offset + 8)
309 .and_then(|value| value.try_into().ok())
310 .map(u64::from_le_bytes)
311}
312
313fn transport_checksum(payload: &[u8]) -> u32 {
314 payload.iter().fold(0x811c_9dc5_u32, |value, byte| {
315 (value ^ u32::from(*byte)).wrapping_mul(0x0100_0193)
316 })
317}
318
319fn align_transport(value: usize) -> Option<usize> {
320 value.checked_add(7).map(|value| value & !7)
321}
322
323fn read_evidence_file(
324 name: &str,
325 contents: &Mmap,
326 run_id: &str,
327 evidence: &mut Evidence,
328) -> Result<(), RubyEvidenceError> {
329 let invalid_transport = |reason: &str| RubyEvidenceError::InvalidTransport {
330 file: name.into(),
331 reason: reason.into(),
332 };
333 if contents.len() < TRANSPORT_HEADER_SIZE
334 || contents.get(..8) != Some(TRANSPORT_MAGIC.as_slice())
335 || transport_u32(contents, 8) != Some(TRANSPORT_VERSION)
336 || transport_u32(contents, 12) != Some(TRANSPORT_HEADER_SIZE as u32)
337 {
338 return Err(invalid_transport("header or version does not match"));
339 }
340 let declared_capacity =
341 transport_u64(contents, 16).ok_or_else(|| invalid_transport("capacity is missing"))?;
342 if declared_capacity < TRANSPORT_HEADER_SIZE as u64 || declared_capacity > contents.len() as u64
343 {
344 return Err(invalid_transport(
345 "declared capacity is outside the mapped file",
346 ));
347 }
348 let dropped =
349 transport_u64(contents, 24).ok_or_else(|| invalid_transport("drop counter is missing"))?;
350 if dropped != 0 {
351 return Err(RubyEvidenceError::DroppedRecords {
352 file: name.into(),
353 count: dropped,
354 });
355 }
356 let transport_pid = transport_u64(contents, 32)
357 .filter(|pid| *pid != 0)
358 .ok_or_else(|| invalid_transport("process id is missing"))?;
359 let mut contexts = BTreeMap::<u64, Identity>::new();
360 let mut process_worker: Option<String> = None;
361 let mut process_started = false;
362 let mut process_reported = false;
363 let mut cursor = TRANSPORT_HEADER_SIZE;
364 let mut record_index = 0;
365 while cursor + TRANSPORT_RECORD_HEADER_SIZE <= contents.len() {
366 let commit = contents[cursor];
367 if commit == 0 {
368 break;
371 }
372 record_index += 1;
373 let line_number = record_index;
374 let invalid = |reason: &str| RubyEvidenceError::InvalidRecord {
375 file: name.into(),
376 line: line_number,
377 reason: reason.into(),
378 };
379 if commit != 1
380 || contents[cursor + 1..cursor + 4] != [0, 0, 0]
381 || contents[cursor + 12..cursor + 16] != [0, 0, 0, 0]
382 {
383 return Err(invalid("commit marker or reserved bytes are invalid"));
384 }
385 let length = transport_u32(contents, cursor + 4)
386 .map(|value| value as usize)
387 .ok_or_else(|| invalid("payload length is missing"))?;
388 if length == 0 || length > TRANSPORT_MAX_RECORD_SIZE {
389 return Err(invalid("payload length is outside the transport bound"));
390 }
391 let payload_start = cursor + TRANSPORT_RECORD_HEADER_SIZE;
392 let payload_end = payload_start
393 .checked_add(length)
394 .filter(|end| *end <= contents.len())
395 .ok_or_else(|| invalid("payload extends past the mapped file"))?;
396 let next_cursor = align_transport(payload_end)
397 .filter(|end| *end <= contents.len())
398 .ok_or_else(|| invalid("aligned frame extends past the mapped file"))?;
399 if contents[payload_end..next_cursor]
400 .iter()
401 .any(|byte| *byte != 0)
402 {
403 return Err(invalid("frame padding is not zero"));
404 }
405 let payload = &contents[payload_start..payload_end];
406 let expected_checksum = transport_u32(contents, cursor + 8)
407 .ok_or_else(|| invalid("payload checksum is missing"))?;
408 if transport_checksum(payload) != expected_checksum {
409 return Err(invalid("payload checksum does not match"));
410 }
411 let record: Record =
412 serde_json::from_slice(payload).map_err(|error| RubyEvidenceError::InvalidRecord {
413 file: name.into(),
414 line: line_number,
415 reason: error.to_string(),
416 })?;
417 match record {
418 Record::Process {
419 v,
420 run,
421 pid,
422 worker,
423 ruby,
424 ..
425 } => {
426 if v != RUBY_EVIDENCE_VERSION {
427 return Err(RubyEvidenceError::UnsupportedVersion(v));
428 }
429 if run != run_id {
430 return Err(RubyEvidenceError::RunMismatch {
431 expected: run_id.into(),
432 actual: run,
433 });
434 }
435 if pid != transport_pid {
436 return Err(invalid("process record does not match the transport owner"));
437 }
438 let supported = ruby
439 .split('.')
440 .take(2)
441 .map(|part| part.parse::<u32>().ok())
442 .collect::<Option<Vec<_>>>()
443 .is_some_and(|parts| parts.len() == 2 && (parts[0], parts[1]) >= (3, 3));
444 if !supported {
445 return Err(RubyEvidenceError::UnsupportedRuby(ruby));
446 }
447 evidence.interpreters += 1;
448 evidence.ruby_versions.insert(ruby);
449 process_started = true;
450 process_worker = Some(worker);
451 }
452 Record::Worker { worker } => process_worker = Some(worker),
453 Record::Phase {
454 ctx,
455 worker,
456 test,
457 retry,
458 phase,
459 ..
460 } => {
461 if ctx == 0 {
462 return Err(invalid("phase context 0 is reserved for background"));
463 }
464 if !matches!(phase.as_str(), "setup" | "call" | "teardown") {
465 return Err(invalid("unknown test phase"));
466 }
467 if test.trim().is_empty() || worker.trim().is_empty() {
468 return Err(invalid("phase identity must name a worker and test"));
469 }
470 contexts.insert(
471 ctx,
472 Identity {
473 worker,
474 test,
475 retry,
476 phase,
477 },
478 );
479 }
480 Record::Outcome {
481 worker,
482 test,
483 retry,
484 phase,
485 outcome,
486 xfail,
487 runner,
488 } => {
489 if !matches!(phase.as_str(), "setup" | "call" | "teardown") {
490 return Err(invalid("unknown test outcome phase"));
491 }
492 if !matches!(
493 outcome.as_str(),
494 "passed" | "failed" | "skipped" | "rerun" | "error"
495 ) {
496 return Err(invalid("unknown test outcome"));
497 }
498 if !matches!(
499 runner.as_str(),
500 RSPEC_RUNNER | MINITEST_RUNNER | TEST_UNIT_RUNNER | CUCUMBER_RUNNER
501 ) {
502 return Err(invalid("unknown Ruby test runner"));
503 }
504 let key = (worker, test, retry);
505 if let Some(previous) = evidence.runners.get(&key)
506 && previous != &runner
507 {
508 return Err(invalid("one attempt was reported by two runners"));
509 }
510 evidence.runners.insert(key.clone(), runner);
511 evidence
512 .outcomes
513 .entry(key)
514 .or_default()
515 .push((phase, outcome, xfail));
516 }
517 Record::Hit { ctx, id } => {
518 observations(
519 evidence,
520 &contexts,
521 process_worker.as_deref(),
522 ctx,
523 name,
524 line_number,
525 )?
526 .hits
527 .insert(id);
528 }
529 Record::Dec { ctx, id, v, o } => {
530 if v.is_empty() || !v.bytes().all(|digit| matches!(digit, b'0' | b'1' | b'2')) {
531 return Err(invalid("decision vector digits must be 0, 1 or 2"));
532 }
533 if o > 1 {
534 return Err(invalid("decision outcome must be 0 or 1"));
535 }
536 let values = v
537 .bytes()
538 .map(|digit| match digit {
539 b'0' => None,
540 b'1' => Some(false),
541 _ => Some(true),
542 })
543 .collect::<Vec<_>>();
544 observations(
545 evidence,
546 &contexts,
547 process_worker.as_deref(),
548 ctx,
549 name,
550 line_number,
551 )?
552 .vectors
553 .entry(id)
554 .or_default()
555 .insert((values, o == 1));
556 }
557 Record::Limitation {
558 id,
559 reason,
560 file,
561 obligation,
562 } => evidence.limitations.push(RuntimeLimitation {
563 id,
564 reason,
565 file,
566 obligation,
567 }),
568 Record::Exit { .. } => process_reported = true,
569 }
570 cursor = next_cursor;
571 }
572 if process_started && !process_reported {
581 evidence.limitations.push(RuntimeLimitation {
582 id: "ruby-process-did-not-report".into(),
583 reason: format!(
584 "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"
585 ),
586 file: None,
587 obligation: None,
588 });
589 }
590 Ok(())
591}
592
593fn observations<'a>(
594 evidence: &'a mut Evidence,
595 contexts: &BTreeMap<u64, Identity>,
596 process_worker: Option<&str>,
597 context: u64,
598 file: &str,
599 line: usize,
600) -> Result<&'a mut Observations, RubyEvidenceError> {
601 if context == 0 {
602 return Ok(evidence
603 .background
604 .entry(process_worker.unwrap_or("main").to_owned())
605 .or_default());
606 }
607 let identity = contexts
608 .get(&context)
609 .ok_or(RubyEvidenceError::UnknownContext {
610 file: file.into(),
611 line,
612 context,
613 })?;
614 Ok(evidence.per_identity.entry(identity.clone()).or_default())
615}
616
617pub fn ruby_coverage_model() -> CoverageModelDeclaration {
618 CoverageModelDeclaration {
619 language: "ruby".into(),
620 variant: "ruby-owned-coverage".into(),
621 name: "ruby-coverage-probes-v1".into(),
622 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(),
623 measured: vec![
624 "executable statements proven by Ruby's line coverage, or a probe when a line holds several".into(),
625 "method definitions entered (Ruby's method coverage)".into(),
626 "if/unless/elsif/ternary/while/until decisions with masking MC/DC vectors from operand probes".into(),
627 "while, until, for and iterator-block (each, map, times, ...) zero-versus-entered iteration".into(),
628 "&&, ||, ||= and &&= short-circuit alternatives".into(),
629 "case/when and case/in clause selection, safe navigation".into(),
630 "begin/rescue completion, handler selection and exception propagation".into(),
631 "RSpec, Minitest, test-unit and Cucumber worker, test and setup/call/teardown phase identity".into(),
632 ],
633 not_measured: vec![
634 "blocks and lambdas as function entry points (they are statements inside their methods)".into(),
635 "blocks passed by reference (map(&:to_s)) as loops: they have no block body to observe".into(),
636 "line, branch and method observations made while test phases overlapped in threads (attributed to the run; probe observations stay per test)".into(),
637 "causal linkage to individual actions or passing assertions".into(),
638 "code compiled from strings at runtime (eval, instance_eval with strings)".into(),
639 "child coverage outside Process.spawn, Kernel#spawn, Kernel#system and fork".into(),
640 "all input values, semantic partitions, paths, or concurrency interleavings".into(),
641 "mutation score or assertion fault-detection strength".into(),
642 ],
643 }
644}
645
646fn phase_id(run: &str, identity: &Identity) -> String {
647 stable_id(
648 "ruby-phase",
649 &[
650 run,
651 &identity.worker,
652 &identity.test,
653 &identity.retry.to_string(),
654 &identity.phase,
655 ],
656 )
657}
658
659fn scope(run: &str, worker: &str, test: &str, retry: usize) -> ExecutionScope {
660 ExecutionScope {
661 version: 1,
662 run_id: run.into(),
663 worker_id: worker.into(),
664 test_id: test.into(),
665 test_key: stable_id("ruby-test", &[worker, test]),
666 retry,
667 attempt_id: stable_id("ruby-attempt", &[run, worker, test, &retry.to_string()]),
668 }
669}
670
671struct ManifestIndex<'a> {
672 points: BTreeSet<&'a str>,
673 alternatives: BTreeSet<&'a str>,
674 decisions: BTreeMap<&'a str, &'a DecisionMeta>,
675 lines: BTreeMap<&'a str, (String, usize)>,
676 sources: BTreeMap<&'a str, &'a str>,
677}
678
679impl<'a> ManifestIndex<'a> {
680 fn new(manifest: &'a CoverageManifest) -> Self {
681 let mut lines = BTreeMap::new();
682 let mut sources = BTreeMap::new();
683 for point in &manifest.points {
684 lines.insert(point.id.as_str(), (point.file.clone(), point.line));
685 sources.insert(point.id.as_str(), point.source.as_str());
686 }
687 for decision in &manifest.decisions {
688 lines.insert(decision.id.as_str(), (decision.file.clone(), decision.line));
689 sources.insert(decision.id.as_str(), decision.source.as_str());
690 }
691 for branch in &manifest.branches {
692 lines.insert(branch.id.as_str(), (branch.file.clone(), branch.line));
693 sources.insert(branch.id.as_str(), branch.source.as_str());
694 }
695 Self {
696 points: manifest
697 .points
698 .iter()
699 .map(|point| point.id.as_str())
700 .collect(),
701 alternatives: manifest
702 .branches
703 .iter()
704 .flat_map(|branch| branch.alternatives.iter().map(|alt| alt.id.as_str()))
705 .collect(),
706 decisions: manifest
707 .decisions
708 .iter()
709 .map(|decision| (decision.id.as_str(), decision))
710 .collect(),
711 lines,
712 sources,
713 }
714 }
715}
716
717fn snapshot(
718 index: &ManifestIndex<'_>,
719 observations: &Observations,
720 phase: &str,
721) -> Result<RuntimeSnapshot, RubyEvidenceError> {
722 let mut hits = BTreeSet::new();
723 for id in &observations.hits {
724 if !index.points.contains(id.as_str()) && !index.alternatives.contains(id.as_str()) {
725 return Err(RubyEvidenceError::UnknownObligation(id.clone()));
726 }
727 hits.insert(id.clone());
728 }
729 let mut decisions = Vec::new();
730 let mut events = Vec::new();
731 let mut clock = 1;
732 for id in &hits {
733 events.push(RuntimeEvent {
734 event_type: "hit".into(),
735 id: id.clone(),
736 vector: None,
737 timestamp_ms: clock,
738 phase_id: Some(phase.into()),
739 environment: "ruby".into(),
740 });
741 clock += 1;
742 }
743 for (id, vectors) in &observations.vectors {
744 let Some(meta) = index.decisions.get(id.as_str()) else {
745 return Err(RubyEvidenceError::UnknownObligation(id.clone()));
746 };
747 let mut observed = Vec::new();
748 for (values, outcome) in vectors {
749 if values.len() != meta.conditions.len() {
750 return Err(RubyEvidenceError::InvalidVector {
751 id: id.clone(),
752 expected: meta.conditions.len(),
753 actual: values.len(),
754 });
755 }
756 let vector = McdcVector {
757 values: values.clone(),
758 outcome: *outcome,
759 };
760 events.push(RuntimeEvent {
761 event_type: "decision".into(),
762 id: id.clone(),
763 vector: Some(vector.clone()),
764 timestamp_ms: clock,
765 phase_id: Some(phase.into()),
766 environment: "ruby".into(),
767 });
768 clock += 1;
769 observed.push(vector);
770 }
771 decisions.push(DecisionSnapshot {
772 meta: (*meta).clone(),
773 vectors: observed,
774 });
775 }
776 Ok(RuntimeSnapshot {
777 decisions,
778 hits: hits.into_iter().collect(),
779 events,
780 })
781}
782
783fn attempt_status(outcomes: &[(String, String, bool)]) -> String {
784 if outcomes
785 .iter()
786 .any(|(_, outcome, _)| matches!(outcome.as_str(), "failed" | "rerun" | "error"))
787 {
788 "failed"
789 } else if outcomes.iter().any(|(_, outcome, _)| outcome == "skipped") {
790 "skipped"
791 } else {
792 "passed"
793 }
794 .into()
795}
796
797#[derive(Debug, Clone, PartialEq)]
798pub struct RubyFrontendRun {
799 pub declaration: FrontendRunDeclaration,
800 pub request: CoverageReportRequest,
801 pub tests: usize,
802 pub interpreters: usize,
803 pub ruby_versions: Vec<String>,
804}
805
806impl RubyFrontendRun {
807 pub fn archive_entries(&self) -> Result<Vec<EvidenceArchiveEntry>, serde_json::Error> {
808 let model = PersistedCoverageModel::from_declaration(
809 self.request
810 .coverage_model
811 .as_ref()
812 .expect("Ruby frontend always declares a coverage model"),
813 )
814 .expect("Ruby coverage model is contract-valid");
815 let mut entries = vec![
816 EvidenceArchiveEntry {
817 path: "coverage-model.json".into(),
818 contents: serde_json::to_vec(&model)?,
819 },
820 EvidenceArchiveEntry {
821 path: "frontend.json".into(),
822 contents: serde_json::to_vec(&self.declaration)?,
823 },
824 EvidenceArchiveEntry {
825 path: "manifest.json".into(),
826 contents: serde_json::to_vec(&self.request.manifest)?,
827 },
828 ];
829 for (index, result) in self.request.raw_results.iter().enumerate() {
830 entries.push(EvidenceArchiveEntry {
831 path: format!("results/{index:08}/mcdc.json"),
832 contents: serde_json::to_vec(result)?,
833 });
834 }
835 Ok(entries)
836 }
837}
838
839pub fn build_ruby_frontend_run(
842 manifest: &CoverageManifest,
843 evidence_directory: &Path,
844 run_id: &str,
845 generated_at: &str,
846 test_exit_code: i32,
847) -> Result<RubyFrontendRun, RubyEvidenceError> {
848 let evidence = read_evidence_directory(evidence_directory, run_id)?;
849 if evidence.interpreters == 0 {
850 return Err(RubyEvidenceError::NoInterpreter);
851 }
852 if evidence.outcomes.is_empty() {
853 return Err(RubyEvidenceError::NoTests);
854 }
855 let Evidence {
856 interpreters,
857 ruby_versions,
858 per_identity,
859 background,
860 outcomes,
861 runners,
862 limitations,
863 } = evidence;
864 let mut manifest = manifest.clone();
865 let index = ManifestIndex::new(&manifest);
866
867 let mut raw_results = Vec::new();
868 let mut observed_runners = BTreeSet::new();
869 let mut identities_by_attempt =
870 BTreeMap::<(String, String, usize), Vec<(&Identity, &Observations)>>::new();
871 for (identity, observations) in &per_identity {
872 identities_by_attempt
873 .entry((
874 identity.worker.clone(),
875 identity.test.clone(),
876 identity.retry,
877 ))
878 .or_default()
879 .push((identity, observations));
880 }
881 for ((worker, test, retry), mut outcomes) in outcomes {
882 let runner = runners
883 .get(&(worker.clone(), test.clone(), retry))
884 .cloned()
885 .unwrap_or_else(default_runner);
886 let attempt_identities = identities_by_attempt
887 .remove(&(worker.clone(), test.clone(), retry))
888 .unwrap_or_default();
889 observed_runners.insert(runner.clone());
890 outcomes.sort_by_key(|(phase, _, _)| match phase.as_str() {
891 "setup" => 0,
892 "call" => 1,
893 _ => 2,
894 });
895 let mut phases = Vec::new();
896 let mut runtime = Vec::new();
897 let mut observed_phases = BTreeSet::new();
898 for (position, (phase_name, outcome, _)) in outcomes.iter().enumerate() {
899 observed_phases.insert(phase_name.clone());
900 let identity = Identity {
901 worker: worker.clone(),
902 test: test.clone(),
903 retry,
904 phase: phase_name.clone(),
905 };
906 let id = phase_id(run_id, &identity);
907 phases.push(CoveragePhase {
908 id: id.clone(),
909 kind: match phase_name.as_str() {
910 "call" => "test",
911 value => value,
912 }
913 .into(),
914 operation: format!("{runner} {phase_name}"),
915 source: Some(test.clone()),
916 caused_by_phase_id: None,
917 started_at_ms: position as i64 * 2 + 1,
918 ended_at_ms: Some(position as i64 * 2 + 2),
919 status: Some(match outcome.as_str() {
920 "rerun" | "error" => "failed".into(),
921 value => value.into(),
922 }),
923 error: None,
924 });
925 if let Some((_, observations)) = attempt_identities
926 .iter()
927 .find(|(candidate, _)| candidate.phase == phase_name.as_str())
928 {
929 runtime.push(snapshot(&index, observations, &id)?);
930 }
931 }
932 for (identity, observations) in attempt_identities {
935 if !observed_phases.contains(&identity.phase) {
936 let id = phase_id(run_id, identity);
937 phases.push(CoveragePhase {
938 id: id.clone(),
939 kind: match identity.phase.as_str() {
940 "call" => "test",
941 value => value,
942 }
943 .into(),
944 operation: format!("{runner} {}", identity.phase),
945 source: Some(test.clone()),
946 caused_by_phase_id: None,
947 started_at_ms: phases.len() as i64 * 2 + 1,
948 ended_at_ms: None,
949 status: Some("failed".into()),
950 error: Some("the phase started but the runner reported no outcome".into()),
951 });
952 runtime.push(snapshot(&index, observations, &id)?);
953 }
954 }
955 let status = if phases.iter().any(|phase| phase.error.is_some()) {
956 "failed".into()
957 } else {
958 attempt_status(&outcomes)
959 };
960 raw_results.push(RawTestResult {
961 test_id: Some(test.clone()),
962 scope: Some(scope(run_id, &worker, &test, retry)),
963 test: test.clone(),
964 test_file: test.split("::").next().map(str::to_owned),
965 title: test.rsplit("::").next().map(str::to_owned),
966 retry: Some(retry),
967 status: Some(status),
968 expected_status: Some(
969 if outcomes.iter().any(|(_, _, xfail)| *xfail) {
970 "failed"
971 } else {
972 "passed"
973 }
974 .into(),
975 ),
976 flaky: false,
977 provenance: TestProvenance {
978 runner: runner.clone(),
979 kind: "unit".into(),
980 project: None,
981 source: RUBY_FRONTEND_VERSION.into(),
982 },
983 role: "test".into(),
984 phases,
985 runtime,
986 browser: Vec::new(),
987 server: Vec::new(),
988 });
989 }
990 let default_observed = observed_runners
993 .iter()
994 .next()
995 .cloned()
996 .unwrap_or_else(default_runner);
997 for ((worker, test, retry), identities) in identities_by_attempt {
998 let runner = default_observed.clone();
999 let mut phases = Vec::new();
1000 let mut runtime = Vec::new();
1001 for (position, (identity, observations)) in identities.iter().enumerate() {
1002 let id = phase_id(run_id, identity);
1003 phases.push(CoveragePhase {
1004 id: id.clone(),
1005 kind: match identity.phase.as_str() {
1006 "call" => "test",
1007 value => value,
1008 }
1009 .into(),
1010 operation: format!("{runner} {}", identity.phase),
1011 source: Some(test.clone()),
1012 caused_by_phase_id: None,
1013 started_at_ms: position as i64 * 2 + 1,
1014 ended_at_ms: None,
1015 status: Some("failed".into()),
1016 error: Some("the phase started but the runner reported no outcome".into()),
1017 });
1018 runtime.push(snapshot(&index, observations, &id)?);
1019 }
1020 raw_results.push(RawTestResult {
1021 test_id: Some(test.clone()),
1022 scope: Some(scope(run_id, &worker, &test, retry)),
1023 test: test.clone(),
1024 test_file: test.split("::").next().map(str::to_owned),
1025 title: test.rsplit("::").next().map(str::to_owned),
1026 retry: Some(retry),
1027 status: Some("failed".into()),
1028 expected_status: Some("passed".into()),
1029 flaky: false,
1030 provenance: TestProvenance {
1031 runner: runner.clone(),
1032 kind: "unit".into(),
1033 project: None,
1034 source: RUBY_FRONTEND_VERSION.into(),
1035 },
1036 role: "test".into(),
1037 phases,
1038 runtime,
1039 browser: Vec::new(),
1040 server: Vec::new(),
1041 });
1042 }
1043 for (worker, observations) in &background {
1044 if observations.hits.is_empty() && observations.vectors.is_empty() {
1045 continue;
1046 }
1047 let test = format!("__supercov_background__:{worker}");
1048 let identity = Identity {
1049 worker: worker.clone(),
1050 test: test.clone(),
1051 retry: 0,
1052 phase: "background".into(),
1053 };
1054 let phase = phase_id(run_id, &identity);
1055 raw_results.push(RawTestResult {
1056 test_id: Some(test.clone()),
1057 scope: Some(scope(run_id, worker, &test, 0)),
1058 test: "Ruby load and background execution".into(),
1059 test_file: None,
1060 title: None,
1061 retry: Some(0),
1062 status: Some("unknown".into()),
1063 expected_status: None,
1064 flaky: false,
1065 provenance: TestProvenance {
1066 runner: default_observed.clone(),
1067 kind: "unit".into(),
1068 project: None,
1069 source: RUBY_FRONTEND_VERSION.into(),
1070 },
1071 role: "background".into(),
1072 phases: vec![CoveragePhase {
1073 id: phase.clone(),
1074 kind: "background".into(),
1075 operation: "Ruby load background".into(),
1076 source: None,
1077 caused_by_phase_id: None,
1078 started_at_ms: 0,
1079 ended_at_ms: Some(0),
1080 status: Some("passed".into()),
1081 error: None,
1082 }],
1083 runtime: vec![snapshot(&index, observations, &phase)?],
1084 browser: Vec::new(),
1085 server: Vec::new(),
1086 });
1087 }
1088
1089 let mut limitation_ids = manifest
1093 .limitations
1094 .iter()
1095 .filter_map(|item| item.get("id").and_then(serde_json::Value::as_str))
1096 .map(str::to_owned)
1097 .collect::<BTreeSet<_>>();
1098 let mut unmeasured = manifest.unmeasured.iter().cloned().collect::<BTreeSet<_>>();
1099 let mut new_limitations = Vec::new();
1100 for limitation in &limitations {
1101 if let Some(obligation) = &limitation.obligation {
1102 if !index.lines.contains_key(obligation.as_str()) {
1103 return Err(RubyEvidenceError::UnknownObligation(obligation.clone()));
1104 }
1105 unmeasured.insert(obligation.clone());
1106 } else if let Some(file) = &limitation.file {
1107 unmeasured.extend(
1112 index
1113 .lines
1114 .iter()
1115 .filter(|(_, (obligation_file, _))| obligation_file == file)
1116 .map(|(id, _)| (*id).to_owned()),
1117 );
1118 }
1119 if limitation_ids.insert(limitation.id.clone()) {
1120 let (file, line) = limitation
1121 .obligation
1122 .as_deref()
1123 .and_then(|id| index.lines.get(id).cloned())
1124 .unwrap_or_else(|| {
1125 (
1126 limitation.file.clone().unwrap_or_else(|| {
1127 manifest
1128 .points
1129 .first()
1130 .map_or(".".into(), |point| point.file.clone())
1131 }),
1132 1,
1133 )
1134 });
1135 let source = limitation
1136 .obligation
1137 .as_deref()
1138 .and_then(|id| index.sources.get(id))
1139 .map(|source| source.lines().next().unwrap_or_default().to_owned())
1140 .unwrap_or_default();
1141 new_limitations.push(json!({
1142 "id": limitation.id,
1143 "kind": "semantic-safety",
1144 "file": file,
1145 "line": line,
1146 "column": 0,
1147 "source": source,
1148 "reason": limitation.reason
1149 }));
1150 }
1151 }
1152 manifest.limitations.extend(new_limitations);
1153 manifest.unmeasured = unmeasured.into_iter().collect();
1154 let structural_limitations = limitation_ids.into_iter().collect::<Vec<_>>();
1155
1156 let tests = raw_results
1160 .iter()
1161 .filter(|raw| raw.role == "test")
1162 .map(|raw| raw.test.as_str())
1163 .collect::<BTreeSet<_>>()
1164 .len();
1165 Ok(RubyFrontendRun {
1166 declaration: FrontendRunDeclaration {
1167 protocol_version: LANGUAGE_FRONTEND_PROTOCOL_VERSION,
1168 frontend_id: "ruby".into(),
1169 frontend_version: RUBY_FRONTEND_VERSION.into(),
1170 language: "ruby".into(),
1171 structural_source: StructuralSource::OwnedProbes,
1172 runners: observed_runners
1173 .iter()
1174 .map(|runner| FrontendRunnerDeclaration {
1175 runner: runner.clone(),
1176 execution_model: ExecutionModel::SerialInProcess,
1177 attribution: FrontendAttribution {
1178 run: AttributionPrecision::Exact,
1179 worker: AttributionPrecision::Exact,
1180 test: AttributionPrecision::Exact,
1181 retry: AttributionPrecision::Exact,
1182 phase: AttributionPrecision::Exact,
1183 action: AttributionPrecision::Unavailable,
1184 assertion: AttributionPrecision::Unavailable,
1185 },
1186 limitations: vec![
1187 FrontendLimitation {
1188 id: format!("ruby-{runner}-action-linkage"),
1189 scopes: vec![FrontendLimitationScope::Action],
1190 reason: format!("{runner} exposes no general action lifecycle"),
1191 },
1192 FrontendLimitation {
1193 id: format!("ruby-{runner}-assertion-linkage"),
1194 scopes: vec![FrontendLimitationScope::Assertion],
1195 reason: format!("{runner} phase outcomes do not identify each passing assertion or the obligations caused by it"),
1196 },
1197 ],
1198 })
1199 .collect(),
1200 structural_limitations,
1201 },
1202 request: CoverageReportRequest {
1203 run_id: run_id.into(),
1204 manifest,
1205 raw_results,
1206 generated_at: generated_at.into(),
1207 coverage_model: Some(ruby_coverage_model()),
1208 integrity: None,
1209 test_exit_code: ExitCodeInput::Present(Some(test_exit_code)),
1210 },
1211 tests,
1212 interpreters,
1213 ruby_versions: ruby_versions.into_iter().collect(),
1214 })
1215}
1216
1217#[cfg(test)]
1218mod tests {
1219 use super::*;
1220 use crate::{
1221 coverage_analysis::PointKind, frontend_protocol::validate_frontend_report_request,
1222 ruby_instrumenter::build_ruby_obligations,
1223 };
1224
1225 fn frame(payload: &[u8]) -> Vec<u8> {
1226 let mut bytes = vec![1u8, 0, 0, 0];
1227 bytes.extend_from_slice(&(payload.len() as u32).to_le_bytes());
1228 bytes.extend_from_slice(&transport_checksum(payload).to_le_bytes());
1229 bytes.extend_from_slice(&[0, 0, 0, 0]);
1230 bytes.extend_from_slice(payload);
1231 while bytes.len() % 8 != 0 {
1232 bytes.push(0);
1233 }
1234 bytes
1235 }
1236
1237 fn transport(records: &[serde_json::Value]) -> Vec<u8> {
1238 let mut body = Vec::new();
1239 for record in records {
1240 body.extend(frame(record.to_string().as_bytes()));
1241 }
1242 let capacity = TRANSPORT_HEADER_SIZE + body.len();
1243 let mut bytes = vec![0u8; TRANSPORT_HEADER_SIZE];
1244 bytes[..8].copy_from_slice(TRANSPORT_MAGIC);
1245 bytes[8..12].copy_from_slice(&TRANSPORT_VERSION.to_le_bytes());
1246 bytes[12..16].copy_from_slice(&(TRANSPORT_HEADER_SIZE as u32).to_le_bytes());
1247 bytes[16..24].copy_from_slice(&(capacity as u64).to_le_bytes());
1248 bytes[32..40].copy_from_slice(&7u64.to_le_bytes());
1249 bytes.extend(body);
1250 bytes
1251 }
1252
1253 fn temporary(name: &str) -> std::path::PathBuf {
1254 let nonce = std::time::SystemTime::now()
1255 .duration_since(std::time::UNIX_EPOCH)
1256 .unwrap()
1257 .as_nanos();
1258 let path = std::env::temp_dir().join(format!(
1259 "supercov-ruby-evidence-{}-{nonce}-{name}",
1260 std::process::id()
1261 ));
1262 fs::create_dir_all(&path).unwrap();
1263 path
1264 }
1265
1266 #[test]
1267 fn declares_the_gap_when_a_process_never_reported() {
1268 let source = "def f(a)\n a\nend\n";
1275 let mut probe = 0;
1276 let obligations =
1277 build_ruby_obligations("lib/m.rb", source.as_bytes(), &mut probe).unwrap();
1278 let statement = obligations
1279 .manifest
1280 .points
1281 .iter()
1282 .find(|point| point.kind == PointKind::Statement)
1283 .unwrap();
1284 let base = [
1285 serde_json::json!({"t":"process","v":1,"run":"run-1","pid":7,"worker":"main","ruby":"4.0.6","executable":"ruby","argv":["child.rb"]}),
1286 serde_json::json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"MTest#test_x","retry":0,"phase":"call"}),
1287 serde_json::json!({"t":"hit","ctx":1,"id":statement.id}),
1288 serde_json::json!({"t":"outcome","worker":"main","test":"MTest#test_x","retry":0,"phase":"call","outcome":"passed","xfail":false,"runner":"minitest"}),
1289 ];
1290
1291 let killed = temporary("killed");
1292 fs::write(killed.join("main.7.a.mmap"), transport(&base)).unwrap();
1293 let run =
1294 build_ruby_frontend_run(&obligations.manifest, &killed, "run-1", "now", 0).unwrap();
1295 let declared = serde_json::to_string(&run.request.manifest.limitations).unwrap();
1296 assert!(
1297 declared.contains("ruby-process-did-not-report"),
1298 "a process that never reported must declare the gap: {declared}"
1299 );
1300 fs::remove_dir_all(&killed).unwrap();
1301
1302 let mut clean = base.to_vec();
1303 clean.push(serde_json::json!({"t":"exit","at":9}));
1304 let reported = temporary("reported");
1305 fs::write(reported.join("main.7.a.mmap"), transport(&clean)).unwrap();
1306 let run =
1307 build_ruby_frontend_run(&obligations.manifest, &reported, "run-1", "now", 0).unwrap();
1308 let declared = serde_json::to_string(&run.request.manifest.limitations).unwrap();
1309 assert!(
1310 !declared.contains("ruby-process-did-not-report"),
1311 "a process that reported cleanly must declare nothing: {declared}"
1312 );
1313 fs::remove_dir_all(&reported).unwrap();
1314 }
1315
1316 #[test]
1317 fn joins_rspec_and_minitest_outcomes_into_exact_results() {
1318 let source = "def f(a, b)\n if a && b\n 1\n else\n 0\n end\nend\n";
1319 let mut probe = 0;
1320 let obligations =
1321 build_ruby_obligations("lib/m.rb", source.as_bytes(), &mut probe).unwrap();
1322 let decision = &obligations.manifest.decisions[0];
1323 let statement = obligations
1324 .manifest
1325 .points
1326 .iter()
1327 .find(|point| point.kind == PointKind::Statement)
1328 .unwrap();
1329 let directory = temporary("join");
1330 let records = [
1331 serde_json::json!({"t":"process","v":1,"run":"run-1","pid":7,"worker":"main","ruby":"4.0.6","executable":"ruby","argv":["rspec"]}),
1332 serde_json::json!({"t":"hit","ctx":0,"id":statement.id}),
1333 serde_json::json!({"t":"phase","ctx":1,"at":5,"worker":"main","test":"spec/m_spec.rb[1:1]","retry":0,"phase":"call"}),
1334 serde_json::json!({"t":"dec","ctx":1,"id":decision.id,"v":"22","o":1}),
1335 serde_json::json!({"t":"outcome","worker":"main","test":"spec/m_spec.rb[1:1]","retry":0,"phase":"setup","outcome":"passed","xfail":false,"runner":"rspec"}),
1336 serde_json::json!({"t":"outcome","worker":"main","test":"spec/m_spec.rb[1:1]","retry":0,"phase":"call","outcome":"passed","xfail":false,"runner":"rspec"}),
1337 serde_json::json!({"t":"outcome","worker":"main","test":"spec/m_spec.rb[1:1]","retry":0,"phase":"teardown","outcome":"passed","xfail":false,"runner":"rspec"}),
1338 serde_json::json!({"t":"phase","ctx":2,"at":6,"worker":"main","test":"MTest#test_x","retry":0,"phase":"call"}),
1339 serde_json::json!({"t":"outcome","worker":"main","test":"MTest#test_x","retry":0,"phase":"call","outcome":"skipped","xfail":false,"runner":"minitest"}),
1340 serde_json::json!({"t":"exit","at":9}),
1341 ];
1342 fs::write(directory.join("main.7.a.mmap"), transport(&records)).unwrap();
1343 let run =
1344 build_ruby_frontend_run(&obligations.manifest, &directory, "run-1", "now", 0).unwrap();
1345 validate_frontend_report_request(&run.declaration, &run.request).unwrap();
1346 assert_eq!(run.tests, 2);
1347 let runners = run
1348 .declaration
1349 .runners
1350 .iter()
1351 .map(|runner| runner.runner.clone())
1352 .collect::<Vec<_>>();
1353 assert_eq!(runners, ["minitest", "rspec"]);
1354 assert_eq!(run.ruby_versions, ["4.0.6"]);
1355 fs::remove_dir_all(directory).unwrap();
1356 }
1357
1358 #[test]
1359 fn fails_closed_without_an_interpreter() {
1360 let mut probe = 0;
1361 let obligations = build_ruby_obligations("m.rb", b"x = 1\n", &mut probe).unwrap();
1362 let directory = temporary("empty");
1363 assert!(matches!(
1364 build_ruby_frontend_run(&obligations.manifest, &directory, "run-1", "now", 0),
1365 Err(RubyEvidenceError::NoInterpreter)
1366 ));
1367 fs::remove_dir_all(directory).unwrap();
1368 }
1369}