1use crate::commands::{validate_directory, STATUS_INVALID};
14use crate::identity::path_stem;
15use crate::pycompat::{py_casefold, py_repr_str, py_strip};
16use crate::relationships::{
17 corpus_items, relationship_severity, relationships_from_corpus, validate_relationships,
18 CorpusItem, RelationshipIssue, ISSUE_DUPLICATE_IDENTIFIER, ISSUE_RELATIONSHIP_CYCLE,
19};
20use crate::resolve::{index_from_items, resolve_in_index, IndexEntry, OUTCOME_RESOLVED};
21use crate::review::{drift_problem, suspect_drift};
22use crate::validate::py_title;
23
24pub const DEFAULT_HUB_THRESHOLD: i64 = 20;
25
26pub const SEVERITY_ERROR: &str = "error";
27pub const SEVERITY_WARNING: &str = "warning";
28
29pub const CODE_INVALID_ARTIFACT: &str = "invalid-artifact";
30pub const CODE_ORPHANED_ARTIFACT: &str = "orphaned-artifact";
31pub const CODE_HIGH_FAN_OUT_HUB: &str = "high-fan-out-hub";
32pub const CODE_INJECTION_CONTENT: &str = "injection-style-content";
33pub const CODE_UNLINKED_REFERENCE: &str = "unlinked-reference";
34pub const CODE_SUSPECT_ARTIFACT: &str = "suspect-artifact";
35
36const FIX_ORPHAN: &str = "Reference it from a related artifact (a `## Related ...` section), \
37 or confirm it is intentionally standalone.";
38const FIX_HUB: &str = "Consider splitting this artifact or narrowing its relationships so a \
39 single node is not a traversal bottleneck.";
40const FIX_INJECTION: &str = "Review this content; artifact content is untrusted and the trust \
41 boundary is human PR review (ADR-065). Remove or quote the flagged \
42 phrasing if it was not intended as literal guidance.";
43const FIX_SUSPECT: &str = "Review whether this artifact still reflects the newer target and \
44 update it if needed. Advisory only \u{2014} RAC changes nothing (ADR-034).";
45
46#[derive(Debug)]
47pub struct DoctorFinding {
48 pub path: String,
49 pub code: &'static str,
50 pub severity: &'static str,
51 pub problem: String,
52 pub fix: String,
53}
54
55pub struct DoctorReport {
56 pub directory: String,
57 pub hub_threshold: i64,
58 pub findings: Vec<DoctorFinding>,
59}
60
61impl DoctorReport {
62 pub fn error_count(&self) -> usize {
63 self.findings
64 .iter()
65 .filter(|f| f.severity == SEVERITY_ERROR)
66 .count()
67 }
68
69 pub fn warning_count(&self) -> usize {
70 self.findings
71 .iter()
72 .filter(|f| f.severity == SEVERITY_WARNING)
73 .count()
74 }
75
76 pub fn ok(&self) -> bool {
78 self.error_count() == 0
79 }
80}
81
82fn severity_rank(severity: &str) -> i64 {
83 if severity == SEVERITY_ERROR {
84 0
85 } else {
86 1
87 }
88}
89
90pub fn diagnose(directory: &str, recursive: bool, hub_threshold: i64) -> DoctorReport {
95 let items = corpus_items(directory, recursive);
96 let mut findings: Vec<DoctorFinding> = Vec::new();
97 findings.extend(validation_findings(directory, recursive));
98 findings.extend(relationship_findings(directory, recursive));
99 findings.extend(degree_findings(&items, hub_threshold));
100 findings.extend(injection_findings(&items));
101 findings.extend(unlinked_reference_findings(&items));
102 findings.extend(suspect_artifact_findings(directory, &items));
103 findings.sort_by(|a, b| {
104 severity_rank(a.severity)
105 .cmp(&severity_rank(b.severity))
106 .then_with(|| a.path.cmp(&b.path))
107 .then_with(|| a.code.cmp(b.code))
108 .then_with(|| a.problem.cmp(&b.problem))
109 });
110 DoctorReport {
111 directory: directory.to_string(),
112 hub_threshold,
113 findings,
114 }
115}
116
117fn validation_findings(directory: &str, recursive: bool) -> Vec<DoctorFinding> {
120 let result = validate_directory(directory, recursive);
121 let mut findings = Vec::new();
122 for file in &result.files {
123 if file.status != STATUS_INVALID {
124 continue;
125 }
126 let mut codes: Vec<&str> = file
127 .issues
128 .iter()
129 .filter(|i| i.severity == SEVERITY_ERROR)
130 .map(|i| i.code.as_str())
131 .collect();
132 codes.sort_unstable();
133 codes.dedup();
134 findings.push(DoctorFinding {
135 path: file.path.clone(),
136 code: CODE_INVALID_ARTIFACT,
137 severity: SEVERITY_ERROR,
138 problem: format!("structural validation failed: {}", codes.join(", ")),
139 fix: format!("Run: decided validate {}", file.path),
140 });
141 }
142 findings
143}
144
145fn issue_path(issue: &RelationshipIssue) -> String {
146 if let Some(source) = &issue.source_path {
147 if !source.is_empty() {
148 return source.clone();
149 }
150 }
151 if let Some(paths) = &issue.paths {
152 if let Some(first) = paths.first() {
153 return first.clone();
154 }
155 }
156 String::new()
157}
158
159fn issue_problem(issue: &RelationshipIssue) -> String {
160 if issue.code == ISSUE_DUPLICATE_IDENTIFIER {
161 return format!(
162 "duplicate artifact identifier {} in: {}",
163 py_repr_str(issue.identifier.as_deref().unwrap_or("")),
164 issue.paths.clone().unwrap_or_default().join(", ")
165 );
166 }
167 if issue.code == ISSUE_RELATIONSHIP_CYCLE {
168 return format!(
169 "relationship cycle in {}: {}",
170 py_repr_str(issue.relationship.as_deref().unwrap_or("")),
171 issue.paths.clone().unwrap_or_default().join(" -> ")
172 );
173 }
174 format!(
175 "{} via {} -> {}",
176 issue.code,
177 py_repr_str(issue.relationship.as_deref().unwrap_or("")),
178 py_repr_str(issue.target.as_deref().unwrap_or(""))
179 )
180}
181
182fn relationship_findings(directory: &str, recursive: bool) -> Vec<DoctorFinding> {
186 let result = validate_relationships(directory, recursive);
187 result
188 .issues
189 .iter()
190 .map(|issue| {
191 let known = matches!(
192 relationship_severity(&issue.code),
193 "error" | "warning"
194 );
195 let severity = if known {
196 if relationship_severity(&issue.code) == "error" {
197 SEVERITY_ERROR
198 } else {
199 SEVERITY_WARNING
200 }
201 } else {
202 SEVERITY_ERROR
203 };
204 DoctorFinding {
205 path: issue_path(issue),
206 code: issue_code_static(&issue.code),
207 severity,
208 problem: issue_problem(issue),
209 fix: format!("Run: decided relationships {directory} --validate"),
210 }
211 })
212 .collect()
213}
214
215fn issue_code_static(code: &str) -> &'static str {
218 use crate::relationships as r;
219 for known in [
220 r::ISSUE_DUPLICATE_IDENTIFIER,
221 r::ISSUE_TARGET_NOT_FOUND,
222 r::ISSUE_TARGET_AMBIGUOUS,
223 r::ISSUE_SELF_REFERENCE,
224 r::ISSUE_EDGE_UNSUPPORTED,
225 r::ISSUE_TARGET_SUPERSEDED,
226 r::ISSUE_TARGET_TYPE_MISMATCH,
227 r::ISSUE_RELATIONSHIP_CYCLE,
228 r::ISSUE_SCOPE_TARGET_NOT_FOUND,
229 ] {
230 if code == known {
231 return known;
232 }
233 }
234 "unknown-relationship-issue"
235}
236
237fn degree_findings(items: &[CorpusItem], hub_threshold: i64) -> Vec<DoctorFinding> {
241 let known: Vec<&CorpusItem> = items.iter().filter(|i| i.spec.is_some()).collect();
242 let mut inbound: std::collections::HashMap<&str, i64> =
243 known.iter().map(|i| (i.path.as_str(), 0)).collect();
244 let mut outbound: std::collections::HashMap<&str, i64> =
245 known.iter().map(|i| (i.path.as_str(), 0)).collect();
246 for rel in relationships_from_corpus(items) {
247 let Some(resolved) = &rel.resolved_path else {
248 continue; };
250 if let Some(count) = inbound.get_mut(resolved.as_str()) {
251 *count += 1;
252 }
253 if let Some(count) = outbound.get_mut(rel.source_path.as_str()) {
254 *count += 1;
255 }
256 }
257 let mut findings = Vec::new();
258 for item in &known {
259 let path = item.path.as_str();
260 let in_degree = *inbound.get(path).unwrap_or(&0);
261 let degree = in_degree + *outbound.get(path).unwrap_or(&0);
262 if in_degree == 0 {
263 findings.push(DoctorFinding {
264 path: path.to_string(),
265 code: CODE_ORPHANED_ARTIFACT,
266 severity: SEVERITY_WARNING,
267 problem: "no other artifact references this one (orphaned)".to_string(),
268 fix: FIX_ORPHAN.to_string(),
269 });
270 }
271 if degree > hub_threshold {
272 findings.push(DoctorFinding {
273 path: path.to_string(),
274 code: CODE_HIGH_FAN_OUT_HUB,
275 severity: SEVERITY_WARNING,
276 problem: format!(
277 "high-fan-out hub: {degree} resolved relationship edges (threshold {hub_threshold})"
278 ),
279 fix: FIX_HUB.to_string(),
280 });
281 }
282 }
283 findings
284}
285
286fn suspect_artifact_findings(directory: &str, items: &[CorpusItem]) -> Vec<DoctorFinding> {
289 suspect_drift(directory, items)
290 .into_iter()
291 .map(|record| DoctorFinding {
292 path: record.source_path.clone(),
293 code: CODE_SUSPECT_ARTIFACT,
294 severity: SEVERITY_WARNING,
295 problem: drift_problem(&record),
296 fix: FIX_SUSPECT.to_string(),
297 })
298 .collect()
299}
300
301fn is_word(c: char) -> bool {
311 c.is_alphanumeric() || c == '_'
312}
313
314fn is_space(c: char) -> bool {
317 c.is_whitespace() || ('\u{1c}'..='\u{1f}').contains(&c)
318}
319
320fn ci_eq(c: char, lower: char) -> bool {
322 if c == lower {
323 return true;
324 }
325 let mut it = c.to_lowercase();
326 it.next() == Some(lower) && it.next().is_none()
327}
328
329fn lit(chars: &[char], i: usize, text: &str) -> Option<usize> {
332 let mut k = i;
333 for lc in text.chars() {
334 if !ci_eq(*chars.get(k)?, lc) {
335 return None;
336 }
337 k += 1;
338 }
339 Some(k)
340}
341
342fn lead_boundary(chars: &[char], i: usize) -> bool {
344 i == 0 || !is_word(chars[i - 1])
345}
346
347fn trail_boundary(chars: &[char], end: usize) -> bool {
349 end >= chars.len() || !is_word(chars[end])
350}
351
352fn word_alt_ends(
356 chars: &[char],
357 i: usize,
358 words: &[&str],
359 lead: bool,
360 trail: bool,
361) -> Vec<usize> {
362 if lead && !lead_boundary(chars, i) {
363 return Vec::new();
364 }
365 let mut ends = Vec::new();
366 for w in words {
367 if let Some(end) = lit(chars, i, w) {
368 if !trail || trail_boundary(chars, end) {
369 ends.push(end);
370 }
371 }
372 }
373 ends
374}
375
376fn gap_positions(chars: &[char], from: usize, max: usize) -> Vec<usize> {
379 let mut out = vec![from];
380 let mut j = from;
381 while j < chars.len() && j - from < max && chars[j] != '\n' {
382 j += 1;
383 out.push(j);
384 }
385 out
386}
387
388fn ws_run(chars: &[char], i: usize) -> Vec<usize> {
391 let mut out = Vec::new();
392 let mut j = i;
393 while j < chars.len() && is_space(chars[j]) {
394 j += 1;
395 out.push(j);
396 }
397 out
398}
399
400fn p_instruction_override(chars: &[char]) -> bool {
401 const G1: [&str; 5] = ["ignore", "disregard", "forget", "override", "bypass"];
402 const G2: [&str; 8] = [
403 "previous", "prior", "above", "earlier", "preceding", "all", "the system", "your",
404 ];
405 const G3: [&str; 8] = [
406 "instruction",
407 "instructions",
408 "prompt",
409 "directive",
410 "directives",
411 "rule",
412 "rules",
413 "context",
414 ];
415 for i in 0..chars.len() {
416 for e1 in word_alt_ends(chars, i, &G1, true, true) {
417 for j in gap_positions(chars, e1, 40) {
418 for e2 in word_alt_ends(chars, j, &G2, true, true) {
419 for k in gap_positions(chars, e2, 20) {
420 if G3.iter().any(|w| lit(chars, k, w).is_some()) {
421 return true;
422 }
423 }
424 }
425 }
426 }
427 }
428 false
429}
430
431fn p_role_reassignment(chars: &[char]) -> bool {
432 for i in 0..chars.len() {
433 if lead_boundary(chars, i) {
435 if let Some(end) = lit(chars, i, "you are now") {
436 if trail_boundary(chars, end) {
437 return true;
438 }
439 }
440 if let Some(end) = lit(chars, i, "pretend to be") {
442 if trail_boundary(chars, end) {
443 return true;
444 }
445 }
446 if let Some(mut e) = lit(chars, i, "from now on") {
448 if chars.get(e) == Some(&',') {
449 e += 1;
450 }
451 for a in ws_run(chars, e) {
452 if let Some(b) = lit(chars, a, "you") {
453 for c in ws_run(chars, b) {
454 if !word_alt_ends(
455 chars,
456 c,
457 &["are", "will", "must", "should", "shall"],
458 false,
459 true,
460 )
461 .is_empty()
462 {
463 return true;
464 }
465 }
466 }
467 }
468 }
469 if let Some(e) = lit(chars, i, "act as if you") {
471 for a in ws_run(chars, e) {
472 if !word_alt_ends(chars, a, &["are", "were"], false, true).is_empty() {
473 return true;
474 }
475 }
476 }
477 }
478 }
479 false
480}
481
482fn p_ai_impersonation(chars: &[char]) -> bool {
483 for i in 0..chars.len() {
485 if !lead_boundary(chars, i) {
486 continue;
487 }
488 let Some(e) = lit(chars, i, "as an ai") else {
489 continue;
490 };
491 for a in ws_run(chars, e) {
492 if let Some(end) = lit(chars, a, "model") {
494 if trail_boundary(chars, end) {
495 return true;
496 }
497 }
498 if let Some(l) = lit(chars, a, "language") {
500 for b in ws_run(chars, l) {
501 if let Some(end) = lit(chars, b, "model") {
502 if trail_boundary(chars, end) {
503 return true;
504 }
505 }
506 }
507 }
508 }
509 }
510 false
511}
512
513fn p_chat_role_injection(chars: &[char]) -> bool {
514 for anchor in std::iter::once(0).chain(
516 chars
517 .iter()
518 .enumerate()
519 .filter(|(_, c)| **c == '\n')
520 .map(|(i, _)| i + 1),
521 ) {
522 let mut p = anchor;
525 while p < chars.len() && is_space(chars[p]) {
526 p += 1;
527 }
528 for role in ["system", "assistant", "developer", "tool"] {
529 if let Some(mut e) = lit(chars, p, role) {
530 while e < chars.len() && is_space(chars[e]) {
531 e += 1;
532 }
533 if chars.get(e) == Some(&':') {
534 return true;
535 }
536 }
537 }
538 }
539 false
540}
541
542fn p_conceal_from_user(chars: &[char]) -> bool {
543 const G1: [&str; 4] = ["do not", "don't", "never", "without"];
544 const G2: [&str; 9] = [
545 "tell",
546 "telling",
547 "inform",
548 "informing",
549 "mention",
550 "mentioning",
551 "reveal",
552 "revealing",
553 "notify",
554 ];
555 const G3: [&str; 3] = ["the user", "them", "anyone"];
556 for i in 0..chars.len() {
557 for e1 in word_alt_ends(chars, i, &G1, true, true) {
558 for j in gap_positions(chars, e1, 30) {
559 for e2 in word_alt_ends(chars, j, &G2, false, true) {
561 for k in gap_positions(chars, e2, 20) {
562 if !word_alt_ends(chars, k, &G3, true, true).is_empty() {
563 return true;
564 }
565 }
566 }
567 }
568 }
569 }
570 false
571}
572
573fn p_decision_steering(chars: &[char]) -> bool {
574 const G1: [&str; 5] = ["ignore", "disregard", "override", "bypass", "violate"];
575 const G3: [&str; 5] = ["decision", "decisions", "adr", "requirement", "policy"];
576 for i in 0..chars.len() {
577 for e1 in word_alt_ends(chars, i, &G1, true, true) {
578 for j in gap_positions(chars, e1, 40) {
579 if !word_alt_ends(chars, j, &G3, true, true).is_empty() {
581 return true;
582 }
583 if lead_boundary(chars, j) {
584 if let Some(e) = lit(chars, j, "recorded") {
585 for a in ws_run(chars, e) {
586 if !word_alt_ends(chars, a, &G3, false, true).is_empty() {
587 return true;
588 }
589 }
590 }
591 }
592 }
593 }
594 }
595 false
596}
597
598type InjectionMatcher = fn(&[char]) -> bool;
600
601const INJECTION_PATTERNS: [(&str, InjectionMatcher); 6] = [
604 ("instruction-override", p_instruction_override),
605 ("role-reassignment", p_role_reassignment),
606 ("ai-impersonation", p_ai_impersonation),
607 ("chat-role-injection", p_chat_role_injection),
608 ("conceal-from-user", p_conceal_from_user),
609 ("decision-steering", p_decision_steering),
610];
611
612fn injection_findings(items: &[CorpusItem]) -> Vec<DoctorFinding> {
613 let mut findings = Vec::new();
614 for item in items {
615 let Ok(bytes) = std::fs::read(&item.path) else {
618 continue;
619 };
620 let Ok(text) = String::from_utf8(bytes) else {
621 continue;
622 };
623 let chars: Vec<char> = text.chars().collect();
624 let mut matched: Vec<&str> = INJECTION_PATTERNS
625 .iter()
626 .filter(|(_, matcher)| matcher(&chars))
627 .map(|(label, _)| *label)
628 .collect();
629 if matched.is_empty() {
630 continue;
631 }
632 matched.sort_unstable();
633 findings.push(DoctorFinding {
634 path: item.path.clone(),
635 code: CODE_INJECTION_CONTENT,
636 severity: SEVERITY_WARNING,
637 problem: format!(
638 "instruction-like / injection-style content for review ({})",
639 matched.join(", ")
640 ),
641 fix: FIX_INJECTION.to_string(),
642 });
643 }
644 findings
645}
646
647const RELATIONSHIP_HEADINGS: [&str; 9] = [
654 "applies to",
655 "related decisions",
656 "related designs",
657 "related prompts",
658 "related requirements",
659 "related roadmaps",
660 "related tickets",
661 "supersedes",
662 "verified by",
663];
664
665fn candidate_tokens(line: &str) -> Vec<String> {
668 fn alnum(c: char) -> bool {
669 c.is_ascii_alphanumeric()
670 }
671 let chars: Vec<char> = line.chars().collect();
672 let mut tokens = Vec::new();
673 let mut i = 0;
674 while i < chars.len() {
675 if !alnum(chars[i]) {
676 i += 1;
677 continue;
678 }
679 let start = i;
680 while i < chars.len() && alnum(chars[i]) {
681 i += 1;
682 }
683 while i + 1 < chars.len() && chars[i] == '-' && alnum(chars[i + 1]) {
685 i += 1;
686 while i < chars.len() && alnum(chars[i]) {
687 i += 1;
688 }
689 }
690 tokens.push(chars[start..i].iter().collect());
691 }
692 tokens
693}
694
695fn is_numbered_ref(alias: &str) -> bool {
697 let Some(dash) = alias.find('-') else {
698 return false;
699 };
700 let (letters, rest) = alias.split_at(dash);
701 let digits = &rest[1..];
702 !letters.is_empty()
703 && letters.chars().all(|c| c.is_ascii_alphabetic())
704 && !digits.is_empty()
705 && digits.chars().all(|c| c.is_ascii_digit())
706 && !digits.contains('-')
707}
708
709fn preferred_ref(aliases: &[String], path: &str) -> String {
712 let mut numbered: Vec<&String> = aliases.iter().filter(|a| is_numbered_ref(a)).collect();
713 numbered.sort_by_key(|a| a.chars().count());
714 match numbered.first() {
715 Some(alias) => (*alias).clone(),
716 None => path_stem(path),
717 }
718}
719
720fn related_section_for(target_type: &str) -> String {
722 py_title(&format!("related {target_type}s"))
723}
724
725struct UnlinkedReference {
726 source_path: String,
727 target_id: String,
728 matched_token: String,
729 related_section: String,
730 suggested_line: String,
731}
732
733fn detect_unlinked_references(items: &[CorpusItem]) -> Vec<UnlinkedReference> {
737 let index: Vec<IndexEntry> = index_from_items(items);
738 let by_path: std::collections::HashMap<&str, &IndexEntry> =
739 index.iter().map(|e| (e.path.as_str(), e)).collect();
740
741 let mut declared: std::collections::HashMap<&str, std::collections::HashSet<String>> =
742 std::collections::HashMap::new();
743 for rel in relationships_from_corpus(items) {
744 if let Some(resolved) = rel.resolved_path {
745 if let Some(item) = items.iter().find(|i| i.path == rel.source_path) {
746 declared
747 .entry(item.path.as_str())
748 .or_default()
749 .insert(resolved);
750 }
751 }
752 }
753
754 let mut findings: Vec<UnlinkedReference> = Vec::new();
755 for source in &index {
756 let self_aliases: std::collections::HashSet<String> =
757 source.aliases.iter().map(|a| py_casefold(a)).collect();
758 let empty = std::collections::HashSet::new();
759 let already = declared.get(source.path.as_str()).unwrap_or(&empty);
760 let mut seen_targets: std::collections::HashSet<String> =
761 std::collections::HashSet::new();
762 for section in &source.search_sections {
763 let heading = py_casefold(py_strip(§ion.heading));
764 if RELATIONSHIP_HEADINGS.contains(&heading.as_str()) {
765 continue; }
767 for line in §ion.lines {
768 for token in candidate_tokens(line) {
769 if self_aliases.contains(&py_casefold(&token)) {
770 continue; }
772 let result = resolve_in_index(&index, &token);
773 if result.outcome != OUTCOME_RESOLVED {
774 continue; }
776 let target = result.artifact.expect("resolved implies artifact");
777 if target.path == source.path || already.contains(&target.path) {
778 continue;
779 }
780 if !seen_targets.insert(target.path.clone()) {
781 continue; }
783 let target_entry = by_path[target.path.as_str()];
784 findings.push(UnlinkedReference {
785 source_path: source.path.clone(),
786 target_id: target.id.clone(),
787 matched_token: token,
788 related_section: related_section_for(&target.artifact_type),
789 suggested_line: format!(
790 "- {}",
791 preferred_ref(&target_entry.aliases, &target.path)
792 ),
793 });
794 }
795 }
796 }
797 }
798 findings.sort_by(|a, b| {
799 a.source_path
800 .cmp(&b.source_path)
801 .then_with(|| a.target_id.cmp(&b.target_id))
802 });
803 findings
804}
805
806fn unlinked_reference_findings(items: &[CorpusItem]) -> Vec<DoctorFinding> {
807 detect_unlinked_references(items)
808 .into_iter()
809 .map(|r| DoctorFinding {
810 path: r.source_path,
811 code: CODE_UNLINKED_REFERENCE,
812 severity: SEVERITY_WARNING,
813 problem: format!(
814 "body references {} but declares no {} link to it",
815 r.matched_token, r.related_section
816 ),
817 fix: format!(
818 "Add `{}` under `## {}` if the link is intended \u{2014} a suggestion to \
819 review; RAC writes no edge (ADR-082).",
820 r.suggested_line, r.related_section
821 ),
822 })
823 .collect()
824}