assay_core/validate/mod.rs
1use crate::config::path_resolver::PathResolver;
2use crate::errors::diagnostic::{codes, Diagnostic};
3use crate::model::EvalConfig;
4use crate::model::Expected;
5use crate::providers::llm::LlmClient; // Import trait for .complete()
6use crate::providers::trace::TraceClient;
7use std::path::{Path, PathBuf};
8
9#[derive(Debug, Clone)]
10pub struct ValidateOptions {
11 pub trace_file: Option<PathBuf>,
12 pub baseline_file: Option<PathBuf>,
13 pub replay_strict: bool,
14}
15
16#[derive(Debug, Clone, Default)]
17pub struct ValidateReport {
18 pub diagnostics: Vec<Diagnostic>,
19}
20
21pub async fn validate(
22 cfg: &EvalConfig,
23 opts: &ValidateOptions,
24 resolver: &PathResolver,
25) -> anyhow::Result<ValidateReport> {
26 let mut diags = Vec::new();
27
28 // 1. Path Resolution Checks (E_PATH_NOT_FOUND)
29 // Actually the CLI loader does this, but we can double check config assets if any.
30 // For now, let's assume config is loaded correctly if we are here,
31 // but check the explicitly provided trace/baseline files if they exist.
32
33 if let Some(path) = &opts.trace_file {
34 if !path.exists() {
35 diags.push(
36 Diagnostic::new(
37 codes::E_PATH_NOT_FOUND,
38 format!("Trace file not found: {}", path.display()),
39 )
40 .with_context(serde_json::json!({ "path": path }))
41 .with_source("validate")
42 .with_fix_step("Ensure the --trace-file path is correct and accessible"),
43 );
44 }
45 }
46
47 if let Some(path) = &opts.baseline_file {
48 if !path.exists() {
49 diags.push(
50 Diagnostic::new(
51 codes::E_PATH_NOT_FOUND,
52 format!("Baseline file not found: {}", path.display()),
53 )
54 .with_context(serde_json::json!({ "path": path }))
55 .with_source("validate")
56 .with_fix_step("Ensure the --baseline path is correct and accessible"),
57 );
58 }
59 }
60
61 // Missing path assets stop the deeper checks to avoid noise. The vacuous scan
62 // still runs once because it needs neither trace nor baseline.
63 let paths_missing = !diags.is_empty();
64 diags.extend(check_vacuous_expected(cfg));
65 if paths_missing {
66 return Ok(ValidateReport { diagnostics: diags });
67 }
68
69 // 2. Load Trace & Baseline for deeper checks
70 let trace_client = if let Some(path) = &opts.trace_file {
71 match TraceClient::from_path(path) {
72 Ok(client) => Some(client),
73 Err(e) => {
74 diags.push(
75 Diagnostic::new(
76 codes::E_TRACE_INVALID,
77 format!("Failed to parse trace file: {}", e),
78 )
79 .with_source("trace")
80 .with_context(serde_json::json!({ "path": path, "error": e.to_string() })),
81 );
82 return Ok(ValidateReport { diagnostics: diags });
83 }
84 }
85 } else {
86 None
87 };
88
89 let baseline = if let Some(path) = &opts.baseline_file {
90 match crate::baseline::Baseline::load(path) {
91 Ok(b) => Some(b),
92 Err(e) => {
93 diags.push(
94 Diagnostic::new(
95 codes::E_BASE_MISMATCH,
96 format!("Failed to parse baseline: {}", e),
97 )
98 .with_source("baseline")
99 .with_context(serde_json::json!({ "path": path, "error": e.to_string() })),
100 );
101 return Ok(ValidateReport { diagnostics: diags });
102 }
103 }
104 } else {
105 None
106 };
107
108 // 3. Trace Coverage (E_TRACE_MISS)
109 if let Some(client) = &trace_client {
110 for tc in &cfg.tests {
111 // We use the same lookup logic as TraceClient::complete
112 // But here we want to collect ALL misses, not just fail on first.
113 // Since `complete` is not exposed as "check only", we iterate.
114 // Actually TraceClient doesn't expose keys publicly yet.
115 // We might need to call complete and catch error?
116 // OR better: call complete() on client. Since it returns LlmResponse or Err(Diagnostic)
117
118 let res = client
119 .complete(&tc.input.prompt, tc.input.context.as_deref())
120 .await;
121 if let Err(e) = res {
122 // If it's a diagnostic, push it.
123 // We use try_map_error from errors module
124 if let Some(diag) = crate::errors::try_map_error(&e) {
125 // Enrich with test_id
126 let mut d = diag.clone();
127 if let serde_json::Value::Object(ref mut map) = d.context {
128 map.insert("test_id".into(), serde_json::json!(tc.id));
129 map.insert("trace_file".into(), serde_json::json!(opts.trace_file));
130 }
131 d.source = "trace".to_string();
132 diags.push(d);
133 } else {
134 // Unexpected error?
135 diags.push(
136 Diagnostic::new("E_UNKNOWN", format!("Unexpected trace error: {}", e))
137 .with_source("trace"),
138 );
139 }
140 } else if let Ok(resp) = res {
141 // Check Strict Replay (Requirement 4)
142 if opts.replay_strict {
143 validate_strict_requirements(tc, &resp, &mut diags, opts.trace_file.as_deref());
144 }
145
146 // Check Embedding Dims (Requirement 5)
147 // This is checking per-test, potentially spammy.
148 // Better to check once per trace? But we don't have access to all embeddings.
149 // We'll check via response meta if available.
150 check_embedding_dims(&resp, &mut diags, opts.trace_file.as_deref());
151
152 // Check Policy (Requirement 2: ArgsValid)
153 if let Expected::ArgsValid {
154 policy: Some(policy_path),
155 ..
156 } = &tc.expected
157 {
158 // 1. Load Policy
159 // For now, load fully. In future, cache via resolver.
160 // We need to resolve relative to config?
161 // resolver.resolve_path(policy_path)?
162 let mut p_str = policy_path.clone();
163 resolver.resolve_str(&mut p_str);
164 let policy_file = std::path::PathBuf::from(p_str);
165 if !policy_file.exists() {
166 diags.push(
167 Diagnostic::new(
168 codes::E_PATH_NOT_FOUND,
169 format!("Policy file not found: {}", policy_file.display()),
170 )
171 .with_source("validate")
172 .with_context(serde_json::json!({ "path": policy_file })),
173 );
174 } else {
175 match crate::model::Policy::load(&policy_file) {
176 Ok(pol) => {
177 // 2. Get Tool Calls from Trace
178 let tool_calls =
179 resp.meta.get("tool_calls").and_then(|v| v.as_array());
180
181 if let Some(calls) = tool_calls {
182 // Convert to policy value for engine
183 let policy_val = serde_json::to_value(
184 pol.tools.arg_constraints.unwrap_or_default(),
185 )
186 .unwrap_or(serde_json::Value::Null);
187
188 // Check for Allowed/Denied lists first?
189 // Let's use simple policy_engine:evaluate_tool_args which expects JSON schema map.
190 // Wait, Policy struct has complex structure.
191 // policy.tools.arg_constraints is Map<Tool, Schema>.
192 // policy.tools.allow/deny are lists.
193
194 // Simplified validation for v1.2.1: Just check args against schema if present.
195 // TODO(validate-v13): full policy context for arg enforcement
196
197 for call in calls {
198 let tool_name = call
199 .get("tool_name")
200 .and_then(|s| s.as_str())
201 .unwrap_or("unknown");
202 let args =
203 call.get("args").unwrap_or(&serde_json::Value::Null);
204
205 // Need to construct the "policy" value expected by evaluate_tool_args
206 // It expects { "ToolName": Schema, ... }
207 // This is exactly `arg_constraints`.
208
209 let verdict = crate::policy_engine::evaluate_tool_args(
210 &policy_val,
211 tool_name,
212 args,
213 );
214
215 if let crate::policy_engine::VerdictStatus::Blocked =
216 verdict.status
217 {
218 let mut d = Diagnostic::new(
219 verdict.reason_code,
220 "Policy violation in tool call",
221 )
222 .with_source("policy")
223 .with_context(verdict.details);
224
225 // Add trace context
226 if let serde_json::Value::Object(ref mut map) =
227 d.context
228 {
229 map.insert("tool".into(), tool_name.into());
230 map.insert("test_id".into(), tc.id.clone().into());
231 }
232 diags.push(d);
233 }
234 }
235 } else {
236 // No tool calls found in trace?
237 // If policy expects validation, maybe warn?
238 }
239 }
240 Err(e) => {
241 diags.push(
242 Diagnostic::new(
243 codes::E_CFG_PARSE,
244 format!("Failed to parse policy: {}", e),
245 )
246 .with_source("policy"),
247 );
248 }
249 }
250 }
251 }
252 }
253 }
254 }
255
256 // Baseline Compat (Requirement 3)
257 if let Some(base) = &baseline {
258 if base.suite != cfg.suite {
259 diags.push(
260 Diagnostic::new(codes::E_BASE_MISMATCH, "Baseline suite mismatch")
261 .with_source("baseline")
262 .with_context(serde_json::json!({
263 "expected_suite": cfg.suite,
264 "baseline_suite": base.suite,
265 "baseline_file": opts.baseline_file
266 }))
267 .with_fix_step("Use the baseline file created for this suite")
268 .with_fix_step("Or export a new baseline: assay ci ... --export-baseline ..."),
269 );
270 }
271 }
272
273 // Deduplicate diagnostics?
274 // E_EMB_DIMS might be spammy if every test fails.
275 // Simple dedup by code + message signature could be added later.
276
277 Ok(ValidateReport { diagnostics: diags })
278}
279
280/// Warn about tests that assert nothing and therefore always pass.
281///
282/// By the time a config has loaded, a vacuous value normally came from an omitted or
283/// null `expected:` key resolving to `Expected::default()`. An explicit tagged
284/// assertion that has no effective constraint is rejected at parse time (see
285/// `model::serde::reject_vacuous`), which is a hard error for every command that
286/// loads a config, including `assay run` and `assay ci`.
287///
288/// That split is deliberate. Omitting `expected:` is a documented, legitimate shape —
289/// a test may carry its checks in `assertions:` — so making it an error here would
290/// contradict the permissive parse and break configs the tool itself writes. It is
291/// still worth reporting when such a test has no assertions either, because then it
292/// really does assert nothing; hence a warning rather than an error.
293///
294/// Tests that carry `assertions:` are not exempt — they are swept too.
295///
296/// The exemption used to test for a **non-empty** `assertions:` list rather than an **effective**
297/// one, and nothing looked at the assertions afterwards. One assertion that could not fail
298/// therefore cleared both gates in a single move: the `expected:` check was skipped because
299/// assertions existed, and the assertions were never examined. Reporting a suite as swept while
300/// stepping over the case the sweep exists to find is the failure this function is meant to
301/// prevent, one layer up.
302///
303/// Effectiveness is decided by `agent_assertions::matchers::ineffective_reason`, which is the same
304/// code the evaluator runs. Nothing here re-states what "cannot fail" means.
305///
306/// This check reads only the config, so `assay validate` can sweep a suite for
307/// always-green tests without running it.
308/// Every assertion in the config that cannot fail, located in the suite.
309///
310/// Two callers need this answer and they must not each derive it: `assay validate` reports it as a
311/// warning, and `load_config` refuses the config outright when the caller opted into
312/// `allow_ineffective_assertions`. Two implementations of one rule drift; the fix is one
313/// implementation with two callers, which is also why the "cannot fail" decision itself stays in
314/// `agent_assertions::matchers::ineffective_reason` — the evaluator's own code — rather than being
315/// restated here.
316///
317/// The diagnostics carry the evaluator's context, which names the variant and the responsible
318/// field, plus `test_id` and `assertion_index`, which only a sweep over the config can supply.
319pub fn ineffective_assertions(cfg: &EvalConfig) -> Vec<Diagnostic> {
320 let mut diags = Vec::new();
321 for tc in &cfg.tests {
322 for (index, assertion) in tc
323 .assertions
324 .as_deref()
325 .unwrap_or_default()
326 .iter()
327 .enumerate()
328 {
329 let Some(mut reason) = crate::agent_assertions::matchers::ineffective_reason(assertion)
330 else {
331 continue;
332 };
333 if let Some(obj) = reason.context.as_object_mut() {
334 obj.insert("test_id".into(), serde_json::json!(tc.id));
335 obj.insert("assertion_index".into(), serde_json::json!(index));
336 }
337 diags.push(
338 reason.with_fix_step(
339 "Or remove the assertion, so the test does not appear to check it",
340 ),
341 );
342 }
343 }
344 diags
345}
346
347fn check_vacuous_expected(cfg: &EvalConfig) -> Vec<Diagnostic> {
348 // An assertion that cannot fail is reported here rather than only when a run reaches it,
349 // so a suite can be swept for always-green tests without executing anything.
350 let mut diags = ineffective_assertions(cfg);
351
352 for tc in &cfg.tests {
353 let has_assertions = !tc.assertions.as_deref().unwrap_or_default().is_empty();
354
355 if has_assertions {
356 continue;
357 }
358
359 let Some(field) = crate::model::vacuous_expected_field(&tc.expected) else {
360 continue;
361 };
362
363 diags.push(
364 Diagnostic::new(
365 codes::W_CFG_VACUOUS_EXPECTED,
366 format!(
367 "Test '{}' asserts nothing: `{}` is empty and there are no `assertions:`, so it passes for any response",
368 tc.id, field
369 ),
370 )
371 .with_severity("warn")
372 .with_source("config")
373 .with_context(serde_json::json!({
374 "test_id": tc.id,
375 "field": field,
376 }))
377 .with_fix_step("Add an `expected:` block that checks something")
378 .with_fix_step("Or give the test `assertions:`"),
379 );
380 }
381
382 diags
383}
384
385fn validate_strict_requirements(
386 tc: &crate::model::TestCase,
387 resp: &crate::model::LlmResponse,
388 diags: &mut Vec<Diagnostic>,
389 trace_path: Option<&Path>,
390) {
391 let mut missing = Vec::new();
392
393 // Check Semantic Metrics -> Need Embeddings
394 if let Expected::SemanticSimilarityTo { .. } = &tc.expected {
395 if resp.meta.pointer("/assay/embeddings/response").is_none() {
396 missing.push(serde_json::json!({
397 "requirement": "embeddings",
398 "needed_by": ["semantic_similarity_to"],
399 "meta_path": "meta.assay.embeddings"
400 }));
401 }
402 }
403
404 // Check Judge -> Need Judge Results
405 // Only if expected is Faithfulness or Relevance
406 #[expect(
407 clippy::wildcard_enum_match_arm,
408 reason = "only judge variants require judge meta; a new one would report no missing requirement"
409 )]
410 match &tc.expected {
411 Expected::Faithfulness { .. }
412 if resp.meta.pointer("/assay/judge/faithfulness").is_none() =>
413 {
414 missing.push(serde_json::json!({
415 "requirement": "judge_faithfulness",
416 "needed_by": ["faithfulness"],
417 "meta_path": "meta.assay.judge.faithfulness"
418 }));
419 }
420 Expected::Relevance { .. } if resp.meta.pointer("/assay/judge/relevance").is_none() => {
421 missing.push(serde_json::json!({
422 "requirement": "judge_relevance",
423 "needed_by": ["relevance"],
424 "meta_path": "meta.assay.judge.relevance"
425 }));
426 }
427 _ => {}
428 }
429
430 if !missing.is_empty() {
431 diags.push(
432 Diagnostic::new(
433 codes::E_REPLAY_STRICT_MISSING,
434 "Strict replay requires precomputed data that is missing from trace",
435 )
436 .with_source("replay")
437 .with_context(serde_json::json!({
438 "replay_strict": true,
439 "trace_file": trace_path,
440 "missing": missing,
441 "test_id": tc.id
442 }))
443 .with_fix_step("Run `assay trace precompute-embeddings ...`")
444 .with_fix_step("Run `assay trace precompute-judge ...`"),
445 );
446 }
447}
448
449fn check_embedding_dims(
450 resp: &crate::model::LlmResponse,
451 diags: &mut Vec<Diagnostic>,
452 trace_path: Option<&Path>,
453) {
454 // Basic heuristic: if we have embeddings, check simple consistency?
455 // Or if we know expected model?
456 // For now, looking for obvious bad data (empty vectors)
457 // Or strict mismatch if we ever passed an embedder config (not available here yet).
458
459 if let Some(embeddings) = resp
460 .meta
461 .pointer("/assay/embeddings")
462 .and_then(|v| v.as_object())
463 {
464 if let Some(response_vec) = embeddings.get("response").and_then(|v| v.as_array()) {
465 if response_vec.is_empty() {
466 diags.push(
467 Diagnostic::new(codes::E_EMB_DIMS, "Empty embedding vector found in trace")
468 .with_source("trace")
469 .with_context(serde_json::json!({ "trace_file": trace_path }))
470 .with_fix_step("Regenerate embeddings with precompute-embeddings"),
471 );
472 }
473 }
474 }
475}
476#[cfg(test)]
477mod vacuous_expected_tests {
478 use super::*;
479 use crate::agent_assertions::model::TraceAssertion;
480 use crate::model::{Settings, TestCase, TestInput};
481
482 fn cfg_with(expected: Expected, assertions: Option<Vec<TraceAssertion>>) -> EvalConfig {
483 EvalConfig {
484 version: 1,
485 suite: "s".into(),
486 model: "dummy".into(),
487 settings: Settings::default(),
488 thresholds: Default::default(),
489 otel: Default::default(),
490 tests: vec![TestCase {
491 id: "t1".into(),
492 input: TestInput {
493 prompt: "hi".into(),
494 context: None,
495 },
496 expected,
497 assertions,
498 on_error: None,
499 tags: vec![],
500 metadata: None,
501 }],
502 }
503 }
504
505 #[test]
506 fn flags_empty_must_contain() {
507 let cfg = cfg_with(
508 Expected::MustContain {
509 must_contain: vec![],
510 },
511 None,
512 );
513 let diags = check_vacuous_expected(&cfg);
514 assert_eq!(diags.len(), 1);
515 assert_eq!(diags[0].code, codes::W_CFG_VACUOUS_EXPECTED);
516 // Warning, not error: omitted or null `expected:` values resolve to the
517 // default, while an explicitly tagged empty assertion never gets this far.
518 assert_eq!(diags[0].severity, "warn");
519 assert!(diags[0].message.contains("t1"), "{}", diags[0].message);
520 assert!(
521 diags[0].message.contains("`must_contain` is empty"),
522 "{}",
523 diags[0].message
524 );
525 assert!(!diags[0].message.contains("no `expected:` block"));
526 }
527
528 #[test]
529 fn flags_empty_must_not_contain() {
530 let cfg = cfg_with(
531 Expected::MustNotContain {
532 must_not_contain: vec![],
533 },
534 None,
535 );
536 let diags = check_vacuous_expected(&cfg);
537 assert_eq!(diags.len(), 1);
538 assert_eq!(diags[0].context["field"], "must_not_contain");
539 }
540
541 /// A missing `expected:` key resolves to the vacuous default, so the same rule
542 /// covers it — this is what keeps the permissive parse honest.
543 #[test]
544 fn flags_default_expected_from_missing_key() {
545 let cfg = cfg_with(Expected::default(), None);
546 assert_eq!(check_vacuous_expected(&cfg).len(), 1);
547 }
548
549 #[test]
550 fn does_not_flag_populated_must_contain() {
551 let cfg = cfg_with(
552 Expected::MustContain {
553 must_contain: vec!["Paris".into()],
554 },
555 None,
556 );
557 assert!(check_vacuous_expected(&cfg).is_empty());
558 }
559
560 /// Assertion-carrying tests legitimately omit `expected:`.
561 #[test]
562 fn does_not_flag_when_assertions_present() {
563 let cfg = cfg_with(
564 Expected::default(),
565 Some(vec![TraceAssertion::TraceMustCallTool {
566 tool: "search".into(),
567 min_calls: None,
568 }]),
569 );
570 assert!(check_vacuous_expected(&cfg).is_empty());
571 }
572
573 /// The case the exemption used to step over: a test carrying one assertion that cannot fail.
574 ///
575 /// Before this check, the non-empty `assertions:` list suppressed the `expected:` warning and
576 /// nothing looked at the assertion, so the suite swept clean while asserting nothing.
577 #[test]
578 fn flags_an_assertion_that_cannot_fail() {
579 let cfg = cfg_with(
580 Expected::default(),
581 Some(vec![TraceAssertion::TraceMustCallTool {
582 tool: "search".into(),
583 min_calls: Some(0),
584 }]),
585 );
586 let diags = check_vacuous_expected(&cfg);
587 assert_eq!(diags.len(), 1, "{diags:?}");
588 assert_eq!(diags[0].code, "E_ASSERT_INEFFECTIVE");
589 assert_eq!(diags[0].severity, "error");
590 assert_eq!(diags[0].context["field"], "min_calls");
591 assert_eq!(diags[0].context["test_id"], "t1");
592 assert_eq!(diags[0].context["assertion_index"], 0);
593 }
594
595 /// One per variant. A sweep that reached only the variants convenient to write would be the
596 /// same partial-coverage problem it exists to report.
597 #[test]
598 fn flags_a_vacuous_shape_of_every_variant() {
599 let cases: Vec<(&str, TraceAssertion)> = vec![
600 (
601 "tool",
602 TraceAssertion::TraceMustCallTool {
603 tool: String::new(),
604 min_calls: None,
605 },
606 ),
607 (
608 "tool",
609 TraceAssertion::TraceMustNotCallTool {
610 tool: String::new(),
611 },
612 ),
613 (
614 "sequence",
615 TraceAssertion::TraceToolSequence {
616 sequence: vec![],
617 allow_other_tools: true,
618 },
619 ),
620 ("max", TraceAssertion::TraceMaxSteps { max: u32::MAX }),
621 (
622 "test_args",
623 TraceAssertion::ArgsValid {
624 tool: "t".into(),
625 test_args: None,
626 policy: None,
627 expect: None,
628 },
629 ),
630 (
631 "test_trace_raw",
632 TraceAssertion::SequenceValid {
633 test_trace: None,
634 test_trace_raw: None,
635 policy: None,
636 expect: None,
637 },
638 ),
639 (
640 "test_tool_calls",
641 TraceAssertion::ToolBlocklist {
642 test_tool_calls: None,
643 policy: None,
644 expect: None,
645 },
646 ),
647 ];
648
649 for (field, assertion) in cases {
650 let cfg = cfg_with(Expected::default(), Some(vec![assertion.clone()]));
651 let diags = check_vacuous_expected(&cfg);
652 assert_eq!(diags.len(), 1, "{assertion:?} produced {diags:?}");
653 assert_eq!(
654 diags[0].context["field"], field,
655 "{assertion:?} blamed the wrong field: {}",
656 diags[0].message
657 );
658 }
659 }
660
661 /// An unrecognized `expect` spelling silently selected *expect failure* and inverted the
662 /// assertion. That is worse than a no-op — a no-op stops checking, an inversion checks the
663 /// opposite — so the static sweep has to reach it too, not only the evaluator.
664 #[test]
665 fn flags_an_unrecognized_expect_spelling() {
666 let cfg = cfg_with(
667 Expected::default(),
668 Some(vec![TraceAssertion::ArgsValid {
669 tool: "t".into(),
670 test_args: Some(serde_json::json!({})),
671 policy: Some(serde_json::json!({ "schema": {} })),
672 expect: Some("Pass".into()),
673 }]),
674 );
675 let diags = check_vacuous_expected(&cfg);
676 assert_eq!(diags.len(), 1, "{diags:?}");
677 assert_eq!(diags[0].code, "E_CONFIG_ERROR");
678 assert!(diags[0].message.contains("expect"), "{}", diags[0].message);
679 }
680
681 /// The invariant the static sweep rests on: it must reject configurations that cannot check
682 /// anything, and **only** those. An assertion that constrains something and would simply not
683 /// hold for a given trace is not a config defect, and reporting it here would make
684 /// `assay validate` refuse working suites — the over-eager detection that earns a suppression
685 /// and takes the real findings down with it.
686 ///
687 /// Each case below fails when evaluated against an empty episode, which is exactly the input
688 /// the sweep uses internally. If a future check answered one of these from the configuration,
689 /// this test fails rather than the sweep quietly growing false positives.
690 #[test]
691 fn does_not_flag_an_assertion_that_merely_fails_for_a_trace() {
692 for assertion in [
693 // Requires three calls; an empty episode has none.
694 TraceAssertion::TraceMustCallTool {
695 tool: "search".into(),
696 min_calls: Some(3),
697 },
698 // Requires this order; an empty episode has no calls at all.
699 TraceAssertion::TraceToolSequence {
700 sequence: vec!["a".into(), "b".into()],
701 allow_other_tools: true,
702 },
703 // Exact-sequence form, likewise unsatisfied by an empty episode.
704 TraceAssertion::TraceToolSequence {
705 sequence: vec!["a".into()],
706 allow_other_tools: false,
707 },
708 // A well-formed policy the supplied arguments violate: a real failure, not a
709 // configuration that checks nothing.
710 TraceAssertion::ArgsValid {
711 tool: "t".into(),
712 test_args: Some(serde_json::json!({ "percent": 90 })),
713 policy: Some(serde_json::json!({
714 "schema": { "properties": { "percent": { "type": "number", "maximum": 30 } } }
715 })),
716 expect: Some("pass".into()),
717 },
718 // A blocked call that is actually made, expected to pass: fails, and should.
719 TraceAssertion::ToolBlocklist {
720 test_tool_calls: Some(vec!["rm".into()]),
721 policy: Some(serde_json::json!({ "blocked": ["rm"] })),
722 expect: Some("pass".into()),
723 },
724 ] {
725 let cfg = cfg_with(Expected::default(), Some(vec![assertion.clone()]));
726 let diags = check_vacuous_expected(&cfg);
727 assert!(
728 diags.is_empty(),
729 "the static sweep rejected a configuration that merely fails for a trace: \
730 {assertion:?} -> {diags:?}"
731 );
732 }
733 }
734
735 /// The sweep reaches the schema compiler from a caller that did not exist before, so a
736 /// schema that cannot compile now has a new way to be encountered. A panic here would take
737 /// down `assay validate` on the one input it most needs to survive: a config someone is
738 /// trying to fix.
739 ///
740 /// It returns, and it says nothing. That was worth measuring rather than assuming, because
741 /// an earlier version of the doc comment on `ineffective_reason` claimed the opposite — that
742 /// the schema compilation the sweep performs would surface a broken schema early. It does
743 /// not: the resulting diagnostic carries an evaluation-decided code, which the filter drops.
744 /// Both are pinned here so the boundary is stated rather than rediscovered.
745 #[test]
746 fn a_schema_that_cannot_compile_neither_panics_nor_is_swept() {
747 for (case, policy) in [
748 (
749 "type is not a schema keyword value",
750 serde_json::json!({ "schema": { "type": 42 } }),
751 ),
752 (
753 "properties is not an object",
754 serde_json::json!({ "schema": { "properties": "not-an-object" } }),
755 ),
756 (
757 "required is not an array",
758 serde_json::json!({ "schema": { "required": 7 } }),
759 ),
760 (
761 "external ref, which the hermetic compiler refuses",
762 serde_json::json!({ "schema": { "$ref": "https://example.invalid/s.json" } }),
763 ),
764 ] {
765 let cfg = cfg_with(
766 Expected::default(),
767 Some(vec![TraceAssertion::ArgsValid {
768 tool: "t".into(),
769 test_args: Some(serde_json::json!({ "a": 1 })),
770 policy: Some(policy),
771 expect: Some("pass".into()),
772 }]),
773 );
774 // The assertion is that this returns rather than unwinding.
775 let diags = check_vacuous_expected(&cfg);
776 assert!(
777 diags.is_empty(),
778 "{case}: the sweep reported {diags:?}. An uncompilable schema is a broken \
779 assertion, not one that cannot fail, and widening the sweep to catch it would \
780 cost the narrowness `does_not_flag_an_assertion_that_merely_fails_for_a_trace` \
781 protects. If this is now wanted, it belongs in a schema-validity check with its \
782 own diagnostic, not in the vacuity filter."
783 );
784 }
785 }
786
787 /// The sweep must work with no trace file and no baseline — that is the point of
788 /// being able to check a suite without running it.
789 #[tokio::test]
790 async fn validate_reports_vacuous_without_trace_file() {
791 let cfg = cfg_with(
792 Expected::MustContain {
793 must_contain: vec![],
794 },
795 None,
796 );
797 let opts = ValidateOptions {
798 trace_file: None,
799 baseline_file: None,
800 replay_strict: false,
801 };
802 let resolver = PathResolver::new(Path::new("eval.yaml"));
803
804 let report = validate(&cfg, &opts, &resolver).await.expect("validate");
805 assert_eq!(report.diagnostics.len(), 1);
806 assert_eq!(report.diagnostics[0].code, codes::W_CFG_VACUOUS_EXPECTED);
807 }
808}