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:
co-authored by
Claude Fable 5
Javier Rodríguez Fernández
parent
e1a8d888e5
commit
6c24f4bddf
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user