diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f65746..2201da7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +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 **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). Cross-component wiring through the cloudposse/atmos `remote-state` module connects too: `module.vpc.outputs.vpc_cidr` in one component reaches the `vpc` component's own output when the component name is statically declared (a literal, or a variable with a literal default) and exactly one directory matches — anything dynamic or ambiguous stays unlinked. Aliased providers are first-class: `provider "aws" { alias = "east" }` gets its own symbol, and `provider = aws.east` on a resource (or a module's `providers` map) links to that configuration, found up the module tree the way Terraform actually inherits it. `moved`/`import`/`removed` state-migration blocks and `check` assertions reference the resources they name, so a refactor's paper trail is part of the graph. `.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 c523d8b..67b2a03 100644 --- a/README.md +++ b/README.md @@ -721,7 +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) | +| Terraform / OpenTofu | `.tf`, `.tfvars`, `.tofu` | Full support (resources, data sources, modules, variables, outputs, providers incl. aliases, `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; cloudposse/atmos `remote-state` cross-component wiring when the component is statically named; `provider = aws.east` selections resolved up the module tree; `moved`/`import`/`removed`/`check` block references; `.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 061e1f0..61ffa64 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -10068,6 +10068,121 @@ module "net" { expect(refs).toContain('module.s3:var.bucket'); }); + it('should emit a remote-output candidate for module.M.outputs.X chains', () => { + const code = ` +resource "aws_eks_cluster" "this" { + vpc_id = module.vpc.outputs.vpc_id +} +`; + const result = extractFromSource('main.tf', code); + const refs = result.unresolvedReferences.map((r) => r.referenceName); + expect(refs).toContain('module.vpc'); + expect(refs).toContain('module.vpc:remote-output.vpc_id'); + // A plain two-segment chain must NOT produce a remote-output candidate. + const plain = extractFromSource('o.tf', 'output "x" {\n value = module.vpc.vpc_id\n}\n'); + const plainRefs = plain.unresolvedReferences.map((r) => r.referenceName); + expect(plainRefs.some((r) => r.includes(':remote-output.'))).toBe(false); + }); + + it('should reference resource addresses from moved/import/removed blocks, anchored to the file', () => { + const code = ` +resource "aws_instance" "new" {} +moved { + from = aws_instance.old + to = aws_instance.new +} +import { + to = aws_s3_bucket.b + id = "bucket-name" +} +removed { + from = module.legacy.aws_iam_role.r +} +`; + const result = extractFromSource('main.tf', code); + const refs = result.unresolvedReferences; + const names = refs.map((r) => r.referenceName); + expect(names).toContain('aws_instance.old'); + expect(names).toContain('aws_instance.new'); + expect(names).toContain('aws_s3_bucket.b'); + expect(names).toContain('module.legacy'); + // Scoped module refs are suppressed here: module.legacy.aws_iam_role.r + // names a resource inside a module instance, never a module output. + expect(names.some((n) => n.includes(':'))).toBe(false); + // Anchored to the file node, and no phantom symbols were declared. + const fileNode = result.nodes.find((n) => n.kind === 'file'); + for (const r of refs.filter((x) => x.referenceName === 'aws_instance.old')) { + expect(r.fromNodeId).toBe(fileNode?.id); + } + expect(result.nodes.filter((n) => n.kind !== 'file')).toHaveLength(1); // just aws_instance.new + }); + + it('should collect check-assert condition references and still index check-scoped data blocks', () => { + const code = ` +check "health" { + data "http" "ping" { + url = var.endpoint + } + assert { + condition = data.http.ping.status_code == 200 && var.strict + error_message = "unhealthy" + } +} +`; + const result = extractFromSource('checks.tf', code); + const names = result.unresolvedReferences.map((r) => r.referenceName); + expect(names).toContain('data.http.ping'); + expect(names).toContain('var.strict'); + expect(names).toContain('var.endpoint'); + // The scoped data source inside the check is a real symbol. + expect(result.nodes.find((n) => n.qualifiedName === 'data.http.ping')).toBeDefined(); + }); + + it('should qualify aliased provider blocks and reference provider selections', () => { + const code = ` +provider "aws" { + region = "us-east-1" +} +provider "aws" { + alias = "east" + region = "us-east-2" +} +resource "aws_s3_bucket" "b" { + provider = aws.east + bucket = "x" +} +resource "google_service_account" "sa" { + provider = google-beta +} +`; + const result = extractFromSource('main.tf', code); + const providers = result.nodes.filter((n) => n.kind === 'namespace').map((n) => n.qualifiedName).sort(); + expect(providers).toEqual(['provider.aws', 'provider.aws.east']); + const names = result.unresolvedReferences.map((r) => r.referenceName); + expect(names).toContain('provider.aws.east'); + expect(names).toContain('provider.google-beta'); + // The selection must not be misread as a resource reference. + expect(names).not.toContain('aws.east'); + }); + + it('should reference the values (not keys) of a module providers map', () => { + const code = ` +module "vpc" { + source = "./modules/vpc" + providers = { + aws = aws.east + } +} +`; + const result = extractFromSource('main.tf', code); + const names = result.unresolvedReferences.map((r) => r.referenceName); + expect(names).toContain('provider.aws.east'); + expect(names).not.toContain('provider.aws'); + expect(names).not.toContain('aws.east'); + // providers is a meta-argument — no input wiring for it. + expect(names).not.toContain('module.vpc:var.providers'); + }); + it('should emit data.T.N references stripped of the trailing attribute', () => { const code = ` output "account" { diff --git a/__tests__/frameworks-integration.test.ts b/__tests__/frameworks-integration.test.ts index a68f8df..8cf8363 100644 --- a/__tests__/frameworks-integration.test.ts +++ b/__tests__/frameworks-integration.test.ts @@ -1077,7 +1077,7 @@ describe('Terraform end-to-end module-boundary resolution', () => { .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); + const orphanTargets = orphanEdges.map((e) => cg.getNode(e.target)?.qualifiedName); expect(orphanTargets).not.toContain('var.undeclared_here_elsewhere_yes'); // Registry-sourced module: inputs stay unresolved (no guessed edges). @@ -1092,3 +1092,125 @@ describe('Terraform end-to-end module-boundary resolution', () => { } }); }); + +describe('Terraform follow-ups: remote-state bridge, provider alias, moved blocks', () => { + let tmpDir: string | undefined; + afterEach(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + }); + + it('bridges atmos remote-state to the target component, resolves provider aliases up the tree, links moved blocks', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-terraform-fu-')); + // Component producing state. + fs.mkdirSync(path.join(tmpDir, 'components/terraform/vpc'), { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, 'components/terraform/vpc/outputs.tf'), + 'output "vpc_id" {\n value = "vpc-123"\n}\n' + ); + // Component consuming it via the cloudposse remote-state module. + fs.mkdirSync(path.join(tmpDir, 'components/terraform/eks/cluster'), { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, 'components/terraform/eks/cluster/remote-state.tf'), + 'module "vpc" {\n' + + ' source = "cloudposse/stack-config/yaml//modules/remote-state"\n' + + ' component = var.vpc_component_name\n' + + '}\n' + + 'variable "vpc_component_name" {\n' + + ' type = string\n' + + ' default = "vpc"\n' + + '}\n' + ); + fs.writeFileSync( + path.join(tmpDir, 'components/terraform/eks/cluster/main.tf'), + 'resource "aws_eks_cluster" "this" {\n vpc_id = module.vpc.outputs.vpc_id\n}\n' + ); + // Ambiguous component name — two directories called "dns" with the same + // output; the bridge must refuse to pick one. + fs.mkdirSync(path.join(tmpDir, 'components/terraform/dns'), { recursive: true }); + fs.mkdirSync(path.join(tmpDir, 'legacy/dns'), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, 'components/terraform/dns/outputs.tf'), 'output "zone_id" {\n value = "z1"\n}\n'); + fs.writeFileSync(path.join(tmpDir, 'legacy/dns/outputs.tf'), 'output "zone_id" {\n value = "z2"\n}\n'); + fs.writeFileSync( + path.join(tmpDir, 'components/terraform/eks/cluster/dns.tf'), + 'module "dns" {\n' + + ' source = "cloudposse/stack-config/yaml//modules/remote-state"\n' + + ' component = "dns"\n' + + '}\n' + + 'output "zone" {\n value = module.dns.outputs.zone_id\n}\n' + ); + // Provider alias declared at the root, selected inside a module dir. + fs.writeFileSync( + path.join(tmpDir, 'providers.tf'), + 'provider "aws" {\n region = "us-east-1"\n}\n' + + 'provider "aws" {\n alias = "east"\n region = "us-east-2"\n}\n' + ); + fs.mkdirSync(path.join(tmpDir, 'modules/app'), { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, 'modules/app/main.tf'), + 'resource "aws_s3_bucket" "b" {\n provider = aws.east\n bucket = "x"\n}\n' + ); + // Moved block referencing a live resource. + fs.writeFileSync( + path.join(tmpDir, 'main.tf'), + 'resource "aws_instance" "renamed" {}\n' + + 'moved {\n from = aws_instance.old\n to = aws_instance.renamed\n}\n' + ); + + 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)); + + // 1. remote-state bridge: consumer resource → producer component's output. + const consumer = byQname('aws_eks_cluster.this')[0] ?? + cg.getNodesInFile('components/terraform/eks/cluster/main.tf').find((n) => n.qualifiedName === 'aws_eks_cluster.this'); + expect(consumer, 'consumer resource').toBeDefined(); + const producerOut = byQname('output.vpc_id', 'components/terraform/vpc/outputs.tf')[0]; + expect(producerOut, "producer component's output").toBeDefined(); + expect( + cg.getOutgoingEdges(consumer!.id).find((e) => e.target === producerOut!.id), + 'remote-state bridge edge eks/cluster → vpc output' + ).toBeDefined(); + + // 2. Ambiguous component name → no bridge edge to either candidate. + const zoneOut = byQname('output.zone', 'components/terraform/eks/cluster/dns.tf')[0]; + expect(zoneOut).toBeDefined(); + const zoneTargets = cg + .getOutgoingEdges(zoneOut!.id) + .map((e) => cg.getNode(e.target)) + .filter((n) => n?.qualifiedName === 'output.zone_id'); + expect(zoneTargets, 'ambiguous component must not be guessed').toHaveLength(0); + + // 3. Provider alias: nodes are distinct, and the selection inside the + // module resolves up the tree to the aliased configuration. + const provNodes = cg.getNodesInFile('providers.tf'); + const aliased = provNodes.find((n) => n.qualifiedName === 'provider.aws.east'); + const defaultProv = provNodes.find((n) => n.qualifiedName === 'provider.aws'); + expect(aliased, 'aliased provider node').toBeDefined(); + expect(defaultProv, 'default provider node').toBeDefined(); + const bucket = cg.getNodesInFile('modules/app/main.tf').find((n) => n.qualifiedName === 'aws_s3_bucket.b'); + expect(bucket).toBeDefined(); + const bucketEdges = cg.getOutgoingEdges(bucket!.id); + expect( + bucketEdges.find((e) => e.target === aliased!.id), + 'provider = aws.east → aliased provider (ancestor walk)' + ).toBeDefined(); + expect(bucketEdges.find((e) => e.target === defaultProv!.id), 'must not link the default provider').toBeUndefined(); + + // 4. moved block: the file references the live resource. + const renamed = cg.getNodesInFile('main.tf').find((n) => n.qualifiedName === 'aws_instance.renamed'); + expect(renamed).toBeDefined(); + const rootFile = cg.getNodesInFile('main.tf').find((n) => n.kind === 'file'); + expect( + cg.getOutgoingEdges(rootFile!.id).find((e) => e.target === renamed!.id), + 'moved block → live resource edge' + ).toBeDefined(); + } finally { + cg.close(); + } + }); +}); diff --git a/src/extraction/languages/terraform.ts b/src/extraction/languages/terraform.ts index 536420b..69368f7 100644 --- a/src/extraction/languages/terraform.ts +++ b/src/extraction/languages/terraform.ts @@ -1,5 +1,5 @@ import type { Node as SyntaxNode } from 'web-tree-sitter'; -import { getNodeText } from '../tree-sitter-helpers'; +import { getNodeText, getChildByField } from '../tree-sitter-helpers'; import type { LanguageExtractor } from '../tree-sitter-types'; // Grammar: tree-sitter-terraform (vendored at src/extraction/wasm/tree-sitter-terraform.wasm, @@ -150,7 +150,7 @@ function qualifyReference(head: string, attrs: string[]): string[] { case 'local': // local.K — locals attribute K return attrs[0] ? [`local.${attrs[0]}`] : []; - case 'module': + 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 @@ -160,9 +160,16 @@ function qualifyReference(head: string, attrs: string[]): string[] { // 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]}`]; + const refs = [`module.${attrs[0]}`]; + if (attrs[1]) refs.push(`module.${attrs[0]}:output.${attrs[1]}`); + // module.M.outputs.X — the cloudposse/atmos remote-state shape (the + // remote-state module re-exposes another component's outputs under its + // `outputs` map). Emit a scoped candidate the resolver bridges to that + // component's own `output "X"` when the target is provably unique; + // anything dynamic or ambiguous stays unresolved. + if (attrs[1] === 'outputs' && attrs[2]) refs.push(`module.${attrs[0]}:remote-output.${attrs[2]}`); + return refs; + } case 'data': // data.TYPE.NAME[.ATTR] — data "TYPE" "NAME" return attrs[0] && attrs[1] ? [`data.${attrs[0]}.${attrs[1]}`] : []; @@ -239,6 +246,32 @@ export const terraformExtractor: LanguageExtractor = { return true; } + // --- moved / import / removed: state-migration blocks. Their from/to + // attributes hold resource addresses, so a refactor's paper trail joins + // the graph ("what references aws_instance.old" includes the moved + // block's file). No symbol is declared — anchor the refs to the file + // node. Scoped module refs are suppressed: `module.a.aws_x.b` here names + // a resource INSIDE a module instance, not a module output. + if ((type === 'moved' || type === 'import' || type === 'removed') && labels.length === 0) { + const fileNodeId = ctx.nodeStack[0]; + if (body && fileNodeId) { + emitReferencesInBody(body, ctx, fileNodeId, { suppressScoped: true }); + } + return true; + } + + // --- assert { condition = … } (inside check blocks): the condition's + // references are real dependencies of the check; anchor them to the file + // node. The check block itself declares no symbol and is left to the + // default walker, so its nested scoped `data` blocks still index. + if (type === 'assert' && labels.length === 0) { + const fileNodeId = ctx.nodeStack[0]; + if (body && fileNodeId) { + emitReferencesInBody(body, ctx, fileNodeId, { suppressScoped: true }); + } + return true; + } + // --- resource / data / module / variable / output / provider --- const decl = describeBlock(type, labels); if (!decl) { @@ -247,6 +280,18 @@ export const terraformExtractor: LanguageExtractor = { return false; } + // provider "aws" { alias = "east" } is addressed as `aws.east`; carry the + // alias in the node so aliased and default configurations of the same + // provider stop colliding on one qualified name. + if (type === 'provider' && body && labels[0]) { + const alias = readStringAttr(body, 'alias', ctx.source); + if (alias) { + decl.name = `${labels[0]}.${alias}`; + decl.qualifiedName = `provider.${labels[0]}.${alias}`; + decl.signature = `provider "${labels[0]}" alias="${alias}"`; + } + } + const created = ctx.createNode(decl.kind, decl.name, node, { qualifiedName: decl.qualifiedName, signature: decl.signature, @@ -259,7 +304,20 @@ export const terraformExtractor: LanguageExtractor = { if (body) { ctx.pushScope(created.id); try { - emitReferencesInBody(body, ctx, created.id); + // The `provider` / `providers` meta-arguments select a provider + // CONFIGURATION (`aws.east`), which the generic expression walk would + // misread as a resource reference — handle them explicitly and skip + // them in the walk. + const skipTopAttrs = new Set(); + if (type === 'resource' || type === 'data') { + emitProviderSelectionRef(body, ctx, created.id); + skipTopAttrs.add('provider'); + } + if (type === 'module') { + emitModuleProvidersRefs(body, ctx, created.id); + skipTopAttrs.add('providers'); + } + emitReferencesInBody(body, ctx, created.id, { skipTopAttrs }); if (type === 'module' && labels[0]) { emitModuleWiring(labels[0], node, body, ctx, created.id); } @@ -447,16 +505,33 @@ function emitLocals( } } +interface EmitRefsOptions { + /** Drop `:`-scoped module refs (moved/import blocks name resources INSIDE a module instance). */ + suppressScoped?: boolean; + /** Direct attributes of `body` to skip (meta-arguments handled explicitly elsewhere). */ + skipTopAttrs?: Set; +} + function emitReferencesInBody( body: SyntaxNode, ctx: Parameters>[1], - fromNodeId: string + fromNodeId: string, + opts?: EmitRefsOptions ): void { - const queue: SyntaxNode[] = [body]; + const queue: SyntaxNode[] = []; + for (const c of body.namedChildren) { + if (!c) continue; + if (opts?.skipTopAttrs && c.type === 'attribute') { + const id = c.namedChildren.find((x) => x?.type === 'identifier'); + if (id && opts.skipTopAttrs.has(getNodeText(id, ctx.source))) continue; + } + queue.push(c); + } while (queue.length) { const n = queue.shift()!; if (n.type === 'expression') { collectReferences(n, ctx.source, (qname, line, column) => { + if (opts?.suppressScoped && qname.includes(':')) return; ctx.addUnresolvedReference({ fromNodeId, referenceName: qname, @@ -473,3 +548,120 @@ function emitReferencesInBody( } } } + +/** + * Value of a direct string attribute of a body (`alias = "east"`), or null. + */ +function readStringAttr(body: SyntaxNode, name: string, source: string): string | null { + for (const attr of body.namedChildren) { + if (!attr || attr.type !== 'attribute') continue; + const idNode = attr.namedChildren.find((c) => c?.type === 'identifier'); + if (!idNode || getNodeText(idNode, source) !== name) continue; + const expr = attr.namedChildren.find((c) => c?.type === 'expression'); + const lit = expr ? findStringLit(expr) : null; + return lit ? stringLitValue(lit, source) : null; + } + return null; +} + +/** + * `provider = aws.east` (or bare `provider = google-beta`) in a resource/data + * block selects a provider CONFIGURATION — reference `provider.aws.east` / + * `provider.google-beta` so the selection links to the aliased provider block + * instead of being misread as a resource named `aws.east`. + */ +function emitProviderSelectionRef( + 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 || getNodeText(idNode, ctx.source) !== 'provider') continue; + const expr = attr.namedChildren.find((c) => c?.type === 'expression'); + if (!expr) return; + const sel = providerSelectionFromExpr(expr, ctx.source); + if (sel) { + ctx.addUnresolvedReference({ + fromNodeId, + referenceName: `provider.${sel}`, + referenceKind: 'references', + line: attr.startPosition.row + 1, + column: attr.startPosition.column, + }); + } + return; + } +} + +/** + * `providers = { aws = aws.east, aws.dns = aws.dns }` in a module block maps + * the child's provider slots (keys) to THIS scope's provider configurations + * (values) — reference each value. + */ +function emitModuleProvidersRefs( + 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 || getNodeText(idNode, ctx.source) !== 'providers') continue; + // Find every object_elem and read its `val` side only — the key names the + // CHILD module's provider requirement, not a configuration here. + const queue: SyntaxNode[] = [attr]; + while (queue.length) { + const n = queue.shift()!; + if (n.type === 'object_elem') { + const val = getChildByField(n, 'val'); + const sel = val ? providerSelectionFromExpr(val, ctx.source) : null; + if (sel) { + ctx.addUnresolvedReference({ + fromNodeId, + referenceName: `provider.${sel}`, + referenceKind: 'references', + line: n.startPosition.row + 1, + column: n.startPosition.column, + }); + } + continue; + } + for (const c of n.namedChildren) { + if (c) queue.push(c); + } + } + return; + } +} + +/** + * Read a provider-configuration address (`aws`, `aws.east`, `google-beta`) + * from an expression. Anything more complex (conditionals, lookups) is + * dynamic — return null and leave it unresolved. + */ +function providerSelectionFromExpr(expr: SyntaxNode, source: string): string | null { + const queue: SyntaxNode[] = [expr]; + while (queue.length) { + const n = queue.shift()!; + if (n.type === 'variable_expr') { + const id = n.namedChildren.find((c) => c?.type === 'identifier'); + if (!id) return null; + const head = getNodeText(id, source); + const next = n.nextNamedSibling; + if (next?.type === 'get_attr') { + const attrId = next.namedChildren.find((c) => c?.type === 'identifier'); + // A second segment means something dynamic (e.g. var.x.y) — bail. + if (!attrId || next.nextNamedSibling) return null; + return `${head}.${getNodeText(attrId, source)}`; + } + return next ? null : head; + } + if (n.type === 'function_call' || n.type === 'conditional' || n.type === 'for_expr') return null; + for (const c of n.namedChildren) { + if (c) queue.push(c); + } + } + return null; +} diff --git a/src/resolution/frameworks/terraform.ts b/src/resolution/frameworks/terraform.ts index 80c3157..de5ecd3 100644 --- a/src/resolution/frameworks/terraform.ts +++ b/src/resolution/frameworks/terraform.ts @@ -38,8 +38,8 @@ 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\.)/; +/** `module.M:file` / `module.M:var.X` / `module.M:output.X` / `module.M:remote-output.X` — extractor-emitted scoped refs. */ +const SCOPED_REF = /^module\.([^.:\s]+):(file$|var\.|output\.|remote-output\.)/; export const terraformResolver: FrameworkResolver = { name: 'terraform', @@ -85,19 +85,25 @@ export const terraformResolver: FrameworkResolver = { // 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', - }; - } + const up = nearestAncestorMatch(candidates, refDir); + if (up) { + return { original: ref, targetNodeId: up.id, confidence: 0.9, resolvedBy: 'framework' }; } } + // 2b. Provider configurations are the one construct Terraform inherits + // across the module tree: they're declared in the root (or a parent) + // module and passed down, so `provider = aws.east` inside a child + // module legitimately names a configuration declared above it. + if (qname.startsWith('provider.')) { + const configs = candidates.filter((c) => c.kind === 'namespace'); + const up = nearestAncestorMatch(configs, refDir); + if (up) { + return { original: ref, targetNodeId: up.id, confidence: 0.9, resolvedBy: 'framework' }; + } + return null; + } + // 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 @@ -106,6 +112,15 @@ export const terraformResolver: FrameworkResolver = { }, }; +/** Nearest candidate walking UP the directory tree from refDir (exclusive). */ +function nearestAncestorMatch(candidates: T[], refDir: string): T | null { + for (let dir = parentOf(refDir); dir !== null; dir = parentOf(dir)) { + const inDir = candidates.filter((c) => dirOf(c.filePath) === dir); + if (inDir.length > 0) return inDir[0]!; + } + return null; +} + /** * Resolve `module.M:` by locating the `module "M"` declaration in the * reference's own directory, reading its `source` attribute, and looking the @@ -126,8 +141,50 @@ function resolveScopedModuleRef( 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('../'))) { + const source = readModuleAttr(decl, 'source', context); + if (!source) return null; + + // --- cloudposse/atmos remote-state: module.M.outputs.X where M is the + // stack-config remote-state module reading another COMPONENT's state. The + // component name is static in the monorepo case (`component = "vpc"` or + // "eks/cluster"), so bridge to that component directory's own + // `output "X"` — but only when every gate holds: the module source is the + // remote-state module, the component is a string literal, and exactly ONE + // directory in the repo matches the component name and declares that + // output. Anything dynamic or ambiguous stays a visible boundary. + if (child.startsWith('remote-output.')) { + if (!/\/remote-state(\/|$)/.test(source)) return null; + let component = readModuleAttr(decl, 'component', context); + if (!component) { + // The other half of real-world declarations indirect through a + // variable with a literal default in the same directory + // (`component = var.vpc_component_name` + `default = "vpc"`) — the + // component's declared static wiring. One hop, same literal gate. + const viaVar = readNodeSpanMatch(decl, /^\s*component\s*=\s*var\.([A-Za-z0-9_-]+)\s*$/, context); + if (viaVar) { + const declared = context + .getNodesByQualifiedName(`var.${viaVar}`) + .filter((n) => dirOf(n.filePath) === dirOf(decl.filePath)); + if (declared.length === 1) { + component = readNodeSpanMatch(declared[0]!, /^\s*default\s*=\s*"([^"]+)"/, context); + } + } + } + if (!component) return null; + const outName = child.slice('remote-output.'.length); + const outs = context + .getNodesByQualifiedName(`output.${outName}`) + .filter((o) => { + const d = dirOf(o.filePath); + return d === component || d.endsWith('/' + component); + }); + if (outs.length === 0) return null; + const dirs = new Set(outs.map((o) => dirOf(o.filePath))); + if (dirs.size > 1) return null; // two directories claim this component name — never guess + return { original: ref, targetNodeId: outs[0]!.id, confidence: 0.9, resolvedBy: 'framework' }; + } + + if (!(source.startsWith('./') || source.startsWith('../'))) { // Registry / git / absolute sources are out-of-repo: stay unresolved. return null; } @@ -154,17 +211,24 @@ function resolveScopedModuleRef( } /** - * 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). + * A direct string-literal attribute (`source = "…"`, `component = "…"`) 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). Non-literal values (variables, + * expressions) return null — dynamic wiring is never guessed. */ -function readModuleSource(decl: Node, context: ResolutionContext): string | null { +function readModuleAttr(decl: Node, name: string, context: ResolutionContext): string | null { + return readNodeSpanMatch(decl, new RegExp(`^\\s*${name}\\s*=\\s*"([^"]+)"`), context); +} + +/** First capture of `re` across the node's line span, or null. */ +function readNodeSpanMatch(node: Node, re: RegExp, context: ResolutionContext): string | null { const lines = - context.getFileLines?.(decl.filePath) ?? context.readFile(decl.filePath)?.split('\n') ?? null; + context.getFileLines?.(node.filePath) ?? context.readFile(node.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*"([^"]+)"/); + const end = Math.min(node.endLine, lines.length); + for (let i = Math.max(node.startLine - 1, 0); i < end; i++) { + const m = lines[i]!.match(re); if (m) return m[1]!; } return null;