Skip to main content

supercov_engine/
asserted_coverage.rs

1//! The language-neutral join of asserted coverage: sites, evidence and observations in, a verdict per
2//! site out.
3//!
4//! Frontends contribute facts and never verdicts, so everything syntactic happens before this module:
5//! which boundaries a site's effect or value reaches, which sites each outcome of a decision controls,
6//! what each assertion reads and how strongly. This module decides only what those facts imply, which is
7//! the one part of the metric that is the same in every language.
8//!
9//! **Evident** and **presence** are candidate classifications under the frontend's flow and observation
10//! rules, not formal proofs of arbitrary-change detection. Unresolved sites carry a reason: a *gap*
11//! a test can close, or an *analysis limit* the frontend could not follow. Limits remain visible and
12//! must not be silently removed from a reported accuracy denominator.
13
14use std::collections::{BTreeMap, BTreeSet};
15
16use serde::{Deserialize, Serialize};
17
18// ---------------------------------------------------------------------------
19// Strength
20// ---------------------------------------------------------------------------
21
22/// How strongly the modeled assertion checks what it reads. This classifies its predicate, not a
23/// formal proof of the complete source-to-assertion dependency; `Presence` says a value arrived.
24#[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    /// Does this strength distinguish one value from another?
34    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// ---------------------------------------------------------------------------
47// Facts in
48// ---------------------------------------------------------------------------
49
50/// A place a site's effect or value arrives, and which an assertion may read: a function's return or
51/// escape, a sink the test injected, a mocked module, an installed global, a process channel, rendered
52/// output, or `internal` for an effect that leaves no boundary at all.
53#[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/// What one assertion reads, as the frontend understood it.
70#[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    /// Exact authored call identity, retained for assertion-specific hint checks.
80    #[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    /// `expect(x).not.toHaveBeenCalled()`: the assertion pins that something did *not* happen
85    #[serde(default)]
86    pub negative: bool,
87    /// the assertion pins a sink's whole call list, so a spurious or missing call shows up
88    #[serde(default)]
89    pub call_list: bool,
90    /// a whole-page render witnessed the site: it ran, but its output was not read
91    #[serde(default)]
92    pub weak: bool,
93    /// log sites whose message this observation's pattern, literal or fragment admits; `None` when the
94    /// observation carries no message constraint and so admits every site on its channel
95    #[serde(default)]
96    pub log_sites: Option<Vec<String>>,
97    /// the observation matches on a regex that several log sites can satisfy, so it pins none of them
98    /// individually; a whole literal or a fragment does not carry that ambiguity
99    #[serde(default)]
100    pub pattern_shared: bool,
101}
102
103impl Observation {
104    /// Does this observation read the given boundary of the given site? Dense channels need the message
105    /// constraint checked, and a spy on one console method sees only that method's calls.
106    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                    // a message constraint pins only the sites whose template can produce it
124                    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/// A test-owned object the test passed into production, and the parameter it arrived through.
144#[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    /// components the test rendered, by owner name
162    #[serde(default)]
163    pub rendered: Vec<String>,
164    /// Rejected/unavailable witnesses are not observations. Legacy frontends
165    /// omit this field; the public JS adapter requires the versioned capability.
166    #[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    /// A statically recognized but rejected observation. Used only to bound
196    /// uncertainty, NEVER to supply strength or evidence to a resolution.
197    #[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
208/// Deserialize a field whose absence and whose explicit `null` mean different things: the field's own
209/// `Option` distinguishes "the key was there" from "it was not", so a present `null` becomes `Some(None)`
210/// instead of collapsing into the same `None` a missing key gives.
211fn 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/// The sites each outcome of a decision controls, and the site sets its witness rules ask about.
220#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
221#[serde(rename_all = "camelCase")]
222pub struct DecisionFacts {
223    /// the site that carries the decision's value (a ternary inside a return, say)
224    #[serde(default)]
225    pub carrier: Option<String>,
226    /// a value-position expression not inside a site: the sites its value flows to
227    #[serde(default)]
228    pub value_flow: Option<Vec<String>>,
229    /// sites the true outcome controls
230    #[serde(default)]
231    pub then: Option<Vec<String>>,
232    /// Sites the false outcome controls. Three states, and they mean different things: absent when the
233    /// decision has no branches at all (a value-position operand), `Some(None)` when there is no else
234    /// branch (the absence case, where a spurious effect is what a test would notice), and `Some(sites)`
235    /// for a real else. An explicit JSON `null` has to survive as `Some(None)`, which is why this reads
236    /// the field itself rather than letting a missing key and a null one collapse together.
237    #[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    /// tests that took each outcome; absent when the frontend has no per-test outcome data
248    #[serde(default)]
249    pub outcomes: Option<Outcomes>,
250    /// tests in which a value-position operand was the selected one
251    #[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    /// Candidate timer-cancellation dependency: active only when a callback site has total evidence.
270    /// Older prototype facts already filtered these candidates and omit this condition.
271    #[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    /// The site's own boundaries, without the flow that carries its value elsewhere. What another site's
305    /// value reaches here is read at these, since the carrying flow does not continue past this point.
306    #[serde(default)]
307    pub direct_bounds: Vec<Boundary>,
308    /// sites this site's value flows into
309    #[serde(default)]
310    pub reached: Vec<String>,
311    pub covered_by: Vec<String>,
312    #[serde(default)]
313    pub object_valued_return: bool,
314    /// operand shapes a covering test asserted that the frontend could not trace to a boundary
315    #[serde(default)]
316    pub unmodelled_shapes: Vec<String>,
317    #[serde(default)]
318    pub decision: Option<DecisionFacts>,
319    /// sites through which an internal effect becomes observable
320    #[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    /// `vi.mock` boundaries, which depend on the test file rather than the test: file → site → boundaries
331    #[serde(default)]
332    pub mocks_by_test_file: BTreeMap<String, BTreeMap<String, Vec<Boundary>>>,
333}
334
335/// A source suggestion, deliberately separate from observations and join inputs.
336#[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    /// Explanatory text only. Never interpreted as a verified dependency.
359    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    /// Only the selected assertion's observations, never another assertion in its test.
380    #[serde(skip_serializing_if = "Vec::is_empty")]
381    pub observations: Vec<Observation>,
382}
383
384/// Check source hints against existing facts, without modifying the normal join.
385/// Indexes are shared across hints; only the selected test's observations are copied.
386/// This bounded first pass supports effect boundaries/flow, not decision or internal
387/// derivation proofs. Unsupported paths remain unresolved, not contradicted.
388pub 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                // Missing inventory can mean an unsupported source construct,
425                // not a bad declaration. Do not call an analysis limit invalid.
426                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// ---------------------------------------------------------------------------
525// Verdicts out
526// ---------------------------------------------------------------------------
527
528#[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/// Why a site is not evident. A gap is closable by writing a test; a limit is something the frontend
538/// could not follow, and an agent that writes a test for one either wastes the effort or learns to
539/// satisfy the analyzer instead of the code.
540#[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    /// tests whose observations produced the evidence
591    #[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
607// ---------------------------------------------------------------------------
608// The join
609// ---------------------------------------------------------------------------
610
611struct 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
618/// Resolve every site. Effect sites first, since a decision's outcomes are judged by the strength of the
619/// sites they control; then decisions; then internal effects derived through their dependents, which can
620/// promote a site and so are followed by a second decision pass, exactly as the prototype does.
621pub 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    /// The strongest strength among these sites, optionally restricted to sites whose evidence came from
674    /// one of `within`: with per-test outcomes known, only a test that took an outcome can have observed
675    /// the sites that outcome controls.
676    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    /// The given boundaries plus what the test file's module mocks bind at this site.
700    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    /// Boundaries of a site as one test sees them: its own, its module mocks, and the DOM when the test
711    /// rendered the component the site belongs to. The DOM applies to the site under judgement only: a
712    /// site merely reached by its value is not read through the page that rendered something else.
713    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    /// Does this observation read a sink the test wired into production at this site?
728    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            // `expect(table.upsert)`: a leaf of the sink object pins only calls of that method
755            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            // a sink on a dense channel still only pins the messages its constraint admits
763            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        // An effect with no boundary, reaching no other site and with no per-test extra, is internal:
786        // only a supported derivation can resolve it; comments are not evidence.
787        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                // a regex several log sites could satisfy pins none of them individually
819                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        // Returning one of several pre-built objects: downstream observations show that *a* value
864        // arrived, not which one.
865        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    /// The same boundary/instance rules used by positive observations. Reusing
892    /// them for limits must never turn a suppressed observation into credit.
893    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    /// Over-approximate which rejected observations could affect a candidate,
920    /// walking only its recorded flow/control/derivation edges. This explains
921    /// uncertainty; it is not a new proof of dependence. Root test ownership is
922    /// checked by the caller. No coverage requirement at a negative target:
923    /// an absence assertion can observe a branch whose effect never executed.
924    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    /// Final reporting pass only: strengths, statuses, caught flags, evidence
969    /// test sets and the denominator are unchanged. Unknown evidence may replace
970    /// an assertion gap with a limit, but never a known execution gap.
971    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    /// A witness for an outcome: a test that took it and pinned that an effect did not happen, either
1019    /// with a negative assertion on its sink or by comparing the sink's whole call list.
1020    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        // A ternary between two pre-built objects is distinguishable only through the sites just one of
1066        // them reaches.
1067        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            // An early exit is witnessed by a test that took it and asserted that a downstream effect of
1149            // the same function did not happen, or that read the early return by value.
1150            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            // Loop control decides which iterations run, which is visible only in the calls the body
1170            // makes: a pinned call list would have changed had the decision gone the other way.
1171            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 (opt) this.x = opt` with no else keeps the field's default when the condition is false:
1186            // a test in which it was false, observing a dependent of the field by value, would have seen
1187            // the write's value there had the condition been stuck true.
1188            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        // With no else branch, "this must not happen" is still pinned when the branch's effects are
1210        // asserted with total equality: a spurious occurrence shows up. With outcome data that needs at
1211        // least one test in which the condition really was false.
1212        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    /// An internal effect is observed through the sites that depend on it, capped at value strength: the
1286    /// dependent proves the state changed, not what it changed to. Needs a test covering both ends.
1287    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                        // A decision the tests pin in either direction proves the state reached it, so
1312                        // it witnesses the write at value strength whatever its own strength says.
1313                        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
1365/// The metric: of the contractual sites, how many are evident.
1366pub 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        // the exit's own branch asserts nothing; the witness is T1 pinning that S2 did not happen
2042        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        // A missing `else` means the decision has no branches; a null one means it has no else branch,
2249        // where a spurious effect is what a test would notice. The two must not collapse.
2250        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}