1use std::collections::{BTreeMap, BTreeSet};
15
16use serde::{Deserialize, Serialize};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
25#[serde(rename_all = "lowercase")]
26pub enum Strength {
27 Presence,
28 Value,
29 Total,
30}
31
32impl Strength {
33 fn caught(self) -> bool {
35 self >= Strength::Value
36 }
37}
38
39fn stronger(a: Option<Strength>, b: Option<Strength>) -> Option<Strength> {
40 match (a, b) {
41 (None, x) | (x, None) => x,
42 (Some(x), Some(y)) => Some(x.max(y)),
43 }
44}
45
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54#[serde(rename_all = "camelCase")]
55pub struct Boundary {
56 pub boundary: String,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub facet: Option<String>,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub via: Option<String>,
61}
62
63impl Boundary {
64 fn internal(&self) -> bool {
65 self.boundary == "internal"
66 }
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71#[serde(rename_all = "camelCase")]
72pub struct Observation {
73 pub boundary: String,
74 #[serde(default)]
75 pub facet: Option<String>,
76 pub strength: Strength,
77 #[serde(default, rename = "where")]
78 pub where_: Option<String>,
79 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub assertion_source: Option<String>,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub assertion_method: Option<String>,
84 #[serde(default)]
86 pub negative: bool,
87 #[serde(default)]
89 pub call_list: bool,
90 #[serde(default)]
92 pub weak: bool,
93 #[serde(default)]
96 pub log_sites: Option<Vec<String>>,
97 #[serde(default)]
100 pub pattern_shared: bool,
101}
102
103impl Observation {
104 fn matches(&self, site: &Site, at: &Boundary) -> bool {
107 if at.boundary != self.boundary {
108 return false;
109 }
110 match at.boundary.as_str() {
111 "client-header" => match (at.facet.as_deref(), self.facet.as_deref()) {
112 (Some("*"), _) | (_, None) | (_, Some("*")) => true,
113 (a, b) => a == b,
114 },
115 "stderr" | "stdout" => {
116 if let (Some(facet), true) = (self.facet.as_deref(), site.category == "log")
117 && let Some(method) = facet.strip_prefix("console.")
118 && site.method.as_deref().is_some_and(|m| m != method)
119 {
120 return false;
121 }
122 match &self.log_sites {
123 Some(admitted) => {
125 if site.category == "log" {
126 admitted.contains(&site.id)
127 } else {
128 at.facet.as_deref() != Some("log")
129 }
130 }
131 None => true,
132 }
133 }
134 "client-message" => match at.facet.as_deref() {
135 Some(facet) => self.facet.as_deref().is_some_and(|f| f.contains(facet)),
136 None => true,
137 },
138 _ => true,
139 }
140 }
141}
142
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145#[serde(rename_all = "camelCase")]
146pub struct SinkBinding {
147 pub sink: String,
148 pub param: String,
149 #[serde(default)]
150 pub member: Option<String>,
151}
152
153#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
154#[serde(rename_all = "camelCase")]
155pub struct TestFacts {
156 pub id: String,
157 pub file: String,
158 pub observations: Vec<Observation>,
159 #[serde(default)]
160 pub sinks: Vec<SinkBinding>,
161 #[serde(default)]
163 pub rendered: Vec<String>,
164 #[serde(default, skip_serializing_if = "Vec::is_empty")]
167 pub witness_issues: Vec<WitnessIssue>,
168}
169
170#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
171#[serde(rename_all = "kebab-case")]
172pub enum WitnessIssueKind {
173 CaptureUnavailable,
174 CallNotRecorded,
175 CallIncomplete,
176 MixedCallOutcomes,
177 CallFailed,
178 UninstrumentedObservation,
179}
180
181impl WitnessIssueKind {
182 fn is_uncertain(self) -> bool {
183 self != Self::CallFailed
184 }
185}
186
187#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
188#[serde(rename_all = "camelCase")]
189pub struct WitnessIssue {
190 pub kind: WitnessIssueKind,
191 #[serde(default, skip_serializing_if = "Option::is_none")]
192 pub source: Option<String>,
193 #[serde(default, skip_serializing_if = "Option::is_none")]
194 pub operation: Option<String>,
195 #[serde(default, skip_serializing_if = "Option::is_none")]
198 pub observation: Option<Observation>,
199}
200
201#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
202pub struct TestWitnessIssue {
203 pub test: String,
204 #[serde(flatten)]
205 pub issue: WitnessIssue,
206}
207
208fn present_option<'de, T, D>(deserializer: D) -> Result<Option<T>, D::Error>
212where
213 T: Deserialize<'de>,
214 D: serde::Deserializer<'de>,
215{
216 T::deserialize(deserializer).map(Some)
217}
218
219#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
221#[serde(rename_all = "camelCase")]
222pub struct DecisionFacts {
223 #[serde(default)]
225 pub carrier: Option<String>,
226 #[serde(default)]
228 pub value_flow: Option<Vec<String>>,
229 #[serde(default)]
231 pub then: Option<Vec<String>>,
232 #[serde(default, rename = "else", deserialize_with = "present_option")]
238 pub else_: Option<Option<Vec<String>>>,
239 #[serde(default)]
240 pub early_exit_downstream: Option<Vec<String>>,
241 #[serde(default)]
242 pub loop_body: Option<Vec<String>>,
243 #[serde(default)]
244 pub default_kept: Option<Vec<DefaultKept>>,
245 #[serde(default)]
246 pub object_valued: Option<ObjectValued>,
247 #[serde(default)]
249 pub outcomes: Option<Outcomes>,
250 #[serde(default)]
252 pub selected: Option<Vec<String>>,
253}
254
255#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
256#[serde(rename_all = "camelCase")]
257pub struct DefaultKept {
258 pub write: String,
259 pub dependents: Vec<Dependent>,
260}
261
262#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
263#[serde(rename_all = "camelCase")]
264pub struct Dependent {
265 pub site: String,
266 pub label: String,
267 #[serde(default)]
268 pub strength: Option<Strength>,
269 #[serde(default, skip_serializing_if = "Option::is_none")]
272 pub requires_total: Option<Vec<String>>,
273}
274
275#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
276#[serde(rename_all = "camelCase")]
277pub struct ObjectValued {
278 pub only_a: Vec<String>,
279 pub only_b: Vec<String>,
280}
281
282#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
283#[serde(rename_all = "camelCase")]
284pub struct Outcomes {
285 #[serde(rename = "true")]
286 pub true_: Vec<String>,
287 #[serde(rename = "false")]
288 pub false_: Vec<String>,
289}
290
291#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
292#[serde(rename_all = "camelCase")]
293pub struct Site {
294 pub id: String,
295 pub file: String,
296 pub line: u32,
297 pub kind: String,
298 pub category: String,
299 pub classification: String,
300 pub owner: String,
301 #[serde(default)]
302 pub method: Option<String>,
303 pub bounds: Vec<Boundary>,
304 #[serde(default)]
307 pub direct_bounds: Vec<Boundary>,
308 #[serde(default)]
310 pub reached: Vec<String>,
311 pub covered_by: Vec<String>,
312 #[serde(default)]
313 pub object_valued_return: bool,
314 #[serde(default)]
316 pub unmodelled_shapes: Vec<String>,
317 #[serde(default)]
318 pub decision: Option<DecisionFacts>,
319 #[serde(default)]
321 pub derive: Vec<Dependent>,
322}
323
324#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
325#[serde(rename_all = "camelCase")]
326pub struct Facts {
327 pub schema: u32,
328 pub sites: Vec<Site>,
329 pub tests: Vec<TestFacts>,
330 #[serde(default)]
332 pub mocks_by_test_file: BTreeMap<String, BTreeMap<String, Vec<Boundary>>>,
333}
334
335#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
337#[serde(rename_all = "camelCase")]
338pub struct PragmaHint {
339 pub id: String,
340 #[serde(rename = "where")]
341 pub where_: String,
342 pub raw: String,
343 pub target: Option<PragmaTarget>,
344 pub candidate_sites: Vec<String>,
345 pub issue: Option<String>,
346 pub test: Option<String>,
347 pub assertion_source: Option<String>,
348 pub assertion_method: Option<String>,
349 pub witness: String,
350 pub witness_issue: Option<String>,
351}
352
353#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
354pub struct PragmaTarget {
355 pub file: String,
356 pub function: String,
357 pub snippet: Option<String>,
358 pub via: Option<String>,
360}
361
362#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
363#[serde(rename_all = "kebab-case")]
364pub enum HintValidation {
365 AnalyzerSupported,
366 Unresolved,
367 Invalid,
368}
369
370#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
371#[serde(rename_all = "camelCase")]
372pub struct PragmaCheck {
373 pub hint: PragmaHint,
374 pub origin: String,
375 pub validation: HintValidation,
376 pub reason: String,
377 #[serde(skip_serializing_if = "Option::is_none")]
378 pub strength: Option<Strength>,
379 #[serde(skip_serializing_if = "Vec::is_empty")]
381 pub observations: Vec<Observation>,
382}
383
384pub fn check_pragma_hints(facts: &Facts, hints: &[PragmaHint]) -> Vec<PragmaCheck> {
389 if hints.is_empty() {
390 return vec![];
391 }
392 let engine = Join {
393 sites: facts.sites.iter().map(|s| (s.id.as_str(), s)).collect(),
394 tests: facts.tests.iter().map(|t| (t.id.as_str(), t)).collect(),
395 facts,
396 resolved: BTreeMap::new(),
397 };
398 let mut assertions: BTreeMap<(&str, &str, &str), Vec<&Observation>> = BTreeMap::new();
399 for test in &facts.tests {
400 for ob in &test.observations {
401 if let (Some(source), Some(method)) = (&ob.assertion_source, &ob.assertion_method)
402 && ob.boundary != "pragma"
403 {
404 assertions
405 .entry((&test.id, source, method))
406 .or_default()
407 .push(ob);
408 }
409 }
410 }
411 hints
412 .iter()
413 .map(|hint| {
414 let mut result = PragmaCheck {
415 hint: hint.clone(),
416 origin: "user-suggested".into(),
417 validation: HintValidation::Unresolved,
418 reason: "connection-not-established".into(),
419 strength: None,
420 observations: vec![],
421 };
422 if let Some(issue) = &hint.issue {
423 result.reason = issue.clone();
424 if !matches!(
427 issue.as_str(),
428 "no-owning-passed-test" | "target-not-in-inventory"
429 ) {
430 result.validation = HintValidation::Invalid;
431 }
432 return result;
433 }
434 let [id] = hint.candidate_sites.as_slice() else {
435 result.validation = HintValidation::Invalid;
436 result.reason = "target-not-unique".into();
437 return result;
438 };
439 let Some(site) = engine.sites.get(id.as_str()) else {
440 result.validation = HintValidation::Invalid;
441 result.reason = "target-not-found".into();
442 return result;
443 };
444 if hint
445 .target
446 .as_ref()
447 .is_none_or(|target| target.file != site.file)
448 {
449 result.validation = HintValidation::Invalid;
450 result.reason = "target-file-mismatch".into();
451 return result;
452 }
453 if hint.witness != "passed" || hint.witness_issue.is_some() {
454 result.reason = hint
455 .witness_issue
456 .clone()
457 .unwrap_or_else(|| "missing-passed-witness".into());
458 return result;
459 }
460 let Some(test) = hint
461 .test
462 .as_ref()
463 .and_then(|id| engine.tests.get(id.as_str()))
464 else {
465 result.reason = "no-owning-passed-test".into();
466 return result;
467 };
468 if hint.assertion_source.as_ref().is_none_or(String::is_empty)
469 || hint.assertion_method.as_ref().is_none_or(String::is_empty)
470 {
471 result.reason = "missing-assertion-identity".into();
472 return result;
473 }
474 if !site.covered_by.contains(&test.id) {
475 result.reason = "target-not-reached-in-owning-test".into();
476 return result;
477 }
478 if site.kind != "effect" {
479 result.reason = "decision-hint-analysis-not-supported".into();
480 return result;
481 }
482 let selected = TestFacts {
483 id: test.id.clone(),
484 file: test.file.clone(),
485 observations: assertions
486 .get(&(
487 test.id.as_str(),
488 hint.assertion_source.as_deref().unwrap(),
489 hint.assertion_method.as_deref().unwrap(),
490 ))
491 .into_iter()
492 .flatten()
493 .map(|ob| (*ob).clone())
494 .collect(),
495 sinks: test.sinks.clone(),
496 rendered: test.rendered.clone(),
497 witness_issues: vec![],
498 };
499 if selected.observations.is_empty() {
500 result.reason = "assertion-operand-not-modelled".into();
501 return result;
502 }
503 let resolution = engine.resolve_effect_for(site, vec![&selected]);
504 if resolution.strength.is_some() && resolution.tests.contains(&test.id) {
505 let bounds = engine.bounds_for_test(site, &selected);
506 result.observations = selected
507 .observations
508 .iter()
509 .filter(|ob| {
510 engine.observation_hits(site, &selected, &bounds, ob)
511 && !(site.category == "log" && ob.pattern_shared)
512 })
513 .cloned()
514 .collect();
515 result.validation = HintValidation::AnalyzerSupported;
516 result.reason = "existing-effect-rules-support-this-assertion-link".into();
517 result.strength = resolution.strength;
518 }
519 result
520 })
521 .collect()
522}
523
524#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
529#[serde(rename_all = "lowercase")]
530pub enum Status {
531 Evident,
532 Presence,
533 Partial,
534 Unresolved,
535}
536
537#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
541pub enum ReasonKind {
542 #[serde(rename = "gap:not-reached")]
543 GapNotReached,
544 #[serde(rename = "gap:not-asserted")]
545 GapNotAsserted,
546 #[serde(rename = "gap:outcome-not-asserted")]
547 GapOutcomeNotAsserted,
548 #[serde(rename = "gap:value-not-asserted")]
549 GapValueNotAsserted,
550 #[serde(rename = "limit:operand-shape")]
551 LimitOperandShape,
552 #[serde(rename = "limit:internal-state")]
553 LimitInternalState,
554 #[serde(rename = "limit:undecidable")]
555 LimitUndecidable,
556 #[serde(rename = "limit:assertion-witness")]
557 LimitAssertionWitness,
558}
559
560impl ReasonKind {
561 pub fn is_limit(self) -> bool {
562 matches!(
563 self,
564 ReasonKind::LimitOperandShape
565 | ReasonKind::LimitInternalState
566 | ReasonKind::LimitUndecidable
567 | ReasonKind::LimitAssertionWitness
568 )
569 }
570}
571
572#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
573#[serde(rename_all = "camelCase")]
574pub struct Reason {
575 pub kind: ReasonKind,
576 #[serde(default, skip_serializing_if = "Option::is_none")]
577 pub detail: Option<String>,
578}
579
580#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
581#[serde(rename_all = "camelCase")]
582pub struct Resolution {
583 pub site: String,
584 pub status: Status,
585 #[serde(default, skip_serializing_if = "Option::is_none")]
586 pub strength: Option<Strength>,
587 #[serde(default, skip_serializing_if = "Option::is_none")]
588 pub reason: Option<Reason>,
589 pub covered_by: usize,
590 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
592 pub tests: BTreeSet<String>,
593 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
594 pub weak_only: bool,
595 #[serde(default, skip_serializing_if = "Option::is_none")]
596 pub stuck_true_caught: Option<bool>,
597 #[serde(default, skip_serializing_if = "Option::is_none")]
598 pub stuck_false_caught: Option<bool>,
599 #[serde(default, skip_serializing_if = "Option::is_none")]
600 pub absence_needed: Option<bool>,
601 #[serde(default, skip_serializing_if = "Option::is_none")]
602 pub value_observed: Option<bool>,
603 #[serde(default, skip_serializing_if = "Vec::is_empty")]
604 pub witness_issues: Vec<TestWitnessIssue>,
605}
606
607struct Join<'a> {
612 sites: BTreeMap<&'a str, &'a Site>,
613 tests: BTreeMap<&'a str, &'a TestFacts>,
614 facts: &'a Facts,
615 resolved: BTreeMap<String, Resolution>,
616}
617
618pub fn join(facts: &Facts) -> Vec<Resolution> {
622 let mut join = Join {
623 sites: facts.sites.iter().map(|s| (s.id.as_str(), s)).collect(),
624 tests: facts.tests.iter().map(|t| (t.id.as_str(), t)).collect(),
625 facts,
626 resolved: BTreeMap::new(),
627 };
628 for site in &facts.sites {
629 if site.kind == "effect" {
630 let r = join.resolve_effect(site);
631 join.resolved.insert(site.id.clone(), r);
632 }
633 }
634 join.resolve_decisions();
635 for site in &facts.sites {
636 if site.kind != "effect" {
637 continue;
638 }
639 let unresolved = join
640 .resolved
641 .get(&site.id)
642 .is_some_and(|r| r.status == Status::Unresolved);
643 if !unresolved || site.derive.is_empty() {
644 continue;
645 }
646 if let Some(derived) = join.derive_internal(site) {
647 join.resolved.insert(site.id.clone(), derived);
648 }
649 }
650 join.resolve_decisions();
651 facts
652 .sites
653 .iter()
654 .filter_map(|s| {
655 join.resolved
656 .get(&s.id)
657 .cloned()
658 .map(|r| join.with_witness_issues(s, r))
659 })
660 .collect()
661}
662
663impl<'a> Join<'a> {
664 fn resolve_decisions(&mut self) {
665 for site in &self.facts.sites {
666 if site.kind == "decision" {
667 let r = self.resolve_decision(site);
668 self.resolved.insert(site.id.clone(), r);
669 }
670 }
671 }
672
673 fn strength_of_sites(
677 &self,
678 ids: &[String],
679 within: Option<&BTreeSet<&str>>,
680 ) -> Option<Strength> {
681 let mut best = None;
682 for id in ids {
683 let Some(r) = self.resolved.get(id) else {
684 continue;
685 };
686 let Some(strength) = r.strength else {
687 continue;
688 };
689 if let Some(within) = within
690 && !r.tests.iter().any(|t| within.contains(t.as_str()))
691 {
692 continue;
693 }
694 best = stronger(best, Some(strength));
695 }
696 best
697 }
698
699 fn with_mocks(&self, site: &Site, test: &TestFacts, base: &[Boundary]) -> Vec<Boundary> {
701 let mut bounds = base.to_vec();
702 if let Some(per_site) = self.facts.mocks_by_test_file.get(&test.file)
703 && let Some(extra) = per_site.get(&site.id)
704 {
705 bounds.extend(extra.iter().cloned());
706 }
707 bounds
708 }
709
710 fn bounds_for_test(&self, site: &Site, test: &TestFacts) -> Vec<Boundary> {
714 let mut bounds = self.with_mocks(site, test, &site.bounds);
715 if (site.category == "return" || site.category == "callback-return")
716 && test.rendered.contains(&site.owner)
717 {
718 bounds.push(Boundary {
719 boundary: "dom".into(),
720 facet: None,
721 via: Some("rendered component".into()),
722 });
723 }
724 bounds
725 }
726
727 fn sink_hit(
729 &self,
730 test: &TestFacts,
731 site: &Site,
732 bounds: &[Boundary],
733 ob: &Observation,
734 ) -> bool {
735 for sink in &test.sinks {
736 let injected = bounds.iter().any(|b| {
737 b.boundary == format!("callback:{}", sink.param)
738 && match (&sink.member, &b.facet) {
739 (None, _) => true,
740 (Some(_), None) => false,
741 (Some(member), Some(facet)) => {
742 facet == member
743 || facet.starts_with(&format!("{member}."))
744 || facet.starts_with('*')
745 }
746 }
747 });
748 let log_sink = site.category == "log"
749 && sink.param == "logger"
750 && sink
751 .member
752 .as_deref()
753 .is_none_or(|m| Some(m) == site.method.as_deref());
754 let leaf = ob
756 .boundary
757 .strip_prefix(&format!("{}.", sink.sink))
758 .and_then(|rest| rest.split('.').next_back());
759 let same_sink = ob.boundary == sink.sink
760 || leaf
761 .is_some_and(|leaf| site.method.as_deref().is_none_or(|method| leaf == method));
762 let message_fits = site.category != "log"
764 || ob
765 .log_sites
766 .as_ref()
767 .is_none_or(|admitted| admitted.contains(&site.id));
768 if (injected || log_sink) && same_sink && message_fits {
769 return true;
770 }
771 }
772 false
773 }
774
775 fn resolve_effect(&self, site: &Site) -> Resolution {
776 let covering: Vec<&TestFacts> = site
777 .covered_by
778 .iter()
779 .filter_map(|id| self.tests.get(id.as_str()).copied())
780 .collect();
781 self.resolve_effect_for(site, covering)
782 }
783
784 fn resolve_effect_for(&self, site: &Site, covering: Vec<&TestFacts>) -> Resolution {
785 let any_extra = covering
788 .iter()
789 .any(|t| self.bounds_for_test(site, t).len() > site.bounds.len());
790 if site.bounds.iter().all(Boundary::internal) && site.reached.is_empty() && !any_extra {
791 return Resolution {
792 site: site.id.clone(),
793 status: Status::Unresolved,
794 strength: None,
795 reason: Some(Reason {
796 kind: ReasonKind::LimitInternalState,
797 detail: None,
798 }),
799 covered_by: site.covered_by.len(),
800 tests: BTreeSet::new(),
801 weak_only: false,
802 stuck_true_caught: None,
803 stuck_false_caught: None,
804 absence_needed: None,
805 value_observed: None,
806 witness_issues: vec![],
807 };
808 }
809 let mut best: Option<Strength> = None;
810 let mut tests = BTreeSet::new();
811 let mut strong_hit = false;
812 for test in &covering {
813 let bounds = self.bounds_for_test(site, test);
814 for ob in &test.observations {
815 if !self.observation_hits(site, test, &bounds, ob) {
816 continue;
817 }
818 if site.category == "log" && ob.pattern_shared {
820 continue;
821 }
822 best = stronger(best, Some(ob.strength));
823 tests.insert(test.id.clone());
824 if !ob.weak {
825 strong_hit = true;
826 }
827 }
828 }
829 let Some(mut best) = best else {
830 let boundaries: BTreeSet<&str> =
831 site.bounds.iter().map(|b| b.boundary.as_str()).collect();
832 let reason = if site.covered_by.is_empty() {
833 Reason {
834 kind: ReasonKind::GapNotReached,
835 detail: None,
836 }
837 } else if !site.unmodelled_shapes.is_empty() {
838 Reason {
839 kind: ReasonKind::LimitOperandShape,
840 detail: Some(site.unmodelled_shapes.join("; ")),
841 }
842 } else {
843 Reason {
844 kind: ReasonKind::GapNotAsserted,
845 detail: Some(boundaries.into_iter().collect::<Vec<_>>().join("|")),
846 }
847 };
848 return Resolution {
849 site: site.id.clone(),
850 status: Status::Unresolved,
851 strength: None,
852 reason: Some(reason),
853 covered_by: site.covered_by.len(),
854 tests: BTreeSet::new(),
855 weak_only: false,
856 stuck_true_caught: None,
857 stuck_false_caught: None,
858 absence_needed: None,
859 value_observed: None,
860 witness_issues: vec![],
861 };
862 };
863 if site.object_valued_return {
866 best = Strength::Presence;
867 }
868 Resolution {
869 site: site.id.clone(),
870 status: if best == Strength::Presence {
871 Status::Presence
872 } else {
873 Status::Evident
874 },
875 strength: Some(best),
876 reason: (best == Strength::Presence).then_some(Reason {
877 kind: ReasonKind::GapValueNotAsserted,
878 detail: None,
879 }),
880 covered_by: site.covered_by.len(),
881 tests,
882 weak_only: !strong_hit,
883 stuck_true_caught: None,
884 stuck_false_caught: None,
885 absence_needed: None,
886 value_observed: None,
887 witness_issues: vec![],
888 }
889 }
890
891 fn observation_hits(
894 &self,
895 site: &Site,
896 test: &TestFacts,
897 bounds: &[Boundary],
898 ob: &Observation,
899 ) -> bool {
900 if bounds.iter().any(|b| ob.matches(site, b)) || self.sink_hit(test, site, &site.bounds, ob)
901 {
902 return true;
903 }
904 site.reached.iter().any(|id| {
905 let Some(reached) = self.sites.get(id.as_str()) else {
906 return false;
907 };
908 if !reached.covered_by.contains(&test.id) {
909 return false;
910 }
911 let bounds = self.with_mocks(reached, test, &reached.direct_bounds);
912 bounds
913 .iter()
914 .any(|b| !b.internal() && ob.matches(reached, b))
915 || self.sink_hit(test, reached, &bounds, ob)
916 })
917 }
918
919 fn issue_reaches(
925 &self,
926 site: &Site,
927 test: &TestFacts,
928 ob: &Observation,
929 seen: &mut BTreeSet<String>,
930 ) -> bool {
931 if !seen.insert(site.id.clone()) {
932 return false;
933 }
934 if self.observation_hits(site, test, &self.bounds_for_test(site, test), ob) {
935 return true;
936 }
937 let mut edges = site.reached.clone();
938 for dep in &site.derive {
939 edges.push(dep.site.clone());
940 edges.extend(dep.requires_total.iter().flatten().cloned());
941 }
942 if let Some(d) = &site.decision {
943 edges.extend(d.carrier.iter().cloned());
944 for ids in [
945 &d.value_flow,
946 &d.then,
947 &d.early_exit_downstream,
948 &d.loop_body,
949 ] {
950 edges.extend(ids.iter().flatten().cloned());
951 }
952 edges.extend(d.else_.iter().flatten().flatten().cloned());
953 for entry in d.default_kept.iter().flatten() {
954 edges.push(entry.write.clone());
955 edges.extend(entry.dependents.iter().map(|d| d.site.clone()));
956 }
957 if let Some(o) = &d.object_valued {
958 edges.extend(o.only_a.iter().chain(&o.only_b).cloned());
959 }
960 }
961 edges.iter().any(|id| {
962 self.sites
963 .get(id.as_str())
964 .is_some_and(|s| self.issue_reaches(s, test, ob, seen))
965 })
966 }
967
968 fn with_witness_issues(&self, site: &Site, mut result: Resolution) -> Resolution {
972 for id in &site.covered_by {
973 let Some(test) = self.tests.get(id.as_str()) else {
974 continue;
975 };
976 for issue in &test.witness_issues {
977 let relevant = match &issue.observation {
978 None => issue.kind == WitnessIssueKind::CaptureUnavailable,
979 Some(ob) => self.issue_reaches(site, test, ob, &mut BTreeSet::new()),
980 };
981 if relevant {
982 result.witness_issues.push(TestWitnessIssue {
983 test: id.clone(),
984 issue: issue.clone(),
985 });
986 }
987 }
988 }
989 let untaken = site
990 .decision
991 .as_ref()
992 .and_then(|d| d.outcomes.as_ref())
993 .is_some_and(|o| {
994 (result.stuck_false_caught == Some(false) && o.true_.is_empty())
995 || (result.stuck_true_caught == Some(false) && o.false_.is_empty())
996 });
997 if result.status != Status::Evident
998 && !untaken
999 && result.reason.as_ref().is_some_and(|reason| {
1000 matches!(
1001 reason.kind,
1002 ReasonKind::GapNotAsserted
1003 | ReasonKind::GapOutcomeNotAsserted
1004 | ReasonKind::GapValueNotAsserted
1005 )
1006 })
1007 && result
1008 .witness_issues
1009 .iter()
1010 .any(|w| w.issue.kind.is_uncertain())
1011 {
1012 result.reason = Some(Reason {kind: ReasonKind::LimitAssertionWitness,
1013 detail: Some("Assertion evidence is unavailable or inconclusive; see witnessIssues. This is not proof that the test lacks an assertion.".into())});
1014 }
1015 result
1016 }
1017
1018 fn pinned(&self, took: &BTreeSet<&str>, targets: &[String]) -> bool {
1021 for id in took {
1022 let Some(test) = self.tests.get(id).copied() else {
1023 continue;
1024 };
1025 for ob in &test.observations {
1026 if !ob.negative && !ob.call_list {
1027 continue;
1028 }
1029 for target_id in targets {
1030 let Some(target) = self.sites.get(target_id.as_str()) else {
1031 continue;
1032 };
1033 let bounds = self.with_mocks(target, test, &target.bounds);
1034 if bounds
1035 .iter()
1036 .any(|b| !b.internal() && ob.matches(target, b))
1037 || self.sink_hit(test, target, &bounds, ob)
1038 {
1039 return true;
1040 }
1041 }
1042 }
1043 }
1044 false
1045 }
1046
1047 fn resolve_decision(&self, site: &Site) -> Resolution {
1048 let covering = site.covered_by.len();
1049 let empty = DecisionFacts::default();
1050 let d = site.decision.as_ref().unwrap_or(&empty);
1051 let unresolved = |reason: Reason, stuck: bool| Resolution {
1052 site: site.id.clone(),
1053 status: Status::Unresolved,
1054 strength: None,
1055 reason: Some(reason),
1056 covered_by: covering,
1057 tests: BTreeSet::new(),
1058 weak_only: false,
1059 stuck_true_caught: stuck.then_some(false),
1060 stuck_false_caught: stuck.then_some(false),
1061 absence_needed: stuck.then_some(false),
1062 value_observed: None,
1063 witness_issues: vec![],
1064 };
1065 if let Some(object_valued) = &d.object_valued {
1068 let only_a = self.strength_of_sites(&object_valued.only_a, None);
1069 let only_b = self.strength_of_sites(&object_valued.only_b, None);
1070 if only_a.is_some_and(Strength::caught) || only_b.is_some_and(Strength::caught) {
1071 return Resolution {
1072 site: site.id.clone(),
1073 status: Status::Evident,
1074 strength: Some(Strength::Value),
1075 reason: None,
1076 covered_by: covering,
1077 tests: BTreeSet::new(),
1078 weak_only: false,
1079 stuck_true_caught: Some(true),
1080 stuck_false_caught: Some(true),
1081 absence_needed: Some(false),
1082 value_observed: None,
1083 witness_issues: vec![],
1084 };
1085 }
1086 return unresolved(
1087 Reason {
1088 kind: ReasonKind::LimitUndecidable,
1089 detail: Some("object-valued branches with no branch-specific site".into()),
1090 },
1091 true,
1092 );
1093 }
1094 let has_shape = d.carrier.is_some()
1095 || d.value_flow.is_some()
1096 || d.then.is_some()
1097 || d.object_valued.is_some();
1098 if !has_shape {
1099 return unresolved(
1100 Reason {
1101 kind: ReasonKind::LimitUndecidable,
1102 detail: Some("decision context not found".into()),
1103 },
1104 false,
1105 );
1106 }
1107 let t_true: Option<BTreeSet<&str>> = d
1108 .outcomes
1109 .as_ref()
1110 .map(|o| o.true_.iter().map(String::as_str).collect());
1111 let t_false: Option<BTreeSet<&str>> = d
1112 .outcomes
1113 .as_ref()
1114 .map(|o| o.false_.iter().map(String::as_str).collect());
1115 let selected: Option<BTreeSet<&str>> = d
1116 .selected
1117 .as_ref()
1118 .map(|s| s.iter().map(String::as_str).collect());
1119 let mut then_s;
1120 let mut else_s = None;
1121 let mut value_observed = None;
1122 if let Some(carrier) = &d.carrier {
1123 let ids = [carrier.clone()];
1124 then_s = self.strength_of_sites(&ids, selected.as_ref().or(t_true.as_ref()));
1125 else_s = self.strength_of_sites(&ids, selected.as_ref().or(t_false.as_ref()));
1126 if selected.is_some() {
1127 value_observed = Some(
1128 self.strength_of_sites(&ids, None)
1129 .is_some_and(Strength::caught),
1130 );
1131 }
1132 } else if let Some(flow) = &d.value_flow {
1133 then_s = self.strength_of_sites(flow, selected.as_ref().or(t_true.as_ref()));
1134 else_s = self.strength_of_sites(flow, selected.as_ref().or(t_false.as_ref()));
1135 if selected.is_some() {
1136 value_observed = Some(
1137 self.strength_of_sites(flow, None)
1138 .is_some_and(Strength::caught),
1139 );
1140 }
1141 } else {
1142 let then_ids = d.then.clone().unwrap_or_default();
1143 then_s = self.strength_of_sites(&then_ids, t_true.as_ref());
1144 let else_ids = d.else_.clone().flatten();
1145 if let Some(else_ids) = &else_ids {
1146 else_s = self.strength_of_sites(else_ids, t_false.as_ref());
1147 }
1148 if !then_s.is_some_and(Strength::caught)
1151 && let (Some(took), Some(downstream)) = (&t_true, &d.early_exit_downstream)
1152 {
1153 let mut witnessed = self.pinned(took, downstream);
1154 if !witnessed {
1155 witnessed = took.iter().any(|id| {
1156 self.tests.get(id).is_some_and(|test| {
1157 test.observations.iter().any(|ob| {
1158 !ob.negative
1159 && ob.boundary == format!("return:{}", site.owner)
1160 && ob.strength.caught()
1161 })
1162 })
1163 });
1164 }
1165 if witnessed {
1166 then_s = Some(Strength::Value);
1167 }
1168 }
1169 if let Some(body) = &d.loop_body {
1172 if !then_s.is_some_and(Strength::caught)
1173 && let Some(took) = &t_true
1174 && self.pinned(took, body)
1175 {
1176 then_s = Some(Strength::Value);
1177 }
1178 if !else_s.is_some_and(Strength::caught)
1179 && let Some(took) = &t_false
1180 && self.pinned(took, body)
1181 {
1182 else_s = Some(Strength::Value);
1183 }
1184 }
1185 if else_ids.is_none()
1189 && !else_s.is_some_and(Strength::caught)
1190 && let (Some(t_false), Some(entries)) = (&t_false, &d.default_kept)
1191 && !t_false.is_empty()
1192 {
1193 for entry in entries {
1194 let kept = entry.dependents.iter().any(|dep| {
1195 self.resolved.get(&dep.site).is_some_and(|r| {
1196 r.strength.is_some_and(Strength::caught)
1197 && r.tests.iter().any(|t| t_false.contains(t.as_str()))
1198 })
1199 });
1200 if kept {
1201 else_s = Some(Strength::Value);
1202 break;
1203 }
1204 }
1205 }
1206 }
1207 let absence_needed = d.carrier.is_none() && d.then.is_some() && d.else_ == Some(None);
1208 let stuck_false_caught = then_s.is_some_and(Strength::caught);
1209 let absence_covered = absence_needed
1213 && then_s == Some(Strength::Total)
1214 && t_false.as_ref().is_none_or(|t| !t.is_empty());
1215 let stuck_true_caught = else_s.is_some_and(Strength::caught) || absence_covered;
1216 let status = match (stuck_false_caught, stuck_true_caught) {
1217 (true, true) => Status::Evident,
1218 (false, false) => Status::Unresolved,
1219 _ => Status::Partial,
1220 };
1221 let weakest = match (then_s, else_s) {
1222 (Some(a), Some(b)) => Some(a.min(b)),
1223 (a, b) => a.or(b),
1224 };
1225 let reason = (status != Status::Evident).then(|| {
1226 let mut missing = Vec::new();
1227 if !stuck_false_caught {
1228 missing.push("true");
1229 }
1230 if !stuck_true_caught {
1231 missing.push("false");
1232 }
1233 let untaken: Vec<&str> = missing
1234 .iter()
1235 .copied()
1236 .filter(|side| {
1237 let set = if *side == "true" { &t_true } else { &t_false };
1238 set.as_ref().is_some_and(|s| s.is_empty())
1239 })
1240 .collect();
1241 if covering == 0 {
1242 Reason {
1243 kind: ReasonKind::GapNotReached,
1244 detail: None,
1245 }
1246 } else if !untaken.is_empty() {
1247 Reason {
1248 kind: ReasonKind::GapOutcomeNotAsserted,
1249 detail: Some(format!(
1250 "no test takes the {} outcome",
1251 untaken.join(" or ")
1252 )),
1253 }
1254 } else if !site.unmodelled_shapes.is_empty() {
1255 Reason {
1256 kind: ReasonKind::LimitOperandShape,
1257 detail: Some(site.unmodelled_shapes.join("; ")),
1258 }
1259 } else {
1260 Reason {
1261 kind: ReasonKind::GapOutcomeNotAsserted,
1262 detail: Some(format!(
1263 "the {} outcome is taken but nothing asserts its effects",
1264 missing.join(" and ")
1265 )),
1266 }
1267 }
1268 });
1269 Resolution {
1270 site: site.id.clone(),
1271 status,
1272 strength: (status == Status::Evident).then_some(weakest).flatten(),
1273 reason,
1274 covered_by: covering,
1275 tests: BTreeSet::new(),
1276 weak_only: false,
1277 stuck_true_caught: Some(stuck_true_caught),
1278 stuck_false_caught: Some(stuck_false_caught),
1279 absence_needed: Some(absence_needed),
1280 value_observed,
1281 witness_issues: vec![],
1282 }
1283 }
1284
1285 fn derive_internal(&self, site: &Site) -> Option<Resolution> {
1288 let mut best = None;
1289 let mut tests = BTreeSet::new();
1290 for dep in &site.derive {
1291 if dep.site == site.id {
1292 continue;
1293 }
1294 if let Some(callbacks) = &dep.requires_total
1295 && !callbacks.iter().any(|id| {
1296 self.resolved
1297 .get(id)
1298 .is_some_and(|r| r.strength == Some(Strength::Total))
1299 })
1300 {
1301 continue;
1302 }
1303 let Some(dependent) = self.sites.get(dep.site.as_str()) else {
1304 continue;
1305 };
1306 let strength = match dep.strength {
1307 Some(strength) => Some(strength),
1308 None => {
1309 let r = self.resolved.get(&dep.site);
1310 match r {
1311 Some(r) if dependent.kind == "decision" => (r.status == Status::Evident
1314 || r.status == Status::Partial)
1315 .then_some(Strength::Value),
1316 Some(r) => r.strength,
1317 None => None,
1318 }
1319 }
1320 };
1321 let Some(strength) = strength else {
1322 continue;
1323 };
1324 let co_covered = site
1325 .covered_by
1326 .iter()
1327 .any(|t| dependent.covered_by.contains(t));
1328 if !co_covered {
1329 continue;
1330 }
1331 best = stronger(best, Some(strength.min(Strength::Value)));
1332 if let Some(r) = self.resolved.get(&dep.site) {
1333 for t in &r.tests {
1334 if site.covered_by.contains(t) {
1335 tests.insert(t.clone());
1336 }
1337 }
1338 }
1339 }
1340 let best = best?;
1341 Some(Resolution {
1342 site: site.id.clone(),
1343 status: if best == Strength::Presence {
1344 Status::Presence
1345 } else {
1346 Status::Evident
1347 },
1348 strength: Some(best),
1349 reason: (best == Strength::Presence).then_some(Reason {
1350 kind: ReasonKind::GapValueNotAsserted,
1351 detail: None,
1352 }),
1353 covered_by: site.covered_by.len(),
1354 tests,
1355 weak_only: false,
1356 stuck_true_caught: None,
1357 stuck_false_caught: None,
1358 absence_needed: None,
1359 value_observed: None,
1360 witness_issues: vec![],
1361 })
1362 }
1363}
1364
1365pub fn summary(sites: &[Site], resolutions: &[Resolution]) -> Summary {
1367 let by_id: BTreeMap<&str, &Resolution> =
1368 resolutions.iter().map(|r| (r.site.as_str(), r)).collect();
1369 let mut summary = Summary::default();
1370 for site in sites {
1371 if site.classification != "contractual" {
1372 continue;
1373 }
1374 let Some(r) = by_id.get(site.id.as_str()) else {
1375 continue;
1376 };
1377 summary.contractual += 1;
1378 match r.status {
1379 Status::Evident => summary.evident += 1,
1380 Status::Partial => summary.partial += 1,
1381 Status::Presence => summary.presence += 1,
1382 Status::Unresolved => summary.unresolved += 1,
1383 }
1384 if let Some(reason) = &r.reason {
1385 if reason.kind.is_limit() {
1386 summary.limits += 1;
1387 } else if r.status != Status::Evident {
1388 summary.gaps += 1;
1389 }
1390 }
1391 }
1392 summary
1393}
1394
1395#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1396#[serde(rename_all = "camelCase")]
1397pub struct Summary {
1398 pub contractual: usize,
1399 pub evident: usize,
1400 pub partial: usize,
1401 pub presence: usize,
1402 pub unresolved: usize,
1403 pub gaps: usize,
1404 pub limits: usize,
1405}
1406
1407#[cfg(test)]
1408mod tests {
1409 use super::*;
1410
1411 fn site(id: &str, category: &str, bounds: Vec<Boundary>, covered: &[&str]) -> Site {
1412 Site {
1413 id: id.into(),
1414 file: "src/a.ts".into(),
1415 line: 1,
1416 kind: "effect".into(),
1417 category: category.into(),
1418 classification: "contractual".into(),
1419 owner: "handler".into(),
1420 method: None,
1421 bounds: bounds.clone(),
1422 direct_bounds: bounds,
1423 reached: vec![],
1424 covered_by: covered.iter().map(|s| (*s).into()).collect(),
1425 object_valued_return: false,
1426 unmodelled_shapes: vec![],
1427 decision: None,
1428 derive: vec![],
1429 }
1430 }
1431
1432 fn boundary(name: &str) -> Boundary {
1433 Boundary {
1434 boundary: name.into(),
1435 facet: None,
1436 via: None,
1437 }
1438 }
1439
1440 fn observation(boundary: &str, strength: Strength) -> Observation {
1441 Observation {
1442 boundary: boundary.into(),
1443 facet: None,
1444 strength,
1445 where_: None,
1446 assertion_source: None,
1447 assertion_method: None,
1448 negative: false,
1449 call_list: false,
1450 weak: false,
1451 log_sites: None,
1452 pattern_shared: false,
1453 }
1454 }
1455
1456 fn test(id: &str, observations: Vec<Observation>) -> TestFacts {
1457 TestFacts {
1458 id: id.into(),
1459 file: "tests/a.test.ts".into(),
1460 observations,
1461 sinks: vec![],
1462 rendered: vec![],
1463 witness_issues: vec![],
1464 }
1465 }
1466
1467 fn facts(sites: Vec<Site>, tests: Vec<TestFacts>) -> Facts {
1468 Facts {
1469 schema: 1,
1470 sites,
1471 tests,
1472 mocks_by_test_file: BTreeMap::new(),
1473 }
1474 }
1475
1476 fn rejected(kind: WitnessIssueKind, target: Option<&str>) -> WitnessIssue {
1477 WitnessIssue {
1478 kind,
1479 source: Some("tests/a.test.ts:7:3".into()),
1480 operation: Some("equal".into()),
1481 observation: target.map(|target| observation(target, Strength::Total)),
1482 }
1483 }
1484
1485 fn pragma_hint() -> PragmaHint {
1486 PragmaHint {
1487 id: "hint-1".into(),
1488 where_: "tests/a.test.ts:6:3".into(),
1489 raw: "// observes: src/a.ts#handler return value".into(),
1490 target: Some(PragmaTarget {
1491 file: "src/a.ts".into(),
1492 function: "handler".into(),
1493 snippet: Some("return value".into()),
1494 via: None,
1495 }),
1496 candidate_sites: vec!["S1".into()],
1497 issue: None,
1498 test: Some("T1".into()),
1499 assertion_source: Some("tests/a.test.ts:7:3".into()),
1500 assertion_method: Some("equal".into()),
1501 witness: "passed".into(),
1502 witness_issue: None,
1503 }
1504 }
1505
1506 #[test]
1507 fn pragma_hints_use_only_the_named_assertion_and_never_change_join_credit() {
1508 let hint = pragma_hint();
1509 let mut wanted = observation("return:handler", Strength::Value);
1510 wanted.assertion_source = hint.assertion_source.clone();
1511 wanted.assertion_method = hint.assertion_method.clone();
1512 let mut other = wanted.clone();
1513 other.assertion_source = Some("tests/a.test.ts:9:3".into());
1514 let f = facts(
1515 vec![site(
1516 "S1",
1517 "return",
1518 vec![boundary("return:handler")],
1519 &["T1"],
1520 )],
1521 vec![test("T1", vec![wanted.clone(), other.clone()])],
1522 );
1523 let before = join(&f);
1524 let checked = check_pragma_hints(&f, std::slice::from_ref(&hint));
1525 assert_eq!(checked[0].validation, HintValidation::AnalyzerSupported);
1526 assert_eq!(checked[0].origin, "user-suggested");
1527 assert_eq!(checked[0].strength, Some(Strength::Value));
1528 assert_eq!(checked[0].observations, vec![wanted.clone()]);
1529 assert_eq!(join(&f), before);
1530
1531 let mut unrelated = f.clone();
1532 unrelated.tests[0].observations[0].boundary = "return:other".into();
1533 assert_eq!(
1534 join(&unrelated)[0].status,
1535 Status::Evident,
1536 "a different assertion still checks it"
1537 );
1538 assert_eq!(
1539 check_pragma_hints(&unrelated, std::slice::from_ref(&hint))[0].validation,
1540 HintValidation::Unresolved,
1541 "cannot borrow that assertion's credit"
1542 );
1543
1544 let mut other_test = f.clone();
1545 other_test.sites[0].covered_by = vec!["T2".into()];
1546 other_test.tests.push(test("T2", vec![wanted]));
1547 assert_eq!(
1548 check_pragma_hints(&other_test, std::slice::from_ref(&hint))[0].reason,
1549 "target-not-reached-in-owning-test"
1550 );
1551
1552 for issue in [
1553 "call-failed",
1554 "mixed-call-outcomes",
1555 "call-incomplete",
1556 "call-not-recorded",
1557 "capture-unavailable",
1558 ] {
1559 let mut rejected = hint.clone();
1560 rejected.witness_issue = Some(issue.into());
1561 rejected.witness = "unavailable".into();
1562 let check = check_pragma_hints(&f, &[rejected]).remove(0);
1563 assert_eq!(check.validation, HintValidation::Unresolved);
1564 assert_eq!(check.reason, issue);
1565 assert_eq!(check.strength, None);
1566 }
1567 let mut ambiguous = hint.clone();
1568 ambiguous.candidate_sites.push("S2".into());
1569 assert_eq!(
1570 check_pragma_hints(&f, &[ambiguous])[0].validation,
1571 HintValidation::Invalid
1572 );
1573 let mut no_identity = hint;
1574 no_identity.assertion_source = None;
1575 assert_eq!(
1576 check_pragma_hints(&f, &[no_identity])[0].reason,
1577 "missing-assertion-identity"
1578 );
1579 }
1580
1581 #[test]
1582 fn pragma_hints_preserve_presence_strength_and_do_not_derive_internal_credit() {
1583 let hint = pragma_hint();
1584 let mut ob = observation("return:handler", Strength::Presence);
1585 ob.assertion_source = hint.assertion_source.clone();
1586 ob.assertion_method = hint.assertion_method.clone();
1587 let mut f = facts(
1588 vec![site(
1589 "S1",
1590 "return",
1591 vec![boundary("return:handler")],
1592 &["T1"],
1593 )],
1594 vec![test("T1", vec![ob])],
1595 );
1596 let check = check_pragma_hints(&f, std::slice::from_ref(&hint)).remove(0);
1597 assert_eq!(check.validation, HintValidation::AnalyzerSupported);
1598 assert_eq!(check.strength, Some(Strength::Presence));
1599 f.sites[0].bounds = vec![boundary("internal")];
1600 assert_eq!(
1601 check_pragma_hints(&f, std::slice::from_ref(&hint))[0].validation,
1602 HintValidation::Unresolved
1603 );
1604 f.sites[0].kind = "decision".into();
1605 assert_eq!(
1606 check_pragma_hints(&f, &[hint])[0].reason,
1607 "decision-hint-analysis-not-supported"
1608 );
1609 }
1610
1611 #[test]
1612 fn unavailable_assertion_evidence_is_a_limit_without_changing_coverage_or_credit() {
1613 let mut f = facts(
1614 vec![site(
1615 "S",
1616 "return",
1617 vec![boundary("return:handler")],
1618 &["T"],
1619 )],
1620 vec![test("T", vec![])],
1621 );
1622 let before = join(&f)[0].clone();
1623 f.tests[0]
1624 .witness_issues
1625 .push(rejected(WitnessIssueKind::CaptureUnavailable, None));
1626 let after = &join(&f)[0];
1627 assert_eq!(
1628 after.reason.as_ref().unwrap().kind,
1629 ReasonKind::LimitAssertionWitness
1630 );
1631 assert_eq!(after.status, before.status);
1632 assert_eq!(after.strength, before.strength);
1633 assert_eq!(after.covered_by, before.covered_by);
1634 assert_eq!(after.tests, before.tests);
1635 assert_eq!(after.witness_issues[0].test, "T");
1636 assert_eq!(summary(&f.sites, &join(&f)).limits, 1);
1637 assert_eq!(summary(&f.sites, &join(&f)).gaps, 0);
1638 }
1639
1640 #[test]
1641 fn witness_limit_kinds_and_successful_evidence_are_not_conflated() {
1642 for kind in [
1643 WitnessIssueKind::CallNotRecorded,
1644 WitnessIssueKind::CallIncomplete,
1645 WitnessIssueKind::MixedCallOutcomes,
1646 WitnessIssueKind::UninstrumentedObservation,
1647 WitnessIssueKind::CallFailed,
1648 ] {
1649 let mut f = facts(
1650 vec![site(
1651 "S",
1652 "return",
1653 vec![boundary("return:handler")],
1654 &["T"],
1655 )],
1656 vec![test("T", vec![])],
1657 );
1658 f.tests[0]
1659 .witness_issues
1660 .push(rejected(kind, Some("return:handler")));
1661 let result = join(&f);
1662 assert_eq!(
1663 result[0].reason.as_ref().unwrap().kind,
1664 if kind == WitnessIssueKind::CallFailed {
1665 ReasonKind::GapNotAsserted
1666 } else {
1667 ReasonKind::LimitAssertionWitness
1668 }
1669 );
1670 assert_eq!(result[0].witness_issues[0].issue.kind, kind);
1671 assert!(result[0].strength.is_none());
1672 f.tests[0]
1673 .observations
1674 .push(observation("return:handler", Strength::Value));
1675 let result = join(&f);
1676 assert_eq!(result[0].status, Status::Evident);
1677 assert_eq!(result[0].strength, Some(Strength::Value));
1678 assert!(result[0].reason.is_none());
1679 }
1680 }
1681
1682 #[test]
1683 fn unrelated_tests_boundaries_and_known_execution_gaps_do_not_become_witness_limits() {
1684 let mut f = facts(
1685 vec![
1686 site(
1687 "covered",
1688 "return",
1689 vec![boundary("return:handler")],
1690 &["T"],
1691 ),
1692 site("uncovered", "return", vec![boundary("return:other")], &[]),
1693 ],
1694 vec![test("T", vec![]), test("foreign", vec![])],
1695 );
1696 f.tests[0].witness_issues.push(rejected(
1697 WitnessIssueKind::CallNotRecorded,
1698 Some("return:unrelated"),
1699 ));
1700 f.tests[1]
1701 .witness_issues
1702 .push(rejected(WitnessIssueKind::CaptureUnavailable, None));
1703 let r = join(&f);
1704 assert_eq!(
1705 r[0].reason.as_ref().unwrap().kind,
1706 ReasonKind::GapNotAsserted
1707 );
1708 assert_eq!(
1709 r[1].reason.as_ref().unwrap().kind,
1710 ReasonKind::GapNotReached
1711 );
1712 assert!(r.iter().all(|r| r.witness_issues.is_empty()));
1713 }
1714
1715 #[test]
1716 fn decision_dependencies_retain_witness_uncertainty_without_inventing_taken_outcomes() {
1717 let mut d = site("D", "condition", vec![], &["T"]);
1718 d.kind = "decision".into();
1719 d.decision = Some(DecisionFacts {
1720 then: Some(vec!["S".into()]),
1721 else_: Some(None),
1722 outcomes: Some(Outcomes {
1723 true_: vec!["T".into()],
1724 false_: vec!["T".into()],
1725 }),
1726 ..Default::default()
1727 });
1728 let mut f = facts(
1729 vec![
1730 d,
1731 site("S", "return", vec![boundary("return:handler")], &["T"]),
1732 ],
1733 vec![test("T", vec![])],
1734 );
1735 f.tests[0].witness_issues.push(rejected(
1736 WitnessIssueKind::CallNotRecorded,
1737 Some("return:handler"),
1738 ));
1739 let r = join(&f);
1740 assert_eq!(
1741 r[0].reason.as_ref().unwrap().kind,
1742 ReasonKind::LimitAssertionWitness
1743 );
1744 assert_eq!(r[0].stuck_false_caught, Some(false));
1745 assert_eq!(r[0].stuck_true_caught, Some(false));
1746 f.sites[0]
1747 .decision
1748 .as_mut()
1749 .unwrap()
1750 .outcomes
1751 .as_mut()
1752 .unwrap()
1753 .false_
1754 .clear();
1755 let r = join(&f);
1756 assert_eq!(
1757 r[0].reason.as_ref().unwrap().kind,
1758 ReasonKind::GapOutcomeNotAsserted
1759 );
1760 assert!(!r[0].witness_issues.is_empty());
1761 }
1762
1763 #[test]
1764 fn witness_provenance_crosses_cyclic_derivations_without_supplying_strength() {
1765 let mut s = site("S", "return", vec![boundary("return:start")], &["T"]);
1766 s.derive.push(Dependent {
1767 site: "end".into(),
1768 label: "flow".into(),
1769 strength: None,
1770 requires_total: None,
1771 });
1772 let mut end = site("end", "return", vec![boundary("return:end")], &["T"]);
1773 end.reached.push("S".into());
1774 let mut f = facts(vec![s, end], vec![test("T", vec![])]);
1775 f.tests[0].witness_issues.push(rejected(
1776 WitnessIssueKind::CallIncomplete,
1777 Some("return:end"),
1778 ));
1779 let result = join(&f);
1780 assert!(
1781 result
1782 .iter()
1783 .all(|r| r.reason.as_ref().unwrap().kind == ReasonKind::LimitAssertionWitness)
1784 );
1785 assert!(result.iter().all(|r| r.strength.is_none()));
1786 f.tests[0].witness_issues[0]
1787 .observation
1788 .as_mut()
1789 .unwrap()
1790 .boundary = "return:unrelated".into();
1791 assert!(join(&f).iter().all(|r| r.witness_issues.is_empty()));
1792 }
1793
1794 #[test]
1795 fn witness_issues_round_trip_and_reject_unknown_kinds() {
1796 let issue = rejected(WitnessIssueKind::CallNotRecorded, Some("return:handler"));
1797 let json = serde_json::to_value(&issue).unwrap();
1798 assert_eq!(
1799 serde_json::from_value::<WitnessIssue>(json.clone()).unwrap(),
1800 issue
1801 );
1802 let mut invalid = json;
1803 invalid["kind"] = serde_json::json!("invented-witness");
1804 assert!(serde_json::from_value::<WitnessIssue>(invalid).is_err());
1805 let mut legacy = serde_json::to_value(test("T", vec![])).unwrap();
1806 legacy.as_object_mut().unwrap().remove("witnessIssues");
1807 assert!(
1808 serde_json::from_value::<TestFacts>(legacy)
1809 .unwrap()
1810 .witness_issues
1811 .is_empty()
1812 );
1813 }
1814
1815 #[test]
1816 fn an_assertion_on_the_return_makes_the_return_site_evident() {
1817 let f = facts(
1818 vec![site(
1819 "S1",
1820 "return",
1821 vec![boundary("return:handler")],
1822 &["T1"],
1823 )],
1824 vec![test(
1825 "T1",
1826 vec![observation("return:handler", Strength::Total)],
1827 )],
1828 );
1829 let r = join(&f);
1830 assert_eq!(r[0].status, Status::Evident);
1831 assert_eq!(r[0].strength, Some(Strength::Total));
1832 assert!(r[0].reason.is_none());
1833 }
1834
1835 #[test]
1836 fn a_site_no_test_reaches_is_a_gap_not_a_limit() {
1837 let f = facts(
1838 vec![site("S1", "return", vec![boundary("return:handler")], &[])],
1839 vec![],
1840 );
1841 let r = join(&f);
1842 assert_eq!(r[0].status, Status::Unresolved);
1843 assert_eq!(
1844 r[0].reason.as_ref().unwrap().kind,
1845 ReasonKind::GapNotReached
1846 );
1847 }
1848
1849 #[test]
1850 fn an_untraceable_operand_makes_it_a_limit_rather_than_a_gap() {
1851 let mut s = site("S1", "return", vec![boundary("return:handler")], &["T1"]);
1852 s.unmodelled_shapes = vec!["blogsTried(admin) [localfn:blogsTried]".into()];
1853 let f = facts(vec![s], vec![test("T1", vec![])]);
1854 let r = join(&f);
1855 assert_eq!(
1856 r[0].reason.as_ref().unwrap().kind,
1857 ReasonKind::LimitOperandShape
1858 );
1859 assert!(r[0].reason.as_ref().unwrap().detail.is_some());
1860 }
1861
1862 #[test]
1863 fn an_effect_with_no_boundary_is_an_internal_state_limit() {
1864 let f = facts(
1865 vec![site(
1866 "S1",
1867 "state-write",
1868 vec![boundary("internal")],
1869 &["T1"],
1870 )],
1871 vec![test(
1872 "T1",
1873 vec![observation("return:handler", Strength::Total)],
1874 )],
1875 );
1876 let r = join(&f);
1877 assert_eq!(
1878 r[0].reason.as_ref().unwrap().kind,
1879 ReasonKind::LimitInternalState
1880 );
1881 }
1882
1883 #[test]
1884 fn a_presence_only_observation_asks_for_a_stronger_matcher() {
1885 let f = facts(
1886 vec![site(
1887 "S1",
1888 "return",
1889 vec![boundary("return:handler")],
1890 &["T1"],
1891 )],
1892 vec![test(
1893 "T1",
1894 vec![observation("return:handler", Strength::Presence)],
1895 )],
1896 );
1897 let r = join(&f);
1898 assert_eq!(r[0].status, Status::Presence);
1899 assert_eq!(
1900 r[0].reason.as_ref().unwrap().kind,
1901 ReasonKind::GapValueNotAsserted
1902 );
1903 }
1904
1905 #[test]
1906 fn both_outcomes_asserted_makes_a_decision_evident_at_the_weaker_strength() {
1907 let mut decision = site("D1", "condition", vec![], &["T1", "T2"]);
1908 decision.kind = "decision".into();
1909 decision.decision = Some(DecisionFacts {
1910 then: Some(vec!["S1".into()]),
1911 else_: Some(Some(vec!["S2".into()])),
1912 outcomes: Some(Outcomes {
1913 true_: vec!["T1".into()],
1914 false_: vec!["T2".into()],
1915 }),
1916 ..Default::default()
1917 });
1918 let f = facts(
1919 vec![
1920 site("S1", "return", vec![boundary("return:handler")], &["T1"]),
1921 site("S2", "return", vec![boundary("return:other")], &["T2"]),
1922 decision,
1923 ],
1924 vec![
1925 test("T1", vec![observation("return:handler", Strength::Total)]),
1926 test("T2", vec![observation("return:other", Strength::Value)]),
1927 ],
1928 );
1929 let r = join(&f);
1930 let d = r.iter().find(|r| r.site == "D1").unwrap();
1931 assert_eq!(d.status, Status::Evident);
1932 assert_eq!(d.strength, Some(Strength::Value));
1933 }
1934
1935 #[test]
1936 fn an_outcome_no_test_takes_is_reported_as_that_gap() {
1937 let mut decision = site("D1", "condition", vec![], &["T1"]);
1938 decision.kind = "decision".into();
1939 decision.decision = Some(DecisionFacts {
1940 then: Some(vec!["S1".into()]),
1941 else_: Some(Some(vec!["S2".into()])),
1942 outcomes: Some(Outcomes {
1943 true_: vec!["T1".into()],
1944 false_: vec![],
1945 }),
1946 ..Default::default()
1947 });
1948 let f = facts(
1949 vec![
1950 site("S1", "return", vec![boundary("return:handler")], &["T1"]),
1951 site("S2", "return", vec![boundary("return:other")], &[]),
1952 decision,
1953 ],
1954 vec![test(
1955 "T1",
1956 vec![observation("return:handler", Strength::Total)],
1957 )],
1958 );
1959 let r = join(&f);
1960 let d = r.iter().find(|r| r.site == "D1").unwrap();
1961 assert_eq!(d.status, Status::Partial);
1962 let reason = d.reason.as_ref().unwrap();
1963 assert_eq!(reason.kind, ReasonKind::GapOutcomeNotAsserted);
1964 assert_eq!(
1965 reason.detail.as_deref(),
1966 Some("no test takes the false outcome")
1967 );
1968 }
1969
1970 #[test]
1971 fn a_total_assertion_covers_the_absence_of_an_empty_else() {
1972 let mut decision = site("D1", "condition", vec![], &["T1", "T2"]);
1973 decision.kind = "decision".into();
1974 decision.decision = Some(DecisionFacts {
1975 then: Some(vec!["S1".into()]),
1976 else_: Some(None),
1977 outcomes: Some(Outcomes {
1978 true_: vec!["T1".into()],
1979 false_: vec!["T2".into()],
1980 }),
1981 ..Default::default()
1982 });
1983 let f = facts(
1984 vec![
1985 site("S1", "io-call", vec![boundary("client-message")], &["T1"]),
1986 decision,
1987 ],
1988 vec![
1989 test("T1", vec![observation("client-message", Strength::Total)]),
1990 test("T2", vec![]),
1991 ],
1992 );
1993 let r = join(&f);
1994 let d = r.iter().find(|r| r.site == "D1").unwrap();
1995 assert_eq!(d.status, Status::Evident);
1996 assert_eq!(d.absence_needed, Some(true));
1997 }
1998
1999 #[test]
2000 fn an_empty_else_with_no_false_test_is_not_absence_covered() {
2001 let mut decision = site("D1", "condition", vec![], &["T1"]);
2002 decision.kind = "decision".into();
2003 decision.decision = Some(DecisionFacts {
2004 then: Some(vec!["S1".into()]),
2005 else_: Some(None),
2006 outcomes: Some(Outcomes {
2007 true_: vec!["T1".into()],
2008 false_: vec![],
2009 }),
2010 ..Default::default()
2011 });
2012 let f = facts(
2013 vec![
2014 site("S1", "io-call", vec![boundary("client-message")], &["T1"]),
2015 decision,
2016 ],
2017 vec![test(
2018 "T1",
2019 vec![observation("client-message", Strength::Total)],
2020 )],
2021 );
2022 let r = join(&f);
2023 let d = r.iter().find(|r| r.site == "D1").unwrap();
2024 assert_eq!(d.status, Status::Partial);
2025 }
2026
2027 #[test]
2028 fn a_negative_assertion_witnesses_an_early_exit() {
2029 let mut decision = site("D1", "condition", vec![], &["T1", "T2"]);
2030 decision.kind = "decision".into();
2031 decision.decision = Some(DecisionFacts {
2032 then: Some(vec!["S1".into()]),
2033 else_: Some(Some(vec!["S2".into()])),
2034 early_exit_downstream: Some(vec!["S2".into()]),
2035 outcomes: Some(Outcomes {
2036 true_: vec!["T1".into()],
2037 false_: vec!["T2".into()],
2038 }),
2039 ..Default::default()
2040 });
2041 let mut negative = observation("client-message", Strength::Presence);
2043 negative.negative = true;
2044 let f = facts(
2045 vec![
2046 site("S1", "return", vec![boundary("internal")], &["T1"]),
2047 site("S2", "io-call", vec![boundary("client-message")], &["T2"]),
2048 decision,
2049 ],
2050 vec![
2051 test("T1", vec![negative]),
2052 test("T2", vec![observation("client-message", Strength::Total)]),
2053 ],
2054 );
2055 let r = join(&f);
2056 let d = r.iter().find(|r| r.site == "D1").unwrap();
2057 assert_eq!(d.stuck_false_caught, Some(true));
2058 assert_eq!(d.status, Status::Evident);
2059 }
2060
2061 #[test]
2062 fn a_call_list_assertion_witnesses_loop_control() {
2063 let mut decision = site("D1", "condition", vec![], &["T1", "T2"]);
2064 decision.kind = "decision".into();
2065 decision.decision = Some(DecisionFacts {
2066 then: Some(vec![]),
2067 else_: Some(Some(vec![])),
2068 loop_body: Some(vec!["S1".into()]),
2069 outcomes: Some(Outcomes {
2070 true_: vec!["T1".into()],
2071 false_: vec!["T2".into()],
2072 }),
2073 ..Default::default()
2074 });
2075 let mut call_list = observation("callback:admin", Strength::Value);
2076 call_list.call_list = true;
2077 let f = facts(
2078 vec![
2079 site(
2080 "S1",
2081 "external-call",
2082 vec![boundary("callback:admin")],
2083 &["T1", "T2"],
2084 ),
2085 decision,
2086 ],
2087 vec![
2088 test("T1", vec![call_list.clone()]),
2089 test("T2", vec![call_list]),
2090 ],
2091 );
2092 let r = join(&f);
2093 let d = r.iter().find(|r| r.site == "D1").unwrap();
2094 assert_eq!(d.status, Status::Evident);
2095 }
2096
2097 #[test]
2098 fn a_dense_channel_pattern_pins_only_the_sites_it_admits() {
2099 let mut log_a = site("S1", "log", vec![boundary("stdout")], &["T1"]);
2100 log_a.method = Some("log".into());
2101 let mut log_b = site("S2", "log", vec![boundary("stdout")], &["T1"]);
2102 log_b.method = Some("log".into());
2103 let mut ob = observation("stdout", Strength::Value);
2104 ob.log_sites = Some(vec!["S1".into()]);
2105 let f = facts(vec![log_a, log_b], vec![test("T1", vec![ob])]);
2106 let r = join(&f);
2107 assert_eq!(r[0].status, Status::Evident);
2108 assert_eq!(r[1].status, Status::Unresolved);
2109 }
2110
2111 #[test]
2112 fn a_pattern_several_log_sites_share_pins_none_of_them() {
2113 let mut log_a = site("S1", "log", vec![boundary("stdout")], &["T1"]);
2114 log_a.method = Some("log".into());
2115 let mut log_b = site("S2", "log", vec![boundary("stdout")], &["T1"]);
2116 log_b.method = Some("log".into());
2117 let mut ob = observation("stdout", Strength::Value);
2118 ob.log_sites = Some(vec!["S1".into(), "S2".into()]);
2119 ob.pattern_shared = true;
2120 let f = facts(vec![log_a, log_b], vec![test("T1", vec![ob])]);
2121 let r = join(&f);
2122 assert_eq!(r[0].status, Status::Unresolved);
2123 assert_eq!(r[1].status, Status::Unresolved);
2124 }
2125
2126 #[test]
2127 fn a_timer_cancellation_candidate_requires_total_callback_evidence() {
2128 for (callback_strength, expected) in [
2129 (Strength::Presence, Status::Unresolved),
2130 (Strength::Value, Status::Unresolved),
2131 (Strength::Total, Status::Evident),
2132 ] {
2133 let mut cancel = site("cancel", "schedule", vec![boundary("internal")], &["T1"]);
2134 cancel.derive = vec![Dependent {
2135 site: "timer".into(),
2136 label: "cancelled timer with total sink".into(),
2137 strength: Some(Strength::Value),
2138 requires_total: Some(vec!["callback".into()]),
2139 }];
2140 let f = facts(
2141 vec![
2142 cancel,
2143 site("timer", "schedule", vec![boundary("internal")], &["T1"]),
2144 site(
2145 "callback",
2146 "return",
2147 vec![boundary("return:callback")],
2148 &["T1"],
2149 ),
2150 ],
2151 vec![test(
2152 "T1",
2153 vec![observation("return:callback", callback_strength)],
2154 )],
2155 );
2156 let r = join(&f);
2157 assert_eq!(
2158 r.iter().find(|r| r.site == "cancel").unwrap().status,
2159 expected
2160 );
2161 }
2162 }
2163
2164 #[test]
2165 fn empty_or_unknown_timer_callback_candidates_do_not_supply_evidence() {
2166 for callbacks in [vec![], vec!["missing".into()]] {
2167 let mut cancel = site("cancel", "schedule", vec![boundary("internal")], &["T1"]);
2168 cancel.derive = vec![Dependent {
2169 site: "timer".into(),
2170 label: "cancelled timer with total sink".into(),
2171 strength: Some(Strength::Value),
2172 requires_total: Some(callbacks),
2173 }];
2174 let f = facts(
2175 vec![
2176 cancel,
2177 site("timer", "schedule", vec![boundary("internal")], &["T1"]),
2178 ],
2179 vec![test("T1", vec![])],
2180 );
2181 assert_eq!(join(&f)[0].status, Status::Unresolved);
2182 }
2183 }
2184
2185 #[test]
2186 fn an_internal_write_is_derived_through_its_dependent_capped_at_value() {
2187 let mut write = site("S1", "state-write", vec![boundary("internal")], &["T1"]);
2188 write.derive = vec![Dependent {
2189 site: "S2".into(),
2190 label: "read this.ready".into(),
2191 strength: None,
2192 requires_total: None,
2193 }];
2194 let f = facts(
2195 vec![
2196 write,
2197 site("S2", "return", vec![boundary("return:handler")], &["T1"]),
2198 ],
2199 vec![test(
2200 "T1",
2201 vec![observation("return:handler", Strength::Total)],
2202 )],
2203 );
2204 let r = join(&f);
2205 let derived = r.iter().find(|r| r.site == "S1").unwrap();
2206 assert_eq!(derived.status, Status::Evident);
2207 assert_eq!(derived.strength, Some(Strength::Value));
2208 }
2209
2210 #[test]
2211 fn a_dependent_no_shared_test_covers_derives_nothing() {
2212 let mut write = site("S1", "state-write", vec![boundary("internal")], &["T1"]);
2213 write.derive = vec![Dependent {
2214 site: "S2".into(),
2215 label: "read this.ready".into(),
2216 strength: None,
2217 requires_total: None,
2218 }];
2219 let f = facts(
2220 vec![
2221 write,
2222 site("S2", "return", vec![boundary("return:handler")], &["T2"]),
2223 ],
2224 vec![
2225 test("T1", vec![]),
2226 test("T2", vec![observation("return:handler", Strength::Total)]),
2227 ],
2228 );
2229 let r = join(&f);
2230 let derived = r.iter().find(|r| r.site == "S1").unwrap();
2231 assert_eq!(derived.status, Status::Unresolved);
2232 }
2233
2234 #[test]
2235 fn a_weak_render_observation_marks_the_resolution_weak() {
2236 let mut ob = observation("dom", Strength::Presence);
2237 ob.weak = true;
2238 let f = facts(
2239 vec![site("S1", "return", vec![boundary("dom")], &["T1"])],
2240 vec![test("T1", vec![ob])],
2241 );
2242 let r = join(&f);
2243 assert!(r[0].weak_only);
2244 }
2245
2246 #[test]
2247 fn an_explicit_null_else_survives_as_the_absence_case() {
2248 let absence: DecisionFacts =
2251 serde_json::from_str(r#"{"then":["A"],"else":null}"#).expect("parses");
2252 assert_eq!(absence.else_, Some(None));
2253 let no_branches: DecisionFacts =
2254 serde_json::from_str(r#"{"carrier":"A"}"#).expect("parses");
2255 assert_eq!(no_branches.else_, None);
2256 let real_else: DecisionFacts =
2257 serde_json::from_str(r#"{"then":["A"],"else":["B"]}"#).expect("parses");
2258 assert_eq!(real_else.else_, Some(Some(vec!["B".to_owned()])));
2259 }
2260
2261 #[test]
2262 fn the_summary_counts_limits_apart_from_gaps() {
2263 let mut limited = site("S1", "state-write", vec![boundary("internal")], &["T1"]);
2264 limited.classification = "contractual".into();
2265 let gap = site("S2", "return", vec![boundary("return:handler")], &[]);
2266 let f = facts(vec![limited, gap], vec![test("T1", vec![])]);
2267 let r = join(&f);
2268 let s = summary(&f.sites, &r);
2269 assert_eq!(s.contractual, 2);
2270 assert_eq!(s.gaps, 1);
2271 assert_eq!(s.limits, 1);
2272 }
2273}