use std::collections::{HashMap, HashSet};
use std::path::Path;
use crate::store::{FileRecord, GapType, KnowledgeGap, Record, RecordSource};
const COVERAGE_HOT_FILE_NO_RECORD: f32 = 0.0;
const COVERAGE_HOT_FILE_NO_PURPOSE: f32 = 0.3;
const COVERAGE_HOT_FILE_NO_GOTCHAS: f32 = 0.5;
const COVERAGE_FREQUENTLY_READ_NO_ENRICH: f32 = 0.2;
const COVERAGE_ORPHANED_DECISION: f32 = 0.0;
const COVERAGE_DEPENDENCY_UNKNOWN: f32 = 0.0;
#[cfg(test)]
const COVERAGE_CO_CHANGE_PAIR_UNMAPPED: f32 = 0.0;
const COVERAGE_STALE_HOTSPOT: f32 = 0.3;
const COVERAGE_HOT_FILE_NO_TESTS: f32 = 0.0;
const COVERAGE_HIGH_FAN_IN_NO_CONTRACT: f32 = 0.0;
const FAN_IN_THRESHOLD: usize = 5;
pub fn analyze(
file_records: &[Record],
gotchas: &[Record],
decisions: &[Record],
deps: &[Record],
fan_in: &HashMap<String, usize>,
) -> Vec<KnowledgeGap> {
let mut gaps = Vec::new();
detect_hot_file_no_record(file_records, &mut gaps);
detect_hot_file_no_purpose(file_records, &mut gaps);
detect_hot_file_no_gotchas(file_records, &mut gaps);
detect_frequently_read_no_enrich(file_records, &mut gaps);
detect_orphaned_decisions(decisions, &mut gaps);
detect_dependency_unknown(file_records, gotchas, deps, &mut gaps);
detect_stale_hotspots(file_records, &mut gaps);
detect_hot_file_no_tests(file_records, &mut gaps);
if !fan_in.is_empty() {
detect_high_fan_in_no_contract(file_records, fan_in, &mut gaps);
}
{
use crate::analysis::blast_radius::BlastTier;
let blast_lookup: HashMap<String, BlastTier> = file_records
.iter()
.filter_map(|r| {
r.payload_as::<crate::store::record::FileRecord>()
.and_then(|fr| fr.blast_radius.as_ref().map(|br| (r.key.clone(), br.tier)))
})
.collect();
for gap in &mut gaps {
let multiplier = match blast_lookup.get(&gap.key).copied() {
Some(BlastTier::Critical) => 2.0,
Some(BlastTier::High) => 1.5,
Some(BlastTier::Moderate) => 1.2,
Some(BlastTier::Low) => 1.0,
Some(BlastTier::Isolated) | None => 0.8,
};
gap.risk_score *= multiplier;
}
}
gaps.sort_by(|a, b| {
b.risk_score
.partial_cmp(&a.risk_score)
.unwrap_or(std::cmp::Ordering::Equal)
});
gaps
}
fn risk_score(change_frequency: f32, coverage: f32) -> f32 {
change_frequency * (1.0 - coverage)
}
#[cfg(test)]
fn coverage_for_gap(gap_type: &GapType) -> f32 {
match gap_type {
GapType::HotFileNoRecord => COVERAGE_HOT_FILE_NO_RECORD,
GapType::HotFileNoPurpose => COVERAGE_HOT_FILE_NO_PURPOSE,
GapType::HotFileNoGotchas => COVERAGE_HOT_FILE_NO_GOTCHAS,
GapType::FrequentlyReadNoEnrich => COVERAGE_FREQUENTLY_READ_NO_ENRICH,
GapType::OrphanedDecision => COVERAGE_ORPHANED_DECISION,
GapType::DependencyUnknown => COVERAGE_DEPENDENCY_UNKNOWN,
GapType::CoChangePairUnmapped => COVERAGE_CO_CHANGE_PAIR_UNMAPPED,
GapType::StaleHotspot => COVERAGE_STALE_HOTSPOT,
GapType::HotFileNoTests => COVERAGE_HOT_FILE_NO_TESTS,
GapType::HighFanInNoContract => COVERAGE_HIGH_FAN_IN_NO_CONTRACT,
}
}
fn description_for_gap(gap_type: &GapType, key: &str) -> String {
match gap_type {
GapType::HotFileNoRecord => {
format!("Hot file {key} has no knowledge record — high churn with zero context")
}
GapType::HotFileNoPurpose => {
format!(
"Hot file {key} has a record but no purpose — Claude cannot explain what it does"
)
}
GapType::HotFileNoGotchas => {
format!("Hot file {key} has no gotchas — frequently changed with no documented traps")
}
GapType::FrequentlyReadNoEnrich => {
format!("{key} is read by Claude but never enriched past Layer 0")
}
GapType::OrphanedDecision => {
format!("Decision {key} has no affected files — cannot be surfaced by hooks")
}
GapType::DependencyUnknown => {
let dep = crate::analysis::dep_display_name_from_key(key);
format!("Dependency {dep} has no confirmed gotchas — upgrade risks are invisible")
}
GapType::CoChangePairUnmapped => {
format!("{key} co-changes frequently with another file but has no graph edge")
}
GapType::StaleHotspot => {
format!(
"Hot file {key} has stale knowledge — record may be outdated after recent changes"
)
}
GapType::HotFileNoTests => {
format!(
"Hot file {key} has no test file — high-churn code with no visible test coverage"
)
}
GapType::HighFanInNoContract => {
format!("{key} is imported by many files but has no gotchas or decisions — interface contracts are undocumented")
}
}
}
fn action_hint_for_gap(gap_type: &GapType, key: &str) -> String {
let bare = key.split_once(':').map_or(key, |(_, rest)| rest);
match gap_type {
GapType::HotFileNoRecord => {
format!("mati show {bare} # inspect the file, then run mati enrich")
}
GapType::HotFileNoPurpose => {
format!("mati enrich # in Claude Code: /mati-enrich {bare}")
}
GapType::HotFileNoGotchas => {
format!("mati gotcha add {bare} -r \"rule text\"")
}
GapType::FrequentlyReadNoEnrich => {
format!("mati enrich # in Claude Code: /mati-enrich {bare}")
}
GapType::OrphanedDecision => {
format!("mati show {bare} # review and link affected files")
}
GapType::DependencyUnknown => {
format!("mati show {key} # inspect the dependency, then add gotchas to affected files")
}
GapType::CoChangePairUnmapped => {
format!("mati show {bare} # review co-change pairs")
}
GapType::StaleHotspot => "mati stale # inspect staleness, then: mati enrich".to_string(),
GapType::HotFileNoTests => {
format!("add tests for {bare} before the next change")
}
GapType::HighFanInNoContract => {
format!("mati gotcha add {bare} # document interface contracts and invariants")
}
}
}
fn contains_word(haystack: &str, needle: &str) -> bool {
haystack
.split(|c: char| !c.is_alphanumeric() && c != '-' && c != '_')
.any(|word| word == needle)
}
fn parse_file_record(record: &Record) -> Option<FileRecord> {
record.payload_as::<FileRecord>()
}
fn detect_hot_file_no_record(file_records: &[Record], gaps: &mut Vec<KnowledgeGap>) {
for record in file_records {
let path = record.key.strip_prefix("file:").unwrap_or(&record.key);
if !is_testable_source_file(path) {
continue;
}
match parse_file_record(record) {
None => {
let gap = KnowledgeGap {
key: record.key.clone(),
gap_type: GapType::HotFileNoRecord,
risk_score: risk_score(1.0, COVERAGE_HOT_FILE_NO_RECORD),
description: description_for_gap(&GapType::HotFileNoRecord, &record.key),
action_hint: action_hint_for_gap(&GapType::HotFileNoRecord, &record.key),
};
gaps.push(gap);
}
Some(fr) => {
if fr.is_hotspot
&& fr.purpose.is_empty()
&& fr.gotcha_keys.is_empty()
&& fr.entry_points.is_empty()
{
let gap = KnowledgeGap {
key: record.key.clone(),
gap_type: GapType::HotFileNoRecord,
risk_score: risk_score(
fr.change_frequency as f32,
COVERAGE_HOT_FILE_NO_RECORD,
),
description: description_for_gap(&GapType::HotFileNoRecord, &record.key),
action_hint: action_hint_for_gap(&GapType::HotFileNoRecord, &record.key),
};
gaps.push(gap);
}
}
}
}
}
fn detect_hot_file_no_purpose(file_records: &[Record], gaps: &mut Vec<KnowledgeGap>) {
for record in file_records {
let Some(fr) = parse_file_record(record) else {
continue;
};
if !fr.is_hotspot || !fr.purpose.is_empty() {
continue;
}
let path = record.key.strip_prefix("file:").unwrap_or(&record.key);
if !is_testable_source_file(path) {
continue;
}
if fr.gotcha_keys.is_empty() && fr.entry_points.is_empty() {
continue;
}
gaps.push(KnowledgeGap {
key: record.key.clone(),
gap_type: GapType::HotFileNoPurpose,
risk_score: risk_score(fr.change_frequency as f32, COVERAGE_HOT_FILE_NO_PURPOSE),
description: description_for_gap(&GapType::HotFileNoPurpose, &record.key),
action_hint: action_hint_for_gap(&GapType::HotFileNoPurpose, &record.key),
});
}
}
fn detect_hot_file_no_gotchas(file_records: &[Record], gaps: &mut Vec<KnowledgeGap>) {
for record in file_records {
let Some(fr) = parse_file_record(record) else {
continue;
};
if !fr.is_hotspot || fr.purpose.is_empty() || !fr.gotcha_keys.is_empty() {
continue;
}
gaps.push(KnowledgeGap {
key: record.key.clone(),
gap_type: GapType::HotFileNoGotchas,
risk_score: risk_score(fr.change_frequency as f32, COVERAGE_HOT_FILE_NO_GOTCHAS),
description: description_for_gap(&GapType::HotFileNoGotchas, &record.key),
action_hint: action_hint_for_gap(&GapType::HotFileNoGotchas, &record.key),
});
}
}
fn detect_frequently_read_no_enrich(file_records: &[Record], gaps: &mut Vec<KnowledgeGap>) {
for record in file_records {
if record.access_count == 0 || record.source != RecordSource::StaticAnalysis {
continue;
}
let freq = parse_file_record(record)
.map(|fr| fr.change_frequency as f32)
.unwrap_or(1.0);
gaps.push(KnowledgeGap {
key: record.key.clone(),
gap_type: GapType::FrequentlyReadNoEnrich,
risk_score: risk_score(freq, COVERAGE_FREQUENTLY_READ_NO_ENRICH),
description: description_for_gap(&GapType::FrequentlyReadNoEnrich, &record.key),
action_hint: action_hint_for_gap(&GapType::FrequentlyReadNoEnrich, &record.key),
});
}
}
fn detect_orphaned_decisions(decisions: &[Record], gaps: &mut Vec<KnowledgeGap>) {
for record in decisions {
let is_orphaned = match &record.payload {
Some(v) => {
let affected = v.get("affected_files");
match affected {
None => true,
Some(arr) => arr.as_array().is_none_or(Vec::is_empty),
}
}
None => true,
};
if !is_orphaned {
continue;
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let age_days = now.saturating_sub(record.created_at) / 86400;
let freq = age_days as f32 / 30.0;
gaps.push(KnowledgeGap {
key: record.key.clone(),
gap_type: GapType::OrphanedDecision,
risk_score: risk_score(freq, COVERAGE_ORPHANED_DECISION),
description: description_for_gap(&GapType::OrphanedDecision, &record.key),
action_hint: action_hint_for_gap(&GapType::OrphanedDecision, &record.key),
});
}
}
fn detect_dependency_unknown(
file_records: &[Record],
gotchas: &[Record],
deps: &[Record],
gaps: &mut Vec<KnowledgeGap>,
) {
let dep_names: Vec<(&str, &str)> = deps
.iter()
.map(|d| {
(
d.key.as_str(),
crate::analysis::dep_display_name_from_key(&d.key),
)
})
.collect();
let mut deps_with_gotchas = std::collections::HashSet::new();
for gotcha in gotchas {
if let Some(gr) = &gotcha.payload {
if let Some(confirmed) = gr.get("confirmed") {
if confirmed.as_bool() != Some(true) {
continue;
}
}
for (dep_key, dep_name) in &dep_names {
if gotcha.key.contains(dep_key) || contains_word(&gotcha.value, dep_name) {
deps_with_gotchas.insert(*dep_key);
}
}
}
}
let mut dep_usage_count: std::collections::HashMap<&str, u32> =
std::collections::HashMap::new();
for file_rec in file_records {
if let Some(fr) = parse_file_record(file_rec) {
for import in &fr.imports {
for (dep_key, dep_name) in &dep_names {
if import.contains(dep_name) {
*dep_usage_count.entry(dep_key).or_default() += 1;
}
}
}
}
}
for (dep_key, _) in &dep_names {
if deps_with_gotchas.contains(dep_key) {
continue;
}
let freq = dep_usage_count.get(dep_key).copied().unwrap_or(1) as f32;
gaps.push(KnowledgeGap {
key: dep_key.to_string(),
gap_type: GapType::DependencyUnknown,
risk_score: risk_score(freq, COVERAGE_DEPENDENCY_UNKNOWN),
description: description_for_gap(&GapType::DependencyUnknown, dep_key),
action_hint: action_hint_for_gap(&GapType::DependencyUnknown, dep_key),
});
}
}
fn detect_hot_file_no_tests(file_records: &[Record], gaps: &mut Vec<KnowledgeGap>) {
let all_paths: HashSet<&str> = file_records
.iter()
.filter_map(|r| r.key.strip_prefix("file:"))
.collect();
for record in file_records {
let Some(fr) = parse_file_record(record) else {
continue;
};
if !fr.is_hotspot {
continue;
}
let path = record.key.strip_prefix("file:").unwrap_or(&record.key);
if !is_testable_source_file(path) {
continue;
}
if has_test_file(path, &all_paths) {
continue;
}
gaps.push(KnowledgeGap {
key: record.key.clone(),
gap_type: GapType::HotFileNoTests,
risk_score: risk_score(fr.change_frequency as f32, COVERAGE_HOT_FILE_NO_TESTS),
description: description_for_gap(&GapType::HotFileNoTests, &record.key),
action_hint: action_hint_for_gap(&GapType::HotFileNoTests, path),
});
}
}
fn is_testable_source_file(path: &str) -> bool {
let ext = Path::new(path)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
matches!(
ext,
"rs" | "go"
| "ts"
| "tsx"
| "js"
| "jsx"
| "mjs"
| "cjs"
| "py"
| "pyi"
| "java"
| "rb"
| "kt"
| "scala"
| "cs"
| "cpp"
| "cc"
| "c"
| "h"
| "hpp"
| "swift"
| "ex"
| "exs"
)
}
fn has_test_file(path: &str, all_paths: &HashSet<&str>) -> bool {
let p = Path::new(path);
let stem = match p.file_stem().and_then(|s| s.to_str()) {
Some(s) => s,
None => return false,
};
let ext = p.extension().and_then(|e| e.to_str()).unwrap_or("");
let parent = p.parent().and_then(|p| p.to_str()).unwrap_or("");
let join = |dir: &str, name: &str| -> String {
if dir.is_empty() {
name.to_string()
} else {
format!("{dir}/{name}")
}
};
let candidates: &[String] = &[
join(parent, &format!("{stem}_test.rs")),
join(parent, &format!("{stem}_tests.rs")),
format!("tests/{path}"),
join(parent, &format!("{stem}_test.go")),
join(parent, &format!("{stem}.test.{ext}")),
join(parent, &format!("{stem}.spec.{ext}")),
join(parent, &format!("__tests__/{stem}.{ext}")),
format!("tests/{path}"),
format!("test/{path}"),
join(parent, &format!("test_{stem}.{ext}")),
join(parent, &format!("{stem}_test.{ext}")),
format!("tests/{path}"),
];
candidates.iter().any(|c| all_paths.contains(c.as_str()))
}
fn detect_high_fan_in_no_contract(
file_records: &[Record],
fan_in: &HashMap<String, usize>,
gaps: &mut Vec<KnowledgeGap>,
) {
for record in file_records {
let count = match fan_in.get(&record.key) {
Some(&n) if n >= FAN_IN_THRESHOLD => n,
_ => continue,
};
let Some(fr) = parse_file_record(record) else {
continue;
};
if !fr.gotcha_keys.is_empty() || !fr.decision_keys.is_empty() {
continue;
}
let bare = record.key.strip_prefix("file:").unwrap_or(&record.key);
gaps.push(KnowledgeGap {
key: record.key.clone(),
gap_type: GapType::HighFanInNoContract,
risk_score: risk_score(count as f32, COVERAGE_HIGH_FAN_IN_NO_CONTRACT),
description: format!(
"{} is imported by {count} files — no interface contract documented",
record.key
),
action_hint: action_hint_for_gap(&GapType::HighFanInNoContract, bare),
});
}
}
fn detect_stale_hotspots(file_records: &[Record], gaps: &mut Vec<KnowledgeGap>) {
for record in file_records {
if record.staleness.computed_at == 0 {
continue;
}
if record.staleness.value < 0.5 {
continue;
}
let Some(fr) = parse_file_record(record) else {
continue;
};
if !fr.is_hotspot {
continue;
}
gaps.push(KnowledgeGap {
key: record.key.clone(),
gap_type: GapType::StaleHotspot,
risk_score: risk_score(fr.change_frequency as f32, COVERAGE_STALE_HOTSPOT),
description: description_for_gap(&GapType::StaleHotspot, &record.key),
action_hint: action_hint_for_gap(&GapType::StaleHotspot, &record.key),
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::{
Category, ConfidenceScore, Priority, QualityScore, Record, RecordLifecycle, RecordSource,
RecordVersion, StalenessScore,
};
fn make_file_record_with(key: &str, fr: FileRecord) -> Record {
Record {
key: key.to_string(),
value: fr.purpose.clone(),
payload: serde_json::to_value(&fr).ok(),
category: Category::File,
priority: Priority::Normal,
tags: vec![],
created_at: 1_000_000,
updated_at: 1_000_000,
ref_url: None,
staleness: StalenessScore::fresh(),
lifecycle: RecordLifecycle::Active,
version: RecordVersion {
device_id: uuid::Uuid::new_v4(),
logical_clock: 1,
wall_clock: 1_000_000,
},
quality: QualityScore::layer0_default(),
access_count: 0,
last_accessed: 0,
source: RecordSource::StaticAnalysis,
confidence: ConfidenceScore::for_new_record(&RecordSource::StaticAnalysis),
gap_analysis_score: 0.0,
}
}
fn hotspot_fr(path: &str, change_frequency: u32) -> FileRecord {
FileRecord {
path: path.to_string(),
purpose: "Does important things".to_string(),
entry_points: vec!["run".to_string()],
imports: vec![],
gotcha_keys: vec![],
decision_keys: vec![],
todos: vec![],
unsafe_count: 0,
unwrap_count: 0,
change_frequency,
last_author: None,
is_hotspot: true,
token_cost_estimate: 100,
last_modified_session: 0,
content_hash: None,
line_count: 0,
blast_radius: None,
propagated_staleness: None,
}
}
#[test]
fn risk_score_zero_coverage_uses_full_frequency() {
let score = risk_score(50.0, 0.0);
assert!((score - 50.0).abs() < f32::EPSILON);
}
#[test]
fn risk_score_full_coverage_is_zero() {
let score = risk_score(50.0, 1.0);
assert!((score - 0.0).abs() < f32::EPSILON);
}
#[test]
fn risk_score_partial_coverage() {
let score = risk_score(40.0, 0.3);
assert!((score - 28.0).abs() < 0.01);
}
#[test]
fn risk_score_hot_file_no_record() {
let score = risk_score(100.0, COVERAGE_HOT_FILE_NO_RECORD);
assert!((score - 100.0).abs() < f32::EPSILON);
}
#[test]
fn risk_score_hot_file_no_purpose() {
let score = risk_score(80.0, COVERAGE_HOT_FILE_NO_PURPOSE);
assert!((score - 56.0).abs() < 0.01);
}
#[test]
fn risk_score_hot_file_no_gotchas() {
let score = risk_score(60.0, COVERAGE_HOT_FILE_NO_GOTCHAS);
assert!((score - 30.0).abs() < f32::EPSILON);
}
#[test]
fn risk_score_frequently_read_no_enrich() {
let score = risk_score(20.0, COVERAGE_FREQUENTLY_READ_NO_ENRICH);
assert!((score - 16.0).abs() < 0.01);
}
#[test]
fn risk_score_orphaned_decision() {
let freq = 90.0 / 30.0;
let score = risk_score(freq, COVERAGE_ORPHANED_DECISION);
assert!((score - 3.0).abs() < f32::EPSILON);
}
#[test]
fn risk_score_dependency_unknown() {
let score = risk_score(5.0, COVERAGE_DEPENDENCY_UNKNOWN);
assert!((score - 5.0).abs() < f32::EPSILON);
}
#[test]
fn risk_score_stale_hotspot() {
let score = risk_score(45.0, COVERAGE_STALE_HOTSPOT);
assert!((score - 31.5).abs() < 0.01);
}
#[test]
fn coverage_for_gap_returns_correct_values() {
assert!((coverage_for_gap(&GapType::HotFileNoRecord) - 0.0).abs() < f32::EPSILON);
assert!((coverage_for_gap(&GapType::HotFileNoPurpose) - 0.3).abs() < f32::EPSILON);
assert!((coverage_for_gap(&GapType::HotFileNoGotchas) - 0.5).abs() < f32::EPSILON);
assert!((coverage_for_gap(&GapType::FrequentlyReadNoEnrich) - 0.2).abs() < f32::EPSILON);
assert!((coverage_for_gap(&GapType::OrphanedDecision) - 0.0).abs() < f32::EPSILON);
assert!((coverage_for_gap(&GapType::DependencyUnknown) - 0.0).abs() < f32::EPSILON);
assert!((coverage_for_gap(&GapType::CoChangePairUnmapped) - 0.0).abs() < f32::EPSILON);
assert!((coverage_for_gap(&GapType::StaleHotspot) - 0.3).abs() < f32::EPSILON);
}
#[test]
fn description_contains_key() {
let desc = description_for_gap(&GapType::HotFileNoRecord, "file:src/main.rs");
assert!(desc.contains("file:src/main.rs"));
}
#[test]
fn description_per_gap_type() {
let cases = [
(GapType::HotFileNoRecord, "no knowledge record"),
(GapType::HotFileNoPurpose, "no purpose"),
(GapType::HotFileNoGotchas, "no gotchas"),
(GapType::FrequentlyReadNoEnrich, "never enriched"),
(GapType::OrphanedDecision, "no affected files"),
(GapType::DependencyUnknown, "no confirmed gotchas"),
(GapType::CoChangePairUnmapped, "co-changes"),
(GapType::StaleHotspot, "stale knowledge"),
];
for (gap_type, expected_substr) in &cases {
let desc = description_for_gap(gap_type, "file:test.rs");
assert!(
desc.contains(expected_substr),
"expected {:?} description to contain '{}', got: {}",
gap_type,
expected_substr,
desc
);
}
}
#[test]
fn action_hint_strips_prefix() {
let hint = action_hint_for_gap(&GapType::HotFileNoPurpose, "file:src/main.rs");
assert!(
hint.contains("src/main.rs"),
"hint should contain bare path: {hint}"
);
assert!(
!hint.contains("file:src/"),
"hint should not contain file: prefix: {hint}"
);
}
#[test]
fn action_hint_suggests_mati_command() {
let hint = action_hint_for_gap(&GapType::HotFileNoGotchas, "file:src/lib.rs");
assert!(
hint.starts_with("mati "),
"hint should suggest a mati command: {hint}"
);
}
#[test]
fn action_hint_uses_quick_capture_syntax_for_missing_gotchas() {
let hint = action_hint_for_gap(&GapType::HotFileNoGotchas, "file:src/lib.rs");
assert!(
hint.contains("mati gotcha add src/lib.rs -r"),
"hint should use the supported quick-capture syntax: {hint}"
);
assert!(
!hint.contains("--file"),
"hint must not suggest unsupported --file syntax: {hint}"
);
}
#[test]
fn action_hint_does_not_suggest_removed_refresh_flag() {
let hint = action_hint_for_gap(&GapType::StaleHotspot, "file:src/lib.rs");
assert!(
!hint.contains("--refresh"),
"hint must not suggest unsupported --refresh syntax: {hint}"
);
assert!(
hint.contains("mati stale"),
"stale hotspot hint should start from the supported stale surface: {hint}"
);
}
#[test]
fn action_hint_per_gap_type() {
let cases = [
(GapType::HotFileNoRecord, "file:src/a.rs", "enrich"),
(GapType::HotFileNoPurpose, "file:src/b.rs", "enrich"),
(GapType::HotFileNoGotchas, "file:src/c.rs", "gotcha add"),
(GapType::FrequentlyReadNoEnrich, "file:src/d.rs", "enrich"),
(GapType::OrphanedDecision, "decision:use-surrealkv", "show"),
(
GapType::DependencyUnknown,
"dep:cargo:serde",
"show dep:cargo:serde",
),
(GapType::StaleHotspot, "file:src/e.rs", "mati stale"),
];
for (gap_type, key, expected_cmd) in &cases {
let hint = action_hint_for_gap(gap_type, key);
assert!(
hint.contains(expected_cmd),
"expected {:?} hint to contain '{}', got: {}",
gap_type,
expected_cmd,
hint
);
}
}
#[test]
fn gap_fields_are_populated() {
let gap = KnowledgeGap {
key: "file:src/store/db.rs".into(),
gap_type: GapType::HotFileNoRecord,
risk_score: risk_score(100.0, COVERAGE_HOT_FILE_NO_RECORD),
description: description_for_gap(&GapType::HotFileNoRecord, "file:src/store/db.rs"),
action_hint: action_hint_for_gap(&GapType::HotFileNoRecord, "file:src/store/db.rs"),
};
assert_eq!(gap.key, "file:src/store/db.rs");
assert_eq!(gap.gap_type, GapType::HotFileNoRecord);
assert!((gap.risk_score - 100.0).abs() < f32::EPSILON);
assert!(!gap.description.is_empty());
assert!(gap.action_hint.starts_with("mati "));
}
#[test]
fn hot_file_no_tests_flags_hotspot_without_test_file() {
let fr = hotspot_fr("src/auth.rs", 20);
let records = vec![make_file_record_with("file:src/auth.rs", fr)];
let mut gaps = vec![];
detect_hot_file_no_tests(&records, &mut gaps);
assert_eq!(gaps.len(), 1);
assert_eq!(gaps[0].gap_type, GapType::HotFileNoTests);
assert_eq!(gaps[0].key, "file:src/auth.rs");
}
#[test]
fn hot_file_no_tests_skips_non_hotspot() {
let mut fr = hotspot_fr("src/util.rs", 20);
fr.is_hotspot = false;
let records = vec![make_file_record_with("file:src/util.rs", fr)];
let mut gaps = vec![];
detect_hot_file_no_tests(&records, &mut gaps);
assert!(gaps.is_empty());
}
#[test]
fn hot_file_no_tests_suppressed_when_rust_test_file_exists() {
let hotspot = make_file_record_with("file:src/auth.rs", hotspot_fr("src/auth.rs", 20));
let test_fr = FileRecord {
path: "src/auth_test.rs".to_string(),
is_hotspot: false,
..hotspot_fr("src/auth_test.rs", 0)
};
let test_rec = make_file_record_with("file:src/auth_test.rs", test_fr);
let records = vec![hotspot, test_rec];
let mut gaps = vec![];
detect_hot_file_no_tests(&records, &mut gaps);
assert!(gaps.is_empty(), "should not flag when auth_test.rs exists");
}
#[test]
fn hot_file_no_tests_suppressed_when_tests_dir_mirror_exists() {
let hotspot = make_file_record_with("file:src/auth.rs", hotspot_fr("src/auth.rs", 20));
let mirror_fr = FileRecord {
path: "tests/src/auth.rs".to_string(),
is_hotspot: false,
..hotspot_fr("tests/src/auth.rs", 0)
};
let mirror_rec = make_file_record_with("file:tests/src/auth.rs", mirror_fr);
let records = vec![hotspot, mirror_rec];
let mut gaps = vec![];
detect_hot_file_no_tests(&records, &mut gaps);
assert!(
gaps.is_empty(),
"should not flag when tests/src/auth.rs exists"
);
}
#[test]
fn hot_file_no_tests_suppressed_when_ts_spec_file_exists() {
let hotspot_fr_ts = FileRecord {
path: "src/parser.ts".to_string(),
is_hotspot: true,
..hotspot_fr("src/parser.ts", 15)
};
let hotspot = make_file_record_with("file:src/parser.ts", hotspot_fr_ts);
let spec_fr = FileRecord {
path: "src/parser.spec.ts".to_string(),
is_hotspot: false,
..hotspot_fr("src/parser.spec.ts", 0)
};
let spec_rec = make_file_record_with("file:src/parser.spec.ts", spec_fr);
let records = vec![hotspot, spec_rec];
let mut gaps = vec![];
detect_hot_file_no_tests(&records, &mut gaps);
assert!(
gaps.is_empty(),
"should not flag when parser.spec.ts exists"
);
}
#[test]
fn hot_file_no_tests_risk_score_uses_change_frequency() {
let fr = hotspot_fr("src/hot.rs", 50);
let records = vec![make_file_record_with("file:src/hot.rs", fr)];
let mut gaps = vec![];
detect_hot_file_no_tests(&records, &mut gaps);
assert_eq!(gaps.len(), 1);
assert!((gaps[0].risk_score - 50.0).abs() < f32::EPSILON);
}
#[test]
fn hot_file_no_tests_suppressed_when_jest_tests_dir_exists() {
let hotspot_fr_js = FileRecord {
path: "src/auth.ts".to_string(),
is_hotspot: true,
..hotspot_fr("src/auth.ts", 15)
};
let hotspot = make_file_record_with("file:src/auth.ts", hotspot_fr_js);
let jest_fr = FileRecord {
path: "src/__tests__/auth.ts".to_string(),
is_hotspot: false,
..hotspot_fr("src/__tests__/auth.ts", 0)
};
let jest_rec = make_file_record_with("file:src/__tests__/auth.ts", jest_fr);
let records = vec![hotspot, jest_rec];
let mut gaps = vec![];
detect_hot_file_no_tests(&records, &mut gaps);
assert!(
gaps.is_empty(),
"should not flag when src/__tests__/auth.ts exists"
);
}
#[test]
fn hot_file_no_tests_suppressed_when_go_test_file_exists() {
let hotspot_fr_go = FileRecord {
path: "pkg/store/db.go".to_string(),
is_hotspot: true,
..hotspot_fr("pkg/store/db.go", 10)
};
let hotspot = make_file_record_with("file:pkg/store/db.go", hotspot_fr_go);
let test_fr = FileRecord {
path: "pkg/store/db_test.go".to_string(),
is_hotspot: false,
..hotspot_fr("pkg/store/db_test.go", 0)
};
let test_rec = make_file_record_with("file:pkg/store/db_test.go", test_fr);
let records = vec![hotspot, test_rec];
let mut gaps = vec![];
detect_hot_file_no_tests(&records, &mut gaps);
assert!(gaps.is_empty(), "should not flag when db_test.go exists");
}
#[test]
fn high_fan_in_flags_file_above_threshold_with_no_contracts() {
let fr = hotspot_fr("src/core.rs", 10);
let records = vec![make_file_record_with("file:src/core.rs", fr)];
let fan_in = HashMap::from([("file:src/core.rs".to_string(), 8)]);
let mut gaps = vec![];
detect_high_fan_in_no_contract(&records, &fan_in, &mut gaps);
assert_eq!(gaps.len(), 1);
assert_eq!(gaps[0].gap_type, GapType::HighFanInNoContract);
assert_eq!(gaps[0].key, "file:src/core.rs");
}
#[test]
fn high_fan_in_skips_file_below_threshold() {
let fr = hotspot_fr("src/core.rs", 10);
let records = vec![make_file_record_with("file:src/core.rs", fr)];
let fan_in = HashMap::from([("file:src/core.rs".to_string(), 4)]);
let mut gaps = vec![];
detect_high_fan_in_no_contract(&records, &fan_in, &mut gaps);
assert!(gaps.is_empty());
}
#[test]
fn high_fan_in_skips_file_with_gotcha_keys() {
let mut fr = hotspot_fr("src/core.rs", 10);
fr.gotcha_keys = vec!["gotcha:core-invariant".to_string()];
let records = vec![make_file_record_with("file:src/core.rs", fr)];
let fan_in = HashMap::from([("file:src/core.rs".to_string(), 10)]);
let mut gaps = vec![];
detect_high_fan_in_no_contract(&records, &fan_in, &mut gaps);
assert!(gaps.is_empty(), "gotcha_keys present — should not flag");
}
#[test]
fn high_fan_in_skips_file_with_decision_keys() {
let mut fr = hotspot_fr("src/core.rs", 10);
fr.decision_keys = vec!["decision:use-surrealkv".to_string()];
let records = vec![make_file_record_with("file:src/core.rs", fr)];
let fan_in = HashMap::from([("file:src/core.rs".to_string(), 10)]);
let mut gaps = vec![];
detect_high_fan_in_no_contract(&records, &fan_in, &mut gaps);
assert!(gaps.is_empty(), "decision_keys present — should not flag");
}
#[test]
fn high_fan_in_risk_score_uses_fan_in_count() {
let fr = hotspot_fr("src/core.rs", 10);
let records = vec![make_file_record_with("file:src/core.rs", fr)];
let fan_in = HashMap::from([("file:src/core.rs".to_string(), 7)]);
let mut gaps = vec![];
detect_high_fan_in_no_contract(&records, &fan_in, &mut gaps);
assert_eq!(gaps.len(), 1);
assert!((gaps[0].risk_score - 7.0).abs() < f32::EPSILON);
}
#[test]
fn high_fan_in_skipped_when_fan_in_map_is_empty() {
let fr = hotspot_fr("src/core.rs", 10);
let records = vec![make_file_record_with("file:src/core.rs", fr)];
let gaps = analyze(&records, &[], &[], &[], &HashMap::new());
assert!(!gaps
.iter()
.any(|g| g.gap_type == GapType::HighFanInNoContract));
}
#[test]
fn high_fan_in_description_mentions_importer_count() {
let fr = hotspot_fr("src/core.rs", 10);
let records = vec![make_file_record_with("file:src/core.rs", fr)];
let fan_in = HashMap::from([("file:src/core.rs".to_string(), 6)]);
let mut gaps = vec![];
detect_high_fan_in_no_contract(&records, &fan_in, &mut gaps);
assert_eq!(gaps.len(), 1);
assert!(
gaps[0].description.contains("6"),
"description should mention importer count"
);
}
#[test]
fn gaps_sort_by_risk_descending() {
let mut gaps = [
KnowledgeGap {
key: "file:low.rs".into(),
gap_type: GapType::HotFileNoGotchas,
risk_score: 10.0,
description: String::new(),
action_hint: String::new(),
},
KnowledgeGap {
key: "file:high.rs".into(),
gap_type: GapType::HotFileNoRecord,
risk_score: 100.0,
description: String::new(),
action_hint: String::new(),
},
KnowledgeGap {
key: "file:mid.rs".into(),
gap_type: GapType::HotFileNoPurpose,
risk_score: 50.0,
description: String::new(),
action_hint: String::new(),
},
];
gaps.sort_by(|a, b| {
b.risk_score
.partial_cmp(&a.risk_score)
.unwrap_or(std::cmp::Ordering::Equal)
});
assert_eq!(gaps[0].key, "file:high.rs");
assert_eq!(gaps[1].key, "file:mid.rs");
assert_eq!(gaps[2].key, "file:low.rs");
}
}