Procházet zdrojové kódy

feat(extraction): add Terraform/OpenTofu language support with module-boundary bridging (#83, #310, #648 — carries #706) (#1173)

* feat(extraction): add Terraform and OpenTofu language support

Index .tf, .tfvars, and .tofu files via the tree-sitter-terraform dialect
of HCL (vendored from @tree-sitter-grammars/tree-sitter-hcl, Apache-2.0).

Symbols extracted:
- resource / data  → class  (qualified "type.name" / "data.type.name")
- module           → module (qualified "module.name")
- variable         → variable (qualified "var.name")
- output           → variable (qualified "output.name")
- provider         → namespace
- locals           → constant per attribute (qualified "local.key")

References resolved cross-file:
- var.X, local.X, module.M[.out], data.T.N[.attr], <type>.<name>[.attr]
- built-ins skipped: each.*, count.*, self.*, path.*, terraform.workspace

The Terraform framework resolver disambiguates same-named candidates
across modules by preferring the one in the same directory as the
reference site, then by closest common-ancestor path, falling back to
the generic name matcher only when neither applies.

Validated on two Terraform monorepos (277 and 470 .tf files): indexing
runs in 1.3s and 2.4s respectively, query latency stays under 200ms,
and cross-module references resolve to the correct module 100% of the
time on inspected samples.

18 new extraction tests; full suite 1146/1148 green (2 pre-existing
flaky skips, 0 regressions).

* feat(terraform): bridge the module boundary and enforce directory scoping

Builds on #706. The module declaration was a dead end: module.M.out
resolved to the declaration and stopped, module inputs never reached the
child module's variables, and impact could not cross the boundary — on
real multi-module repos that breaks the core blast-radius question
("what breaks upstream if I change this module's variable/output").

- module blocks now wire across the boundary through :-scoped refs only
  the Terraform resolver understands: module.M:var.<input> → the child's
  variable node, module.M:output.<o> → the child's output node (emitted
  alongside the module.M declaration ref), and module.M:file → the local
  source directory's entry file (imports). Registry/git sources emit no
  file ref and resolve nothing — an out-of-repo module stays a visible
  boundary instead of a guess.
- .tfvars top-level assignments reference the variable they set, walking
  up to the nearest ancestor directory (envs/prod.tfvars → root vars).
- Resolution now enforces Terraform's real scoping: same-directory only
  (no cross-module fallback by common path prefix, no single-candidate
  anywhere-in-tree binding), and terraform refs never fall through to
  the generic name matcher — var.X can never legally bind outside its
  module directory, so the fallback could only add wrong edges.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(terraform): README language table + changelog entry + agent-eval corpus

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Javier Rodríguez Fernández <jfernandez@freepik.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Colby Mchenry před 2 měsíci
rodič
revize
6c24f4bddf

+ 23 - 0
.claude/skills/agent-eval/corpus.json

@@ -538,5 +538,28 @@
       "files": "~2700",
       "question": "When a cutlass device-level GEMM (cutlass::gemm::device::Gemm) is invoked, how does it reach the GPU kernel? Trace from the operator() call to the kernel entry point and its launch site."
     }
+  ],
+  "Terraform": [
+    {
+      "name": "terraform-aws-vpc",
+      "repo": "https://github.com/terraform-aws-modules/terraform-aws-vpc",
+      "size": "Small",
+      "files": "~77",
+      "question": "How does the private_subnets variable shape the NAT gateway setup? Trace from the variable through the subnet and NAT gateway resources to the outputs that expose the private subnets."
+    },
+    {
+      "name": "cloud-foundation-fabric",
+      "repo": "https://github.com/GoogleCloudPlatform/cloud-foundation-fabric",
+      "size": "Medium",
+      "files": "~990",
+      "question": "In the project module (modules/project), how does the iam variable turn into actual IAM bindings on the project, and what depends on the module's project_id output elsewhere in the repo?"
+    },
+    {
+      "name": "terraform-aws-components",
+      "repo": "https://github.com/cloudposse/terraform-aws-components",
+      "size": "Large",
+      "files": "~1800",
+      "question": "In the eks/cluster component, how does the cluster IAM role get created and reach the EKS cluster resource, and which outputs expose cluster identity to other components?"
+    }
   ]
 }

+ 1 - 0
CHANGELOG.md

@@ -11,6 +11,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ### New Features
 
+- CodeGraph now indexes **Terraform and OpenTofu** (`.tf`, `.tfvars`, `.tofu`) — resources, data sources, modules, variables, outputs, providers, and every `locals` attribute become symbols (e.g. `aws_s3_bucket.my_bucket`, `var.region`, `module.vpc`, `local.prefix`), and uses like `var.region`, `module.vpc.id`, `data.aws_caller_identity.current`, or `aws_s3_bucket.my.arn` are wired up cross-file, so search, callers, and impact queries return real results on infrastructure repos instead of nothing. Module calls are bridged across the module boundary: a `module` block's inputs link to the child module's variables, `module.vpc.vpc_id` reaches the child's `output "vpc_id"` definition, and the block's local `source` path links to the module's files — so "what breaks if I change this module's variable" reaches every caller instead of dead-ending at the declaration (registry and git sources are deliberately left as visible boundaries rather than guessed). `.tfvars` assignments link to the variables they set, including var-files kept in a subdirectory. Resolution follows Terraform's real per-directory scoping, so same-named variables across modules never cross-link and "what depends on `var.project_id`" in a multi-module repo never mixes in unrelated modules. Thanks @Javviviii2. (#83, #310, #648)
 - CodeGraph now indexes **CUDA** (`.cu`, `.cuh`) — kernels, device/host functions, structs, and classes become symbols, and the host→kernel call edge survives the `<<<grid, block>>>` launch syntax, so questions like "how does this call reach the GPU kernel?" trace across the CPU/GPU boundary instead of going dark at the launch site. Real-world launch styles all connect: templated launches (`my_kernel<Traits, 256><<<grid, block>>>(args)`), launches through a local function pointer (`auto kernel = &my_kernel<...>; ... kernel<<<grid, block>>>(args)` — each branch-assigned target linked), brace-initialized launch configs (`<<<dim3{1,1,1}, dim3{256,1,1}>>>`), and kernels defined through a name-in-first-argument macro (flash-attention's `DEFINE_FLASH_FORWARD_KERNEL(kernel_name, ...) { ... }` style), which now index under their real kernel names. CUDA that lives in plain `.h`/`.hpp` headers — where much real-world device code sits, launch-template headers included — is recognized by content and indexed the same way. Validated on llm.c, flash-attention, and NVIDIA CUTLASS. (#387, #648)
 - C++ symbols defined inside `namespace` blocks now carry the namespace in their qualified name (`flash::compute_attn`, C++17 `namespace a::b {` included), and namespace-qualified calls (`ns::fn(...)`) resolve to their definitions — previously such calls never linked at all, which hid much of the call graph in namespace-heavy C++ codebases from callers and impact analysis.
 - C++ calls that spell out template arguments (`fn<T, 256>(args)`) now link to the function they instantiate, the same normalization templated base classes already had.

+ 2 - 1
README.md

@@ -244,7 +244,7 @@ The reliable, universal payoff is **surgical context and speed**: CodeGraph coll
 | **Full-Text Search** | Find code by name instantly across your entire codebase, powered by FTS5 |
 | **Impact Analysis** | Trace callers, callees, and the full impact radius of any symbol before making changes |
 | **Always Fresh** | File watcher uses native OS events (FSEvents/inotify/ReadDirectoryChangesW) with debounced auto-sync — the graph stays current as you code, zero config |
-| **20+ Languages** | TypeScript, JavaScript, Python, Go, Rust, Java, C#, VB.NET, PHP, Ruby, C, C++, CUDA, Objective-C, Metal, Swift, Kotlin, Scala, Dart, Lua, Luau, R, Erlang, CFML, COBOL, Solidity, Svelte, Vue, Astro, Liquid, Pascal/Delphi |
+| **20+ Languages** | TypeScript, JavaScript, Python, Go, Rust, Java, C#, VB.NET, PHP, Ruby, C, C++, CUDA, Objective-C, Metal, Swift, Kotlin, Scala, Dart, Lua, Luau, R, Erlang, CFML, COBOL, Solidity, Terraform/OpenTofu, Svelte, Vue, Astro, Liquid, Pascal/Delphi |
 | **Framework-aware Routes** | Recognizes web-framework routing files and links URL patterns to their handlers across 17 frameworks |
 | **Mixed iOS / React Native / Expo** | Closes cross-language flows that static parsing misses: Swift ↔ ObjC bridging, React Native legacy bridge + TurboModules + Fabric view components, native → JS event emitters, Expo Modules |
 | **100% Local** | No data leaves your machine. No API keys. No external services. SQLite database only |
@@ -721,6 +721,7 @@ is written):
 | Visual Basic .NET | `.vb` | Full support (classes, Modules, interfaces, structures, enums, properties, events, `Declare` P/Invoke, `Handles`/`WithEvents`, `Inherits`/`Implements` edges, call edges through VB's call/index paren ambiguity, `As New` instantiation, interpolated strings, LINQ, Unicode identifiers) |
 | Erlang | `.erl`, `.hrl`, `.escript`, `.app.src`, `.app` | Full support (functions with multi-clause/multi-arity grouping, `-spec` signatures, records with fields, `-type`/`-opaque` aliases, `-define` macros, `-include`/`-include_lib`/`-import` edges, local and `mod:fn` remote call edges, `fun name/arity` references, `spawn`/`apply`/`proc_lib`/`timer`/`rpc` MFA-argument call edges, `gen_server:call/cast(?MODULE)` → own `handle_call`/`handle_cast` links, `-behaviour` links, `-export`-based visibility) |
 | Solidity | `.sol` | Full support (contracts, libraries, interfaces, structs, enums, modifiers, events, errors, state variables, `import`/`using` directives, `emit`/`revert` calls) |
+| Terraform / OpenTofu | `.tf`, `.tfvars`, `.tofu` | Full support (resources, data sources, modules, variables, outputs, providers, `locals`; `var.`/`local.`/`module.`/resource references with Terraform's per-directory scoping enforced; module calls bridged across the boundary — inputs to the child module's variables, `module.M.out` to the child's output, `source` to the module's files; `.tfvars` assignments linked to the variables they set) |
 
 ## Measured cross-file coverage
 

+ 290 - 0
__tests__/extraction.test.ts

@@ -131,6 +131,13 @@ describe('Language Detection', () => {
     expect(detectLanguage('contracts/Vault.sol')).toBe('solidity');
   });
 
+  it('should detect Terraform files', () => {
+    expect(detectLanguage('main.tf')).toBe('terraform');
+    expect(detectLanguage('variables.tf')).toBe('terraform');
+    expect(detectLanguage('terraform.tfvars')).toBe('terraform');
+    expect(detectLanguage('versions.tofu')).toBe('terraform');
+  });
+
   it('should return unknown for unsupported extensions', () => {
     expect(detectLanguage('styles.css')).toBe('unknown');
     expect(detectLanguage('data.json')).toBe('unknown');
@@ -9833,3 +9840,286 @@ init(_) -> {ok, #{}}.
     });
   });
 });
+
+describe('Terraform Extraction', () => {
+  describe('Language detection', () => {
+    it('should detect Terraform files', () => {
+      expect(detectLanguage('main.tf')).toBe('terraform');
+      expect(detectLanguage('terraform.tfvars')).toBe('terraform');
+      expect(detectLanguage('versions.tofu')).toBe('terraform');
+    });
+
+    it('should report Terraform as supported', () => {
+      expect(isLanguageSupported('terraform')).toBe(true);
+      expect(getSupportedLanguages()).toContain('terraform');
+    });
+  });
+
+  describe('Block extraction', () => {
+    it('should extract a resource block as a class with qualified type.name', () => {
+      const code = `
+resource "aws_s3_bucket" "my_bucket" {
+  bucket = "example"
+}
+`;
+      const result = extractFromSource('main.tf', code);
+      const res = result.nodes.find((n) => n.name === 'aws_s3_bucket.my_bucket');
+      expect(res).toBeDefined();
+      expect(res?.kind).toBe('class');
+      expect(res?.qualifiedName).toBe('aws_s3_bucket.my_bucket');
+      expect(res?.signature).toBe('resource "aws_s3_bucket" "my_bucket"');
+      expect(res?.language).toBe('terraform');
+    });
+
+    it('should extract a data block under the data.* qualified name', () => {
+      const code = `
+data "aws_caller_identity" "current" {}
+`;
+      const result = extractFromSource('main.tf', code);
+      const node = result.nodes.find((n) => n.qualifiedName === 'data.aws_caller_identity.current');
+      expect(node).toBeDefined();
+      expect(node?.kind).toBe('class');
+    });
+
+    it('should extract a variable block as variable with qualified name var.X', () => {
+      const code = `
+variable "region" {
+  type    = string
+  default = "us-east-1"
+}
+`;
+      const result = extractFromSource('variables.tf', code);
+      const v = result.nodes.find((n) => n.qualifiedName === 'var.region');
+      expect(v).toBeDefined();
+      expect(v?.kind).toBe('variable');
+      expect(v?.name).toBe('region');
+    });
+
+    it('should extract an output block as variable with qualified name output.X', () => {
+      const code = `
+output "bucket_arn" {
+  value = aws_s3_bucket.my_bucket.arn
+}
+`;
+      const result = extractFromSource('outputs.tf', code);
+      const out = result.nodes.find((n) => n.qualifiedName === 'output.bucket_arn');
+      expect(out).toBeDefined();
+      expect(out?.kind).toBe('variable');
+    });
+
+    it('should extract a module block as module with qualified name module.X', () => {
+      const code = `
+module "vpc" {
+  source = "./modules/vpc"
+  cidr   = var.vpc_cidr
+}
+`;
+      const result = extractFromSource('main.tf', code);
+      const m = result.nodes.find((n) => n.qualifiedName === 'module.vpc');
+      expect(m).toBeDefined();
+      expect(m?.kind).toBe('module');
+    });
+
+    it('should extract a provider block as namespace', () => {
+      const code = `
+provider "aws" {
+  region = "us-east-1"
+}
+`;
+      const result = extractFromSource('main.tf', code);
+      const p = result.nodes.find((n) => n.qualifiedName === 'provider.aws');
+      expect(p).toBeDefined();
+      expect(p?.kind).toBe('namespace');
+    });
+
+    it('should extract every locals attribute as its own constant with local.K qualified name', () => {
+      const code = `
+locals {
+  prefix      = "prod"
+  full_name   = "\${local.prefix}-app"
+  max_retries = 3
+}
+`;
+      const result = extractFromSource('locals.tf', code);
+      const names = result.nodes
+        .filter((n) => n.kind === 'constant')
+        .map((n) => n.qualifiedName)
+        .sort();
+      expect(names).toEqual(['local.full_name', 'local.max_retries', 'local.prefix']);
+    });
+
+    it('should ignore a terraform settings block', () => {
+      const code = `
+terraform {
+  required_version = ">= 1.5"
+}
+`;
+      const result = extractFromSource('versions.tf', code);
+      const symbols = result.nodes.filter((n) => n.kind !== 'file');
+      expect(symbols).toHaveLength(0);
+    });
+
+    it('should index .tfvars top-level attributes via the same parser path', () => {
+      // .tfvars files have no blocks — just bare attributes, each of which
+      // SETS the root module variable of that name. No symbols are declared,
+      // but every top-level assignment references its variable so "what sets
+      // var.region" is answerable.
+      const code = `
+region      = "us-east-1"
+environment = "prod"
+`;
+      const result = extractFromSource('terraform.tfvars', code);
+      expect(result.errors.filter((e) => e.severity === 'error')).toHaveLength(0);
+      const symbols = result.nodes.filter((n) => n.kind !== 'file');
+      expect(symbols).toHaveLength(0);
+      const refs = result.unresolvedReferences.map((r) => r.referenceName);
+      expect(refs).toContain('var.region');
+      expect(refs).toContain('var.environment');
+    });
+  });
+
+  describe('Reference extraction', () => {
+    it('should emit a reference for var.X used inside a resource', () => {
+      const code = `
+variable "region" {}
+resource "aws_s3_bucket" "b" {
+  bucket = var.region
+}
+`;
+      const result = extractFromSource('main.tf', code);
+      const refs = result.unresolvedReferences.map((r) => r.referenceName);
+      expect(refs).toContain('var.region');
+    });
+
+    it('should emit a reference for module.M.<output> as module.M', () => {
+      const code = `
+output "vpc_id" {
+  value = module.vpc.vpc_id
+}
+`;
+      const result = extractFromSource('outputs.tf', code);
+      const refs = result.unresolvedReferences.map((r) => r.referenceName);
+      expect(refs).toContain('module.vpc');
+    });
+
+    it('should emit a scoped module.M:output.X ref alongside module.M for output chains', () => {
+      const code = `
+output "vpc_id" {
+  value = module.vpc.vpc_id
+}
+`;
+      const result = extractFromSource('outputs.tf', code);
+      const refs = result.unresolvedReferences.map((r) => r.referenceName);
+      expect(refs).toContain('module.vpc:output.vpc_id');
+      // A bare module.M use (no output segment) stays a single ref.
+      const bare = extractFromSource('main.tf', 'output "m" {\n  value = module.vpc\n}\n');
+      const bareRefs = bare.unresolvedReferences.map((r) => r.referenceName);
+      expect(bareRefs).toContain('module.vpc');
+      expect(bareRefs.some((r) => r.includes(':output.'))).toBe(false);
+    });
+
+    it('should wire module blocks: scoped input refs, meta-args skipped, local source imported', () => {
+      const code = `
+module "vpc" {
+  source     = "./modules/vpc"
+  version    = "1.0.0"
+  count      = 2
+  depends_on = [aws_iam_role.net]
+  cidr       = var.vpc_cidr
+  name       = "prod"
+}
+`;
+      const result = extractFromSource('main.tf', code);
+      const refs = result.unresolvedReferences.map((r) => r.referenceName);
+      // Input attributes wire to the child module's variables (scoped spelling).
+      expect(refs).toContain('module.vpc:var.cidr');
+      expect(refs).toContain('module.vpc:var.name');
+      // Meta-arguments configure the call, not child variables.
+      expect(refs).not.toContain('module.vpc:var.source');
+      expect(refs).not.toContain('module.vpc:var.version');
+      expect(refs).not.toContain('module.vpc:var.count');
+      expect(refs).not.toContain('module.vpc:var.depends_on');
+      // A local ./ source emits the module→file imports ref.
+      const fileRef = result.unresolvedReferences.find((r) => r.referenceName === 'module.vpc:file');
+      expect(fileRef).toBeDefined();
+      expect(fileRef?.referenceKind).toBe('imports');
+      // Attribute VALUES still reference the parent scope as before.
+      expect(refs).toContain('var.vpc_cidr');
+      expect(refs).toContain('aws_iam_role.net');
+    });
+
+    it('should not emit a module.M:file ref for registry or git sources', () => {
+      const code = `
+module "s3" {
+  source  = "terraform-aws-modules/s3-bucket/aws"
+  version = "4.0.0"
+  bucket  = "x"
+}
+module "net" {
+  source = "git::https://example.com/net.git"
+  cidr   = "10.0.0.0/16"
+}
+`;
+      const result = extractFromSource('main.tf', code);
+      const refs = result.unresolvedReferences.map((r) => r.referenceName);
+      expect(refs.some((r) => r.endsWith(':file'))).toBe(false);
+      // Input wiring is still emitted — the resolver drops it when the
+      // source turns out to be out-of-repo.
+      expect(refs).toContain('module.s3:var.bucket');
+    });
+
+    it('should emit data.T.N references stripped of the trailing attribute', () => {
+      const code = `
+output "account" {
+  value = data.aws_caller_identity.current.account_id
+}
+`;
+      const result = extractFromSource('outputs.tf', code);
+      const refs = result.unresolvedReferences.map((r) => r.referenceName);
+      expect(refs).toContain('data.aws_caller_identity.current');
+    });
+
+    it('should emit T.N references for managed-resource attribute access', () => {
+      const code = `
+resource "aws_iam_policy" "p" {
+  policy = aws_s3_bucket.my.arn
+}
+`;
+      const result = extractFromSource('main.tf', code);
+      const refs = result.unresolvedReferences.map((r) => r.referenceName);
+      expect(refs).toContain('aws_s3_bucket.my');
+    });
+
+    it('should emit local.K references from locals attribute expressions', () => {
+      const code = `
+locals {
+  prefix = "prod"
+  name   = "\${local.prefix}-app"
+}
+`;
+      const result = extractFromSource('locals.tf', code);
+      const refs = result.unresolvedReferences.map((r) => r.referenceName);
+      expect(refs).toContain('local.prefix');
+    });
+
+    it('should skip built-in heads (each, count, self, path, terraform.workspace)', () => {
+      const code = `
+resource "aws_instance" "x" {
+  count       = each.value
+  name        = path.module
+  workspace   = terraform.workspace
+  self_ref    = self.id
+  index_value = count.index
+}
+`;
+      const result = extractFromSource('main.tf', code);
+      const refs = result.unresolvedReferences.map((r) => r.referenceName);
+      // None of the built-ins should produce project references.
+      expect(refs.some((r) => r.startsWith('each.'))).toBe(false);
+      expect(refs.some((r) => r.startsWith('count.'))).toBe(false);
+      expect(refs.some((r) => r.startsWith('self.'))).toBe(false);
+      expect(refs.some((r) => r.startsWith('path.'))).toBe(false);
+      expect(refs.some((r) => r.startsWith('terraform.'))).toBe(false);
+    });
+  });
+});

+ 131 - 0
__tests__/frameworks-integration.test.ts

@@ -961,3 +961,134 @@ export function AppRoutes() {
     }
   });
 });
+
+describe('Terraform end-to-end module-boundary resolution', () => {
+  let tmpDir: string | undefined;
+  afterEach(() => {
+    if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
+    tmpDir = undefined;
+  });
+
+  function writeMultiModuleRepo(root: string) {
+    fs.mkdirSync(path.join(root, 'modules/vpc'), { recursive: true });
+    fs.mkdirSync(path.join(root, 'modules/other'), { recursive: true });
+    fs.mkdirSync(path.join(root, 'envs'), { recursive: true });
+    fs.writeFileSync(
+      path.join(root, 'main.tf'),
+      'variable "vpc_cidr" {\n  type = string\n}\n\n' +
+        'module "vpc" {\n  source = "./modules/vpc"\n  cidr   = var.vpc_cidr\n}\n\n' +
+        'module "registry_thing" {\n  source  = "terraform-aws-modules/s3-bucket/aws"\n  bucket  = "x"\n}\n\n' +
+        'output "vpc_id" {\n  value = module.vpc.vpc_id\n}\n'
+    );
+    fs.writeFileSync(
+      path.join(root, 'modules/vpc/variables.tf'),
+      'variable "cidr" {\n  type = string\n}\n'
+    );
+    fs.writeFileSync(
+      path.join(root, 'modules/vpc/main.tf'),
+      'resource "aws_vpc" "this" {\n  cidr_block = var.cidr\n}\n'
+    );
+    fs.writeFileSync(
+      path.join(root, 'modules/vpc/outputs.tf'),
+      'output "vpc_id" {\n  value = aws_vpc.this.id\n}\n'
+    );
+    // Same-named variable in an UNRELATED module — must never receive edges
+    // from outside its own directory.
+    fs.writeFileSync(
+      path.join(root, 'modules/other/variables.tf'),
+      'variable "cidr" {\n  type = string\n}\nvariable "orphan_ref_target" {}\n'
+    );
+    // References a variable that has no same-dir declaration: must stay unlinked.
+    fs.writeFileSync(
+      path.join(root, 'modules/other/main.tf'),
+      'resource "aws_eip" "e" {\n  tags = { Name = var.undeclared_here_elsewhere_yes }\n}\n'
+    );
+    fs.writeFileSync(path.join(root, 'envs/prod.tfvars'), 'vpc_cidr = "10.0.0.0/16"\n');
+  }
+
+  it('bridges module inputs/outputs/source and enforces directory scoping', async () => {
+    tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-terraform-'));
+    writeMultiModuleRepo(tmpDir);
+
+    const cg = CodeGraph.initSync(tmpDir);
+    await cg.indexAll();
+    try {
+      const byQname = (q: string, file?: string) =>
+        cg
+          .getNodesByName(q.split('.').pop()!)
+          .filter((n) => n.qualifiedName === q && (!file || n.filePath === file));
+
+      const moduleDecl = byQname('module.vpc')[0];
+      expect(moduleDecl, 'module.vpc declaration node').toBeDefined();
+      const childCidr = byQname('var.cidr', 'modules/vpc/variables.tf')[0];
+      expect(childCidr, "child module's var.cidr").toBeDefined();
+      const childOutput = byQname('output.vpc_id', 'modules/vpc/outputs.tf')[0];
+      expect(childOutput, "child module's output.vpc_id").toBeDefined();
+      const rootOutput = byQname('output.vpc_id', 'main.tf')[0];
+      expect(rootOutput, 'root output.vpc_id').toBeDefined();
+
+      const declEdges = cg.getOutgoingEdges(moduleDecl!.id);
+      // Input wiring: module block → child variable (cross-directory).
+      expect(
+        declEdges.find((e) => e.target === childCidr!.id),
+        'module.vpc → child var.cidr input edge'
+      ).toBeDefined();
+      // Source wiring: module block → child entry file.
+      const fileNode = cg
+        .getNodesInFile('modules/vpc/main.tf')
+        .find((n) => n.kind === 'file');
+      expect(fileNode).toBeDefined();
+      const importEdge = declEdges.find((e) => e.target === fileNode!.id);
+      expect(importEdge, 'module.vpc → modules/vpc/main.tf imports edge').toBeDefined();
+      expect(importEdge!.kind).toBe('imports');
+
+      // Output bridge: root output → child output (not just the declaration).
+      const rootOutEdges = cg.getOutgoingEdges(rootOutput!.id);
+      expect(
+        rootOutEdges.find((e) => e.target === childOutput!.id),
+        'root output.vpc_id → child output.vpc_id'
+      ).toBeDefined();
+      expect(
+        rootOutEdges.find((e) => e.target === moduleDecl!.id),
+        'root output.vpc_id → module.vpc declaration'
+      ).toBeDefined();
+
+      // tfvars assignment walks up to the ROOT variable.
+      const rootVar = byQname('var.vpc_cidr', 'main.tf')[0];
+      expect(rootVar).toBeDefined();
+      const tfvarsFile = cg.getNodesInFile('envs/prod.tfvars').find((n) => n.kind === 'file');
+      expect(tfvarsFile).toBeDefined();
+      expect(
+        cg.getOutgoingEdges(tfvarsFile!.id).find((e) => e.target === rootVar!.id),
+        'envs/prod.tfvars → var.vpc_cidr'
+      ).toBeDefined();
+
+      // Directory scoping: the unrelated module's same-named var.cidr gets
+      // NO incoming edges from outside its own directory…
+      const otherCidr = byQname('var.cidr', 'modules/other/variables.tf')[0];
+      expect(otherCidr).toBeDefined();
+      const incomingOther = cg.getIncomingEdges(otherCidr!.id).filter((e) => e.kind !== 'contains');
+      expect(incomingOther, 'unrelated module var.cidr must stay isolated').toHaveLength(0);
+
+      // …and a reference with no same-dir declaration stays unlinked rather
+      // than borrowing another module's declaration.
+      const orphanEdges = cg
+        .getNodesInFile('modules/other/main.tf')
+        .filter((n) => n.qualifiedName === 'aws_eip.e')
+        .flatMap((n) => cg.getOutgoingEdges(n.id))
+        .filter((e) => e.kind === 'references');
+      const orphanTargets = orphanEdges.map((e) => cg.getNodeById(e.target)?.qualifiedName);
+      expect(orphanTargets).not.toContain('var.undeclared_here_elsewhere_yes');
+
+      // Registry-sourced module: inputs stay unresolved (no guessed edges).
+      const registryDecl = byQname('module.registry_thing')[0];
+      expect(registryDecl).toBeDefined();
+      const registryEdges = cg
+        .getOutgoingEdges(registryDecl!.id)
+        .filter((e) => e.kind !== 'contains');
+      expect(registryEdges, 'registry module must not link anywhere').toHaveLength(0);
+    } finally {
+      cg.close();
+    }
+  });
+});

+ 11 - 2
src/extraction/grammars.ts

@@ -46,6 +46,7 @@ const WASM_GRAMMAR_FILES: Record<GrammarLanguage, string> = {
   vbnet: 'tree-sitter-vbnet.wasm',
   erlang: 'tree-sitter-erlang.wasm',
   solidity: 'tree-sitter-solidity.wasm',
+  terraform: 'tree-sitter-terraform.wasm',
 };
 
 /**
@@ -157,6 +158,10 @@ export const EXTENSION_MAP: Record<string, Language> = {
   // shape as the `.yml` variants — the YAML/properties extractor emits one node
   // per leaf key, and the Spring resolver links `@Value("${k}")` references.
   '.properties': 'properties',
+  // Terraform / OpenTofu / HCL config — tree-sitter-terraform dialect of HCL.
+  '.tf': 'terraform',
+  '.tfvars': 'terraform',
+  '.tofu': 'terraform',
 };
 
 /**
@@ -283,8 +288,11 @@ export async function loadGrammarsForLanguages(languages: Language[]): Promise<v
       // build (ABI 13) has no primary-constructor support and parses
       // `class Foo(...)` as an ERROR that swallows the whole class (#237); we
       // vendor the upstream ABI-15 tree-sitter-c-sharp 0.23.5 wasm, which parses
-      // primary constructors natively.
-      const wasmPath = (lang === 'pascal' || lang === 'scala' || lang === 'lua' || lang === 'luau' || lang === 'csharp' || lang === 'r' || lang === 'cfml' || lang === 'cfscript' || lang === 'cfquery' || lang === 'cobol' || lang === 'vbnet' || lang === 'erlang')
+      // primary constructors natively. Terraform: tree-sitter-wasms does not
+      // ship HCL/Terraform at all, so we vendor the prebuilt
+      // tree-sitter-terraform.wasm from @tree-sitter-grammars/tree-sitter-hcl
+      // 1.2.0 (Apache-2.0) — byte-identical to the npm package's artifact.
+      const wasmPath = (lang === 'pascal' || lang === 'scala' || lang === 'lua' || lang === 'luau' || lang === 'csharp' || lang === 'r' || lang === 'cfml' || lang === 'cfscript' || lang === 'cfquery' || lang === 'cobol' || lang === 'vbnet' || lang === 'erlang' || lang === 'terraform')
         ? path.join(__dirname, 'wasm', wasmFile)
         : require.resolve(`tree-sitter-wasms/out/${wasmFile}`);
       const language = await WasmLanguage.load(wasmPath);
@@ -509,6 +517,7 @@ export function getLanguageDisplayName(language: Language): string {
     cobol: 'COBOL',
     vbnet: 'Visual Basic .NET',
     erlang: 'Erlang',
+    terraform: 'Terraform',
     unknown: 'Unknown',
   };
   return names[language] || language;

+ 2 - 0
src/extraction/languages/index.ts

@@ -33,6 +33,7 @@ import { cobolExtractor } from './cobol';
 import { vbnetExtractor } from './vbnet';
 import { erlangExtractor } from './erlang';
 import { solidityExtractor } from './solidity';
+import { terraformExtractor } from './terraform';
 
 export const EXTRACTORS: Partial<Record<Language, LanguageExtractor>> = {
   typescript: typescriptExtractor,
@@ -63,4 +64,5 @@ export const EXTRACTORS: Partial<Record<Language, LanguageExtractor>> = {
   vbnet: vbnetExtractor,
   erlang: erlangExtractor,
   solidity: solidityExtractor,
+  terraform: terraformExtractor,
 };

+ 475 - 0
src/extraction/languages/terraform.ts

@@ -0,0 +1,475 @@
+import type { Node as SyntaxNode } from 'web-tree-sitter';
+import { getNodeText } from '../tree-sitter-helpers';
+import type { LanguageExtractor } from '../tree-sitter-types';
+
+// Grammar: tree-sitter-terraform (vendored at src/extraction/wasm/tree-sitter-terraform.wasm,
+// built from @tree-sitter-grammars/tree-sitter-hcl, Apache-2.0). The HCL grammar
+// is intentionally generic: ALL Terraform top-level constructs share the same
+// AST node type `block`, distinguished only by the first `identifier` child
+// (the block "type": resource, variable, data, module, output, locals, …).
+// Labels for resources/data/modules/variables come from `string_lit` children
+// AFTER that first identifier.
+//
+//   resource "aws_s3_bucket" "my_bucket" { ... }
+//   └─ block
+//        ├─ identifier            ("resource")
+//        ├─ string_lit            ("aws_s3_bucket")  ← type label
+//        ├─ string_lit            ("my_bucket")      ← name label
+//        ├─ block_start
+//        ├─ body
+//        │    ├─ attribute (identifier "bucket" "=" expression)
+//        │    └─ block ("tags" { ... })                ← nested block (skipped)
+//        └─ block_end
+//
+// References live inside `expression` subtrees: a leading `identifier` followed
+// by zero or more `get_attr` (`.foo`) nodes. We synthesise qualified-name refs
+// matching the node names emitted above (e.g. `var.region` → unresolved ref
+// `var.region`, which the matcher resolves to the `variable "region"` node).
+
+/** Built-in references that should NOT be resolved to project nodes. */
+const BUILTIN_HEADS = new Set([
+  'each',         // for_each iterator: each.key / each.value
+  'count',        // count meta-argument: count.index
+  'self',         // provisioner connection self.*
+  'path',         // path.module / path.root / path.cwd
+  'terraform',    // terraform.workspace
+]);
+
+/** Bare strings that we never want to treat as references. */
+const BUILTIN_KEYWORDS = new Set(['null', 'true', 'false']);
+
+/** Read a string_lit value (skipping the quotes / template start/end tokens). */
+function stringLitValue(node: SyntaxNode, source: string): string {
+  const literal = node.namedChildren.find((c) => c?.type === 'template_literal');
+  if (literal) return getNodeText(literal, source);
+  // Empty string ("") parses as quoted_template_start + quoted_template_end
+  // with no template_literal — return empty.
+  return '';
+}
+
+/** Block "type" and its label values. Returns null if the block is malformed. */
+function readBlockHeader(block: SyntaxNode, source: string): { type: string; labels: string[] } | null {
+  const named = block.namedChildren.filter((c): c is SyntaxNode => c !== null);
+  const first = named[0];
+  if (!first || first.type !== 'identifier') return null;
+  const type = getNodeText(first, source);
+  const labels: string[] = [];
+  for (let i = 1; i < named.length; i++) {
+    const child = named[i];
+    if (!child) continue;
+    if (child.type === 'string_lit') {
+      labels.push(stringLitValue(child, source));
+    } else if (child.type === 'identifier') {
+      // HCL allows unquoted identifier labels (rare in Terraform but legal).
+      labels.push(getNodeText(child, source));
+    } else {
+      break;
+    }
+  }
+  return { type, labels };
+}
+
+/** Find the `body` child of a block (it's after the labels and block_start). */
+function getBlockBody(block: SyntaxNode): SyntaxNode | null {
+  return block.namedChildren.find((c) => c?.type === 'body') ?? null;
+}
+
+/**
+ * Walk an `expression` subtree and emit a reference for every dotted name
+ * whose head is a Terraform reference root (var / local / module / data / a
+ * resource type name). Skips built-ins.
+ *
+ * Patterns we recognise:
+ *   var.X        → ref "var.X"           (variable "X")
+ *   local.X      → ref "local.X"         (locals.X)
+ *   module.M.O   → ref "module.M"        (module "M")
+ *   data.T.N.A   → ref "data.T.N"        (data "T" "N")
+ *   T.N[.A]      → ref "T.N"             (resource "T" "N", e.g. aws_x.y)
+ */
+function collectReferences(
+  expr: SyntaxNode,
+  source: string,
+  onRef: (qualifiedName: string, line: number, column: number) => void
+): void {
+  // BFS for variable_expr and inspect each. variable_expr's only child is an
+  // identifier (the head); its siblings via get_attr / index chains live on
+  // the parent _expr_term, so walk the parent chain to collect them.
+  const queue: SyntaxNode[] = [expr];
+  while (queue.length) {
+    const n = queue.shift()!;
+    if (n.type === 'variable_expr') {
+      emitRefFromVariableExpr(n, source, onRef);
+      // Don't recurse into the chain we just read — but DO continue scanning
+      // siblings (e.g. function call arguments).
+    }
+    for (const c of n.namedChildren) {
+      if (c) queue.push(c);
+    }
+  }
+}
+
+function emitRefFromVariableExpr(
+  varExpr: SyntaxNode,
+  source: string,
+  onRef: (qualifiedName: string, line: number, column: number) => void
+): void {
+  const id = varExpr.namedChildren.find((c) => c?.type === 'identifier');
+  if (!id) return;
+  const head = getNodeText(id, source);
+  if (BUILTIN_HEADS.has(head) || BUILTIN_KEYWORDS.has(head)) return;
+
+  // Walk get_attr siblings on the parent. The AST shape is roughly:
+  //   expression > _expr_term (hidden) → variable_expr + get_attr + get_attr + ...
+  // tree-sitter exposes _expr_term children flattened on `expression`.
+  const attrs: string[] = [];
+  let cursor: SyntaxNode | null = varExpr.nextNamedSibling;
+  while (cursor) {
+    if (cursor.type === 'get_attr') {
+      const attrId = cursor.namedChildren.find((c) => c?.type === 'identifier');
+      if (!attrId) break;
+      attrs.push(getNodeText(attrId, source));
+      cursor = cursor.nextNamedSibling;
+    } else if (cursor.type === 'index' || cursor.type === 'new_index' || cursor.type === 'legacy_index' || cursor.type === 'splat' || cursor.type === 'attr_splat' || cursor.type === 'full_splat') {
+      // foo[0], foo[*], foo.*  — keep walking but don't add a segment.
+      cursor = cursor.nextNamedSibling;
+    } else {
+      break;
+    }
+  }
+
+  const line = varExpr.startPosition.row + 1;
+  const col = varExpr.startPosition.column;
+  for (const qname of qualifyReference(head, attrs)) onRef(qname, line, col);
+}
+
+function qualifyReference(head: string, attrs: string[]): string[] {
+  switch (head) {
+    case 'var':
+      // var.X — variable "X"
+      return attrs[0] ? [`var.${attrs[0]}`] : [];
+    case 'local':
+      // local.K — locals attribute K
+      return attrs[0] ? [`local.${attrs[0]}`] : [];
+    case 'module':
+      // module.M[.OUTPUT] — module "M". A two-segment chain (`module.M.out`)
+      // additionally emits a scoped `module.M:output.out` ref that the
+      // Terraform resolver bridges to the `output "out"` node inside the
+      // module's source directory — the edge that carries impact across the
+      // module boundary instead of dead-ending at the declaration. Only the
+      // Terraform framework resolver understands the `:`-scoped spelling; if
+      // the module's source is a registry/git address the ref simply stays
+      // unresolved and the boundary remains visible.
+      if (!attrs[0]) return [];
+      return attrs[1]
+        ? [`module.${attrs[0]}`, `module.${attrs[0]}:output.${attrs[1]}`]
+        : [`module.${attrs[0]}`];
+    case 'data':
+      // data.TYPE.NAME[.ATTR] — data "TYPE" "NAME"
+      return attrs[0] && attrs[1] ? [`data.${attrs[0]}.${attrs[1]}`] : [];
+    default:
+      // <type>.<name>[.<attr>...] — managed resource (e.g. aws_s3_bucket.my)
+      // Skip plain identifiers with no dotted chain — those are function calls,
+      // local-only variables, or template params.
+      if (!attrs[0]) return [];
+      return [`${head}.${attrs[0]}`];
+  }
+}
+
+export const terraformExtractor: LanguageExtractor = {
+  // The HCL grammar exposes everything as `block` / `attribute`; the default
+  // dispatcher does not know how to read Terraform's first-identifier-as-type
+  // convention, so we drive extraction entirely from visitNode below.
+  functionTypes: [],
+  classTypes: [],
+  methodTypes: [],
+  interfaceTypes: [],
+  structTypes: [],
+  enumTypes: [],
+  typeAliasTypes: [],
+  importTypes: [],
+  callTypes: [],
+  variableTypes: [],
+  nameField: '',
+  bodyField: '',
+  paramsField: '',
+
+  visitNode: (node, ctx) => {
+    if (node.type !== 'block') {
+      // .tfvars files carry no blocks — just top-level `name = value`
+      // assignments, each of which SETS the root module variable of that
+      // name. Reference the variable from the file node so "what sets
+      // var.region" is answerable from the graph.
+      if (
+        node.type === 'attribute' &&
+        ctx.filePath.endsWith('.tfvars') &&
+        node.parent?.type === 'body' &&
+        node.parent.parent?.type === 'config_file'
+      ) {
+        const idNode = node.namedChildren.find((c) => c?.type === 'identifier');
+        const fileNodeId = ctx.nodeStack[0];
+        if (idNode && fileNodeId) {
+          ctx.addUnresolvedReference({
+            fromNodeId: fileNodeId,
+            referenceName: `var.${getNodeText(idNode, ctx.source)}`,
+            referenceKind: 'references',
+            line: node.startPosition.row + 1,
+            column: node.startPosition.column,
+          });
+        }
+        return true;
+      }
+      // Let the default walker descend into bodies/expressions; we only claim
+      // top-level blocks.
+      return false;
+    }
+
+    const header = readBlockHeader(node, ctx.source);
+    if (!header) return false;
+    const { type, labels } = header;
+    const body = getBlockBody(node);
+
+    // --- locals: every attribute becomes its own constant ---
+    if (type === 'locals' && labels.length === 0) {
+      emitLocals(body, ctx);
+      return true; // we handled everything inside this block
+    }
+
+    // --- terraform { ... } settings block — no symbols, no refs to project ---
+    if (type === 'terraform' && labels.length === 0) {
+      return true;
+    }
+
+    // --- resource / data / module / variable / output / provider ---
+    const decl = describeBlock(type, labels);
+    if (!decl) {
+      // Unknown top-level block (e.g. nested block hoisted as top-level via
+      // walker). Let the default walker continue.
+      return false;
+    }
+
+    const created = ctx.createNode(decl.kind, decl.name, node, {
+      qualifiedName: decl.qualifiedName,
+      signature: decl.signature,
+      isExported: decl.kind === 'variable',
+    });
+
+    if (!created) return true;
+
+    // Collect references inside this block's body (attribute expressions).
+    if (body) {
+      ctx.pushScope(created.id);
+      try {
+        emitReferencesInBody(body, ctx, created.id);
+        if (type === 'module' && labels[0]) {
+          emitModuleWiring(labels[0], node, body, ctx, created.id);
+        }
+      } finally {
+        ctx.popScope();
+      }
+    }
+    return true;
+  },
+};
+
+/**
+ * Module meta-arguments — attributes of a `module` block that configure the
+ * call itself rather than set one of the child module's input variables.
+ */
+const MODULE_META_ARGS = new Set(['source', 'version', 'count', 'for_each', 'providers', 'depends_on']);
+
+/**
+ * Bridge a `module "M" { ... }` block across the module boundary with
+ * `:`-scoped references that only the Terraform framework resolver
+ * understands (a plain qualified name would let the generic matcher bind
+ * them to a same-named symbol in an unrelated module — a wrong edge is
+ * worse than none):
+ *
+ *   - `module.M:file`      (imports)    → the module source directory's
+ *     entry file, when `source` is a local `./`/`../` path. Registry and
+ *     git sources emit nothing — an out-of-repo module stays a visible
+ *     boundary instead of a guessed edge.
+ *   - `module.M:var.<in>`  (references) → the child module's
+ *     `variable "<in>"` node, one per input attribute. This is what lets
+ *     "what depends on modules/vpc's var.cidr" reach the callers.
+ */
+function emitModuleWiring(
+  moduleName: string,
+  block: SyntaxNode,
+  body: SyntaxNode,
+  ctx: Parameters<NonNullable<LanguageExtractor['visitNode']>>[1],
+  fromNodeId: string
+): void {
+  for (const attr of body.namedChildren) {
+    if (!attr || attr.type !== 'attribute') continue;
+    const idNode = attr.namedChildren.find((c) => c?.type === 'identifier');
+    if (!idNode) continue;
+    const attrName = getNodeText(idNode, ctx.source);
+    if (attrName === 'source') {
+      const expr = attr.namedChildren.find((c) => c?.type === 'expression');
+      const lit = expr ? findStringLit(expr) : null;
+      const source = lit ? stringLitValue(lit, ctx.source) : '';
+      if (source.startsWith('./') || source.startsWith('../')) {
+        ctx.addUnresolvedReference({
+          fromNodeId,
+          referenceName: `module.${moduleName}:file`,
+          referenceKind: 'imports',
+          line: block.startPosition.row + 1,
+          column: block.startPosition.column,
+        });
+      }
+      continue;
+    }
+    if (MODULE_META_ARGS.has(attrName)) continue;
+    ctx.addUnresolvedReference({
+      fromNodeId,
+      referenceName: `module.${moduleName}:var.${attrName}`,
+      referenceKind: 'references',
+      line: attr.startPosition.row + 1,
+      column: attr.startPosition.column,
+    });
+  }
+}
+
+/** First string_lit anywhere under an expression (source = "./modules/x"). */
+function findStringLit(expr: SyntaxNode): SyntaxNode | null {
+  const queue: SyntaxNode[] = [expr];
+  while (queue.length) {
+    const n = queue.shift()!;
+    if (n.type === 'string_lit') return n;
+    for (const c of n.namedChildren) {
+      if (c) queue.push(c);
+    }
+  }
+  return null;
+}
+
+interface BlockDecl {
+  kind: 'class' | 'module' | 'variable' | 'namespace';
+  name: string;
+  qualifiedName: string;
+  signature: string;
+}
+
+function describeBlock(type: string, labels: string[]): BlockDecl | null {
+  const [first, second] = labels;
+  switch (type) {
+    case 'resource': {
+      if (!first || !second) return null;
+      return {
+        kind: 'class',
+        name: `${first}.${second}`,
+        qualifiedName: `${first}.${second}`,
+        signature: `resource "${first}" "${second}"`,
+      };
+    }
+    case 'data': {
+      if (!first || !second) return null;
+      return {
+        kind: 'class',
+        name: `${first}.${second}`,
+        qualifiedName: `data.${first}.${second}`,
+        signature: `data "${first}" "${second}"`,
+      };
+    }
+    case 'module': {
+      if (!first) return null;
+      return {
+        kind: 'module',
+        name: first,
+        qualifiedName: `module.${first}`,
+        signature: `module "${first}"`,
+      };
+    }
+    case 'variable': {
+      if (!first) return null;
+      return {
+        kind: 'variable',
+        name: first,
+        qualifiedName: `var.${first}`,
+        signature: `variable "${first}"`,
+      };
+    }
+    case 'output': {
+      if (!first) return null;
+      return {
+        kind: 'variable',
+        name: first,
+        qualifiedName: `output.${first}`,
+        signature: `output "${first}"`,
+      };
+    }
+    case 'provider': {
+      if (!first) return null;
+      return {
+        kind: 'namespace',
+        name: first,
+        qualifiedName: `provider.${first}`,
+        signature: `provider "${first}"`,
+      };
+    }
+    default:
+      return null;
+  }
+}
+
+function emitLocals(
+  body: SyntaxNode | null,
+  ctx: Parameters<NonNullable<LanguageExtractor['visitNode']>>[1]
+): void {
+  if (!body) return;
+  for (const attr of body.namedChildren) {
+    if (!attr || attr.type !== 'attribute') continue;
+    const idNode = attr.namedChildren.find((c) => c?.type === 'identifier');
+    if (!idNode) continue;
+    const name = getNodeText(idNode, ctx.source);
+    const created = ctx.createNode('constant', name, attr, {
+      qualifiedName: `local.${name}`,
+      signature: `local.${name}`,
+    });
+    if (!created) continue;
+    const expr = attr.namedChildren.find((c) => c?.type === 'expression');
+    if (expr) {
+      ctx.pushScope(created.id);
+      try {
+        collectReferences(expr, ctx.source, (qname, line, column) => {
+          ctx.addUnresolvedReference({
+            fromNodeId: created.id,
+            referenceName: qname,
+            referenceKind: 'references',
+            line,
+            column,
+          });
+        });
+      } finally {
+        ctx.popScope();
+      }
+    }
+  }
+}
+
+function emitReferencesInBody(
+  body: SyntaxNode,
+  ctx: Parameters<NonNullable<LanguageExtractor['visitNode']>>[1],
+  fromNodeId: string
+): void {
+  const queue: SyntaxNode[] = [body];
+  while (queue.length) {
+    const n = queue.shift()!;
+    if (n.type === 'expression') {
+      collectReferences(n, ctx.source, (qname, line, column) => {
+        ctx.addUnresolvedReference({
+          fromNodeId,
+          referenceName: qname,
+          referenceKind: 'references',
+          line,
+          column,
+        });
+      });
+      // Don't descend into expression — collectReferences already does.
+      continue;
+    }
+    for (const c of n.namedChildren) {
+      if (c) queue.push(c);
+    }
+  }
+}

binární
src/extraction/wasm/tree-sitter-terraform.wasm


+ 3 - 0
src/resolution/frameworks/index.ts

@@ -28,6 +28,7 @@ import { reactNativeBridgeResolver } from './react-native';
 import { expoModulesResolver } from './expo-modules';
 import { fabricViewResolver } from './fabric';
 import { cicsResolver } from './cics';
+import { terraformResolver } from './terraform';
 
 /**
  * All registered framework resolvers
@@ -73,6 +74,8 @@ const FRAMEWORK_RESOLVERS: FrameworkResolver[] = [
   fabricViewResolver,
   // CICS pseudo-conversational TRANSID hops (COBOL)
   cicsResolver,
+  // Terraform / OpenTofu — disambiguate var/local/module/resource refs to same-dir module
+  terraformResolver,
 ];
 
 /**

+ 195 - 0
src/resolution/frameworks/terraform.ts

@@ -0,0 +1,195 @@
+/**
+ * Terraform Framework Resolver
+ *
+ * Terraform's scoping rule is narrow and directory-shaped: `var.X`,
+ * `local.X`, `module.M`, and resource/data references resolve ONLY inside
+ * the same module directory as the reference site. The generic name matcher
+ * resolves by qualified-name alone, so a reference to `var.project_id` from
+ * `modules/net-vpc/main.tf` could bind to a `variable "project_id"` declared
+ * in an unrelated module — a wrong cross-module edge that poisons impact
+ * analysis. This resolver enforces the real semantics:
+ *
+ *   1. Same directory as the reference site → resolve (highest confidence).
+ *   2. `.tfvars` files additionally walk UP to the nearest ancestor
+ *      directory declaring the variable (`terraform apply -var-file=envs/prod.tfvars`
+ *      sets ROOT module variables from a subdirectory).
+ *   3. Otherwise: no edge. Terraform cannot reference across sibling module
+ *      directories, so a non-local candidate is never a correct target.
+ *
+ * It also bridges the module boundary through `:`-scoped references that
+ * only this resolver understands (see the extractor's emitModuleWiring):
+ *
+ *   - `module.M:file`       → the entry file of the module's local source
+ *     directory (an `imports` edge, so a module call connects to the code
+ *     it instantiates).
+ *   - `module.M:var.<in>`   → the child module's `variable "<in>"` node —
+ *     the module block sets that variable, so "what depends on the child's
+ *     var.cidr" reaches every caller.
+ *   - `module.M:output.<o>` → the child module's `output "<o>"` node —
+ *     `module.M.o` uses flow through to the output's definition instead of
+ *     dead-ending at the module declaration.
+ *
+ * The module's `source` is re-read from the declaration's file (cached
+ * lines); only local `./`/`../` sources bridge. Registry/git sources stay
+ * unresolved — an out-of-repo module is a visible boundary, never a guess.
+ */
+
+import * as path from 'path';
+import type { Node } from '../../types';
+import type { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types';
+
+/** `module.M:file` / `module.M:var.X` / `module.M:output.X` — extractor-emitted scoped refs. */
+const SCOPED_REF = /^module\.([^.:\s]+):(file$|var\.|output\.)/;
+
+export const terraformResolver: FrameworkResolver = {
+  name: 'terraform',
+  languages: ['terraform'],
+
+  detect(context: ResolutionContext): boolean {
+    return context.getAllFiles().some((f) => f.endsWith('.tf') || f.endsWith('.tfvars') || f.endsWith('.tofu'));
+  },
+
+  // Scoped refs name no declared symbol; opt them through the resolver's
+  // name-exists pre-filter so they reach resolve() at all.
+  claimsReference(name: string): boolean {
+    return SCOPED_REF.test(name);
+  },
+
+  resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
+    if (ref.language !== 'terraform') return null;
+
+    const qname = ref.referenceName;
+    const refDir = dirOf(ref.filePath);
+
+    // --- module-boundary bridge: module.M:file / module.M:var.X / module.M:output.X ---
+    const scoped = qname.match(/^module\.([^.:\s]+):(.+)$/);
+    if (scoped) {
+      return resolveScopedModuleRef(ref, scoped[1]!, scoped[2]!, refDir, context);
+    }
+
+    const candidates = context.getNodesByQualifiedName(qname);
+    if (candidates.length === 0) return null;
+
+    // 1. Same directory — the only scope Terraform can actually reference.
+    const sameDir = candidates.filter((c) => dirOf(c.filePath) === refDir);
+    if (sameDir.length > 0) {
+      return {
+        original: ref,
+        targetNodeId: sameDir[0]!.id,
+        confidence: 0.95,
+        resolvedBy: 'framework',
+      };
+    }
+
+    // 2. `.tfvars` assignments set ROOT module variables, and var-files are
+    //    routinely kept in a subdirectory (`envs/prod.tfvars`). Walk up to
+    //    the nearest ancestor directory that declares the variable.
+    if (ref.filePath.endsWith('.tfvars') && qname.startsWith('var.')) {
+      for (let dir = parentOf(refDir); dir !== null; dir = parentOf(dir)) {
+        const inDir = candidates.filter((c) => dirOf(c.filePath) === dir);
+        if (inDir.length > 0) {
+          return {
+            original: ref,
+            targetNodeId: inDir[0]!.id,
+            confidence: 0.9,
+            resolvedBy: 'framework',
+          };
+        }
+      }
+    }
+
+    // 3. No same-directory declaration → no edge. A candidate in another
+    //    module directory is never the real target (cross-module access only
+    //    exists through module.M inputs/outputs, bridged above), and a wrong
+    //    edge is worse than none.
+    return null;
+  },
+};
+
+/**
+ * Resolve `module.M:<child>` by locating the `module "M"` declaration in the
+ * reference's own directory, reading its `source` attribute, and looking the
+ * child symbol up inside that directory.
+ */
+function resolveScopedModuleRef(
+  ref: UnresolvedRef,
+  moduleName: string,
+  child: string,
+  refDir: string,
+  context: ResolutionContext
+): ResolvedRef | null {
+  const decls = context
+    .getNodesByQualifiedName(`module.${moduleName}`)
+    .filter((n) => n.kind === 'module');
+  if (decls.length === 0) return null;
+  // Terraform scoping: the declaration lives in the reference's directory.
+  const decl = decls.find((d) => dirOf(d.filePath) === refDir) ?? (decls.length === 1 ? decls[0]! : null);
+  if (!decl) return null;
+
+  const source = readModuleSource(decl, context);
+  if (!source || !(source.startsWith('./') || source.startsWith('../'))) {
+    // Registry / git / absolute sources are out-of-repo: stay unresolved.
+    return null;
+  }
+  const targetDir = normalizeRel(joinDirs(dirOf(decl.filePath), source));
+
+  if (child === 'file') {
+    const tfFiles = context
+      .getAllFiles()
+      .filter((f) => dirOf(f) === targetDir && (f.endsWith('.tf') || f.endsWith('.tofu')))
+      .sort();
+    if (tfFiles.length === 0) return null;
+    const entry = tfFiles.find((f) => f.endsWith('/main.tf') || f === 'main.tf') ?? tfFiles[0]!;
+    const fileNode = context.getNodesInFile(entry).find((n) => n.kind === 'file');
+    if (!fileNode) return null;
+    return { original: ref, targetNodeId: fileNode.id, confidence: 0.95, resolvedBy: 'framework' };
+  }
+
+  // child is `var.X` or `output.X` — the child module's own qualified names.
+  const target = context
+    .getNodesByQualifiedName(child)
+    .filter((c) => dirOf(c.filePath) === targetDir);
+  if (target.length === 0) return null;
+  return { original: ref, targetNodeId: target[0]!.id, confidence: 0.95, resolvedBy: 'framework' };
+}
+
+/**
+ * The `source = "…"` string of a module declaration, re-read from its file
+ * (project paths are stored relative; node metadata isn't persisted, so the
+ * declaration's line span + cached file lines are the durable carrier).
+ */
+function readModuleSource(decl: Node, context: ResolutionContext): string | null {
+  const lines =
+    context.getFileLines?.(decl.filePath) ?? context.readFile(decl.filePath)?.split('\n') ?? null;
+  if (!lines) return null;
+  const end = Math.min(decl.endLine, lines.length);
+  for (let i = Math.max(decl.startLine - 1, 0); i < end; i++) {
+    const m = lines[i]!.match(/^\s*source\s*=\s*"([^"]+)"/);
+    if (m) return m[1]!;
+  }
+  return null;
+}
+
+/** Directory of a stored (forward-slash, project-relative) path. */
+function dirOf(p: string): string {
+  const d = path.dirname(p);
+  return d === '' ? '.' : d;
+}
+
+/** Parent directory, or null above the project root. */
+function parentOf(dir: string): string | null {
+  if (dir === '.' || dir === '') return null;
+  const parent = path.dirname(dir);
+  return parent === dir ? null : parent;
+}
+
+/** Join a base directory with a `./`/`../` relative source path. */
+function joinDirs(base: string, rel: string): string {
+  return path.join(base === '.' ? '' : base, rel);
+}
+
+/** Normalize to the stored path shape: forward slashes, '.' for the root. */
+function normalizeRel(p: string): string {
+  const n = path.normalize(p).replace(/\\/g, '/').replace(/\/+$/, '');
+  return n === '' ? '.' : n;
+}

+ 5 - 1
src/resolution/index.ts

@@ -815,7 +815,11 @@ export class ReferenceResolver {
     // If that didn't find the file, do NOT fall back to the symbol
     // name-matcher — it would mis-connect e.g. "inc/db.php" to an unrelated
     // db.php elsewhere in the tree (a wrong edge is worse than none, #660).
-    if (isPhpIncludePathRef(ref) || isCobolCopybookRef(ref)) {
+    // Terraform refs are directory-scoped by language semantics — the
+    // framework resolver IS the whole rulebook (`var.X` can never legally
+    // bind outside its module directory), so the name-matcher's
+    // qualified-name fallback would only ever add wrong cross-module edges.
+    if (isPhpIncludePathRef(ref) || isCobolCopybookRef(ref) || ref.language === 'terraform') {
       return candidates.length > 0
         ? candidates.reduce((best, curr) =>
             curr.confidence > best.confidence ? curr : best

+ 1 - 0
src/types.ts

@@ -102,6 +102,7 @@ export const LANGUAGES = [
   'cobol',
   'vbnet',
   'erlang',
+  'terraform',
   'unknown',
 ] as const;