Prechádzať zdrojové kódy

feat(terraform): bridge the module boundary and enforce directory scoping

Builds on #706. The module declaration was a dead end: module.M.out
resolved to the declaration and stopped, module inputs never reached the
child module's variables, and impact could not cross the boundary — on
real multi-module repos that breaks the core blast-radius question
("what breaks upstream if I change this module's variable/output").

- module blocks now wire across the boundary through :-scoped refs only
  the Terraform resolver understands: module.M:var.<input> → the child's
  variable node, module.M:output.<o> → the child's output node (emitted
  alongside the module.M declaration ref), and module.M:file → the local
  source directory's entry file (imports). Registry/git sources emit no
  file ref and resolve nothing — an out-of-repo module stays a visible
  boundary instead of a guess.
- .tfvars top-level assignments reference the variable they set, walking
  up to the nearest ancestor directory (envs/prod.tfvars → root vars).
- Resolution now enforces Terraform's real scoping: same-directory only
  (no cross-module fallback by common path prefix, no single-candidate
  anywhere-in-tree binding), and terraform refs never fall through to
  the generic name matcher — var.X can never legally bind outside its
  module directory, so the fallback could only add wrong edges.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Colby McHenry 2 mesiacov pred
rodič
commit
9aad69ffc3

+ 75 - 4
__tests__/extraction.test.ts

@@ -9960,16 +9960,21 @@ terraform {
     });
 
     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).
+      // .tfvars files have no blocks — just bare attributes, each of which
+      // SETS the root module variable of that name. No symbols are declared,
+      // but every top-level assignment references its variable so "what sets
+      // var.region" is answerable.
       const code = `
 region      = "us-east-1"
 environment = "prod"
 `;
       const result = extractFromSource('terraform.tfvars', code);
-      // 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);
+      const symbols = result.nodes.filter((n) => n.kind !== 'file');
+      expect(symbols).toHaveLength(0);
+      const refs = result.unresolvedReferences.map((r) => r.referenceName);
+      expect(refs).toContain('var.region');
+      expect(refs).toContain('var.environment');
     });
   });
 
@@ -9997,6 +10002,72 @@ output "vpc_id" {
       expect(refs).toContain('module.vpc');
     });
 
+    it('should emit a scoped module.M:output.X ref alongside module.M for output chains', () => {
+      const code = `
+output "vpc_id" {
+  value = module.vpc.vpc_id
+}
+`;
+      const result = extractFromSource('outputs.tf', code);
+      const refs = result.unresolvedReferences.map((r) => r.referenceName);
+      expect(refs).toContain('module.vpc:output.vpc_id');
+      // A bare module.M use (no output segment) stays a single ref.
+      const bare = extractFromSource('main.tf', 'output "m" {\n  value = module.vpc\n}\n');
+      const bareRefs = bare.unresolvedReferences.map((r) => r.referenceName);
+      expect(bareRefs).toContain('module.vpc');
+      expect(bareRefs.some((r) => r.includes(':output.'))).toBe(false);
+    });
+
+    it('should wire module blocks: scoped input refs, meta-args skipped, local source imported', () => {
+      const code = `
+module "vpc" {
+  source     = "./modules/vpc"
+  version    = "1.0.0"
+  count      = 2
+  depends_on = [aws_iam_role.net]
+  cidr       = var.vpc_cidr
+  name       = "prod"
+}
+`;
+      const result = extractFromSource('main.tf', code);
+      const refs = result.unresolvedReferences.map((r) => r.referenceName);
+      // Input attributes wire to the child module's variables (scoped spelling).
+      expect(refs).toContain('module.vpc:var.cidr');
+      expect(refs).toContain('module.vpc:var.name');
+      // Meta-arguments configure the call, not child variables.
+      expect(refs).not.toContain('module.vpc:var.source');
+      expect(refs).not.toContain('module.vpc:var.version');
+      expect(refs).not.toContain('module.vpc:var.count');
+      expect(refs).not.toContain('module.vpc:var.depends_on');
+      // A local ./ source emits the module→file imports ref.
+      const fileRef = result.unresolvedReferences.find((r) => r.referenceName === 'module.vpc:file');
+      expect(fileRef).toBeDefined();
+      expect(fileRef?.referenceKind).toBe('imports');
+      // Attribute VALUES still reference the parent scope as before.
+      expect(refs).toContain('var.vpc_cidr');
+      expect(refs).toContain('aws_iam_role.net');
+    });
+
+    it('should not emit a module.M:file ref for registry or git sources', () => {
+      const code = `
+module "s3" {
+  source  = "terraform-aws-modules/s3-bucket/aws"
+  version = "4.0.0"
+  bucket  = "x"
+}
+module "net" {
+  source = "git::https://example.com/net.git"
+  cidr   = "10.0.0.0/16"
+}
+`;
+      const result = extractFromSource('main.tf', code);
+      const refs = result.unresolvedReferences.map((r) => r.referenceName);
+      expect(refs.some((r) => r.endsWith(':file'))).toBe(false);
+      // Input wiring is still emitted — the resolver drops it when the
+      // source turns out to be out-of-repo.
+      expect(refs).toContain('module.s3:var.bucket');
+    });
+
     it('should emit data.T.N references stripped of the trailing attribute', () => {
       const code = `
 output "account" {

+ 131 - 0
__tests__/frameworks-integration.test.ts

@@ -961,3 +961,134 @@ export function AppRoutes() {
     }
   });
 });
+
+describe('Terraform end-to-end module-boundary resolution', () => {
+  let tmpDir: string | undefined;
+  afterEach(() => {
+    if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
+    tmpDir = undefined;
+  });
+
+  function writeMultiModuleRepo(root: string) {
+    fs.mkdirSync(path.join(root, 'modules/vpc'), { recursive: true });
+    fs.mkdirSync(path.join(root, 'modules/other'), { recursive: true });
+    fs.mkdirSync(path.join(root, 'envs'), { recursive: true });
+    fs.writeFileSync(
+      path.join(root, 'main.tf'),
+      'variable "vpc_cidr" {\n  type = string\n}\n\n' +
+        'module "vpc" {\n  source = "./modules/vpc"\n  cidr   = var.vpc_cidr\n}\n\n' +
+        'module "registry_thing" {\n  source  = "terraform-aws-modules/s3-bucket/aws"\n  bucket  = "x"\n}\n\n' +
+        'output "vpc_id" {\n  value = module.vpc.vpc_id\n}\n'
+    );
+    fs.writeFileSync(
+      path.join(root, 'modules/vpc/variables.tf'),
+      'variable "cidr" {\n  type = string\n}\n'
+    );
+    fs.writeFileSync(
+      path.join(root, 'modules/vpc/main.tf'),
+      'resource "aws_vpc" "this" {\n  cidr_block = var.cidr\n}\n'
+    );
+    fs.writeFileSync(
+      path.join(root, 'modules/vpc/outputs.tf'),
+      'output "vpc_id" {\n  value = aws_vpc.this.id\n}\n'
+    );
+    // Same-named variable in an UNRELATED module — must never receive edges
+    // from outside its own directory.
+    fs.writeFileSync(
+      path.join(root, 'modules/other/variables.tf'),
+      'variable "cidr" {\n  type = string\n}\nvariable "orphan_ref_target" {}\n'
+    );
+    // References a variable that has no same-dir declaration: must stay unlinked.
+    fs.writeFileSync(
+      path.join(root, 'modules/other/main.tf'),
+      'resource "aws_eip" "e" {\n  tags = { Name = var.undeclared_here_elsewhere_yes }\n}\n'
+    );
+    fs.writeFileSync(path.join(root, 'envs/prod.tfvars'), 'vpc_cidr = "10.0.0.0/16"\n');
+  }
+
+  it('bridges module inputs/outputs/source and enforces directory scoping', async () => {
+    tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-terraform-'));
+    writeMultiModuleRepo(tmpDir);
+
+    const cg = CodeGraph.initSync(tmpDir);
+    await cg.indexAll();
+    try {
+      const byQname = (q: string, file?: string) =>
+        cg
+          .getNodesByName(q.split('.').pop()!)
+          .filter((n) => n.qualifiedName === q && (!file || n.filePath === file));
+
+      const moduleDecl = byQname('module.vpc')[0];
+      expect(moduleDecl, 'module.vpc declaration node').toBeDefined();
+      const childCidr = byQname('var.cidr', 'modules/vpc/variables.tf')[0];
+      expect(childCidr, "child module's var.cidr").toBeDefined();
+      const childOutput = byQname('output.vpc_id', 'modules/vpc/outputs.tf')[0];
+      expect(childOutput, "child module's output.vpc_id").toBeDefined();
+      const rootOutput = byQname('output.vpc_id', 'main.tf')[0];
+      expect(rootOutput, 'root output.vpc_id').toBeDefined();
+
+      const declEdges = cg.getOutgoingEdges(moduleDecl!.id);
+      // Input wiring: module block → child variable (cross-directory).
+      expect(
+        declEdges.find((e) => e.target === childCidr!.id),
+        'module.vpc → child var.cidr input edge'
+      ).toBeDefined();
+      // Source wiring: module block → child entry file.
+      const fileNode = cg
+        .getNodesInFile('modules/vpc/main.tf')
+        .find((n) => n.kind === 'file');
+      expect(fileNode).toBeDefined();
+      const importEdge = declEdges.find((e) => e.target === fileNode!.id);
+      expect(importEdge, 'module.vpc → modules/vpc/main.tf imports edge').toBeDefined();
+      expect(importEdge!.kind).toBe('imports');
+
+      // Output bridge: root output → child output (not just the declaration).
+      const rootOutEdges = cg.getOutgoingEdges(rootOutput!.id);
+      expect(
+        rootOutEdges.find((e) => e.target === childOutput!.id),
+        'root output.vpc_id → child output.vpc_id'
+      ).toBeDefined();
+      expect(
+        rootOutEdges.find((e) => e.target === moduleDecl!.id),
+        'root output.vpc_id → module.vpc declaration'
+      ).toBeDefined();
+
+      // tfvars assignment walks up to the ROOT variable.
+      const rootVar = byQname('var.vpc_cidr', 'main.tf')[0];
+      expect(rootVar).toBeDefined();
+      const tfvarsFile = cg.getNodesInFile('envs/prod.tfvars').find((n) => n.kind === 'file');
+      expect(tfvarsFile).toBeDefined();
+      expect(
+        cg.getOutgoingEdges(tfvarsFile!.id).find((e) => e.target === rootVar!.id),
+        'envs/prod.tfvars → var.vpc_cidr'
+      ).toBeDefined();
+
+      // Directory scoping: the unrelated module's same-named var.cidr gets
+      // NO incoming edges from outside its own directory…
+      const otherCidr = byQname('var.cidr', 'modules/other/variables.tf')[0];
+      expect(otherCidr).toBeDefined();
+      const incomingOther = cg.getIncomingEdges(otherCidr!.id).filter((e) => e.kind !== 'contains');
+      expect(incomingOther, 'unrelated module var.cidr must stay isolated').toHaveLength(0);
+
+      // …and a reference with no same-dir declaration stays unlinked rather
+      // than borrowing another module's declaration.
+      const orphanEdges = cg
+        .getNodesInFile('modules/other/main.tf')
+        .filter((n) => n.qualifiedName === 'aws_eip.e')
+        .flatMap((n) => cg.getOutgoingEdges(n.id))
+        .filter((e) => e.kind === 'references');
+      const orphanTargets = orphanEdges.map((e) => cg.getNodeById(e.target)?.qualifiedName);
+      expect(orphanTargets).not.toContain('var.undeclared_here_elsewhere_yes');
+
+      // Registry-sourced module: inputs stay unresolved (no guessed edges).
+      const registryDecl = byQname('module.registry_thing')[0];
+      expect(registryDecl).toBeDefined();
+      const registryEdges = cg
+        .getOutgoingEdges(registryDecl!.id)
+        .filter((e) => e.kind !== 'contains');
+      expect(registryEdges, 'registry module must not link anywhere').toHaveLength(0);
+    } finally {
+      cg.close();
+    }
+  });
+});

+ 117 - 10
src/extraction/languages/terraform.ts

@@ -139,30 +139,39 @@ function emitRefFromVariableExpr(
 
   const line = varExpr.startPosition.row + 1;
   const col = varExpr.startPosition.column;
-  const qname = qualifyReference(head, attrs);
-  if (qname) onRef(qname, line, col);
+  for (const qname of qualifyReference(head, attrs)) onRef(qname, line, col);
 }
 
-function qualifyReference(head: string, attrs: string[]): string | null {
+function qualifyReference(head: string, attrs: string[]): string[] {
   switch (head) {
     case 'var':
       // var.X — variable "X"
-      return attrs[0] ? `var.${attrs[0]}` : null;
+      return attrs[0] ? [`var.${attrs[0]}`] : [];
     case 'local':
       // local.K — locals attribute K
-      return attrs[0] ? `local.${attrs[0]}` : null;
+      return attrs[0] ? [`local.${attrs[0]}`] : [];
     case 'module':
-      // module.M[.OUTPUT] — module "M"
-      return attrs[0] ? `module.${attrs[0]}` : null;
+      // module.M[.OUTPUT] — module "M". A two-segment chain (`module.M.out`)
+      // additionally emits a scoped `module.M:output.out` ref that the
+      // Terraform resolver bridges to the `output "out"` node inside the
+      // module's source directory — the edge that carries impact across the
+      // module boundary instead of dead-ending at the declaration. Only the
+      // Terraform framework resolver understands the `:`-scoped spelling; if
+      // the module's source is a registry/git address the ref simply stays
+      // unresolved and the boundary remains visible.
+      if (!attrs[0]) return [];
+      return attrs[1]
+        ? [`module.${attrs[0]}`, `module.${attrs[0]}:output.${attrs[1]}`]
+        : [`module.${attrs[0]}`];
     case 'data':
       // data.TYPE.NAME[.ATTR] — data "TYPE" "NAME"
-      return attrs[0] && attrs[1] ? `data.${attrs[0]}.${attrs[1]}` : null;
+      return attrs[0] && attrs[1] ? [`data.${attrs[0]}.${attrs[1]}`] : [];
     default:
       // <type>.<name>[.<attr>...] — 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]}`;
+      if (!attrs[0]) return [];
+      return [`${head}.${attrs[0]}`];
   }
 }
 
@@ -186,6 +195,29 @@ export const terraformExtractor: LanguageExtractor = {
 
   visitNode: (node, ctx) => {
     if (node.type !== 'block') {
+      // .tfvars files carry no blocks — just top-level `name = value`
+      // assignments, each of which SETS the root module variable of that
+      // name. Reference the variable from the file node so "what sets
+      // var.region" is answerable from the graph.
+      if (
+        node.type === 'attribute' &&
+        ctx.filePath.endsWith('.tfvars') &&
+        node.parent?.type === 'body' &&
+        node.parent.parent?.type === 'config_file'
+      ) {
+        const idNode = node.namedChildren.find((c) => c?.type === 'identifier');
+        const fileNodeId = ctx.nodeStack[0];
+        if (idNode && fileNodeId) {
+          ctx.addUnresolvedReference({
+            fromNodeId: fileNodeId,
+            referenceName: `var.${getNodeText(idNode, ctx.source)}`,
+            referenceKind: 'references',
+            line: node.startPosition.row + 1,
+            column: node.startPosition.column,
+          });
+        }
+        return true;
+      }
       // Let the default walker descend into bodies/expressions; we only claim
       // top-level blocks.
       return false;
@@ -228,6 +260,9 @@ export const terraformExtractor: LanguageExtractor = {
       ctx.pushScope(created.id);
       try {
         emitReferencesInBody(body, ctx, created.id);
+        if (type === 'module' && labels[0]) {
+          emitModuleWiring(labels[0], node, body, ctx, created.id);
+        }
       } finally {
         ctx.popScope();
       }
@@ -236,6 +271,78 @@ export const terraformExtractor: LanguageExtractor = {
   },
 };
 
+/**
+ * Module meta-arguments — attributes of a `module` block that configure the
+ * call itself rather than set one of the child module's input variables.
+ */
+const MODULE_META_ARGS = new Set(['source', 'version', 'count', 'for_each', 'providers', 'depends_on']);
+
+/**
+ * Bridge a `module "M" { ... }` block across the module boundary with
+ * `:`-scoped references that only the Terraform framework resolver
+ * understands (a plain qualified name would let the generic matcher bind
+ * them to a same-named symbol in an unrelated module — a wrong edge is
+ * worse than none):
+ *
+ *   - `module.M:file`      (imports)    → the module source directory's
+ *     entry file, when `source` is a local `./`/`../` path. Registry and
+ *     git sources emit nothing — an out-of-repo module stays a visible
+ *     boundary instead of a guessed edge.
+ *   - `module.M:var.<in>`  (references) → the child module's
+ *     `variable "<in>"` node, one per input attribute. This is what lets
+ *     "what depends on modules/vpc's var.cidr" reach the callers.
+ */
+function emitModuleWiring(
+  moduleName: string,
+  block: SyntaxNode,
+  body: SyntaxNode,
+  ctx: Parameters<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) continue;
+    const attrName = getNodeText(idNode, ctx.source);
+    if (attrName === 'source') {
+      const expr = attr.namedChildren.find((c) => c?.type === 'expression');
+      const lit = expr ? findStringLit(expr) : null;
+      const source = lit ? stringLitValue(lit, ctx.source) : '';
+      if (source.startsWith('./') || source.startsWith('../')) {
+        ctx.addUnresolvedReference({
+          fromNodeId,
+          referenceName: `module.${moduleName}:file`,
+          referenceKind: 'imports',
+          line: block.startPosition.row + 1,
+          column: block.startPosition.column,
+        });
+      }
+      continue;
+    }
+    if (MODULE_META_ARGS.has(attrName)) continue;
+    ctx.addUnresolvedReference({
+      fromNodeId,
+      referenceName: `module.${moduleName}:var.${attrName}`,
+      referenceKind: 'references',
+      line: attr.startPosition.row + 1,
+      column: attr.startPosition.column,
+    });
+  }
+}
+
+/** First string_lit anywhere under an expression (source = "./modules/x"). */
+function findStringLit(expr: SyntaxNode): SyntaxNode | null {
+  const queue: SyntaxNode[] = [expr];
+  while (queue.length) {
+    const n = queue.shift()!;
+    if (n.type === 'string_lit') return n;
+    for (const c of n.namedChildren) {
+      if (c) queue.push(c);
+    }
+  }
+  return null;
+}
+
 interface BlockDecl {
   kind: 'class' | 'module' | 'variable' | 'namespace';
   name: string;

+ 148 - 59
src/resolution/frameworks/terraform.ts

@@ -1,24 +1,46 @@
 /**
  * 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 scoping rule is narrow and directory-shaped: `var.X`,
+ * `local.X`, `module.M`, and resource/data references resolve ONLY inside
+ * the same module directory as the reference site. The generic name matcher
+ * resolves by qualified-name alone, so a reference to `var.project_id` from
+ * `modules/net-vpc/main.tf` could bind to a `variable "project_id"` declared
+ * in an unrelated module — a wrong cross-module edge that poisons impact
+ * analysis. This resolver enforces the real semantics:
  *
- * 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.<output>` 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).
+ *   1. Same directory as the reference site → resolve (highest confidence).
+ *   2. `.tfvars` files additionally walk UP to the nearest ancestor
+ *      directory declaring the variable (`terraform apply -var-file=envs/prod.tfvars`
+ *      sets ROOT module variables from a subdirectory).
+ *   3. Otherwise: no edge. Terraform cannot reference across sibling module
+ *      directories, so a non-local candidate is never a correct target.
+ *
+ * It also bridges the module boundary through `:`-scoped references that
+ * only this resolver understands (see the extractor's emitModuleWiring):
+ *
+ *   - `module.M:file`       → the entry file of the module's local source
+ *     directory (an `imports` edge, so a module call connects to the code
+ *     it instantiates).
+ *   - `module.M:var.<in>`   → the child module's `variable "<in>"` node —
+ *     the module block sets that variable, so "what depends on the child's
+ *     var.cidr" reaches every caller.
+ *   - `module.M:output.<o>` → the child module's `output "<o>"` node —
+ *     `module.M.o` uses flow through to the output's definition instead of
+ *     dead-ending at the module declaration.
+ *
+ * The module's `source` is re-read from the declaration's file (cached
+ * lines); only local `./`/`../` sources bridge. Registry/git sources stay
+ * unresolved — an out-of-repo module is a visible boundary, never a guess.
  */
 
 import * as path from 'path';
+import type { Node } from '../../types';
 import type { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types';
 
+/** `module.M:file` / `module.M:var.X` / `module.M:output.X` — extractor-emitted scoped refs. */
+const SCOPED_REF = /^module\.([^.:\s]+):(file$|var\.|output\.)/;
+
 export const terraformResolver: FrameworkResolver = {
   name: 'terraform',
   languages: ['terraform'],
@@ -27,30 +49,29 @@ export const terraformResolver: FrameworkResolver = {
     return context.getAllFiles().some((f) => f.endsWith('.tf') || f.endsWith('.tfvars') || f.endsWith('.tofu'));
   },
 
+  // Scoped refs name no declared symbol; opt them through the resolver's
+  // name-exists pre-filter so they reach resolve() at all.
+  claimsReference(name: string): boolean {
+    return SCOPED_REF.test(name);
+  },
+
   resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
     if (ref.language !== 'terraform') return null;
 
     const qname = ref.referenceName;
-    const candidates = context.getNodesByQualifiedName(qname);
-    if (candidates.length === 0) return null;
-
-    const refDir = path.dirname(ref.filePath);
+    const refDir = dirOf(ref.filePath);
 
-    if (candidates.length === 1) {
-      // Cross-module module-output style refs (`module.M.<output>`) 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',
-      };
+    // --- module-boundary bridge: module.M:file / module.M:var.X / module.M:output.X ---
+    const scoped = qname.match(/^module\.([^.:\s]+):(.+)$/);
+    if (scoped) {
+      return resolveScopedModuleRef(ref, scoped[1]!, scoped[2]!, refDir, context);
     }
 
-    // 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);
+    const candidates = context.getNodesByQualifiedName(qname);
+    if (candidates.length === 0) return null;
+
+    // 1. Same directory — the only scope Terraform can actually reference.
+    const sameDir = candidates.filter((c) => dirOf(c.filePath) === refDir);
     if (sameDir.length > 0) {
       return {
         original: ref,
@@ -60,47 +81,115 @@ export const terraformResolver: FrameworkResolver = {
       };
     }
 
-    // 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) {
+    // 2. `.tfvars` assignments set ROOT module variables, and var-files are
+    //    routinely kept in a subdirectory (`envs/prod.tfvars`). Walk up to
+    //    the nearest ancestor directory that declares the variable.
+    if (ref.filePath.endsWith('.tfvars') && qname.startsWith('var.')) {
+      for (let dir = parentOf(refDir); dir !== null; dir = parentOf(dir)) {
+        const inDir = candidates.filter((c) => dirOf(c.filePath) === dir);
+        if (inDir.length > 0) {
           return {
             original: ref,
-            targetNodeId: byModuleDir[0]!.id,
-            confidence: 0.85,
+            targetNodeId: inDir[0]!.id,
+            confidence: 0.9,
             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',
-    };
+    // 3. No same-directory declaration → no edge. A candidate in another
+    //    module directory is never the real target (cross-module access only
+    //    exists through module.M inputs/outputs, bridged above), and a wrong
+    //    edge is worse than none.
+    return null;
   },
 };
 
-/** 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;
+/**
+ * Resolve `module.M:<child>` by locating the `module "M"` declaration in the
+ * reference's own directory, reading its `source` attribute, and looking the
+ * child symbol up inside that directory.
+ */
+function resolveScopedModuleRef(
+  ref: UnresolvedRef,
+  moduleName: string,
+  child: string,
+  refDir: string,
+  context: ResolutionContext
+): ResolvedRef | null {
+  const decls = context
+    .getNodesByQualifiedName(`module.${moduleName}`)
+    .filter((n) => n.kind === 'module');
+  if (decls.length === 0) return null;
+  // Terraform scoping: the declaration lives in the reference's directory.
+  const decl = decls.find((d) => dirOf(d.filePath) === refDir) ?? (decls.length === 1 ? decls[0]! : null);
+  if (!decl) return null;
+
+  const source = readModuleSource(decl, context);
+  if (!source || !(source.startsWith('./') || source.startsWith('../'))) {
+    // Registry / git / absolute sources are out-of-repo: stay unresolved.
+    return null;
   }
-  return i;
+  const targetDir = normalizeRel(joinDirs(dirOf(decl.filePath), source));
+
+  if (child === 'file') {
+    const tfFiles = context
+      .getAllFiles()
+      .filter((f) => dirOf(f) === targetDir && (f.endsWith('.tf') || f.endsWith('.tofu')))
+      .sort();
+    if (tfFiles.length === 0) return null;
+    const entry = tfFiles.find((f) => f.endsWith('/main.tf') || f === 'main.tf') ?? tfFiles[0]!;
+    const fileNode = context.getNodesInFile(entry).find((n) => n.kind === 'file');
+    if (!fileNode) return null;
+    return { original: ref, targetNodeId: fileNode.id, confidence: 0.95, resolvedBy: 'framework' };
+  }
+
+  // child is `var.X` or `output.X` — the child module's own qualified names.
+  const target = context
+    .getNodesByQualifiedName(child)
+    .filter((c) => dirOf(c.filePath) === targetDir);
+  if (target.length === 0) return null;
+  return { original: ref, targetNodeId: target[0]!.id, confidence: 0.95, resolvedBy: 'framework' };
 }
 
+/**
+ * The `source = "…"` string of a module declaration, re-read from its file
+ * (project paths are stored relative; node metadata isn't persisted, so the
+ * declaration's line span + cached file lines are the durable carrier).
+ */
+function readModuleSource(decl: Node, context: ResolutionContext): string | null {
+  const lines =
+    context.getFileLines?.(decl.filePath) ?? context.readFile(decl.filePath)?.split('\n') ?? null;
+  if (!lines) return null;
+  const end = Math.min(decl.endLine, lines.length);
+  for (let i = Math.max(decl.startLine - 1, 0); i < end; i++) {
+    const m = lines[i]!.match(/^\s*source\s*=\s*"([^"]+)"/);
+    if (m) return m[1]!;
+  }
+  return null;
+}
+
+/** Directory of a stored (forward-slash, project-relative) path. */
+function dirOf(p: string): string {
+  const d = path.dirname(p);
+  return d === '' ? '.' : d;
+}
+
+/** Parent directory, or null above the project root. */
+function parentOf(dir: string): string | null {
+  if (dir === '.' || dir === '') return null;
+  const parent = path.dirname(dir);
+  return parent === dir ? null : parent;
+}
+
+/** Join a base directory with a `./`/`../` relative source path. */
+function joinDirs(base: string, rel: string): string {
+  return path.join(base === '.' ? '' : base, rel);
+}
+
+/** Normalize to the stored path shape: forward slashes, '.' for the root. */
+function normalizeRel(p: string): string {
+  const n = path.normalize(p).replace(/\\/g, '/').replace(/\/+$/, '');
+  return n === '' ? '.' : n;
+}

+ 5 - 1
src/resolution/index.ts

@@ -815,7 +815,11 @@ export class ReferenceResolver {
     // If that didn't find the file, do NOT fall back to the symbol
     // name-matcher — it would mis-connect e.g. "inc/db.php" to an unrelated
     // db.php elsewhere in the tree (a wrong edge is worse than none, #660).
-    if (isPhpIncludePathRef(ref) || isCobolCopybookRef(ref)) {
+    // Terraform refs are directory-scoped by language semantics — the
+    // framework resolver IS the whole rulebook (`var.X` can never legally
+    // bind outside its module directory), so the name-matcher's
+    // qualified-name fallback would only ever add wrong cross-module edges.
+    if (isPhpIncludePathRef(ref) || isCobolCopybookRef(ref) || ref.language === 'terraform') {
       return candidates.length > 0
         ? candidates.reduce((best, curr) =>
             curr.confidence > best.confidence ? curr : best