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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
6c24f4bddf
commit
f8cdbe3c67
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<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;
|
||||
|
||||
Reference in New Issue
Block a user