1use std::collections::{BTreeMap, BTreeSet};
4
5use code_system_graph_model::{
6 Edge, EdgeId, EdgeKind, EpistemicStatus, Evidence, EvidenceId, Node, NodeId, NodeKind, Provenance, RepoId, stable_id
7};
8
9use crate::{
10 DataAccessObservation, DataAccessRole, DataArtifactKind, DataArtifactReference, DataArtifactReferenceKind, DataDocument, DatabaseForeignKey, DatabaseTable, DeploymentKind, DeploymentUnit, DocumentKind, DocumentRecord, DocumentationDocument, ExplicitReference, ExplicitReferenceKind, InfrastructureDocument, InfrastructureEvidence, InfrastructureResource, InfrastructureResourceKind, OwnershipRule, SafeConfigDocument
11};
12
13#[derive(Debug, Clone, Default)]
15pub struct ExtractionGraphFacts {
16 pub nodes: Vec<Node>,
18 pub edges: Vec<Edge>,
20 pub evidence: Vec<Evidence>,
22}
23
24#[derive(Debug, Clone)]
25struct TableInfo {
26 node: Node,
27 database: Option<String>,
28 schema: Option<String>,
29 name: String,
30 columns: BTreeMap<String, Node>,
31}
32
33#[derive(Debug, Clone)]
34struct PendingAccess {
35 repo_id: RepoId,
36 source_path: String,
37 content_hash: String,
38 access: DataAccessObservation,
39}
40
41#[derive(Debug, Clone)]
42struct PendingForeignKey {
43 repo_id: RepoId,
44 source_path: String,
45 content_hash: String,
46 source_table_key: String,
47 foreign_key: DatabaseForeignKey,
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51enum MigrationDirection {
52 Forward,
53 Up,
54 Down,
55}
56
57#[derive(Debug, Clone)]
58struct MigrationArtifact {
59 repo_id: RepoId,
60 source_path: String,
61 content_hash: String,
62 artifact: Node,
63 order_hint: Option<u64>,
64 evidence_line: u32,
65 direction: MigrationDirection,
66}
67
68#[derive(Debug, Clone)]
69struct DeploymentInfo {
70 repo_id: RepoId,
71 node: Node,
72 aliases: BTreeSet<String>,
73}
74
75#[derive(Debug, Clone)]
76struct ResourceInfo {
77 node: Node,
78 aliases: BTreeSet<String>,
79}
80
81#[derive(Debug, Clone)]
82struct PendingDependency {
83 source: Node,
84 target: String,
85 repo_id: RepoId,
86 source_path: String,
87 content_hash: String,
88 line: Option<u32>,
89}
90
91#[derive(Debug, Clone)]
92struct PendingReference {
93 document: Node,
94 repo_id: RepoId,
95 source_path: String,
96 content_hash: String,
97 reference: ExplicitReference,
98}
99
100#[derive(Default)]
101struct GraphBuilder {
102 facts: ExtractionGraphFacts,
103}
104
105impl GraphBuilder {
106 fn node(&mut self, node: Node) {
107 self.facts.nodes.push(node);
108 }
109
110 fn evidence(&mut self, evidence: Evidence) {
111 self.facts.evidence.push(evidence);
112 }
113
114 fn edge(&mut self, source: &Node, target: &Node, kind: EdgeKind, evidence: &Evidence) {
115 if source.id == target.id {
116 return;
117 }
118 let key = format!("{}:{kind:?}:{}", source.id.as_str(), target.id.as_str());
119 self.facts.edges.push(Edge {
120 id: EdgeId::new(stable_id("edge", &key)),
121 source: source.id.clone(),
122 target: target.id.clone(),
123 kind,
124 confidence: 1.0,
125 status: EpistemicStatus::Confirmed,
126 evidence: vec![evidence.id.clone()],
127 });
128 }
129
130 fn finish(mut self) -> ExtractionGraphFacts {
131 let mut nodes = BTreeMap::new();
132 for node in self.facts.nodes {
133 nodes.entry(node.id.clone()).or_insert(node);
134 }
135 self.facts.nodes = nodes.into_values().collect();
136
137 let mut edges: BTreeMap<EdgeId, Edge> = BTreeMap::new();
138 for edge in self.facts.edges {
139 if let Some(existing) = edges.get_mut(&edge.id) {
140 existing.evidence.extend(edge.evidence);
141 existing.confidence = existing.confidence.max(edge.confidence);
142 } else {
143 edges.insert(edge.id.clone(), edge);
144 }
145 }
146 self.facts.edges = edges
147 .into_values()
148 .map(|mut edge| {
149 edge.evidence.sort();
150 edge.evidence.dedup();
151 edge
152 })
153 .collect();
154
155 let mut evidence = BTreeMap::new();
156 for item in self.facts.evidence {
157 evidence.entry(item.id.clone()).or_insert(item);
158 }
159 self.facts.evidence = evidence.into_values().collect();
160 self.facts
161 }
162}
163
164#[must_use]
170pub fn documents_to_graph(
171 data_inputs: &[(&RepoId, &str, &str, &DataDocument)],
172 infrastructure_inputs: &[(&RepoId, &str, &str, &InfrastructureDocument)],
173 documentation_inputs: &[(&RepoId, &str, &str, &DocumentationDocument)],
174 config_inputs: &[(&RepoId, &str, &str, &SafeConfigDocument)],
175 known_nodes: &[Node],
176 repository_aliases: &[(&str, &RepoId)],
177) -> ExtractionGraphFacts {
178 let mut builder = GraphBuilder::default();
179
180 append_config_documents(&mut builder, config_inputs);
181 let tables = append_data_documents(&mut builder, data_inputs);
182 append_infrastructure_documents(&mut builder, infrastructure_inputs);
183 append_documentation_documents(
184 &mut builder,
185 documentation_inputs,
186 known_nodes,
187 repository_aliases,
188 );
189
190 link_data_observations(&mut builder, data_inputs, &tables);
193 link_data_artifact_references(&mut builder, data_inputs, &tables);
194 builder.finish()
195}
196
197fn append_config_documents(
198 builder: &mut GraphBuilder,
199 inputs: &[(&RepoId, &str, &str, &SafeConfigDocument)],
200) {
201 for (repo_id, source_path, content_hash, document) in inputs {
202 if document.keys.is_empty() {
203 continue;
204 }
205 let artifact = append_artifact(
206 builder,
207 repo_id,
208 source_path,
209 content_hash,
210 "code-system-graph.extraction.config",
211 "configuration artifact",
212 );
213 for key in &document.keys {
214 let node = config_node(repo_id, &key.scope.join("."), &key.name);
215 let evidence = extraction_evidence(
216 repo_id,
217 source_path,
218 content_hash,
219 line_from_usize(key.line),
220 line_from_usize(key.line),
221 "code-system-graph.extraction.config",
222 "configuration key declaration",
223 );
224 builder.edge(&artifact, &node, EdgeKind::Configures, &evidence);
225 builder.node(node);
226 builder.evidence(evidence);
227 }
228 }
229}
230
231fn append_data_documents(
232 builder: &mut GraphBuilder,
233 inputs: &[(&RepoId, &str, &str, &DataDocument)],
234) -> BTreeMap<String, TableInfo> {
235 let mut tables = BTreeMap::new();
236 let mut migrations = Vec::new();
237 for (repo_id, source_path, content_hash, document) in inputs {
238 if document.tables.is_empty()
239 && document.accesses.is_empty()
240 && document.references.is_empty()
241 && document.artifact_kind != DataArtifactKind::SqlMigration
242 {
243 continue;
244 }
245 let artifact = append_artifact(
246 builder,
247 repo_id,
248 source_path,
249 content_hash,
250 "code-system-graph.extraction.data",
251 "data artifact",
252 );
253 if let Some(migration) = &document.migration {
254 migrations.push(MigrationArtifact {
255 repo_id: (*repo_id).clone(),
256 source_path: (*source_path).to_owned(),
257 content_hash: (*content_hash).to_owned(),
258 artifact: artifact.clone(),
259 order_hint: migration.order_hint,
260 evidence_line: migration.evidence.get(),
261 direction: migration_direction(source_path),
262 });
263 }
264 for table in &document.tables {
265 let info = table_info(document, table);
266 let table_evidence = extraction_evidence(
267 repo_id,
268 source_path,
269 content_hash,
270 Some(table.evidence.get()),
271 Some(table.evidence.get()),
272 "code-system-graph.extraction.data",
273 "database table declaration",
274 );
275 builder.edge(&artifact, &info.node, EdgeKind::Contains, &table_evidence);
276 builder.node(info.node.clone());
277 builder.evidence(table_evidence.clone());
278
279 if let Some(database) = &info.database {
280 let database = database_node(database);
281 builder.edge(&database, &info.node, EdgeKind::Contains, &table_evidence);
282 builder.node(database);
283 }
284 for column in &table.columns {
285 let Some(column_node) = info.columns.get(&column.name) else {
286 continue;
287 };
288 let evidence = extraction_evidence(
289 repo_id,
290 source_path,
291 content_hash,
292 Some(column.evidence.get()),
293 Some(column.evidence.get()),
294 "code-system-graph.extraction.data",
295 "database column declaration",
296 );
297 builder.edge(&info.node, column_node, EdgeKind::Contains, &evidence);
298 builder.node(column_node.clone());
299 builder.evidence(evidence);
300 }
301 tables
302 .entry(info.node.stable_key.clone())
303 .and_modify(|existing: &mut TableInfo| {
304 existing.columns.extend(info.columns.clone());
305 })
306 .or_insert(info);
307 }
308 }
309 link_migration_artifacts(builder, &migrations);
310 tables
311}
312
313fn link_migration_artifacts(builder: &mut GraphBuilder, migrations: &[MigrationArtifact]) {
314 let mut pairs: BTreeMap<(RepoId, String, String), Vec<&MigrationArtifact>> = BTreeMap::new();
315 let mut ordered: BTreeMap<(RepoId, String), BTreeMap<u64, Vec<&MigrationArtifact>>> =
316 BTreeMap::new();
317
318 for migration in migrations {
319 let (directory, family) = migration_family(&migration.source_path);
320 pairs
321 .entry((migration.repo_id.clone(), directory.clone(), family))
322 .or_default()
323 .push(migration);
324 if migration.direction != MigrationDirection::Down
325 && let Some(order_hint) = migration.order_hint
326 {
327 ordered
328 .entry((migration.repo_id.clone(), directory))
329 .or_default()
330 .entry(order_hint)
331 .or_default()
332 .push(migration);
333 }
334 }
335
336 for candidates in pairs.values() {
337 let up = exactly_one_migration(
338 candidates
339 .iter()
340 .filter(|migration| migration.direction == MigrationDirection::Up)
341 .copied(),
342 );
343 let down = exactly_one_migration(
344 candidates
345 .iter()
346 .filter(|migration| migration.direction == MigrationDirection::Down)
347 .copied(),
348 );
349 let (Some(up), Some(down)) = (up, down) else {
350 continue;
351 };
352 let evidence = extraction_evidence(
353 &down.repo_id,
354 &down.source_path,
355 &down.content_hash,
356 Some(down.evidence_line),
357 Some(down.evidence_line),
358 "code-system-graph.extraction.data",
359 "reversible migration pair",
360 );
361 builder.edge(&down.artifact, &up.artifact, EdgeKind::Reverts, &evidence);
362 builder.evidence(evidence);
363 }
364
365 for revisions in ordered.values() {
366 let migrations = revisions
367 .values()
368 .map(|candidates| exactly_one_migration(candidates.iter().copied()))
369 .collect::<Vec<_>>();
370 for pair in migrations.windows(2) {
371 let [Some(previous), Some(next)] = pair else {
372 continue;
373 };
374 let evidence = extraction_evidence(
375 &next.repo_id,
376 &next.source_path,
377 &next.content_hash,
378 Some(next.evidence_line),
379 Some(next.evidence_line),
380 "code-system-graph.extraction.data",
381 "migration order",
382 );
383 builder.edge(
384 &previous.artifact,
385 &next.artifact,
386 EdgeKind::Precedes,
387 &evidence,
388 );
389 builder.evidence(evidence);
390 }
391 }
392}
393
394fn exactly_one_migration<'a>(
395 mut candidates: impl Iterator<Item = &'a MigrationArtifact>,
396) -> Option<&'a MigrationArtifact> {
397 let candidate = candidates.next()?;
398 candidates.next().is_none().then_some(candidate)
399}
400
401fn migration_direction(source_path: &str) -> MigrationDirection {
402 if source_path.ends_with(".up.sql") {
403 MigrationDirection::Up
404 } else if source_path.ends_with(".down.sql") {
405 MigrationDirection::Down
406 } else {
407 MigrationDirection::Forward
408 }
409}
410
411fn migration_family(source_path: &str) -> (String, String) {
412 let (directory, filename) = source_path
413 .rsplit_once('/')
414 .map_or(("", source_path), |(directory, filename)| {
415 (directory, filename)
416 });
417 let family = filename
418 .strip_suffix(".up.sql")
419 .or_else(|| filename.strip_suffix(".down.sql"))
420 .or_else(|| filename.strip_suffix(".sql"))
421 .unwrap_or(filename);
422 (directory.to_owned(), family.to_owned())
423}
424
425fn link_data_observations(
426 builder: &mut GraphBuilder,
427 inputs: &[(&RepoId, &str, &str, &DataDocument)],
428 tables: &BTreeMap<String, TableInfo>,
429) {
430 let mut accesses = Vec::new();
431 let mut foreign_keys = Vec::new();
432 for (repo_id, source_path, content_hash, document) in inputs {
433 accesses.extend(
434 document
435 .accesses
436 .iter()
437 .cloned()
438 .map(|access| PendingAccess {
439 repo_id: (*repo_id).clone(),
440 source_path: (*source_path).to_owned(),
441 content_hash: (*content_hash).to_owned(),
442 access,
443 }),
444 );
445 for table in &document.tables {
446 let info = table_info(document, table);
447 foreign_keys.extend(table.foreign_keys.iter().cloned().map(|foreign_key| {
448 PendingForeignKey {
449 repo_id: (*repo_id).clone(),
450 source_path: (*source_path).to_owned(),
451 content_hash: (*content_hash).to_owned(),
452 source_table_key: info.node.stable_key.clone(),
453 foreign_key,
454 }
455 }));
456 }
457 }
458 link_accesses(builder, accesses, tables);
459 link_foreign_keys(builder, foreign_keys, tables);
460}
461
462fn link_data_artifact_references(
463 builder: &mut GraphBuilder,
464 inputs: &[(&RepoId, &str, &str, &DataDocument)],
465 tables: &BTreeMap<String, TableInfo>,
466) {
467 let mut referenced_accesses = Vec::new();
468 for (repo_id, source_path, content_hash, document) in inputs {
469 for reference in &document.references {
470 let effective_path = effective_data_reference_path(reference, repo_id, inputs);
471 let targets = inputs
472 .iter()
473 .filter(|(candidate_repo, candidate_path, _, candidate)| {
474 *candidate_repo == *repo_id
475 && match reference.kind {
476 DataArtifactReferenceKind::QueryFile => {
477 *candidate_path == reference.path
478 && candidate.artifact_kind == DataArtifactKind::SqlQueryFile
479 }
480 DataArtifactReferenceKind::MigrationDirectory
481 | DataArtifactReferenceKind::SqlxDefaultMigrationDirectory => {
482 matches!(
483 candidate.artifact_kind,
484 DataArtifactKind::SqlMigration
485 | DataArtifactKind::DeclarativeSqlSchema
486 | DataArtifactKind::SqlQueryFile
487 ) && path_is_within(candidate_path, &effective_path)
488 }
489 }
490 })
491 .collect::<Vec<_>>();
492 if reference.kind == DataArtifactReferenceKind::QueryFile && targets.len() != 1 {
493 continue;
494 }
495 let source_artifact = append_artifact(
496 builder,
497 repo_id,
498 source_path,
499 content_hash,
500 "code-system-graph.extraction.data",
501 "data source artifact",
502 );
503 let source = reference.owner.as_deref().map_or_else(
504 || source_artifact.clone(),
505 |owner| data_symbol_node(repo_id, source_path, owner),
506 );
507 for (target_repo, target_path, target_hash, target_document) in targets {
508 let target_artifact = append_artifact(
509 builder,
510 target_repo,
511 target_path,
512 target_hash,
513 "code-system-graph.extraction.data",
514 "referenced data artifact",
515 );
516 let evidence = extraction_evidence(
517 repo_id,
518 source_path,
519 content_hash,
520 Some(reference.evidence.get()),
521 Some(reference.evidence.get()),
522 "code-system-graph.extraction.data",
523 match reference.kind {
524 DataArtifactReferenceKind::QueryFile => "SQLx query file reference",
525 DataArtifactReferenceKind::MigrationDirectory => {
526 "SQLx migration directory reference"
527 }
528 DataArtifactReferenceKind::SqlxDefaultMigrationDirectory => {
529 "SQLx configured migration directory reference"
530 }
531 },
532 );
533 builder.edge(&source, &target_artifact, EdgeKind::Consumes, &evidence);
534 builder.node(source.clone());
535 builder.evidence(evidence);
536
537 if reference.kind == DataArtifactReferenceKind::QueryFile {
538 referenced_accesses.extend(target_document.accesses.iter().map(|access| {
539 let mut access = access.clone();
540 access.owner.clone_from(&reference.owner);
541 access.evidence = reference.evidence;
542 PendingAccess {
543 repo_id: (*repo_id).clone(),
544 source_path: (*source_path).to_owned(),
545 content_hash: (*content_hash).to_owned(),
546 access,
547 }
548 }));
549 }
550 }
551 }
552 }
553 link_accesses(builder, referenced_accesses, tables);
554}
555
556fn effective_data_reference_path(
557 reference: &DataArtifactReference,
558 repo_id: &RepoId,
559 inputs: &[(&RepoId, &str, &str, &DataDocument)],
560) -> String {
561 if reference.kind != DataArtifactReferenceKind::SqlxDefaultMigrationDirectory {
562 return reference.path.clone();
563 }
564 inputs
565 .iter()
566 .find(|(candidate_repo, candidate_path, _, candidate)| {
567 *candidate_repo == repo_id
568 && candidate.artifact_kind == DataArtifactKind::SqlxConfiguration
569 && *candidate_path == sqlx_configuration_path(&reference.path)
570 })
571 .and_then(|(_, _, _, configuration)| {
572 configuration
573 .references
574 .iter()
575 .find(|candidate| candidate.kind == DataArtifactReferenceKind::MigrationDirectory)
576 .map(|candidate| candidate.path.clone())
577 })
578 .unwrap_or_else(|| join_portable_path(&reference.path, "migrations"))
579}
580
581fn path_is_within(candidate: &str, directory: &str) -> bool {
582 candidate == directory
583 || candidate
584 .strip_prefix(directory)
585 .is_some_and(|suffix| suffix.starts_with('/'))
586}
587
588fn sqlx_configuration_path(crate_root: &str) -> String {
589 join_portable_path(crate_root, "sqlx.toml")
590}
591
592fn join_portable_path(parent: &str, child: &str) -> String {
593 if parent.is_empty() {
594 child.to_owned()
595 } else {
596 format!("{}/{}", parent.trim_end_matches('/'), child)
597 }
598}
599
600fn link_accesses(
601 builder: &mut GraphBuilder,
602 accesses: Vec<PendingAccess>,
603 tables: &BTreeMap<String, TableInfo>,
604) {
605 let mut model_tables = BTreeMap::<String, BTreeSet<String>>::new();
606 for pending in &accesses {
607 if pending.access.role != DataAccessRole::ModelBinding {
608 continue;
609 }
610 let Some(model) = pending.access.owner.as_ref() else {
611 continue;
612 };
613 model_tables
614 .entry(model.clone())
615 .or_default()
616 .insert(pending.access.table.clone());
617 }
618 for pending in accesses {
619 let Some(owner) = pending.access.owner.as_deref() else {
620 continue;
621 };
622 let resolved_model_table = pending
623 .access
624 .model
625 .as_ref()
626 .and_then(|model| model_tables.get(model).cloned().and_then(exactly_one));
627 let target_name = if pending.access.table.is_empty() {
628 let Some(table) = resolved_model_table.as_deref() else {
629 continue;
630 };
631 table
632 } else {
633 &pending.access.table
634 };
635 let Some(target) = resolve_table(tables, target_name) else {
636 continue;
637 };
638 let symbol = data_symbol_node(&pending.repo_id, &pending.source_path, owner);
639 let evidence = extraction_evidence(
640 &pending.repo_id,
641 &pending.source_path,
642 &pending.content_hash,
643 Some(pending.access.evidence.get()),
644 Some(pending.access.evidence.get()),
645 "code-system-graph.extraction.data",
646 "database access observation",
647 );
648 match pending.access.role {
649 DataAccessRole::Reader => {
650 builder.edge(&symbol, &target.node, EdgeKind::ReadsTable, &evidence);
651 }
652 DataAccessRole::Writer => {
653 builder.edge(&symbol, &target.node, EdgeKind::WritesTable, &evidence);
654 }
655 DataAccessRole::ModelBinding => {
656 builder.edge(&target.node, &symbol, EdgeKind::ImplementedBy, &evidence);
657 }
658 DataAccessRole::Declaration => continue,
659 }
660 builder.node(symbol);
661 builder.evidence(evidence);
662 }
663}
664
665fn link_foreign_keys(
666 builder: &mut GraphBuilder,
667 foreign_keys: Vec<PendingForeignKey>,
668 tables: &BTreeMap<String, TableInfo>,
669) {
670 for pending in foreign_keys {
671 let Some(source) = tables.get(&pending.source_table_key) else {
672 continue;
673 };
674 let Some(target) = resolve_table(tables, &pending.foreign_key.referenced_table) else {
675 continue;
676 };
677 let evidence = extraction_evidence(
678 &pending.repo_id,
679 &pending.source_path,
680 &pending.content_hash,
681 Some(pending.foreign_key.evidence.get()),
682 Some(pending.foreign_key.evidence.get()),
683 "code-system-graph.extraction.data",
684 "foreign key declaration",
685 );
686 if pending.foreign_key.referenced_columns.is_empty() {
687 builder.edge(&source.node, &target.node, EdgeKind::Consumes, &evidence);
688 builder.evidence(evidence);
689 continue;
690 }
691 if pending.foreign_key.columns.len() != pending.foreign_key.referenced_columns.len() {
692 continue;
693 }
694 let pairs = pending
695 .foreign_key
696 .columns
697 .iter()
698 .zip(&pending.foreign_key.referenced_columns)
699 .filter_map(|(local, referenced)| {
700 Some((source.columns.get(local)?, target.columns.get(referenced)?))
701 })
702 .collect::<Vec<_>>();
703 if pairs.len() != pending.foreign_key.columns.len() {
704 continue;
705 }
706 for (local, referenced) in pairs {
707 builder.edge(local, referenced, EdgeKind::Consumes, &evidence);
708 }
709 builder.evidence(evidence);
710 }
711}
712
713fn append_infrastructure_documents(
714 builder: &mut GraphBuilder,
715 inputs: &[(&RepoId, &str, &str, &InfrastructureDocument)],
716) {
717 let mut deployments = Vec::new();
718 let mut resources = Vec::new();
719 let mut dependencies = Vec::new();
720
721 for (repo_id, source_path, content_hash, document) in inputs {
722 if document.deployment_units.is_empty()
723 && document.resources.is_empty()
724 && document.environment_keys.is_empty()
725 {
726 continue;
727 }
728 let artifact = append_artifact(
729 builder,
730 repo_id,
731 source_path,
732 content_hash,
733 "code-system-graph.extraction.infrastructure",
734 "infrastructure artifact",
735 );
736 append_deployment_units(
737 builder,
738 repo_id,
739 source_path,
740 content_hash,
741 &document.deployment_units,
742 &mut deployments,
743 &mut dependencies,
744 );
745 append_infrastructure_resources(
746 builder,
747 repo_id,
748 source_path,
749 content_hash,
750 &artifact,
751 &document.resources,
752 &mut deployments,
753 &mut resources,
754 &mut dependencies,
755 );
756 append_document_environment_keys(
757 builder,
758 repo_id,
759 source_path,
760 content_hash,
761 &artifact,
762 &document.environment_keys,
763 );
764 }
765 link_infrastructure_dependencies(builder, &deployments, &resources, dependencies);
766}
767
768fn link_infrastructure_dependencies(
769 builder: &mut GraphBuilder,
770 deployments: &[DeploymentInfo],
771 resources: &[ResourceInfo],
772 dependencies: Vec<PendingDependency>,
773) {
774 for dependency in dependencies {
775 let target = resolve_infrastructure_target(deployments, resources, &dependency.target);
776 let Some(target) = target else {
777 continue;
778 };
779 let evidence = extraction_evidence(
780 &dependency.repo_id,
781 &dependency.source_path,
782 &dependency.content_hash,
783 dependency.line,
784 dependency.line,
785 "code-system-graph.extraction.infrastructure",
786 "explicit infrastructure dependency",
787 );
788 builder.edge(
789 &dependency.source,
790 &target,
791 EdgeKind::CallsRemote,
792 &evidence,
793 );
794 builder.node(target);
795 builder.evidence(evidence);
796 }
797}
798
799fn append_deployment_units(
800 builder: &mut GraphBuilder,
801 repo_id: &RepoId,
802 source_path: &str,
803 content_hash: &str,
804 units: &[DeploymentUnit],
805 deployments: &mut Vec<DeploymentInfo>,
806 dependencies: &mut Vec<PendingDependency>,
807) {
808 for unit in units {
809 let technology = deployment_technology(unit.kind);
810 let deployment = deployment_node(technology, unit.namespace.as_deref(), &unit.name);
811 let line = infrastructure_line(&unit.evidence);
812 let evidence = extraction_evidence(
813 repo_id,
814 source_path,
815 content_hash,
816 line,
817 line,
818 "code-system-graph.extraction.infrastructure",
819 "deployment declaration",
820 );
821 let repository = repository_node(repo_id);
822 builder.edge(&repository, &deployment, EdgeKind::Deploys, &evidence);
823 builder.node(repository);
824 builder.node(deployment.clone());
825 builder.evidence(evidence.clone());
826 for service_name in &unit.service_names {
827 let service = service_node(repo_id, service_name);
828 builder.edge(&deployment, &service, EdgeKind::Provides, &evidence);
829 builder.node(service);
830 }
831 append_deployment_environment_keys(
832 builder,
833 repo_id,
834 source_path,
835 content_hash,
836 &deployment,
837 &unit.environment_keys,
838 line,
839 );
840 dependencies.extend(unit.dependencies.iter().map(|target| PendingDependency {
841 source: deployment.clone(),
842 target: target.clone(),
843 repo_id: repo_id.clone(),
844 source_path: source_path.to_owned(),
845 content_hash: content_hash.to_owned(),
846 line,
847 }));
848 let aliases = BTreeSet::from([
849 unit.name.clone(),
850 deployment.stable_key.clone(),
851 deployment
852 .stable_key
853 .strip_prefix("deployment:")
854 .unwrap_or(&deployment.stable_key)
855 .to_owned(),
856 ]);
857 deployments.push(DeploymentInfo {
858 repo_id: repo_id.clone(),
859 node: deployment,
860 aliases,
861 });
862 }
863}
864
865fn append_deployment_environment_keys(
866 builder: &mut GraphBuilder,
867 repo_id: &RepoId,
868 source_path: &str,
869 content_hash: &str,
870 deployment: &Node,
871 key_names: &[String],
872 line: Option<u32>,
873) {
874 for key_name in key_names {
875 let config = resolve_or_create_config(builder, repo_id, &deployment.stable_key, key_name);
876 let evidence = extraction_evidence(
877 repo_id,
878 source_path,
879 content_hash,
880 line,
881 line,
882 "code-system-graph.extraction.infrastructure",
883 "environment key declaration",
884 );
885 builder.edge(deployment, &config, EdgeKind::Configures, &evidence);
886 builder.node(config);
887 builder.evidence(evidence);
888 }
889}
890
891#[expect(
892 clippy::too_many_arguments,
893 reason = "The helper preserves explicit source provenance."
894)]
895fn append_infrastructure_resources(
896 builder: &mut GraphBuilder,
897 repo_id: &RepoId,
898 source_path: &str,
899 content_hash: &str,
900 artifact: &Node,
901 document_resources: &[InfrastructureResource],
902 deployments: &mut [DeploymentInfo],
903 resources: &mut Vec<ResourceInfo>,
904 dependencies: &mut Vec<PendingDependency>,
905) {
906 for resource in document_resources {
907 let line = infrastructure_line(&resource.evidence);
908 let node = infrastructure_resource_node(repo_id, resource);
909 if let Some(node) = node {
910 let evidence = extraction_evidence(
911 repo_id,
912 source_path,
913 content_hash,
914 line,
915 line,
916 "code-system-graph.extraction.infrastructure",
917 "infrastructure resource declaration",
918 );
919 builder.edge(artifact, &node, EdgeKind::Contains, &evidence);
920 builder.node(node.clone());
921 builder.evidence(evidence);
922 let aliases = BTreeSet::from([
923 resource.name.clone(),
924 format!("{}.{}", resource.resource_type, resource.name),
925 format!("{}/{}", resource.resource_type, resource.name),
926 node.stable_key.clone(),
927 ]);
928 resources.push(ResourceInfo {
929 node: node.clone(),
930 aliases: aliases.clone(),
931 });
932 for deployment in &mut *deployments {
933 if deployment.repo_id == *repo_id && deployment.node.label == resource.name {
934 deployment.aliases.extend(aliases.iter().cloned());
935 }
936 }
937 dependencies.extend(
938 resource
939 .dependencies
940 .iter()
941 .map(|target| PendingDependency {
942 source: node.clone(),
943 target: target.clone(),
944 repo_id: repo_id.clone(),
945 source_path: source_path.to_owned(),
946 content_hash: content_hash.to_owned(),
947 line,
948 }),
949 );
950 }
951 append_resource_config_keys(
952 builder,
953 repo_id,
954 source_path,
955 content_hash,
956 artifact,
957 resource,
958 line,
959 );
960 }
961}
962
963fn infrastructure_resource_node(
964 repo_id: &RepoId,
965 resource: &InfrastructureResource,
966) -> Option<Node> {
967 match resource.kind {
968 InfrastructureResourceKind::Service => Some(service_node(repo_id, &resource.name)),
969 InfrastructureResourceKind::Database => Some(database_node(&resource.name)),
970 InfrastructureResourceKind::Topic | InfrastructureResourceKind::Queue => Some(
971 event_channel_node(resource.namespace.as_deref(), &resource.name),
972 ),
973 InfrastructureResourceKind::Ingress | InfrastructureResourceKind::Other => None,
974 }
975}
976
977fn append_resource_config_keys(
978 builder: &mut GraphBuilder,
979 repo_id: &RepoId,
980 source_path: &str,
981 content_hash: &str,
982 artifact: &Node,
983 resource: &InfrastructureResource,
984 line: Option<u32>,
985) {
986 for key_name in &resource.key_names {
987 let scope = format!(
988 "resource:{}:{}:{}",
989 resource.resource_type,
990 resource.namespace.as_deref().unwrap_or(""),
991 resource.name
992 );
993 let config = resolve_or_create_config(builder, repo_id, &scope, key_name);
994 let evidence = extraction_evidence(
995 repo_id,
996 source_path,
997 content_hash,
998 line,
999 line,
1000 "code-system-graph.extraction.infrastructure",
1001 "infrastructure configuration key",
1002 );
1003 builder.edge(artifact, &config, EdgeKind::Configures, &evidence);
1004 builder.node(config);
1005 builder.evidence(evidence);
1006 }
1007}
1008
1009fn append_document_environment_keys(
1010 builder: &mut GraphBuilder,
1011 repo_id: &RepoId,
1012 source_path: &str,
1013 content_hash: &str,
1014 artifact: &Node,
1015 key_names: &[String],
1016) {
1017 for key_name in key_names {
1018 let config = resolve_or_create_config(builder, repo_id, "environment", key_name);
1019 let evidence = extraction_evidence(
1020 repo_id,
1021 source_path,
1022 content_hash,
1023 None,
1024 None,
1025 "code-system-graph.extraction.infrastructure",
1026 "environment key declaration",
1027 );
1028 builder.edge(artifact, &config, EdgeKind::Configures, &evidence);
1029 builder.node(config);
1030 builder.evidence(evidence);
1031 }
1032}
1033
1034fn append_documentation_documents(
1035 builder: &mut GraphBuilder,
1036 inputs: &[(&RepoId, &str, &str, &DocumentationDocument)],
1037 known_nodes: &[Node],
1038 repository_aliases: &[(&str, &RepoId)],
1039) {
1040 let mut references = Vec::new();
1041 for (repo_id, source_path, content_hash, document) in inputs {
1042 let artifact = append_artifact(
1043 builder,
1044 repo_id,
1045 source_path,
1046 content_hash,
1047 "code-system-graph.extraction.documents",
1048 "documentation artifact",
1049 );
1050 append_document_records(
1051 builder,
1052 repo_id,
1053 source_path,
1054 content_hash,
1055 &artifact,
1056 &document.records,
1057 &mut references,
1058 );
1059 append_ownership_rules(
1060 builder,
1061 repo_id,
1062 source_path,
1063 content_hash,
1064 &document.ownership_rules,
1065 );
1066 }
1067
1068 let mut candidates = builder.facts.nodes.clone();
1069 candidates.extend_from_slice(known_nodes);
1070 for (alias, repo_id) in repository_aliases {
1071 if !alias.is_empty() {
1072 candidates.push(repository_node(repo_id));
1073 }
1074 }
1075 link_document_references(builder, &candidates, repository_aliases, references);
1076}
1077
1078fn link_document_references(
1079 builder: &mut GraphBuilder,
1080 candidates: &[Node],
1081 repository_aliases: &[(&str, &RepoId)],
1082 references: Vec<PendingReference>,
1083) {
1084 for pending in references {
1085 let Some(target) = resolve_reference(
1086 candidates,
1087 repository_aliases,
1088 &pending.repo_id,
1089 &pending.reference,
1090 ) else {
1091 continue;
1092 };
1093 let evidence = extraction_evidence(
1094 &pending.repo_id,
1095 &pending.source_path,
1096 &pending.content_hash,
1097 Some(pending.reference.evidence.start),
1098 Some(pending.reference.evidence.end),
1099 "code-system-graph.extraction.documents",
1100 "explicit document reference",
1101 );
1102 builder.edge(&pending.document, &target, EdgeKind::Documents, &evidence);
1103 builder.node(target);
1104 builder.evidence(evidence);
1105 }
1106}
1107
1108fn append_document_records(
1109 builder: &mut GraphBuilder,
1110 repo_id: &RepoId,
1111 source_path: &str,
1112 content_hash: &str,
1113 artifact: &Node,
1114 records: &[DocumentRecord],
1115 references: &mut Vec<PendingReference>,
1116) {
1117 for record in records {
1118 let node = document_node(
1119 repo_id,
1120 &record.source_path,
1121 record.kind,
1122 record.title.as_deref(),
1123 );
1124 let record_evidence = extraction_evidence(
1125 repo_id,
1126 source_path,
1127 content_hash,
1128 None,
1129 None,
1130 "code-system-graph.extraction.documents",
1131 "document record",
1132 );
1133 builder.edge(artifact, &node, EdgeKind::Contains, &record_evidence);
1134 builder.node(node.clone());
1135 builder.evidence(record_evidence);
1136 let catalog_service = (record.kind == DocumentKind::ServiceCatalog)
1137 .then_some(record.title.as_deref())
1138 .flatten()
1139 .map(|name| service_node(repo_id, name));
1140 if let Some(service) = &catalog_service {
1141 builder.node(service.clone());
1142 }
1143 for owner_name in &record.owners {
1144 let owner = owner_node(owner_name);
1145 let owner_range = record
1146 .references
1147 .iter()
1148 .find(|reference| {
1149 reference.kind == ExplicitReferenceKind::Owner
1150 && reference.target == *owner_name
1151 })
1152 .map(|reference| reference.evidence);
1153 let evidence = extraction_evidence(
1154 repo_id,
1155 source_path,
1156 content_hash,
1157 owner_range.map(|range| range.start),
1158 owner_range.map(|range| range.end),
1159 "code-system-graph.extraction.documents",
1160 "explicit ownership declaration",
1161 );
1162 let ownership_subject = catalog_service.as_ref().unwrap_or(&node);
1163 builder.edge(ownership_subject, &owner, EdgeKind::OwnedBy, &evidence);
1164 builder.node(owner);
1165 builder.evidence(evidence);
1166 }
1167 references.extend(
1168 record
1169 .references
1170 .iter()
1171 .cloned()
1172 .map(|reference| PendingReference {
1173 document: node.clone(),
1174 repo_id: repo_id.clone(),
1175 source_path: source_path.to_owned(),
1176 content_hash: content_hash.to_owned(),
1177 reference,
1178 }),
1179 );
1180 }
1181}
1182
1183fn append_ownership_rules(
1184 builder: &mut GraphBuilder,
1185 repo_id: &RepoId,
1186 source_path: &str,
1187 content_hash: &str,
1188 rules: &[OwnershipRule],
1189) {
1190 let document = document_node(
1191 repo_id,
1192 source_path,
1193 DocumentKind::Codeowners,
1194 Some("CODEOWNERS"),
1195 );
1196 for rule in rules {
1197 for owner_name in &rule.owners {
1198 let owner = owner_node(owner_name);
1199 let evidence = extraction_evidence(
1200 repo_id,
1201 source_path,
1202 content_hash,
1203 Some(rule.line),
1204 Some(rule.line),
1205 "code-system-graph.extraction.documents",
1206 "CODEOWNERS owner declaration",
1207 );
1208 builder.edge(&document, &owner, EdgeKind::OwnedBy, &evidence);
1209 builder.node(document.clone());
1210 builder.node(owner);
1211 builder.evidence(evidence);
1212 }
1213 }
1214}
1215
1216fn append_artifact(
1217 builder: &mut GraphBuilder,
1218 repo_id: &RepoId,
1219 source_path: &str,
1220 content_hash: &str,
1221 extractor: &str,
1222 note: &str,
1223) -> Node {
1224 let repository = repository_node(repo_id);
1225 let artifact = artifact_node(repo_id, source_path);
1226 let evidence = extraction_evidence(
1227 repo_id,
1228 source_path,
1229 content_hash,
1230 Some(1),
1231 Some(1),
1232 extractor,
1233 note,
1234 );
1235 builder.edge(&repository, &artifact, EdgeKind::Contains, &evidence);
1236 builder.node(repository);
1237 builder.node(artifact.clone());
1238 builder.evidence(evidence);
1239 artifact
1240}
1241
1242fn table_info(document: &DataDocument, table: &DatabaseTable) -> TableInfo {
1243 let database = table
1244 .database
1245 .clone()
1246 .or_else(|| document.database_name.clone());
1247 let schema = table
1248 .schema
1249 .clone()
1250 .or_else(|| document.schema_name.clone());
1251 let node = table_node(database.as_deref(), schema.as_deref(), &table.name);
1252 let columns = table
1253 .columns
1254 .iter()
1255 .map(|column| {
1256 (
1257 column.name.clone(),
1258 column_node(&node.stable_key, &column.name),
1259 )
1260 })
1261 .collect();
1262 TableInfo {
1263 node,
1264 database,
1265 schema,
1266 name: table.name.clone(),
1267 columns,
1268 }
1269}
1270
1271fn resolve_table<'a>(
1272 tables: &'a BTreeMap<String, TableInfo>,
1273 target: &str,
1274) -> Option<&'a TableInfo> {
1275 let matches = tables
1276 .values()
1277 .filter(|table| table_aliases(table).contains(target))
1278 .map(|table| table.node.id.clone())
1279 .collect::<BTreeSet<_>>();
1280 let id = exactly_one(matches)?;
1281 tables.values().find(|table| table.node.id == id)
1282}
1283
1284fn table_aliases(table: &TableInfo) -> BTreeSet<String> {
1285 let mut aliases = BTreeSet::from([
1286 table.node.stable_key.clone(),
1287 table
1288 .node
1289 .stable_key
1290 .strip_prefix("table:")
1291 .unwrap_or(&table.node.stable_key)
1292 .to_owned(),
1293 table.name.clone(),
1294 ]);
1295 if let Some(schema) = &table.schema {
1296 aliases.insert(format!("{schema}.{}", table.name));
1297 }
1298 if let Some(database) = &table.database {
1299 aliases.insert(format!("{database}.{}", table.name));
1300 if let Some(schema) = &table.schema {
1301 aliases.insert(format!("{database}.{schema}.{}", table.name));
1302 }
1303 }
1304 aliases
1305}
1306
1307fn resolve_or_create_config(
1308 builder: &GraphBuilder,
1309 repo_id: &RepoId,
1310 fallback_scope: &str,
1311 name: &str,
1312) -> Node {
1313 let matches = builder
1314 .facts
1315 .nodes
1316 .iter()
1317 .filter(|node| {
1318 node.kind == NodeKind::ConfigKey
1319 && node.repo_id.as_ref() == Some(repo_id)
1320 && node.label == name
1321 })
1322 .map(|node| node.id.clone())
1323 .collect::<BTreeSet<_>>();
1324 exactly_one(matches)
1325 .and_then(|id| {
1326 builder
1327 .facts
1328 .nodes
1329 .iter()
1330 .find(|node| node.id == id)
1331 .cloned()
1332 })
1333 .unwrap_or_else(|| config_node(repo_id, fallback_scope, name))
1334}
1335
1336fn resolve_infrastructure_target(
1337 deployments: &[DeploymentInfo],
1338 resources: &[ResourceInfo],
1339 target: &str,
1340) -> Option<Node> {
1341 let deployment_matches = deployments
1342 .iter()
1343 .filter(|deployment| deployment.aliases.contains(target))
1344 .map(|deployment| deployment.node.id.clone())
1345 .collect::<BTreeSet<_>>();
1346 if !deployment_matches.is_empty() {
1347 let id = exactly_one(deployment_matches)?;
1348 return deployments
1349 .iter()
1350 .find(|deployment| deployment.node.id == id)
1351 .map(|deployment| deployment.node.clone());
1352 }
1353 let resource_matches = resources
1354 .iter()
1355 .filter(|resource| resource.aliases.contains(target))
1356 .map(|resource| resource.node.id.clone())
1357 .collect::<BTreeSet<_>>();
1358 let id = exactly_one(resource_matches)?;
1359 resources
1360 .iter()
1361 .find(|resource| resource.node.id == id)
1362 .map(|resource| resource.node.clone())
1363}
1364
1365fn resolve_reference(
1366 candidates: &[Node],
1367 repository_aliases: &[(&str, &RepoId)],
1368 repo_id: &RepoId,
1369 reference: &ExplicitReference,
1370) -> Option<Node> {
1371 let matches = candidates
1372 .iter()
1373 .filter(|node| {
1374 reference_matches(
1375 node,
1376 repository_aliases,
1377 repo_id,
1378 reference.kind,
1379 &reference.target,
1380 )
1381 })
1382 .map(|node| node.id.clone())
1383 .collect::<BTreeSet<_>>();
1384 let id = exactly_one(matches)?;
1385 candidates.iter().find(|node| node.id == id).cloned()
1386}
1387
1388fn reference_matches(
1389 node: &Node,
1390 repository_aliases: &[(&str, &RepoId)],
1391 repo_id: &RepoId,
1392 kind: ExplicitReferenceKind,
1393 target: &str,
1394) -> bool {
1395 let expected = match kind {
1396 ExplicitReferenceKind::Repository => &[NodeKind::Repository][..],
1397 ExplicitReferenceKind::Service => &[NodeKind::Service][..],
1398 ExplicitReferenceKind::HttpContract => &[NodeKind::HttpOperation][..],
1399 ExplicitReferenceKind::EventChannel => &[NodeKind::EventChannel][..],
1400 ExplicitReferenceKind::GraphqlOperation => &[NodeKind::GraphqlOperation][..],
1401 ExplicitReferenceKind::RpcMethod => &[NodeKind::RpcMethod][..],
1402 ExplicitReferenceKind::DatabaseTable => &[NodeKind::DatabaseTable][..],
1403 ExplicitReferenceKind::Deployment => &[NodeKind::Deployment][..],
1404 ExplicitReferenceKind::ConfigKey => &[NodeKind::ConfigKey][..],
1405 ExplicitReferenceKind::Document => &[NodeKind::Document, NodeKind::Adr][..],
1406 ExplicitReferenceKind::Owner => &[NodeKind::Owner][..],
1407 };
1408 if !expected.contains(&node.kind) {
1409 return false;
1410 }
1411 if kind == ExplicitReferenceKind::Repository {
1412 return node.repo_id.as_ref().is_some_and(|candidate| {
1413 candidate.as_str() == target
1414 || repository_aliases
1415 .iter()
1416 .any(|(alias, repo)| *alias == target && *repo == candidate)
1417 });
1418 }
1419 if node.stable_key == target || node.label == target {
1420 return true;
1421 }
1422 let prefix = reference_prefix(kind);
1423 if node
1424 .stable_key
1425 .strip_prefix(prefix)
1426 .is_some_and(|suffix| suffix == target)
1427 {
1428 return true;
1429 }
1430 match kind {
1431 ExplicitReferenceKind::Service => {
1432 target
1433 .split_once(':')
1434 .and_then(|(alias, name)| {
1435 repository_aliases
1436 .iter()
1437 .find(|(candidate, _)| *candidate == alias)
1438 .map(|(_, repository)| (repository, name))
1439 })
1440 .is_some_and(|(repository, name)| {
1441 node.stable_key == format!("service:{}:{name}", repository.as_str())
1442 })
1443 || node.stable_key == format!("service:{}:{target}", repo_id.as_str())
1444 }
1445 ExplicitReferenceKind::Document => {
1446 node.stable_key == format!("document:{}:{target}", repo_id.as_str())
1447 }
1448 ExplicitReferenceKind::ConfigKey => {
1449 node.stable_key == format!("config:{}:{target}", repo_id.as_str())
1450 }
1451 ExplicitReferenceKind::Repository
1452 | ExplicitReferenceKind::HttpContract
1453 | ExplicitReferenceKind::EventChannel
1454 | ExplicitReferenceKind::GraphqlOperation
1455 | ExplicitReferenceKind::RpcMethod
1456 | ExplicitReferenceKind::DatabaseTable
1457 | ExplicitReferenceKind::Deployment
1458 | ExplicitReferenceKind::Owner => false,
1459 }
1460}
1461
1462const fn reference_prefix(kind: ExplicitReferenceKind) -> &'static str {
1463 match kind {
1464 ExplicitReferenceKind::Repository => "repository:",
1465 ExplicitReferenceKind::Service => "service:",
1466 ExplicitReferenceKind::HttpContract => "http:",
1467 ExplicitReferenceKind::EventChannel => "event:",
1468 ExplicitReferenceKind::GraphqlOperation => "graphql:",
1469 ExplicitReferenceKind::RpcMethod => "rpc:",
1470 ExplicitReferenceKind::DatabaseTable => "table:",
1471 ExplicitReferenceKind::Deployment => "deployment:",
1472 ExplicitReferenceKind::ConfigKey => "config:",
1473 ExplicitReferenceKind::Document => "document:",
1474 ExplicitReferenceKind::Owner => "owner:",
1475 }
1476}
1477
1478fn exactly_one<T: Ord>(mut values: BTreeSet<T>) -> Option<T> {
1479 (values.len() == 1).then(|| values.pop_first()).flatten()
1480}
1481
1482fn repository_node(repo_id: &RepoId) -> Node {
1483 graph_node(
1484 format!("repository:{}", repo_id.as_str()),
1485 NodeKind::Repository,
1486 Some(repo_id.clone()),
1487 repo_id.as_str(),
1488 )
1489}
1490
1491fn artifact_node(repo_id: &RepoId, source_path: &str) -> Node {
1492 graph_node(
1493 format!("artifact:{}:{source_path}", repo_id.as_str()),
1494 NodeKind::Artifact,
1495 Some(repo_id.clone()),
1496 source_path,
1497 )
1498}
1499
1500fn database_node(name: &str) -> Node {
1501 graph_node(format!("database:{name}"), NodeKind::Database, None, name)
1502}
1503
1504fn table_node(database: Option<&str>, schema: Option<&str>, name: &str) -> Node {
1505 graph_node(
1506 format!(
1507 "table:{}:{}:{name}",
1508 database.unwrap_or(""),
1509 schema.unwrap_or("")
1510 ),
1511 NodeKind::DatabaseTable,
1512 None,
1513 name,
1514 )
1515}
1516
1517fn column_node(table_key: &str, name: &str) -> Node {
1518 graph_node(
1519 format!("column:{table_key}:{name}"),
1520 NodeKind::DatabaseColumn,
1521 None,
1522 name,
1523 )
1524}
1525
1526fn data_symbol_node(repo_id: &RepoId, source_path: &str, owner: &str) -> Node {
1527 graph_node(
1528 format!("data-symbol:{}:{source_path}:{owner}", repo_id.as_str()),
1529 NodeKind::SymbolRef,
1530 Some(repo_id.clone()),
1531 owner,
1532 )
1533}
1534
1535fn deployment_node(technology: &str, namespace: Option<&str>, name: &str) -> Node {
1536 graph_node(
1537 format!("deployment:{technology}:{}:{name}", namespace.unwrap_or("")),
1538 NodeKind::Deployment,
1539 None,
1540 name,
1541 )
1542}
1543
1544fn service_node(repo_id: &RepoId, name: &str) -> Node {
1545 graph_node(
1546 format!("service:{}:{name}", repo_id.as_str()),
1547 NodeKind::Service,
1548 Some(repo_id.clone()),
1549 name,
1550 )
1551}
1552
1553fn config_node(repo_id: &RepoId, scope: &str, name: &str) -> Node {
1554 graph_node(
1555 format!("config:{}:{scope}:{name}", repo_id.as_str()),
1556 NodeKind::ConfigKey,
1557 Some(repo_id.clone()),
1558 name,
1559 )
1560}
1561
1562fn event_channel_node(namespace: Option<&str>, name: &str) -> Node {
1563 graph_node(
1564 format!("event:infrastructure:{}:{name}", namespace.unwrap_or("")),
1565 NodeKind::EventChannel,
1566 None,
1567 name,
1568 )
1569}
1570
1571fn document_node(
1572 repo_id: &RepoId,
1573 source_path: &str,
1574 kind: DocumentKind,
1575 _title: Option<&str>,
1576) -> Node {
1577 graph_node(
1578 format!("document:{}:{source_path}", repo_id.as_str()),
1579 if kind == DocumentKind::Adr {
1580 NodeKind::Adr
1581 } else {
1582 NodeKind::Document
1583 },
1584 Some(repo_id.clone()),
1585 source_path,
1586 )
1587}
1588
1589fn owner_node(name: &str) -> Node {
1590 graph_node(format!("owner:{name}"), NodeKind::Owner, None, name)
1591}
1592
1593fn graph_node(stable_key: String, kind: NodeKind, repo_id: Option<RepoId>, label: &str) -> Node {
1594 Node {
1595 id: NodeId::new(stable_id("node", &stable_key)),
1596 kind,
1597 repo_id,
1598 stable_key,
1599 label: label.to_owned(),
1600 }
1601}
1602
1603fn extraction_evidence(
1604 repo_id: &RepoId,
1605 source_path: &str,
1606 content_hash: &str,
1607 start_line: Option<u32>,
1608 end_line: Option<u32>,
1609 extractor: &str,
1610 note: &str,
1611) -> Evidence {
1612 let key = format!(
1613 "{}:{source_path}:{}:{}:{extractor}:{note}:{content_hash}",
1614 repo_id.as_str(),
1615 start_line.map_or_else(String::new, |line| line.to_string()),
1616 end_line.map_or_else(String::new, |line| line.to_string())
1617 );
1618 Evidence {
1619 id: EvidenceId::new(stable_id("evidence", &key)),
1620 repo_id: Some(repo_id.clone()),
1621 file_path: Some(source_path.to_owned()),
1622 start_line,
1623 end_line,
1624 extractor: extractor.to_owned(),
1625 extractor_version: "1.0.0".to_owned(),
1626 provenance: Provenance::Extracted,
1627 confidence: 1.0,
1628 observed_at_commit: None,
1629 content_hash: Some(content_hash.to_owned()),
1630 note: Some(note.to_owned()),
1631 }
1632}
1633
1634const fn deployment_technology(kind: DeploymentKind) -> &'static str {
1635 match kind {
1636 DeploymentKind::DockerComposeService => "docker_compose_service",
1637 DeploymentKind::KubernetesDeployment => "kubernetes_deployment",
1638 DeploymentKind::KubernetesStatefulSet => "kubernetes_stateful_set",
1639 DeploymentKind::KubernetesDaemonSet => "kubernetes_daemon_set",
1640 DeploymentKind::KubernetesJob => "kubernetes_job",
1641 DeploymentKind::KubernetesCronJob => "kubernetes_cron_job",
1642 DeploymentKind::KubernetesPod => "kubernetes_pod",
1643 DeploymentKind::HelmTemplate => "helm_template",
1644 DeploymentKind::TerraformResource => "terraform_resource",
1645 }
1646}
1647
1648fn infrastructure_line(evidence: &[InfrastructureEvidence]) -> Option<u32> {
1649 evidence.iter().filter_map(|item| item.line).min()
1650}
1651
1652fn line_from_usize(line: usize) -> Option<u32> {
1653 u32::try_from(line).ok()
1654}
1655
1656#[cfg(test)]
1657mod tests {
1658 use code_system_graph_model::{EdgeKind, NodeKind, RepoId};
1659
1660 use super::documents_to_graph;
1661 use crate::{
1662 DataAccessObservation, DataAccessRole, DataArtifactKind, DataDocument, DataEvidenceLine, DataOperation, DatabaseTable, DeploymentKind, DeploymentUnit, InfrastructureArtifactKind, InfrastructureDocument, InfrastructureEvidence, InfrastructureEvidenceKind, SourceLanguage, extract_data_artifact, extract_safe_config, extract_service_catalog, parse_literal_sql_source_at_root
1663 };
1664
1665 #[test]
1666 fn data_reader_links_source_symbol_to_unique_table() {
1667 let repo = RepoId::new("repo:data");
1668 let line = DataEvidenceLine::new(7).expect("valid evidence line");
1669 let declaration = DataDocument {
1670 source_path: "schema.sql".to_owned(),
1671 artifact_kind: DataArtifactKind::DeclarativeSqlSchema,
1672 database_name: None,
1673 schema_name: None,
1674 tables: vec![DatabaseTable {
1675 database: None,
1676 schema: None,
1677 name: "users".to_owned(),
1678 columns: Vec::new(),
1679 indexes: Vec::new(),
1680 foreign_keys: Vec::new(),
1681 evidence: line,
1682 }],
1683 migration: None,
1684 accesses: Vec::new(),
1685 frameworks: Vec::new(),
1686 references: Vec::new(),
1687 owners: Vec::new(),
1688 warnings: Vec::new(),
1689 incomplete: false,
1690 };
1691 let reader = DataDocument {
1692 source_path: "src/users.rs".to_owned(),
1693 artifact_kind: DataArtifactKind::LiteralQuerySource,
1694 database_name: None,
1695 schema_name: None,
1696 tables: Vec::new(),
1697 migration: None,
1698 accesses: vec![DataAccessObservation {
1699 role: DataAccessRole::Reader,
1700 table: "users".to_owned(),
1701 model: None,
1702 owner: Some("load_users".to_owned()),
1703 operation: Some(DataOperation::Select),
1704 evidence: line,
1705 }],
1706 frameworks: Vec::new(),
1707 references: Vec::new(),
1708 owners: vec!["load_users".to_owned()],
1709 warnings: Vec::new(),
1710 incomplete: false,
1711 };
1712
1713 let facts = documents_to_graph(
1714 &[
1715 (&repo, "schema.sql", "schema-hash", &declaration),
1716 (&repo, "src/users.rs", "source-hash", &reader),
1717 ],
1718 &[],
1719 &[],
1720 &[],
1721 &[],
1722 &[],
1723 );
1724
1725 assert!(facts.edges.iter().any(|edge| {
1726 edge.kind == EdgeKind::ReadsTable
1727 && facts.nodes.iter().any(|node| {
1728 node.id == edge.source
1729 && node.kind == NodeKind::SymbolRef
1730 && node.label == "load_users"
1731 })
1732 && facts.nodes.iter().any(|node| {
1733 node.id == edge.target
1734 && node.kind == NodeKind::DatabaseTable
1735 && node.label == "users"
1736 })
1737 }));
1738 }
1739
1740 #[test]
1741 fn sqlalchemy_access_links_through_explicit_model_binding() {
1742 let repo = RepoId::new("repo:orm");
1743 let models = extract_data_artifact(
1744 "app/models.py",
1745 r#"
1746class Services(Base):
1747 __tablename__ = "services"
1748 id = Column(Integer, primary_key=True)
1749"#,
1750 )
1751 .expect("model should parse");
1752 let controller = parse_literal_sql_source_at_root(
1753 SourceLanguage::Python,
1754 "app/controllers.py",
1755 "",
1756 r"
1757from sqlalchemy.orm import Session
1758from app import models
1759
1760def list_services(db: Session):
1761 return db.query(models.Services).all()
1762
1763def create_service(db: Session):
1764 service = models.Services()
1765 db.add(service)
1766",
1767 );
1768
1769 let facts = documents_to_graph(
1770 &[
1771 (&repo, "app/models.py", "models-hash", &models),
1772 (&repo, "app/controllers.py", "controllers-hash", &controller),
1773 ],
1774 &[],
1775 &[],
1776 &[],
1777 &[],
1778 &[],
1779 );
1780
1781 assert!(facts.edges.iter().any(|edge| {
1782 edge.kind == EdgeKind::ReadsTable
1783 && facts
1784 .nodes
1785 .iter()
1786 .any(|node| node.id == edge.source && node.label == "list_services")
1787 && facts
1788 .nodes
1789 .iter()
1790 .any(|node| node.id == edge.target && node.label == "services")
1791 }));
1792 assert!(facts.edges.iter().any(|edge| {
1793 edge.kind == EdgeKind::WritesTable
1794 && facts
1795 .nodes
1796 .iter()
1797 .any(|node| node.id == edge.source && node.label == "create_service")
1798 && facts
1799 .nodes
1800 .iter()
1801 .any(|node| node.id == edge.target && node.label == "services")
1802 }));
1803 }
1804
1805 #[test]
1806 fn sqlx_query_file_links_calling_symbol_to_file_and_table() {
1807 let repo = RepoId::new("repo:data");
1808 let schema =
1809 extract_data_artifact("crates/api/schema.sql", "CREATE TABLE users (id INTEGER);")
1810 .expect("schema should parse");
1811 let query = extract_data_artifact(
1812 "crates/api/queries/users.sql",
1813 "SELECT id FROM users WHERE id = $1;",
1814 )
1815 .expect("query should parse");
1816 let source = parse_literal_sql_source_at_root(
1817 SourceLanguage::Rust,
1818 "crates/api/src/users.rs",
1819 "crates/api",
1820 r#"
1821async fn load_user(pool: &sqlx::PgPool) {
1822 sqlx::query_file!("queries/users.sql", 7_i64).fetch_one(pool).await;
1823}
1824"#,
1825 );
1826
1827 let facts = documents_to_graph(
1828 &[
1829 (&repo, "crates/api/schema.sql", "schema-hash", &schema),
1830 (&repo, "crates/api/queries/users.sql", "query-hash", &query),
1831 (&repo, "crates/api/src/users.rs", "source-hash", &source),
1832 ],
1833 &[],
1834 &[],
1835 &[],
1836 &[],
1837 &[],
1838 );
1839 let symbol = facts
1840 .nodes
1841 .iter()
1842 .find(|node| node.kind == NodeKind::SymbolRef && node.label == "load_user")
1843 .expect("calling symbol");
1844 let query_artifact = facts
1845 .nodes
1846 .iter()
1847 .find(|node| {
1848 node.kind == NodeKind::Artifact && node.label == "crates/api/queries/users.sql"
1849 })
1850 .expect("query artifact");
1851 let users = facts
1852 .nodes
1853 .iter()
1854 .find(|node| node.kind == NodeKind::DatabaseTable && node.label == "users")
1855 .expect("users table");
1856
1857 assert!(facts.edges.iter().any(|edge| {
1858 edge.source == symbol.id
1859 && edge.target == query_artifact.id
1860 && edge.kind == EdgeKind::Consumes
1861 }));
1862 assert!(facts.edges.iter().any(|edge| {
1863 edge.source == symbol.id && edge.target == users.id && edge.kind == EdgeKind::ReadsTable
1864 }));
1865 }
1866
1867 #[test]
1868 fn sqlx_default_migrate_macro_uses_incremental_configuration_artifact() {
1869 let repo = RepoId::new("repo:data");
1870 let source = parse_literal_sql_source_at_root(
1871 SourceLanguage::Rust,
1872 "crates/api/src/lib.rs",
1873 "crates/api",
1874 "static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!();",
1875 );
1876 let configuration = extract_data_artifact(
1877 "crates/api/sqlx.toml",
1878 "[migrate]\nmigrations-dir = \"db/migrations\"\n",
1879 )
1880 .expect("SQLx configuration should parse");
1881 let migration = extract_data_artifact(
1882 "crates/api/db/migrations/0001_users.sql",
1883 "CREATE TABLE users (id INTEGER);",
1884 )
1885 .expect("migration should parse");
1886
1887 let facts = documents_to_graph(
1888 &[
1889 (&repo, "crates/api/src/lib.rs", "source-hash", &source),
1890 (
1891 &repo,
1892 "crates/api/sqlx.toml",
1893 "configuration-hash",
1894 &configuration,
1895 ),
1896 (
1897 &repo,
1898 "crates/api/db/migrations/0001_users.sql",
1899 "migration-hash",
1900 &migration,
1901 ),
1902 ],
1903 &[],
1904 &[],
1905 &[],
1906 &[],
1907 &[],
1908 );
1909 let source_artifact = facts
1910 .nodes
1911 .iter()
1912 .find(|node| node.kind == NodeKind::Artifact && node.label == "crates/api/src/lib.rs")
1913 .expect("source artifact");
1914 let migration_artifact = facts
1915 .nodes
1916 .iter()
1917 .find(|node| {
1918 node.kind == NodeKind::Artifact
1919 && node.label == "crates/api/db/migrations/0001_users.sql"
1920 })
1921 .expect("migration artifact");
1922
1923 assert!(facts.edges.iter().any(|edge| {
1924 edge.source == source_artifact.id
1925 && edge.target == migration_artifact.id
1926 && edge.kind == EdgeKind::Consumes
1927 }));
1928 }
1929
1930 #[test]
1931 fn reversible_migrations_keep_empty_artifacts_and_explicit_lineage() {
1932 let repo = RepoId::new("repo:migrations");
1933 let first_up = extract_data_artifact(
1934 "migrations/0001_users.up.sql",
1935 "CREATE TABLE users (id INTEGER);",
1936 )
1937 .expect("up migration should parse");
1938 let first_down =
1939 extract_data_artifact("migrations/0001_users.down.sql", "DROP TABLE users;")
1940 .expect("down migration should parse");
1941 let second_up = extract_data_artifact(
1942 "migrations/0002_accounts.up.sql",
1943 "CREATE TABLE accounts (id INTEGER);",
1944 )
1945 .expect("second migration should parse");
1946
1947 let facts = documents_to_graph(
1948 &[
1949 (&repo, "migrations/0001_users.up.sql", "first-up", &first_up),
1950 (
1951 &repo,
1952 "migrations/0001_users.down.sql",
1953 "first-down",
1954 &first_down,
1955 ),
1956 (
1957 &repo,
1958 "migrations/0002_accounts.up.sql",
1959 "second-up",
1960 &second_up,
1961 ),
1962 ],
1963 &[],
1964 &[],
1965 &[],
1966 &[],
1967 &[],
1968 );
1969 let artifact = |label: &str| {
1970 facts
1971 .nodes
1972 .iter()
1973 .find(|node| node.kind == NodeKind::Artifact && node.label == label)
1974 .unwrap_or_else(|| panic!("missing migration artifact {label}"))
1975 };
1976 let first_up = artifact("migrations/0001_users.up.sql");
1977 let first_down = artifact("migrations/0001_users.down.sql");
1978 let second_up = artifact("migrations/0002_accounts.up.sql");
1979
1980 assert!(facts.edges.iter().any(|edge| {
1981 edge.source == first_down.id
1982 && edge.target == first_up.id
1983 && edge.kind == EdgeKind::Reverts
1984 }));
1985 assert!(facts.edges.iter().any(|edge| {
1986 edge.source == first_up.id
1987 && edge.target == second_up.id
1988 && edge.kind == EdgeKind::Precedes
1989 }));
1990 }
1991
1992 #[test]
1993 fn repository_deploys_unit_that_provides_exact_service() {
1994 let repo = RepoId::new("repo:api");
1995 let document = InfrastructureDocument {
1996 artifact_kind: InfrastructureArtifactKind::DockerCompose,
1997 source_path: "compose.yaml".to_owned(),
1998 deployment_units: vec![DeploymentUnit {
1999 kind: DeploymentKind::DockerComposeService,
2000 name: "api".to_owned(),
2001 namespace: None,
2002 images: Vec::new(),
2003 ports: Vec::new(),
2004 environment_keys: Vec::new(),
2005 dependencies: Vec::new(),
2006 service_names: vec!["api".to_owned()],
2007 host_aliases: Vec::new(),
2008 selectors: Vec::new(),
2009 evidence: vec![InfrastructureEvidence {
2010 kind: InfrastructureEvidenceKind::Declaration,
2011 line: Some(2),
2012 }],
2013 }],
2014 resources: Vec::new(),
2015 environment_keys: Vec::new(),
2016 warnings: Vec::new(),
2017 incomplete: false,
2018 };
2019
2020 let facts = documents_to_graph(
2021 &[],
2022 &[(&repo, "compose.yaml", "compose-hash", &document)],
2023 &[],
2024 &[],
2025 &[],
2026 &[],
2027 );
2028
2029 assert!(
2030 [EdgeKind::Deploys, EdgeKind::Provides]
2031 .into_iter()
2032 .all(|kind| facts.edges.iter().any(|edge| edge.kind == kind))
2033 );
2034 }
2035
2036 #[test]
2037 fn explicit_catalog_references_document_service_and_owner() {
2038 let repo = RepoId::new("repo:billing");
2039 let document = extract_service_catalog(
2040 "catalog.yaml",
2041 "services:\n - name: billing\n owner: '@payments'\n",
2042 )
2043 .expect("valid service catalog");
2044
2045 let facts = documents_to_graph(
2046 &[],
2047 &[],
2048 &[(&repo, "catalog.yaml", "catalog-hash", &document)],
2049 &[],
2050 &[],
2051 &[],
2052 );
2053
2054 assert!(
2055 facts
2056 .edges
2057 .iter()
2058 .filter(|edge| edge.kind == EdgeKind::Documents)
2059 .any(|edge| {
2060 facts.nodes.iter().any(|node| {
2061 node.id == edge.target
2062 && node.kind == NodeKind::Service
2063 && node.label == "billing"
2064 })
2065 })
2066 && facts
2067 .edges
2068 .iter()
2069 .filter(|edge| edge.kind == EdgeKind::Documents)
2070 .any(|edge| {
2071 facts.nodes.iter().any(|node| {
2072 node.id == edge.target
2073 && node.kind == NodeKind::Owner
2074 && node.label == "@payments"
2075 })
2076 })
2077 );
2078 }
2079
2080 #[test]
2081 fn serialized_graph_never_contains_fixture_secret() {
2082 const FIXTURE_SECRET: &str = "extraction-fixture-secret-5f16";
2083 let repo = RepoId::new("repo:config");
2084 let source = format!("API_TOKEN={FIXTURE_SECRET}\n");
2085 let document = extract_safe_config(".env", &source).expect("valid dotenv");
2086 let facts = documents_to_graph(
2087 &[],
2088 &[],
2089 &[],
2090 &[(&repo, ".env", "config-hash", &document)],
2091 &[],
2092 &[],
2093 );
2094
2095 let serialized = serde_json::to_string(&(&facts.nodes, &facts.edges, &facts.evidence))
2096 .expect("graph facts should serialize");
2097
2098 assert!(!serialized.contains(FIXTURE_SECRET));
2099 }
2100}