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