Skip to main content

open_kioku_patch/
lib.rs

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}