1use open_kioku_actions::{ActionKind, PolicyGate};
2use open_kioku_config::OkConfig;
3use open_kioku_context::ContextPackBuilder;
4use open_kioku_core::{
5 AnalysisFact, EvidenceSourceType, PatchId, PatchPlan, PlanReport, SearchResult, TestTarget,
6};
7use open_kioku_errors::{OkError, Result};
8use open_kioku_impact::ImpactEngine;
9use open_kioku_storage::{MetadataStore, OkStore, SearchIndex};
10use open_kioku_tests::TestSelector;
11use serde::{Deserialize, Serialize};
12use sha2::{Digest, Sha256};
13use std::collections::BTreeSet;
14use std::path::{Path, PathBuf};
15use std::process::Command;
16
17pub struct PatchPlanner<'a> {
18 config: &'a OkConfig,
19 store: &'a dyn OkStore,
20}
21
22impl<'a> PatchPlanner<'a> {
23 pub fn new(config: &'a OkConfig, store: &'a dyn OkStore) -> Self {
24 Self { config, store }
25 }
26
27 pub fn plan(&self, task: &str) -> Result<PatchPlan> {
28 let context = ContextPackBuilder::new(self.store).build(task, 12)?;
29 Ok(PatchPlan {
30 id: PatchId::new(stable_id(task)),
31 task: task.into(),
32 allowed_files: context.recommended_change_boundary.allowed_files,
33 caution_files: context.recommended_change_boundary.caution_files,
34 forbidden_files: context.recommended_change_boundary.forbidden_files,
35 change_steps: vec![
36 "Inspect primary symbols and definitions from the context pack".into(),
37 "Constrain edits to allowed files unless evidence justifies expansion".into(),
38 "Run the recommended validation plan after approval".into(),
39 ],
40 risks: context.risk_report.reasons,
41 assumptions: vec![
42 "Generated and vendor files remain out of scope".into(),
43 "Patch application requires explicit write mode and approval".into(),
44 ],
45 tests: context.test_candidates,
46 rollback_notes: vec!["Revert the unified diff if validation fails".into()],
47 unified_diff: None,
48 requires_approval: self.config.security.approval_required,
49 evidence: context.evidence,
50 })
51 }
52
53 pub fn apply(&self, _patch: &PatchPlan, approved: bool) -> Result<()> {
54 PolicyGate::new(self.config).ensure_allowed(ActionKind::ApplyPatch)?;
55 if self.config.security.approval_required && !approved {
56 return Err(OkError::PolicyDenied(
57 "patch application requires explicit approval".into(),
58 ));
59 }
60 Err(OkError::Unsupported(
61 "patch application is intentionally not implemented without a diff applicator".into(),
62 ))
63 }
64}
65
66pub struct ChangeVerifier<'a> {
67 store: &'a dyn OkStore,
68 search_index: Option<&'a dyn SearchIndex>,
69}
70
71#[derive(Debug, Clone, Default, Serialize, Deserialize)]
72pub struct VerifyChangeInput {
73 #[serde(default)]
74 pub changed_files: Vec<PathBuf>,
75 #[serde(default)]
76 pub unified_diff: Option<String>,
77 #[serde(default)]
78 pub evidence_refs: Vec<String>,
79 #[serde(default)]
80 pub run_commands: bool,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct ChangeVerificationReport {
85 pub verdict: VerificationVerdict,
86 pub changed_files: Vec<PathBuf>,
87 pub changed_symbols: Vec<String>,
88 pub boundary_violations: Vec<VerificationFinding>,
89 pub warnings: Vec<VerificationFinding>,
90 pub missing_tests: Vec<VerificationFinding>,
91 pub changed_impact: Vec<VerificationFinding>,
92 pub recommended_tests: Vec<TestTarget>,
93 pub command_results: Vec<ValidationCommandResult>,
94 pub evidence_refs: Vec<String>,
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
98#[serde(rename_all = "snake_case")]
99pub enum VerificationVerdict {
100 Pass,
101 Warn,
102 Fail,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct VerificationFinding {
107 pub path: Option<PathBuf>,
108 pub kind: String,
109 pub reason: String,
110 #[serde(default)]
111 pub evidence_refs: Vec<String>,
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct ValidationCommandResult {
116 pub command: String,
117 pub status: String,
118 pub exit_code: Option<i32>,
119 pub stdout: String,
120 pub stderr: String,
121}
122
123impl<'a> ChangeVerifier<'a> {
124 pub fn new(store: &'a dyn OkStore) -> Self {
125 Self {
126 store,
127 search_index: None,
128 }
129 }
130
131 pub fn with_search_index(mut self, search_index: Option<&'a dyn SearchIndex>) -> Self {
132 self.search_index = search_index;
133 self
134 }
135
136 pub fn verify(
137 &self,
138 repo: &Path,
139 plan: &PlanReport,
140 input: VerifyChangeInput,
141 ) -> Result<ChangeVerificationReport> {
142 let changed_files = changed_files_from_input(&input);
143 if changed_files.is_empty() {
144 return Err(OkError::Config(
145 "verify requires at least one changed file or a non-empty unified diff".into(),
146 ));
147 }
148
149 let boundary_violations = boundary_violations(plan, &changed_files, &input.evidence_refs);
150 let changed_symbols = changed_symbols(self.store, &changed_files)?;
151 let recommended_tests = recommended_tests(self.store, &changed_files)?;
152 let missing_tests = missing_tests(plan, &recommended_tests);
153 let changed_impact = changed_impact(self.store, self.search_index, plan, &changed_files)?;
154 let command_results = if input.run_commands {
155 run_validation_commands(repo, plan)
156 } else {
157 Vec::new()
158 };
159 let command_failures = command_results
160 .iter()
161 .filter(|result| result.status == "fail")
162 .map(|result| VerificationFinding {
163 path: None,
164 kind: "command_failed".into(),
165 reason: format!(
166 "validation command `{}` exited with {:?}",
167 result.command, result.exit_code
168 ),
169 evidence_refs: Vec::new(),
170 })
171 .collect::<Vec<_>>();
172
173 let mut warnings = Vec::new();
174 warnings.extend(caution_warnings(plan, &changed_files));
175 warnings.extend(expansion_warnings(
176 plan,
177 &changed_files,
178 &input.evidence_refs,
179 ));
180 warnings.extend(runtime_warnings(self.store, &changed_files)?);
181
182 let verdict = if !boundary_violations.is_empty() || !command_failures.is_empty() {
183 VerificationVerdict::Fail
184 } else if !warnings.is_empty() || !missing_tests.is_empty() || !changed_impact.is_empty() {
185 VerificationVerdict::Warn
186 } else {
187 VerificationVerdict::Pass
188 };
189
190 let mut all_boundary_violations = boundary_violations;
191 all_boundary_violations.extend(command_failures);
192
193 Ok(ChangeVerificationReport {
194 verdict,
195 changed_files,
196 changed_symbols,
197 boundary_violations: all_boundary_violations,
198 warnings,
199 missing_tests,
200 changed_impact,
201 recommended_tests,
202 command_results,
203 evidence_refs: input.evidence_refs,
204 })
205 }
206}
207
208pub fn changed_files_from_unified_diff(diff: &str) -> Vec<PathBuf> {
209 let mut paths = BTreeSet::new();
210 let mut pending_old: Option<String> = None;
211 for line in diff.lines() {
212 if let Some(rest) = line.strip_prefix("diff --git ") {
213 let parts = rest.split_whitespace().collect::<Vec<_>>();
214 if let Some(path) = parts.get(1).and_then(|part| part.strip_prefix("b/")) {
215 paths.insert(PathBuf::from(path));
216 }
217 continue;
218 }
219 if let Some(path) = line.strip_prefix("--- ") {
220 pending_old = diff_path(path);
221 continue;
222 }
223 if let Some(path) = line.strip_prefix("+++ ") {
224 if let Some(path) = diff_path(path).or_else(|| pending_old.take()) {
225 paths.insert(PathBuf::from(path));
226 }
227 }
228 }
229 paths.into_iter().collect()
230}
231
232fn changed_files_from_input(input: &VerifyChangeInput) -> Vec<PathBuf> {
233 let mut paths = input
234 .changed_files
235 .iter()
236 .map(|path| normalize_path(path))
237 .collect::<BTreeSet<_>>();
238 if let Some(diff) = &input.unified_diff {
239 paths.extend(
240 changed_files_from_unified_diff(diff)
241 .into_iter()
242 .map(|p| normalize_path(&p)),
243 );
244 }
245 paths.into_iter().map(PathBuf::from).collect()
246}
247
248fn diff_path(raw: &str) -> Option<String> {
249 let path = raw.split_whitespace().next().unwrap_or_default();
250 if path == "/dev/null" {
251 return None;
252 }
253 Some(
254 path.strip_prefix("a/")
255 .or_else(|| path.strip_prefix("b/"))
256 .unwrap_or(path)
257 .to_string(),
258 )
259}
260
261fn boundary_violations(
262 plan: &PlanReport,
263 changed_files: &[PathBuf],
264 evidence_refs: &[String],
265) -> Vec<VerificationFinding> {
266 let boundary = &plan.recommended_change_boundary;
267 let allowed = boundary
268 .allowed_files
269 .iter()
270 .map(|path| normalize_path(path))
271 .collect::<BTreeSet<_>>();
272 let caution = boundary
273 .caution_files
274 .iter()
275 .map(|path| normalize_path(path))
276 .collect::<BTreeSet<_>>();
277 let mut findings = Vec::new();
278 for path in changed_files {
279 let normalized = normalize_path(path);
280 if let Some(rule) = boundary
281 .forbidden_rules
282 .iter()
283 .find(|rule| boundary_pattern_matches(&rule.pattern, &normalized))
284 {
285 findings.push(VerificationFinding {
286 path: Some(path.clone()),
287 kind: "forbidden_boundary".into(),
288 reason: format!(
289 "matches forbidden pattern `{}`: {}",
290 rule.pattern, rule.reason
291 ),
292 evidence_refs: rule.evidence_refs.clone(),
293 });
294 continue;
295 }
296 if allowed.contains(&normalized) || caution.contains(&normalized) {
297 continue;
298 }
299 if evidence_refs.is_empty() {
300 findings.push(VerificationFinding {
301 path: Some(path.clone()),
302 kind: "out_of_boundary".into(),
303 reason:
304 "path is outside the saved plan boundary and no expansion evidence was supplied"
305 .into(),
306 evidence_refs: Vec::new(),
307 });
308 }
309 }
310 findings
311}
312
313fn caution_warnings(plan: &PlanReport, changed_files: &[PathBuf]) -> Vec<VerificationFinding> {
314 let boundary = &plan.recommended_change_boundary;
315 changed_files
316 .iter()
317 .filter_map(|path| {
318 let normalized = normalize_path(path);
319 boundary
320 .caution_rules
321 .iter()
322 .find(|rule| normalize_path(&rule.path) == normalized)
323 .map(|rule| VerificationFinding {
324 path: Some(path.clone()),
325 kind: "caution_boundary".into(),
326 reason: rule.reason.clone(),
327 evidence_refs: rule.evidence_refs.clone(),
328 })
329 })
330 .collect()
331}
332
333fn expansion_warnings(
334 plan: &PlanReport,
335 changed_files: &[PathBuf],
336 evidence_refs: &[String],
337) -> Vec<VerificationFinding> {
338 if evidence_refs.is_empty() {
339 return Vec::new();
340 }
341 let boundary = &plan.recommended_change_boundary;
342 let allowed = boundary
343 .allowed_files
344 .iter()
345 .map(|path| normalize_path(path))
346 .collect::<BTreeSet<_>>();
347 let caution = boundary
348 .caution_files
349 .iter()
350 .map(|path| normalize_path(path))
351 .collect::<BTreeSet<_>>();
352 changed_files
353 .iter()
354 .filter_map(|path| {
355 let normalized = normalize_path(path);
356 if allowed.contains(&normalized)
357 || caution.contains(&normalized)
358 || boundary
359 .forbidden_rules
360 .iter()
361 .any(|rule| boundary_pattern_matches(&rule.pattern, &normalized))
362 {
363 return None;
364 }
365 Some(VerificationFinding {
366 path: Some(path.clone()),
367 kind: "boundary_expansion".into(),
368 reason: "path is outside the saved boundary but explicit expansion evidence was supplied".into(),
369 evidence_refs: evidence_refs.to_vec(),
370 })
371 })
372 .collect()
373}
374
375fn changed_symbols(store: &dyn MetadataStore, changed_files: &[PathBuf]) -> Result<Vec<String>> {
376 let mut symbols = BTreeSet::new();
377 for path in changed_files {
378 if let Some(file) = store.get_file_by_path(path)? {
379 for symbol in store.symbols_for_file(&file.id)? {
380 symbols.insert(symbol.qualified_name);
381 }
382 }
383 }
384 Ok(symbols.into_iter().collect())
385}
386
387fn recommended_tests(store: &dyn OkStore, changed_files: &[PathBuf]) -> Result<Vec<TestTarget>> {
388 let selector = TestSelector::new(store);
389 let mut tests = Vec::new();
390 let mut seen = BTreeSet::new();
391 for path in changed_files {
392 for test in selector.for_changed_path_with_evidence(path, 8)? {
393 if seen.insert(test.id.clone()) {
394 tests.push(test);
395 }
396 }
397 }
398 Ok(tests)
399}
400
401fn missing_tests(plan: &PlanReport, recommended_tests: &[TestTarget]) -> Vec<VerificationFinding> {
402 let planned = plan
403 .validation
404 .iter()
405 .flat_map(|test| [test.id.clone(), test.name.clone()])
406 .collect::<BTreeSet<_>>();
407 recommended_tests
408 .iter()
409 .filter(|test| !planned.contains(&test.id) && !planned.contains(&test.name))
410 .map(|test| VerificationFinding {
411 path: Some(PathBuf::from(test.file_id.0.clone())),
412 kind: "missing_test".into(),
413 reason: format!("recommended test `{}` is not in the saved plan", test.name),
414 evidence_refs: test.evidence_refs.clone(),
415 })
416 .collect()
417}
418
419fn changed_impact(
420 store: &dyn OkStore,
421 search_index: Option<&dyn SearchIndex>,
422 plan: &PlanReport,
423 changed_files: &[PathBuf],
424) -> Result<Vec<VerificationFinding>> {
425 let planned_impacts = plan
426 .impact
427 .direct_impacts
428 .iter()
429 .chain(plan.impact.indirect_impacts.iter())
430 .map(|result| normalize_path(&result.path))
431 .chain(
432 plan.recommended_change_boundary
433 .allowed_files
434 .iter()
435 .map(|path| normalize_path(path)),
436 )
437 .chain(
438 plan.recommended_change_boundary
439 .caution_files
440 .iter()
441 .map(|path| normalize_path(path)),
442 )
443 .collect::<BTreeSet<_>>();
444 let impact_engine = ImpactEngine::new(store).with_search_index(search_index);
445 let mut findings = Vec::new();
446 let mut seen = BTreeSet::new();
447 for path in changed_files {
448 let impact = impact_engine.for_file(path)?;
449 for result in impact
450 .direct_impacts
451 .iter()
452 .chain(impact.indirect_impacts.iter())
453 .take(12)
454 {
455 let normalized = normalize_path(&result.path);
456 if !planned_impacts.contains(&normalized) && seen.insert(normalized.clone()) {
457 findings.push(impact_finding(result));
458 }
459 }
460 }
461 Ok(findings)
462}
463
464fn runtime_warnings(
465 store: &dyn MetadataStore,
466 changed_files: &[PathBuf],
467) -> Result<Vec<VerificationFinding>> {
468 let runtime_facts = store.analysis_facts(Some(EvidenceSourceType::Runtime), 500)?;
469 if runtime_facts.is_empty() {
470 return Ok(Vec::new());
471 }
472 let mut findings = Vec::new();
473 let mut seen = BTreeSet::new();
474 for path in changed_files {
475 let Some(file) = store.get_file_by_path(path)? else {
476 continue;
477 };
478 for fact in runtime_facts
479 .iter()
480 .filter(|fact| fact.file_id == file.id)
481 .take(5)
482 {
483 if seen.insert((normalize_path(path), fact.id.clone())) {
484 findings.push(runtime_finding(path, fact));
485 }
486 }
487 }
488 Ok(findings)
489}
490
491fn runtime_finding(path: &Path, fact: &AnalysisFact) -> VerificationFinding {
492 VerificationFinding {
493 path: Some(path.to_path_buf()),
494 kind: "nearby_runtime_signal".into(),
495 reason: format!(
496 "changed file has local runtime trace/log/incident evidence `{}`: {}",
497 fact.target, fact.message
498 ),
499 evidence_refs: vec![fact.id.clone()],
500 }
501}
502
503fn impact_finding(result: &SearchResult) -> VerificationFinding {
504 VerificationFinding {
505 path: Some(result.path.clone()),
506 kind: "changed_impact".into(),
507 reason: format!(
508 "post-edit impact candidate was not present in the saved plan: {}",
509 result.match_reason
510 ),
511 evidence_refs: result.derived_evidence_ids(),
512 }
513}
514
515fn run_validation_commands(repo: &Path, plan: &PlanReport) -> Vec<ValidationCommandResult> {
516 let mut seen = BTreeSet::new();
517 let commands = plan
518 .validation
519 .iter()
520 .filter_map(|test| test.command.clone())
521 .filter(|command| seen.insert(command.clone()))
522 .collect::<Vec<_>>();
523 commands
524 .into_iter()
525 .map(|command| run_validation_command(repo, &command))
526 .collect()
527}
528
529fn run_validation_command(repo: &Path, command: &str) -> ValidationCommandResult {
530 let output = Command::new("sh")
531 .arg("-lc")
532 .arg(command)
533 .current_dir(repo)
534 .output();
535 match output {
536 Ok(output) => ValidationCommandResult {
537 command: command.into(),
538 status: if output.status.success() {
539 "pass".into()
540 } else {
541 "fail".into()
542 },
543 exit_code: output.status.code(),
544 stdout: truncate_output(&String::from_utf8_lossy(&output.stdout)),
545 stderr: truncate_output(&String::from_utf8_lossy(&output.stderr)),
546 },
547 Err(err) => ValidationCommandResult {
548 command: command.into(),
549 status: "fail".into(),
550 exit_code: None,
551 stdout: String::new(),
552 stderr: truncate_output(&err.to_string()),
553 },
554 }
555}
556
557fn truncate_output(value: &str) -> String {
558 const MAX: usize = 4000;
559 if value.len() <= MAX {
560 value.into()
561 } else {
562 format!("{}... <truncated>", &value[..MAX])
563 }
564}
565
566fn normalize_path(path: &Path) -> String {
567 path.to_string_lossy()
568 .replace('\\', "/")
569 .trim_start_matches("./")
570 .to_string()
571}
572
573fn boundary_pattern_matches(pattern: &str, path: &str) -> bool {
574 let pattern = pattern.trim_start_matches("./").replace('\\', "/");
575 if pattern == path {
576 return true;
577 }
578 if let Some(prefix) = pattern.strip_suffix("/**") {
579 if let Some(middle) = prefix.strip_prefix("**/") {
580 return path == middle
581 || path.starts_with(&format!("{middle}/"))
582 || path.contains(&format!("/{middle}/"));
583 }
584 return path == prefix || path.starts_with(&format!("{prefix}/"));
585 }
586 if pattern.contains('*') {
587 let mut remainder = path;
588 for part in pattern.split('*').filter(|part| !part.is_empty()) {
589 if let Some(index) = remainder.find(part) {
590 remainder = &remainder[index + part.len()..];
591 } else {
592 return false;
593 }
594 }
595 return true;
596 }
597 false
598}
599
600fn stable_id(value: &str) -> String {
601 let mut hasher = Sha256::new();
602 hasher.update(value.as_bytes());
603 format!("{:x}", hasher.finalize())
604}
605
606#[cfg(test)]
607mod tests {
608 use super::*;
609 use open_kioku_core::{
610 CodeChunk, Confidence, File, FileId, GraphEdge, GraphEdgeType, GraphNode, GraphNodeType,
611 Import, IndexManifest, Language, LineRange, RepositoryId, Symbol, SymbolId,
612 SymbolOccurrence,
613 };
614 use open_kioku_errors::Result;
615 use open_kioku_storage::{GraphStore, IndexData};
616
617 struct RuntimeStore {
618 file: File,
619 fact: AnalysisFact,
620 }
621
622 impl RuntimeStore {
623 fn new() -> Self {
624 let file = File {
625 id: FileId::new("handler"),
626 repository_id: RepositoryId::new("repo"),
627 path: PathBuf::from("src/handler.rs"),
628 language: Language::Rust,
629 size_bytes: 100,
630 content_hash: "handler".into(),
631 is_generated: false,
632 is_vendor: false,
633 };
634 let fact = AnalysisFact {
635 id: "runtime-incident".into(),
636 file_id: file.id.clone(),
637 symbol_id: None,
638 target: "panic in checkout flow".into(),
639 target_kind: GraphNodeType::RuntimeError,
640 edge_type: GraphEdgeType::FailedIn,
641 range: Some(LineRange::single(9)),
642 confidence: Confidence::High,
643 source: "open-kioku-runtime:.ok/runtime/incidents.jsonl".into(),
644 source_type: EvidenceSourceType::Runtime,
645 message: "runtime incident observed in local log or failure artifact".into(),
646 };
647 Self { file, fact }
648 }
649 }
650
651 impl MetadataStore for RuntimeStore {
652 fn initialize(&self) -> Result<()> {
653 Ok(())
654 }
655
656 fn put_manifest(&self, _manifest: &IndexManifest) -> Result<()> {
657 Ok(())
658 }
659
660 fn manifest(&self) -> Result<Option<IndexManifest>> {
661 Ok(None)
662 }
663
664 fn replace_index(&self, _data: IndexData<'_>) -> Result<()> {
665 Ok(())
666 }
667
668 fn list_files(&self, _limit: usize, _offset: usize) -> Result<Vec<File>> {
669 Ok(vec![self.file.clone()])
670 }
671
672 fn get_file_by_path(&self, path: &Path) -> Result<Option<File>> {
673 Ok((path == self.file.path).then(|| self.file.clone()))
674 }
675
676 fn list_symbols(
677 &self,
678 _query: Option<&str>,
679 _limit: usize,
680 _offset: usize,
681 ) -> Result<Vec<Symbol>> {
682 Ok(Vec::new())
683 }
684
685 fn symbol_by_id(&self, _id: &SymbolId) -> Result<Option<Symbol>> {
686 Ok(None)
687 }
688
689 fn chunks_for_file(&self, _file_id: &FileId) -> Result<Vec<CodeChunk>> {
690 Ok(Vec::new())
691 }
692
693 fn all_chunks(&self) -> Result<Vec<CodeChunk>> {
694 Ok(Vec::new())
695 }
696
697 fn tests(&self) -> Result<Vec<TestTarget>> {
698 Ok(Vec::new())
699 }
700
701 fn imports(&self) -> Result<Vec<Import>> {
702 Ok(Vec::new())
703 }
704
705 fn analysis_facts(
706 &self,
707 source_type: Option<EvidenceSourceType>,
708 _limit: usize,
709 ) -> Result<Vec<AnalysisFact>> {
710 if source_type == Some(EvidenceSourceType::Runtime) {
711 Ok(vec![self.fact.clone()])
712 } else {
713 Ok(Vec::new())
714 }
715 }
716
717 fn references_for_symbol(
718 &self,
719 _id: &SymbolId,
720 _limit: usize,
721 ) -> Result<Vec<SymbolOccurrence>> {
722 Ok(Vec::new())
723 }
724
725 fn occurrences_for_file(&self, _file_id: &FileId) -> Result<Vec<SymbolOccurrence>> {
726 Ok(Vec::new())
727 }
728 }
729
730 impl GraphStore for RuntimeStore {
731 fn replace_graph(&self, _nodes: &[GraphNode], _edges: &[GraphEdge]) -> Result<()> {
732 Ok(())
733 }
734
735 fn neighbors(
736 &self,
737 _node: &str,
738 _limit: usize,
739 ) -> Result<(Vec<GraphNode>, Vec<GraphEdge>)> {
740 Ok((Vec::new(), Vec::new()))
741 }
742
743 fn shortest_path(
744 &self,
745 _from: &str,
746 _to: &str,
747 _max_depth: usize,
748 ) -> Result<Vec<GraphEdge>> {
749 Ok(Vec::new())
750 }
751 }
752
753 #[test]
754 fn runtime_warnings_surface_nearby_incidents() {
755 let store = RuntimeStore::new();
756 let warnings = runtime_warnings(&store, &[PathBuf::from("src/handler.rs")]).unwrap();
757
758 assert_eq!(warnings.len(), 1);
759 assert_eq!(warnings[0].kind, "nearby_runtime_signal");
760 assert!(warnings[0].reason.contains("panic in checkout flow"));
761 assert_eq!(warnings[0].evidence_refs, vec!["runtime-incident"]);
762 }
763}