use crate::commands::{validate_directory, STATUS_INVALID};
use crate::identity::path_stem;
use crate::pycompat::{py_casefold, py_repr_str, py_strip};
use crate::relationships::{
corpus_items, relationship_severity, relationships_from_corpus, validate_relationships,
CorpusItem, RelationshipIssue, ISSUE_DUPLICATE_IDENTIFIER, ISSUE_RELATIONSHIP_CYCLE,
};
use crate::resolve::{index_from_items, resolve_in_index, IndexEntry, OUTCOME_RESOLVED};
use crate::review::{drift_problem, suspect_drift};
use crate::validate::py_title;
pub const DEFAULT_HUB_THRESHOLD: i64 = 20;
pub const SEVERITY_ERROR: &str = "error";
pub const SEVERITY_WARNING: &str = "warning";
pub const CODE_INVALID_ARTIFACT: &str = "invalid-artifact";
pub const CODE_ORPHANED_ARTIFACT: &str = "orphaned-artifact";
pub const CODE_HIGH_FAN_OUT_HUB: &str = "high-fan-out-hub";
pub const CODE_INJECTION_CONTENT: &str = "injection-style-content";
pub const CODE_UNLINKED_REFERENCE: &str = "unlinked-reference";
pub const CODE_SUSPECT_ARTIFACT: &str = "suspect-artifact";
const FIX_ORPHAN: &str = "Reference it from a related artifact (a `## Related ...` section), \
or confirm it is intentionally standalone.";
const FIX_HUB: &str = "Consider splitting this artifact or narrowing its relationships so a \
single node is not a traversal bottleneck.";
const FIX_INJECTION: &str = "Review this content; artifact content is untrusted and the trust \
boundary is human PR review (ADR-065). Remove or quote the flagged \
phrasing if it was not intended as literal guidance.";
const FIX_SUSPECT: &str = "Review whether this artifact still reflects the newer target and \
update it if needed. Advisory only \u{2014} RAC changes nothing (ADR-034).";
#[derive(Debug)]
pub struct DoctorFinding {
pub path: String,
pub code: &'static str,
pub severity: &'static str,
pub problem: String,
pub fix: String,
}
pub struct DoctorReport {
pub directory: String,
pub hub_threshold: i64,
pub findings: Vec<DoctorFinding>,
}
impl DoctorReport {
pub fn error_count(&self) -> usize {
self.findings
.iter()
.filter(|f| f.severity == SEVERITY_ERROR)
.count()
}
pub fn warning_count(&self) -> usize {
self.findings
.iter()
.filter(|f| f.severity == SEVERITY_WARNING)
.count()
}
pub fn ok(&self) -> bool {
self.error_count() == 0
}
}
fn severity_rank(severity: &str) -> i64 {
if severity == SEVERITY_ERROR {
0
} else {
1
}
}
pub fn diagnose(directory: &str, recursive: bool, hub_threshold: i64) -> DoctorReport {
let items = corpus_items(directory, recursive);
let mut findings: Vec<DoctorFinding> = Vec::new();
findings.extend(validation_findings(directory, recursive));
findings.extend(relationship_findings(directory, recursive));
findings.extend(degree_findings(&items, hub_threshold));
findings.extend(injection_findings(&items));
findings.extend(unlinked_reference_findings(&items));
findings.extend(suspect_artifact_findings(directory, &items));
findings.sort_by(|a, b| {
severity_rank(a.severity)
.cmp(&severity_rank(b.severity))
.then_with(|| a.path.cmp(&b.path))
.then_with(|| a.code.cmp(b.code))
.then_with(|| a.problem.cmp(&b.problem))
});
DoctorReport {
directory: directory.to_string(),
hub_threshold,
findings,
}
}
fn validation_findings(directory: &str, recursive: bool) -> Vec<DoctorFinding> {
let result = validate_directory(directory, recursive);
let mut findings = Vec::new();
for file in &result.files {
if file.status != STATUS_INVALID {
continue;
}
let mut codes: Vec<&str> = file
.issues
.iter()
.filter(|i| i.severity == SEVERITY_ERROR)
.map(|i| i.code.as_str())
.collect();
codes.sort_unstable();
codes.dedup();
findings.push(DoctorFinding {
path: file.path.clone(),
code: CODE_INVALID_ARTIFACT,
severity: SEVERITY_ERROR,
problem: format!("structural validation failed: {}", codes.join(", ")),
fix: format!("Run: decided validate {}", file.path),
});
}
findings
}
fn issue_path(issue: &RelationshipIssue) -> String {
if let Some(source) = &issue.source_path {
if !source.is_empty() {
return source.clone();
}
}
if let Some(paths) = &issue.paths {
if let Some(first) = paths.first() {
return first.clone();
}
}
String::new()
}
fn issue_problem(issue: &RelationshipIssue) -> String {
if issue.code == ISSUE_DUPLICATE_IDENTIFIER {
return format!(
"duplicate artifact identifier {} in: {}",
py_repr_str(issue.identifier.as_deref().unwrap_or("")),
issue.paths.clone().unwrap_or_default().join(", ")
);
}
if issue.code == ISSUE_RELATIONSHIP_CYCLE {
return format!(
"relationship cycle in {}: {}",
py_repr_str(issue.relationship.as_deref().unwrap_or("")),
issue.paths.clone().unwrap_or_default().join(" -> ")
);
}
format!(
"{} via {} -> {}",
issue.code,
py_repr_str(issue.relationship.as_deref().unwrap_or("")),
py_repr_str(issue.target.as_deref().unwrap_or(""))
)
}
fn relationship_findings(directory: &str, recursive: bool) -> Vec<DoctorFinding> {
let result = validate_relationships(directory, recursive);
result
.issues
.iter()
.map(|issue| {
let known = matches!(
relationship_severity(&issue.code),
"error" | "warning"
);
let severity = if known {
if relationship_severity(&issue.code) == "error" {
SEVERITY_ERROR
} else {
SEVERITY_WARNING
}
} else {
SEVERITY_ERROR
};
DoctorFinding {
path: issue_path(issue),
code: issue_code_static(&issue.code),
severity,
problem: issue_problem(issue),
fix: format!("Run: decided relationships {directory} --validate"),
}
})
.collect()
}
fn issue_code_static(code: &str) -> &'static str {
use crate::relationships as r;
for known in [
r::ISSUE_DUPLICATE_IDENTIFIER,
r::ISSUE_TARGET_NOT_FOUND,
r::ISSUE_TARGET_AMBIGUOUS,
r::ISSUE_SELF_REFERENCE,
r::ISSUE_EDGE_UNSUPPORTED,
r::ISSUE_TARGET_SUPERSEDED,
r::ISSUE_TARGET_TYPE_MISMATCH,
r::ISSUE_RELATIONSHIP_CYCLE,
r::ISSUE_SCOPE_TARGET_NOT_FOUND,
] {
if code == known {
return known;
}
}
"unknown-relationship-issue"
}
fn degree_findings(items: &[CorpusItem], hub_threshold: i64) -> Vec<DoctorFinding> {
let known: Vec<&CorpusItem> = items.iter().filter(|i| i.spec.is_some()).collect();
let mut inbound: std::collections::HashMap<&str, i64> =
known.iter().map(|i| (i.path.as_str(), 0)).collect();
let mut outbound: std::collections::HashMap<&str, i64> =
known.iter().map(|i| (i.path.as_str(), 0)).collect();
for rel in relationships_from_corpus(items) {
let Some(resolved) = &rel.resolved_path else {
continue; };
if let Some(count) = inbound.get_mut(resolved.as_str()) {
*count += 1;
}
if let Some(count) = outbound.get_mut(rel.source_path.as_str()) {
*count += 1;
}
}
let mut findings = Vec::new();
for item in &known {
let path = item.path.as_str();
let in_degree = *inbound.get(path).unwrap_or(&0);
let degree = in_degree + *outbound.get(path).unwrap_or(&0);
if in_degree == 0 {
findings.push(DoctorFinding {
path: path.to_string(),
code: CODE_ORPHANED_ARTIFACT,
severity: SEVERITY_WARNING,
problem: "no other artifact references this one (orphaned)".to_string(),
fix: FIX_ORPHAN.to_string(),
});
}
if degree > hub_threshold {
findings.push(DoctorFinding {
path: path.to_string(),
code: CODE_HIGH_FAN_OUT_HUB,
severity: SEVERITY_WARNING,
problem: format!(
"high-fan-out hub: {degree} resolved relationship edges (threshold {hub_threshold})"
),
fix: FIX_HUB.to_string(),
});
}
}
findings
}
fn suspect_artifact_findings(directory: &str, items: &[CorpusItem]) -> Vec<DoctorFinding> {
suspect_drift(directory, items)
.into_iter()
.map(|record| DoctorFinding {
path: record.source_path.clone(),
code: CODE_SUSPECT_ARTIFACT,
severity: SEVERITY_WARNING,
problem: drift_problem(&record),
fix: FIX_SUSPECT.to_string(),
})
.collect()
}
fn is_word(c: char) -> bool {
c.is_alphanumeric() || c == '_'
}
fn is_space(c: char) -> bool {
c.is_whitespace() || ('\u{1c}'..='\u{1f}').contains(&c)
}
fn ci_eq(c: char, lower: char) -> bool {
if c == lower {
return true;
}
let mut it = c.to_lowercase();
it.next() == Some(lower) && it.next().is_none()
}
fn lit(chars: &[char], i: usize, text: &str) -> Option<usize> {
let mut k = i;
for lc in text.chars() {
if !ci_eq(*chars.get(k)?, lc) {
return None;
}
k += 1;
}
Some(k)
}
fn lead_boundary(chars: &[char], i: usize) -> bool {
i == 0 || !is_word(chars[i - 1])
}
fn trail_boundary(chars: &[char], end: usize) -> bool {
end >= chars.len() || !is_word(chars[end])
}
fn word_alt_ends(
chars: &[char],
i: usize,
words: &[&str],
lead: bool,
trail: bool,
) -> Vec<usize> {
if lead && !lead_boundary(chars, i) {
return Vec::new();
}
let mut ends = Vec::new();
for w in words {
if let Some(end) = lit(chars, i, w) {
if !trail || trail_boundary(chars, end) {
ends.push(end);
}
}
}
ends
}
fn gap_positions(chars: &[char], from: usize, max: usize) -> Vec<usize> {
let mut out = vec![from];
let mut j = from;
while j < chars.len() && j - from < max && chars[j] != '\n' {
j += 1;
out.push(j);
}
out
}
fn ws_run(chars: &[char], i: usize) -> Vec<usize> {
let mut out = Vec::new();
let mut j = i;
while j < chars.len() && is_space(chars[j]) {
j += 1;
out.push(j);
}
out
}
fn p_instruction_override(chars: &[char]) -> bool {
const G1: [&str; 5] = ["ignore", "disregard", "forget", "override", "bypass"];
const G2: [&str; 8] = [
"previous", "prior", "above", "earlier", "preceding", "all", "the system", "your",
];
const G3: [&str; 8] = [
"instruction",
"instructions",
"prompt",
"directive",
"directives",
"rule",
"rules",
"context",
];
for i in 0..chars.len() {
for e1 in word_alt_ends(chars, i, &G1, true, true) {
for j in gap_positions(chars, e1, 40) {
for e2 in word_alt_ends(chars, j, &G2, true, true) {
for k in gap_positions(chars, e2, 20) {
if G3.iter().any(|w| lit(chars, k, w).is_some()) {
return true;
}
}
}
}
}
}
false
}
fn p_role_reassignment(chars: &[char]) -> bool {
for i in 0..chars.len() {
if lead_boundary(chars, i) {
if let Some(end) = lit(chars, i, "you are now") {
if trail_boundary(chars, end) {
return true;
}
}
if let Some(end) = lit(chars, i, "pretend to be") {
if trail_boundary(chars, end) {
return true;
}
}
if let Some(mut e) = lit(chars, i, "from now on") {
if chars.get(e) == Some(&',') {
e += 1;
}
for a in ws_run(chars, e) {
if let Some(b) = lit(chars, a, "you") {
for c in ws_run(chars, b) {
if !word_alt_ends(
chars,
c,
&["are", "will", "must", "should", "shall"],
false,
true,
)
.is_empty()
{
return true;
}
}
}
}
}
if let Some(e) = lit(chars, i, "act as if you") {
for a in ws_run(chars, e) {
if !word_alt_ends(chars, a, &["are", "were"], false, true).is_empty() {
return true;
}
}
}
}
}
false
}
fn p_ai_impersonation(chars: &[char]) -> bool {
for i in 0..chars.len() {
if !lead_boundary(chars, i) {
continue;
}
let Some(e) = lit(chars, i, "as an ai") else {
continue;
};
for a in ws_run(chars, e) {
if let Some(end) = lit(chars, a, "model") {
if trail_boundary(chars, end) {
return true;
}
}
if let Some(l) = lit(chars, a, "language") {
for b in ws_run(chars, l) {
if let Some(end) = lit(chars, b, "model") {
if trail_boundary(chars, end) {
return true;
}
}
}
}
}
}
false
}
fn p_chat_role_injection(chars: &[char]) -> bool {
for anchor in std::iter::once(0).chain(
chars
.iter()
.enumerate()
.filter(|(_, c)| **c == '\n')
.map(|(i, _)| i + 1),
) {
let mut p = anchor;
while p < chars.len() && is_space(chars[p]) {
p += 1;
}
for role in ["system", "assistant", "developer", "tool"] {
if let Some(mut e) = lit(chars, p, role) {
while e < chars.len() && is_space(chars[e]) {
e += 1;
}
if chars.get(e) == Some(&':') {
return true;
}
}
}
}
false
}
fn p_conceal_from_user(chars: &[char]) -> bool {
const G1: [&str; 4] = ["do not", "don't", "never", "without"];
const G2: [&str; 9] = [
"tell",
"telling",
"inform",
"informing",
"mention",
"mentioning",
"reveal",
"revealing",
"notify",
];
const G3: [&str; 3] = ["the user", "them", "anyone"];
for i in 0..chars.len() {
for e1 in word_alt_ends(chars, i, &G1, true, true) {
for j in gap_positions(chars, e1, 30) {
for e2 in word_alt_ends(chars, j, &G2, false, true) {
for k in gap_positions(chars, e2, 20) {
if !word_alt_ends(chars, k, &G3, true, true).is_empty() {
return true;
}
}
}
}
}
}
false
}
fn p_decision_steering(chars: &[char]) -> bool {
const G1: [&str; 5] = ["ignore", "disregard", "override", "bypass", "violate"];
const G3: [&str; 5] = ["decision", "decisions", "adr", "requirement", "policy"];
for i in 0..chars.len() {
for e1 in word_alt_ends(chars, i, &G1, true, true) {
for j in gap_positions(chars, e1, 40) {
if !word_alt_ends(chars, j, &G3, true, true).is_empty() {
return true;
}
if lead_boundary(chars, j) {
if let Some(e) = lit(chars, j, "recorded") {
for a in ws_run(chars, e) {
if !word_alt_ends(chars, a, &G3, false, true).is_empty() {
return true;
}
}
}
}
}
}
}
false
}
type InjectionMatcher = fn(&[char]) -> bool;
const INJECTION_PATTERNS: [(&str, InjectionMatcher); 6] = [
("instruction-override", p_instruction_override),
("role-reassignment", p_role_reassignment),
("ai-impersonation", p_ai_impersonation),
("chat-role-injection", p_chat_role_injection),
("conceal-from-user", p_conceal_from_user),
("decision-steering", p_decision_steering),
];
fn injection_findings(items: &[CorpusItem]) -> Vec<DoctorFinding> {
let mut findings = Vec::new();
for item in items {
let Ok(bytes) = std::fs::read(&item.path) else {
continue;
};
let Ok(text) = String::from_utf8(bytes) else {
continue;
};
let chars: Vec<char> = text.chars().collect();
let mut matched: Vec<&str> = INJECTION_PATTERNS
.iter()
.filter(|(_, matcher)| matcher(&chars))
.map(|(label, _)| *label)
.collect();
if matched.is_empty() {
continue;
}
matched.sort_unstable();
findings.push(DoctorFinding {
path: item.path.clone(),
code: CODE_INJECTION_CONTENT,
severity: SEVERITY_WARNING,
problem: format!(
"instruction-like / injection-style content for review ({})",
matched.join(", ")
),
fix: FIX_INJECTION.to_string(),
});
}
findings
}
const RELATIONSHIP_HEADINGS: [&str; 9] = [
"applies to",
"related decisions",
"related designs",
"related prompts",
"related requirements",
"related roadmaps",
"related tickets",
"supersedes",
"verified by",
];
fn candidate_tokens(line: &str) -> Vec<String> {
fn alnum(c: char) -> bool {
c.is_ascii_alphanumeric()
}
let chars: Vec<char> = line.chars().collect();
let mut tokens = Vec::new();
let mut i = 0;
while i < chars.len() {
if !alnum(chars[i]) {
i += 1;
continue;
}
let start = i;
while i < chars.len() && alnum(chars[i]) {
i += 1;
}
while i + 1 < chars.len() && chars[i] == '-' && alnum(chars[i + 1]) {
i += 1;
while i < chars.len() && alnum(chars[i]) {
i += 1;
}
}
tokens.push(chars[start..i].iter().collect());
}
tokens
}
fn is_numbered_ref(alias: &str) -> bool {
let Some(dash) = alias.find('-') else {
return false;
};
let (letters, rest) = alias.split_at(dash);
let digits = &rest[1..];
!letters.is_empty()
&& letters.chars().all(|c| c.is_ascii_alphabetic())
&& !digits.is_empty()
&& digits.chars().all(|c| c.is_ascii_digit())
&& !digits.contains('-')
}
fn preferred_ref(aliases: &[String], path: &str) -> String {
let mut numbered: Vec<&String> = aliases.iter().filter(|a| is_numbered_ref(a)).collect();
numbered.sort_by_key(|a| a.chars().count());
match numbered.first() {
Some(alias) => (*alias).clone(),
None => path_stem(path),
}
}
fn related_section_for(target_type: &str) -> String {
py_title(&format!("related {target_type}s"))
}
struct UnlinkedReference {
source_path: String,
target_id: String,
matched_token: String,
related_section: String,
suggested_line: String,
}
fn detect_unlinked_references(items: &[CorpusItem]) -> Vec<UnlinkedReference> {
let index: Vec<IndexEntry> = index_from_items(items);
let by_path: std::collections::HashMap<&str, &IndexEntry> =
index.iter().map(|e| (e.path.as_str(), e)).collect();
let mut declared: std::collections::HashMap<&str, std::collections::HashSet<String>> =
std::collections::HashMap::new();
for rel in relationships_from_corpus(items) {
if let Some(resolved) = rel.resolved_path {
if let Some(item) = items.iter().find(|i| i.path == rel.source_path) {
declared
.entry(item.path.as_str())
.or_default()
.insert(resolved);
}
}
}
let mut findings: Vec<UnlinkedReference> = Vec::new();
for source in &index {
let self_aliases: std::collections::HashSet<String> =
source.aliases.iter().map(|a| py_casefold(a)).collect();
let empty = std::collections::HashSet::new();
let already = declared.get(source.path.as_str()).unwrap_or(&empty);
let mut seen_targets: std::collections::HashSet<String> =
std::collections::HashSet::new();
for section in &source.search_sections {
let heading = py_casefold(py_strip(§ion.heading));
if RELATIONSHIP_HEADINGS.contains(&heading.as_str()) {
continue; }
for line in §ion.lines {
for token in candidate_tokens(line) {
if self_aliases.contains(&py_casefold(&token)) {
continue; }
let result = resolve_in_index(&index, &token);
if result.outcome != OUTCOME_RESOLVED {
continue; }
let target = result.artifact.expect("resolved implies artifact");
if target.path == source.path || already.contains(&target.path) {
continue;
}
if !seen_targets.insert(target.path.clone()) {
continue; }
let target_entry = by_path[target.path.as_str()];
findings.push(UnlinkedReference {
source_path: source.path.clone(),
target_id: target.id.clone(),
matched_token: token,
related_section: related_section_for(&target.artifact_type),
suggested_line: format!(
"- {}",
preferred_ref(&target_entry.aliases, &target.path)
),
});
}
}
}
}
findings.sort_by(|a, b| {
a.source_path
.cmp(&b.source_path)
.then_with(|| a.target_id.cmp(&b.target_id))
});
findings
}
fn unlinked_reference_findings(items: &[CorpusItem]) -> Vec<DoctorFinding> {
detect_unlinked_references(items)
.into_iter()
.map(|r| DoctorFinding {
path: r.source_path,
code: CODE_UNLINKED_REFERENCE,
severity: SEVERITY_WARNING,
problem: format!(
"body references {} but declares no {} link to it",
r.matched_token, r.related_section
),
fix: format!(
"Add `{}` under `## {}` if the link is intended \u{2014} a suggestion to \
review; RAC writes no edge (ADR-082).",
r.suggested_line, r.related_section
),
})
.collect()
}