1use std::collections::{BTreeMap, BTreeSet};
15
16use serde::{Deserialize, Serialize};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
25#[serde(rename_all = "lowercase")]
26pub enum Strength {
27 Presence,
28 Value,
29 Total,
30}
31
32impl Strength {
33 fn caught(self) -> bool {
35 self >= Strength::Value
36 }
37}
38
39fn stronger(a: Option<Strength>, b: Option<Strength>) -> Option<Strength> {
40 match (a, b) {
41 (None, x) | (x, None) => x,
42 (Some(x), Some(y)) => Some(x.max(y)),
43 }
44}
45
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54#[serde(rename_all = "camelCase")]
55pub struct Boundary {
56 pub boundary: String,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub facet: Option<String>,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub via: Option<String>,
61}
62
63impl Boundary {
64 fn internal(&self) -> bool {
65 self.boundary == "internal"
66 }
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72#[serde(rename_all = "camelCase")]
73pub struct MockProjection {
74 pub target: String,
75 pub kind: String,
76 pub path: Vec<String>,
77 #[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#[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#[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#[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#[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#[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#[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#[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#[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 #[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 #[serde(default)]
309 pub negative: bool,
310 #[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 #[serde(default)]
321 pub weak: bool,
322 #[serde(default)]
325 pub log_sites: Option<Vec<String>>,
326 #[serde(default)]
329 pub pattern_shared: bool,
330}
331
332impl Observation {
333 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 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 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#[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 #[serde(default)]
411 pub rendered: Vec<String>,
412 #[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 TestSourceUnlinked,
425 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 #[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
471fn 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#[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 #[serde(default)]
490 pub carrier: Option<String>,
491 #[serde(default)]
493 pub value_flow: Option<Vec<String>>,
494 #[serde(default)]
496 pub then: Option<Vec<String>>,
497 #[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 #[serde(default)]
514 pub outcomes: Option<Outcomes>,
515 #[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 #[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 #[serde(default)]
572 pub direct_bounds: Vec<Boundary>,
573 #[serde(default)]
575 pub reached: Vec<String>,
576 pub covered_by: Vec<String>,
577 #[serde(default)]
578 pub object_valued_return: bool,
579 #[serde(default)]
581 pub unmodelled_shapes: Vec<String>,
582 #[serde(default)]
583 pub decision: Option<DecisionFacts>,
584 #[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 #[serde(default)]
597 pub mocks_by_test_file: BTreeMap<String, BTreeMap<String, Vec<Boundary>>>,
598}
599
600#[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 #[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 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 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
1115fn 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#[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 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 #[serde(skip_serializing_if = "Vec::is_empty")]
1305 pub observations: Vec<Observation>,
1306}
1307
1308pub 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 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; }
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; }
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 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; }
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 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 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#[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#[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 #[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 #[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
1858struct 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
1869pub 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 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 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 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 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 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 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 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 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 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 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 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 fn issue_reaches(
2196 &self,
2197 site: &Site,
2198 test: &TestFacts,
2199 ob: &Observation,
2200 seen: &mut BTreeSet<String>,
2201 ) -> bool {
2202 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 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 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 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 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 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 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 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 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 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 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
2815pub 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 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 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 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 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 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 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 "ed,
3995 &serde_json::json!({"kind":"string","value":"hello"}),
3996 false
3997 ),
3998 Some(false)
3999 );
4000 assert_eq!(
4001 payload_equal(
4002 "ed,
4003 &serde_json::json!({"kind":"string","value":"'hello'"}),
4004 false
4005 ),
4006 None
4007 );
4008 assert_eq!(payload_equal("ed, "ed, 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 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 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 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 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 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 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}