Sfoglia il codice sorgente

feat(terraform): remote-state bridge, provider aliases, moved/import/check refs (#1174)

Follow-ups noted in #1173:

- cloudposse/atmos remote-state: module.M.outputs.X emits a scoped
  module.M:remote-output.X candidate; the resolver bridges it to the
  target COMPONENT's own output when every gate holds — the module
  source is the stack-config remote-state module, the component name is
  static (a literal, or component = var.X whose variable declares a
  literal default in the same directory), and exactly one directory in
  the repo matches the component name and declares that output. Dynamic
  (each.value) or ambiguous wiring stays unlinked. On
  cloudposse/terraform-aws-components: 254 remote-state bridge edges,
  every one re-derived from a matching source declaration (789/789
  cross-directory output edges explained: 528 local-module + 254
  remote-state + 7 checker-artifact false alarms under deprecated/);
  coverage 66.4% -> 69.1%.

- provider aliases: provider "aws" { alias = "east" } is addressed as
  provider.aws.east so aliased and default configurations stop
  colliding; provider = aws.east on a resource/data block (and the
  values of a module's providers map) reference the selected
  configuration, resolved same-directory first then up the module tree
  — the one construct Terraform genuinely inherits from parents. The
  selection is no longer misread as a resource reference (aws.east).

- moved/import/removed blocks reference the resource addresses they
  name (anchored to the file node — no phantom symbols), so a
  refactor's paper trail joins the graph; check-assert conditions
  contribute their references while check-scoped data blocks keep
  indexing as before. Scoped module candidates are suppressed there:
  module.a.aws_x.b names a resource inside a module instance, not an
  output. +91 edges on cloud-foundation-fabric's moved-heavy stages.

Also fixes a latent test bug from #1173: cg.getNodeById is not public
API (cg.getNode is) — it only passed because the asserted edge list was
empty.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Colby Mchenry 2 mesi fa
parent
commit
f8cdbe3c67

File diff suppressed because it is too large
+ 0 - 1
CHANGELOG.md


+ 1 - 1
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
 

+ 115 - 0
__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" {

+ 123 - 1
__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();
+    }
+  });
+});

+ 200 - 8
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<string>();
+        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<string>;
+}
+
 function emitReferencesInBody(
   body: SyntaxNode,
   ctx: Parameters<NonNullable<LanguageExtractor['visitNode']>>[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<NonNullable<LanguageExtractor['visitNode']>>[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<NonNullable<LanguageExtractor['visitNode']>>[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;
+}

+ 86 - 22
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,17 +85,23 @@ 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
@@ -106,6 +112,15 @@ export const terraformResolver: FrameworkResolver = {
   },
 };
 
+/** Nearest candidate walking UP the directory tree from refDir (exclusive). */
+function nearestAncestorMatch<T extends { filePath: string }>(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:<child>` 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;

Some files were not shown because too many files changed in this diff