Skip to main content

code_system_graph_core/
extraction_graph.rs

1//! Deterministic graph conversion for extraction documents.
2
3use 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/// Graph-ready facts assembled from supported extraction documents.
14#[derive(Debug, Clone, Default)]
15pub struct ExtractionGraphFacts {
16    /// Repository, artifact, data, deployment, configuration, document, and owner nodes.
17    pub nodes: Vec<Node>,
18    /// Directly evidenced extraction relationships.
19    pub edges: Vec<Edge>,
20    /// Value-free evidence supporting the emitted relationships.
21    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/// Converts all supported extractor outputs into deterministic, ambiguity-safe graph facts.
165///
166/// Only exact, unique targets are linked. Dynamic or incomplete observations that lack a concrete
167/// target remain unlinked, and retained evidence contains locators and static notes rather than
168/// source or configuration values.
169#[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    // Data observations can resolve across documents, so they are linked after every declaration
191    // has entered the exact-match table index.
192    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}