Skip to main content

rac_engine/
doctor.rs

1//! Repository health diagnostic (`decided.services.doctor`, v0.23.0 WS3) plus
2//! its two service dependencies with no prior Rust port:
3//! mentioned-but-unlinked reference detection (`decided.services.links`,
4//! ADR-082) and the six injection-style content heuristics (REQ-005).
5//!
6//! Composes: structural validation (invalid-artifact errors), relationship
7//! integrity (upstream codes, upstream severities), the one-hop degree pass
8//! (orphans + high-fan-out hubs), injection-style content flags, unlinked
9//! body references, and git-native suspect-artifact drift (shared with
10//! `decided review` via `review::suspect_drift` — one source of truth).
11//! Exit is non-zero only on an error-severity finding (REQ-007).
12
13use 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    /// A run passes when no error-severity finding is present (REQ-007).
77    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
90/// `diagnose(directory, recursive, hub_threshold)` — all phases in the
91/// oracle's insertion order (validation, relationships, degree, injection,
92/// unlinked, suspect), then one stable sort by
93/// `(severity rank, path, code, problem)`.
94pub 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
117/// One finding per structurally invalid artifact; the problem names the
118/// sorted, deduplicated error codes.
119fn 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
182/// One finding per relationship-integrity issue; intrinsic severity is the
183/// recorded map with doctor's own "error" fallback
184/// (`RELATIONSHIP_SEVERITY.get(code, SEVERITY_ERROR)`).
185fn 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
215/// Relationship finding codes are the upstream constants; map back to
216/// 'static so `DoctorFinding.code` stays a static str across all sources.
217fn 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
237/// Orphans (inbound degree 0) and high-fan-out hubs from one degree pass
238/// over the resolved edges; the orphan definition matches the portfolio's
239/// "never a resolved target" count exactly.
240fn 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; // only resolved (unique, non-self) edges
249        };
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
286/// Suspect-link drift: shared with `decided review` via `review::suspect_drift`
287/// (git recency over the validated relationship graph). Empty outside git.
288fn 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
301// ---------------------------------------------------------------------------
302// Injection-style content heuristics (decided.services.doctor._INJECTION_PATTERNS)
303//
304// Six deterministic Python-`re` patterns, hand-compiled (no regex crate in
305// the workspace): IGNORECASE throughout; `.` never crosses a newline;
306// `\s` includes the CPython extras \x1c-\x1f; `\b` is the Unicode word
307// boundary. Each matcher answers existence only (`pattern.search`).
308// ---------------------------------------------------------------------------
309
310fn is_word(c: char) -> bool {
311    c.is_alphanumeric() || c == '_'
312}
313
314/// Python `\s` (str patterns): Unicode whitespace plus the \x1c-\x1f
315/// information separators CPython counts as space.
316fn is_space(c: char) -> bool {
317    c.is_whitespace() || ('\u{1c}'..='\u{1f}').contains(&c)
318}
319
320/// Case-insensitive char equality against a lowercase ASCII pattern char.
321fn 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
329/// Match a literal (lowercase ASCII, spaces literal) case-insensitively at
330/// `i`; returns the end index.
331fn 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
342/// `\b` before a word-char literal starting at `i`.
343fn lead_boundary(chars: &[char], i: usize) -> bool {
344    i == 0 || !is_word(chars[i - 1])
345}
346
347/// `\b` after a word-char literal ending at `end` (exclusive).
348fn trail_boundary(chars: &[char], end: usize) -> bool {
349    end >= chars.len() || !is_word(chars[end])
350}
351
352/// All `end` positions where one of `words` matches at `i` under the given
353/// boundary requirements. Alternation for existence: every alternative that
354/// fits is a candidate continuation point.
355fn 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
376/// `.{0,max}` gap starts: every j in [from, from+max] reachable without
377/// crossing a newline (`.` has no DOTALL here).
378fn 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
388/// `\s+` from `i`: Some(first index past at least one whitespace run
389/// position) — all lengths ≥ 1 are candidates, returned as a range.
390fn 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        // \byou are now\b
434        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            // \bpretend to be\b
441            if let Some(end) = lit(chars, i, "pretend to be") {
442                if trail_boundary(chars, end) {
443                    return true;
444                }
445            }
446            // \bfrom now on,?\s+you\s+(are|will|must|should|shall)\b
447            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            // \bact as if you\s+(are|were)\b
470            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    // \bas an ai(\s+language)?\s+model\b
484    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            // Without the optional group: \s+model\b
493            if let Some(end) = lit(chars, a, "model") {
494                if trail_boundary(chars, end) {
495                    return true;
496                }
497            }
498            // With it: \s+language\s+model\b
499            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    // ^\s*(system|assistant|developer|tool)\s*: with MULTILINE anchors.
515    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        // \s* is greedy but the role word must start at the first
523        // non-whitespace position after the anchor.
524        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                // G2 carries NO leading \b in the pattern — only trailing.
560                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                // \b(recorded\s+)?(...)\b — with or without the prefix.
580                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
598/// One hand-compiled injection matcher (existence over the char sequence).
599type InjectionMatcher = fn(&[char]) -> bool;
600
601/// Pattern label -> matcher, in the oracle's declaration order; the finding
602/// text joins the SORTED matching labels.
603const 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        // The oracle re-reads the stored text (strict UTF-8; unreadable
616        // files are skipped).
617        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
647// ---------------------------------------------------------------------------
648// Mentioned-but-unlinked reference detection (decided.services.links, ADR-082)
649// ---------------------------------------------------------------------------
650
651/// Normalized relationship-section headings whose lines are declared edges,
652/// not body mentions (`RELATIONSHIP_SECTIONS`).
653const 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
665/// `_CANDIDATE_RE = [0-9A-Za-z]+(?:-[0-9A-Za-z]+)*` — maximal ASCII
666/// alphanumeric runs with single interior hyphens kept.
667fn 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        // (?:-[0-9A-Za-z]+)* — a hyphen joins only when alnum follows.
684        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
695/// `^[A-Za-z]+-\d+$` — the numbered short-alias shape (`adr-074`).
696fn 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
709/// `_preferred_ref(aliases, path)`: shortest numbered alias (stable on
710/// ties, like `sorted(key=len)`), else the filename stem.
711fn 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
720/// `_related_section_for(target_type)` — the display heading.
721fn 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
733/// `detect_unlinked_references` over the shared corpus snapshot: body
734/// mentions resolving to another artifact with no declared resolved edge,
735/// one finding per (source, target), sorted by `(source_path, target_id)`.
736fn 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(&section.heading));
764            if RELATIONSHIP_HEADINGS.contains(&heading.as_str()) {
765                continue; // declared edges, not body mentions
766            }
767            for line in &section.lines {
768                for token in candidate_tokens(line) {
769                    if self_aliases.contains(&py_casefold(&token)) {
770                        continue; // self-reference
771                    }
772                    let result = resolve_in_index(&index, &token);
773                    if result.outcome != OUTCOME_RESOLVED {
774                        continue; // not a unique corpus artifact
775                    }
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; // one finding per (source, target) pair
782                    }
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}