use crate::check::{Check, Finding, FindingKind, Severity};
use crate::scan::{list_adr_files, list_feature_files, rel, tagged_lines};
use koala_core::invariant::Context;
use koala_core::wiki::{extract_frontmatter, parse_yaml_frontmatter};
use regex::Regex;
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use std::sync::OnceLock;
pub struct FeatureAdrRefs;
#[derive(Debug)]
struct AdrRecord {
status: String,
superseded_by: Option<String>,
}
fn adr_pattern() -> &'static Regex {
static R: OnceLock<Regex> = OnceLock::new();
R.get_or_init(|| Regex::new(r"\bADR-(\d{4})\b").expect("static regex compiles"))
}
impl Check for FeatureAdrRefs {
fn id(&self) -> &'static str {
"feature.adr-ref-resolves"
}
fn intent(&self) -> &'static str {
"ADR references in feature docs resolve to an existing decision \
file and are not superseded."
}
fn run(&self, ctx: &Context) -> Vec<Finding> {
let adr_index = build_adr_index(ctx.root());
let mut out = Vec::new();
for feature_path in list_feature_files(ctx.root()) {
let Ok(content) = fs::read_to_string(&feature_path) else {
continue;
};
let display = rel(&feature_path, ctx.root());
for line in tagged_lines(&content) {
if line.in_fence {
continue;
}
let mut seen_on_line: Vec<&str> = Vec::new();
for m in adr_pattern().captures_iter(line.text) {
let id = m.get(1).expect("group 1 always present").as_str();
if seen_on_line.contains(&id) {
continue;
}
seen_on_line.push(id);
match adr_index.get(id) {
None => out.push(Finding {
check_id: self.id(),
file: display.clone(),
line: line.line_no,
claim: line.text.trim().to_string(),
kind: FindingKind::AdrRefDangling,
severity: Severity::Hard,
fix_hint: Some(format!(
"no `wiki/decisions/{id}-*.md` exists; remove \
the reference or land the ADR first"
)),
}),
Some(rec) if rec.status == "superseded" => {
let superseder = rec.superseded_by.clone();
let hint = match superseder.as_deref() {
Some(s) => format!(
"ADR-{id} is superseded by ADR-{s}; update \
the reference"
),
None => format!(
"ADR-{id} is superseded; update the \
reference to its superseder"
),
};
out.push(Finding {
check_id: self.id(),
file: display.clone(),
line: line.line_no,
claim: line.text.trim().to_string(),
kind: FindingKind::AdrRefSuperseded { superseder },
severity: Severity::Hard,
fix_hint: Some(hint),
});
}
Some(_) => {}
}
}
}
}
out
}
}
fn build_adr_index(root: &Path) -> HashMap<String, AdrRecord> {
let mut out = HashMap::new();
for path in list_adr_files(root) {
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
let id = name[..4].to_string();
let Ok(content) = fs::read_to_string(&path) else {
continue;
};
let fm = extract_frontmatter(&content).unwrap_or("");
let fields = parse_yaml_frontmatter(fm);
let status = fields
.get("status")
.cloned()
.unwrap_or_else(|| "unknown".to_string());
let superseded_by = fields
.get("superseded-by")
.or_else(|| fields.get("superseded_by"))
.cloned();
out.insert(
id,
AdrRecord {
status,
superseded_by,
},
);
}
out
}