1use 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
20pub 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
31pub 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#[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 pub directional: bool,
60 pub external_provider: bool,
63}
64
65pub 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
162pub 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
180pub fn extract_relationships_full(
183 artifact: &Artifact,
184 spec: &ArtifactSpec,
185) -> Vec<(String, Vec<String>)> {
186 collect_relationships(artifact, spec, true)
187}
188
189pub fn extract_relationships(
193 artifact: &Artifact,
194 spec: &ArtifactSpec,
195) -> Vec<(String, Vec<String>)> {
196 collect_relationships(artifact, spec, false)
197}
198
199fn 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
229pub 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
247fn 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#[derive(Debug, Clone)]
267pub struct ValidationRow {
268 pub path: String,
269 pub spec_name: Option<String>,
271 pub canonical_id: String,
272 pub identifiers: Vec<String>,
273 pub retired: bool,
274 pub unsupported_sections: Vec<String>,
276 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
309pub 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#[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
393pub(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
408pub(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
431fn 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
447enum 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
474fn 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
483fn 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 let mut issues = Vec::new();
566 for kind in ["supersedes"] {
567 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
641pub 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 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 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 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 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 issues.extend(cycle_issues(rows, &index));
765
766 let (checked, ref_issues) = resolve_references(rows, &index);
768 issues.extend(ref_issues);
769
770 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#[derive(Debug, Clone)]
787pub struct ArtifactRelationships {
788 pub path: String,
789 pub type_name: String,
790 pub relationships: Vec<(String, Vec<String>)>,
792}
793
794#[derive(Debug)]
796pub struct RelationshipReport {
797 pub directory: String,
798 pub recursive: bool,
799 pub total_files: usize,
800 pub artifacts: Vec<ArtifactRelationships>,
801 pub labels: HashMap<String, String>,
804}
805
806impl RelationshipReport {
807 pub fn artifacts_with_relationships(&self) -> usize {
808 self.artifacts.len()
809 }
810
811 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
833fn resolution_labels(
835 artifacts: &[ArtifactRelationships],
836 items: &[CorpusItem],
837) -> HashMap<String, String> {
838 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
881fn 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#[derive(Clone)]
928pub struct CorpusItem {
929 pub path: String,
930 pub artifact: Artifact,
931 pub spec: Option<&'static ArtifactSpec>,
932}
933
934pub 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
977pub 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; }
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#[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
1027pub 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
1073pub 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#[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
1095fn 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
1130pub 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
1175pub fn rows_from_corpus_items(items: &[CorpusItem]) -> Vec<ValidationRow> {
1177 rows_from_items(items)
1178}