Skip to main content

tfparser_core/graph/
builder.rs

1//! Top-level graph builder.
2//!
3//! [`GraphBuilder::build`] is the Phase 5 entry point. Given a vector of
4//! per-component [`EvaluatedComponent`]s (the evaluator output) and a
5//! [`ModuleRegistry`] indexing every local module body, it flattens module
6//! bodies into their callers, applies `count`/`for_each` expansion, sorts the
7//! result deterministically, and returns a [`Workspace`] suitable for the
8//! Parquet exporter (Phase 3) to write.
9//!
10//! Per [15-resource-graph.md § 2] and § 3.
11
12use std::{collections::HashSet, path::Path, sync::Arc};
13
14use crate::{
15    Result,
16    diagnostic::Diagnostic,
17    eval::EvaluatedComponent,
18    graph::{
19        expand::{ExpansionState, expand_resource, flatten_modules},
20        registry::ModuleRegistry,
21    },
22    ir::{Component, Resource, Workspace},
23};
24
25/// Per-`build` context: workspace root, recursion / expansion caps.
26///
27/// `infer_dependencies` is reserved for Phase 8 (edge collection); for
28/// Phase 5 it has no effect.
29#[derive(Clone, Debug)]
30#[non_exhaustive]
31pub struct GraphContext {
32    /// Canonical workspace root (absolute path). All module sources must
33    /// resolve underneath this.
34    pub workspace_root: Arc<Path>,
35    /// Maximum nested-module recursion depth (per `I-GRAPH-4`).
36    pub max_module_depth: u32,
37    /// Reserved for Phase 8 (edge collection). Phase 5 ignores it.
38    pub infer_dependencies: bool,
39    /// `count` / `for_each` expansion cap. Spec 15 § 3.3 pins 1024 as the
40    /// default; the cap collapses to a template row + a diagnostic when
41    /// exceeded.
42    pub max_expansion_per_resource: u32,
43}
44
45impl GraphContext {
46    /// Construct a `GraphContext` with the spec defaults: `max_module_depth =
47    /// 8`, `infer_dependencies = true`, `max_expansion_per_resource = 1024`.
48    #[must_use]
49    pub fn new(workspace_root: Arc<Path>) -> Self {
50        Self {
51            workspace_root,
52            max_module_depth: 8,
53            infer_dependencies: true,
54            max_expansion_per_resource: 1024,
55        }
56    }
57}
58
59/// Trait the orchestrator calls to build a [`Workspace`]. Phase 5 ships
60/// exactly one implementation, [`DefaultGraphBuilder`]; downstream tests
61/// may swap an in-memory variant.
62pub trait GraphBuilder: Send + Sync + std::fmt::Debug {
63    /// Flatten every module call into its caller and produce a [`Workspace`].
64    ///
65    /// # Errors
66    ///
67    /// Returns [`crate::Error`] only on fatal IR-construction failures
68    /// (currently: an [`crate::ir::Address`] collision after expansion).
69    /// Non-fatal anomalies — unresolvable module sources, depth-cap
70    /// breaches, cycles — surface as
71    /// [`Workspace::diagnostics`](crate::ir::Workspace::diagnostics).
72    fn build(
73        &self,
74        components: Vec<EvaluatedComponent>,
75        registry: &ModuleRegistry,
76        ctx: &GraphContext,
77    ) -> Result<Workspace>;
78}
79
80/// Default builder.
81#[derive(Debug, Default)]
82#[non_exhaustive]
83pub struct DefaultGraphBuilder;
84
85impl DefaultGraphBuilder {
86    /// Construct a default builder. Equivalent to `DefaultGraphBuilder::default()`.
87    #[must_use]
88    pub const fn new() -> Self {
89        Self
90    }
91}
92
93impl GraphBuilder for DefaultGraphBuilder {
94    fn build(
95        &self,
96        components: Vec<EvaluatedComponent>,
97        registry: &ModuleRegistry,
98        ctx: &GraphContext,
99    ) -> Result<Workspace> {
100        let mut diagnostics: Vec<Diagnostic> = Vec::new();
101        let mut out_components: Vec<Component> = Vec::with_capacity(components.len());
102
103        // Only top-level components (kind=Component) become rows in the
104        // workspace; modules contribute by expansion into their callers
105        // (D5 — flattened module bodies).
106        for evaluated in &components {
107            if !matches!(evaluated.raw.kind, crate::ir::ComponentKind::Component) {
108                continue;
109            }
110            let mut state = ExpansionState::new(
111                ctx.workspace_root.as_ref(),
112                ctx.max_module_depth,
113                ctx.max_expansion_per_resource,
114                registry,
115            );
116            let module_resources: Vec<Resource> = flatten_modules(evaluated, &mut state);
117            diagnostics.append(&mut state.diagnostics);
118
119            // Combine top-level + module-expanded resources. Both get
120            // count/for_each expansion uniformly.
121            let mut combined: Vec<Resource> =
122                Vec::with_capacity(evaluated.resources.len() + module_resources.len());
123            for r in &evaluated.resources {
124                let expanded =
125                    expand_resource(r.clone(), ctx.max_expansion_per_resource, &mut diagnostics);
126                combined.extend(expanded);
127            }
128            for r in module_resources {
129                let expanded = expand_resource(r, ctx.max_expansion_per_resource, &mut diagnostics);
130                combined.extend(expanded);
131            }
132
133            // Address uniqueness (I-GRAPH-1): de-duplicate by address,
134            // emitting a diagnostic on collision. We retain the first
135            // occurrence rather than failing fatally — per spec 15 § 7,
136            // collisions indicate a bug in expansion logic; the test
137            // suite covers the case, and at runtime we prefer to keep
138            // the run alive with a loud diagnostic.
139            let mut seen: HashSet<String> = HashSet::with_capacity(combined.len());
140            let mut deduped: Vec<Resource> = Vec::with_capacity(combined.len());
141            for r in combined {
142                let key = r.address.as_str().to_string();
143                if seen.insert(key.clone()) {
144                    deduped.push(r);
145                } else {
146                    diagnostics.push(crate::diagnostic::Diagnostic::new(
147                        crate::Severity::Warn,
148                        "TF1506",
149                        format!("address collision after expansion: {key}"),
150                    ));
151                }
152            }
153
154            // Build the final Component, carrying over evaluator-resolved
155            // pieces. Component diagnostics from the evaluator are
156            // surfaced through Workspace.diagnostics.
157            diagnostics.extend(evaluated.diagnostics.iter().cloned());
158
159            let raw = evaluated.raw.as_ref();
160            out_components.push(
161                Component::builder()
162                    .id(raw.id)
163                    .path(Arc::clone(&raw.path))
164                    .kind(raw.kind)
165                    .files(raw.files.clone())
166                    .variables(evaluated.variables.clone())
167                    .locals(evaluated.locals.clone())
168                    .providers(evaluated.providers.clone())
169                    .resources(deduped)
170                    .modules(evaluated.modules.clone())
171                    .outputs(evaluated.outputs.clone())
172                    .terragrunt(raw.terragrunt.clone())
173                    .state_backend(raw.state_backend.clone())
174                    .build(),
175            );
176        }
177
178        // Workspace components are sorted by `Component.path` per I-GRAPH-5.
179        out_components.sort_by(|a, b| a.path.cmp(&b.path));
180
181        // Build workspace.modules from the registry (placeholder — Phase 8
182        // will replace with the dependency-graph view; Phase 5 just
183        // surfaces the modules we walked so the round-trip test pins the
184        // shape).
185        let modules = build_workspace_modules(&components);
186
187        let mut ws = Workspace::builder()
188            .root(Arc::clone(&ctx.workspace_root))
189            .components(out_components)
190            .modules(modules)
191            .diagnostics(diagnostics)
192            .build();
193
194        // Phase 8: dependency-edge inference. Off when the orchestrator
195        // wants only the row table (`infer_dependencies = false`).
196        if ctx.infer_dependencies {
197            super::edges::collect_edges_in_place(&mut ws);
198        }
199
200        Ok(ws)
201    }
202}
203
204fn build_workspace_modules(components: &[EvaluatedComponent]) -> Vec<crate::ir::Module> {
205    use crate::ir::{Module, ModuleId, ModuleSource};
206
207    let mut modules: Vec<Module> = Vec::new();
208    for (i, e) in components.iter().enumerate() {
209        let raw = e.raw.as_ref();
210        if !matches!(raw.kind, crate::ir::ComponentKind::Module) {
211            continue;
212        }
213        let id = ModuleId::from_index(i);
214        modules.push(
215            Module::builder()
216                .id(id)
217                .source(ModuleSource::Local(Arc::from(
218                    raw.path.to_string_lossy().as_ref(),
219                )))
220                .component(raw.clone())
221                .build(),
222        );
223    }
224    modules
225}
226
227#[cfg(test)]
228#[allow(clippy::unwrap_used, clippy::indexing_slicing, clippy::panic)]
229mod tests {
230    use std::path::{Path, PathBuf};
231
232    use super::*;
233    use crate::{
234        eval::EvaluatedComponent,
235        ir::{
236            Address, AttributeMap, Component, ComponentId, ComponentKind, Expression, ModuleCall,
237            ModuleSource, Resource, ResourceKind, Span, SymbolKind, Symbolic, Value,
238        },
239    };
240
241    fn span() -> Span {
242        Span::synthetic()
243    }
244
245    fn eval(c: &Component) -> EvaluatedComponent {
246        EvaluatedComponent::from_component(c.clone())
247    }
248
249    #[test]
250    fn test_builder_passes_through_top_level_resources() {
251        let r = Resource::builder()
252            .address(Address::new("aws_iam_role.r").unwrap())
253            .kind(ResourceKind::Managed)
254            .type_(Arc::<str>::from("aws_iam_role"))
255            .name(Arc::<str>::from("r"))
256            .span(span())
257            .build();
258        let c = Component::builder()
259            .id(ComponentId::from_index(0))
260            .path(Arc::<Path>::from(PathBuf::from("svc")))
261            .kind(ComponentKind::Component)
262            .resources(vec![r])
263            .build();
264        let registry = ModuleRegistry::new();
265        let ctx = GraphContext::new(Arc::<Path>::from(PathBuf::from("/tmp/repo")));
266        let workspace = DefaultGraphBuilder
267            .build(vec![eval(&c)], &registry, &ctx)
268            .unwrap();
269        assert_eq!(workspace.components.len(), 1);
270        assert_eq!(workspace.components[0].resources.len(), 1);
271    }
272
273    #[test]
274    fn test_builder_skips_module_kind_components() {
275        let c = Component::builder()
276            .id(ComponentId::from_index(0))
277            .path(Arc::<Path>::from(PathBuf::from("modules/x")))
278            .kind(ComponentKind::Module)
279            .build();
280        let registry = ModuleRegistry::new();
281        let ctx = GraphContext::new(Arc::<Path>::from(PathBuf::from("/tmp/repo")));
282        let workspace = DefaultGraphBuilder
283            .build(vec![eval(&c)], &registry, &ctx)
284            .unwrap();
285        assert!(workspace.components.is_empty());
286        // Module body lives in workspace.modules instead.
287        assert_eq!(workspace.modules.len(), 1);
288    }
289
290    #[test]
291    fn test_count_literal_expands_top_level_resources() {
292        let r = Resource::builder()
293            .address(Address::new("aws_iam_role.r").unwrap())
294            .kind(ResourceKind::Managed)
295            .type_(Arc::<str>::from("aws_iam_role"))
296            .name(Arc::<str>::from("r"))
297            .count_expr(Some(Expression::Literal(Value::Int(3))))
298            .span(span())
299            .build();
300        let c = Component::builder()
301            .id(ComponentId::from_index(0))
302            .path(Arc::<Path>::from(PathBuf::from("svc")))
303            .kind(ComponentKind::Component)
304            .resources(vec![r])
305            .build();
306        let registry = ModuleRegistry::new();
307        let ctx = GraphContext::new(Arc::<Path>::from(PathBuf::from("/tmp/repo")));
308        let workspace = DefaultGraphBuilder
309            .build(vec![eval(&c)], &registry, &ctx)
310            .unwrap();
311        let addrs: Vec<&str> = workspace.components[0]
312            .resources
313            .iter()
314            .map(|r| r.address.as_str())
315            .collect();
316        assert_eq!(
317            addrs,
318            vec![
319                "aws_iam_role.r[0]",
320                "aws_iam_role.r[1]",
321                "aws_iam_role.r[2]",
322            ]
323        );
324    }
325
326    #[test]
327    fn test_count_unresolved_keeps_one_template_row() {
328        let r = Resource::builder()
329            .address(Address::new("aws_iam_role.r").unwrap())
330            .kind(ResourceKind::Managed)
331            .type_(Arc::<str>::from("aws_iam_role"))
332            .name(Arc::<str>::from("r"))
333            .count_expr(Some(Expression::Unresolved(
334                Symbolic::builder()
335                    .kind(SymbolKind::Var)
336                    .source(Arc::<str>::from("var.foo"))
337                    .span(span())
338                    .build(),
339            )))
340            .span(span())
341            .build();
342        let c = Component::builder()
343            .id(ComponentId::from_index(0))
344            .path(Arc::<Path>::from(PathBuf::from("svc")))
345            .kind(ComponentKind::Component)
346            .resources(vec![r])
347            .build();
348        let registry = ModuleRegistry::new();
349        let ctx = GraphContext::new(Arc::<Path>::from(PathBuf::from("/tmp/repo")));
350        let workspace = DefaultGraphBuilder
351            .build(vec![eval(&c)], &registry, &ctx)
352            .unwrap();
353        let resources = &workspace.components[0].resources;
354        assert_eq!(resources.len(), 1);
355        assert_eq!(resources[0].address.as_str(), "aws_iam_role.r");
356        assert!(resources[0].count_expr.is_some());
357    }
358
359    #[test]
360    fn test_workspace_components_sorted_by_path() {
361        let c1 = Component::builder()
362            .id(ComponentId::from_index(0))
363            .path(Arc::<Path>::from(PathBuf::from("z")))
364            .kind(ComponentKind::Component)
365            .build();
366        let c2 = Component::builder()
367            .id(ComponentId::from_index(1))
368            .path(Arc::<Path>::from(PathBuf::from("a")))
369            .kind(ComponentKind::Component)
370            .build();
371        let registry = ModuleRegistry::new();
372        let ctx = GraphContext::new(Arc::<Path>::from(PathBuf::from("/tmp/repo")));
373        let workspace = DefaultGraphBuilder
374            .build(vec![eval(&c1), eval(&c2)], &registry, &ctx)
375            .unwrap();
376        assert_eq!(workspace.components[0].path.as_ref(), Path::new("a"));
377        assert_eq!(workspace.components[1].path.as_ref(), Path::new("z"));
378    }
379
380    fn make_module_call(call_name: &str, source_rel: &str, inputs: AttributeMap) -> ModuleCall {
381        let raw: Arc<str> = Arc::from(source_rel);
382        ModuleCall::builder()
383            .address(Address::new(format!("module.{call_name}")).unwrap())
384            .source_raw(Arc::clone(&raw))
385            .source(ModuleSource::classify(&raw))
386            .inputs(inputs)
387            .span(span())
388            .build()
389    }
390
391    fn module_body(addr: &str) -> EvaluatedComponent {
392        let r = Resource::builder()
393            .address(Address::new(addr).unwrap())
394            .kind(ResourceKind::Managed)
395            .type_(Arc::<str>::from("aws_s3_bucket"))
396            .name(Arc::<str>::from("this"))
397            .attributes(vec![(
398                Arc::from("name"),
399                Expression::Unresolved(
400                    Symbolic::builder()
401                        .kind(SymbolKind::Var)
402                        .source(Arc::<str>::from("var.name"))
403                        .span(span())
404                        .build(),
405                ),
406            )])
407            .span(span())
408            .build();
409        let c = Component::builder()
410            .id(ComponentId::from_index(0))
411            .path(Arc::<Path>::from(PathBuf::from("modules/s3")))
412            .kind(ComponentKind::Module)
413            .resources(vec![r])
414            .build();
415        eval(&c)
416    }
417
418    fn module_calling_self(canonical: &Arc<Path>) -> EvaluatedComponent {
419        let mc = ModuleCall::builder()
420            .address(Address::new("module.self_ref").unwrap())
421            .source_raw(Arc::<str>::from("."))
422            .source(ModuleSource::Local(Arc::from(".")))
423            .span(span())
424            .build();
425        let c = Component::builder()
426            .id(ComponentId::from_index(0))
427            .path(Arc::clone(canonical))
428            .kind(ComponentKind::Module)
429            .modules(vec![mc])
430            .build();
431        eval(&c)
432    }
433
434    #[test]
435    fn test_should_detect_module_self_cycle_and_emit_diagnostic() {
436        let tmp = tempfile::tempdir().unwrap();
437        let root: Arc<Path> = Arc::from(std::fs::canonicalize(tmp.path()).unwrap());
438        std::fs::create_dir_all(root.join("mod")).unwrap();
439        let mod_path: Arc<Path> = Arc::from(root.join("mod"));
440
441        let mut registry = ModuleRegistry::new();
442        // Module body that calls itself via `source = "."`.
443        let module_eval = module_calling_self(&mod_path);
444        registry.insert_local(Arc::clone(&mod_path), module_eval);
445
446        // Top-level caller invokes the module.
447        let caller_call = ModuleCall::builder()
448            .address(Address::new("module.outer").unwrap())
449            .source_raw(Arc::<str>::from("./mod"))
450            .source(ModuleSource::Local(Arc::from("./mod")))
451            .span(span())
452            .build();
453        let caller = Component::builder()
454            .id(ComponentId::from_index(1))
455            .path(Arc::<Path>::from(PathBuf::from("")))
456            .kind(ComponentKind::Component)
457            .modules(vec![caller_call])
458            .build();
459
460        let ctx = GraphContext::new(root);
461        let workspace = DefaultGraphBuilder
462            .build(vec![eval(&caller)], &registry, &ctx)
463            .unwrap();
464        // Cycle detection drops the recursive expansion and surfaces a
465        // diagnostic with code `TF1504`.
466        assert!(
467            workspace.diagnostics.iter().any(|d| &*d.code == "TF1504"),
468            "{:?}",
469            workspace.diagnostics
470        );
471    }
472
473    #[test]
474    fn test_should_enforce_max_module_depth_cap() {
475        // Build a 3-deep chain a → b → c → … with cap 2. Expansion of the
476        // 3rd level must surface a `TF1501` (LimitKind::Expansion) diagnostic.
477        let tmp = tempfile::tempdir().unwrap();
478        let root: Arc<Path> = Arc::from(std::fs::canonicalize(tmp.path()).unwrap());
479        for p in ["a", "b", "c"] {
480            std::fs::create_dir_all(root.join(p)).unwrap();
481        }
482        let mut registry = ModuleRegistry::new();
483        // c body: empty (terminal)
484        let c_body = Component::builder()
485            .id(ComponentId::from_index(0))
486            .path(Arc::<Path>::from(root.join("c")))
487            .kind(ComponentKind::Module)
488            .build();
489        registry.insert_local(Arc::from(root.join("c")), eval(&c_body));
490        // b body: calls c
491        let b_to_c = ModuleCall::builder()
492            .address(Address::new("module.c").unwrap())
493            .source_raw(Arc::<str>::from("../c"))
494            .source(ModuleSource::Local(Arc::from("../c")))
495            .span(span())
496            .build();
497        let b_body = Component::builder()
498            .id(ComponentId::from_index(0))
499            .path(Arc::<Path>::from(root.join("b")))
500            .kind(ComponentKind::Module)
501            .modules(vec![b_to_c])
502            .build();
503        registry.insert_local(Arc::from(root.join("b")), eval(&b_body));
504        // a body: calls b
505        let a_to_b = ModuleCall::builder()
506            .address(Address::new("module.b").unwrap())
507            .source_raw(Arc::<str>::from("../b"))
508            .source(ModuleSource::Local(Arc::from("../b")))
509            .span(span())
510            .build();
511        let a_body = Component::builder()
512            .id(ComponentId::from_index(0))
513            .path(Arc::<Path>::from(root.join("a")))
514            .kind(ComponentKind::Module)
515            .modules(vec![a_to_b])
516            .build();
517        registry.insert_local(Arc::from(root.join("a")), eval(&a_body));
518
519        let caller_call = ModuleCall::builder()
520            .address(Address::new("module.a").unwrap())
521            .source_raw(Arc::<str>::from("./a"))
522            .source(ModuleSource::Local(Arc::from("./a")))
523            .span(span())
524            .build();
525        let caller = Component::builder()
526            .id(ComponentId::from_index(1))
527            .path(Arc::<Path>::from(PathBuf::from("")))
528            .kind(ComponentKind::Component)
529            .modules(vec![caller_call])
530            .build();
531
532        let mut ctx = GraphContext::new(root);
533        ctx.max_module_depth = 2; // a (depth 0) → b (depth 1) → c (depth 2 = cap)
534        let workspace = DefaultGraphBuilder
535            .build(vec![eval(&caller)], &registry, &ctx)
536            .unwrap();
537        // Expect a `TF1501` diagnostic when the depth cap fires.
538        assert!(
539            workspace.diagnostics.iter().any(|d| &*d.code == "TF1501"),
540            "{:?}",
541            workspace.diagnostics
542        );
543    }
544
545    #[test]
546    fn test_should_flatten_module_into_parent_with_address_prefix() {
547        let tmp = tempfile::tempdir().unwrap();
548        // Canonicalise upfront — on macOS `/tmp` is a symlink to `/private/tmp`
549        // and the path-safety helpers reject symlink ancestors.
550        let workspace_root: Arc<Path> = Arc::from(std::fs::canonicalize(tmp.path()).unwrap());
551        // Materialise the module dir so `canonicalize_inside(Reject)` finds
552        // no symlinks in the chain.
553        std::fs::create_dir_all(workspace_root.join("modules/s3")).unwrap();
554        std::fs::create_dir_all(workspace_root.join("services/api-gateway")).unwrap();
555
556        // Register the module body under the canonical path that
557        // `caller_dir.join("../../modules/s3")` will normalise to.
558        let mod_path: Arc<Path> = Arc::from(workspace_root.join("modules/s3"));
559        let mut registry = ModuleRegistry::new();
560        registry.insert_local(Arc::clone(&mod_path), module_body("aws_s3_bucket.this"));
561
562        let mc = make_module_call(
563            "edge_logs",
564            "../../modules/s3",
565            vec![(
566                Arc::from("name"),
567                Expression::Literal(Value::Str(Arc::from("hello"))),
568            )],
569        );
570        let c = Component::builder()
571            .id(ComponentId::from_index(1))
572            .path(Arc::<Path>::from(PathBuf::from("services/api-gateway")))
573            .kind(ComponentKind::Component)
574            .modules(vec![mc])
575            .build();
576        let ctx = GraphContext::new(workspace_root);
577        let workspace = DefaultGraphBuilder
578            .build(vec![eval(&c)], &registry, &ctx)
579            .unwrap();
580        let resources = &workspace.components[0].resources;
581        assert_eq!(
582            resources.len(),
583            1,
584            "resources={resources:?}\ndiagnostics={:?}",
585            workspace.diagnostics
586        );
587        assert_eq!(
588            resources[0].address.as_str(),
589            "module.edge_logs.aws_s3_bucket.this"
590        );
591        // var.name → "hello" substitution from inputs.
592        let (_, name_expr) = resources[0]
593            .attributes
594            .iter()
595            .find(|(k, _)| k.as_ref() == "name")
596            .unwrap();
597        assert_eq!(
598            name_expr,
599            &Expression::Literal(Value::Str(Arc::from("hello")))
600        );
601    }
602}