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}