use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use serde::{Deserialize, Serialize};
use crate::config::{Config, EmbeddingConfig, FallbackProviderConfig, ProviderConfig};
use crate::utils::providers::{configured_providers, normalize_provider_name};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Verdict {
Ok,
Stale,
Unverifiable,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AnchorKind {
Binary,
ProviderName,
ConfigKey,
FilePath,
}
pub const ALL_ANCHOR_KINDS: [AnchorKind; 4] = [
AnchorKind::Binary,
AnchorKind::ProviderName,
AnchorKind::ConfigKey,
AnchorKind::FilePath,
];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LineClass {
Prescription,
HistoricalExempt,
Neutral,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FindingAction {
RewordViaUpdate,
SurfaceToUser,
None,
}
impl FindingAction {
#[cfg_attr(not(test), allow(dead_code))]
pub fn as_str(&self) -> &'static str {
match self {
FindingAction::RewordViaUpdate => "reword_via_update",
FindingAction::SurfaceToUser => "surface_to_user",
FindingAction::None => "none",
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct StaleFinding {
pub file: String,
pub line_no: usize,
pub line: String,
pub anchor: String,
pub anchor_kind: AnchorKind,
pub verdict: Verdict,
pub action: FindingAction,
pub evidence: String,
}
impl StaleFinding {
pub fn unique_key(&self) -> String {
format!(
"{}:{}:{}:{}",
self.file,
self.line_no,
self.anchor_kind_str(),
self.anchor
)
}
fn anchor_kind_str(&self) -> &'static str {
match self.anchor_kind {
AnchorKind::Binary => "binary",
AnchorKind::ProviderName => "provider",
AnchorKind::ConfigKey => "config_key",
AnchorKind::FilePath => "file_path",
}
}
}
const MONTHS: &[&str] = &[
"jan",
"feb",
"mar",
"apr",
"may",
"jun",
"jul",
"aug",
"sep",
"oct",
"nov",
"dec",
"january",
"february",
"march",
"april",
"june",
"july",
"august",
"september",
"october",
"november",
"december",
];
const HISTORICAL_HINTS: &[&str] = &[
"violation",
"ledger",
"observed",
"incident",
"post-mortem",
"postmortem",
"reverted",
"lesson learned",
"history",
"archived",
"superseded",
"deprecation log",
];
const IMPERATIVE_STARTERS: &[&str] = &[
"never", "always", "must", "do", "don't", "dont", "use", "avoid", "prefer", "run", "check",
"skip", "stop", "set", "enable", "disable", "keep", "require", "verify", "ensure", "call",
"pass", "write", "read", "stage", "commit", "reply", "respond", "load", "before", "when", "if",
"for", "treat", "route", "match", "send", "fetch", "grep", "clone",
];
const SHELL_BUILTINS: &[&str] = &[
":", ".", "[", "alias", "bg", "bind", "break", "builtin", "caller", "cd", "command", "compgen",
"complete", "continue", "declare", "dirs", "disown", "echo", "enable", "eval", "exec", "exit",
"export", "false", "fc", "fg", "getopts", "hash", "help", "history", "jobs", "kill", "let",
"local", "logout", "popd", "printf", "pushd", "pwd", "read", "return", "select", "set",
"shift", "shopt", "source", "suspend", "test", "times", "trap", "true", "type", "typeset",
"ulimit", "umask", "unalias", "unset", "wait",
];
fn contains_ascii_word(low: &str, word: &[u8]) -> bool {
let b = low.as_bytes();
let n = word.len();
if n == 0 || b.len() < n {
return false;
}
for i in 0..=b.len() - n {
if &b[i..i + n] != word {
continue;
}
let before_ok = i == 0 || !b[i - 1].is_ascii_alphanumeric();
let after_ok = i + n == b.len() || !b[i + n].is_ascii_alphanumeric();
if before_ok && after_ok {
return true;
}
}
false
}
pub(crate) fn has_date_marker(line: &str) -> bool {
let low = line.to_ascii_lowercase();
if MONTHS
.iter()
.any(|m| contains_ascii_word(&low, m.as_bytes()))
{
return true;
}
let b = low.as_bytes();
for i in 0..b.len().saturating_sub(3) {
if b[i..i + 4].iter().all(u8::is_ascii_digit)
&& (b[i] == b'1' || b[i] == b'2')
&& (i == 0 || !b[i - 1].is_ascii_digit())
&& (i + 4 >= b.len() || !b[i + 4].is_ascii_digit())
{
return true;
}
}
false
}
pub fn classify_line(raw: &str) -> LineClass {
let t = raw.trim();
let low = t.to_ascii_lowercase();
if HISTORICAL_HINTS.iter().any(|h| low.contains(h)) || has_date_marker(t) {
return LineClass::HistoricalExempt;
}
let body = t
.strip_prefix("- ")
.or_else(|| t.strip_prefix("* "))
.unwrap_or(t);
let first_word = body
.split(|c: char| !c.is_ascii_alphanumeric() && c != '\'')
.next()
.unwrap_or("")
.to_ascii_lowercase();
if IMPERATIVE_STARTERS.contains(&first_word.as_str()) {
LineClass::Prescription
} else {
LineClass::Neutral
}
}
pub fn decide_action(kind: AnchorKind, verdict: Verdict) -> FindingAction {
match verdict {
Verdict::Ok | Verdict::Unverifiable => FindingAction::None,
Verdict::Stale => match kind {
AnchorKind::FilePath => FindingAction::SurfaceToUser,
AnchorKind::Binary | AnchorKind::ProviderName | AnchorKind::ConfigKey => {
FindingAction::RewordViaUpdate
}
},
}
}
pub fn backtick_spans(line: &str) -> Vec<String> {
let mut out = Vec::new();
let mut rest = line;
while let Some(open) = rest.find('`') {
let after = &rest[open + 1..];
match after.find('`') {
Some(close) => {
out.push(after[..close].to_string());
rest = &after[close + 1..];
}
None => break,
}
}
out
}
fn command_like(span: &str, line: &str) -> bool {
let low = line.to_ascii_lowercase();
if low.contains("$ ") {
return true;
}
if low.contains("--") && !span.starts_with("--") && line.contains(span) {
return true;
}
let clean_span = span.trim_start_matches("$ ");
for cue in [
"run ", "via ", "using ", "execute ", "invoke ", "call ", "> ",
] {
let mut from = 0usize;
while let Some(rel) = low[from..].find(cue) {
let pos = from + rel;
from = pos + cue.len();
if pos > 0 && low.as_bytes()[pos - 1].is_ascii_alphanumeric() {
continue;
}
let tail = line[pos + cue.len()..].trim_start().trim_start_matches('`');
if tail.starts_with(clean_span) {
return true;
}
}
}
false
}
pub fn anchor_kind(span: &str, line: &str) -> Option<AnchorKind> {
let s = span.trim();
if s.is_empty() {
return None;
}
if s.starts_with("<<") || (s.starts_with('<') && s.ends_with(">>")) {
return None;
}
if s.starts_with('[') && s.ends_with(']') {
return Some(AnchorKind::ConfigKey);
}
let body = s.strip_prefix('/').unwrap_or(s);
let looks_path = body.contains('/')
|| ["*.md", "*.rs", "*.toml", "*.json", "*.yaml", "*.yml"]
.iter()
.any(|ext| body.ends_with(&ext[1..]));
if looks_path {
return Some(AnchorKind::FilePath);
}
if s.starts_with('/') {
return None;
}
if command_like(s, line) {
return Some(AnchorKind::Binary);
}
None
}
pub fn provider_mentions(line: &str) -> Vec<String> {
let mut out = Vec::new();
let low = line.to_ascii_lowercase();
for cue in ["provider ", "providers "] {
let mut from = 0usize;
while let Some(rel) = low[from..].find(cue) {
let start = from + rel + cue.len();
let tail = &low[start..];
let tail = tail.trim_start().trim_start_matches(['`', '"', '\'']);
let name: String = tail
.chars()
.take_while(|c| {
c.is_ascii_alphanumeric() || *c == '-' || *c == '_' || *c == '.' || *c == ':'
})
.collect();
let trimmed = name.trim_end_matches(['\'', '"', '`', ',']).to_string();
if !trimmed.is_empty() {
out.push(trimmed);
}
from = start + name.len().max(1);
}
}
out.sort();
out.dedup();
out
}
fn expand_home(p: &Path) -> PathBuf {
let s = p.to_string_lossy();
if let Some(rest) = s.strip_prefix("~/") {
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.unwrap_or_default();
if !home.is_empty() {
return PathBuf::from(home).join(rest);
}
}
p.to_path_buf()
}
pub fn verify_binary(span: &str) -> Verdict {
let clean = span.trim().trim_start_matches("$ ");
let bin = clean.split_whitespace().next().unwrap_or("");
if bin.is_empty() {
return Verdict::Unverifiable;
}
if SHELL_BUILTINS.contains(&bin) {
return Verdict::Ok;
}
if bin.contains('/') {
return Verdict::Unverifiable;
}
let dirs = std::env::var("PATH").unwrap_or_default();
for dir in dirs.split(':') {
if dir.is_empty() {
continue;
}
if let Ok(md) = std::fs::metadata(Path::new(dir).join(bin)) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if md.is_file() && md.permissions().mode() & 0o111 != 0 {
return Verdict::Ok;
}
}
#[cfg(not(unix))]
if md.is_file() {
return Verdict::Ok;
}
}
}
Verdict::Stale
}
pub fn verify_provider(name: &str, config: &Config) -> Verdict {
let trimmed = name.trim();
if trimmed.is_empty() {
return Verdict::Unverifiable;
}
let id = normalize_provider_name(trimmed);
if configured_providers(&config.providers)
.iter()
.any(|(have, _)| *have == id)
{
Verdict::Ok
} else {
Verdict::Stale
}
}
fn full_provider_sentinel() -> serde_json::Value {
let cfg = ProviderConfig {
enabled: true,
api_key: Some("k".into()),
base_url: Some("https://example.invalid".into()),
default_model: Some("m".into()),
models: vec!["m".into()],
force_default: false,
vision_model: Some("v".into()),
generation_model: Some("g".into()),
context_window: Some(1),
endpoint_type: Some("api".into()),
plan: Some("moderato".into()),
reasoning_effort: Some("max".into()),
voice: Some("voice".into()),
model: Some("tts-m".into()),
enable_thinking: Some(true),
cache_enabled: Some(true),
cache_ttl: Some(300),
};
serde_json::to_value(cfg).expect("ProviderConfig serializes")
}
fn full_fallback_sentinel() -> serde_json::Value {
let cfg = FallbackProviderConfig {
enabled: true,
provider: Some("p".into()),
providers: vec!["p".into()],
vision: vec!["p".into()],
};
serde_json::to_value(cfg).expect("FallbackProviderConfig serializes")
}
fn union_provider_leaves(
node: &mut serde_json::Value,
exemplar: &serde_json::Map<String, serde_json::Value>,
) {
let Some(obj) = node.as_object_mut() else {
return; };
for (_k, v) in obj.iter_mut() {
union_provider_leaves(v, exemplar);
}
for (k, v) in exemplar {
obj.entry(k.clone()).or_insert_with(|| v.clone());
}
}
fn schema_witness() -> &'static serde_json::Value {
static WITNESS: OnceLock<serde_json::Value> = OnceLock::new();
WITNESS.get_or_init(|| {
let mut witness =
serde_json::to_value(Config::default()).expect("Config::default serializes");
if let Some(agent) = witness.get_mut("agent").and_then(|a| a.as_object_mut()) {
agent.insert("eval_providers".into(), serde_json::json!(["sentinel"]));
agent.insert("redact_group".into(), serde_json::json!(true));
agent.insert("redact_dm".into(), serde_json::json!(false));
}
if let Some(memory) = witness.get_mut("memory").and_then(|m| m.as_object_mut())
&& let Ok(embedding) = serde_json::to_value(EmbeddingConfig::default())
{
memory.insert("embedding".into(), embedding);
}
let exemplar = full_provider_sentinel();
let exemplar_map = exemplar.as_object().cloned().unwrap_or_default();
if let Some(providers) = witness.get_mut("providers").and_then(|p| p.as_object_mut()) {
providers.insert("fallback".into(), full_fallback_sentinel());
providers.insert(
"web_search".into(),
serde_json::json!({ "exa": exemplar, "brave": exemplar }),
);
providers.insert("image".into(), serde_json::json!({ "gemini": exemplar }));
for (k, v) in providers.iter_mut() {
if matches!(k.as_str(), "custom" | "stt" | "tts") {
continue; }
if v.is_null() {
*v = exemplar.clone();
} else {
union_provider_leaves(v, &exemplar_map);
}
}
}
witness
})
}
pub fn verify_config_key(span: &str) -> Verdict {
let inner = span.trim().trim_start_matches('[').trim_end_matches(']');
if inner.is_empty() {
return Verdict::Unverifiable;
}
let normalized = inner
.strip_prefix("gateway")
.map(|rest| format!("a2a{rest}"));
let path: &str = normalized.as_deref().unwrap_or(inner);
let mut node = schema_witness();
let segments: Vec<&str> = path
.split('.')
.map(str::trim)
.filter(|s| !s.is_empty())
.collect();
for (i, seg) in segments.iter().enumerate() {
let last = i + 1 == segments.len();
match node {
serde_json::Value::Object(obj) => {
match obj.get(*seg) {
Some(child) => {
if last {
return Verdict::Ok;
}
node = child;
}
None => {
if obj.is_empty() {
return Verdict::Unverifiable;
}
return Verdict::Stale;
}
}
}
serde_json::Value::Null => return Verdict::Unverifiable,
_ => return Verdict::Unverifiable,
}
}
Verdict::Ok
}
pub fn verify_path(span: &str) -> Verdict {
let s = span.trim();
if s.is_empty() {
return Verdict::Unverifiable;
}
if s.contains(['*', '?', '[']) && !s.starts_with('[') {
return Verdict::Unverifiable;
}
let expanded = expand_home(Path::new(s));
if expanded.is_absolute() {
if expanded.exists() {
Verdict::Ok
} else {
Verdict::Stale
}
} else if expanded.exists() {
Verdict::Ok
} else {
Verdict::Unverifiable
}
}
pub fn scan_brain_files(config: &Config, brain_root: &Path) -> Vec<StaleFinding> {
let mut findings = Vec::new();
for file in crate::memory::BRAIN_FILES {
let path = brain_root.join(file);
let Ok(content) = std::fs::read_to_string(&path) else {
continue;
};
for (idx, raw_line) in content.lines().enumerate() {
if classify_line(raw_line) != LineClass::Prescription {
continue;
}
let spans = backtick_spans(raw_line);
let mentions = provider_mentions(raw_line);
for span in &spans {
let kind = if mentions.iter().any(|m| m.eq_ignore_ascii_case(span.trim())) {
AnchorKind::ProviderName
} else {
match anchor_kind(span, raw_line) {
Some(k) => k,
None => continue,
}
};
findings.push(make_finding(
file,
idx + 1,
raw_line,
span.trim(),
kind,
config,
));
}
for mention in &mentions {
if spans.iter().any(|s| s.trim().eq_ignore_ascii_case(mention)) {
continue;
}
findings.push(make_finding(
file,
idx + 1,
raw_line,
mention,
AnchorKind::ProviderName,
config,
));
}
}
}
findings
}
fn make_finding(
file: &str,
line_no: usize,
raw_line: &str,
anchor: &str,
kind: AnchorKind,
config: &Config,
) -> StaleFinding {
let (verdict, evidence) = match kind {
AnchorKind::FilePath => match verify_path(anchor) {
Verdict::Ok => (Verdict::Ok, "path exists (fs::metadata)".to_string()),
Verdict::Stale => (Verdict::Stale, "path does not exist".to_string()),
Verdict::Unverifiable => (
Verdict::Unverifiable,
"glob pattern / relative path — existence not decidable".to_string(),
),
},
AnchorKind::ConfigKey => match verify_config_key(anchor) {
Verdict::Ok => (Verdict::Ok, "key present in embedded config schema".into()),
Verdict::Stale => (
Verdict::Stale,
"key absent from embedded config schema (compiled types, not *.example)".into(),
),
Verdict::Unverifiable => (
Verdict::Unverifiable,
"map-valued/malformed key path".into(),
),
},
AnchorKind::Binary => match verify_binary(anchor) {
Verdict::Ok => (
Verdict::Ok,
"executable found on PATH (or shell builtin)".into(),
),
Verdict::Stale => (Verdict::Stale, "no executable on PATH".into()),
Verdict::Unverifiable => (Verdict::Unverifiable, "not a decidable binary".into()),
},
AnchorKind::ProviderName => match verify_provider(anchor, config) {
Verdict::Ok => (Verdict::Ok, "provider in configured-provider table".into()),
Verdict::Stale => (
Verdict::Stale,
"provider not in this install's configured-provider table".into(),
),
Verdict::Unverifiable => (Verdict::Unverifiable, "empty provider name".into()),
},
};
StaleFinding {
file: file.to_string(),
line_no,
line: raw_line.to_string(),
anchor: anchor.to_string(),
anchor_kind: kind,
verdict,
action: decide_action(kind, verdict),
evidence,
}
}