Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### New Features

- Terraform and OpenTofu are now first-class indexed languages — `.tf`, `.tfvars`, and `.tofu` files are parsed into the graph, and `codegraph_search`, `codegraph_callers`, `codegraph_callees`, and `codegraph_impact` return real results instead of nothing. Resources, data sources, modules, variables, outputs, providers, and every `locals` attribute become symbols (e.g. `aws_s3_bucket.my_bucket`, `var.region`, `module.vpc`, `local.prefix`), and uses like `var.region`, `module.vpc.id`, `data.aws_caller_identity.current`, or `aws_s3_bucket.my.arn` are wired up cross-file. The Terraform resolver disambiguates same-named variables across modules by preferring the variable defined in the same module directory as the reference site, so asking "what depends on `var.project_id`" in a multi-module repo no longer mixes in unrelated modules.
- `codegraph status --json` now also reports the running CLI `version`, the index directory (`indexPath`), and a `lastIndexed` timestamp (ISO-8601, or null when nothing's indexed yet), so CI and scripts can pin the CLI version and check index freshness from a single command. A matching `CodeGraph.getLastIndexedAt()` library method exposes the same freshness check without shelling out. Thanks @12122J and @eddieran. (#329)

### Fixes
Expand Down
219 changes: 219 additions & 0 deletions __tests__/extraction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,13 @@ describe('Language Detection', () => {
expect(detectLanguage('stdio.h', '#ifndef STDIO_H\nvoid printf();\n#endif\n')).toBe('c');
});

it('should detect Terraform files', () => {
expect(detectLanguage('main.tf')).toBe('terraform');
expect(detectLanguage('variables.tf')).toBe('terraform');
expect(detectLanguage('terraform.tfvars')).toBe('terraform');
expect(detectLanguage('versions.tofu')).toBe('terraform');
});

it('should return unknown for unsupported extensions', () => {
expect(detectLanguage('styles.css')).toBe('unknown');
expect(detectLanguage('data.json')).toBe('unknown');
Expand Down Expand Up @@ -4459,3 +4466,215 @@ func (s Stack[T]) Len() int { return len(s.items) }
expect(js.nodes.find((n) => n.name === 'handleRequest' && n.kind === 'function')).toBeDefined();
});
});

describe('Terraform Extraction', () => {
describe('Language detection', () => {
it('should detect Terraform files', () => {
expect(detectLanguage('main.tf')).toBe('terraform');
expect(detectLanguage('terraform.tfvars')).toBe('terraform');
expect(detectLanguage('versions.tofu')).toBe('terraform');
});

it('should report Terraform as supported', () => {
expect(isLanguageSupported('terraform')).toBe(true);
expect(getSupportedLanguages()).toContain('terraform');
});
});

describe('Block extraction', () => {
it('should extract a resource block as a class with qualified type.name', () => {
const code = `
resource "aws_s3_bucket" "my_bucket" {
bucket = "example"
}
`;
const result = extractFromSource('main.tf', code);
const res = result.nodes.find((n) => n.name === 'aws_s3_bucket.my_bucket');
expect(res).toBeDefined();
expect(res?.kind).toBe('class');
expect(res?.qualifiedName).toBe('aws_s3_bucket.my_bucket');
expect(res?.signature).toBe('resource "aws_s3_bucket" "my_bucket"');
expect(res?.language).toBe('terraform');
});

it('should extract a data block under the data.* qualified name', () => {
const code = `
data "aws_caller_identity" "current" {}
`;
const result = extractFromSource('main.tf', code);
const node = result.nodes.find((n) => n.qualifiedName === 'data.aws_caller_identity.current');
expect(node).toBeDefined();
expect(node?.kind).toBe('class');
});

it('should extract a variable block as variable with qualified name var.X', () => {
const code = `
variable "region" {
type = string
default = "us-east-1"
}
`;
const result = extractFromSource('variables.tf', code);
const v = result.nodes.find((n) => n.qualifiedName === 'var.region');
expect(v).toBeDefined();
expect(v?.kind).toBe('variable');
expect(v?.name).toBe('region');
});

it('should extract an output block as variable with qualified name output.X', () => {
const code = `
output "bucket_arn" {
value = aws_s3_bucket.my_bucket.arn
}
`;
const result = extractFromSource('outputs.tf', code);
const out = result.nodes.find((n) => n.qualifiedName === 'output.bucket_arn');
expect(out).toBeDefined();
expect(out?.kind).toBe('variable');
});

it('should extract a module block as module with qualified name module.X', () => {
const code = `
module "vpc" {
source = "./modules/vpc"
cidr = var.vpc_cidr
}
`;
const result = extractFromSource('main.tf', code);
const m = result.nodes.find((n) => n.qualifiedName === 'module.vpc');
expect(m).toBeDefined();
expect(m?.kind).toBe('module');
});

it('should extract a provider block as namespace', () => {
const code = `
provider "aws" {
region = "us-east-1"
}
`;
const result = extractFromSource('main.tf', code);
const p = result.nodes.find((n) => n.qualifiedName === 'provider.aws');
expect(p).toBeDefined();
expect(p?.kind).toBe('namespace');
});

it('should extract every locals attribute as its own constant with local.K qualified name', () => {
const code = `
locals {
prefix = "prod"
full_name = "\${local.prefix}-app"
max_retries = 3
}
`;
const result = extractFromSource('locals.tf', code);
const names = result.nodes
.filter((n) => n.kind === 'constant')
.map((n) => n.qualifiedName)
.sort();
expect(names).toEqual(['local.full_name', 'local.max_retries', 'local.prefix']);
});

it('should ignore a terraform settings block', () => {
const code = `
terraform {
required_version = ">= 1.5"
}
`;
const result = extractFromSource('versions.tf', code);
const symbols = result.nodes.filter((n) => n.kind !== 'file');
expect(symbols).toHaveLength(0);
});

it('should index .tfvars top-level attributes via the same parser path', () => {
// .tfvars files have no blocks — just bare attributes. Confirm we don't
// crash and that the file is still tracked (file node present).
const code = `
region = "us-east-1"
environment = "prod"
`;
const result = extractFromSource('terraform.tfvars', code);
// No symbols expected (tfvars has no declarations we extract), but the
// file must still parse cleanly with zero errors.
expect(result.errors.filter((e) => e.severity === 'error')).toHaveLength(0);
});
});

describe('Reference extraction', () => {
it('should emit a reference for var.X used inside a resource', () => {
const code = `
variable "region" {}
resource "aws_s3_bucket" "b" {
bucket = var.region
}
`;
const result = extractFromSource('main.tf', code);
const refs = result.unresolvedReferences.map((r) => r.referenceName);
expect(refs).toContain('var.region');
});

it('should emit a reference for module.M.<output> as module.M', () => {
const code = `
output "vpc_id" {
value = module.vpc.vpc_id
}
`;
const result = extractFromSource('outputs.tf', code);
const refs = result.unresolvedReferences.map((r) => r.referenceName);
expect(refs).toContain('module.vpc');
});

it('should emit data.T.N references stripped of the trailing attribute', () => {
const code = `
output "account" {
value = data.aws_caller_identity.current.account_id
}
`;
const result = extractFromSource('outputs.tf', code);
const refs = result.unresolvedReferences.map((r) => r.referenceName);
expect(refs).toContain('data.aws_caller_identity.current');
});

it('should emit T.N references for managed-resource attribute access', () => {
const code = `
resource "aws_iam_policy" "p" {
policy = aws_s3_bucket.my.arn
}
`;
const result = extractFromSource('main.tf', code);
const refs = result.unresolvedReferences.map((r) => r.referenceName);
expect(refs).toContain('aws_s3_bucket.my');
});

it('should emit local.K references from locals attribute expressions', () => {
const code = `
locals {
prefix = "prod"
name = "\${local.prefix}-app"
}
`;
const result = extractFromSource('locals.tf', code);
const refs = result.unresolvedReferences.map((r) => r.referenceName);
expect(refs).toContain('local.prefix');
});

it('should skip built-in heads (each, count, self, path, terraform.workspace)', () => {
const code = `
resource "aws_instance" "x" {
count = each.value
name = path.module
workspace = terraform.workspace
self_ref = self.id
index_value = count.index
}
`;
const result = extractFromSource('main.tf', code);
const refs = result.unresolvedReferences.map((r) => r.referenceName);
// None of the built-ins should produce project references.
expect(refs.some((r) => r.startsWith('each.'))).toBe(false);
expect(refs.some((r) => r.startsWith('count.'))).toBe(false);
expect(refs.some((r) => r.startsWith('self.'))).toBe(false);
expect(refs.some((r) => r.startsWith('path.'))).toBe(false);
expect(refs.some((r) => r.startsWith('terraform.'))).toBe(false);
});
});
});
12 changes: 10 additions & 2 deletions src/extraction/grammars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ const WASM_GRAMMAR_FILES: Record<GrammarLanguage, string> = {
lua: 'tree-sitter-lua.wasm',
luau: 'tree-sitter-luau.wasm',
objc: 'tree-sitter-objc.wasm',
terraform: 'tree-sitter-terraform.wasm',
};

/**
Expand Down Expand Up @@ -108,6 +109,10 @@ export const EXTENSION_MAP: Record<string, Language> = {
// shape as the `.yml` variants — the YAML/properties extractor emits one node
// per leaf key, and the Spring resolver links `@Value("${k}")` references.
'.properties': 'properties',
// Terraform / OpenTofu / HCL config — tree-sitter-terraform dialect of HCL.
'.tf': 'terraform',
'.tfvars': 'terraform',
'.tofu': 'terraform',
};

/**
Expand Down Expand Up @@ -184,8 +189,10 @@ export async function loadGrammarsForLanguages(languages: Language[]): Promise<v
// tree-sitter-wasms build is too old). Lua: tree-sitter-wasms ships an
// ABI-13 build that corrupts the shared WASM heap under web-tree-sitter
// 0.25 (drops nested calls/imports on every file after the first); we
// vendor the upstream ABI-15 wasm instead.
const wasmPath = (lang === 'pascal' || lang === 'scala' || lang === 'lua' || lang === 'luau')
// vendor the upstream ABI-15 wasm instead. Terraform: tree-sitter-wasms
// does not ship HCL/Terraform at all, so we vendor the prebuilt wasm
// from @tree-sitter-grammars/tree-sitter-hcl (Apache-2.0).
const wasmPath = (lang === 'pascal' || lang === 'scala' || lang === 'lua' || lang === 'luau' || lang === 'terraform')
? path.join(__dirname, 'wasm', wasmFile)
: require.resolve(`tree-sitter-wasms/out/${wasmFile}`);
const language = await WasmLanguage.load(wasmPath);
Expand Down Expand Up @@ -388,6 +395,7 @@ export function getLanguageDisplayName(language: Language): string {
twig: 'Twig',
xml: 'XML',
properties: 'Java properties',
terraform: 'Terraform',
unknown: 'Unknown',
};
return names[language] || language;
Expand Down
2 changes: 2 additions & 0 deletions src/extraction/languages/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { scalaExtractor } from './scala';
import { luaExtractor } from './lua';
import { luauExtractor } from './luau';
import { objcExtractor } from './objc';
import { terraformExtractor } from './terraform';

export const EXTRACTORS: Partial<Record<Language, LanguageExtractor>> = {
typescript: typescriptExtractor,
Expand All @@ -49,4 +50,5 @@ export const EXTRACTORS: Partial<Record<Language, LanguageExtractor>> = {
lua: luaExtractor,
luau: luauExtractor,
objc: objcExtractor,
terraform: terraformExtractor,
};
Loading