diff --git a/CHANGELOG.md b/CHANGELOG.md index 54ef5f5aa..ae8ddafba 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 +- Terraform and OpenTofu are now first-class indexed languages — `.tf`, `.tfvars`, and `.tofu` files are parsed into the graph, and `codegraph_search`, `codegraph_callers`, `codegraph_callees`, and `codegraph_impact` return real results instead of nothing. 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. The Terraform resolver disambiguates same-named variables across modules by preferring the variable defined in the same module directory as the reference site, so asking "what depends on `var.project_id`" in a multi-module repo no longer mixes in unrelated modules. - `codegraph status --json` now also reports the running CLI `version`, the index directory (`indexPath`), and a `lastIndexed` timestamp (ISO-8601, or null when nothing's indexed yet), so CI and scripts can pin the CLI version and check index freshness from a single command. A matching `CodeGraph.getLastIndexedAt()` library method exposes the same freshness check without shelling out. Thanks @12122J and @eddieran. (#329) ### Fixes diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index d29fa11b3..36773716c 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -101,6 +101,13 @@ describe('Language Detection', () => { expect(detectLanguage('stdio.h', '#ifndef STDIO_H\nvoid printf();\n#endif\n')).toBe('c'); }); + 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'); @@ -4459,3 +4466,215 @@ func (s Stack[T]) Len() int { return len(s.items) } expect(js.nodes.find((n) => n.name === 'handleRequest' && n.kind === 'function')).toBeDefined(); }); }); + +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. Confirm we don't + // crash and that the file is still tracked (file node present). + const code = ` +region = "us-east-1" +environment = "prod" +`; + const result = extractFromSource('terraform.tfvars', code); + // No symbols expected (tfvars has no declarations we extract), but the + // file must still parse cleanly with zero errors. + expect(result.errors.filter((e) => e.severity === 'error')).toHaveLength(0); + }); + }); + + 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 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/src/extraction/grammars.ts b/src/extraction/grammars.ts index 576845e20..074bebe10 100644 --- a/src/extraction/grammars.ts +++ b/src/extraction/grammars.ts @@ -38,6 +38,7 @@ const WASM_GRAMMAR_FILES: Record = { lua: 'tree-sitter-lua.wasm', luau: 'tree-sitter-luau.wasm', objc: 'tree-sitter-objc.wasm', + terraform: 'tree-sitter-terraform.wasm', }; /** @@ -108,6 +109,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', }; /** @@ -184,8 +189,10 @@ export async function loadGrammarsForLanguages(languages: Language[]): Promise> = { typescript: typescriptExtractor, @@ -49,4 +50,5 @@ export const EXTRACTORS: Partial> = { lua: luaExtractor, luau: luauExtractor, objc: objcExtractor, + terraform: terraformExtractor, }; diff --git a/src/extraction/languages/terraform.ts b/src/extraction/languages/terraform.ts new file mode 100644 index 000000000..b1c289446 --- /dev/null +++ b/src/extraction/languages/terraform.ts @@ -0,0 +1,368 @@ +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; + const qname = qualifyReference(head, attrs); + if (qname) onRef(qname, line, col); +} + +function qualifyReference(head: string, attrs: string[]): string | null { + switch (head) { + case 'var': + // var.X — variable "X" + return attrs[0] ? `var.${attrs[0]}` : null; + case 'local': + // local.K — locals attribute K + return attrs[0] ? `local.${attrs[0]}` : null; + case 'module': + // module.M[.OUTPUT] — module "M" + return attrs[0] ? `module.${attrs[0]}` : null; + case 'data': + // data.TYPE.NAME[.ATTR] — data "TYPE" "NAME" + return attrs[0] && attrs[1] ? `data.${attrs[0]}.${attrs[1]}` : null; + 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 null; + 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') { + // 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); + } finally { + ctx.popScope(); + } + } + return true; + }, +}; + +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 000000000..5a5e0e1d5 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 88bf205e6..a318a8d96 100644 --- a/src/resolution/frameworks/index.ts +++ b/src/resolution/frameworks/index.ts @@ -25,6 +25,7 @@ import { swiftObjcBridgeResolver } from './swift-objc'; import { reactNativeBridgeResolver } from './react-native'; import { expoModulesResolver } from './expo-modules'; import { fabricViewResolver } from './fabric'; +import { terraformResolver } from './terraform'; /** * All registered framework resolvers @@ -66,6 +67,8 @@ const FRAMEWORK_RESOLVERS: FrameworkResolver[] = [ expoModulesResolver, // React Native Fabric / Codegen view components — TS spec → component nodes fabricViewResolver, + // 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 000000000..1fc936d7b --- /dev/null +++ b/src/resolution/frameworks/terraform.ts @@ -0,0 +1,106 @@ +/** + * Terraform Framework Resolver + * + * Disambiguates Terraform references when the same qualified name exists in + * multiple modules. The generic name matcher resolves by qualified-name only, + * so a reference to `var.project_id` from `modules/net-vpc/main.tf` may bind + * to a `variable "project_id"` declared in an unrelated module like + * `modules/__experimental/net-neg/variables.tf`. + * + * Terraform's actual scoping rule is much narrower: `var.X`, `local.X`, and + * unqualified resource refs only resolve inside the *same module directory*. + * `module.M.` is resolved against `modules/M/outputs.tf` etc. We + * prefer: + * 1. Same directory as the reference site (highest confidence). + * 2. For `module.M` refs, the directory that contains a `module "M"` declaration. + * 3. Closest common-ancestor directory (fallback for shared root files). + */ + +import * as path from 'path'; +import type { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types'; + +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')); + }, + + resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { + if (ref.language !== 'terraform') return null; + + const qname = ref.referenceName; + const candidates = context.getNodesByQualifiedName(qname); + if (candidates.length === 0) return null; + + const refDir = path.dirname(ref.filePath); + + if (candidates.length === 1) { + // Cross-module module-output style refs (`module.M.`) will only + // ever have one variable matching `module.M`, but it could be anywhere + // in the tree; same-dir preference still applies if present. + const only = candidates[0]!; + return { + original: ref, + targetNodeId: only.id, + confidence: path.dirname(only.filePath) === refDir ? 0.95 : 0.8, + resolvedBy: 'framework', + }; + } + + // 1. Same directory wins — by far the most common case for var/local/resource refs. + const sameDir = candidates.filter((c) => path.dirname(c.filePath) === refDir); + if (sameDir.length > 0) { + return { + original: ref, + targetNodeId: sameDir[0]!.id, + confidence: 0.95, + resolvedBy: 'framework', + }; + } + + // 2. For `module.M[.X]` references, prefer the candidate whose directory + // matches the module name (e.g. `modules/iam` for `module.iam`). + if (qname.startsWith('module.')) { + const modName = qname.split('.')[1]; + if (modName) { + const byModuleDir = candidates.filter((c) => path.dirname(c.filePath).split(path.sep).includes(modName)); + if (byModuleDir.length > 0) { + return { + original: ref, + targetNodeId: byModuleDir[0]!.id, + confidence: 0.85, + resolvedBy: 'framework', + }; + } + } + } + + // 3. Closest common-prefix directory among siblings. + const ranked = [...candidates].sort( + (a, b) => commonPathPrefixLength(b.filePath, ref.filePath) - commonPathPrefixLength(a.filePath, ref.filePath) + ); + const best = ranked[0]!; + return { + original: ref, + targetNodeId: best.id, + // Lower confidence — this is a heuristic guess across modules. + confidence: 0.6, + resolvedBy: 'framework', + }; + }, +}; + +/** Length of the shared path prefix in path segments. */ +function commonPathPrefixLength(a: string, b: string): number { + const aSeg = path.dirname(a).split(path.sep); + const bSeg = path.dirname(b).split(path.sep); + const lim = Math.min(aSeg.length, bSeg.length); + let i = 0; + for (; i < lim; i++) { + if (aSeg[i] !== bSeg[i]) break; + } + return i; +} + diff --git a/src/types.ts b/src/types.ts index e710e31a1..147459fb5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -92,6 +92,7 @@ export const LANGUAGES = [ 'twig', 'xml', 'properties', + 'terraform', 'unknown', ] as const;