Skip to main content

rac_engine/
relationships.rs

1//! Relationship extraction and validation (`decided.services.references`,
2//! `decided.services.relationships`, `decided.core.relationship_types`), per
3//! PORT-CONTRACT.d/05.
4//!
5//! Covers the surfaces the validate/corpus parity gate needs:
6//! `validate_relationships` (directory), `validate_relationships_file`, and
7//! `validate_document_against_corpus` (the `decided validate - --corpus` seam).
8
9use std::collections::HashMap;
10use std::path::PathBuf;
11
12use crate::classify::classify;
13use crate::identity::{artifact_identifier, artifact_identifiers, strip_list_marker};
14use crate::parse::{parse_file, Artifact};
15use crate::pycompat::{py_casefold, py_splitlines, py_strip};
16use crate::spec::{snake, spec_for, ArtifactSpec, RELATIONSHIP_SECTIONS};
17use crate::validate::repository_root;
18use crate::walk::find_markdown_files;
19
20// Stable issue codes (JSON contract).
21pub const ISSUE_DUPLICATE_IDENTIFIER: &str = "duplicate-artifact-identifier";
22pub const ISSUE_TARGET_NOT_FOUND: &str = "relationship-target-not-found";
23pub const ISSUE_TARGET_AMBIGUOUS: &str = "relationship-target-ambiguous";
24pub const ISSUE_SELF_REFERENCE: &str = "relationship-self-reference";
25pub const ISSUE_EDGE_UNSUPPORTED: &str = "relationship-edge-unsupported";
26pub const ISSUE_TARGET_SUPERSEDED: &str = "relationship-target-superseded";
27pub const ISSUE_TARGET_TYPE_MISMATCH: &str = "relationship-target-type-mismatch";
28pub const ISSUE_RELATIONSHIP_CYCLE: &str = "relationship-cycle";
29pub const ISSUE_SCOPE_TARGET_NOT_FOUND: &str = "applies-to-target-not-found";
30
31/// Canonical intrinsic severity per finding (`RELATIONSHIP_SEVERITY`).
32pub fn relationship_severity(code: &str) -> &'static str {
33    match code {
34        ISSUE_TARGET_NOT_FOUND
35        | ISSUE_TARGET_AMBIGUOUS
36        | ISSUE_TARGET_TYPE_MISMATCH
37        | ISSUE_RELATIONSHIP_CYCLE
38        | ISSUE_DUPLICATE_IDENTIFIER
39        | ISSUE_SCOPE_TARGET_NOT_FOUND => "error",
40        ISSUE_TARGET_SUPERSEDED | ISSUE_SELF_REFERENCE | ISSUE_EDGE_UNSUPPORTED => "warning",
41        _ => "warning",
42    }
43}
44
45// ---------------------------------------------------------------------------
46// Edge registry (decided.core.relationship_types)
47// ---------------------------------------------------------------------------
48
49#[derive(Debug, Clone)]
50pub struct EdgeSpec {
51    pub name: &'static str,
52    pub range: &'static [&'static str],
53    pub acyclic: bool,
54    pub forbids_target_status: bool,
55    pub external: bool,
56    pub filesystem_scoped: bool,
57    /// `supersedes`/`verified_by`/`applies_to` are directional; `related_*` and
58    /// `related_tickets` are not.
59    pub directional: bool,
60    /// Whether an external edge's target lives in the repository's configured
61    /// ticketing provider (`related_tickets` only, ADR-088).
62    pub external_provider: bool,
63}
64
65/// `edge_spec(name)` over the built-in registry.
66pub fn edge_spec(name: &str) -> Option<&'static EdgeSpec> {
67    static REGISTRY: [EdgeSpec; 9] = [
68        EdgeSpec {
69            name: "related_requirements",
70            range: &["requirement"],
71            acyclic: false,
72            forbids_target_status: true,
73            external: false,
74            filesystem_scoped: false,
75            directional: false,
76            external_provider: false,
77        },
78        EdgeSpec {
79            name: "related_decisions",
80            range: &["decision"],
81            acyclic: false,
82            forbids_target_status: true,
83            external: false,
84            filesystem_scoped: false,
85            directional: false,
86            external_provider: false,
87        },
88        EdgeSpec {
89            name: "related_roadmaps",
90            range: &["roadmap"],
91            acyclic: false,
92            forbids_target_status: true,
93            external: false,
94            filesystem_scoped: false,
95            directional: false,
96            external_provider: false,
97        },
98        EdgeSpec {
99            name: "related_prompts",
100            range: &["prompt"],
101            acyclic: false,
102            forbids_target_status: true,
103            external: false,
104            filesystem_scoped: false,
105            directional: false,
106            external_provider: false,
107        },
108        EdgeSpec {
109            name: "related_designs",
110            range: &["design"],
111            acyclic: false,
112            forbids_target_status: true,
113            external: false,
114            filesystem_scoped: false,
115            directional: false,
116            external_provider: false,
117        },
118        EdgeSpec {
119            name: "supersedes",
120            range: &["decision"],
121            acyclic: true,
122            forbids_target_status: false,
123            external: false,
124            filesystem_scoped: false,
125            directional: true,
126            external_provider: false,
127        },
128        EdgeSpec {
129            name: "related_tickets",
130            range: &[],
131            acyclic: false,
132            forbids_target_status: true,
133            external: true,
134            filesystem_scoped: false,
135            directional: false,
136            external_provider: true,
137        },
138        EdgeSpec {
139            name: "verified_by",
140            range: &[],
141            acyclic: false,
142            forbids_target_status: true,
143            external: true,
144            filesystem_scoped: false,
145            directional: true,
146            external_provider: false,
147        },
148        EdgeSpec {
149            name: "applies_to",
150            range: &[],
151            acyclic: false,
152            forbids_target_status: true,
153            external: true,
154            filesystem_scoped: true,
155            directional: true,
156            external_provider: false,
157        },
158    ];
159    REGISTRY.iter().find(|e| e.name == name)
160}
161
162// ---------------------------------------------------------------------------
163// Reference extraction (decided.services.references)
164// ---------------------------------------------------------------------------
165
166/// `parse_references(body)`: one reference per non-empty line, one leading
167/// well-formed list marker stripped.
168pub fn parse_references(body: &str) -> Vec<String> {
169    let mut refs = Vec::new();
170    for line in py_splitlines(body) {
171        let stripped = py_strip(line);
172        if stripped.is_empty() {
173            continue;
174        }
175        refs.push(py_strip(strip_list_marker(stripped)).to_string());
176    }
177    refs
178}
179
180/// `extract_relationships_full(product, spec)`: `{snake_section -> refs}` in
181/// `spec.optional` order, including `supersedes`.
182pub fn extract_relationships_full(
183    artifact: &Artifact,
184    spec: &ArtifactSpec,
185) -> Vec<(String, Vec<String>)> {
186    collect_relationships(artifact, spec, true)
187}
188
189/// `extract_relationships(product, spec)` — the `decided inspect` extractor:
190/// identical to the full variant except `supersedes` is excluded (it stays a
191/// top-level scalar in inspect output, ADR-007).
192pub fn extract_relationships(
193    artifact: &Artifact,
194    spec: &ArtifactSpec,
195) -> Vec<(String, Vec<String>)> {
196    collect_relationships(artifact, spec, false)
197}
198
199/// `_collect(product, spec, allowed)` — the single core behind the two
200/// extractors: `{snake_section -> refs}` in `spec.optional` order, only
201/// sections present with at least one parsed reference.
202fn collect_relationships(
203    artifact: &Artifact,
204    spec: &ArtifactSpec,
205    include_supersedes: bool,
206) -> Vec<(String, Vec<String>)> {
207    let mut out = Vec::new();
208    for section in &spec.optional {
209        if !RELATIONSHIP_SECTIONS.iter().any(|(name, _)| name == section) {
210            continue;
211        }
212        if !include_supersedes && section == "supersedes" {
213            continue;
214        }
215        let Some(body) = artifact.section(section) else {
216            continue;
217        };
218        if body.is_empty() {
219            continue;
220        }
221        let refs = parse_references(body);
222        if !refs.is_empty() {
223            out.push((snake(section), refs));
224        }
225    }
226    out
227}
228
229/// `unsupported_relationship_sections(product, spec)`: canonical-order
230/// relationship sections declared with refs but absent from `spec.optional`.
231pub fn unsupported_relationship_sections(artifact: &Artifact, spec: &ArtifactSpec) -> Vec<String> {
232    let mut out = Vec::new();
233    for (section, _) in RELATIONSHIP_SECTIONS.iter() {
234        if spec.optional.iter().any(|s| s == section) {
235            continue;
236        }
237        let Some(body) = artifact.section(section) else {
238            continue;
239        };
240        if !body.is_empty() && !parse_references(body).is_empty() {
241            out.push(section.to_string());
242        }
243    }
244    out
245}
246
247/// `_is_retired_artifact(product, spec)`.
248fn is_retired(artifact: &Artifact, spec: &ArtifactSpec) -> bool {
249    if spec.retired_status.is_empty() {
250        return false;
251    }
252    let Some(body) = artifact.section("status") else {
253        return false;
254    };
255    if body.is_empty() {
256        return false;
257    }
258    let ff = py_casefold(crate::pycompat::first_nonempty_line(body));
259    spec.retired_status.iter().any(|s| py_casefold(s) == ff)
260}
261
262// ---------------------------------------------------------------------------
263// Compact validation rows + resolution index (ADR-108)
264// ---------------------------------------------------------------------------
265
266#[derive(Debug, Clone)]
267pub struct ValidationRow {
268    pub path: String,
269    /// Artifact type name, or None for an Unknown/untyped document.
270    pub spec_name: Option<String>,
271    pub canonical_id: String,
272    pub identifiers: Vec<String>,
273    pub retired: bool,
274    /// Canonical (space) section names.
275    pub unsupported_sections: Vec<String>,
276    /// `(snake_section, refs)` in schema order.
277    pub edges: Vec<(String, Vec<String>)>,
278}
279
280pub fn validation_row(
281    path: &str,
282    artifact: &Artifact,
283    spec: Option<&ArtifactSpec>,
284) -> ValidationRow {
285    let identifiers = artifact_identifiers(artifact, spec, path);
286    let canonical_id = artifact_identifier(artifact, spec, path);
287    match spec {
288        None => ValidationRow {
289            path: path.to_string(),
290            spec_name: None,
291            canonical_id,
292            identifiers,
293            retired: false,
294            unsupported_sections: Vec::new(),
295            edges: Vec::new(),
296        },
297        Some(spec) => ValidationRow {
298            path: path.to_string(),
299            spec_name: Some(spec.name.clone()),
300            canonical_id,
301            identifiers,
302            retired: is_retired(artifact, spec),
303            unsupported_sections: unsupported_relationship_sections(artifact, spec),
304            edges: extract_relationships_full(artifact, spec),
305        },
306    }
307}
308
309/// Insertion-ordered `{casefold(ident) -> [(path, ident)]}` index.
310pub struct ResolutionIndex {
311    order: Vec<String>,
312    map: HashMap<String, Vec<(String, String)>>,
313}
314
315impl ResolutionIndex {
316    fn new() -> Self {
317        ResolutionIndex {
318            order: Vec::new(),
319            map: HashMap::new(),
320        }
321    }
322
323    fn insert(&mut self, key: String, value: (String, String)) {
324        match self.map.get_mut(&key) {
325            Some(v) => v.push(value),
326            None => {
327                self.map.insert(key.clone(), vec![value]);
328                self.order.push(key);
329            }
330        }
331    }
332
333    pub fn get(&self, key: &str) -> &[(String, String)] {
334        self.map.get(key).map(|v| v.as_slice()).unwrap_or(&[])
335    }
336
337    fn values(&self) -> impl Iterator<Item = &Vec<(String, String)>> {
338        self.order.iter().map(|k| &self.map[k])
339    }
340}
341
342pub fn resolution_index_from_rows(rows: &[ValidationRow]) -> ResolutionIndex {
343    let mut index = ResolutionIndex::new();
344    for row in rows {
345        for ident in &row.identifiers {
346            index.insert(py_casefold(ident), (row.path.clone(), ident.clone()));
347        }
348    }
349    index
350}
351
352// ---------------------------------------------------------------------------
353// Findings model
354// ---------------------------------------------------------------------------
355
356#[derive(Debug, Clone)]
357pub struct RelationshipIssue {
358    pub code: String,
359    pub source_path: Option<String>,
360    pub relationship: Option<String>,
361    pub target: Option<String>,
362    pub identifier: Option<String>,
363    pub paths: Option<Vec<String>>,
364}
365
366impl RelationshipIssue {
367    fn reference(code: &str, source_path: &str, relationship: &str, target: &str) -> Self {
368        RelationshipIssue {
369            code: code.to_string(),
370            source_path: Some(source_path.to_string()),
371            relationship: Some(relationship.to_string()),
372            target: Some(target.to_string()),
373            identifier: None,
374            paths: None,
375        }
376    }
377}
378
379#[derive(Debug)]
380pub struct RelationshipValidation {
381    pub directory: String,
382    pub recursive: bool,
383    pub relationships_checked: usize,
384    pub issues: Vec<RelationshipIssue>,
385}
386
387impl RelationshipValidation {
388    pub fn ok(&self) -> bool {
389        self.issues.is_empty()
390    }
391}
392
393// ---------------------------------------------------------------------------
394// Scope entries (decided.services.scope_paths)
395// ---------------------------------------------------------------------------
396
397/// `classify_scope_entry(entry)` -> "glob" | "path" | "component".
398pub(crate) fn classify_scope_entry(entry: &str) -> &'static str {
399    if entry.contains('*') || entry.contains('?') || entry.contains('[') {
400        "glob"
401    } else if entry.contains('/') {
402        "path"
403    } else {
404        "component"
405    }
406}
407
408/// `normalized_scope_path(entry)` — POSIX repo-relative form, or None.
409pub(crate) fn normalized_scope_path(entry: &str) -> Option<String> {
410    let text = py_strip(entry);
411    if text.is_empty() || text.starts_with('/') {
412        return None;
413    }
414    let mut parts: Vec<&str> = Vec::new();
415    for part in text.split('/').filter(|p| !p.is_empty()) {
416        if part == "." {
417            continue;
418        }
419        if part == ".." {
420            return None;
421        }
422        parts.push(part);
423    }
424    if parts.is_empty() {
425        None
426    } else {
427        Some(parts.join("/"))
428    }
429}
430
431// ---------------------------------------------------------------------------
432// validation_from_rows — the gate core
433// ---------------------------------------------------------------------------
434
435fn resolved_unique<'a>(
436    index: &'a ResolutionIndex,
437    reference: &str,
438    source_path: &str,
439) -> Option<&'a str> {
440    let targets = index.get(&py_casefold(reference));
441    if targets.len() != 1 || targets[0].0 == source_path {
442        return None;
443    }
444    Some(&targets[0].0)
445}
446
447/// Outcome of resolving one internal reference against the index: checked
448/// empty -> not found, then multiple -> ambiguous, then same-path -> self,
449/// else uniquely resolved. Shared by the issue and `Relationship` loops.
450enum ReferenceResolution<'a> {
451    Resolved(&'a str),
452    NotFound,
453    Ambiguous,
454    SelfRef,
455}
456
457fn classify_reference<'a>(
458    index: &'a ResolutionIndex,
459    reference: &str,
460    source_path: &str,
461) -> ReferenceResolution<'a> {
462    let targets = index.get(&py_casefold(reference));
463    if targets.is_empty() {
464        ReferenceResolution::NotFound
465    } else if targets.len() > 1 {
466        ReferenceResolution::Ambiguous
467    } else if targets[0].0 == source_path {
468        ReferenceResolution::SelfRef
469    } else {
470        ReferenceResolution::Resolved(&targets[0].0)
471    }
472}
473
474/// `_resolve_references(rows, index)` -> `(checked, issues)`.
475fn resolve_references(
476    rows: &[ValidationRow],
477    index: &ResolutionIndex,
478) -> (usize, Vec<RelationshipIssue>) {
479    let (checked, issues, _) = resolve_references_full(rows, index);
480    (checked, issues)
481}
482
483/// Tarjan SCC over the sorted-adjacency graph; components of size > 1,
484/// each sorted, ordered by first element.
485fn cyclic_components(adjacency: &[(String, Vec<String>)]) -> Vec<Vec<String>> {
486    let adj: HashMap<&str, &Vec<String>> =
487        adjacency.iter().map(|(k, v)| (k.as_str(), v)).collect();
488    let mut nodes: Vec<&str> = adjacency
489        .iter()
490        .flat_map(|(k, vs)| std::iter::once(k.as_str()).chain(vs.iter().map(|v| v.as_str())))
491        .collect();
492    nodes.sort();
493    nodes.dedup();
494
495    struct State<'a> {
496        indices: HashMap<&'a str, usize>,
497        lowlink: HashMap<&'a str, usize>,
498        on_stack: std::collections::HashSet<&'a str>,
499        stack: Vec<&'a str>,
500        counter: usize,
501        components: Vec<Vec<String>>,
502    }
503
504    fn strongconnect<'a>(
505        v: &'a str,
506        adj: &HashMap<&'a str, &'a Vec<String>>,
507        st: &mut State<'a>,
508    ) {
509        st.indices.insert(v, st.counter);
510        st.lowlink.insert(v, st.counter);
511        st.counter += 1;
512        st.stack.push(v);
513        st.on_stack.insert(v);
514        if let Some(neighbors) = adj.get(v) {
515            for w in neighbors.iter() {
516                let w = w.as_str();
517                if !st.indices.contains_key(w) {
518                    strongconnect(w, adj, st);
519                    let lw = st.lowlink[w];
520                    let lv = st.lowlink[v];
521                    st.lowlink.insert(v, lv.min(lw));
522                } else if st.on_stack.contains(w) {
523                    let iw = st.indices[w];
524                    let lv = st.lowlink[v];
525                    st.lowlink.insert(v, lv.min(iw));
526                }
527            }
528        }
529        if st.lowlink[v] == st.indices[v] {
530            let mut component: Vec<String> = Vec::new();
531            loop {
532                let w = st.stack.pop().expect("stack nonempty");
533                st.on_stack.remove(w);
534                component.push(w.to_string());
535                if w == v {
536                    break;
537                }
538            }
539            if component.len() > 1 {
540                component.sort();
541                st.components.push(component);
542            }
543        }
544    }
545
546    let mut st = State {
547        indices: HashMap::new(),
548        lowlink: HashMap::new(),
549        on_stack: std::collections::HashSet::new(),
550        stack: Vec::new(),
551        counter: 0,
552        components: Vec::new(),
553    };
554    for node in &nodes {
555        if !st.indices.contains_key(node) {
556            strongconnect(node, &adj, &mut st);
557        }
558    }
559    st.components.sort_by(|a, b| a[0].cmp(&b[0]));
560    st.components
561}
562
563fn cycle_issues(rows: &[ValidationRow], index: &ResolutionIndex) -> Vec<RelationshipIssue> {
564    // Sorted acyclic edge kinds — today only `supersedes`.
565    let mut issues = Vec::new();
566    for kind in ["supersedes"] {
567        // `_acyclic_adjacency`: {source -> sorted unique resolved non-self targets}.
568        let mut adjacency: Vec<(String, Vec<String>)> = Vec::new();
569        for row in rows {
570            if row.spec_name.is_none() {
571                continue;
572            }
573            let refs = row
574                .edges
575                .iter()
576                .find(|(s, _)| s == kind)
577                .map(|(_, r)| r.as_slice())
578                .unwrap_or(&[]);
579            let mut targets: Vec<String> = Vec::new();
580            for reference in refs {
581                if let Some(t) = resolved_unique(index, reference, &row.path) {
582                    if !targets.iter().any(|x| x == t) {
583                        targets.push(t.to_string());
584                    }
585                }
586            }
587            if !targets.is_empty() {
588                targets.sort();
589                adjacency.push((row.path.clone(), targets));
590            }
591        }
592        for component in cyclic_components(&adjacency) {
593            issues.push(RelationshipIssue {
594                code: ISSUE_RELATIONSHIP_CYCLE.to_string(),
595                source_path: None,
596                relationship: Some(kind.to_string()),
597                target: None,
598                identifier: None,
599                paths: Some(component),
600            });
601        }
602    }
603    issues
604}
605
606fn scope_validation_issues(directory: &str, rows: &[ValidationRow]) -> Vec<RelationshipIssue> {
607    let root: PathBuf = repository_root(directory);
608    let mut issues = Vec::new();
609    for row in rows {
610        if row.spec_name.is_none() {
611            continue;
612        }
613        for (section, refs) in &row.edges {
614            let Some(edge) = edge_spec(section) else {
615                continue;
616            };
617            if !edge.filesystem_scoped {
618                continue;
619            }
620            for reference in refs {
621                if classify_scope_entry(reference) != "path" {
622                    continue;
623                }
624                if let Some(normalized) = normalized_scope_path(reference) {
625                    if root.join(&normalized).exists() {
626                        continue;
627                    }
628                }
629                issues.push(RelationshipIssue::reference(
630                    ISSUE_SCOPE_TARGET_NOT_FOUND,
631                    &row.path,
632                    section,
633                    reference,
634                ));
635            }
636        }
637    }
638    issues
639}
640
641/// `validation_from_rows(directory, rows, recursive)` — the single gate core.
642pub fn validation_from_rows(
643    directory: &str,
644    rows: &[ValidationRow],
645    recursive: bool,
646) -> RelationshipValidation {
647    let mut issues: Vec<RelationshipIssue> = Vec::new();
648
649    // Duplicate identifiers first, sorted by display identifier (casefold).
650    let mut ident_index = ResolutionIndex::new();
651    for row in rows {
652        ident_index.insert(
653            py_casefold(&row.canonical_id),
654            (row.path.clone(), row.canonical_id.clone()),
655        );
656    }
657    let mut duplicates: Vec<(String, Vec<String>)> = Vec::new();
658    for entries in ident_index.values() {
659        if entries.len() > 1 {
660            let display = entries
661                .iter()
662                .min_by(|a, b| a.0.cmp(&b.0))
663                .expect("nonempty")
664                .1
665                .clone();
666            let mut paths: Vec<String> = entries.iter().map(|(p, _)| p.clone()).collect();
667            paths.sort();
668            duplicates.push((display, paths));
669        }
670    }
671    duplicates.sort_by_cached_key(|a| py_casefold(&a.0));
672    for (display, dup_paths) in duplicates {
673        issues.push(RelationshipIssue {
674            code: ISSUE_DUPLICATE_IDENTIFIER.to_string(),
675            source_path: None,
676            relationship: None,
677            target: None,
678            identifier: Some(display),
679            paths: Some(dup_paths),
680        });
681    }
682
683    // Edge-legality: unsupported declared sections (canonical order per row).
684    for row in rows {
685        if row.spec_name.is_none() {
686            continue;
687        }
688        for section in &row.unsupported_sections {
689            issues.push(RelationshipIssue {
690                code: ISSUE_EDGE_UNSUPPORTED.to_string(),
691                source_path: Some(row.path.clone()),
692                relationship: Some(snake(section)),
693                target: None,
694                identifier: None,
695                paths: None,
696            });
697        }
698    }
699
700    let index = resolution_index_from_rows(rows);
701    let by_path: HashMap<&str, &ValidationRow> =
702        rows.iter().map(|r| (r.path.as_str(), r)).collect();
703
704    // Range violations.
705    for row in rows {
706        if row.spec_name.is_none() {
707            continue;
708        }
709        for (section, refs) in &row.edges {
710            let Some(edge) = edge_spec(section) else {
711                continue;
712            };
713            if edge.external {
714                continue;
715            }
716            for reference in refs {
717                let Some(target) = resolved_unique(&index, reference, &row.path) else {
718                    continue;
719                };
720                let Some(target_spec) = by_path[target].spec_name.as_deref() else {
721                    continue;
722                };
723                if !edge.range.contains(&target_spec) {
724                    issues.push(RelationshipIssue::reference(
725                        ISSUE_TARGET_TYPE_MISMATCH,
726                        &row.path,
727                        section,
728                        reference,
729                    ));
730                }
731            }
732        }
733    }
734
735    // Status-consistency: live source -> retired target.
736    for row in rows {
737        if row.spec_name.is_none() || row.retired {
738            continue;
739        }
740        for (section, refs) in &row.edges {
741            let Some(edge) = edge_spec(section) else {
742                continue;
743            };
744            if edge.external || !edge.forbids_target_status {
745                continue;
746            }
747            for reference in refs {
748                let Some(target) = resolved_unique(&index, reference, &row.path) else {
749                    continue;
750                };
751                if by_path[target].retired {
752                    issues.push(RelationshipIssue::reference(
753                        ISSUE_TARGET_SUPERSEDED,
754                        &row.path,
755                        section,
756                        reference,
757                    ));
758                }
759            }
760        }
761    }
762
763    // Acyclicity.
764    issues.extend(cycle_issues(rows, &index));
765
766    // Referential integrity.
767    let (checked, ref_issues) = resolve_references(rows, &index);
768    issues.extend(ref_issues);
769
770    // Code-scope existence (appended last).
771    issues.extend(scope_validation_issues(directory, rows));
772
773    RelationshipValidation {
774        directory: directory.to_string(),
775        recursive,
776        relationships_checked: checked,
777        issues,
778    }
779}
780
781// ---------------------------------------------------------------------------
782// Repository-level relationship inspection (non-validate report)
783// ---------------------------------------------------------------------------
784
785/// One artifact's relationships in a report (`ArtifactRelationships`).
786#[derive(Debug, Clone)]
787pub struct ArtifactRelationships {
788    pub path: String,
789    pub type_name: String,
790    /// `(snake_section, refs)` in `spec.optional` order.
791    pub relationships: Vec<(String, Vec<String>)>,
792}
793
794/// `RelationshipReport` (non-validate inspection).
795#[derive(Debug)]
796pub struct RelationshipReport {
797    pub directory: String,
798    pub recursive: bool,
799    pub total_files: usize,
800    pub artifacts: Vec<ArtifactRelationships>,
801    /// `{casefold(ref) -> "Title (type · id)"}` for uniquely-resolved refs.
802    /// Insertion-ordered (first-seen wins); presentation-only, never in JSON.
803    pub labels: HashMap<String, String>,
804}
805
806impl RelationshipReport {
807    pub fn artifacts_with_relationships(&self) -> usize {
808        self.artifacts.len()
809    }
810
811    /// References per relationship type, canonical order, zero types omitted.
812    pub fn counts(&self) -> Vec<(String, usize)> {
813        let mut totals: HashMap<String, usize> = HashMap::new();
814        for artifact in &self.artifacts {
815            for (section, refs) in &artifact.relationships {
816                *totals.entry(section.clone()).or_insert(0) += refs.len();
817            }
818        }
819        let mut out = Vec::new();
820        for (_, snake_key) in crate::spec::RELATIONSHIP_SECTIONS.iter() {
821            if let Some(count) = totals.get(*snake_key) {
822                out.push((snake_key.to_string(), *count));
823            }
824        }
825        out
826    }
827
828    pub fn relationship_count(&self) -> usize {
829        self.counts().iter().map(|(_, c)| c).sum()
830    }
831}
832
833/// `_resolution_labels(artifacts, items)`.
834fn resolution_labels(
835    artifacts: &[ArtifactRelationships],
836    items: &[CorpusItem],
837) -> HashMap<String, String> {
838    // Resolution index over every alias of every item, in item order.
839    let mut index = ResolutionIndex::new();
840    let mut info: HashMap<&str, (String, Option<&'static ArtifactSpec>, Option<String>)> =
841        HashMap::new();
842    for item in items {
843        let identifiers = artifact_identifiers(&item.artifact, item.spec, &item.path);
844        for ident in &identifiers {
845            index.insert(py_casefold(ident), (item.path.clone(), ident.clone()));
846        }
847        let canonical = artifact_identifier(&item.artifact, item.spec, &item.path);
848        info.insert(
849            item.path.as_str(),
850            (canonical, item.spec, item.artifact.product.title.clone()),
851        );
852    }
853    let mut labels: HashMap<String, String> = HashMap::new();
854    for artifact in artifacts {
855        for (_, refs) in &artifact.relationships {
856            for reference in refs {
857                let key = py_casefold(reference);
858                if labels.contains_key(&key) {
859                    continue;
860                }
861                let entries = index.get(&key);
862                let mut distinct: Vec<&str> = entries.iter().map(|(p, _)| p.as_str()).collect();
863                distinct.sort();
864                distinct.dedup();
865                if distinct.len() != 1 {
866                    continue;
867                }
868                let (canonical, spec, title) = &info[distinct[0]];
869                let type_name = spec.map(|s| s.name.as_str()).unwrap_or("unknown");
870                let display = match title {
871                    Some(t) if !t.is_empty() => t.as_str(),
872                    _ => canonical.as_str(),
873                };
874                labels.insert(key, format!("{display} ({type_name} · {canonical})"));
875            }
876        }
877    }
878    labels
879}
880
881/// `_build_report(directory, items, recursive)`.
882fn build_report(directory: &str, items: Vec<CorpusItem>, recursive: bool) -> RelationshipReport {
883    let mut artifacts: Vec<ArtifactRelationships> = Vec::new();
884    for item in &items {
885        let Some(spec) = item.spec else {
886            continue;
887        };
888        let relationships = extract_relationships_full(&item.artifact, spec);
889        if !relationships.is_empty() {
890            artifacts.push(ArtifactRelationships {
891                path: item.path.clone(),
892                type_name: spec.name.clone(),
893                relationships,
894            });
895        }
896    }
897    let labels = resolution_labels(&artifacts, &items);
898    RelationshipReport {
899        directory: directory.to_string(),
900        recursive,
901        total_files: items.len(),
902        artifacts,
903        labels,
904    }
905}
906
907pub fn build_relationship_report(directory: &str, recursive: bool) -> RelationshipReport {
908    build_report(directory, corpus_items(directory, recursive), recursive)
909}
910
911pub fn build_relationship_report_file(path: &str) -> RelationshipReport {
912    let artifact = parse_file(path);
913    let spec = spec_for(&classify(&artifact).artifact_type);
914    let items = vec![CorpusItem {
915        path: path.to_string(),
916        artifact,
917        spec,
918    }];
919    build_report(path, items, false)
920}
921
922// ---------------------------------------------------------------------------
923// Corpus entry points
924// ---------------------------------------------------------------------------
925
926/// One parsed + classified item: `(display path, artifact, spec)`.
927#[derive(Clone)]
928pub struct CorpusItem {
929    pub path: String,
930    pub artifact: Artifact,
931    pub spec: Option<&'static ArtifactSpec>,
932}
933
934/// `_corpus_items(directory, recursive)` — the sorted-path walk, parsed and
935/// classified.
936///
937/// The per-file parse+classify work is parallelized with rayon over the
938/// already-sorted file list (PORT-CONTRACT decision 5). `into_par_iter` on a
939/// `Vec` is an indexed parallel iterator, so `collect` reassembles results in
940/// the original (sorted) order — the worker count is invisible in the output.
941/// Rendering stays sequential over the ordered results.
942pub fn corpus_items(directory: &str, recursive: bool) -> Vec<CorpusItem> {
943    use rayon::prelude::*;
944    find_markdown_files(directory, recursive)
945        .into_par_iter()
946        .map(|entry| {
947            let artifact = parse_file(&entry.display);
948            let spec = spec_for(&classify(&artifact).artifact_type);
949            CorpusItem {
950                path: entry.display,
951                artifact,
952                spec,
953            }
954        })
955        .collect()
956}
957
958fn rows_from_items(items: &[CorpusItem]) -> Vec<ValidationRow> {
959    items
960        .iter()
961        .map(|item| validation_row(&item.path, &item.artifact, item.spec))
962        .collect()
963}
964
965pub fn validate_relationships(directory: &str, recursive: bool) -> RelationshipValidation {
966    let items = corpus_items(directory, recursive);
967    validation_from_rows(directory, &rows_from_items(&items), recursive)
968}
969
970pub fn validate_relationships_file(path: &str) -> RelationshipValidation {
971    let artifact = parse_file(path);
972    let spec = spec_for(&classify(&artifact).artifact_type);
973    let rows = vec![validation_row(path, &artifact, spec)];
974    validation_from_rows(path, &rows, false)
975}
976
977/// `validate_document_against_corpus(product, source_path, directory)` — the
978/// `decided validate - --corpus DIR` seam (ADR-067).
979pub fn validate_document_against_corpus(
980    artifact: &Artifact,
981    source_path: &str,
982    directory: &str,
983    recursive: bool,
984) -> RelationshipValidation {
985    let corpus = corpus_items(directory, recursive);
986    let spec = spec_for(&classify(artifact).artifact_type);
987    let proposed_ident = py_casefold(&artifact_identifier(artifact, spec, source_path));
988    let mut rows: Vec<ValidationRow> = Vec::new();
989    for item in &corpus {
990        let ident = artifact_identifier(&item.artifact, item.spec, &item.path);
991        if py_casefold(&ident) == proposed_ident {
992            continue; // the on-disk counterpart of the document being edited
993        }
994        rows.push(validation_row(&item.path, &item.artifact, item.spec));
995    }
996    rows.push(validation_row(source_path, artifact, spec));
997    let result = validation_from_rows(directory, &rows, recursive);
998    let own: Vec<RelationshipIssue> = result
999        .issues
1000        .into_iter()
1001        .filter(|i| i.source_path.as_deref() == Some(source_path))
1002        .collect();
1003    RelationshipValidation {
1004        directory: directory.to_string(),
1005        recursive,
1006        relationships_checked: result.relationships_checked,
1007        issues: own,
1008    }
1009}
1010
1011// ---------------------------------------------------------------------------
1012// Resolved relationship objects (decided.services.relationships.Relationship)
1013// ---------------------------------------------------------------------------
1014
1015/// One declared cross-artifact reference with its resolution outcome
1016/// (`Relationship`). `resolved_path` is set only when the reference resolves
1017/// uniquely to another artifact.
1018#[derive(Debug, Clone)]
1019pub struct Relationship {
1020    pub source_path: String,
1021    pub relationship: String,
1022    pub target: String,
1023    pub resolved_path: Option<String>,
1024    pub issue: Option<String>,
1025}
1026
1027/// `resolve_relationships(rows, index)` — the single resolve loop. Rows in
1028/// order, sections in each row's schema order, refs in declaration order.
1029pub fn resolve_relationships(
1030    rows: &[ValidationRow],
1031    index: &ResolutionIndex,
1032) -> Vec<Relationship> {
1033    let mut out = Vec::new();
1034    for row in rows {
1035        for (section, refs) in &row.edges {
1036            let external = edge_spec(section).is_some_and(|e| e.external);
1037            for reference in refs {
1038                if external {
1039                    out.push(Relationship {
1040                        source_path: row.path.clone(),
1041                        relationship: section.clone(),
1042                        target: reference.clone(),
1043                        resolved_path: None,
1044                        issue: None,
1045                    });
1046                    continue;
1047                }
1048                let (resolved, issue) = match classify_reference(index, reference, &row.path) {
1049                    ReferenceResolution::Resolved(target) => (Some(target.to_string()), None),
1050                    ReferenceResolution::NotFound => {
1051                        (None, Some(ISSUE_TARGET_NOT_FOUND.to_string()))
1052                    }
1053                    ReferenceResolution::Ambiguous => {
1054                        (None, Some(ISSUE_TARGET_AMBIGUOUS.to_string()))
1055                    }
1056                    ReferenceResolution::SelfRef => {
1057                        (None, Some(ISSUE_SELF_REFERENCE.to_string()))
1058                    }
1059                };
1060                out.push(Relationship {
1061                    source_path: row.path.clone(),
1062                    relationship: section.clone(),
1063                    target: reference.clone(),
1064                    resolved_path: resolved,
1065                    issue,
1066                });
1067            }
1068        }
1069    }
1070    out
1071}
1072
1073/// `relationships_from_corpus(entries)` — every declared reference in a corpus
1074/// snapshot, resolved. Ordering matches `_resolve_references`.
1075pub fn relationships_from_corpus(items: &[CorpusItem]) -> Vec<Relationship> {
1076    let rows = rows_from_items(items);
1077    let index = resolution_index_from_rows(&rows);
1078    resolve_relationships(&rows, &index)
1079}
1080
1081// ---------------------------------------------------------------------------
1082// Relationship summary (decided.services.relationships.RelationshipSummary)
1083// ---------------------------------------------------------------------------
1084
1085#[derive(Debug, Clone)]
1086pub struct RelationshipSummary {
1087    pub total: usize,
1088    pub valid: usize,
1089    pub broken: usize,
1090    pub orphaned: usize,
1091    pub coverage: f64,
1092    pub issues: Vec<RelationshipIssue>,
1093}
1094
1095/// `_resolve_references(rows, index)` returning also the set of resolved target
1096/// paths (for orphan detection).
1097fn resolve_references_full(
1098    rows: &[ValidationRow],
1099    index: &ResolutionIndex,
1100) -> (usize, Vec<RelationshipIssue>, std::collections::HashSet<String>) {
1101    let mut issues = Vec::new();
1102    let mut resolved_targets: std::collections::HashSet<String> = std::collections::HashSet::new();
1103    let mut checked = 0usize;
1104    for row in rows {
1105        if row.spec_name.is_none() {
1106            continue;
1107        }
1108        for (section, refs) in &row.edges {
1109            if edge_spec(section).is_some_and(|e| e.external) {
1110                continue;
1111            }
1112            for reference in refs {
1113                checked += 1;
1114                let code = match classify_reference(index, reference, &row.path) {
1115                    ReferenceResolution::Resolved(target) => {
1116                        resolved_targets.insert(target.to_string());
1117                        continue;
1118                    }
1119                    ReferenceResolution::NotFound => ISSUE_TARGET_NOT_FOUND,
1120                    ReferenceResolution::Ambiguous => ISSUE_TARGET_AMBIGUOUS,
1121                    ReferenceResolution::SelfRef => ISSUE_SELF_REFERENCE,
1122                };
1123                issues.push(RelationshipIssue::reference(code, &row.path, section, reference));
1124            }
1125        }
1126    }
1127    (checked, issues, resolved_targets)
1128}
1129
1130/// `summary_from_rows(rows)`.
1131pub fn summary_from_rows(rows: &[ValidationRow]) -> RelationshipSummary {
1132    if rows.is_empty() {
1133        return RelationshipSummary {
1134            total: 0,
1135            valid: 0,
1136            broken: 0,
1137            orphaned: 0,
1138            coverage: 1.0,
1139            issues: Vec::new(),
1140        };
1141    }
1142    let index = resolution_index_from_rows(rows);
1143    let (checked, ref_issues, resolved_targets) = resolve_references_full(rows, &index);
1144    let broken = ref_issues.len();
1145    let valid = checked - broken;
1146
1147    let known_paths: Vec<&str> = rows
1148        .iter()
1149        .filter(|r| r.spec_name.is_some())
1150        .map(|r| r.path.as_str())
1151        .collect();
1152    let orphaned = known_paths
1153        .iter()
1154        .filter(|p| !resolved_targets.contains(**p))
1155        .count();
1156    let artifacts_with_rels = rows
1157        .iter()
1158        .filter(|r| r.spec_name.is_some() && !r.edges.is_empty())
1159        .count();
1160    let coverage = if known_paths.is_empty() {
1161        1.0
1162    } else {
1163        crate::pycompat::py_round(artifacts_with_rels as f64 / known_paths.len() as f64, 4)
1164    };
1165    RelationshipSummary {
1166        total: checked,
1167        valid,
1168        broken,
1169        orphaned,
1170        coverage,
1171        issues: ref_issues,
1172    }
1173}
1174
1175/// Public alias so callers outside this module build validation rows.
1176pub fn rows_from_corpus_items(items: &[CorpusItem]) -> Vec<ValidationRow> {
1177    rows_from_items(items)
1178}