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/// Which part of a mock's history a passing assertion reads. This is source
70/// evidence; relating a history element to a production site remains unresolved.
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72#[serde(rename_all = "camelCase")]
73pub struct MockProjection {
74    pub target: String,
75    pub kind: String,
76    pub path: Vec<String>,
77    /// A bounded source-model count, not argument protection or general site credit.
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub count_evidence: Option<MockCountEvidence>,
80}
81
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(rename_all = "camelCase")]
84pub struct MockCountCall {
85    pub source: String,
86    pub action: String,
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub site: Option<String>,
89}
90
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92#[serde(rename_all = "camelCase")]
93pub struct MockCountEvidence {
94    pub model: String,
95    pub status: String,
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub reason: Option<String>,
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub instance: Option<String>,
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub created_at: Option<String>,
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub reset_at: Option<String>,
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub read_at: Option<String>,
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub installed_at_read: Option<bool>,
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub expected_count: Option<u64>,
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub observed_count: Option<u64>,
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub calls: Option<Vec<MockCountCall>>,
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub history_selections: Option<Vec<MockHistorySelection>>,
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub row_binding: Option<MockCountRowBinding>,
118}
119
120/// A bounded selection from a copied history, not a claim about the whole mock.
121#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
122#[serde(rename_all = "camelCase")]
123pub struct MockHistorySelection {
124    pub source: String,
125    pub input_count: u64,
126    pub from: u64,
127    pub to: u64,
128}
129
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131#[serde(rename_all = "camelCase")]
132pub struct MockCountRowValue {
133    pub declaration: String,
134    pub name: String,
135    pub value: serde_json::Value,
136}
137
138/// Source-reproduced registration inputs; not a value-protection proof.
139#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
140#[serde(rename_all = "camelCase")]
141pub struct MockCountRowBinding {
142    pub model: String,
143    pub status: String,
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub reason: Option<String>,
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    pub r#loop: Option<String>,
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub table: Option<String>,
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub row: Option<String>,
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub row_index: Option<u64>,
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub title: Option<String>,
156    #[serde(default, skip_serializing_if = "Option::is_none")]
157    pub bindings: Option<Vec<MockCountRowValue>>,
158}
159
160/// Source identities of both operands. Equal source text is not binding identity.
161#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
162#[serde(rename_all = "camelCase")]
163pub struct ComparisonOperand {
164    pub source: String,
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub value: Option<SourcePrimitive>,
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub binding: Option<String>,
169    #[serde(default, skip_serializing_if = "Option::is_none")]
170    pub input: Option<ComparisonInput>,
171}
172
173/// Source literals, not sampled runtime values. Numeric text preserves -0.
174#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
175#[serde(tag = "kind", content = "value", rename_all = "lowercase")]
176pub enum SourcePrimitive {
177    Number(String),
178    String(String),
179    Boolean(bool),
180    Null,
181}
182
183impl SourcePrimitive {
184    fn same_value(&self, other: &Self) -> Option<bool> {
185        let valid = |v: &Self| match v {
186            Self::Number(n) => n.parse::<f64>().ok().is_some_and(f64::is_finite),
187            _ => true,
188        };
189        if !valid(self) || !valid(other) {
190            return None;
191        }
192        Some(match (self, other) {
193            (Self::Number(a), Self::Number(b)) => {
194                a.parse::<f64>().ok()?.to_bits() == b.parse::<f64>().ok()?.to_bits()
195            }
196            (Self::String(a), Self::String(b)) => a == b,
197            (Self::Boolean(a), Self::Boolean(b)) => a == b,
198            (Self::Null, Self::Null) => true,
199            _ => false,
200        })
201    }
202}
203
204#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
205#[serde(rename_all = "camelCase")]
206pub struct PrimitiveDecisionCheck {
207    pub test: String,
208    pub assertion_source: String,
209    pub predicate: String,
210    pub expected: SourcePrimitive,
211    pub original_outcome: bool,
212}
213
214/// Bounded direct-call, side-effect-free literal branches checked by the frontend.
215#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
216#[serde(rename_all = "camelCase")]
217pub struct PrimitiveDecision {
218    pub model: String,
219    pub source: String,
220    pub when_true: SourcePrimitive,
221    pub when_false: SourcePrimitive,
222    pub checks: Vec<PrimitiveDecisionCheck>,
223}
224
225/// Input provenance through const aliases/await, not evaluated value identity.
226#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
227#[serde(rename_all = "camelCase")]
228pub struct ComparisonInput {
229    pub binding: String,
230    pub awaits: Vec<String>,
231}
232
233/// Checked resolver syntax, not a checked runtime/producer instance relation.
234#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
235#[serde(rename_all = "camelCase")]
236pub struct ProcessExitEvidence {
237    pub model: String,
238    pub status: String,
239    pub reason: String,
240    pub operand: String,
241    pub helper_calls: Vec<String>,
242    #[serde(default, skip_serializing_if = "Option::is_none")]
243    pub promise: Option<String>,
244    #[serde(default, skip_serializing_if = "Option::is_none")]
245    pub spawn: Option<String>,
246    #[serde(default, skip_serializing_if = "Option::is_none")]
247    pub event: Option<ProcessExitEvent>,
248    #[serde(default, skip_serializing_if = "Option::is_none")]
249    pub resolution: Option<ProcessExitResolution>,
250    #[serde(default, skip_serializing_if = "Option::is_none")]
251    pub consumer: Option<ProcessExitConsumer>,
252}
253
254#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
255#[serde(rename_all = "camelCase")]
256pub struct ProcessExitConsumer {
257    pub status: String,
258    #[serde(default, skip_serializing_if = "Option::is_none")]
259    pub reason: Option<String>,
260    pub bindings: Vec<String>,
261    pub read: String,
262    #[serde(default, skip_serializing_if = "Option::is_none")]
263    pub blocked_at: Option<String>,
264}
265
266#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
267pub struct ProcessExitEvent {
268    pub source: String,
269    pub name: String,
270}
271
272#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
273#[serde(rename_all = "camelCase")]
274pub struct ProcessExitResolution {
275    pub status: String,
276    pub source: String,
277    #[serde(default, skip_serializing_if = "Option::is_none")]
278    pub field: Option<String>,
279    pub event_argument: String,
280}
281
282#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
283#[serde(rename_all = "camelCase")]
284pub struct Comparison {
285    pub predicate: String,
286    pub actual: ComparisonOperand,
287    pub expected: ComparisonOperand,
288    pub relation: String,
289}
290
291/// Predicate strength applies to the projected value, not automatically to the
292/// arguments or count of each production call contributing to a mock history.
293#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
294#[serde(rename_all = "camelCase")]
295pub struct Observation {
296    pub boundary: String,
297    #[serde(default)]
298    pub facet: Option<String>,
299    pub strength: Strength,
300    #[serde(default, rename = "where")]
301    pub where_: Option<String>,
302    /// Exact authored call identity, retained for assertion-specific hint checks.
303    #[serde(default, skip_serializing_if = "Option::is_none")]
304    pub assertion_source: Option<String>,
305    #[serde(default, skip_serializing_if = "Option::is_none")]
306    pub assertion_method: Option<String>,
307    /// `expect(x).not.toHaveBeenCalled()`: the assertion pins that something did *not* happen
308    #[serde(default)]
309    pub negative: bool,
310    /// the assertion pins a sink's whole call list, so a spurious or missing call shows up
311    #[serde(default)]
312    pub call_list: bool,
313    #[serde(default, skip_serializing_if = "Option::is_none")]
314    pub mock: Option<MockProjection>,
315    #[serde(default, skip_serializing_if = "Option::is_none")]
316    pub comparison: Option<Comparison>,
317    #[serde(default, skip_serializing_if = "Option::is_none")]
318    pub process_exit: Option<ProcessExitEvidence>,
319    /// a whole-page render witnessed the site: it ran, but its output was not read
320    #[serde(default)]
321    pub weak: bool,
322    /// log sites whose message this observation's pattern, literal or fragment admits; `None` when the
323    /// observation carries no message constraint and so admits every site on its channel
324    #[serde(default)]
325    pub log_sites: Option<Vec<String>>,
326    /// the observation matches on a regex that several log sites can satisfy, so it pins none of them
327    /// individually; a whole literal or a fragment does not carry that ambiguity
328    #[serde(default)]
329    pub pattern_shared: bool,
330}
331
332impl Observation {
333    /// Reflexive comparisons do not constrain their stable value. Shared input
334    /// through await may constrain something, but needs a separate dependence
335    /// model before it can supply producer value, absence or pragma credit.
336    /// Exit channels additionally need a checked producer/result-instance link;
337    /// even accepted resolver source syntax is insufficient on its own.
338    fn can_constrain_value(&self) -> bool {
339        self.mock.is_none()
340            && self.boundary != "exit"
341            && self.comparison.as_ref().is_none_or(|c| {
342                !matches!(
343                    c.relation.as_str(),
344                    "same-immutable-binding" | "shared-input-through-await"
345                )
346            })
347    }
348
349    /// Does this observation read the given boundary of the given site? Dense channels need the message
350    /// constraint checked, and a spy on one console method sees only that method's calls.
351    fn matches(&self, site: &Site, at: &Boundary) -> bool {
352        if !self.can_constrain_value() {
353            return false;
354        }
355        if at.boundary != self.boundary {
356            return false;
357        }
358        match at.boundary.as_str() {
359            "client-header" => match (at.facet.as_deref(), self.facet.as_deref()) {
360                (Some("*"), _) | (_, None) | (_, Some("*")) => true,
361                (a, b) => a == b,
362            },
363            "stderr" | "stdout" => {
364                if let (Some(facet), true) = (self.facet.as_deref(), site.category == "log")
365                    && let Some(method) = facet.strip_prefix("console.")
366                    && site.method.as_deref().is_some_and(|m| m != method)
367                {
368                    return false;
369                }
370                match &self.log_sites {
371                    // a message constraint pins only the sites whose template can produce it
372                    Some(admitted) => {
373                        if site.category == "log" {
374                            admitted.contains(&site.id)
375                        } else {
376                            at.facet.as_deref() != Some("log")
377                        }
378                    }
379                    None => true,
380                }
381            }
382            "client-message" => match at.facet.as_deref() {
383                Some(facet) => self.facet.as_deref().is_some_and(|f| f.contains(facet)),
384                None => true,
385            },
386            _ => true,
387        }
388    }
389}
390
391/// A test-owned object the test passed into production, and the parameter it arrived through.
392#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
393#[serde(rename_all = "camelCase")]
394pub struct SinkBinding {
395    pub sink: String,
396    pub param: String,
397    #[serde(default)]
398    pub member: Option<String>,
399}
400
401#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
402#[serde(rename_all = "camelCase")]
403pub struct TestFacts {
404    pub id: String,
405    pub file: String,
406    pub observations: Vec<Observation>,
407    #[serde(default)]
408    pub sinks: Vec<SinkBinding>,
409    /// components the test rendered, by owner name
410    #[serde(default)]
411    pub rendered: Vec<String>,
412    /// Rejected/unavailable witnesses are not observations. Legacy frontends
413    /// omit this field; the public JS adapter requires the versioned capability.
414    #[serde(default, skip_serializing_if = "Vec::is_empty")]
415    pub witness_issues: Vec<WitnessIssue>,
416}
417
418#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
419#[serde(rename_all = "kebab-case")]
420pub enum WitnessIssueKind {
421    CaptureUnavailable,
422    /// Runtime attribution exists, but no source test body could be linked.
423    /// This is missing analysis, not evidence that the test contains no oracle.
424    TestSourceUnlinked,
425    /// Exact passing calls identify a callback, not its registrar, row inputs,
426    /// failure propagation, or the whole test's other observers.
427    TestRegistrationScopeUnverified,
428    CallNotRecorded,
429    CallIncomplete,
430    MixedCallOutcomes,
431    CallFailed,
432    UninstrumentedObservation,
433}
434
435impl WitnessIssueKind {
436    fn applies_to_whole_test(self) -> bool {
437        matches!(
438            self,
439            Self::CaptureUnavailable
440                | Self::TestSourceUnlinked
441                | Self::TestRegistrationScopeUnverified
442        )
443    }
444
445    fn is_uncertain(self) -> bool {
446        self != Self::CallFailed
447    }
448}
449
450#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
451#[serde(rename_all = "camelCase")]
452pub struct WitnessIssue {
453    pub kind: WitnessIssueKind,
454    #[serde(default, skip_serializing_if = "Option::is_none")]
455    pub source: Option<String>,
456    #[serde(default, skip_serializing_if = "Option::is_none")]
457    pub operation: Option<String>,
458    /// A statically recognized but rejected observation. Used only to bound
459    /// uncertainty, NEVER to supply strength or evidence to a resolution.
460    #[serde(default, skip_serializing_if = "Option::is_none")]
461    pub observation: Option<Observation>,
462}
463
464#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
465pub struct TestWitnessIssue {
466    pub test: String,
467    #[serde(flatten)]
468    pub issue: WitnessIssue,
469}
470
471/// Deserialize a field whose absence and whose explicit `null` mean different things: the field's own
472/// `Option` distinguishes "the key was there" from "it was not", so a present `null` becomes `Some(None)`
473/// instead of collapsing into the same `None` a missing key gives.
474fn present_option<'de, T, D>(deserializer: D) -> Result<Option<T>, D::Error>
475where
476    T: Deserialize<'de>,
477    D: serde::Deserializer<'de>,
478{
479    T::deserialize(deserializer).map(Some)
480}
481
482/// The sites each outcome of a decision controls, and the site sets its witness rules ask about.
483#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
484#[serde(rename_all = "camelCase")]
485pub struct DecisionFacts {
486    #[serde(default, skip_serializing_if = "Option::is_none")]
487    pub primitive: Option<PrimitiveDecision>,
488    /// the site that carries the decision's value (a ternary inside a return, say)
489    #[serde(default)]
490    pub carrier: Option<String>,
491    /// a value-position expression not inside a site: the sites its value flows to
492    #[serde(default)]
493    pub value_flow: Option<Vec<String>>,
494    /// sites the true outcome controls
495    #[serde(default)]
496    pub then: Option<Vec<String>>,
497    /// Sites the false outcome controls. Three states, and they mean different things: absent when the
498    /// decision has no branches at all (a value-position operand), `Some(None)` when there is no else
499    /// branch (the absence case, where a spurious effect is what a test would notice), and `Some(sites)`
500    /// for a real else. An explicit JSON `null` has to survive as `Some(None)`, which is why this reads
501    /// the field itself rather than letting a missing key and a null one collapse together.
502    #[serde(default, rename = "else", deserialize_with = "present_option")]
503    pub else_: Option<Option<Vec<String>>>,
504    #[serde(default)]
505    pub early_exit_downstream: Option<Vec<String>>,
506    #[serde(default)]
507    pub loop_body: Option<Vec<String>>,
508    #[serde(default)]
509    pub default_kept: Option<Vec<DefaultKept>>,
510    #[serde(default)]
511    pub object_valued: Option<ObjectValued>,
512    /// tests that took each outcome; absent when the frontend has no per-test outcome data
513    #[serde(default)]
514    pub outcomes: Option<Outcomes>,
515    /// tests in which a value-position operand was the selected one
516    #[serde(default)]
517    pub selected: Option<Vec<String>>,
518}
519
520#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
521#[serde(rename_all = "camelCase")]
522pub struct DefaultKept {
523    pub write: String,
524    pub dependents: Vec<Dependent>,
525}
526
527#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
528#[serde(rename_all = "camelCase")]
529pub struct Dependent {
530    pub site: String,
531    pub label: String,
532    #[serde(default)]
533    pub strength: Option<Strength>,
534    /// Candidate timer-cancellation dependency: active only when a callback site has total evidence.
535    /// Older prototype facts already filtered these candidates and omit this condition.
536    #[serde(default, skip_serializing_if = "Option::is_none")]
537    pub requires_total: Option<Vec<String>>,
538}
539
540#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
541#[serde(rename_all = "camelCase")]
542pub struct ObjectValued {
543    pub only_a: Vec<String>,
544    pub only_b: Vec<String>,
545}
546
547#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
548#[serde(rename_all = "camelCase")]
549pub struct Outcomes {
550    #[serde(rename = "true")]
551    pub true_: Vec<String>,
552    #[serde(rename = "false")]
553    pub false_: Vec<String>,
554}
555
556#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
557#[serde(rename_all = "camelCase")]
558pub struct Site {
559    pub id: String,
560    pub file: String,
561    pub line: u32,
562    pub kind: String,
563    pub category: String,
564    pub classification: String,
565    pub owner: String,
566    #[serde(default)]
567    pub method: Option<String>,
568    pub bounds: Vec<Boundary>,
569    /// The site's own boundaries, without the flow that carries its value elsewhere. What another site's
570    /// value reaches here is read at these, since the carrying flow does not continue past this point.
571    #[serde(default)]
572    pub direct_bounds: Vec<Boundary>,
573    /// sites this site's value flows into
574    #[serde(default)]
575    pub reached: Vec<String>,
576    pub covered_by: Vec<String>,
577    #[serde(default)]
578    pub object_valued_return: bool,
579    /// operand shapes a covering test asserted that the frontend could not trace to a boundary
580    #[serde(default)]
581    pub unmodelled_shapes: Vec<String>,
582    #[serde(default)]
583    pub decision: Option<DecisionFacts>,
584    /// sites through which an internal effect becomes observable
585    #[serde(default)]
586    pub derive: Vec<Dependent>,
587}
588
589#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
590#[serde(rename_all = "camelCase")]
591pub struct Facts {
592    pub schema: u32,
593    pub sites: Vec<Site>,
594    pub tests: Vec<TestFacts>,
595    /// `vi.mock` boundaries, which depend on the test file rather than the test: file → site → boundaries
596    #[serde(default)]
597    pub mocks_by_test_file: BTreeMap<String, BTreeMap<String, Vec<Boundary>>>,
598}
599
600/// A source suggestion, deliberately separate from observations and join inputs.
601#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
602#[serde(rename_all = "camelCase")]
603pub struct PragmaHint {
604    pub id: String,
605    #[serde(rename = "where")]
606    pub where_: String,
607    pub raw: String,
608    pub target: Option<PragmaTarget>,
609    pub candidate_sites: Vec<String>,
610    pub issue: Option<String>,
611    pub test: Option<String>,
612    pub assertion_source: Option<String>,
613    pub assertion_method: Option<String>,
614    pub witness: String,
615    pub witness_issue: Option<String>,
616    /// A checked source pattern, not a captured read or a passing assertion.
617    #[serde(default, skip_serializing_if = "Option::is_none")]
618    pub awaited_observation: Option<AwaitedObservationSource>,
619    #[serde(default, skip_serializing_if = "Option::is_none")]
620    pub check: Option<String>,
621    #[serde(default, skip_serializing_if = "Option::is_none")]
622    pub call_omission: Option<CallOmissionEvidence>,
623    #[serde(default, skip_serializing_if = "Option::is_none")]
624    pub count_sensitivity: Option<CountSensitivityEvidence>,
625    #[serde(default, skip_serializing_if = "Option::is_none")]
626    pub payload_sensitivity: Option<PayloadSensitivityEvidence>,
627    #[serde(default, skip_serializing_if = "Option::is_none")]
628    pub direct_return_sensitivity: Option<DirectReturnSensitivityEvidence>,
629    #[serde(default, skip_serializing_if = "Option::is_none")]
630    pub completion_sensitivity: Option<CompletionSensitivityEvidence>,
631}
632
633#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
634#[serde(rename_all = "camelCase")]
635pub struct CompletionSensitivityEvidence {
636    pub model: String,
637    pub status: String,
638    pub reason: Option<String>,
639    pub scope: Option<String>,
640    pub assertion_source: Option<String>,
641    pub target_source: Option<String>,
642    pub change_text: Option<String>,
643    pub change: Option<String>,
644    pub original: Option<CompletionCheck>,
645    pub omitted: Option<CompletionCheck>,
646}
647
648#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
649#[serde(rename_all = "camelCase")]
650pub struct CompletionCheck {
651    pub method: String,
652    pub callback_source: String,
653    pub completion: String,
654    pub throw_source: Option<String>,
655    pub target_evaluations: u64,
656    pub outcome: String,
657    #[serde(default, skip_serializing_if = "Option::is_none")]
658    pub matcher: Option<CompletionMatcher>,
659    #[serde(default, skip_serializing_if = "Option::is_none")]
660    pub missing_exception_diagnostic: Option<MissingExceptionDiagnostic>,
661    #[serde(default, skip_serializing_if = "Option::is_none")]
662    pub diagnostic: Option<CompletionDiagnostic>,
663}
664
665#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
666#[serde(rename_all = "camelCase")]
667pub struct CompletionMatcher {
668    pub source: String,
669    pub kind: String,
670}
671
672#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
673#[serde(rename_all = "camelCase")]
674pub struct MissingExceptionDiagnostic {
675    pub name_basis: String,
676    #[serde(default, skip_serializing_if = "Option::is_none")]
677    pub name: Option<serde_json::Value>,
678    pub message: serde_json::Value,
679}
680
681#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
682#[serde(rename_all = "camelCase")]
683pub struct CompletionDiagnostic {
684    pub basis: String,
685    pub message: serde_json::Value,
686}
687
688fn completion_evidence_issue(
689    hint: &PragmaHint,
690    e: &CompletionSensitivityEvidence,
691    site: &Site,
692    test: &TestFacts,
693) -> Option<String> {
694    let location_in = |location: &str, file: &str| {
695        location
696            .strip_prefix(&format!("{file}:"))
697            .and_then(|rest| rest.split_once(':'))
698            .is_some_and(|(line, column)| {
699                line.parse::<u32>().is_ok_and(|n| n > 0)
700                    && column.parse::<u32>().is_ok_and(|n| n > 0)
701            })
702    };
703    if e.model != "node-first-test-completion-v1"
704        || e.status != "source-checked"
705        || e.reason.is_some()
706        || e.scope.as_deref() != Some("first-synchronous-test-prefix")
707        || e.assertion_source != hint.assertion_source
708        || e.assertion_source
709            .as_ref()
710            .is_none_or(|s| !location_in(s, &test.file))
711        || e.target_source.as_ref().is_none_or(|s| {
712            !location_in(s, &site.file) || !s.starts_with(&format!("{}:{}:", site.file, site.line))
713        })
714        || e.change_text.as_ref().is_none_or(String::is_empty)
715        || e.change.as_deref() != Some("statement-omitted")
716        || !matches!(site.category.as_str(), "return" | "throw")
717        || hint.call_omission.is_some()
718        || hint.count_sensitivity.is_some()
719        || hint.payload_sensitivity.is_some()
720        || hint.direct_return_sensitivity.is_some()
721        || test.witness_issues.iter().any(|issue| {
722            issue.kind.applies_to_whole_test()
723                || (issue.source.is_some() && issue.source == hint.assertion_source)
724        })
725    {
726        return Some(
727            e.reason
728                .clone()
729                .unwrap_or_else(|| "unsupported-completion-evidence".into()),
730        );
731    }
732    let valid_primitive = |value: &serde_json::Value| {
733        let mut budget = 16;
734        valid_payload(value, 0, &mut budget)
735            && matches!(
736                value["kind"].as_str(),
737                Some("undefined" | "null" | "string" | "number" | "boolean")
738            )
739    };
740    let valid = |c: &CompletionCheck, original: bool| {
741        let rejected = match (c.method.as_str(), c.completion.as_str()) {
742            ("throws", "normal") | ("doesNotThrow", "throw") => Some(true),
743            ("throws", "throw") | ("doesNotThrow", "normal") => Some(false),
744            _ => None,
745        };
746        let predicate_valid = if let Some(m) = &c.matcher {
747            c.method == "throws"
748                && location_in(&m.source, &test.file)
749                && matches!(
750                    m.kind.as_str(),
751                    "native-regexp"
752                        | "source-function"
753                        | "native-error-constructor"
754                        | "native-error"
755                        | "object"
756                        | "array"
757                        | "none"
758                        | "message-overload"
759                )
760                && c.diagnostic.is_none()
761                && if original {
762                    c.completion == "throw"
763                        && c.outcome == "witnessed-pass"
764                        && c.missing_exception_diagnostic.is_none()
765                } else {
766                    c.completion == "normal"
767                        && c.outcome == "rejected"
768                        && c.missing_exception_diagnostic.as_ref().is_some_and(|d| {
769                            valid_primitive(&d.message)
770                                && (m.kind != "message-overload" || d.message["kind"] == "string")
771                                && match (m.kind.as_str(), d.name_basis.as_str(), &d.name) {
772                                    (
773                                        "native-regexp" | "array" | "none" | "message-overload"
774                                        | "object",
775                                        "absent",
776                                        None,
777                                    ) => true,
778                                    ("source-function", "source-function-name", None) => true,
779                                    (
780                                        "native-error-constructor" | "native-error",
781                                        "native-error-name",
782                                        Some(name),
783                                    ) => {
784                                        valid_primitive(name)
785                                            && name["kind"] == "string"
786                                            && name["value"] == "Error"
787                                    }
788                                    ("object", "own-primitive-name", Some(name)) => {
789                                        valid_primitive(name)
790                                    }
791                                    _ => false,
792                                }
793                        })
794                }
795        } else {
796            c.missing_exception_diagnostic.is_none()
797                && rejected
798                    .is_some_and(|r| c.outcome == if r { "rejected" } else { "not-rejected" })
799        };
800        predicate_valid
801            && if c.method == "doesNotThrow" && c.completion == "throw" {
802                c.diagnostic.as_ref().is_some_and(|d| {
803                    let mut budget = 16;
804                    valid_payload(&d.message, 0, &mut budget)
805                        && match d.basis.as_str() {
806                            "native-error-message" => d.message["kind"] == "string",
807                            "absent-message" | "primitive-thrown-value" => {
808                                d.message["kind"] == "undefined"
809                            }
810                            "own-primitive-message" => matches!(
811                                d.message["kind"].as_str(),
812                                Some("undefined" | "null" | "string" | "number" | "boolean")
813                            ),
814                            _ => false,
815                        }
816                })
817            } else {
818                c.diagnostic.is_none()
819            }
820            && hint.assertion_method.as_deref() == Some(c.method.as_str())
821            && (location_in(&c.callback_source, &test.file)
822                || location_in(&c.callback_source, &site.file))
823            && (1..=4096).contains(&c.target_evaluations)
824            && match (c.completion.as_str(), &c.throw_source) {
825                ("normal", None) => true,
826                ("throw", Some(s)) => location_in(s, &test.file) || location_in(s, &site.file),
827                _ => false,
828            }
829    };
830    if e.original.as_ref().is_none_or(|c| {
831        !valid(c, true) || !matches!(c.outcome.as_str(), "not-rejected" | "witnessed-pass")
832    }) || e.omitted.as_ref().is_none_or(|c| !valid(c, false))
833    {
834        return Some("inconsistent-completion-predicate".into());
835    }
836    // A matcher-backed question cannot lose/change the argument's source or
837    // borrow a witness for a changed still-throwing callback. Kind may change
838    // only because the same source expression was evaluated along a new path.
839    if e.original
840        .as_ref()
841        .and_then(|c| c.matcher.as_ref())
842        .map(|m| &m.source)
843        != e.omitted
844            .as_ref()
845            .and_then(|c| c.matcher.as_ref())
846            .map(|m| &m.source)
847    {
848        return Some("inconsistent-completion-matcher-source".into());
849    }
850    None
851}
852
853#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
854#[serde(rename_all = "camelCase")]
855pub struct DirectReturnSensitivityEvidence {
856    pub model: String,
857    pub status: String,
858    pub reason: Option<String>,
859    pub scope: Option<String>,
860    pub assertion_source: Option<String>,
861    pub target_source: Option<String>,
862    pub change_source: Option<String>,
863    pub change_text: Option<String>,
864    pub original: Option<DirectReturnCheck>,
865    pub variants: Option<Vec<DirectReturnVariant>>,
866}
867
868#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
869#[serde(rename_all = "camelCase")]
870pub struct DirectReturnCheck {
871    pub predicate: String,
872    pub actual: serde_json::Value,
873    pub expected: serde_json::Value,
874    pub call_source: String,
875    pub target_evaluations: u64,
876    pub outcome: String,
877}
878
879#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
880pub struct DirectReturnVariant {
881    pub change: String,
882    pub status: String,
883    pub reason: Option<String>,
884    pub check: Option<DirectReturnCheck>,
885}
886
887fn direct_return_evidence_issue(
888    hint: &PragmaHint,
889    e: &DirectReturnSensitivityEvidence,
890    site: &Site,
891    test: &TestFacts,
892) -> Option<String> {
893    let test_file = test.file.as_str();
894    let location_in = |location: &str, file: &str| {
895        location
896            .strip_prefix(&format!("{file}:"))
897            .and_then(|rest| rest.split_once(':'))
898            .is_some_and(|(line, column)| {
899                line.parse::<u32>().is_ok_and(|n| n > 0)
900                    && column.parse::<u32>().is_ok_and(|n| n > 0)
901            })
902    };
903    if e.model != "node-first-test-direct-return-v1"
904        || e.status != "source-checked"
905        || e.reason.is_some()
906        || e.scope.as_deref() != Some("first-synchronous-test-prefix")
907        || e.assertion_source != hint.assertion_source
908        || e.assertion_source
909            .as_ref()
910            .is_none_or(|s| !location_in(s, test_file))
911        || e.target_source
912            .as_ref()
913            .is_none_or(|s| !location_in(s, &site.file))
914        || e.target_source
915            .as_ref()
916            .is_none_or(|s| !s.starts_with(&format!("{}:{}:", site.file, site.line)))
917        || e.change_source
918            .as_ref()
919            .is_none_or(|s| !location_in(s, &site.file))
920        || e.change_text.as_ref().is_none_or(String::is_empty)
921        || !(site.kind == "decision" || site.category == "return")
922        || hint.payload_sensitivity.is_some()
923        || test.witness_issues.iter().any(|issue| {
924            issue.kind.applies_to_whole_test()
925                || (issue.source.is_some() && issue.source == hint.assertion_source)
926        })
927    {
928        return Some(
929            e.reason
930                .clone()
931                .unwrap_or_else(|| "unsupported-direct-return-evidence".into()),
932        );
933    }
934    let (Some(original), Some(variants)) = (&e.original, &e.variants) else {
935        return Some("incomplete-direct-return-evidence".into());
936    };
937    // This model has no opaque strings or assumed original predicate results.
938    fn concrete(value: &serde_json::Value, depth: usize) -> bool {
939        if depth > 32 {
940            return false;
941        }
942        match value["kind"].as_str() {
943            Some("undefined" | "null" | "string" | "number" | "boolean") => true,
944            Some("array" | "object") => value["properties"]
945                .as_array()
946                .is_some_and(|ps| ps.iter().all(|p| concrete(&p["value"], depth + 1))),
947            _ => false,
948        }
949    }
950    let valid = |c: &DirectReturnCheck| {
951        let mut budget = 4096;
952        matches!(
953            c.predicate.as_str(),
954            "node-same-value" | "node-deep-strict-equality"
955        ) && location_in(&c.call_source, test_file)
956            && (1..=4096).contains(&c.target_evaluations)
957            && valid_payload(&c.actual, 0, &mut budget)
958            && valid_payload(&c.expected, 0, &mut budget)
959            && concrete(&c.actual, 0)
960            && concrete(&c.expected, 0)
961            && payload_equal(
962                &c.actual,
963                &c.expected,
964                c.predicate == "node-deep-strict-equality",
965            )
966            .is_some_and(|equal| c.outcome == if equal { "not-rejected" } else { "rejected" })
967    };
968    if !valid(original)
969        || original.outcome != "not-rejected"
970        || !matches!(
971            (
972                hint.assertion_method.as_deref(),
973                original.predicate.as_str()
974            ),
975            (Some("equal" | "strictEqual"), "node-same-value")
976                | (
977                    Some("deepEqual" | "deepStrictEqual"),
978                    "node-deep-strict-equality"
979                )
980        )
981    {
982        return Some("direct-return-assertion-not-modelled".into());
983    }
984    let mut names: Vec<_> = variants.iter().map(|v| v.change.as_str()).collect();
985    names.sort();
986    if !(names == ["boolean-literal-inverted"]
987        || names == ["condition-false", "condition-inverted", "condition-true"])
988        || (names == ["boolean-literal-inverted"]
989            && !matches!(e.change_text.as_deref(), Some("true" | "false")))
990        || variants.iter().any(|v| match v.status.as_str() {
991            "source-checked" => {
992                v.reason.is_some()
993                    || v.check.as_ref().is_none_or(|c| {
994                        !valid(c)
995                            || c.call_source != original.call_source
996                            || c.expected != original.expected
997                            || c.predicate != original.predicate
998                    })
999            }
1000            "unresolved" => v.reason.as_ref().is_none_or(String::is_empty) || v.check.is_some(),
1001            _ => true,
1002        })
1003    {
1004        return Some("inconsistent-direct-return-variants".into());
1005    }
1006    if variants.iter().all(|v| v.status != "source-checked") {
1007        return Some("direct-return-variants-unavailable".into());
1008    }
1009    None
1010}
1011
1012#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1013#[serde(rename_all = "camelCase")]
1014pub struct PayloadSensitivityEvidence {
1015    pub model: String,
1016    pub status: String,
1017    pub reason: Option<String>,
1018    pub scope: Option<String>,
1019    pub assertion_source: Option<String>,
1020    pub target_source: Option<String>,
1021    pub change_source: Option<String>,
1022    pub change_text: Option<String>,
1023    pub allocations: Option<Vec<String>>,
1024    pub original: Option<PayloadCheck>,
1025    pub variants: Option<Vec<PayloadVariant>>,
1026}
1027#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1028pub struct PayloadCheck {
1029    pub predicate: String,
1030    pub actual: serde_json::Value,
1031    pub expected: serde_json::Value,
1032    pub projection: PayloadProjection,
1033    pub outcome: String,
1034}
1035#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1036#[serde(rename_all = "camelCase")]
1037pub struct PayloadProjection {
1038    pub instance: String,
1039    pub call_source: String,
1040    pub call_index: u64,
1041    pub argument_index: u64,
1042    pub read_at: String,
1043    pub history_selections: Option<Vec<MockHistorySelection>>,
1044    pub coercion: Option<PayloadCoercion>,
1045}
1046#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1047pub struct PayloadCoercion {
1048    pub source: String,
1049    pub rule: String,
1050    pub input: serde_json::Value,
1051}
1052#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1053pub struct PayloadVariant {
1054    pub change: String,
1055    pub status: String,
1056    pub reason: Option<String>,
1057    pub check: Option<PayloadCheck>,
1058}
1059
1060fn valid_payload(v: &serde_json::Value, depth: usize, budget: &mut usize) -> bool {
1061    if depth > 32 || *budget == 0 {
1062        return false;
1063    }
1064    *budget -= 1;
1065    let Some(o) = v.as_object() else {
1066        return false;
1067    };
1068    match v["kind"].as_str() {
1069        Some("undefined" | "null" | "opaque-string") => o.len() == 1,
1070        Some("string") => o.len() == 2 && v["value"].is_string(),
1071        Some("quoted-string") => {
1072            o.len() == 2
1073                && v["value"].as_str().is_some_and(|s| {
1074                    s.len() <= 64
1075                        && s.bytes()
1076                            .all(|c| c.is_ascii_alphanumeric() || b" _-".contains(&c))
1077                })
1078        }
1079        Some("substring-pattern") => {
1080            o.len() == 2
1081                && v["value"].as_str().is_some_and(|s| {
1082                    !s.is_empty()
1083                        && s.len() <= 80
1084                        && s.bytes()
1085                            .all(|c| c.is_ascii_alphanumeric() || b" _:-".contains(&c))
1086                })
1087        }
1088        Some("boolean") => o.len() == 2 && v["value"].is_boolean(),
1089        Some("number") => {
1090            o.len() == 2
1091                && v["value"]
1092                    .as_f64()
1093                    .is_some_and(|n| n.is_finite() && !(n == 0.0 && n.is_sign_negative()))
1094        }
1095        Some(kind @ ("object" | "array")) => {
1096            let Some(properties) = v["properties"].as_array() else {
1097                return false;
1098            };
1099            let mut names = std::collections::HashSet::new();
1100            o.len() == 2
1101                && properties.iter().all(|p| {
1102                    p.as_object().is_some_and(|o| o.len() == 2)
1103                        && p["name"]
1104                            .as_str()
1105                            .is_some_and(|n| n != "__proto__" && names.insert(n))
1106                        && valid_payload(&p["value"], depth + 1, budget)
1107                })
1108                && (kind != "array"
1109                    || (0..properties.len()).all(|i| names.contains(i.to_string().as_str())))
1110        }
1111        _ => false,
1112    }
1113}
1114
1115// Both values have already passed bounded shape validation. Unknown string bytes
1116// must never compare equal just because their abstract descriptions are equal.
1117fn payload_equal(a: &serde_json::Value, b: &serde_json::Value, deep: bool) -> Option<bool> {
1118    let (ak, bk) = (a["kind"].as_str()?, b["kind"].as_str()?);
1119    if ak == "quoted-string" || bk == "quoted-string" {
1120        let other = if ak == "quoted-string" { b } else { a };
1121        return match other["kind"].as_str()? {
1122            "string" => {
1123                if other["value"].as_str()?.contains('\'') {
1124                    None
1125                } else {
1126                    Some(false)
1127                }
1128            }
1129            "quoted-string" | "opaque-string" => None,
1130            _ => Some(false),
1131        };
1132    }
1133    if ak == "opaque-string" || bk == "opaque-string" {
1134        let other = if ak == "opaque-string" { bk } else { ak };
1135        return if matches!(other, "string" | "opaque-string") {
1136            None
1137        } else {
1138            Some(false)
1139        };
1140    }
1141    if ak != bk {
1142        return Some(false);
1143    }
1144    if matches!(ak, "object" | "array") {
1145        if !deep {
1146            return None;
1147        }
1148        let (ap, bp) = (a["properties"].as_array()?, b["properties"].as_array()?);
1149        if ap.len() != bp.len() {
1150            return Some(false);
1151        }
1152        let mut unknown = false;
1153        for p in ap {
1154            let Some(q) = bp.iter().find(|q| q["name"] == p["name"]) else {
1155                return Some(false);
1156            };
1157            match payload_equal(&p["value"], &q["value"], true) {
1158                Some(false) => return Some(false),
1159                None => unknown = true,
1160                _ => (),
1161            }
1162        }
1163        return if unknown { None } else { Some(true) };
1164    }
1165    if ak == "number" {
1166        return Some(a["value"].as_f64()? == b["value"].as_f64()?);
1167    }
1168    Some(a["value"] == b["value"])
1169}
1170
1171fn payload_predicate(c: &PayloadCheck) -> Option<bool> {
1172    if c.predicate == "node-literal-regexp" {
1173        if c.expected["kind"] != "substring-pattern" || c.actual["kind"] != "string" {
1174            return None;
1175        }
1176        return Some(
1177            c.actual["value"]
1178                .as_str()?
1179                .contains(c.expected["value"].as_str()?),
1180        );
1181    }
1182    payload_equal(
1183        &c.actual,
1184        &c.expected,
1185        c.predicate == "node-deep-strict-equality",
1186    )
1187}
1188
1189fn valid_payload_coercion(c: &PayloadCheck) -> bool {
1190    let Some(coercion) = &c.projection.coercion else {
1191        return true;
1192    };
1193    if coercion.source != c.projection.read_at || !valid_payload(&coercion.input, 0, &mut 4096) {
1194        return false;
1195    }
1196    match coercion.rule.as_str() {
1197        "string-identity" => {
1198            matches!(
1199                coercion.input["kind"].as_str(),
1200                Some("string" | "opaque-string" | "quoted-string")
1201            ) && c.actual == coercion.input
1202        }
1203        "plain-object-default-string" => {
1204            coercion.input["kind"] == "object"
1205                && coercion.input["properties"].as_array().is_some_and(|p| {
1206                    p.iter()
1207                        .all(|p| !matches!(p["name"].as_str(), Some("toString" | "valueOf")))
1208                })
1209                && c.actual == serde_json::json!({"kind":"string","value":"[object Object]"})
1210        }
1211        _ => false,
1212    }
1213}
1214
1215#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1216#[serde(rename_all = "camelCase")]
1217pub struct CountSensitivityEvidence {
1218    pub model: String,
1219    pub status: String,
1220    pub reason: Option<String>,
1221    pub scope: Option<String>,
1222    pub assertion_source: Option<String>,
1223    pub target_source: Option<String>,
1224    pub condition_source: Option<String>,
1225    pub condition_text: Option<String>,
1226    pub allocations: Option<Vec<String>>,
1227    pub instance: Option<String>,
1228    pub expected_count: Option<u64>,
1229    pub original_count: Option<u64>,
1230    pub variants: Option<Vec<CountSensitivityVariant>>,
1231}
1232
1233#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1234#[serde(rename_all = "camelCase")]
1235pub struct CountSensitivityVariant {
1236    pub change: String,
1237    pub status: String,
1238    pub reason: Option<String>,
1239    pub count: Option<u64>,
1240    pub outcome: Option<String>,
1241}
1242
1243/// A bounded check of one specified edit, never general value or site credit.
1244#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1245#[serde(rename_all = "camelCase")]
1246pub struct CallOmissionEvidence {
1247    pub model: String,
1248    pub status: String,
1249    pub reason: Option<String>,
1250    pub outcome: Option<String>,
1251    pub scope: Option<String>,
1252    pub assertion_source: Option<String>,
1253    pub call_source: Option<String>,
1254    pub callback_source: Option<String>,
1255    pub instance: Option<String>,
1256    pub expected_count: Option<u64>,
1257    pub original_count: Option<u64>,
1258    pub omitted_count: Option<u64>,
1259}
1260
1261#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1262#[serde(rename_all = "camelCase")]
1263pub struct AwaitedObservationSource {
1264    pub model: String,
1265    pub factory_source: String,
1266    pub predicate_source: String,
1267    pub captures: Vec<ObservationCaptureSource>,
1268    pub pattern: String,
1269}
1270
1271#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1272pub struct ObservationCaptureSource {
1273    pub stream: String,
1274    pub source: String,
1275}
1276
1277#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1278pub struct PragmaTarget {
1279    pub file: String,
1280    pub function: String,
1281    pub snippet: Option<String>,
1282    /// Explanatory text only. Never interpreted as a verified dependency.
1283    pub via: Option<String>,
1284}
1285
1286#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1287#[serde(rename_all = "kebab-case")]
1288pub enum HintValidation {
1289    AnalyzerSupported,
1290    Unresolved,
1291    Invalid,
1292}
1293
1294#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1295#[serde(rename_all = "camelCase")]
1296pub struct PragmaCheck {
1297    pub hint: PragmaHint,
1298    pub origin: String,
1299    pub validation: HintValidation,
1300    pub reason: String,
1301    #[serde(skip_serializing_if = "Option::is_none")]
1302    pub strength: Option<Strength>,
1303    /// Only the selected assertion's observations, never another assertion in its test.
1304    #[serde(skip_serializing_if = "Vec::is_empty")]
1305    pub observations: Vec<Observation>,
1306}
1307
1308/// Check source hints against existing facts, without modifying the normal join.
1309/// Indexes are shared across hints; only the selected test's observations are copied.
1310/// This bounded first pass supports effect boundaries/flow, not decision or internal
1311/// derivation proofs. Unsupported paths remain unresolved, not contradicted.
1312pub fn check_pragma_hints(facts: &Facts, hints: &[PragmaHint]) -> Vec<PragmaCheck> {
1313    if hints.is_empty() {
1314        return vec![];
1315    }
1316    let engine = Join {
1317        sites: facts.sites.iter().map(|s| (s.id.as_str(), s)).collect(),
1318        tests: facts.tests.iter().map(|t| (t.id.as_str(), t)).collect(),
1319        facts,
1320        resolved: BTreeMap::new(),
1321    };
1322    let mut assertions: BTreeMap<(&str, &str, &str), Vec<&Observation>> = BTreeMap::new();
1323    for test in &facts.tests {
1324        for ob in &test.observations {
1325            if let (Some(source), Some(method)) = (&ob.assertion_source, &ob.assertion_method)
1326                && ob.boundary != "pragma"
1327            {
1328                assertions
1329                    .entry((&test.id, source, method))
1330                    .or_default()
1331                    .push(ob);
1332            }
1333        }
1334    }
1335    hints
1336        .iter()
1337        .map(|hint| {
1338            let mut result = PragmaCheck {
1339                hint: hint.clone(),
1340                origin: "user-suggested".into(),
1341                validation: HintValidation::Unresolved,
1342                reason: "connection-not-established".into(),
1343                strength: None,
1344                observations: vec![],
1345            };
1346            if let Some(issue) = &hint.issue {
1347                result.reason = issue.clone();
1348                // Missing inventory can mean an unsupported source construct,
1349                // not a bad declaration. Do not call an analysis limit invalid.
1350                if !matches!(
1351                    issue.as_str(),
1352                    "no-owning-passed-test" | "target-not-in-inventory"
1353                ) {
1354                    result.validation = HintValidation::Invalid;
1355                }
1356                return result;
1357            }
1358            let [id] = hint.candidate_sites.as_slice() else {
1359                result.validation = HintValidation::Invalid;
1360                result.reason = "target-not-unique".into();
1361                return result;
1362            };
1363            let Some(site) = engine.sites.get(id.as_str()) else {
1364                result.validation = HintValidation::Invalid;
1365                result.reason = "target-not-found".into();
1366                return result;
1367            };
1368            if hint
1369                .target
1370                .as_ref()
1371                .is_none_or(|target| target.file != site.file)
1372            {
1373                result.validation = HintValidation::Invalid;
1374                result.reason = "target-file-mismatch".into();
1375                return result;
1376            }
1377            if hint.awaited_observation.is_some() {
1378                result.reason = "observation-capture-unavailable".into();
1379                return result;
1380            }
1381            if hint.witness != "passed" || hint.witness_issue.is_some() {
1382                result.reason = hint
1383                    .witness_issue
1384                    .clone()
1385                    .unwrap_or_else(|| "missing-passed-witness".into());
1386                return result;
1387            }
1388            let Some(test) = hint
1389                .test
1390                .as_ref()
1391                .and_then(|id| engine.tests.get(id.as_str()))
1392            else {
1393                result.reason = "no-owning-passed-test".into();
1394                return result;
1395            };
1396            if test
1397                .witness_issues
1398                .iter()
1399                .any(|issue| issue.kind == WitnessIssueKind::TestSourceUnlinked)
1400            {
1401                result.reason = "test-source-unlinked".into();
1402                return result;
1403            }
1404            if test
1405                .witness_issues
1406                .iter()
1407                .any(|issue| issue.kind == WitnessIssueKind::TestRegistrationScopeUnverified)
1408            {
1409                result.reason = "test-registration-scope-unverified".into();
1410                return result;
1411            }
1412            if hint.assertion_source.as_ref().is_none_or(String::is_empty)
1413                || hint.assertion_method.as_ref().is_none_or(String::is_empty)
1414            {
1415                result.reason = "missing-assertion-identity".into();
1416                return result;
1417            }
1418            if !site.covered_by.contains(&test.id) {
1419                result.reason = "target-not-reached-in-owning-test".into();
1420                return result;
1421            }
1422            if let Some(recipe) = &hint.check {
1423                result.reason = "omission-check-unavailable".into();
1424                let selected = assertions.get(&(
1425                    test.id.as_str(),
1426                    hint.assertion_source.as_deref().unwrap(),
1427                    hint.assertion_method.as_deref().unwrap(),
1428                ));
1429                if recipe == "completion" {
1430                    result.reason = "completion-sensitivity-unavailable".into();
1431                    let Some(e) = &hint.completion_sensitivity else {
1432                        return result;
1433                    };
1434                    if let Some(issue) = completion_evidence_issue(hint, e, site, test) {
1435                        result.reason = issue;
1436                        return result;
1437                    }
1438                    result.validation = HintValidation::AnalyzerSupported;
1439                    result.reason = "modeled-completion-sensitivity".into();
1440                    return result; // One exact omission/prefix, not general site or MC/DC credit.
1441                }
1442                if recipe == "value" {
1443                    if let (Some(direct), Some(payload)) =
1444                        (&hint.direct_return_sensitivity, &hint.payload_sensitivity)
1445                        && (direct.status == "source-checked" || payload.status == "source-checked")
1446                    {
1447                        result.reason = "conflicting-value-models".into();
1448                        return result;
1449                    }
1450                    if let Some(e) = &hint.direct_return_sensitivity
1451                        && (hint.payload_sensitivity.is_none() || e.status == "source-checked")
1452                    {
1453                        if let Some(issue) = direct_return_evidence_issue(hint, e, site, test) {
1454                            result.reason = issue;
1455                            return result;
1456                        }
1457                        result.validation = HintValidation::AnalyzerSupported;
1458                        result.reason = "modeled-direct-return-sensitivity".into();
1459                        return result; // Exact predicate outcomes only, no site/MC-DC credit.
1460                    }
1461                    result.reason = "payload-sensitivity-unavailable".into();
1462                    let Some(e) = &hint.payload_sensitivity else {
1463                        return result;
1464                    };
1465                    let in_file = |s: &Option<String>| {
1466                        s.as_ref()
1467                            .is_some_and(|s| s.starts_with(&format!("{}:", site.file)))
1468                    };
1469                    if e.model != "node-closed-payload-sensitivity-v2"
1470                        || e.status != "source-checked"
1471                        || e.reason.is_some()
1472                        || e.scope.as_deref() != Some("closed-synchronous-test-module")
1473                        || e.assertion_source != hint.assertion_source
1474                        || !in_file(&e.target_source)
1475                        || !in_file(&e.change_source)
1476                        || e.change_text.as_ref().is_none_or(String::is_empty)
1477                        || e.allocations.as_ref().is_none_or(|a| {
1478                            a.is_empty()
1479                                || a.iter().any(|s| !s.starts_with(&format!("{}:", site.file)))
1480                        })
1481                        || !(site.kind == "decision" || site.category == "return")
1482                    {
1483                        result.reason = e
1484                            .reason
1485                            .clone()
1486                            .unwrap_or_else(|| "unsupported-payload-sensitivity-evidence".into());
1487                        return result;
1488                    }
1489                    let (Some(original), Some(variants)) = (&e.original, &e.variants) else {
1490                        return result;
1491                    };
1492                    let valid_check = |c: &PayloadCheck, original_witness: bool| {
1493                        let mut budget = 4096;
1494                        let p = &c.projection;
1495                        matches!(
1496                            c.predicate.as_str(),
1497                            "node-same-value" | "node-deep-strict-equality" | "node-literal-regexp"
1498                        ) && p.instance.starts_with(&format!("{}:", test.file))
1499                            && p.read_at.starts_with(&format!("{}:", test.file))
1500                            && p.call_source.starts_with(&format!("{}:", site.file))
1501                            && p.history_selections.as_ref().is_none_or(|ss| {
1502                                ss.iter().all(|s| {
1503                                    !s.source.is_empty() && s.from <= s.to && s.to <= s.input_count
1504                                })
1505                            })
1506                            && valid_payload(&c.actual, 0, &mut budget)
1507                            && valid_payload(&c.expected, 0, &mut budget)
1508                            && valid_payload_coercion(c)
1509                            && (c.predicate == "node-literal-regexp")
1510                                == (c.expected["kind"] == "substring-pattern")
1511                            && match payload_predicate(c) {
1512                                Some(eq) => {
1513                                    c.outcome == if eq { "not-rejected" } else { "rejected" }
1514                                }
1515                                None => {
1516                                    original_witness
1517                                        && c.outcome == "witnessed-pass"
1518                                        && matches!(
1519                                            c.actual["kind"].as_str(),
1520                                            Some("opaque-string" | "quoted-string")
1521                                        )
1522                                        && matches!(
1523                                            c.expected["kind"].as_str(),
1524                                            Some("string" | "substring-pattern")
1525                                        )
1526                                }
1527                            }
1528                    };
1529                    if !valid_check(original, true)
1530                        || !matches!(original.outcome.as_str(), "not-rejected" | "witnessed-pass")
1531                        || !matches!(
1532                            (
1533                                hint.assertion_method.as_deref(),
1534                                original.predicate.as_str()
1535                            ),
1536                            (Some("equal" | "strictEqual"), "node-same-value")
1537                                | (
1538                                    Some("deepEqual" | "deepStrictEqual"),
1539                                    "node-deep-strict-equality"
1540                                )
1541                                | (Some("match"), "node-literal-regexp")
1542                        )
1543                    {
1544                        result.reason = "payload-assertion-not-modelled".into();
1545                        return result;
1546                    }
1547                    // Unlike the legacy boundary heuristic, the source interpreter
1548                    // derives the exact argument through history aliases. The owning
1549                    // passing witness above remains mandatory; no observation is invented.
1550                    let names: Vec<_> = variants.iter().map(|v| v.change.as_str()).collect();
1551                    let mut sorted = names;
1552                    sorted.sort();
1553                    if !(sorted == ["map-callback-empty"]
1554                        || sorted == ["condition-false", "condition-inverted", "condition-true"])
1555                        || variants.iter().any(|v| match v.status.as_str() {
1556                            "unresolved" => {
1557                                v.reason.as_ref().is_none_or(String::is_empty) || v.check.is_some()
1558                            }
1559                            "source-checked" => {
1560                                v.reason.is_some()
1561                                    || v.check.as_ref().is_none_or(|c| {
1562                                        !valid_check(c, false)
1563                                            || c.expected != original.expected
1564                                            || c.predicate != original.predicate
1565                                            || c.projection.instance != original.projection.instance
1566                                            || c.projection.argument_index
1567                                                != original.projection.argument_index
1568                                            || c.projection.read_at != original.projection.read_at
1569                                    })
1570                            }
1571                            _ => true,
1572                        })
1573                    {
1574                        result.reason = "inconsistent-payload-sensitivity-variants".into();
1575                        return result;
1576                    }
1577                    if variants.iter().all(|v| v.status != "source-checked") {
1578                        return result;
1579                    }
1580                    result.validation = HintValidation::AnalyzerSupported;
1581                    result.reason = "modeled-payload-sensitivity".into();
1582                    return result; // Explicit variant answers only; no general site credit.
1583                }
1584                if selected.is_none_or(|observations| {
1585                    !observations.iter().any(|ob| {
1586                        ob.mock.as_ref().is_some_and(|m| m.kind == "call-count")
1587                            && ob
1588                                .comparison
1589                                .as_ref()
1590                                .is_some_and(|c| c.predicate == "node-same-value")
1591                    })
1592                }) {
1593                    result.reason = "omission-count-assertion-not-modelled".into();
1594                    return result;
1595                }
1596                if recipe == "count" {
1597                    result.reason = "count-sensitivity-unavailable".into();
1598                    let Some(check) = &hint.count_sensitivity else {
1599                        return result;
1600                    };
1601                    let in_file = |s: &Option<String>| {
1602                        s.as_ref()
1603                            .is_some_and(|s| s.starts_with(&format!("{}:", site.file)))
1604                    };
1605                    if check.model != "node-closed-count-sensitivity-v1"
1606                        || check.status != "source-checked"
1607                        || check.reason.is_some()
1608                        || check.scope.as_deref() != Some("closed-synchronous-test-module")
1609                        || check.assertion_source != hint.assertion_source
1610                        || !in_file(&check.target_source)
1611                        || !in_file(&check.condition_source)
1612                        || check.condition_text.as_ref().is_none_or(String::is_empty)
1613                        || check.instance.as_ref().is_none_or(String::is_empty)
1614                        || check.allocations.as_ref().is_none_or(|a| {
1615                            a.is_empty()
1616                                || a.iter().any(|s| !s.starts_with(&format!("{}:", site.file)))
1617                        })
1618                        || !(site.kind == "decision" || site.category == "return")
1619                    {
1620                        result.reason = check
1621                            .reason
1622                            .clone()
1623                            .unwrap_or_else(|| "unsupported-count-sensitivity-evidence".into());
1624                        return result;
1625                    }
1626                    let (Some(expected), Some(original), Some(variants)) =
1627                        (check.expected_count, check.original_count, &check.variants)
1628                    else {
1629                        return result;
1630                    };
1631                    let expected_changes =
1632                        ["condition-true", "condition-false", "condition-inverted"];
1633                    if expected != original
1634                        || variants.len() != 3
1635                        || !expected_changes
1636                            .iter()
1637                            .all(|name| variants.iter().filter(|v| v.change == *name).count() == 1)
1638                        || variants.iter().any(|v| match v.status.as_str() {
1639                            "source-checked" => {
1640                                v.reason.is_some()
1641                                    || v.count.is_none()
1642                                    || v.outcome.as_deref()
1643                                        != Some(if v.count == Some(expected) {
1644                                            "not-rejected"
1645                                        } else {
1646                                            "rejected"
1647                                        })
1648                            }
1649                            "unresolved" => {
1650                                v.reason.as_ref().is_none_or(String::is_empty)
1651                                    || v.count.is_some()
1652                                    || v.outcome.is_some()
1653                            }
1654                            _ => true,
1655                        })
1656                    {
1657                        result.reason = "inconsistent-count-sensitivity-variants".into();
1658                        return result;
1659                    }
1660                    if variants.iter().all(|v| v.status != "source-checked") {
1661                        return result;
1662                    }
1663                    result.validation = HintValidation::AnalyzerSupported;
1664                    result.reason = "modeled-count-sensitivity".into();
1665                    // Explicit per-variant answers only; no join/whole-site credit.
1666                    return result;
1667                }
1668                let Some(check) = &hint.call_omission else {
1669                    return result;
1670                };
1671                if recipe != "missing-call"
1672                    || check.model != "node-first-test-call-omission-v1"
1673                    || check.status != "source-checked"
1674                    || check.reason.is_some()
1675                    || check.scope.as_deref() != Some("first-synchronous-test")
1676                    || check.assertion_source != hint.assertion_source
1677                    || check
1678                        .call_source
1679                        .as_ref()
1680                        .is_none_or(|s| !s.starts_with(&format!("{}:", site.file)))
1681                    || check.callback_source.as_ref().is_none_or(String::is_empty)
1682                    || check.instance.as_ref().is_none_or(String::is_empty)
1683                    || site.kind != "effect"
1684                {
1685                    result.reason = check
1686                        .reason
1687                        .clone()
1688                        .unwrap_or_else(|| "unsupported-omission-evidence".into());
1689                    return result;
1690                }
1691                let (Some(expected), Some(original), Some(omitted)) = (
1692                    check.expected_count,
1693                    check.original_count,
1694                    check.omitted_count,
1695                ) else {
1696                    return result;
1697                };
1698                let outcome = if omitted != expected {
1699                    "rejected"
1700                } else {
1701                    "not-rejected"
1702                };
1703                if original != expected || check.outcome.as_deref() != Some(outcome) {
1704                    result.reason = "inconsistent-omission-counts".into();
1705                    return result;
1706                }
1707                result.validation = HintValidation::AnalyzerSupported;
1708                result.reason = format!("modeled-callback-omission-{outcome}");
1709                // Do not promote ordinary observations, site strength, or the join.
1710                return result;
1711            }
1712            if site.kind != "effect" {
1713                result.reason = "decision-hint-analysis-not-supported".into();
1714                return result;
1715            }
1716            let selected = TestFacts {
1717                id: test.id.clone(),
1718                file: test.file.clone(),
1719                observations: assertions
1720                    .get(&(
1721                        test.id.as_str(),
1722                        hint.assertion_source.as_deref().unwrap(),
1723                        hint.assertion_method.as_deref().unwrap(),
1724                    ))
1725                    .into_iter()
1726                    .flatten()
1727                    .map(|ob| (*ob).clone())
1728                    .collect(),
1729                sinks: test.sinks.clone(),
1730                rendered: test.rendered.clone(),
1731                witness_issues: vec![],
1732            };
1733            if selected.observations.is_empty() {
1734                result.reason = "assertion-operand-not-modelled".into();
1735                return result;
1736            }
1737            let resolution = engine.resolve_effect_for(site, vec![&selected]);
1738            if resolution.strength.is_some() && resolution.tests.contains(&test.id) {
1739                let bounds = engine.bounds_for_test(site, &selected);
1740                result.observations = selected
1741                    .observations
1742                    .iter()
1743                    .filter(|ob| {
1744                        engine.observation_hits(site, &selected, &bounds, ob)
1745                            && !(site.category == "log" && ob.pattern_shared)
1746                    })
1747                    .cloned()
1748                    .collect();
1749                result.validation = HintValidation::AnalyzerSupported;
1750                result.reason = "existing-effect-rules-support-this-assertion-link".into();
1751                result.strength = resolution.strength;
1752            }
1753            result
1754        })
1755        .collect()
1756}
1757
1758// ---------------------------------------------------------------------------
1759// Verdicts out
1760// ---------------------------------------------------------------------------
1761
1762#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1763#[serde(rename_all = "lowercase")]
1764pub enum Status {
1765    Evident,
1766    Presence,
1767    Partial,
1768    Unresolved,
1769}
1770
1771/// Why a site is not evident. A gap is closable by writing a test; a limit is something the frontend
1772/// could not follow, and an agent that writes a test for one either wastes the effort or learns to
1773/// satisfy the analyzer instead of the code.
1774#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1775pub enum ReasonKind {
1776    #[serde(rename = "gap:not-reached")]
1777    GapNotReached,
1778    #[serde(rename = "gap:not-asserted")]
1779    GapNotAsserted,
1780    #[serde(rename = "gap:outcome-not-asserted")]
1781    GapOutcomeNotAsserted,
1782    #[serde(rename = "gap:value-not-asserted")]
1783    GapValueNotAsserted,
1784    #[serde(rename = "limit:operand-shape")]
1785    LimitOperandShape,
1786    #[serde(rename = "limit:internal-state")]
1787    LimitInternalState,
1788    #[serde(rename = "limit:undecidable")]
1789    LimitUndecidable,
1790    #[serde(rename = "limit:assertion-witness")]
1791    LimitAssertionWitness,
1792    #[serde(rename = "limit:predicate-dependence")]
1793    LimitPredicateDependence,
1794    #[serde(rename = "limit:process-exit-link")]
1795    LimitProcessExitLink,
1796}
1797
1798impl ReasonKind {
1799    pub fn is_limit(self) -> bool {
1800        matches!(
1801            self,
1802            ReasonKind::LimitOperandShape
1803                | ReasonKind::LimitInternalState
1804                | ReasonKind::LimitUndecidable
1805                | ReasonKind::LimitAssertionWitness
1806                | ReasonKind::LimitPredicateDependence
1807                | ReasonKind::LimitProcessExitLink
1808        )
1809    }
1810}
1811
1812#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1813#[serde(rename_all = "camelCase")]
1814pub struct Reason {
1815    pub kind: ReasonKind,
1816    #[serde(default, skip_serializing_if = "Option::is_none")]
1817    pub detail: Option<String>,
1818}
1819
1820#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1821#[serde(rename_all = "camelCase")]
1822pub struct Resolution {
1823    pub site: String,
1824    pub status: Status,
1825    #[serde(default, skip_serializing_if = "Option::is_none")]
1826    pub strength: Option<Strength>,
1827    #[serde(default, skip_serializing_if = "Option::is_none")]
1828    pub reason: Option<Reason>,
1829    pub covered_by: usize,
1830    /// tests whose observations produced the evidence
1831    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
1832    pub tests: BTreeSet<String>,
1833    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1834    pub weak_only: bool,
1835    /// Basis of the forced-outcome flags, not global semantic certainty.
1836    #[serde(default, skip_serializing_if = "Option::is_none")]
1837    pub sensitivity_basis: Option<SensitivityBasis>,
1838    #[serde(default, skip_serializing_if = "Option::is_none")]
1839    pub stuck_true_caught: Option<bool>,
1840    #[serde(default, skip_serializing_if = "Option::is_none")]
1841    pub stuck_false_caught: Option<bool>,
1842    #[serde(default, skip_serializing_if = "Option::is_none")]
1843    pub absence_needed: Option<bool>,
1844    #[serde(default, skip_serializing_if = "Option::is_none")]
1845    pub value_observed: Option<bool>,
1846    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1847    pub witness_issues: Vec<TestWitnessIssue>,
1848}
1849
1850#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1851#[serde(rename_all = "kebab-case")]
1852pub enum SensitivityBasis {
1853    BoundedSourceModel,
1854    BranchObservationHeuristic,
1855    Unavailable,
1856}
1857
1858// ---------------------------------------------------------------------------
1859// The join
1860// ---------------------------------------------------------------------------
1861
1862struct Join<'a> {
1863    sites: BTreeMap<&'a str, &'a Site>,
1864    tests: BTreeMap<&'a str, &'a TestFacts>,
1865    facts: &'a Facts,
1866    resolved: BTreeMap<String, Resolution>,
1867}
1868
1869/// Resolve every site. Effect sites first, since a decision's outcomes are judged by the strength of the
1870/// sites they control; then decisions; then internal effects derived through their dependents, which can
1871/// promote a site and so are followed by a second decision pass, exactly as the prototype does.
1872pub fn join(facts: &Facts) -> Vec<Resolution> {
1873    let mut join = Join {
1874        sites: facts.sites.iter().map(|s| (s.id.as_str(), s)).collect(),
1875        tests: facts.tests.iter().map(|t| (t.id.as_str(), t)).collect(),
1876        facts,
1877        resolved: BTreeMap::new(),
1878    };
1879    for site in &facts.sites {
1880        if site.kind == "effect" {
1881            let r = join.resolve_effect(site);
1882            join.resolved.insert(site.id.clone(), r);
1883        }
1884    }
1885    join.resolve_decisions();
1886    for site in &facts.sites {
1887        if site.kind != "effect" {
1888            continue;
1889        }
1890        let unresolved = join
1891            .resolved
1892            .get(&site.id)
1893            .is_some_and(|r| r.status == Status::Unresolved);
1894        if !unresolved || site.derive.is_empty() {
1895            continue;
1896        }
1897        if let Some(derived) = join.derive_internal(site) {
1898            join.resolved.insert(site.id.clone(), derived);
1899        }
1900    }
1901    join.resolve_decisions();
1902    facts
1903        .sites
1904        .iter()
1905        .filter_map(|s| {
1906            join.resolved.get(&s.id).cloned().map(|r| {
1907                let mut r = join.with_witness_issues(s, join.with_comparison_limits(s, r));
1908                if s.kind == "decision" {
1909                    r.sensitivity_basis = Some(
1910                        if r.stuck_true_caught.is_none() || r.stuck_false_caught.is_none() {
1911                            SensitivityBasis::Unavailable
1912                        } else if s.decision.as_ref().is_some_and(|d| d.primitive.is_some()) {
1913                            // Only successful primitive_sensitivity checks produce flags
1914                            // when a primitive model is present; invalid models do not fall
1915                            // through to the heuristic path.
1916                            SensitivityBasis::BoundedSourceModel
1917                        } else {
1918                            SensitivityBasis::BranchObservationHeuristic
1919                        },
1920                    );
1921                }
1922                r
1923            })
1924        })
1925        .collect()
1926}
1927
1928impl<'a> Join<'a> {
1929    fn resolve_decisions(&mut self) {
1930        for site in &self.facts.sites {
1931            if site.kind == "decision" {
1932                let r = self.resolve_decision(site);
1933                self.resolved.insert(site.id.clone(), r);
1934            }
1935        }
1936    }
1937
1938    /// The strongest strength among these sites, optionally restricted to sites whose evidence came from
1939    /// one of `within`: with per-test outcomes known, only a test that took an outcome can have observed
1940    /// the sites that outcome controls.
1941    fn strength_of_sites(
1942        &self,
1943        ids: &[String],
1944        within: Option<&BTreeSet<&str>>,
1945    ) -> Option<Strength> {
1946        let mut best = None;
1947        for id in ids {
1948            let Some(r) = self.resolved.get(id) else {
1949                continue;
1950            };
1951            let Some(strength) = r.strength else {
1952                continue;
1953            };
1954            if let Some(within) = within
1955                && !r.tests.iter().any(|t| within.contains(t.as_str()))
1956            {
1957                continue;
1958            }
1959            best = stronger(best, Some(strength));
1960        }
1961        best
1962    }
1963
1964    /// The given boundaries plus what the test file's module mocks bind at this site.
1965    fn with_mocks(&self, site: &Site, test: &TestFacts, base: &[Boundary]) -> Vec<Boundary> {
1966        let mut bounds = base.to_vec();
1967        if let Some(per_site) = self.facts.mocks_by_test_file.get(&test.file)
1968            && let Some(extra) = per_site.get(&site.id)
1969        {
1970            bounds.extend(extra.iter().cloned());
1971        }
1972        bounds
1973    }
1974
1975    /// Boundaries of a site as one test sees them: its own, its module mocks, and the DOM when the test
1976    /// rendered the component the site belongs to. The DOM applies to the site under judgement only: a
1977    /// site merely reached by its value is not read through the page that rendered something else.
1978    fn bounds_for_test(&self, site: &Site, test: &TestFacts) -> Vec<Boundary> {
1979        let mut bounds = self.with_mocks(site, test, &site.bounds);
1980        if (site.category == "return" || site.category == "callback-return")
1981            && test.rendered.contains(&site.owner)
1982        {
1983            bounds.push(Boundary {
1984                boundary: "dom".into(),
1985                facet: None,
1986                via: Some("rendered component".into()),
1987            });
1988        }
1989        bounds
1990    }
1991
1992    /// Does this observation read a sink the test wired into production at this site?
1993    fn sink_hit(
1994        &self,
1995        test: &TestFacts,
1996        site: &Site,
1997        bounds: &[Boundary],
1998        ob: &Observation,
1999    ) -> bool {
2000        if !ob.can_constrain_value() {
2001            return false;
2002        }
2003        for sink in &test.sinks {
2004            let injected = bounds.iter().any(|b| {
2005                b.boundary == format!("callback:{}", sink.param)
2006                    && match (&sink.member, &b.facet) {
2007                        (None, _) => true,
2008                        (Some(_), None) => false,
2009                        (Some(member), Some(facet)) => {
2010                            facet == member
2011                                || facet.starts_with(&format!("{member}."))
2012                                || facet.starts_with('*')
2013                        }
2014                    }
2015            });
2016            let log_sink = site.category == "log"
2017                && sink.param == "logger"
2018                && sink
2019                    .member
2020                    .as_deref()
2021                    .is_none_or(|m| Some(m) == site.method.as_deref());
2022            // `expect(table.upsert)`: a leaf of the sink object pins only calls of that method
2023            let leaf = ob
2024                .boundary
2025                .strip_prefix(&format!("{}.", sink.sink))
2026                .and_then(|rest| rest.split('.').next_back());
2027            let same_sink = ob.boundary == sink.sink
2028                || leaf
2029                    .is_some_and(|leaf| site.method.as_deref().is_none_or(|method| leaf == method));
2030            // a sink on a dense channel still only pins the messages its constraint admits
2031            let message_fits = site.category != "log"
2032                || ob
2033                    .log_sites
2034                    .as_ref()
2035                    .is_none_or(|admitted| admitted.contains(&site.id));
2036            if (injected || log_sink) && same_sink && message_fits {
2037                return true;
2038            }
2039        }
2040        false
2041    }
2042
2043    fn resolve_effect(&self, site: &Site) -> Resolution {
2044        let covering: Vec<&TestFacts> = site
2045            .covered_by
2046            .iter()
2047            .filter_map(|id| self.tests.get(id.as_str()).copied())
2048            .collect();
2049        self.resolve_effect_for(site, covering)
2050    }
2051
2052    fn resolve_effect_for(&self, site: &Site, covering: Vec<&TestFacts>) -> Resolution {
2053        // An effect with no boundary, reaching no other site and with no per-test extra, is internal:
2054        // only a supported derivation can resolve it; comments are not evidence.
2055        let any_extra = covering
2056            .iter()
2057            .any(|t| self.bounds_for_test(site, t).len() > site.bounds.len());
2058        if site.bounds.iter().all(Boundary::internal) && site.reached.is_empty() && !any_extra {
2059            return Resolution {
2060                site: site.id.clone(),
2061                status: Status::Unresolved,
2062                strength: None,
2063                reason: Some(Reason {
2064                    kind: ReasonKind::LimitInternalState,
2065                    detail: None,
2066                }),
2067                covered_by: site.covered_by.len(),
2068                tests: BTreeSet::new(),
2069                weak_only: false,
2070                sensitivity_basis: None,
2071                stuck_true_caught: None,
2072                stuck_false_caught: None,
2073                absence_needed: None,
2074                value_observed: None,
2075                witness_issues: vec![],
2076            };
2077        }
2078        let mut best: Option<Strength> = None;
2079        let mut tests = BTreeSet::new();
2080        let mut strong_hit = false;
2081        for test in &covering {
2082            let bounds = self.bounds_for_test(site, test);
2083            for ob in &test.observations {
2084                if !self.observation_hits(site, test, &bounds, ob) {
2085                    continue;
2086                }
2087                // a regex several log sites could satisfy pins none of them individually
2088                if site.category == "log" && ob.pattern_shared {
2089                    continue;
2090                }
2091                best = stronger(best, Some(ob.strength));
2092                tests.insert(test.id.clone());
2093                if !ob.weak {
2094                    strong_hit = true;
2095                }
2096            }
2097        }
2098        let Some(mut best) = best else {
2099            let boundaries: BTreeSet<&str> =
2100                site.bounds.iter().map(|b| b.boundary.as_str()).collect();
2101            let reason = if site.covered_by.is_empty() {
2102                Reason {
2103                    kind: ReasonKind::GapNotReached,
2104                    detail: None,
2105                }
2106            } else if !site.unmodelled_shapes.is_empty() {
2107                Reason {
2108                    kind: ReasonKind::LimitOperandShape,
2109                    detail: Some(site.unmodelled_shapes.join("; ")),
2110                }
2111            } else {
2112                Reason {
2113                    kind: ReasonKind::GapNotAsserted,
2114                    detail: Some(boundaries.into_iter().collect::<Vec<_>>().join("|")),
2115                }
2116            };
2117            return Resolution {
2118                site: site.id.clone(),
2119                status: Status::Unresolved,
2120                strength: None,
2121                reason: Some(reason),
2122                covered_by: site.covered_by.len(),
2123                tests: BTreeSet::new(),
2124                weak_only: false,
2125                sensitivity_basis: None,
2126                stuck_true_caught: None,
2127                stuck_false_caught: None,
2128                absence_needed: None,
2129                value_observed: None,
2130                witness_issues: vec![],
2131            };
2132        };
2133        // Returning one of several pre-built objects: downstream observations show that *a* value
2134        // arrived, not which one.
2135        if site.object_valued_return {
2136            best = Strength::Presence;
2137        }
2138        Resolution {
2139            site: site.id.clone(),
2140            status: if best == Strength::Presence {
2141                Status::Presence
2142            } else {
2143                Status::Evident
2144            },
2145            strength: Some(best),
2146            reason: (best == Strength::Presence).then_some(Reason {
2147                kind: ReasonKind::GapValueNotAsserted,
2148                detail: None,
2149            }),
2150            covered_by: site.covered_by.len(),
2151            tests,
2152            weak_only: !strong_hit,
2153            sensitivity_basis: None,
2154            stuck_true_caught: None,
2155            stuck_false_caught: None,
2156            absence_needed: None,
2157            value_observed: None,
2158            witness_issues: vec![],
2159        }
2160    }
2161
2162    /// The same boundary/instance rules used by positive observations. Reusing
2163    /// them for limits must never turn a suppressed observation into credit.
2164    fn observation_hits(
2165        &self,
2166        site: &Site,
2167        test: &TestFacts,
2168        bounds: &[Boundary],
2169        ob: &Observation,
2170    ) -> bool {
2171        if bounds.iter().any(|b| ob.matches(site, b)) || self.sink_hit(test, site, &site.bounds, ob)
2172        {
2173            return true;
2174        }
2175        site.reached.iter().any(|id| {
2176            let Some(reached) = self.sites.get(id.as_str()) else {
2177                return false;
2178            };
2179            if !reached.covered_by.contains(&test.id) {
2180                return false;
2181            }
2182            let bounds = self.with_mocks(reached, test, &reached.direct_bounds);
2183            bounds
2184                .iter()
2185                .any(|b| !b.internal() && ob.matches(reached, b))
2186                || self.sink_hit(test, reached, &bounds, ob)
2187        })
2188    }
2189
2190    /// Over-approximate which rejected observations could affect a candidate,
2191    /// walking only its recorded flow/control/derivation edges. This explains
2192    /// uncertainty; it is not a new proof of dependence. Root test ownership is
2193    /// checked by the caller. No coverage requirement at a negative target:
2194    /// an absence assertion can observe a branch whose effect never executed.
2195    fn issue_reaches(
2196        &self,
2197        site: &Site,
2198        test: &TestFacts,
2199        ob: &Observation,
2200        seen: &mut BTreeSet<String>,
2201    ) -> bool {
2202        // A dependent comparison may have a missing witness as well as an
2203        // unresolved value relationship. Retain structural relevance for limit
2204        // reporting only; the positive join always keeps its comparison guard.
2205        let mut structural;
2206        let ob = if ob
2207            .comparison
2208            .as_ref()
2209            .is_some_and(|c| c.relation == "shared-input-through-await")
2210        {
2211            structural = ob.clone();
2212            structural.comparison = None;
2213            &structural
2214        } else {
2215            ob
2216        };
2217        if !seen.insert(site.id.clone()) {
2218            return false;
2219        }
2220        let bounds = self.bounds_for_test(site, test);
2221        if self.observation_hits(site, test, &bounds, ob)
2222            || (ob.boundary == "exit" && bounds.iter().any(|b| b.boundary == "exit"))
2223        {
2224            return true;
2225        }
2226        let mut edges = site.reached.clone();
2227        for dep in &site.derive {
2228            edges.push(dep.site.clone());
2229            edges.extend(dep.requires_total.iter().flatten().cloned());
2230        }
2231        if let Some(d) = &site.decision {
2232            edges.extend(d.carrier.iter().cloned());
2233            for ids in [
2234                &d.value_flow,
2235                &d.then,
2236                &d.early_exit_downstream,
2237                &d.loop_body,
2238            ] {
2239                edges.extend(ids.iter().flatten().cloned());
2240            }
2241            edges.extend(d.else_.iter().flatten().flatten().cloned());
2242            for entry in d.default_kept.iter().flatten() {
2243                edges.push(entry.write.clone());
2244                edges.extend(entry.dependents.iter().map(|d| d.site.clone()));
2245            }
2246            if let Some(o) = &d.object_valued {
2247                edges.extend(o.only_a.iter().chain(&o.only_b).cloned());
2248            }
2249        }
2250        edges.iter().any(|id| {
2251            self.sites
2252                .get(id.as_str())
2253                .is_some_and(|s| self.issue_reaches(s, test, ob, seen))
2254        })
2255    }
2256
2257    /// Keep a passing dependent predicate visible as an analysis limit, not a
2258    /// missing assertion. Structural relevance is used only to explain limits;
2259    /// it never supplies a strength, caught flag or evidence test set.
2260    fn with_comparison_limits(&self, site: &Site, mut result: Resolution) -> Resolution {
2261        let untaken = site
2262            .decision
2263            .as_ref()
2264            .and_then(|d| d.outcomes.as_ref())
2265            .is_some_and(|o| {
2266                (result.stuck_false_caught == Some(false) && o.true_.is_empty())
2267                    || (result.stuck_true_caught == Some(false) && o.false_.is_empty())
2268            });
2269        if result.status == Status::Evident
2270            || untaken
2271            || !result.reason.as_ref().is_some_and(|r| {
2272                matches!(
2273                    r.kind,
2274                    ReasonKind::GapNotAsserted
2275                        | ReasonKind::GapOutcomeNotAsserted
2276                        | ReasonKind::GapValueNotAsserted
2277                )
2278            })
2279        {
2280            return result;
2281        }
2282        let exit_relevant = site
2283            .covered_by
2284            .iter()
2285            .filter_map(|id| self.tests.get(id.as_str()))
2286            .any(|test| {
2287                test.observations.iter().any(|ob| {
2288                    ob.boundary == "exit"
2289                        && self.issue_reaches(site, test, ob, &mut BTreeSet::new())
2290                })
2291            });
2292        if exit_relevant {
2293            result.reason = Some(Reason {
2294                kind: ReasonKind::LimitProcessExitLink,
2295                detail: Some("A passing assertion has exit-related source evidence, but the selected child, resolved result history and production-site link are not jointly established. See processExit in test observations.".into()),
2296            });
2297            return result;
2298        }
2299        let relevant = site
2300            .covered_by
2301            .iter()
2302            .filter_map(|id| self.tests.get(id.as_str()))
2303            .any(|test| {
2304                test.observations.iter().any(|ob| {
2305                    if ob
2306                        .comparison
2307                        .as_ref()
2308                        .is_none_or(|c| c.relation != "shared-input-through-await")
2309                    {
2310                        return false;
2311                    }
2312                    self.issue_reaches(site, test, ob, &mut BTreeSet::new())
2313                })
2314            });
2315        if relevant {
2316            result.reason = Some(Reason {
2317                kind: ReasonKind::LimitPredicateDependence,
2318                detail: Some("A passing assertion compares values from the same immutable input through await; its constraints on the producer value are not established. See comparison operand inputs in test observations.".into()),
2319            });
2320        }
2321        result
2322    }
2323
2324    /// Final reporting pass only: strengths, statuses, caught flags, evidence
2325    /// test sets and the denominator are unchanged. Unknown evidence may replace
2326    /// an assertion gap with a limit, but never a known execution gap.
2327    fn with_witness_issues(&self, site: &Site, mut result: Resolution) -> Resolution {
2328        for id in &site.covered_by {
2329            let Some(test) = self.tests.get(id.as_str()) else {
2330                continue;
2331            };
2332            for issue in &test.witness_issues {
2333                let relevant = match &issue.observation {
2334                    None => issue.kind.applies_to_whole_test(),
2335                    Some(ob) => self.issue_reaches(site, test, ob, &mut BTreeSet::new()),
2336                };
2337                if relevant {
2338                    result.witness_issues.push(TestWitnessIssue {
2339                        test: id.clone(),
2340                        issue: issue.clone(),
2341                    });
2342                }
2343            }
2344        }
2345        let untaken = site
2346            .decision
2347            .as_ref()
2348            .and_then(|d| d.outcomes.as_ref())
2349            .is_some_and(|o| {
2350                (result.stuck_false_caught == Some(false) && o.true_.is_empty())
2351                    || (result.stuck_true_caught == Some(false) && o.false_.is_empty())
2352            });
2353        if result.status != Status::Evident
2354            && !untaken
2355            && result.reason.as_ref().is_some_and(|reason| {
2356                matches!(
2357                    reason.kind,
2358                    ReasonKind::GapNotAsserted
2359                        | ReasonKind::GapOutcomeNotAsserted
2360                        | ReasonKind::GapValueNotAsserted
2361                )
2362            })
2363            && result
2364                .witness_issues
2365                .iter()
2366                .any(|w| w.issue.kind.is_uncertain())
2367        {
2368            result.reason = Some(Reason {kind: ReasonKind::LimitAssertionWitness,
2369                detail: Some("Assertion evidence is unavailable or inconclusive; see witnessIssues. This is not proof that the test lacks an assertion.".into())});
2370        }
2371        result
2372    }
2373
2374    /// A witness for an outcome: a test that took it and pinned that an effect did not happen, either
2375    /// with a negative assertion on its sink or by comparing the sink's whole call list.
2376    fn pinned(&self, took: &BTreeSet<&str>, targets: &[String]) -> bool {
2377        for id in took {
2378            let Some(test) = self.tests.get(id).copied() else {
2379                continue;
2380            };
2381            for ob in &test.observations {
2382                if !ob.negative && !ob.call_list {
2383                    continue;
2384                }
2385                for target_id in targets {
2386                    let Some(target) = self.sites.get(target_id.as_str()) else {
2387                        continue;
2388                    };
2389                    let bounds = self.with_mocks(target, test, &target.bounds);
2390                    if bounds
2391                        .iter()
2392                        .any(|b| !b.internal() && ob.matches(target, b))
2393                        || self.sink_hit(test, target, &bounds, ob)
2394                    {
2395                        return true;
2396                    }
2397                }
2398            }
2399        }
2400        false
2401    }
2402
2403    fn primitive_sensitivity(
2404        &self,
2405        site: &Site,
2406        d: &DecisionFacts,
2407        p: &PrimitiveDecision,
2408    ) -> Option<(bool, bool)> {
2409        if p.model != "js-primitive-decision-v1" || p.source.is_empty() || p.checks.is_empty() {
2410            return None;
2411        }
2412        let outcomes = d.outcomes.as_ref()?;
2413        let covered: BTreeSet<_> = site.covered_by.iter().collect();
2414        let checked: BTreeSet<_> = p.checks.iter().map(|c| &c.test).collect();
2415        if covered != checked || checked.len() != p.checks.len() {
2416            return None;
2417        }
2418        let mut stuck_true = false;
2419        let mut stuck_false = false;
2420        for c in &p.checks {
2421            if outcomes.true_.contains(&c.test) != c.original_outcome
2422                || outcomes.false_.contains(&c.test) == c.original_outcome
2423            {
2424                return None;
2425            }
2426            let test = self.tests.get(c.test.as_str())?;
2427            if !test.witness_issues.is_empty() || test.observations.len() != 1 {
2428                return None;
2429            }
2430            let ob = &test.observations[0];
2431            let comparison = ob.comparison.as_ref()?;
2432            if !ob.can_constrain_value()
2433                || ob.weak
2434                || ob.boundary != format!("return:{}", site.owner)
2435                || ob.assertion_source.as_ref() != Some(&c.assertion_source)
2436                || comparison.predicate != c.predicate
2437                || comparison.expected.value.as_ref() != Some(&c.expected)
2438            {
2439                return None;
2440            }
2441            let accepts = |actual: &SourcePrimitive| -> Option<bool> {
2442                let same = actual.same_value(&c.expected)?;
2443                match c.predicate.as_str() {
2444                    "node-same-value" => Some(same),
2445                    "node-not-same-value" => Some(!same),
2446                    _ => None,
2447                }
2448            };
2449            if !accepts(if c.original_outcome {
2450                &p.when_true
2451            } else {
2452                &p.when_false
2453            })? {
2454                return None;
2455            }
2456            stuck_true |= !accepts(&p.when_true)?;
2457            stuck_false |= !accepts(&p.when_false)?;
2458        }
2459        Some((stuck_true, stuck_false))
2460    }
2461
2462    fn resolve_decision(&self, site: &Site) -> Resolution {
2463        let covering = site.covered_by.len();
2464        let empty = DecisionFacts::default();
2465        let d = site.decision.as_ref().unwrap_or(&empty);
2466        let unresolved = |reason: Reason, stuck: bool| Resolution {
2467            site: site.id.clone(),
2468            status: Status::Unresolved,
2469            strength: None,
2470            reason: Some(reason),
2471            covered_by: covering,
2472            tests: BTreeSet::new(),
2473            weak_only: false,
2474            sensitivity_basis: None,
2475            stuck_true_caught: stuck.then_some(false),
2476            stuck_false_caught: stuck.then_some(false),
2477            absence_needed: stuck.then_some(false),
2478            value_observed: None,
2479            witness_issues: vec![],
2480        };
2481        if let Some(primitive) = &d.primitive {
2482            let Some((stuck_true, stuck_false)) = self.primitive_sensitivity(site, d, primitive)
2483            else {
2484                return unresolved(
2485                    Reason {
2486                        kind: ReasonKind::LimitOperandShape,
2487                        detail: Some("invalid or incomplete primitive decision evidence".into()),
2488                    },
2489                    false,
2490                );
2491            };
2492            let status = match (stuck_true, stuck_false) {
2493                (true, true) => Status::Evident,
2494                (false, false) => Status::Unresolved,
2495                _ => Status::Partial,
2496            };
2497            return Resolution {
2498                site: site.id.clone(), status,
2499                strength: (status == Status::Evident).then_some(Strength::Value),
2500                reason: (status != Status::Evident).then(|| Reason {
2501                    kind: ReasonKind::GapOutcomeNotAsserted,
2502                    detail: Some(format!("source-checked primitive branches: forcing {} remains accepted by every modeled assertion",
2503                        match (stuck_true, stuck_false) { (false, false) => "either outcome", (false, true) => "true", _ => "false" })),
2504                }),
2505                covered_by: covering, tests: BTreeSet::new(), weak_only: false,
2506                sensitivity_basis: None,
2507                stuck_true_caught: Some(stuck_true), stuck_false_caught: Some(stuck_false),
2508                absence_needed: Some(false), value_observed: None, witness_issues: vec![],
2509            };
2510        }
2511        // A ternary between two pre-built objects is distinguishable only through the sites just one of
2512        // them reaches.
2513        if let Some(object_valued) = &d.object_valued {
2514            let only_a = self.strength_of_sites(&object_valued.only_a, None);
2515            let only_b = self.strength_of_sites(&object_valued.only_b, None);
2516            if only_a.is_some_and(Strength::caught) || only_b.is_some_and(Strength::caught) {
2517                return Resolution {
2518                    site: site.id.clone(),
2519                    status: Status::Evident,
2520                    strength: Some(Strength::Value),
2521                    reason: None,
2522                    covered_by: covering,
2523                    tests: BTreeSet::new(),
2524                    weak_only: false,
2525                    sensitivity_basis: None,
2526                    stuck_true_caught: Some(true),
2527                    stuck_false_caught: Some(true),
2528                    absence_needed: Some(false),
2529                    value_observed: None,
2530                    witness_issues: vec![],
2531                };
2532            }
2533            return unresolved(
2534                Reason {
2535                    kind: ReasonKind::LimitUndecidable,
2536                    detail: Some("object-valued branches with no branch-specific site".into()),
2537                },
2538                true,
2539            );
2540        }
2541        let has_shape = d.carrier.is_some()
2542            || d.value_flow.is_some()
2543            || d.then.is_some()
2544            || d.object_valued.is_some();
2545        if !has_shape {
2546            return unresolved(
2547                Reason {
2548                    kind: ReasonKind::LimitUndecidable,
2549                    detail: Some("decision context not found".into()),
2550                },
2551                false,
2552            );
2553        }
2554        let t_true: Option<BTreeSet<&str>> = d
2555            .outcomes
2556            .as_ref()
2557            .map(|o| o.true_.iter().map(String::as_str).collect());
2558        let t_false: Option<BTreeSet<&str>> = d
2559            .outcomes
2560            .as_ref()
2561            .map(|o| o.false_.iter().map(String::as_str).collect());
2562        let selected: Option<BTreeSet<&str>> = d
2563            .selected
2564            .as_ref()
2565            .map(|s| s.iter().map(String::as_str).collect());
2566        let mut then_s;
2567        let mut else_s = None;
2568        let mut value_observed = None;
2569        if let Some(carrier) = &d.carrier {
2570            let ids = [carrier.clone()];
2571            then_s = self.strength_of_sites(&ids, selected.as_ref().or(t_true.as_ref()));
2572            else_s = self.strength_of_sites(&ids, selected.as_ref().or(t_false.as_ref()));
2573            if selected.is_some() {
2574                value_observed = Some(
2575                    self.strength_of_sites(&ids, None)
2576                        .is_some_and(Strength::caught),
2577                );
2578            }
2579        } else if let Some(flow) = &d.value_flow {
2580            then_s = self.strength_of_sites(flow, selected.as_ref().or(t_true.as_ref()));
2581            else_s = self.strength_of_sites(flow, selected.as_ref().or(t_false.as_ref()));
2582            if selected.is_some() {
2583                value_observed = Some(
2584                    self.strength_of_sites(flow, None)
2585                        .is_some_and(Strength::caught),
2586                );
2587            }
2588        } else {
2589            let then_ids = d.then.clone().unwrap_or_default();
2590            then_s = self.strength_of_sites(&then_ids, t_true.as_ref());
2591            let else_ids = d.else_.clone().flatten();
2592            if let Some(else_ids) = &else_ids {
2593                else_s = self.strength_of_sites(else_ids, t_false.as_ref());
2594            }
2595            // An early exit is witnessed by a test that took it and asserted that a downstream effect of
2596            // the same function did not happen, or that read the early return by value.
2597            if !then_s.is_some_and(Strength::caught)
2598                && let (Some(took), Some(downstream)) = (&t_true, &d.early_exit_downstream)
2599            {
2600                let mut witnessed = self.pinned(took, downstream);
2601                if !witnessed {
2602                    witnessed = took.iter().any(|id| {
2603                        self.tests.get(id).is_some_and(|test| {
2604                            test.observations.iter().any(|ob| {
2605                                ob.can_constrain_value()
2606                                    && !ob.negative
2607                                    && ob.boundary == format!("return:{}", site.owner)
2608                                    && ob.strength.caught()
2609                            })
2610                        })
2611                    });
2612                }
2613                if witnessed {
2614                    then_s = Some(Strength::Value);
2615                }
2616            }
2617            // Loop control decides which iterations run, which is visible only in the calls the body
2618            // makes: a pinned call list would have changed had the decision gone the other way.
2619            if let Some(body) = &d.loop_body {
2620                if !then_s.is_some_and(Strength::caught)
2621                    && let Some(took) = &t_true
2622                    && self.pinned(took, body)
2623                {
2624                    then_s = Some(Strength::Value);
2625                }
2626                if !else_s.is_some_and(Strength::caught)
2627                    && let Some(took) = &t_false
2628                    && self.pinned(took, body)
2629                {
2630                    else_s = Some(Strength::Value);
2631                }
2632            }
2633            // `if (opt) this.x = opt` with no else keeps the field's default when the condition is false:
2634            // a test in which it was false, observing a dependent of the field by value, would have seen
2635            // the write's value there had the condition been stuck true.
2636            if else_ids.is_none()
2637                && !else_s.is_some_and(Strength::caught)
2638                && let (Some(t_false), Some(entries)) = (&t_false, &d.default_kept)
2639                && !t_false.is_empty()
2640            {
2641                for entry in entries {
2642                    let kept = entry.dependents.iter().any(|dep| {
2643                        self.resolved.get(&dep.site).is_some_and(|r| {
2644                            r.strength.is_some_and(Strength::caught)
2645                                && r.tests.iter().any(|t| t_false.contains(t.as_str()))
2646                        })
2647                    });
2648                    if kept {
2649                        else_s = Some(Strength::Value);
2650                        break;
2651                    }
2652                }
2653            }
2654        }
2655        let absence_needed = d.carrier.is_none() && d.then.is_some() && d.else_ == Some(None);
2656        let stuck_false_caught = then_s.is_some_and(Strength::caught);
2657        // With no else branch, "this must not happen" is still pinned when the branch's effects are
2658        // asserted with total equality: a spurious occurrence shows up. With outcome data that needs at
2659        // least one test in which the condition really was false.
2660        let absence_covered = absence_needed
2661            && then_s == Some(Strength::Total)
2662            && t_false.as_ref().is_none_or(|t| !t.is_empty());
2663        let stuck_true_caught = else_s.is_some_and(Strength::caught) || absence_covered;
2664        let status = match (stuck_false_caught, stuck_true_caught) {
2665            (true, true) => Status::Evident,
2666            (false, false) => Status::Unresolved,
2667            _ => Status::Partial,
2668        };
2669        let weakest = match (then_s, else_s) {
2670            (Some(a), Some(b)) => Some(a.min(b)),
2671            (a, b) => a.or(b),
2672        };
2673        let reason = (status != Status::Evident).then(|| {
2674            let mut missing = Vec::new();
2675            if !stuck_false_caught {
2676                missing.push("true");
2677            }
2678            if !stuck_true_caught {
2679                missing.push("false");
2680            }
2681            let untaken: Vec<&str> = missing
2682                .iter()
2683                .copied()
2684                .filter(|side| {
2685                    let set = if *side == "true" { &t_true } else { &t_false };
2686                    set.as_ref().is_some_and(|s| s.is_empty())
2687                })
2688                .collect();
2689            if covering == 0 {
2690                Reason {
2691                    kind: ReasonKind::GapNotReached,
2692                    detail: None,
2693                }
2694            } else if !untaken.is_empty() {
2695                Reason {
2696                    kind: ReasonKind::GapOutcomeNotAsserted,
2697                    detail: Some(format!(
2698                        "no test takes the {} outcome",
2699                        untaken.join(" or ")
2700                    )),
2701                }
2702            } else if !site.unmodelled_shapes.is_empty() {
2703                Reason {
2704                    kind: ReasonKind::LimitOperandShape,
2705                    detail: Some(site.unmodelled_shapes.join("; ")),
2706                }
2707            } else {
2708                Reason {
2709                    kind: ReasonKind::GapOutcomeNotAsserted,
2710                    detail: Some(format!(
2711                        "the {} outcome is taken but nothing asserts its effects",
2712                        missing.join(" and ")
2713                    )),
2714                }
2715            }
2716        });
2717        Resolution {
2718            site: site.id.clone(),
2719            status,
2720            strength: (status == Status::Evident).then_some(weakest).flatten(),
2721            reason,
2722            covered_by: covering,
2723            tests: BTreeSet::new(),
2724            weak_only: false,
2725            sensitivity_basis: None,
2726            stuck_true_caught: Some(stuck_true_caught),
2727            stuck_false_caught: Some(stuck_false_caught),
2728            absence_needed: Some(absence_needed),
2729            value_observed,
2730            witness_issues: vec![],
2731        }
2732    }
2733
2734    /// An internal effect is observed through the sites that depend on it, capped at value strength: the
2735    /// dependent proves the state changed, not what it changed to. Needs a test covering both ends.
2736    fn derive_internal(&self, site: &Site) -> Option<Resolution> {
2737        let mut best = None;
2738        let mut tests = BTreeSet::new();
2739        for dep in &site.derive {
2740            if dep.site == site.id {
2741                continue;
2742            }
2743            if let Some(callbacks) = &dep.requires_total
2744                && !callbacks.iter().any(|id| {
2745                    self.resolved
2746                        .get(id)
2747                        .is_some_and(|r| r.strength == Some(Strength::Total))
2748                })
2749            {
2750                continue;
2751            }
2752            let Some(dependent) = self.sites.get(dep.site.as_str()) else {
2753                continue;
2754            };
2755            let strength = match dep.strength {
2756                Some(strength) => Some(strength),
2757                None => {
2758                    let r = self.resolved.get(&dep.site);
2759                    match r {
2760                        // A decision the tests pin in either direction proves the state reached it, so
2761                        // it witnesses the write at value strength whatever its own strength says.
2762                        Some(r) if dependent.kind == "decision" => (r.status == Status::Evident
2763                            || r.status == Status::Partial)
2764                            .then_some(Strength::Value),
2765                        Some(r) => r.strength,
2766                        None => None,
2767                    }
2768                }
2769            };
2770            let Some(strength) = strength else {
2771                continue;
2772            };
2773            let co_covered = site
2774                .covered_by
2775                .iter()
2776                .any(|t| dependent.covered_by.contains(t));
2777            if !co_covered {
2778                continue;
2779            }
2780            best = stronger(best, Some(strength.min(Strength::Value)));
2781            if let Some(r) = self.resolved.get(&dep.site) {
2782                for t in &r.tests {
2783                    if site.covered_by.contains(t) {
2784                        tests.insert(t.clone());
2785                    }
2786                }
2787            }
2788        }
2789        let best = best?;
2790        Some(Resolution {
2791            site: site.id.clone(),
2792            status: if best == Strength::Presence {
2793                Status::Presence
2794            } else {
2795                Status::Evident
2796            },
2797            strength: Some(best),
2798            reason: (best == Strength::Presence).then_some(Reason {
2799                kind: ReasonKind::GapValueNotAsserted,
2800                detail: None,
2801            }),
2802            covered_by: site.covered_by.len(),
2803            tests,
2804            weak_only: false,
2805            sensitivity_basis: None,
2806            stuck_true_caught: None,
2807            stuck_false_caught: None,
2808            absence_needed: None,
2809            value_observed: None,
2810            witness_issues: vec![],
2811        })
2812    }
2813}
2814
2815/// The metric: of the contractual sites, how many are evident.
2816pub fn summary(sites: &[Site], resolutions: &[Resolution]) -> Summary {
2817    let by_id: BTreeMap<&str, &Resolution> =
2818        resolutions.iter().map(|r| (r.site.as_str(), r)).collect();
2819    let mut summary = Summary::default();
2820    for site in sites {
2821        if site.classification != "contractual" {
2822            continue;
2823        }
2824        let Some(r) = by_id.get(site.id.as_str()) else {
2825            continue;
2826        };
2827        summary.contractual += 1;
2828        match r.status {
2829            Status::Evident => summary.evident += 1,
2830            Status::Partial => summary.partial += 1,
2831            Status::Presence => summary.presence += 1,
2832            Status::Unresolved => summary.unresolved += 1,
2833        }
2834        if let Some(reason) = &r.reason {
2835            if reason.kind.is_limit() {
2836                summary.limits += 1;
2837            } else if r.status != Status::Evident {
2838                summary.gaps += 1;
2839            }
2840        }
2841    }
2842    summary
2843}
2844
2845#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
2846#[serde(rename_all = "camelCase")]
2847pub struct Summary {
2848    pub contractual: usize,
2849    pub evident: usize,
2850    pub partial: usize,
2851    pub presence: usize,
2852    pub unresolved: usize,
2853    pub gaps: usize,
2854    pub limits: usize,
2855}
2856
2857#[cfg(test)]
2858mod tests {
2859    use super::*;
2860
2861    fn site(id: &str, category: &str, bounds: Vec<Boundary>, covered: &[&str]) -> Site {
2862        Site {
2863            id: id.into(),
2864            file: "src/a.ts".into(),
2865            line: 1,
2866            kind: "effect".into(),
2867            category: category.into(),
2868            classification: "contractual".into(),
2869            owner: "handler".into(),
2870            method: None,
2871            bounds: bounds.clone(),
2872            direct_bounds: bounds,
2873            reached: vec![],
2874            covered_by: covered.iter().map(|s| (*s).into()).collect(),
2875            object_valued_return: false,
2876            unmodelled_shapes: vec![],
2877            decision: None,
2878            derive: vec![],
2879        }
2880    }
2881
2882    fn boundary(name: &str) -> Boundary {
2883        Boundary {
2884            boundary: name.into(),
2885            facet: None,
2886            via: None,
2887        }
2888    }
2889
2890    fn observation(boundary: &str, strength: Strength) -> Observation {
2891        Observation {
2892            boundary: boundary.into(),
2893            facet: None,
2894            strength,
2895            where_: None,
2896            assertion_source: None,
2897            assertion_method: None,
2898            negative: false,
2899            call_list: false,
2900            mock: None,
2901            comparison: None,
2902            process_exit: None,
2903            weak: false,
2904            log_sites: None,
2905            pattern_shared: false,
2906        }
2907    }
2908
2909    fn test(id: &str, observations: Vec<Observation>) -> TestFacts {
2910        TestFacts {
2911            id: id.into(),
2912            file: "tests/a.test.ts".into(),
2913            observations,
2914            sinks: vec![],
2915            rendered: vec![],
2916            witness_issues: vec![],
2917        }
2918    }
2919
2920    fn facts(sites: Vec<Site>, tests: Vec<TestFacts>) -> Facts {
2921        Facts {
2922            schema: 1,
2923            sites,
2924            tests,
2925            mocks_by_test_file: BTreeMap::new(),
2926        }
2927    }
2928
2929    fn rejected(kind: WitnessIssueKind, target: Option<&str>) -> WitnessIssue {
2930        WitnessIssue {
2931            kind,
2932            source: Some("tests/a.test.ts:7:3".into()),
2933            operation: Some("equal".into()),
2934            observation: target.map(|target| observation(target, Strength::Total)),
2935        }
2936    }
2937
2938    fn primitive_fixture(
2939        a: SourcePrimitive,
2940        b: SourcePrimitive,
2941        predicate: &str,
2942        expected_a: SourcePrimitive,
2943        expected_b: SourcePrimitive,
2944    ) -> Facts {
2945        let mut tests = vec![];
2946        let mut checks = vec![];
2947        for (id, outcome, expected) in [("T1", true, expected_a), ("T2", false, expected_b)] {
2948            let source = format!("tests/a.test.ts:{id}:3");
2949            let mut ob = observation("return:handler", Strength::Value);
2950            ob.assertion_source = Some(source.clone());
2951            ob.comparison = Some(Comparison {
2952                predicate: predicate.into(),
2953                relation: "unresolved".into(),
2954                actual: ComparisonOperand {
2955                    source: "tests/a:10:20".into(),
2956                    binding: None,
2957                    input: None,
2958                    value: None,
2959                },
2960                expected: ComparisonOperand {
2961                    source: "tests/a:22:23".into(),
2962                    binding: None,
2963                    input: None,
2964                    value: Some(expected.clone()),
2965                },
2966            });
2967            tests.push(test(id, vec![ob]));
2968            checks.push(PrimitiveDecisionCheck {
2969                test: id.into(),
2970                assertion_source: source,
2971                predicate: predicate.into(),
2972                expected,
2973                original_outcome: outcome,
2974            });
2975        }
2976        let mut decision = site("D", "condition", vec![], &["T1", "T2"]);
2977        decision.kind = "decision".into();
2978        decision.decision = Some(DecisionFacts {
2979            primitive: Some(PrimitiveDecision {
2980                model: "js-primitive-decision-v1".into(),
2981                source: "src/a:0:40".into(),
2982                when_true: a,
2983                when_false: b,
2984                checks,
2985            }),
2986            else_: Some(Some(vec![])),
2987            outcomes: Some(Outcomes {
2988                true_: vec!["T1".into()],
2989                false_: vec!["T2".into()],
2990            }),
2991            ..Default::default()
2992        });
2993        facts(vec![decision], tests)
2994    }
2995
2996    #[test]
2997    fn primitive_sensitivity_preserves_types_signed_zero_and_predicate_acceptance() {
2998        let n = |v: &str| SourcePrimitive::Number(v.into());
2999        for (a, b, predicate, x, y, caught) in [
3000            (n("7"), n("7"), "node-same-value", n("7"), n("7"), false),
3001            (n("1"), n("2"), "node-same-value", n("1"), n("2"), true),
3002            (n("1"), n("2"), "node-not-same-value", n("0"), n("0"), false),
3003            (n("-0"), n("0"), "node-same-value", n("-0"), n("0"), true),
3004            (n("1e0"), n("1"), "node-same-value", n("1"), n("1"), false),
3005            (
3006                SourcePrimitive::String("1".into()),
3007                n("1"),
3008                "node-same-value",
3009                SourcePrimitive::String("1".into()),
3010                n("1"),
3011                true,
3012            ),
3013            (
3014                SourcePrimitive::Boolean(true),
3015                SourcePrimitive::Boolean(false),
3016                "node-same-value",
3017                SourcePrimitive::Boolean(true),
3018                SourcePrimitive::Boolean(false),
3019                true,
3020            ),
3021            (
3022                SourcePrimitive::Null,
3023                SourcePrimitive::Null,
3024                "node-same-value",
3025                SourcePrimitive::Null,
3026                SourcePrimitive::Null,
3027                false,
3028            ),
3029        ] {
3030            let f = primitive_fixture(a, b, predicate, x, y);
3031            let encoded = serde_json::to_value(&f).unwrap();
3032            let decoded: Facts = serde_json::from_value(encoded).unwrap();
3033            assert_eq!(f, decoded);
3034            let r = join(&decoded);
3035            assert_eq!(
3036                r[0].sensitivity_basis,
3037                Some(SensitivityBasis::BoundedSourceModel)
3038            );
3039            assert_eq!(r[0].stuck_true_caught, Some(caught));
3040            assert_eq!(r[0].stuck_false_caught, Some(caught));
3041            assert_eq!(
3042                r[0].status,
3043                if caught {
3044                    Status::Evident
3045                } else {
3046                    Status::Unresolved
3047                }
3048            );
3049        }
3050    }
3051
3052    #[test]
3053    fn primitive_sensitivity_rejects_incomplete_or_inconsistent_evidence() {
3054        let n = |v: &str| SourcePrimitive::Number(v.into());
3055        let base = primitive_fixture(n("1"), n("2"), "node-same-value", n("1"), n("2"));
3056        for case in 0..10 {
3057            let mut f = base.clone();
3058            let d = f.sites[0].decision.as_mut().unwrap();
3059            let p = d.primitive.as_mut().unwrap();
3060            match case {
3061                0 => {
3062                    p.checks.pop();
3063                }
3064                1 => p.checks.push(p.checks[0].clone()),
3065                2 => p.checks[0].assertion_source = "elsewhere".into(),
3066                3 => p.checks[0].expected = n("42"),
3067                4 => p.model = "unknown".into(),
3068                5 => p.when_true = n("NaN"),
3069                6 => p.when_false = n("Infinity"),
3070                7 => d.outcomes.as_mut().unwrap().true_.clear(),
3071                8 => f.tests[0]
3072                    .witness_issues
3073                    .push(rejected(WitnessIssueKind::CaptureUnavailable, None)),
3074                9 => {
3075                    p.checks[0].predicate = "node-loose-equality".into();
3076                    f.tests[0].observations[0]
3077                        .comparison
3078                        .as_mut()
3079                        .unwrap()
3080                        .predicate = "node-loose-equality".into();
3081                }
3082                _ => unreachable!(),
3083            }
3084            let r = join(&f);
3085            assert_eq!(r[0].status, Status::Unresolved, "case {case}");
3086            assert_eq!(r[0].sensitivity_basis, Some(SensitivityBasis::Unavailable));
3087            assert_eq!(
3088                r[0].reason.as_ref().unwrap().kind,
3089                ReasonKind::LimitOperandShape,
3090                "case {case}"
3091            );
3092            // Rejected evidence establishes neither rejection nor survival.
3093            assert_eq!(r[0].stuck_true_caught, None, "case {case}");
3094            assert_eq!(r[0].stuck_false_caught, None, "case {case}");
3095            let json = serde_json::to_value(&r[0]).unwrap();
3096            assert!(json.get("stuckTrueCaught").is_none(), "case {case}");
3097            assert!(json.get("stuckFalseCaught").is_none(), "case {case}");
3098        }
3099    }
3100
3101    #[test]
3102    fn primitive_sensitivity_keeps_one_sided_rejection_distinct_from_unknown() {
3103        let n = |v: &str| SourcePrimitive::Number(v.into());
3104        // Both original tests pass: 1 != 0 and 2 != 1. Forcing true fails the
3105        // second test; forcing false is accepted by both modeled assertions.
3106        let f = primitive_fixture(n("1"), n("2"), "node-not-same-value", n("0"), n("1"));
3107        let r = join(&f);
3108        assert_eq!(r[0].status, Status::Partial);
3109        assert_eq!(
3110            r[0].sensitivity_basis,
3111            Some(SensitivityBasis::BoundedSourceModel)
3112        );
3113        assert_eq!(r[0].stuck_true_caught, Some(true));
3114        assert_eq!(r[0].stuck_false_caught, Some(false));
3115        assert_eq!(
3116            r[0].reason.as_ref().unwrap().kind,
3117            ReasonKind::GapOutcomeNotAsserted
3118        );
3119    }
3120
3121    #[test]
3122    fn sensitivity_basis_does_not_promote_legacy_decisions_or_effects() {
3123        let mut d = site("D", "condition", vec![], &["T"]);
3124        d.kind = "decision".into();
3125        let mut f = facts(
3126            vec![d, site("E", "return", vec![], &["T"])],
3127            vec![test("T", vec![])],
3128        );
3129        let r = join(&f);
3130        assert_eq!(r[0].sensitivity_basis, Some(SensitivityBasis::Unavailable));
3131        assert!(r[1].sensitivity_basis.is_none());
3132        assert!(
3133            serde_json::to_value(&r[1])
3134                .unwrap()
3135                .get("sensitivityBasis")
3136                .is_none()
3137        );
3138        f.sites[0].decision = Some(DecisionFacts {
3139            then: Some(vec![]),
3140            ..Default::default()
3141        });
3142        let r = join(&f);
3143        assert_eq!(
3144            r[0].sensitivity_basis,
3145            Some(SensitivityBasis::BranchObservationHeuristic)
3146        );
3147        assert_eq!(
3148            serde_json::to_value(&r[0]).unwrap()["sensitivityBasis"],
3149            "branch-observation-heuristic"
3150        );
3151    }
3152
3153    fn pragma_hint() -> PragmaHint {
3154        PragmaHint {
3155            id: "hint-1".into(),
3156            where_: "tests/a.test.ts:6:3".into(),
3157            raw: "// observes: src/a.ts#handler return value".into(),
3158            target: Some(PragmaTarget {
3159                file: "src/a.ts".into(),
3160                function: "handler".into(),
3161                snippet: Some("return value".into()),
3162                via: None,
3163            }),
3164            candidate_sites: vec!["S1".into()],
3165            issue: None,
3166            test: Some("T1".into()),
3167            assertion_source: Some("tests/a.test.ts:7:3".into()),
3168            assertion_method: Some("equal".into()),
3169            witness: "passed".into(),
3170            witness_issue: None,
3171            awaited_observation: None,
3172            check: None,
3173            call_omission: None,
3174            count_sensitivity: None,
3175            payload_sensitivity: None,
3176            direct_return_sensitivity: None,
3177            completion_sensitivity: None,
3178        }
3179    }
3180
3181    #[test]
3182    fn omission_checks_require_own_count_witness_and_never_promote_the_site() {
3183        let mut hint = pragma_hint();
3184        hint.check = Some("missing-call".into());
3185        hint.call_omission = Some(
3186            serde_json::from_value(serde_json::json!({
3187                "model": "node-first-test-call-omission-v1", "status":"source-checked",
3188                "scope":"first-synchronous-test", "outcome":"rejected",
3189                "assertionSource":"tests/a.test.ts:7:3", "callSource":"src/a.ts:4:3",
3190                "callbackSource":"src/a.ts:3:3", "instance":"tests/a.test.ts:2:3",
3191                "expectedCount":1, "originalCount":1, "omittedCount":0
3192            }))
3193            .unwrap(),
3194        );
3195        let mut ob = observation("stdout", Strength::Total);
3196        ob.assertion_source = hint.assertion_source.clone();
3197        ob.assertion_method = hint.assertion_method.clone();
3198        ob.mock = Some(MockProjection {
3199            target: "console.log".into(),
3200            kind: "call-count".into(),
3201            path: vec!["mock".into(), "callCount()".into()],
3202            count_evidence: None,
3203        });
3204        ob.comparison = Some(
3205            serde_json::from_value(serde_json::json!({
3206                "predicate":"node-same-value", "relation":"distinct-or-unknown",
3207                "actual":{"source":"tests/a.test.ts:7:16"}, "expected":{"source":"tests/a.test.ts:7:38"}
3208            }))
3209            .unwrap(),
3210        );
3211        let f = facts(
3212            vec![site("S1", "log", vec![boundary("stdout")], &["T1"])],
3213            vec![test("T1", vec![ob])],
3214        );
3215        let joined = serde_json::to_value(join(&f)).unwrap();
3216        let positive = &check_pragma_hints(&f, &[hint.clone()])[0];
3217        assert_eq!(positive.validation, HintValidation::AnalyzerSupported);
3218        assert_eq!(positive.reason, "modeled-callback-omission-rejected");
3219        assert_eq!(positive.strength, None);
3220        assert_eq!(serde_json::to_value(join(&f)).unwrap(), joined);
3221        for case in 0..9 {
3222            let mut f = f.clone();
3223            let mut h = hint.clone();
3224            match case {
3225                0 => h.witness = "unavailable".into(),
3226                1 => h.assertion_source = Some("tests/a.test.ts:8:3".into()),
3227                2 => f.tests[0].observations.clear(),
3228                3 => h.call_omission.as_mut().unwrap().original_count = Some(2),
3229                4 => h.call_omission.as_mut().unwrap().omitted_count = Some(1),
3230                5 => h.call_omission.as_mut().unwrap().scope = Some("any-test".into()),
3231                6 => h.call_omission.as_mut().unwrap().model = "unrecognized".into(),
3232                7 => {
3233                    h.call_omission.as_mut().unwrap().call_source = Some("src/other.ts:4:3".into())
3234                }
3235                8 => f.sites[0].covered_by.clear(),
3236                _ => unreachable!(),
3237            }
3238            assert_eq!(
3239                check_pragma_hints(&f, &[h])[0].validation,
3240                HintValidation::Unresolved,
3241                "case {case}"
3242            );
3243        }
3244        let c = hint.call_omission.as_mut().unwrap();
3245        c.omitted_count = Some(1);
3246        c.outcome = Some("not-rejected".into());
3247        let negative = &check_pragma_hints(&f, &[hint])[0];
3248        assert_eq!(negative.validation, HintValidation::AnalyzerSupported);
3249        assert_eq!(negative.reason, "modeled-callback-omission-not-rejected");
3250        assert_eq!(negative.strength, None);
3251    }
3252
3253    #[test]
3254    fn count_sensitivity_keeps_variant_scope_and_requires_own_witness() {
3255        let mut hint = pragma_hint();
3256        hint.check = Some("count".into());
3257        hint.count_sensitivity = Some(serde_json::from_value(serde_json::json!({
3258            "model":"node-closed-count-sensitivity-v1", "status":"source-checked",
3259            "scope":"closed-synchronous-test-module", "assertionSource":"tests/a.test.ts:7:3",
3260            "targetSource":"src/a.ts:4:3", "conditionSource":"src/a.ts:4:3", "conditionText":"mode === 'quiet'",
3261            "allocations":["src/a.ts:2:3"], "instance":"tests/a.test.ts:2:3", "expectedCount":1, "originalCount":1,
3262            "variants":[
3263                {"change":"condition-true","status":"source-checked","count":0,"outcome":"rejected"},
3264                {"change":"condition-false","status":"source-checked","count":1,"outcome":"not-rejected"},
3265                {"change":"condition-inverted","status":"unresolved","reason":"unsupported-prefix"}
3266            ]
3267        })).unwrap());
3268        let mut ob = observation("stdout", Strength::Total);
3269        ob.assertion_source = hint.assertion_source.clone();
3270        ob.assertion_method = hint.assertion_method.clone();
3271        ob.mock = Some(MockProjection {
3272            target: "console.log".into(),
3273            kind: "call-count".into(),
3274            path: vec!["mock".into(), "callCount()".into()],
3275            count_evidence: None,
3276        });
3277        ob.comparison = Some(serde_json::from_value(serde_json::json!({
3278            "predicate":"node-same-value", "relation":"distinct-or-unknown",
3279            "actual":{"source":"tests/a.test.ts:7:16"},"expected":{"source":"tests/a.test.ts:7:38"}
3280        })).unwrap());
3281        let mut s = site("S1", "condition", vec![boundary("stdout")], &["T1"]);
3282        s.kind = "decision".into();
3283        let f = facts(vec![s], vec![test("T1", vec![ob])]);
3284        let before = serde_json::to_value(join(&f)).unwrap();
3285        let result = &check_pragma_hints(&f, &[hint.clone()])[0];
3286        assert_eq!(result.validation, HintValidation::AnalyzerSupported);
3287        assert_eq!(result.reason, "modeled-count-sensitivity");
3288        assert_eq!(result.strength, None);
3289        assert_eq!(serde_json::to_value(join(&f)).unwrap(), before);
3290        for case in 0..12 {
3291            let mut h = hint.clone();
3292            let mut input = f.clone();
3293            let e = h.count_sensitivity.as_mut().unwrap();
3294            match case {
3295                0 => h.witness = "unavailable".into(),
3296                1 => h.assertion_source = Some("tests/a.test.ts:8:3".into()),
3297                2 => input.tests[0].observations.clear(),
3298                3 => e.original_count = Some(9),
3299                4 => e.variants.as_mut().unwrap()[0].count = Some(1),
3300                5 => e.variants.as_mut().unwrap()[2].outcome = Some("rejected".into()),
3301                6 => e.variants.as_mut().unwrap()[0].change = "arbitrary-edit".into(),
3302                7 => e.allocations = Some(vec![]),
3303                8 => e.target_source = Some("src/other.ts:4:3".into()),
3304                9 => e.scope = Some("whole-repository".into()),
3305                10 => input.sites[0].covered_by.clear(),
3306                11 => e.variants.as_mut().unwrap()[0].change = "condition-false".into(),
3307                _ => unreachable!(),
3308            }
3309            assert_eq!(
3310                check_pragma_hints(&input, &[h])[0].validation,
3311                HintValidation::Unresolved,
3312                "case {case}"
3313            );
3314        }
3315    }
3316
3317    #[test]
3318    fn completion_checks_keep_scope_and_predicate_consistency_without_promoting_sites() {
3319        let mut hint = pragma_hint();
3320        hint.check = Some("completion".into());
3321        hint.assertion_method = Some("throws".into());
3322        hint.completion_sensitivity = Some(serde_json::from_value(serde_json::json!({
3323            "model":"node-first-test-completion-v1", "status":"source-checked",
3324            "scope":"first-synchronous-test-prefix", "assertionSource":"tests/a.test.ts:7:3",
3325            "targetSource":"src/a.ts:4:3", "changeText":"throw Error('boom');", "change":"statement-omitted",
3326            "original":{"method":"throws", "callbackSource":"src/a.ts:3:1", "completion":"throw",
3327                "throwSource":"src/a.ts:4:3", "targetEvaluations":1, "outcome":"not-rejected"},
3328            "omitted":{"method":"throws", "callbackSource":"src/a.ts:3:1", "completion":"normal",
3329                "targetEvaluations":1, "outcome":"rejected"}
3330        })).unwrap());
3331        let mut s = site("S1", "throw", vec![], &["T1"]);
3332        s.line = 4;
3333        let mut t = test("T1", vec![]);
3334        t.file = "tests/a.test.ts".into();
3335        let f = facts(vec![s], vec![t]);
3336        let before = join(&f);
3337        let result = check_pragma_hints(&f, &[hint.clone()]).remove(0);
3338        assert_eq!(result.validation, HintValidation::AnalyzerSupported);
3339        assert_eq!(result.reason, "modeled-completion-sensitivity");
3340        assert!(result.strength.is_none());
3341        assert!(result.observations.is_empty());
3342        assert_eq!(join(&f), before);
3343        for case in 0..20 {
3344            let mut h = hint.clone();
3345            let mut ff = f.clone();
3346            let e = h.completion_sensitivity.as_mut().unwrap();
3347            match case {
3348                0 => h.witness = "unavailable".into(),
3349                1 => h.assertion_source = Some("tests/a.test.ts:8:3".into()),
3350                2 => e.original.as_mut().unwrap().outcome = "rejected".into(),
3351                3 => e.omitted.as_mut().unwrap().outcome = "not-rejected".into(),
3352                4 => e.original.as_mut().unwrap().throw_source = None,
3353                5 => e.omitted.as_mut().unwrap().throw_source = Some("src/a.ts:4:3".into()),
3354                6 => e.original.as_mut().unwrap().callback_source = "src/other.ts:3:1".into(),
3355                7 => e.original.as_mut().unwrap().target_evaluations = 0,
3356                8 => e.omitted.as_mut().unwrap().target_evaluations = 4097,
3357                9 => e.scope = Some("whole-suite".into()),
3358                10 => e.target_source = Some("src/a.ts:5:3".into()),
3359                11 => h.assertion_method = Some("rejects".into()),
3360                12 => e.change = Some("arbitrary-edit".into()),
3361                13 => e.change_text = Some("".into()),
3362                14 => e.original.as_mut().unwrap().completion = "rejection".into(),
3363                15 => e.omitted.as_mut().unwrap().method = "doesNotThrow".into(),
3364                16 => e.original.as_mut().unwrap().callback_source = "src/a.ts:0:0".into(),
3365                17 => ff.tests[0]
3366                    .witness_issues
3367                    .push(rejected(WitnessIssueKind::CaptureUnavailable, None)),
3368                18 => {
3369                    let mut issue = rejected(WitnessIssueKind::CallFailed, None);
3370                    issue.source = h.assertion_source.clone();
3371                    ff.tests[0].witness_issues.push(issue);
3372                }
3373                19 => {
3374                    h.payload_sensitivity = Some(
3375                        serde_json::from_value(
3376                            serde_json::json!({"model":"unused", "status":"unresolved"}),
3377                        )
3378                        .unwrap(),
3379                    )
3380                }
3381                _ => unreachable!(),
3382            }
3383            assert_eq!(
3384                check_pragma_hints(&ff, &[h])[0].validation,
3385                HintValidation::Unresolved,
3386                "case {case}"
3387            );
3388        }
3389        // An unchanged completion is also a checked answer for this one edit;
3390        // it must not become whole-suite survival or ordinary assertion credit.
3391        let e = hint.completion_sensitivity.as_mut().unwrap();
3392        e.omitted = e.original.clone();
3393        let local = check_pragma_hints(&f, &[hint.clone()]).remove(0);
3394        assert_eq!(local.validation, HintValidation::AnalyzerSupported);
3395        assert!(local.strength.is_none());
3396        hint.assertion_method = Some("doesNotThrow".into());
3397        let e = hint.completion_sensitivity.as_mut().unwrap();
3398        for c in [&mut e.original, &mut e.omitted].into_iter().flatten() {
3399            c.method = "doesNotThrow".into();
3400            c.completion = "normal".into();
3401            c.throw_source = None;
3402        }
3403        assert_eq!(
3404            check_pragma_hints(&f, &[hint])[0].validation,
3405            HintValidation::AnalyzerSupported
3406        );
3407    }
3408
3409    #[test]
3410    fn completion_rejection_requires_safe_native_diagnostic_evidence() {
3411        let mut hint = pragma_hint();
3412        hint.check = Some("completion".into());
3413        hint.assertion_method = Some("doesNotThrow".into());
3414        hint.completion_sensitivity = Some(serde_json::from_value(serde_json::json!({
3415            "model":"node-first-test-completion-v1", "status":"source-checked",
3416            "scope":"first-synchronous-test-prefix", "assertionSource":"tests/a.test.ts:7:3",
3417            "targetSource":"src/a.ts:4:3", "changeText":"return 101;", "change":"statement-omitted",
3418            "original":{"method":"doesNotThrow", "callbackSource":"src/a.ts:3:1", "completion":"normal",
3419                "targetEvaluations":1, "outcome":"not-rejected"},
3420            "omitted":{"method":"doesNotThrow", "callbackSource":"src/a.ts:3:1", "completion":"throw",
3421                "throwSource":"src/a.ts:5:3", "targetEvaluations":1, "outcome":"rejected"}
3422        })).unwrap());
3423        let mut s = site("S1", "return", vec![], &["T1"]);
3424        s.line = 4;
3425        let mut t = test("T1", vec![]);
3426        t.file = "tests/a.test.ts".into();
3427        let f = facts(vec![s], vec![t]);
3428        let before = join(&f);
3429        // Older evidence that equated any throw with rejection must not pass.
3430        assert_eq!(
3431            check_pragma_hints(&f, &[hint.clone()])[0].validation,
3432            HintValidation::Unresolved
3433        );
3434        for value in [
3435            serde_json::json!({"basis":"native-error-message", "message":{"kind":"string", "value":"boom"}}),
3436            serde_json::json!({"basis":"absent-message", "message":{"kind":"undefined"}}),
3437            serde_json::json!({"basis":"primitive-thrown-value", "message":{"kind":"undefined"}}),
3438            serde_json::json!({"basis":"own-primitive-message", "message":{"kind":"undefined"}}),
3439            serde_json::json!({"basis":"own-primitive-message", "message":{"kind":"null"}}),
3440            serde_json::json!({"basis":"own-primitive-message", "message":{"kind":"number", "value":42}}),
3441            serde_json::json!({"basis":"own-primitive-message", "message":{"kind":"boolean", "value":false}}),
3442            serde_json::json!({"basis":"own-primitive-message", "message":{"kind":"string", "value":"boom"}}),
3443        ] {
3444            let mut h = hint.clone();
3445            h.completion_sensitivity
3446                .as_mut()
3447                .unwrap()
3448                .omitted
3449                .as_mut()
3450                .unwrap()
3451                .diagnostic = Some(serde_json::from_value(value.clone()).unwrap());
3452            let result = check_pragma_hints(&f, &[h]).remove(0);
3453            assert_eq!(
3454                result.validation,
3455                HintValidation::AnalyzerSupported,
3456                "{value}"
3457            );
3458            assert!(result.strength.is_none());
3459            assert!(result.observations.is_empty());
3460        }
3461        for value in [
3462            serde_json::json!({"basis":"assumed-safe", "message":{"kind":"undefined"}}),
3463            serde_json::json!({"basis":"native-error-message", "message":{"kind":"number", "value":42}}),
3464            serde_json::json!({"basis":"native-error-message", "message":{"kind":"string"}}),
3465            serde_json::json!({"basis":"native-error-message", "message":{"kind":"string", "value":false}}),
3466            serde_json::json!({"basis":"absent-message", "message":{"kind":"null"}}),
3467            serde_json::json!({"basis":"primitive-thrown-value", "message":{"kind":"string", "value":"boom"}}),
3468            serde_json::json!({"basis":"own-primitive-message", "message":{"kind":"object", "properties":[]}}),
3469            serde_json::json!({"basis":"own-primitive-message", "message":{"kind":"opaque-string"}}),
3470            serde_json::json!({"basis":"own-primitive-message", "message":{"kind":"undefined", "extra":true}}),
3471        ] {
3472            let mut h = hint.clone();
3473            h.completion_sensitivity
3474                .as_mut()
3475                .unwrap()
3476                .omitted
3477                .as_mut()
3478                .unwrap()
3479                .diagnostic = Some(serde_json::from_value(value.clone()).unwrap());
3480            assert_eq!(
3481                check_pragma_hints(&f, &[h])[0].validation,
3482                HintValidation::Unresolved,
3483                "{value}"
3484            );
3485        }
3486        let descriptor: CompletionDiagnostic = serde_json::from_value(serde_json::json!({
3487            "basis":"native-error-message", "message":{"kind":"string", "value":"boom"}
3488        }))
3489        .unwrap();
3490        for method in ["doesNotThrow", "throws"] {
3491            let mut h = hint.clone();
3492            h.assertion_method = Some(method.into());
3493            let e = h.completion_sensitivity.as_mut().unwrap();
3494            for c in [&mut e.original, &mut e.omitted].into_iter().flatten() {
3495                c.method = method.into();
3496                c.completion = if method == "throws" {
3497                    "throw"
3498                } else {
3499                    "normal"
3500                }
3501                .into();
3502                c.throw_source = (method == "throws").then(|| "src/a.ts:5:3".into());
3503                c.outcome = "not-rejected".into();
3504                c.diagnostic = Some(descriptor.clone());
3505            }
3506            // Do not accept diagnostics on paths whose predicate never reads them.
3507            assert_eq!(
3508                check_pragma_hints(&f, &[h])[0].validation,
3509                HintValidation::Unresolved
3510            );
3511        }
3512        assert_eq!(join(&f), before);
3513    }
3514
3515    #[test]
3516    fn completion_matcher_shortcut_uses_only_original_witness_and_safe_missing_diagnostic() {
3517        let mut hint = pragma_hint();
3518        hint.check = Some("completion".into());
3519        hint.assertion_method = Some("throws".into());
3520        hint.completion_sensitivity = Some(serde_json::from_value(serde_json::json!({
3521            "model":"node-first-test-completion-v1", "status":"source-checked",
3522            "scope":"first-synchronous-test-prefix", "assertionSource":"tests/a.test.ts:7:3",
3523            "targetSource":"src/a.ts:4:3", "changeText":"throw Error('boom');", "change":"statement-omitted",
3524            "original":{"method":"throws", "callbackSource":"src/a.ts:3:1", "completion":"throw",
3525                "throwSource":"src/a.ts:4:3", "targetEvaluations":1, "outcome":"witnessed-pass",
3526                "matcher":{"source":"tests/a.test.ts:7:28", "kind":"native-regexp"}},
3527            "omitted":{"method":"throws", "callbackSource":"src/a.ts:3:1", "completion":"normal",
3528                "targetEvaluations":1, "outcome":"rejected",
3529                "matcher":{"source":"tests/a.test.ts:7:28", "kind":"native-regexp"},
3530                "missingExceptionDiagnostic":{"nameBasis":"absent", "message":{"kind":"undefined"}}}
3531        })).unwrap());
3532        let mut s = site("S1", "throw", vec![], &["T1"]);
3533        s.line = 4;
3534        let mut t = test("T1", vec![]);
3535        t.file = "tests/a.test.ts".into();
3536        let f = facts(vec![s], vec![t]);
3537        let before = join(&f);
3538        for (kind, diagnostic) in [
3539            (
3540                "native-regexp",
3541                serde_json::json!({"nameBasis":"absent", "message":{"kind":"undefined"}}),
3542            ),
3543            (
3544                "source-function",
3545                serde_json::json!({"nameBasis":"source-function-name", "message":{"kind":"string", "value":"required"}}),
3546            ),
3547            (
3548                "native-error-constructor",
3549                serde_json::json!({"nameBasis":"native-error-name", "name":{"kind":"string", "value":"Error"}, "message":{"kind":"undefined"}}),
3550            ),
3551            (
3552                "native-error",
3553                serde_json::json!({"nameBasis":"native-error-name", "name":{"kind":"string", "value":"Error"}, "message":{"kind":"undefined"}}),
3554            ),
3555            (
3556                "object",
3557                serde_json::json!({"nameBasis":"own-primitive-name", "name":{"kind":"string", "value":"Error"}, "message":{"kind":"number", "value":42}}),
3558            ),
3559            (
3560                "object",
3561                serde_json::json!({"nameBasis":"own-primitive-name", "name":{"kind":"null"}, "message":{"kind":"null"}}),
3562            ),
3563            (
3564                "object",
3565                serde_json::json!({"nameBasis":"absent", "message":{"kind":"undefined"}}),
3566            ),
3567            (
3568                "array",
3569                serde_json::json!({"nameBasis":"absent", "message":{"kind":"boolean", "value":false}}),
3570            ),
3571            (
3572                "none",
3573                serde_json::json!({"nameBasis":"absent", "message":{"kind":"undefined"}}),
3574            ),
3575            (
3576                "message-overload",
3577                serde_json::json!({"nameBasis":"absent", "message":{"kind":"string", "value":"required"}}),
3578            ),
3579        ] {
3580            let mut h = hint.clone();
3581            let e = h.completion_sensitivity.as_mut().unwrap();
3582            for c in [&mut e.original, &mut e.omitted].into_iter().flatten() {
3583                c.matcher.as_mut().unwrap().kind = kind.into();
3584            }
3585            e.omitted.as_mut().unwrap().missing_exception_diagnostic =
3586                Some(serde_json::from_value(diagnostic).unwrap());
3587            let result = check_pragma_hints(&f, &[h]).remove(0);
3588            assert_eq!(
3589                result.validation,
3590                HintValidation::AnalyzerSupported,
3591                "{kind}"
3592            );
3593            assert!(result.strength.is_none());
3594            assert!(result.observations.is_empty());
3595        }
3596        for case in 0..20 {
3597            let mut h = hint.clone();
3598            let e = h.completion_sensitivity.as_mut().unwrap();
3599            let original = e.original.as_mut().unwrap();
3600            let omitted = e.omitted.as_mut().unwrap();
3601            match case {
3602                0 => h.witness = "unavailable".into(),
3603                1 => original.outcome = "not-rejected".into(),
3604                2 => omitted.outcome = "witnessed-pass".into(),
3605                3 => {
3606                    omitted.completion = "throw".into();
3607                    omitted.throw_source = Some("src/a.ts:4:3".into());
3608                }
3609                4 => omitted.missing_exception_diagnostic = None,
3610                5 => {
3611                    original.missing_exception_diagnostic =
3612                        omitted.missing_exception_diagnostic.clone()
3613                }
3614                6 => omitted.matcher = None,
3615                7 => original.matcher = None,
3616                8 => omitted.matcher.as_mut().unwrap().source = "tests/a.test.ts:8:28".into(),
3617                9 => original.matcher.as_mut().unwrap().source = "tests/other.test.ts:7:28".into(),
3618                10 => omitted.matcher.as_mut().unwrap().kind = "unknown".into(),
3619                11 => {
3620                    omitted
3621                        .missing_exception_diagnostic
3622                        .as_mut()
3623                        .unwrap()
3624                        .message = serde_json::json!({"kind":"object", "properties":[]})
3625                }
3626                12 => {
3627                    omitted
3628                        .missing_exception_diagnostic
3629                        .as_mut()
3630                        .unwrap()
3631                        .name_basis = "source-function-name".into()
3632                }
3633                13 => {
3634                    omitted.missing_exception_diagnostic.as_mut().unwrap().name =
3635                        Some(serde_json::json!({"kind":"undefined"}))
3636                }
3637                14 => {
3638                    omitted
3639                        .missing_exception_diagnostic
3640                        .as_mut()
3641                        .unwrap()
3642                        .message = serde_json::json!({"kind":"string", "value":42})
3643                }
3644                15 => omitted.matcher.as_mut().unwrap().kind = "message-overload".into(),
3645                16 => {
3646                    omitted.matcher.as_mut().unwrap().kind = "object".into();
3647                    omitted
3648                        .missing_exception_diagnostic
3649                        .as_mut()
3650                        .unwrap()
3651                        .name_basis = "own-primitive-name".into();
3652                    omitted.missing_exception_diagnostic.as_mut().unwrap().name =
3653                        Some(serde_json::json!({"kind":"object", "properties":[]}));
3654                }
3655                17 => {
3656                    omitted.matcher.as_mut().unwrap().kind = "native-error".into();
3657                    omitted
3658                        .missing_exception_diagnostic
3659                        .as_mut()
3660                        .unwrap()
3661                        .name_basis = "native-error-name".into();
3662                    omitted.missing_exception_diagnostic.as_mut().unwrap().name =
3663                        Some(serde_json::json!({"kind":"string", "value":"Other"}));
3664                }
3665                18 => h.assertion_method = Some("doesNotThrow".into()),
3666                19 => original.target_evaluations = 0,
3667                _ => unreachable!(),
3668            }
3669            assert_eq!(
3670                check_pragma_hints(&f, &[h])[0].validation,
3671                HintValidation::Unresolved,
3672                "case {case}"
3673            );
3674        }
3675        assert_eq!(join(&f), before);
3676    }
3677
3678    #[test]
3679    fn direct_return_variants_require_exact_scope_witness_and_consistent_values() {
3680        let mut hint = pragma_hint();
3681        hint.check = Some("value".into());
3682        let original = serde_json::json!({
3683            "predicate":"node-same-value", "actual":{"kind":"string","value":"*"},
3684            "expected":{"kind":"string","value":"*"}, "outcome":"not-rejected",
3685            "callSource":"tests/a.test.ts:7:16", "targetEvaluations":1
3686        });
3687        let mut changed = original.clone();
3688        changed["actual"] = serde_json::json!({"kind":"array","properties":[]});
3689        changed["outcome"] = "rejected".into();
3690        hint.direct_return_sensitivity = Some(serde_json::from_value(serde_json::json!({
3691            "model":"node-first-test-direct-return-v1", "status":"source-checked",
3692            "scope":"first-synchronous-test-prefix", "assertionSource":"tests/a.test.ts:7:3",
3693            "targetSource":"src/a.ts:4:3", "changeSource":"src/a.ts:4:3", "changeText":"items.length === 0",
3694            "original":original,
3695            "variants":[
3696                {"change":"condition-true","status":"source-checked","check":original},
3697                {"change":"condition-false","status":"source-checked","check":changed},
3698                {"change":"condition-inverted","status":"unresolved","reason":"not supported"}
3699            ]
3700        })).unwrap());
3701        let mut s = site("S1", "condition", vec![], &["T1"]);
3702        s.kind = "decision".into();
3703        s.line = 4;
3704        let mut t = test("T1", vec![]);
3705        t.file = "tests/a.test.ts".into();
3706        let f = facts(vec![s], vec![t]);
3707        let before = join(&f);
3708        let result = check_pragma_hints(&f, &[hint.clone()]).remove(0);
3709        assert_eq!(result.validation, HintValidation::AnalyzerSupported);
3710        assert!(result.strength.is_none());
3711        assert!(result.observations.is_empty());
3712        assert_eq!(join(&f), before);
3713        for case in 0..18 {
3714            let mut h = hint.clone();
3715            let mut ff = f.clone();
3716            let e = h.direct_return_sensitivity.as_mut().unwrap();
3717            match case {
3718                0 => h.witness = "unavailable".into(),
3719                1 => h.assertion_source = Some("tests/a.test.ts:8:3".into()),
3720                2 => e.original.as_mut().unwrap().outcome = "witnessed-pass".into(),
3721                3 => {
3722                    e.variants.as_mut().unwrap()[1]
3723                        .check
3724                        .as_mut()
3725                        .unwrap()
3726                        .outcome = "not-rejected".into()
3727                }
3728                4 => {
3729                    e.variants.as_mut().unwrap()[1]
3730                        .check
3731                        .as_mut()
3732                        .unwrap()
3733                        .expected = serde_json::json!({"kind":"undefined"})
3734                }
3735                5 => e.original.as_mut().unwrap().call_source = "tests/other.ts:7:16".into(),
3736                6 => e.original.as_mut().unwrap().target_evaluations = 0,
3737                7 => e.scope = Some("whole-suite".into()),
3738                8 => e.target_source = Some("src/a.ts:5:3".into()),
3739                9 => h.assertion_method = Some("ok".into()),
3740                10 => e.variants.as_mut().unwrap()[0].change = "arbitrary-edit".into(),
3741                11 => {
3742                    e.original.as_mut().unwrap().actual =
3743                        serde_json::json!({"kind":"opaque-string"})
3744                }
3745                12 => {
3746                    e.original.as_mut().unwrap().actual =
3747                        serde_json::json!({"kind":"string","value":"*","extra":1})
3748                }
3749                13 => e.variants.as_mut().unwrap()[2].check = e.original.clone(),
3750                14 => e.original.as_mut().unwrap().call_source = "tests/a.test.ts:0:0".into(),
3751                15 => ff.tests[0]
3752                    .witness_issues
3753                    .push(rejected(WitnessIssueKind::CaptureUnavailable, None)),
3754                16 => {
3755                    let mut issue = rejected(WitnessIssueKind::CallFailed, None);
3756                    issue.source = h.assertion_source.clone();
3757                    ff.tests[0].witness_issues.push(issue);
3758                }
3759                17 => {
3760                    h.payload_sensitivity = Some(
3761                        serde_json::from_value(
3762                            serde_json::json!({"model":"unused", "status":"unresolved"}),
3763                        )
3764                        .unwrap(),
3765                    )
3766                }
3767                _ => unreachable!(),
3768            }
3769            assert_eq!(
3770                check_pragma_hints(&ff, &[h])[0].validation,
3771                HintValidation::Unresolved,
3772                "case {case}"
3773            );
3774        }
3775        let e = hint.direct_return_sensitivity.as_mut().unwrap();
3776        e.change_text = Some("false".into());
3777        e.original = Some(
3778            serde_json::from_value(serde_json::json!({
3779                "predicate":"node-same-value", "actual":{"kind":"boolean","value":false},
3780                "expected":{"kind":"boolean","value":false}, "outcome":"not-rejected",
3781                "callSource":"tests/a.test.ts:7:16", "targetEvaluations":1
3782            }))
3783            .unwrap(),
3784        );
3785        let mut c = e.original.clone().unwrap();
3786        c.actual = serde_json::json!({"kind":"boolean","value":true});
3787        c.outcome = "rejected".into();
3788        e.variants = Some(vec![DirectReturnVariant {
3789            change: "boolean-literal-inverted".into(),
3790            status: "source-checked".into(),
3791            reason: None,
3792            check: Some(c),
3793        }]);
3794        assert_eq!(
3795            check_pragma_hints(&f, &[hint])[0].validation,
3796            HintValidation::AnalyzerSupported
3797        );
3798    }
3799
3800    #[test]
3801    fn payload_sensitivity_checks_values_and_own_witness_without_legacy_links() {
3802        let mut hint = pragma_hint();
3803        hint.check = Some("value".into());
3804        let original = serde_json::json!({
3805            "predicate":"node-same-value", "actual":{"kind":"string","value":"hello"},
3806            "expected":{"kind":"string","value":"hello"}, "outcome":"not-rejected",
3807            "projection":{"instance":"tests/a.test.ts:2:3", "callSource":"src/a.ts:4:3", "callIndex":0, "argumentIndex":1, "readAt":"tests/a.test.ts:7:16"}
3808        });
3809        let mut changed = original.clone();
3810        changed["actual"] = serde_json::json!({"kind":"undefined"});
3811        changed["outcome"] = "rejected".into();
3812        hint.payload_sensitivity = Some(serde_json::from_value(serde_json::json!({
3813            "model":"node-closed-payload-sensitivity-v2", "status":"source-checked",
3814            "scope":"closed-synchronous-test-module", "assertionSource":"tests/a.test.ts:7:3",
3815            "targetSource":"src/a.ts:4:3", "changeSource":"src/a.ts:4:12", "changeText":"{ return arg; }",
3816            "allocations":["src/a.ts:2:3"], "original":original,
3817            "variants":[{"change":"map-callback-empty","status":"source-checked","check":changed}]
3818        })).unwrap());
3819        let mut s = site("S1", "return", vec![], &["T1"]);
3820        s.category = "return".into();
3821        // Conditional history aliases need not have a legacy heuristic observation.
3822        let mut t = test("T1", vec![]);
3823        t.file = "tests/a.test.ts".into();
3824        let f = facts(vec![s], vec![t]);
3825        let before = serde_json::to_value(join(&f)).unwrap();
3826        let result = check_pragma_hints(&f, &[hint.clone()]).remove(0);
3827        assert_eq!(result.validation, HintValidation::AnalyzerSupported);
3828        assert_eq!(result.strength, None);
3829        assert_eq!(serde_json::to_value(join(&f)).unwrap(), before);
3830        for case in 0..13 {
3831            let mut h = hint.clone();
3832            let e = h.payload_sensitivity.as_mut().unwrap();
3833            match case {
3834                0 => h.witness = "unavailable".into(),
3835                1 => h.assertion_source = Some("tests/a.test.ts:9:3".into()),
3836                2 => {
3837                    e.variants.as_mut().unwrap()[0]
3838                        .check
3839                        .as_mut()
3840                        .unwrap()
3841                        .outcome = "not-rejected".into()
3842                }
3843                3 => {
3844                    e.original.as_mut().unwrap().actual =
3845                        serde_json::json!({"kind":"opaque-string"})
3846                }
3847                4 => {
3848                    e.variants.as_mut().unwrap()[0]
3849                        .check
3850                        .as_mut()
3851                        .unwrap()
3852                        .expected = serde_json::json!({"kind":"undefined"})
3853                }
3854                5 => {
3855                    e.variants.as_mut().unwrap()[0]
3856                        .check
3857                        .as_mut()
3858                        .unwrap()
3859                        .projection
3860                        .argument_index = 9
3861                }
3862                6 => e.variants.as_mut().unwrap()[0].change = "arbitrary-edit".into(),
3863                7 => e.allocations = Some(vec![]),
3864                8 => e.original.as_mut().unwrap().projection.instance = "tests/other.ts:2:3".into(),
3865                9 => h.assertion_method = Some("ok".into()),
3866                10 => e.variants.as_mut().unwrap()[0].status = "unresolved".into(),
3867                11 => e.scope = Some("whole-suite".into()),
3868                12 => {
3869                    e.original.as_mut().unwrap().actual =
3870                        serde_json::json!({"kind":"string","value":"hello","invented":true})
3871                }
3872                _ => unreachable!(),
3873            }
3874            assert_eq!(
3875                check_pragma_hints(&f, &[h])[0].validation,
3876                HintValidation::Unresolved,
3877                "case {case}"
3878            );
3879        }
3880        let object = serde_json::json!({"kind":"object","properties":[{"name":"a","value":{"kind":"number","value":1}}]});
3881        let opaque = serde_json::json!({"kind":"opaque-string"});
3882        assert_eq!(payload_equal(&opaque, &object, true), Some(false));
3883        assert_eq!(payload_equal(&opaque, &opaque, true), None);
3884        assert_eq!(payload_equal(&object, &object, false), None);
3885        assert!(valid_payload(&object, 0, &mut 4096));
3886        assert!(!valid_payload(
3887            &serde_json::json!({"kind":"object","properties":[{"name":"x","value":{"kind":"null"}},{"name":"x","value":{"kind":"null"}}]}),
3888            0,
3889            &mut 4096
3890        ));
3891    }
3892
3893    #[test]
3894    fn payload_native_predicates_keep_original_witness_separate_from_variant_proofs() {
3895        let mut hint = pragma_hint();
3896        hint.check = Some("value".into());
3897        hint.assertion_method = Some("match".into());
3898        let original = serde_json::json!({
3899            "predicate":"node-literal-regexp", "actual":{"kind":"opaque-string"},
3900            "expected":{"kind":"substring-pattern","value":"a: 1"}, "outcome":"witnessed-pass",
3901            "projection":{"instance":"tests/a.test.ts:2:3", "callSource":"src/a.ts:4:3", "callIndex":0, "argumentIndex":2, "readAt":"tests/a.test.ts:7:16",
3902                "coercion":{"source":"tests/a.test.ts:7:16", "rule":"string-identity", "input":{"kind":"opaque-string"}}}
3903        });
3904        let mut changed = original.clone();
3905        changed["actual"] = serde_json::json!({"kind":"string","value":"[object Object]"});
3906        changed["outcome"] = "rejected".into();
3907        changed["projection"]["coercion"] = serde_json::json!({
3908            "source":"tests/a.test.ts:7:16", "rule":"plain-object-default-string",
3909            "input":{"kind":"object","properties":[{"name":"a","value":{"kind":"number","value":1}}]}
3910        });
3911        hint.payload_sensitivity = Some(serde_json::from_value(serde_json::json!({
3912            "model":"node-closed-payload-sensitivity-v2", "status":"source-checked",
3913            "scope":"closed-synchronous-test-module", "assertionSource":"tests/a.test.ts:7:3",
3914            "targetSource":"src/a.ts:4:3", "changeSource":"src/a.ts:4:12", "changeText":"mode === 'verbose'",
3915            "allocations":["src/a.ts:2:3"], "original":original,
3916            "variants":[
3917                {"change":"condition-false","status":"source-checked","check":changed},
3918                {"change":"condition-true","status":"unresolved","reason":"opaque value"},
3919                {"change":"condition-inverted","status":"unresolved","reason":"opaque value"}
3920            ]
3921        })).unwrap());
3922        let mut s = site("S1", "return", vec![], &["T1"]);
3923        s.category = "return".into();
3924        let mut t = test("T1", vec![]);
3925        t.file = "tests/a.test.ts".into();
3926        let f = facts(vec![s], vec![t]);
3927        assert_eq!(
3928            check_pragma_hints(&f, &[hint.clone()])[0].validation,
3929            HintValidation::AnalyzerSupported
3930        );
3931        for case in 0..11 {
3932            let mut h = hint.clone();
3933            let e = h.payload_sensitivity.as_mut().unwrap();
3934            match case {
3935                0 => h.witness = "unavailable".into(),
3936                1 => h.assertion_source = Some("tests/a.test.ts:9:3".into()),
3937                2 => e.original.as_mut().unwrap().outcome = "not-rejected".into(),
3938                3 => e.variants.as_mut().unwrap()[0].check = e.original.clone(),
3939                4 => {
3940                    let o = e.original.as_mut().unwrap();
3941                    o.actual = serde_json::json!({"kind":"string","value":"does not match"});
3942                    o.projection.coercion = None;
3943                }
3944                5 => {
3945                    e.variants.as_mut().unwrap()[0]
3946                        .check
3947                        .as_mut()
3948                        .unwrap()
3949                        .projection
3950                        .coercion
3951                        .as_mut()
3952                        .unwrap()
3953                        .rule = "guess".into()
3954                }
3955                6 => {
3956                    e.variants.as_mut().unwrap()[0]
3957                        .check
3958                        .as_mut()
3959                        .unwrap()
3960                        .projection
3961                        .coercion
3962                        .as_mut()
3963                        .unwrap()
3964                        .source = "tests/a.test.ts:9:3".into()
3965                }
3966                7 => {
3967                    e.variants.as_mut().unwrap()[0]
3968                        .check
3969                        .as_mut()
3970                        .unwrap()
3971                        .projection
3972                        .coercion
3973                        .as_mut()
3974                        .unwrap()
3975                        .input = serde_json::json!({"kind":"object","properties":[{"name":"toString","value":{"kind":"string","value":"a: 1"}}]})
3976                }
3977                8 => {
3978                    e.original.as_mut().unwrap().expected =
3979                        serde_json::json!({"kind":"substring-pattern","value":"a: [0-9]"})
3980                }
3981                9 => e.model = "node-closed-payload-sensitivity-v1".into(),
3982                10 => h.assertion_method = Some("equal".into()),
3983                _ => unreachable!(),
3984            }
3985            assert_eq!(
3986                check_pragma_hints(&f, &[h])[0].validation,
3987                HintValidation::Unresolved,
3988                "case {case}"
3989            );
3990        }
3991        let quoted = serde_json::json!({"kind":"quoted-string","value":"hello"});
3992        assert_eq!(
3993            payload_equal(
3994                &quoted,
3995                &serde_json::json!({"kind":"string","value":"hello"}),
3996                false
3997            ),
3998            Some(false)
3999        );
4000        assert_eq!(
4001            payload_equal(
4002                &quoted,
4003                &serde_json::json!({"kind":"string","value":"'hello'"}),
4004                false
4005            ),
4006            None
4007        );
4008        assert_eq!(payload_equal(&quoted, &quoted, false), None);
4009        assert!(!valid_payload(
4010            &serde_json::json!({"kind":"quoted-string","value":"hello!"}),
4011            0,
4012            &mut 4096
4013        ));
4014    }
4015
4016    #[test]
4017    fn source_supported_await_does_not_borrow_a_passing_assertion_phase() {
4018        let mut hint = pragma_hint();
4019        hint.awaited_observation = Some(AwaitedObservationSource {
4020            model: "node-child-capture-poll-v1".into(),
4021            factory_source: "tests/process.mjs:4:1".into(),
4022            predicate_source: "tests/process.mjs:21:38".into(),
4023            captures: vec![ObservationCaptureSource {
4024                stream: "stdout".into(),
4025                source: "tests/process.mjs:8:58".into(),
4026            }],
4027            pattern: "/ready/".into(),
4028        });
4029        // Even a supplied 'passed' hint and matching ordinary observation are
4030        // not a read receipt for an awaited source model.
4031        let mut ob = observation("return:handler", Strength::Total);
4032        ob.assertion_source = hint.assertion_source.clone();
4033        ob.assertion_method = hint.assertion_method.clone();
4034        let f = facts(
4035            vec![site("S1", "return", vec![], &["T1"])],
4036            vec![test("T1", vec![ob])],
4037        );
4038        let before = join(&f);
4039        let checks = check_pragma_hints(&f, &[hint]);
4040        assert_eq!(checks[0].validation, HintValidation::Unresolved);
4041        assert_eq!(checks[0].reason, "observation-capture-unavailable");
4042        assert!(checks[0].observations.is_empty());
4043        assert!(checks[0].strength.is_none());
4044        assert_eq!(join(&f), before);
4045    }
4046
4047    #[test]
4048    fn pragma_hints_use_only_the_named_assertion_and_never_change_join_credit() {
4049        let hint = pragma_hint();
4050        let mut wanted = observation("return:handler", Strength::Value);
4051        wanted.assertion_source = hint.assertion_source.clone();
4052        wanted.assertion_method = hint.assertion_method.clone();
4053        let mut other = wanted.clone();
4054        other.assertion_source = Some("tests/a.test.ts:9:3".into());
4055        let f = facts(
4056            vec![site(
4057                "S1",
4058                "return",
4059                vec![boundary("return:handler")],
4060                &["T1"],
4061            )],
4062            vec![test("T1", vec![wanted.clone(), other.clone()])],
4063        );
4064        let before = join(&f);
4065        let checked = check_pragma_hints(&f, std::slice::from_ref(&hint));
4066        assert_eq!(checked[0].validation, HintValidation::AnalyzerSupported);
4067        assert_eq!(checked[0].origin, "user-suggested");
4068        assert_eq!(checked[0].strength, Some(Strength::Value));
4069        assert_eq!(checked[0].observations, vec![wanted.clone()]);
4070        assert_eq!(join(&f), before);
4071
4072        let mut unrelated = f.clone();
4073        unrelated.tests[0].observations[0].boundary = "return:other".into();
4074        assert_eq!(
4075            join(&unrelated)[0].status,
4076            Status::Evident,
4077            "a different assertion still checks it"
4078        );
4079        assert_eq!(
4080            check_pragma_hints(&unrelated, std::slice::from_ref(&hint))[0].validation,
4081            HintValidation::Unresolved,
4082            "cannot borrow that assertion's credit"
4083        );
4084
4085        let mut other_test = f.clone();
4086        other_test.sites[0].covered_by = vec!["T2".into()];
4087        other_test.tests.push(test("T2", vec![wanted]));
4088        assert_eq!(
4089            check_pragma_hints(&other_test, std::slice::from_ref(&hint))[0].reason,
4090            "target-not-reached-in-owning-test"
4091        );
4092
4093        for issue in [
4094            "call-failed",
4095            "mixed-call-outcomes",
4096            "call-incomplete",
4097            "call-not-recorded",
4098            "capture-unavailable",
4099        ] {
4100            let mut rejected = hint.clone();
4101            rejected.witness_issue = Some(issue.into());
4102            rejected.witness = "unavailable".into();
4103            let check = check_pragma_hints(&f, &[rejected]).remove(0);
4104            assert_eq!(check.validation, HintValidation::Unresolved);
4105            assert_eq!(check.reason, issue);
4106            assert_eq!(check.strength, None);
4107        }
4108        let mut ambiguous = hint.clone();
4109        ambiguous.candidate_sites.push("S2".into());
4110        assert_eq!(
4111            check_pragma_hints(&f, &[ambiguous])[0].validation,
4112            HintValidation::Invalid
4113        );
4114        let mut no_identity = hint;
4115        no_identity.assertion_source = None;
4116        assert_eq!(
4117            check_pragma_hints(&f, &[no_identity])[0].reason,
4118            "missing-assertion-identity"
4119        );
4120    }
4121
4122    #[test]
4123    fn pragma_hints_preserve_presence_strength_and_do_not_derive_internal_credit() {
4124        let hint = pragma_hint();
4125        let mut ob = observation("return:handler", Strength::Presence);
4126        ob.assertion_source = hint.assertion_source.clone();
4127        ob.assertion_method = hint.assertion_method.clone();
4128        let mut f = facts(
4129            vec![site(
4130                "S1",
4131                "return",
4132                vec![boundary("return:handler")],
4133                &["T1"],
4134            )],
4135            vec![test("T1", vec![ob])],
4136        );
4137        let check = check_pragma_hints(&f, std::slice::from_ref(&hint)).remove(0);
4138        assert_eq!(check.validation, HintValidation::AnalyzerSupported);
4139        assert_eq!(check.strength, Some(Strength::Presence));
4140        f.sites[0].bounds = vec![boundary("internal")];
4141        assert_eq!(
4142            check_pragma_hints(&f, std::slice::from_ref(&hint))[0].validation,
4143            HintValidation::Unresolved
4144        );
4145        f.sites[0].kind = "decision".into();
4146        assert_eq!(
4147            check_pragma_hints(&f, &[hint])[0].reason,
4148            "decision-hint-analysis-not-supported"
4149        );
4150    }
4151
4152    #[test]
4153    fn unavailable_assertion_evidence_is_a_limit_without_changing_coverage_or_credit() {
4154        let mut f = facts(
4155            vec![site(
4156                "S",
4157                "return",
4158                vec![boundary("return:handler")],
4159                &["T"],
4160            )],
4161            vec![test("T", vec![])],
4162        );
4163        let before = join(&f)[0].clone();
4164        f.tests[0]
4165            .witness_issues
4166            .push(rejected(WitnessIssueKind::CaptureUnavailable, None));
4167        let after = &join(&f)[0];
4168        assert_eq!(
4169            after.reason.as_ref().unwrap().kind,
4170            ReasonKind::LimitAssertionWitness
4171        );
4172        assert_eq!(after.status, before.status);
4173        assert_eq!(after.strength, before.strength);
4174        assert_eq!(after.covered_by, before.covered_by);
4175        assert_eq!(after.tests, before.tests);
4176        assert_eq!(after.witness_issues[0].test, "T");
4177        assert_eq!(summary(&f.sites, &join(&f)).limits, 1);
4178        assert_eq!(summary(&f.sites, &join(&f)).gaps, 0);
4179    }
4180
4181    #[test]
4182    fn unlinked_test_source_is_not_an_assertion_gap_or_pragma_permission() {
4183        let mut missing = test("T1", vec![]);
4184        missing.witness_issues.push(WitnessIssue {
4185            kind: WitnessIssueKind::TestSourceUnlinked,
4186            source: None,
4187            operation: None,
4188            observation: None,
4189        });
4190        let mut f = facts(
4191            vec![
4192                site("S1", "return", vec![boundary("return:handler")], &["T1"]),
4193                site("uncovered", "return", vec![boundary("return:handler")], &[]),
4194                site(
4195                    "unrelated",
4196                    "return",
4197                    vec![boundary("return:handler")],
4198                    &["T2"],
4199                ),
4200            ],
4201            vec![missing, test("T2", vec![])],
4202        );
4203        let rows = join(&f);
4204        assert_eq!(
4205            rows[0].reason.as_ref().unwrap().kind,
4206            ReasonKind::LimitAssertionWitness
4207        );
4208        assert_eq!(
4209            rows[0].witness_issues[0].issue.kind,
4210            WitnessIssueKind::TestSourceUnlinked
4211        );
4212        assert_eq!(rows[0].strength, None);
4213        assert_eq!(
4214            rows[1].reason.as_ref().unwrap().kind,
4215            ReasonKind::GapNotReached
4216        );
4217        assert_eq!(
4218            rows[2].reason.as_ref().unwrap().kind,
4219            ReasonKind::GapNotAsserted
4220        );
4221        let mut hint = pragma_hint();
4222        hint.assertion_source = Some("tests/a.test.ts:7:3".into());
4223        for recipe in [None, Some("value"), Some("count"), Some("missing-call")] {
4224            hint.check = recipe.map(String::from);
4225            let result = check_pragma_hints(&f, &[hint.clone()]).remove(0);
4226            assert_eq!(result.validation, HintValidation::Unresolved);
4227            assert_eq!(result.reason, "test-source-unlinked");
4228        }
4229        // An independent positive observation is not erased by an unlinked test.
4230        f.sites[0].covered_by.push("T2".into());
4231        f.tests[1]
4232            .observations
4233            .push(observation("return:handler", Strength::Total));
4234        assert_eq!(join(&f)[0].status, Status::Evident);
4235        assert_eq!(join(&f)[0].witness_issues.len(), 1);
4236    }
4237
4238    #[test]
4239    fn witnessed_callback_does_not_close_test_scope_or_validate_guidance() {
4240        let mut t = test("T1", vec![]);
4241        t.witness_issues.push(WitnessIssue {
4242            kind: WitnessIssueKind::TestRegistrationScopeUnverified,
4243            source: None,
4244            operation: None,
4245            observation: None,
4246        });
4247        let mut f = facts(
4248            vec![site(
4249                "S1",
4250                "return",
4251                vec![boundary("return:handler")],
4252                &["T1"],
4253            )],
4254            vec![t],
4255        );
4256        assert_eq!(
4257            join(&f)[0].reason.as_ref().unwrap().kind,
4258            ReasonKind::LimitAssertionWitness
4259        );
4260        let mut hint = pragma_hint();
4261        hint.assertion_source = Some("tests/a.test.ts:7:3".into());
4262        for recipe in [None, Some("value"), Some("count"), Some("missing-call")] {
4263            hint.check = recipe.map(String::from);
4264            let result = check_pragma_hints(&f, &[hint.clone()]).remove(0);
4265            assert_eq!(result.validation, HintValidation::Unresolved);
4266            assert_eq!(result.reason, "test-registration-scope-unverified");
4267        }
4268        // Local observations remain candidate evidence, never scope closure.
4269        f.tests[0]
4270            .observations
4271            .push(observation("return:handler", Strength::Total));
4272        let row = join(&f).remove(0);
4273        assert_eq!(row.status, Status::Evident);
4274        assert_eq!(
4275            row.witness_issues[0].issue.kind,
4276            WitnessIssueKind::TestRegistrationScopeUnverified
4277        );
4278        assert_eq!(
4279            check_pragma_hints(&f, &[hint]).remove(0).validation,
4280            HintValidation::Unresolved
4281        );
4282    }
4283
4284    #[test]
4285    fn self_comparisons_cannot_supply_value_absence_sink_pragma_or_early_exit_credit() {
4286        for (predicate, relation) in [
4287            "node-same-value",
4288            "node-loose-equality",
4289            "node-deep-equality",
4290            "node-deep-strict-equality",
4291        ]
4292        .into_iter()
4293        .flat_map(|predicate| {
4294            ["same-immutable-binding", "shared-input-through-await"]
4295                .map(|relation| (predicate, relation))
4296        }) {
4297            let operand = ComparisonOperand {
4298                source: "tests/a.test.ts:70:76".into(),
4299                value: None,
4300                binding: Some("tests/a.test.ts:20:40".into()),
4301                input: (relation == "shared-input-through-await").then(|| ComparisonInput {
4302                    binding: "tests/a.test.ts:20:40".into(),
4303                    awaits: vec!["tests/a.test.ts:64:76".into()],
4304                }),
4305            };
4306            let mut ob = observation("return:handler", Strength::Total);
4307            ob.comparison = Some(Comparison {
4308                predicate: predicate.into(),
4309                actual: operand.clone(),
4310                expected: ComparisonOperand {
4311                    source: "tests/a.test.ts:78:84".into(),
4312                    ..operand
4313                },
4314                relation: relation.into(),
4315            });
4316            ob.assertion_source = Some("tests/a.test.ts:7:3".into());
4317            ob.assertion_method = Some("equal".into());
4318            ob.call_list = true;
4319            let mut negative = ob.clone();
4320            negative.negative = true;
4321            let mut sink_ob = ob.clone();
4322            sink_ob.boundary = "sink:records".into();
4323            let mut negative_sink = sink_ob.clone();
4324            negative_sink.negative = true;
4325            let mut t = test("T1", vec![ob.clone(), negative, sink_ob, negative_sink]);
4326            t.sinks.push(SinkBinding {
4327                sink: "sink:records".into(),
4328                param: "writer".into(),
4329                member: None,
4330            });
4331            let mut upstream = site("S2", "return", vec![boundary("internal")], &["T1"]);
4332            upstream.reached = vec!["S1".into()];
4333            let mut decision = site("D1", "condition", vec![], &["T1", "T2"]);
4334            decision.kind = "decision".into();
4335            decision.decision = Some(DecisionFacts {
4336                then: Some(vec!["S4".into()]),
4337                else_: Some(Some(vec!["S5".into()])),
4338                early_exit_downstream: Some(vec!["S5".into()]),
4339                outcomes: Some(Outcomes {
4340                    true_: vec!["T1".into()],
4341                    false_: vec!["T2".into()],
4342                }),
4343                ..Default::default()
4344            });
4345            let mut f = facts(
4346                vec![
4347                    site("S1", "return", vec![boundary("return:handler")], &["T1"]),
4348                    upstream,
4349                    site(
4350                        "S3",
4351                        "external-call",
4352                        vec![boundary("callback:writer")],
4353                        &["T1"],
4354                    ),
4355                    site("S4", "return", vec![boundary("internal")], &["T1"]),
4356                    site("S5", "io-call", vec![boundary("client-message")], &["T2"]),
4357                    decision,
4358                ],
4359                vec![
4360                    t,
4361                    test("T2", vec![observation("client-message", Strength::Total)]),
4362                ],
4363            );
4364            let rows = join(&f);
4365            assert!(
4366                rows[..4]
4367                    .iter()
4368                    .all(|r| r.status == Status::Unresolved && r.strength.is_none()),
4369                "{predicate}"
4370            );
4371            let d = rows.iter().find(|r| r.site == "D1").unwrap();
4372            assert_eq!(d.status, Status::Partial);
4373            assert_eq!(d.stuck_false_caught, Some(false));
4374            assert_eq!(
4375                check_pragma_hints(&f, &[pragma_hint()])[0].validation,
4376                HintValidation::Unresolved
4377            );
4378            let joiner = Join {
4379                sites: f.sites.iter().map(|s| (s.id.as_str(), s)).collect(),
4380                tests: f.tests.iter().map(|t| (t.id.as_str(), t)).collect(),
4381                facts: &f,
4382                resolved: BTreeMap::new(),
4383            };
4384            assert!(!joiner.pinned(&BTreeSet::from(["T1"]), &["S1".into()]));
4385            let bytes = serde_json::to_vec(&ob).unwrap();
4386            assert_eq!(serde_json::from_slice::<Observation>(&bytes).unwrap(), ob);
4387            f.tests[0]
4388                .observations
4389                .push(observation("return:handler", Strength::Value));
4390            let rows = join(&f);
4391            assert_eq!(
4392                rows[0].status,
4393                Status::Evident,
4394                "independent evidence is preserved"
4395            );
4396            assert_eq!(
4397                rows.iter()
4398                    .find(|r| r.site == "D1")
4399                    .unwrap()
4400                    .stuck_false_caught,
4401                Some(true)
4402            );
4403        }
4404    }
4405
4406    #[test]
4407    fn awaited_input_limits_preserve_execution_gaps_and_independent_evidence() {
4408        let mut ob = observation("return:handler", Strength::Total);
4409        ob.comparison = Some(Comparison {
4410            predicate: "node-deep-strict-equality".into(),
4411            actual: ComparisonOperand {
4412                source: "tests/a:10:20".into(),
4413                value: None,
4414                binding: None,
4415                input: Some(ComparisonInput {
4416                    binding: "tests/a:1:5".into(),
4417                    awaits: vec!["tests/a:10:20".into()],
4418                }),
4419            },
4420            expected: ComparisonOperand {
4421                source: "tests/a:22:25".into(),
4422                value: None,
4423                binding: Some("tests/a:1:5".into()),
4424                input: Some(ComparisonInput {
4425                    binding: "tests/a:1:5".into(),
4426                    awaits: vec![],
4427                }),
4428            },
4429            relation: "shared-input-through-await".into(),
4430        });
4431        let mut decision = site("D", "condition", vec![], &["T"]);
4432        decision.kind = "decision".into();
4433        decision.decision = Some(DecisionFacts {
4434            then: Some(vec!["S".into()]),
4435            else_: Some(Some(vec!["S".into()])),
4436            outcomes: Some(Outcomes {
4437                true_: vec!["T".into()],
4438                false_: vec![],
4439            }),
4440            ..Default::default()
4441        });
4442        let mut f = facts(
4443            vec![
4444                site("S", "return", vec![boundary("return:handler")], &["T"]),
4445                site("untested", "return", vec![boundary("return:handler")], &[]),
4446                site(
4447                    "unrelated",
4448                    "return",
4449                    vec![boundary("return:other")],
4450                    &["T"],
4451                ),
4452                decision,
4453            ],
4454            vec![test("T", vec![ob.clone()])],
4455        );
4456        let rows = join(&f);
4457        assert_eq!(rows[0].status, Status::Unresolved);
4458        assert_eq!(rows[0].strength, None);
4459        assert!(rows[0].tests.is_empty());
4460        assert!(
4461            rows[0].witness_issues.is_empty(),
4462            "the witness is not rejected"
4463        );
4464        assert_eq!(
4465            rows[0].reason.as_ref().unwrap().kind,
4466            ReasonKind::LimitPredicateDependence
4467        );
4468        assert_eq!(
4469            rows[1].reason.as_ref().unwrap().kind,
4470            ReasonKind::GapNotReached
4471        );
4472        assert_eq!(
4473            rows[2].reason.as_ref().unwrap().kind,
4474            ReasonKind::GapNotAsserted
4475        );
4476        assert_eq!(
4477            rows[3].reason.as_ref().unwrap().kind,
4478            ReasonKind::GapOutcomeNotAsserted
4479        );
4480        assert_eq!(summary(&f.sites, &rows).limits, 1);
4481        assert_eq!(f.tests[0].observations, vec![ob.clone()]);
4482        f.tests[0]
4483            .observations
4484            .push(observation("return:handler", Strength::Total));
4485        assert_eq!(join(&f)[0].status, Status::Evident);
4486        f.tests[0].observations.clear();
4487        let mut issue = rejected(WitnessIssueKind::CallNotRecorded, Some("return:handler"));
4488        issue.observation = Some(ob);
4489        f.tests[0].witness_issues.push(issue);
4490        let missing = &join(&f)[0];
4491        assert_eq!(missing.status, Status::Unresolved);
4492        assert_eq!(missing.strength, None);
4493        assert_eq!(missing.witness_issues.len(), 1);
4494        assert_eq!(
4495            missing.reason.as_ref().unwrap().kind,
4496            ReasonKind::LimitAssertionWitness
4497        );
4498    }
4499
4500    #[test]
4501    fn exit_resolver_source_facts_are_not_producer_value_or_pragma_evidence() {
4502        let mut ob = observation("exit", Strength::Total);
4503        ob.assertion_source = Some("tests/a.test.ts:7:3".into());
4504        ob.assertion_method = Some("equal".into());
4505        ob.process_exit = Some(
4506            serde_json::from_value(serde_json::json!({
4507                "model": "node-child-exit-source-v1", "status": "unresolved",
4508                "reason": "producer-instance-link-unverified",
4509                "operand": "tests/a.test.ts:30:50", "helperCalls": ["tests/a.test.ts:20:28"],
4510                "promise": "tests/helper.ts:50:100", "spawn": "tests/helper.ts:10:40",
4511                "event": {"source": "tests/helper.ts:60:90", "name": "exit"},
4512                "resolution": {"status": "source-checked", "source": "tests/helper.ts:75:89",
4513                    "field": "code", "eventArgument": "code"},
4514                "consumer": {"status": "source-checked", "bindings": ["tests/a.test.ts:10:28"],
4515                    "read": "tests/a.test.ts:30:50"}
4516            }))
4517            .unwrap(),
4518        );
4519        let encoded = serde_json::to_value(&ob).unwrap();
4520        assert_eq!(
4521            encoded["processExit"]["resolution"]["eventArgument"],
4522            "code"
4523        );
4524        assert_eq!(
4525            encoded["processExit"]["consumer"]["status"],
4526            "source-checked"
4527        );
4528        assert_eq!(serde_json::from_value::<Observation>(encoded).unwrap(), ob);
4529        let mut parent = site("parent", "return", vec![], &["T1"]);
4530        parent.reached.push("S1".into());
4531        let mut f = facts(
4532            vec![
4533                site("S1", "io-call", vec![boundary("exit")], &["T1"]),
4534                parent,
4535                site("unreached", "io-call", vec![boundary("exit")], &[]),
4536                site(
4537                    "independent",
4538                    "return",
4539                    vec![boundary("return:handler")],
4540                    &["T1"],
4541                ),
4542            ],
4543            vec![test(
4544                "T1",
4545                vec![ob.clone(), observation("return:handler", Strength::Total)],
4546            )],
4547        );
4548        for legacy in [false, true] {
4549            if legacy {
4550                f.tests[0].observations[0].process_exit = None;
4551            }
4552            let rows = join(&f);
4553            for row in &rows[..2] {
4554                assert_eq!(row.status, Status::Unresolved);
4555                assert_eq!(row.strength, None);
4556                assert_eq!(
4557                    row.reason.as_ref().unwrap().kind,
4558                    ReasonKind::LimitProcessExitLink
4559                );
4560            }
4561            assert_eq!(
4562                rows[2].reason.as_ref().unwrap().kind,
4563                ReasonKind::GapNotReached
4564            );
4565            assert_eq!(rows[3].status, Status::Evident);
4566            assert_eq!(
4567                check_pragma_hints(&f, &[pragma_hint()])[0].validation,
4568                HintValidation::Unresolved
4569            );
4570        }
4571        f.tests[0].observations.clear();
4572        let mut issue = rejected(WitnessIssueKind::CallNotRecorded, Some("exit"));
4573        issue.observation = Some(ob);
4574        f.tests[0].witness_issues.push(issue);
4575        let rows = join(&f);
4576        assert_eq!(
4577            rows[0].reason.as_ref().unwrap().kind,
4578            ReasonKind::LimitAssertionWitness
4579        );
4580        assert_eq!(rows[0].witness_issues.len(), 1);
4581    }
4582
4583    #[test]
4584    fn mock_projections_cannot_supply_value_absence_sink_or_pragma_credit() {
4585        let mut direct = site("S1", "log", vec![boundary("stdout")], &["T1"]);
4586        direct.unmodelled_shapes = vec!["mock call identity unresolved".into()];
4587        let mut upstream = site("S2", "return", vec![boundary("internal")], &["T1"]);
4588        upstream.reached = vec!["S1".into()];
4589        upstream.unmodelled_shapes = direct.unmodelled_shapes.clone();
4590        let mut injected = site(
4591            "S3",
4592            "external-call",
4593            vec![boundary("callback:writer")],
4594            &["T1"],
4595        );
4596        injected.unmodelled_shapes = direct.unmodelled_shapes.clone();
4597        for kind in [
4598            "call-count",
4599            "call-arguments",
4600            "call-history",
4601            "projection",
4602            "future-kind",
4603        ] {
4604            let mut ob = observation("stdout", Strength::Total);
4605            ob.mock = Some(MockProjection {
4606                target: "console.log".into(),
4607                kind: kind.into(),
4608                path: vec!["mock".into(), "calls".into()],
4609                count_evidence: Some(serde_json::from_value(serde_json::json!({
4610                    "model": "node-sync-console-count-v2", "status": "source-checked",
4611                    "instance": "tests/a.test.ts:2:3", "createdAt": "tests/a.test.ts:2:3",
4612                    "readAt": "tests/a.test.ts:7:3", "installedAtRead": true,
4613                    "expectedCount": 1, "observedCount": 1,
4614                    "calls": [{"source":"src/a.ts:4:3", "action":"tests/a.test.ts:5:3", "site":"S1"}],
4615                    "historySelections": [{"source":"tests/a.test.ts:6:3", "inputCount":3, "from":1, "to":2}],
4616                    "rowBinding": {"model":"node-test-for-of-v1", "status":"source-checked", "rowIndex":0,
4617                        "title":"row a", "loop":"tests/a.test.ts:1:1", "table":"tests/a.test.ts:1:10",
4618                        "row":"tests/a.test.ts:1:12", "bindings":[{"declaration":"tests/a.test.ts:2:1", "name":"label", "value":"a"}]}
4619                })).unwrap()),
4620            });
4621            // Even contradictory whole-list/negative flags cannot bypass the
4622            // projection guard. A hint is not an escape hatch either.
4623            ob.call_list = true;
4624            ob.negative = true;
4625            ob.assertion_source = Some("tests/a.test.ts:7:3".into());
4626            ob.assertion_method = Some("equal".into());
4627            let mut sink_ob = ob.clone();
4628            sink_ob.boundary = "sink:records".into();
4629            let mut positive = ob.clone();
4630            positive.negative = false;
4631            let mut positive_sink = sink_ob.clone();
4632            positive_sink.negative = false;
4633            let mut t = test("T1", vec![ob.clone(), positive, sink_ob, positive_sink]);
4634            t.sinks.push(SinkBinding {
4635                sink: "sink:records".into(),
4636                param: "writer".into(),
4637                member: None,
4638            });
4639            let mut f = facts(
4640                vec![direct.clone(), upstream.clone(), injected.clone()],
4641                vec![t],
4642            );
4643            assert!(
4644                join(&f)
4645                    .iter()
4646                    .all(|r| r.status == Status::Unresolved && r.strength.is_none()),
4647                "{kind}"
4648            );
4649            assert_eq!(
4650                check_pragma_hints(&f, &[pragma_hint()])[0].validation,
4651                HintValidation::Unresolved
4652            );
4653            let joiner = Join {
4654                sites: f.sites.iter().map(|s| (s.id.as_str(), s)).collect(),
4655                tests: f.tests.iter().map(|t| (t.id.as_str(), t)).collect(),
4656                facts: &f,
4657                resolved: BTreeMap::new(),
4658            };
4659            assert!(
4660                !joiner.pinned(&BTreeSet::from(["T1"]), &["S1".into()]),
4661                "{kind}"
4662            );
4663            let bytes = serde_json::to_vec(&ob).unwrap();
4664            assert_eq!(serde_json::from_slice::<Observation>(&bytes).unwrap(), ob);
4665            f.tests[0]
4666                .observations
4667                .push(observation("stdout", Strength::Value));
4668            assert_eq!(
4669                join(&f)[0].status,
4670                Status::Evident,
4671                "independent non-mock evidence is preserved"
4672            );
4673        }
4674    }
4675
4676    #[test]
4677    fn mock_projection_cannot_supply_the_early_return_decision_shortcut() {
4678        let mut decision = site("D1", "condition", vec![], &["T1", "T2"]);
4679        decision.kind = "decision".into();
4680        decision.decision = Some(DecisionFacts {
4681            then: Some(vec!["S1".into()]),
4682            else_: Some(Some(vec!["S2".into()])),
4683            early_exit_downstream: Some(vec!["S2".into()]),
4684            outcomes: Some(Outcomes {
4685                true_: vec!["T1".into()],
4686                false_: vec!["T2".into()],
4687            }),
4688            ..Default::default()
4689        });
4690        let mut projected_return = observation("return:handler", Strength::Value);
4691        projected_return.mock = Some(MockProjection {
4692            target: "console.log".into(),
4693            kind: "projection".into(),
4694            path: vec!["mock".into(), "calls".into()],
4695            count_evidence: Some(
4696                serde_json::from_value(serde_json::json!({
4697                    "model":"node-sync-console-count-v1", "status":"source-checked",
4698                    "observedCount":0,"expectedCount":0,"calls":[]
4699                }))
4700                .unwrap(),
4701            ),
4702        });
4703        let mut f = facts(
4704            vec![
4705                site("S1", "return", vec![boundary("internal")], &["T1"]),
4706                site("S2", "io-call", vec![boundary("client-message")], &["T2"]),
4707                decision,
4708            ],
4709            vec![
4710                test("T1", vec![projected_return]),
4711                test("T2", vec![observation("client-message", Strength::Total)]),
4712            ],
4713        );
4714        let r = join(&f);
4715        let d = r.iter().find(|r| r.site == "D1").unwrap();
4716        assert_eq!(d.stuck_false_caught, Some(false));
4717        assert_eq!(d.status, Status::Partial);
4718        f.tests[0].observations[0].mock = None;
4719        let r = join(&f);
4720        let d = r.iter().find(|r| r.site == "D1").unwrap();
4721        assert_eq!(d.stuck_false_caught, Some(true));
4722        assert_eq!(d.status, Status::Evident);
4723    }
4724
4725    #[test]
4726    fn witness_limit_kinds_and_successful_evidence_are_not_conflated() {
4727        for kind in [
4728            WitnessIssueKind::CallNotRecorded,
4729            WitnessIssueKind::CallIncomplete,
4730            WitnessIssueKind::MixedCallOutcomes,
4731            WitnessIssueKind::UninstrumentedObservation,
4732            WitnessIssueKind::CallFailed,
4733        ] {
4734            let mut f = facts(
4735                vec![site(
4736                    "S",
4737                    "return",
4738                    vec![boundary("return:handler")],
4739                    &["T"],
4740                )],
4741                vec![test("T", vec![])],
4742            );
4743            f.tests[0]
4744                .witness_issues
4745                .push(rejected(kind, Some("return:handler")));
4746            let result = join(&f);
4747            assert_eq!(
4748                result[0].reason.as_ref().unwrap().kind,
4749                if kind == WitnessIssueKind::CallFailed {
4750                    ReasonKind::GapNotAsserted
4751                } else {
4752                    ReasonKind::LimitAssertionWitness
4753                }
4754            );
4755            assert_eq!(result[0].witness_issues[0].issue.kind, kind);
4756            assert!(result[0].strength.is_none());
4757            f.tests[0]
4758                .observations
4759                .push(observation("return:handler", Strength::Value));
4760            let result = join(&f);
4761            assert_eq!(result[0].status, Status::Evident);
4762            assert_eq!(result[0].strength, Some(Strength::Value));
4763            assert!(result[0].reason.is_none());
4764        }
4765    }
4766
4767    #[test]
4768    fn unrelated_tests_boundaries_and_known_execution_gaps_do_not_become_witness_limits() {
4769        let mut f = facts(
4770            vec![
4771                site(
4772                    "covered",
4773                    "return",
4774                    vec![boundary("return:handler")],
4775                    &["T"],
4776                ),
4777                site("uncovered", "return", vec![boundary("return:other")], &[]),
4778            ],
4779            vec![test("T", vec![]), test("foreign", vec![])],
4780        );
4781        f.tests[0].witness_issues.push(rejected(
4782            WitnessIssueKind::CallNotRecorded,
4783            Some("return:unrelated"),
4784        ));
4785        f.tests[1]
4786            .witness_issues
4787            .push(rejected(WitnessIssueKind::CaptureUnavailable, None));
4788        let r = join(&f);
4789        assert_eq!(
4790            r[0].reason.as_ref().unwrap().kind,
4791            ReasonKind::GapNotAsserted
4792        );
4793        assert_eq!(
4794            r[1].reason.as_ref().unwrap().kind,
4795            ReasonKind::GapNotReached
4796        );
4797        assert!(r.iter().all(|r| r.witness_issues.is_empty()));
4798    }
4799
4800    #[test]
4801    fn decision_dependencies_retain_witness_uncertainty_without_inventing_taken_outcomes() {
4802        let mut d = site("D", "condition", vec![], &["T"]);
4803        d.kind = "decision".into();
4804        d.decision = Some(DecisionFacts {
4805            then: Some(vec!["S".into()]),
4806            else_: Some(None),
4807            outcomes: Some(Outcomes {
4808                true_: vec!["T".into()],
4809                false_: vec!["T".into()],
4810            }),
4811            ..Default::default()
4812        });
4813        let mut f = facts(
4814            vec![
4815                d,
4816                site("S", "return", vec![boundary("return:handler")], &["T"]),
4817            ],
4818            vec![test("T", vec![])],
4819        );
4820        f.tests[0].witness_issues.push(rejected(
4821            WitnessIssueKind::CallNotRecorded,
4822            Some("return:handler"),
4823        ));
4824        let r = join(&f);
4825        assert_eq!(
4826            r[0].reason.as_ref().unwrap().kind,
4827            ReasonKind::LimitAssertionWitness
4828        );
4829        assert_eq!(r[0].stuck_false_caught, Some(false));
4830        assert_eq!(r[0].stuck_true_caught, Some(false));
4831        f.sites[0]
4832            .decision
4833            .as_mut()
4834            .unwrap()
4835            .outcomes
4836            .as_mut()
4837            .unwrap()
4838            .false_
4839            .clear();
4840        let r = join(&f);
4841        assert_eq!(
4842            r[0].reason.as_ref().unwrap().kind,
4843            ReasonKind::GapOutcomeNotAsserted
4844        );
4845        assert!(!r[0].witness_issues.is_empty());
4846    }
4847
4848    #[test]
4849    fn witness_provenance_crosses_cyclic_derivations_without_supplying_strength() {
4850        let mut s = site("S", "return", vec![boundary("return:start")], &["T"]);
4851        s.derive.push(Dependent {
4852            site: "end".into(),
4853            label: "flow".into(),
4854            strength: None,
4855            requires_total: None,
4856        });
4857        let mut end = site("end", "return", vec![boundary("return:end")], &["T"]);
4858        end.reached.push("S".into());
4859        let mut f = facts(vec![s, end], vec![test("T", vec![])]);
4860        f.tests[0].witness_issues.push(rejected(
4861            WitnessIssueKind::CallIncomplete,
4862            Some("return:end"),
4863        ));
4864        let result = join(&f);
4865        assert!(
4866            result
4867                .iter()
4868                .all(|r| r.reason.as_ref().unwrap().kind == ReasonKind::LimitAssertionWitness)
4869        );
4870        assert!(result.iter().all(|r| r.strength.is_none()));
4871        f.tests[0].witness_issues[0]
4872            .observation
4873            .as_mut()
4874            .unwrap()
4875            .boundary = "return:unrelated".into();
4876        assert!(join(&f).iter().all(|r| r.witness_issues.is_empty()));
4877    }
4878
4879    #[test]
4880    fn witness_issues_round_trip_and_reject_unknown_kinds() {
4881        let issue = rejected(WitnessIssueKind::CallNotRecorded, Some("return:handler"));
4882        let json = serde_json::to_value(&issue).unwrap();
4883        assert_eq!(
4884            serde_json::from_value::<WitnessIssue>(json.clone()).unwrap(),
4885            issue
4886        );
4887        let mut invalid = json;
4888        invalid["kind"] = serde_json::json!("invented-witness");
4889        assert!(serde_json::from_value::<WitnessIssue>(invalid).is_err());
4890        let mut legacy = serde_json::to_value(test("T", vec![])).unwrap();
4891        legacy.as_object_mut().unwrap().remove("witnessIssues");
4892        assert!(
4893            serde_json::from_value::<TestFacts>(legacy)
4894                .unwrap()
4895                .witness_issues
4896                .is_empty()
4897        );
4898    }
4899
4900    #[test]
4901    fn an_assertion_on_the_return_makes_the_return_site_evident() {
4902        let f = facts(
4903            vec![site(
4904                "S1",
4905                "return",
4906                vec![boundary("return:handler")],
4907                &["T1"],
4908            )],
4909            vec![test(
4910                "T1",
4911                vec![observation("return:handler", Strength::Total)],
4912            )],
4913        );
4914        let r = join(&f);
4915        assert_eq!(r[0].status, Status::Evident);
4916        assert_eq!(r[0].strength, Some(Strength::Total));
4917        assert!(r[0].reason.is_none());
4918    }
4919
4920    #[test]
4921    fn a_site_no_test_reaches_is_a_gap_not_a_limit() {
4922        let f = facts(
4923            vec![site("S1", "return", vec![boundary("return:handler")], &[])],
4924            vec![],
4925        );
4926        let r = join(&f);
4927        assert_eq!(r[0].status, Status::Unresolved);
4928        assert_eq!(
4929            r[0].reason.as_ref().unwrap().kind,
4930            ReasonKind::GapNotReached
4931        );
4932    }
4933
4934    #[test]
4935    fn an_untraceable_operand_makes_it_a_limit_rather_than_a_gap() {
4936        let mut s = site("S1", "return", vec![boundary("return:handler")], &["T1"]);
4937        s.unmodelled_shapes = vec!["blogsTried(admin) [localfn:blogsTried]".into()];
4938        let f = facts(vec![s], vec![test("T1", vec![])]);
4939        let r = join(&f);
4940        assert_eq!(
4941            r[0].reason.as_ref().unwrap().kind,
4942            ReasonKind::LimitOperandShape
4943        );
4944        assert!(r[0].reason.as_ref().unwrap().detail.is_some());
4945    }
4946
4947    #[test]
4948    fn an_effect_with_no_boundary_is_an_internal_state_limit() {
4949        let f = facts(
4950            vec![site(
4951                "S1",
4952                "state-write",
4953                vec![boundary("internal")],
4954                &["T1"],
4955            )],
4956            vec![test(
4957                "T1",
4958                vec![observation("return:handler", Strength::Total)],
4959            )],
4960        );
4961        let r = join(&f);
4962        assert_eq!(
4963            r[0].reason.as_ref().unwrap().kind,
4964            ReasonKind::LimitInternalState
4965        );
4966    }
4967
4968    #[test]
4969    fn a_presence_only_observation_asks_for_a_stronger_matcher() {
4970        let f = facts(
4971            vec![site(
4972                "S1",
4973                "return",
4974                vec![boundary("return:handler")],
4975                &["T1"],
4976            )],
4977            vec![test(
4978                "T1",
4979                vec![observation("return:handler", Strength::Presence)],
4980            )],
4981        );
4982        let r = join(&f);
4983        assert_eq!(r[0].status, Status::Presence);
4984        assert_eq!(
4985            r[0].reason.as_ref().unwrap().kind,
4986            ReasonKind::GapValueNotAsserted
4987        );
4988    }
4989
4990    #[test]
4991    fn both_outcomes_asserted_makes_a_decision_evident_at_the_weaker_strength() {
4992        let mut decision = site("D1", "condition", vec![], &["T1", "T2"]);
4993        decision.kind = "decision".into();
4994        decision.decision = Some(DecisionFacts {
4995            then: Some(vec!["S1".into()]),
4996            else_: Some(Some(vec!["S2".into()])),
4997            outcomes: Some(Outcomes {
4998                true_: vec!["T1".into()],
4999                false_: vec!["T2".into()],
5000            }),
5001            ..Default::default()
5002        });
5003        let f = facts(
5004            vec![
5005                site("S1", "return", vec![boundary("return:handler")], &["T1"]),
5006                site("S2", "return", vec![boundary("return:other")], &["T2"]),
5007                decision,
5008            ],
5009            vec![
5010                test("T1", vec![observation("return:handler", Strength::Total)]),
5011                test("T2", vec![observation("return:other", Strength::Value)]),
5012            ],
5013        );
5014        let r = join(&f);
5015        let d = r.iter().find(|r| r.site == "D1").unwrap();
5016        assert_eq!(d.status, Status::Evident);
5017        assert_eq!(d.strength, Some(Strength::Value));
5018    }
5019
5020    #[test]
5021    fn an_outcome_no_test_takes_is_reported_as_that_gap() {
5022        let mut decision = site("D1", "condition", vec![], &["T1"]);
5023        decision.kind = "decision".into();
5024        decision.decision = Some(DecisionFacts {
5025            then: Some(vec!["S1".into()]),
5026            else_: Some(Some(vec!["S2".into()])),
5027            outcomes: Some(Outcomes {
5028                true_: vec!["T1".into()],
5029                false_: vec![],
5030            }),
5031            ..Default::default()
5032        });
5033        let f = facts(
5034            vec![
5035                site("S1", "return", vec![boundary("return:handler")], &["T1"]),
5036                site("S2", "return", vec![boundary("return:other")], &[]),
5037                decision,
5038            ],
5039            vec![test(
5040                "T1",
5041                vec![observation("return:handler", Strength::Total)],
5042            )],
5043        );
5044        let r = join(&f);
5045        let d = r.iter().find(|r| r.site == "D1").unwrap();
5046        assert_eq!(d.status, Status::Partial);
5047        let reason = d.reason.as_ref().unwrap();
5048        assert_eq!(reason.kind, ReasonKind::GapOutcomeNotAsserted);
5049        assert_eq!(
5050            reason.detail.as_deref(),
5051            Some("no test takes the false outcome")
5052        );
5053    }
5054
5055    #[test]
5056    fn a_total_assertion_covers_the_absence_of_an_empty_else() {
5057        let mut decision = site("D1", "condition", vec![], &["T1", "T2"]);
5058        decision.kind = "decision".into();
5059        decision.decision = Some(DecisionFacts {
5060            then: Some(vec!["S1".into()]),
5061            else_: Some(None),
5062            outcomes: Some(Outcomes {
5063                true_: vec!["T1".into()],
5064                false_: vec!["T2".into()],
5065            }),
5066            ..Default::default()
5067        });
5068        let f = facts(
5069            vec![
5070                site("S1", "io-call", vec![boundary("client-message")], &["T1"]),
5071                decision,
5072            ],
5073            vec![
5074                test("T1", vec![observation("client-message", Strength::Total)]),
5075                test("T2", vec![]),
5076            ],
5077        );
5078        let r = join(&f);
5079        let d = r.iter().find(|r| r.site == "D1").unwrap();
5080        assert_eq!(d.status, Status::Evident);
5081        assert_eq!(d.absence_needed, Some(true));
5082    }
5083
5084    #[test]
5085    fn an_empty_else_with_no_false_test_is_not_absence_covered() {
5086        let mut decision = site("D1", "condition", vec![], &["T1"]);
5087        decision.kind = "decision".into();
5088        decision.decision = Some(DecisionFacts {
5089            then: Some(vec!["S1".into()]),
5090            else_: Some(None),
5091            outcomes: Some(Outcomes {
5092                true_: vec!["T1".into()],
5093                false_: vec![],
5094            }),
5095            ..Default::default()
5096        });
5097        let f = facts(
5098            vec![
5099                site("S1", "io-call", vec![boundary("client-message")], &["T1"]),
5100                decision,
5101            ],
5102            vec![test(
5103                "T1",
5104                vec![observation("client-message", Strength::Total)],
5105            )],
5106        );
5107        let r = join(&f);
5108        let d = r.iter().find(|r| r.site == "D1").unwrap();
5109        assert_eq!(d.status, Status::Partial);
5110    }
5111
5112    #[test]
5113    fn a_negative_assertion_witnesses_an_early_exit() {
5114        let mut decision = site("D1", "condition", vec![], &["T1", "T2"]);
5115        decision.kind = "decision".into();
5116        decision.decision = Some(DecisionFacts {
5117            then: Some(vec!["S1".into()]),
5118            else_: Some(Some(vec!["S2".into()])),
5119            early_exit_downstream: Some(vec!["S2".into()]),
5120            outcomes: Some(Outcomes {
5121                true_: vec!["T1".into()],
5122                false_: vec!["T2".into()],
5123            }),
5124            ..Default::default()
5125        });
5126        // the exit's own branch asserts nothing; the witness is T1 pinning that S2 did not happen
5127        let mut negative = observation("client-message", Strength::Presence);
5128        negative.negative = true;
5129        let f = facts(
5130            vec![
5131                site("S1", "return", vec![boundary("internal")], &["T1"]),
5132                site("S2", "io-call", vec![boundary("client-message")], &["T2"]),
5133                decision,
5134            ],
5135            vec![
5136                test("T1", vec![negative]),
5137                test("T2", vec![observation("client-message", Strength::Total)]),
5138            ],
5139        );
5140        let r = join(&f);
5141        let d = r.iter().find(|r| r.site == "D1").unwrap();
5142        assert_eq!(d.stuck_false_caught, Some(true));
5143        assert_eq!(d.status, Status::Evident);
5144    }
5145
5146    #[test]
5147    fn a_call_list_assertion_witnesses_loop_control() {
5148        let mut decision = site("D1", "condition", vec![], &["T1", "T2"]);
5149        decision.kind = "decision".into();
5150        decision.decision = Some(DecisionFacts {
5151            then: Some(vec![]),
5152            else_: Some(Some(vec![])),
5153            loop_body: Some(vec!["S1".into()]),
5154            outcomes: Some(Outcomes {
5155                true_: vec!["T1".into()],
5156                false_: vec!["T2".into()],
5157            }),
5158            ..Default::default()
5159        });
5160        let mut call_list = observation("callback:admin", Strength::Value);
5161        call_list.call_list = true;
5162        let f = facts(
5163            vec![
5164                site(
5165                    "S1",
5166                    "external-call",
5167                    vec![boundary("callback:admin")],
5168                    &["T1", "T2"],
5169                ),
5170                decision,
5171            ],
5172            vec![
5173                test("T1", vec![call_list.clone()]),
5174                test("T2", vec![call_list]),
5175            ],
5176        );
5177        let r = join(&f);
5178        let d = r.iter().find(|r| r.site == "D1").unwrap();
5179        assert_eq!(d.status, Status::Evident);
5180    }
5181
5182    #[test]
5183    fn a_dense_channel_pattern_pins_only_the_sites_it_admits() {
5184        let mut log_a = site("S1", "log", vec![boundary("stdout")], &["T1"]);
5185        log_a.method = Some("log".into());
5186        let mut log_b = site("S2", "log", vec![boundary("stdout")], &["T1"]);
5187        log_b.method = Some("log".into());
5188        let mut ob = observation("stdout", Strength::Value);
5189        ob.log_sites = Some(vec!["S1".into()]);
5190        let f = facts(vec![log_a, log_b], vec![test("T1", vec![ob])]);
5191        let r = join(&f);
5192        assert_eq!(r[0].status, Status::Evident);
5193        assert_eq!(r[1].status, Status::Unresolved);
5194    }
5195
5196    #[test]
5197    fn a_pattern_several_log_sites_share_pins_none_of_them() {
5198        let mut log_a = site("S1", "log", vec![boundary("stdout")], &["T1"]);
5199        log_a.method = Some("log".into());
5200        let mut log_b = site("S2", "log", vec![boundary("stdout")], &["T1"]);
5201        log_b.method = Some("log".into());
5202        let mut ob = observation("stdout", Strength::Value);
5203        ob.log_sites = Some(vec!["S1".into(), "S2".into()]);
5204        ob.pattern_shared = true;
5205        let f = facts(vec![log_a, log_b], vec![test("T1", vec![ob])]);
5206        let r = join(&f);
5207        assert_eq!(r[0].status, Status::Unresolved);
5208        assert_eq!(r[1].status, Status::Unresolved);
5209    }
5210
5211    #[test]
5212    fn a_timer_cancellation_candidate_requires_total_callback_evidence() {
5213        for (callback_strength, expected) in [
5214            (Strength::Presence, Status::Unresolved),
5215            (Strength::Value, Status::Unresolved),
5216            (Strength::Total, Status::Evident),
5217        ] {
5218            let mut cancel = site("cancel", "schedule", vec![boundary("internal")], &["T1"]);
5219            cancel.derive = vec![Dependent {
5220                site: "timer".into(),
5221                label: "cancelled timer with total sink".into(),
5222                strength: Some(Strength::Value),
5223                requires_total: Some(vec!["callback".into()]),
5224            }];
5225            let f = facts(
5226                vec![
5227                    cancel,
5228                    site("timer", "schedule", vec![boundary("internal")], &["T1"]),
5229                    site(
5230                        "callback",
5231                        "return",
5232                        vec![boundary("return:callback")],
5233                        &["T1"],
5234                    ),
5235                ],
5236                vec![test(
5237                    "T1",
5238                    vec![observation("return:callback", callback_strength)],
5239                )],
5240            );
5241            let r = join(&f);
5242            assert_eq!(
5243                r.iter().find(|r| r.site == "cancel").unwrap().status,
5244                expected
5245            );
5246        }
5247    }
5248
5249    #[test]
5250    fn empty_or_unknown_timer_callback_candidates_do_not_supply_evidence() {
5251        for callbacks in [vec![], vec!["missing".into()]] {
5252            let mut cancel = site("cancel", "schedule", vec![boundary("internal")], &["T1"]);
5253            cancel.derive = vec![Dependent {
5254                site: "timer".into(),
5255                label: "cancelled timer with total sink".into(),
5256                strength: Some(Strength::Value),
5257                requires_total: Some(callbacks),
5258            }];
5259            let f = facts(
5260                vec![
5261                    cancel,
5262                    site("timer", "schedule", vec![boundary("internal")], &["T1"]),
5263                ],
5264                vec![test("T1", vec![])],
5265            );
5266            assert_eq!(join(&f)[0].status, Status::Unresolved);
5267        }
5268    }
5269
5270    #[test]
5271    fn an_internal_write_is_derived_through_its_dependent_capped_at_value() {
5272        let mut write = site("S1", "state-write", vec![boundary("internal")], &["T1"]);
5273        write.derive = vec![Dependent {
5274            site: "S2".into(),
5275            label: "read this.ready".into(),
5276            strength: None,
5277            requires_total: None,
5278        }];
5279        let f = facts(
5280            vec![
5281                write,
5282                site("S2", "return", vec![boundary("return:handler")], &["T1"]),
5283            ],
5284            vec![test(
5285                "T1",
5286                vec![observation("return:handler", Strength::Total)],
5287            )],
5288        );
5289        let r = join(&f);
5290        let derived = r.iter().find(|r| r.site == "S1").unwrap();
5291        assert_eq!(derived.status, Status::Evident);
5292        assert_eq!(derived.strength, Some(Strength::Value));
5293    }
5294
5295    #[test]
5296    fn a_dependent_no_shared_test_covers_derives_nothing() {
5297        let mut write = site("S1", "state-write", vec![boundary("internal")], &["T1"]);
5298        write.derive = vec![Dependent {
5299            site: "S2".into(),
5300            label: "read this.ready".into(),
5301            strength: None,
5302            requires_total: None,
5303        }];
5304        let f = facts(
5305            vec![
5306                write,
5307                site("S2", "return", vec![boundary("return:handler")], &["T2"]),
5308            ],
5309            vec![
5310                test("T1", vec![]),
5311                test("T2", vec![observation("return:handler", Strength::Total)]),
5312            ],
5313        );
5314        let r = join(&f);
5315        let derived = r.iter().find(|r| r.site == "S1").unwrap();
5316        assert_eq!(derived.status, Status::Unresolved);
5317    }
5318
5319    #[test]
5320    fn a_weak_render_observation_marks_the_resolution_weak() {
5321        let mut ob = observation("dom", Strength::Presence);
5322        ob.weak = true;
5323        let f = facts(
5324            vec![site("S1", "return", vec![boundary("dom")], &["T1"])],
5325            vec![test("T1", vec![ob])],
5326        );
5327        let r = join(&f);
5328        assert!(r[0].weak_only);
5329    }
5330
5331    #[test]
5332    fn an_explicit_null_else_survives_as_the_absence_case() {
5333        // A missing `else` means the decision has no branches; a null one means it has no else branch,
5334        // where a spurious effect is what a test would notice. The two must not collapse.
5335        let absence: DecisionFacts =
5336            serde_json::from_str(r#"{"then":["A"],"else":null}"#).expect("parses");
5337        assert_eq!(absence.else_, Some(None));
5338        let no_branches: DecisionFacts =
5339            serde_json::from_str(r#"{"carrier":"A"}"#).expect("parses");
5340        assert_eq!(no_branches.else_, None);
5341        let real_else: DecisionFacts =
5342            serde_json::from_str(r#"{"then":["A"],"else":["B"]}"#).expect("parses");
5343        assert_eq!(real_else.else_, Some(Some(vec!["B".to_owned()])));
5344    }
5345
5346    #[test]
5347    fn the_summary_counts_limits_apart_from_gaps() {
5348        let mut limited = site("S1", "state-write", vec![boundary("internal")], &["T1"]);
5349        limited.classification = "contractual".into();
5350        let gap = site("S2", "return", vec![boundary("return:handler")], &[]);
5351        let f = facts(vec![limited, gap], vec![test("T1", vec![])]);
5352        let r = join(&f);
5353        let s = summary(&f.sites, &r);
5354        assert_eq!(s.contractual, 2);
5355        assert_eq!(s.gaps, 1);
5356        assert_eq!(s.limits, 1);
5357    }
5358}