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>
This commit is contained in:
Colby Mchenry
2026-07-03 18:37:07 -05:00
committed by GitHub
co-authored by Claude Fable 5 Javier Rodríguez Fernández
parent e1a8d888e5
commit 6c24f4bddf
13 changed files with 1139 additions and 4 deletions
+131
View File
@@ -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();
}
});
});