diff --git a/.claude/skills/agent-eval/corpus.json b/.claude/skills/agent-eval/corpus.json index def7c3e..8699a53 100644 --- a/.claude/skills/agent-eval/corpus.json +++ b/.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?" + } ] } diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a7dc37..3f65746 100644 --- a/CHANGELOG.md +++ b/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 `<<>>` 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<<>>(args)`), launches through a local function pointer (`auto kernel = &my_kernel<...>; ... kernel<<>>(args)` — each branch-assigned target linked), brace-initialized launch configs (`<<>>`), 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(args)`) now link to the function they instantiate, the same normalization templated base classes already had. diff --git a/README.md b/README.md index e26ed81..c523d8b 100644 --- a/README.md +++ b/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 diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index eb51c86..061e1f0 100644 --- a/__tests__/extraction.test.ts +++ b/__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. 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); + }); + }); +}); diff --git a/__tests__/frameworks-integration.test.ts b/__tests__/frameworks-integration.test.ts index 2354604..a68f8df 100644 --- a/__tests__/frameworks-integration.test.ts +++ b/__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(); + } + }); +}); diff --git a/src/extraction/grammars.ts b/src/extraction/grammars.ts index 973f902..aef4cac 100644 --- a/src/extraction/grammars.ts +++ b/src/extraction/grammars.ts @@ -46,6 +46,7 @@ const WASM_GRAMMAR_FILES: Record = { 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 = { // 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> = { typescript: typescriptExtractor, @@ -63,4 +64,5 @@ export const EXTRACTORS: Partial> = { vbnet: vbnetExtractor, erlang: erlangExtractor, solidity: solidityExtractor, + terraform: terraformExtractor, }; diff --git a/src/extraction/languages/terraform.ts b/src/extraction/languages/terraform.ts new file mode 100644 index 0000000..536420b --- /dev/null +++ b/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: + // .[....] — 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.` (references) → the child module's + * `variable ""` 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>[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>[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>[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); + } + } +} diff --git a/src/extraction/wasm/tree-sitter-terraform.wasm b/src/extraction/wasm/tree-sitter-terraform.wasm new file mode 100644 index 0000000..5a5e0e1 Binary files /dev/null and b/src/extraction/wasm/tree-sitter-terraform.wasm differ diff --git a/src/resolution/frameworks/index.ts b/src/resolution/frameworks/index.ts index 28d835b..91da9a0 100644 --- a/src/resolution/frameworks/index.ts +++ b/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, ]; /** diff --git a/src/resolution/frameworks/terraform.ts b/src/resolution/frameworks/terraform.ts new file mode 100644 index 0000000..80c3157 --- /dev/null +++ b/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.` → the child module's `variable ""` node — + * the module block sets that variable, so "what depends on the child's + * var.cidr" reaches every caller. + * - `module.M:output.` → the child module's `output ""` 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:` 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; +} diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 698417e..aa36fbf 100644 --- a/src/resolution/index.ts +++ b/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 diff --git a/src/types.ts b/src/types.ts index 82c03c4..3681723 100644 --- a/src/types.ts +++ b/src/types.ts @@ -102,6 +102,7 @@ export const LANGUAGES = [ 'cobol', 'vbnet', 'erlang', + 'terraform', 'unknown', ] as const;