From 852ed779a62c85e376e189a113bd6e689cf389f2 Mon Sep 17 00:00:00 2001
From: sunerpy <nkuzhangshn@gmail.com>
Date: Wed, 15 Jul 2026 14:53:15 +0800
Subject: [PATCH] feat(extraction): index Terraform/HCL (.tf/.tfvars/.tofu) at
 the extraction tier
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Ports the extraction half of upstream colby Terraform support (#1173).
Terraform is HCL; the tree-sitter-hcl grammar parses `.tf`, `.tfvars`, and
`.tofu`. Those files now index their blocks as qualified symbols — resource and
data blocks as `type.name` / `data.type.name`, `module.name`, `var.name`,
`output.name`, providers as namespaces, and each `locals` attribute as
`local.key` — plus the traversal references (`var.X`, `local.X`, `module.M`,
`data.T.N`, `<type>.<name>`) in block bodies, via the tree-sitter-hcl crates.io
grammar (no vendored grammar).

HCL is block-based: every construct is a `block` distinguished by its first
identifier, so TERRAFORM_SPEC carries empty type-sets and a single
Language::Terraform-guarded `visit_terraform_node` walker arm (the pattern Nix
and GDScript use) dispatches on the block type and emits the qualified symbol
and its body references. The arm is gated to Language::Terraform and
shape-guarded, so no other language's extraction changes.

Adds the Language::Terraform variant, the three extension mappings, the store
language-parser arm, and a dedicated terraform golden fixture. Same-file
references resolve through the existing name matcher (e.g. `var.region` to its
variable, `module.vpc` to the module block); an undeclared target stays an
unresolved reference.

The module-boundary framework resolver from the upstream commit — the
`:`-scoped `module.M:var`/`:output` cross-module wiring, the directory-scoped
`.tfvars`-to-variable resolution, and the cross-module impact bridge — is
deferred: this port has one concrete FrameworkResolver (Godot), and a second is
outside the extraction-tier scope, the same disposition used for the ArkTS
ArkUI bridges and the Nix module-system synthesizer.

The language enum is a stored TEXT value, not DDL, so the golden .schema is
unchanged and the nine existing golden fixtures are byte-identical (no regen);
only the new terraform fixture is added.
---
 Cargo.lock                                    |  31 +-
 README.md                                     |   6 +-
 .../fixtures/terraform/main.tf                |  30 ++
 crates/codegraph-bench/tests/equivalence.rs   |  33 ++
 crates/codegraph-core/src/types.rs            |   9 +-
 crates/codegraph-extract/Cargo.toml           |   1 +
 crates/codegraph-extract/src/engine.rs        |  10 +-
 crates/codegraph-extract/src/lang/mod.rs      |   7 +
 .../codegraph-extract/src/lang/terraform.rs   | 444 ++++++++++++++++++
 crates/codegraph-extract/src/walker.rs        | 254 ++++++++++
 crates/codegraph-store/src/queries.rs         |   9 +
 docs/equivalence.md                           |  53 +++
 docs/languages.md                             |  61 +--
 reference/golden/terraform/colby.db           | Bin 0 -> 151552 bytes
 reference/golden/terraform/edges.json         | 143 ++++++
 reference/golden/terraform/files.json         |  10 +
 reference/golden/terraform/nodes.json         | 222 +++++++++
 reference/golden/terraform/refs.json          |  12 +
 reference/golden/terraform/schema.sql         | 117 +++++
 19 files changed, 1406 insertions(+), 46 deletions(-)
 create mode 100644 crates/codegraph-bench/fixtures/terraform/main.tf
 create mode 100644 crates/codegraph-extract/src/lang/terraform.rs
 create mode 100644 reference/golden/terraform/colby.db
 create mode 100644 reference/golden/terraform/edges.json
 create mode 100644 reference/golden/terraform/files.json
 create mode 100644 reference/golden/terraform/nodes.json
 create mode 100644 reference/golden/terraform/refs.json
 create mode 100644 reference/golden/terraform/schema.sql

diff --git a/Cargo.lock b/Cargo.lock
index 6c80e93..886f024 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -432,7 +432,7 @@ dependencies = [
 
 [[package]]
 name = "codegraph-bench"
-version = "0.35.0"
+version = "0.36.0"
 dependencies = [
  "anyhow",
  "clap",
@@ -447,7 +447,7 @@ dependencies = [
 
 [[package]]
 name = "codegraph-core"
-version = "0.35.0"
+version = "0.36.0"
 dependencies = [
  "anyhow",
  "serde",
@@ -463,7 +463,7 @@ dependencies = [
 
 [[package]]
 name = "codegraph-daemon"
-version = "0.35.0"
+version = "0.36.0"
 dependencies = [
  "anyhow",
  "codegraph-core",
@@ -483,7 +483,7 @@ dependencies = [
 
 [[package]]
 name = "codegraph-extract"
-version = "0.35.0"
+version = "0.36.0"
 dependencies = [
  "anyhow",
  "codegraph-core",
@@ -502,6 +502,7 @@ dependencies = [
  "tree-sitter-dart",
  "tree-sitter-gdscript",
  "tree-sitter-go",
+ "tree-sitter-hcl",
  "tree-sitter-html",
  "tree-sitter-java",
  "tree-sitter-javascript",
@@ -528,7 +529,7 @@ dependencies = [
 
 [[package]]
 name = "codegraph-graph"
-version = "0.35.0"
+version = "0.36.0"
 dependencies = [
  "anyhow",
  "codegraph-core",
@@ -542,7 +543,7 @@ dependencies = [
 
 [[package]]
 name = "codegraph-mcp"
-version = "0.35.0"
+version = "0.36.0"
 dependencies = [
  "anyhow",
  "axum",
@@ -565,7 +566,7 @@ dependencies = [
 
 [[package]]
 name = "codegraph-resolve"
-version = "0.35.0"
+version = "0.36.0"
 dependencies = [
  "anyhow",
  "codegraph-core",
@@ -579,7 +580,7 @@ dependencies = [
 
 [[package]]
 name = "codegraph-rs"
-version = "0.35.0"
+version = "0.36.0"
 dependencies = [
  "anyhow",
  "assert_cmd",
@@ -615,7 +616,7 @@ dependencies = [
 
 [[package]]
 name = "codegraph-store"
-version = "0.35.0"
+version = "0.36.0"
 dependencies = [
  "anyhow",
  "codegraph-core",
@@ -627,7 +628,7 @@ dependencies = [
 
 [[package]]
 name = "codegraph-watch"
-version = "0.35.0"
+version = "0.36.0"
 dependencies = [
  "anyhow",
  "codegraph-core",
@@ -3504,6 +3505,16 @@ dependencies = [
  "tree-sitter-language",
 ]
 
+[[package]]
+name = "tree-sitter-hcl"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a7b2cc3d7121553b84309fab9d11b3ff3d420403eef9ae50f9fd1cd9d9cf012"
+dependencies = [
+ "cc",
+ "tree-sitter-language",
+]
+
 [[package]]
 name = "tree-sitter-html"
 version = "0.23.2"
diff --git a/README.md b/README.md
index e9c2878..6efe80e 100644
--- a/README.md
+++ b/README.md
@@ -439,7 +439,7 @@ Omit `--install` to print to stdout. Full per-shell install paths and notes:
 
 ## What CodeGraph Does (and Doesn't)
 
-**Does:** deterministic code-structure extraction across 35 languages (TypeScript,
+**Does:** deterministic code-structure extraction across 36 languages (TypeScript,
 Python, Go, Rust, Java, C/C++, C#, Vue, Svelte, GDScript, and more — see
 [`docs/languages.md`](docs/languages.md)), cross-file resolution (including
 Godot project graphs), graph traversal, FTS5 search, whole-graph export with
@@ -454,9 +454,9 @@ beyond the fixed `LANGUAGES` set.
 
 ## Supported Languages
 
-CodeGraph supports **35 languages** grouped by extraction depth. Quick overview:
+CodeGraph supports **36 languages** grouped by extraction depth. Quick overview:
 
-- **Tier 1 — Full symbol extraction (26):** TypeScript, TSX, JavaScript, JSX, ArkTS, Python, Go, Rust, Java, C, C++, C#, PHP, Ruby, Swift, Kotlin, Dart, Scala, Lua, Luau, Objective-C, R, Solidity, Nix, GDScript, Pascal.
+- **Tier 1 — Full symbol extraction (27):** TypeScript, TSX, JavaScript, JSX, ArkTS, Python, Go, Rust, Java, C, C++, C#, PHP, Ruby, Swift, Kotlin, Dart, Scala, Lua, Luau, Objective-C, R, Solidity, Nix, Terraform, GDScript, Pascal.
 - **Tier 2 — Embedded / template extraction (6):** Vue, Svelte, Astro, Razor/`.cshtml`, Liquid, XML/MyBatis mapper.
 - **Tier 3 — File-level only (3):** YAML, Twig, Properties.
 
diff --git a/crates/codegraph-bench/fixtures/terraform/main.tf b/crates/codegraph-bench/fixtures/terraform/main.tf
new file mode 100644
index 0000000..a8ecacb
--- /dev/null
+++ b/crates/codegraph-bench/fixtures/terraform/main.tf
@@ -0,0 +1,30 @@
+terraform {}
+
+provider "aws" {}
+
+variable "region" {}
+
+locals {
+  name   = "demo"
+  tagged = var.region
+}
+
+data "aws_ami" "ubuntu" {}
+
+resource "aws_s3_bucket" "b" {
+  bucket     = var.region
+  kms_key_id = aws_kms_key.logs.arn
+}
+
+module "vpc" {
+  source = "./vpc"
+  region = var.region
+}
+
+output "bucket_id" {
+  value = aws_s3_bucket.b.id
+}
+
+output "vpc_id" {
+  value = module.vpc.id
+}
diff --git a/crates/codegraph-bench/tests/equivalence.rs b/crates/codegraph-bench/tests/equivalence.rs
index 0c93da1..efe0227 100644
--- a/crates/codegraph-bench/tests/equivalence.rs
+++ b/crates/codegraph-bench/tests/equivalence.rs
@@ -179,6 +179,31 @@ fn nix_db_is_self_equivalent_to_nix_golden() {
     assert_equivalent(&nix_db(), &nix_golden_dir()).unwrap();
 }
 
+#[test]
+fn generated_golden_matches_committed_terraform_fixture() {
+    // Guards Terraform/HCL extraction (upstream #1173, extraction slice only):
+    // the `.tf`/`.tfvars`/`.tofu`->Terraform mapping, block-type dispatch
+    // (resource/data->Class, module->Module, variable/output->Variable,
+    // provider->Namespace, locals->Constant per attr) with qualified names, and
+    // plain attribute-expression traversal refs
+    // (var.X/local.X/module.M/data.T.N/<type>.<name>)->References with built-ins
+    // skipped. The module-boundary TerraformResolver, emitModuleWiring's
+    // :-scoped refs, and the .tfvars var ref are DEFERRED, so no :-scoped ref is
+    // emitted; the undeclared aws_kms_key.logs stays unresolved.
+    let tempdir = TestDir::new("generated-golden-terraform");
+    write_golden(&terraform_db(), tempdir.path()).unwrap();
+
+    let expected = load_golden(&terraform_golden_dir()).unwrap();
+    let actual = load_golden(tempdir.path()).unwrap();
+
+    diff_canonical(&expected, &actual, None).unwrap();
+}
+
+#[test]
+fn terraform_db_is_self_equivalent_to_terraform_golden() {
+    assert_equivalent(&terraform_db(), &terraform_golden_dir()).unwrap();
+}
+
 #[test]
 fn tier1_node_drift_is_reported() {
     let expected = load_golden(&mini_golden_dir()).unwrap();
@@ -297,6 +322,14 @@ fn nix_golden_dir() -> PathBuf {
     workspace_root().join("reference/golden/nix")
 }
 
+fn terraform_db() -> PathBuf {
+    workspace_root().join("reference/golden/terraform/colby.db")
+}
+
+fn terraform_golden_dir() -> PathBuf {
+    workspace_root().join("reference/golden/terraform")
+}
+
 struct TestDir {
     path: PathBuf,
 }
diff --git a/crates/codegraph-core/src/types.rs b/crates/codegraph-core/src/types.rs
index 92ea64e..7459c43 100644
--- a/crates/codegraph-core/src/types.rs
+++ b/crates/codegraph-core/src/types.rs
@@ -49,7 +49,7 @@ pub const EDGE_KIND_STRINGS: [&str; 12] = [
     "decorates",
 ];
 
-pub const LANGUAGE_STRINGS: [&str; 39] = [
+pub const LANGUAGE_STRINGS: [&str; 40] = [
     "typescript",
     "javascript",
     "tsx",
@@ -80,6 +80,7 @@ pub const LANGUAGE_STRINGS: [&str; 39] = [
     "r",
     "solidity",
     "nix",
+    "terraform",
     "yaml",
     "twig",
     "xml",
@@ -329,6 +330,8 @@ pub enum Language {
     Solidity,
     #[serde(rename = "nix")]
     Nix,
+    #[serde(rename = "terraform")]
+    Terraform,
     #[serde(rename = "yaml")]
     Yaml,
     #[serde(rename = "twig")]
@@ -350,7 +353,7 @@ pub enum Language {
 }
 
 impl Language {
-    pub const ALL: [Self; 39] = [
+    pub const ALL: [Self; 40] = [
         Self::TypeScript,
         Self::JavaScript,
         Self::Tsx,
@@ -381,6 +384,7 @@ impl Language {
         Self::R,
         Self::Solidity,
         Self::Nix,
+        Self::Terraform,
         Self::Yaml,
         Self::Twig,
         Self::Xml,
@@ -424,6 +428,7 @@ impl Language {
             Self::R => "r",
             Self::Solidity => "solidity",
             Self::Nix => "nix",
+            Self::Terraform => "terraform",
             Self::Yaml => "yaml",
             Self::Twig => "twig",
             Self::Xml => "xml",
diff --git a/crates/codegraph-extract/Cargo.toml b/crates/codegraph-extract/Cargo.toml
index 72fe682..97fb9df 100644
--- a/crates/codegraph-extract/Cargo.toml
+++ b/crates/codegraph-extract/Cargo.toml
@@ -31,6 +31,7 @@ tree-sitter-css = "0.25.0"
 tree-sitter-dart = "0.2.0"
 tree-sitter-go = "0.25.0"
 tree-sitter-gdscript = "6.1.0"
+tree-sitter-hcl = "1.1.0"
 tree-sitter-html = "0.23.2"
 tree-sitter-java = "0.23.5"
 tree-sitter-javascript = "0.25.0"
diff --git a/crates/codegraph-extract/src/engine.rs b/crates/codegraph-extract/src/engine.rs
index 39069f0..7978be6 100644
--- a/crates/codegraph-extract/src/engine.rs
+++ b/crates/codegraph-extract/src/engine.rs
@@ -84,6 +84,7 @@ pub fn builtin_language_for_ext(ext: &str) -> Option<Language> {
         "r" => Language::R,
         "sol" => Language::Solidity,
         "nix" => Language::Nix,
+        "tf" | "tfvars" | "tofu" => Language::Terraform,
         "yml" | "yaml" => Language::Yaml,
         "twig" => Language::Twig,
         "xml" => Language::Xml,
@@ -872,7 +873,7 @@ mod tests {
         assert_eq!(detect_language("s.metal"), Language::Cpp);
         assert_eq!(detect_language("k.cu"), Language::Cpp);
         assert_eq!(detect_language("k.cuh"), Language::Cpp);
-        assert_eq!(Language::ALL.len(), 39);
+        assert_eq!(Language::ALL.len(), 40);
     }
 
     #[test]
@@ -890,6 +891,13 @@ mod tests {
         assert_eq!(detect_language("flake.nix"), Language::Nix);
     }
 
+    #[test]
+    fn terraform_extensions_map_to_terraform() {
+        assert_eq!(detect_language("main.tf"), Language::Terraform);
+        assert_eq!(detect_language("prod.tfvars"), Language::Terraform);
+        assert_eq!(detect_language("main.tofu"), Language::Terraform);
+    }
+
     #[test]
     fn plain_ts_stays_typescript() {
         assert_eq!(detect_language("m.ts"), Language::TypeScript);
diff --git a/crates/codegraph-extract/src/lang/mod.rs b/crates/codegraph-extract/src/lang/mod.rs
index 4a2074b..d26bda9 100644
--- a/crates/codegraph-extract/src/lang/mod.rs
+++ b/crates/codegraph-extract/src/lang/mod.rs
@@ -27,6 +27,7 @@ mod rust;
 mod scala;
 mod solidity;
 mod swift;
+mod terraform;
 mod tsx;
 mod typescript;
 
@@ -64,6 +65,11 @@ pub use rust::RUST_SPEC;
 pub use scala::SCALA_SPEC;
 pub use solidity::SOLIDITY_SPEC;
 pub use swift::SWIFT_SPEC;
+pub use terraform::TERRAFORM_SPEC;
+pub(crate) use terraform::{
+    TerraformBlockDecl, collect_terraform_references, describe_terraform_block,
+    read_terraform_block_header, terraform_block_body,
+};
 pub use tsx::TSX_SPEC;
 pub use typescript::TYPESCRIPT_SPEC;
 
@@ -94,6 +100,7 @@ pub fn spec_for_language(language: Language) -> Option<&'static dyn LanguageSpec
         Language::R => Some(&R_SPEC),
         Language::Solidity => Some(&SOLIDITY_SPEC),
         Language::Nix => Some(&NIX_SPEC),
+        Language::Terraform => Some(&TERRAFORM_SPEC),
         Language::Gdscript => Some(&GDSCRIPT_SPEC),
         _ => None,
     }
diff --git a/crates/codegraph-extract/src/lang/terraform.rs b/crates/codegraph-extract/src/lang/terraform.rs
new file mode 100644
index 0000000..8da5013
--- /dev/null
+++ b/crates/codegraph-extract/src/lang/terraform.rs
@@ -0,0 +1,444 @@
+//! Terraform / OpenTofu (`.tf` / `.tfvars` / `.tofu`) `LanguageSpec`.
+//!
+//! Extraction-tier port of `upstream extraction/languages/terraform.ts` (commit
+//! `6c24f4b`, #1173 — the extraction slice only). HCL is intentionally generic:
+//! ALL Terraform top-level constructs share the AST node kind `block`,
+//! distinguished only by the first `identifier` child (the block "type":
+//! `resource` / `data` / `module` / `variable` / `output` / `provider` /
+//! `locals` / `terraform`). There are no C-family `class`/`struct`/`method`/
+//! `enum`/`interface`/`function` node kinds, so — faithful to upstream's
+//! entirely empty `terraformExtractor` config — [`TerraformSpec`] returns `&[]`
+//! for every type-set and the extraction is driven entirely by the
+//! `Language::Terraform`-guarded `visit_terraform_node` walker extension in
+//! [`crate::walker`].
+//!
+//! The module-boundary framework RESOLVER (`resolution/frameworks/terraform.ts`),
+//! its registration, the directory-scoping resolution gate, and the
+//! extraction-side emitters that feed ONLY that resolver (`emitModuleWiring`'s
+//! `module.M:file` / `module.M:var.X` / `module.M:output.X` `:`-scoped refs, the
+//! `.tfvars` top-level-assignment `var.X` ref, and the `module.M:output.<out>`
+//! scoped half of `qualifyReference`) are DEFERRED — the port has exactly one
+//! concrete `FrameworkResolver` (`GodotResolver`).
+//!
+//! The pure AST helper fns below are `pub(crate)` and re-exported through
+//! [`crate::lang`] so the walker can reach them as `crate::lang::<fn>`.
+
+use codegraph_core::types::{Language, NodeKind};
+use tree_sitter::{Language as TsLanguage, Node};
+
+use crate::spec::LanguageSpec;
+use crate::walker::node_text;
+
+pub struct TerraformSpec;
+
+pub static TERRAFORM_SPEC: TerraformSpec = TerraformSpec;
+
+impl LanguageSpec for TerraformSpec {
+    fn language(&self) -> Language {
+        Language::Terraform
+    }
+
+    fn tree_sitter_language(&self) -> TsLanguage {
+        tree_sitter_hcl::LANGUAGE.into()
+    }
+
+    fn function_types(&self) -> &'static [&'static str] {
+        &[]
+    }
+
+    fn class_types(&self) -> &'static [&'static str] {
+        &[]
+    }
+
+    fn method_types(&self) -> &'static [&'static str] {
+        &[]
+    }
+
+    fn interface_types(&self) -> &'static [&'static str] {
+        &[]
+    }
+
+    fn struct_types(&self) -> &'static [&'static str] {
+        &[]
+    }
+
+    fn enum_types(&self) -> &'static [&'static str] {
+        &[]
+    }
+
+    fn enum_member_types(&self) -> &'static [&'static str] {
+        &[]
+    }
+
+    fn type_alias_types(&self) -> &'static [&'static str] {
+        &[]
+    }
+
+    fn import_types(&self) -> &'static [&'static str] {
+        &[]
+    }
+
+    fn call_types(&self) -> &'static [&'static str] {
+        &[]
+    }
+
+    fn variable_types(&self) -> &'static [&'static str] {
+        &[]
+    }
+
+    fn name_field(&self) -> &'static str {
+        ""
+    }
+
+    fn body_field(&self) -> &'static str {
+        ""
+    }
+
+    fn params_field(&self) -> &'static str {
+        ""
+    }
+
+    fn return_field(&self) -> &'static str {
+        ""
+    }
+}
+
+/// Built-in reference heads that should NOT be resolved to project nodes.
+/// Ports `BUILTIN_HEADS` (terraform.ts:37-43).
+const BUILTIN_HEADS: [&str; 5] = ["each", "count", "self", "path", "terraform"];
+
+/// Bare strings never treated as references. Ports `BUILTIN_KEYWORDS`
+/// (terraform.ts:46).
+const BUILTIN_KEYWORDS: [&str; 3] = ["null", "true", "false"];
+
+/// A described top-level Terraform block: its `NodeKind`, symbol name, qualified
+/// name, and signature. Ports the `BlockDecl` interface (terraform.ts:361-366).
+pub(crate) struct TerraformBlockDecl {
+    pub kind: NodeKind,
+    pub name: String,
+    pub qualified_name: String,
+    pub signature: String,
+}
+
+/// Read a `string_lit` value (its `template_literal` named child text), else
+/// `""` (an empty `""` string parses with no `template_literal`). Ports
+/// `stringLitValue` (terraform.ts:49-55).
+pub(crate) fn terraform_string_lit_value(node: Node<'_>, source: &str) -> String {
+    node.named_children(&mut node.walk())
+        .find(|c| c.kind() == "template_literal")
+        .map(|literal| node_text(literal, source))
+        .unwrap_or_default()
+}
+
+/// Block "type" and its label values. `None` if the block is malformed (first
+/// named child is not an `identifier`). Ports `readBlockHeader`
+/// (terraform.ts:58-79).
+pub(crate) fn read_terraform_block_header(
+    block: Node<'_>,
+    source: &str,
+) -> Option<(String, Vec<String>)> {
+    let named: Vec<Node<'_>> = block.named_children(&mut block.walk()).collect();
+    let first = named.first()?;
+    if first.kind() != "identifier" {
+        return None;
+    }
+    let block_type = node_text(*first, source);
+    let mut labels: Vec<String> = Vec::new();
+    for child in named.iter().skip(1) {
+        match child.kind() {
+            "string_lit" => labels.push(terraform_string_lit_value(*child, source)),
+            // HCL allows unquoted identifier labels (rare in Terraform, legal).
+            "identifier" => labels.push(node_text(*child, source)),
+            _ => break,
+        }
+    }
+    Some((block_type, labels))
+}
+
+/// The `body` named child of a block. Ports `getBlockBody`
+/// (terraform.ts:82-84).
+pub(crate) fn terraform_block_body(block: Node<'_>) -> Option<Node<'_>> {
+    block
+        .named_children(&mut block.walk())
+        .find(|c| c.kind() == "body")
+}
+
+/// Map a block type + labels to a symbol declaration. Ports `describeBlock`
+/// (terraform.ts:368-435): resource/data → Class, module → Module,
+/// variable/output → Variable, provider → Namespace. `None` for unknown types
+/// or missing required labels.
+pub(crate) fn describe_terraform_block(
+    block_type: &str,
+    labels: &[String],
+) -> Option<TerraformBlockDecl> {
+    let first = labels.first();
+    let second = labels.get(1);
+    match block_type {
+        "resource" => {
+            let (first, second) = (first?, second?);
+            Some(TerraformBlockDecl {
+                kind: NodeKind::Class,
+                name: format!("{first}.{second}"),
+                qualified_name: format!("{first}.{second}"),
+                signature: format!("resource \"{first}\" \"{second}\""),
+            })
+        }
+        "data" => {
+            let (first, second) = (first?, second?);
+            Some(TerraformBlockDecl {
+                kind: NodeKind::Class,
+                name: format!("{first}.{second}"),
+                qualified_name: format!("data.{first}.{second}"),
+                signature: format!("data \"{first}\" \"{second}\""),
+            })
+        }
+        "module" => {
+            let first = first?;
+            Some(TerraformBlockDecl {
+                kind: NodeKind::Module,
+                name: first.clone(),
+                qualified_name: format!("module.{first}"),
+                signature: format!("module \"{first}\""),
+            })
+        }
+        "variable" => {
+            let first = first?;
+            Some(TerraformBlockDecl {
+                kind: NodeKind::Variable,
+                name: first.clone(),
+                qualified_name: format!("var.{first}"),
+                signature: format!("variable \"{first}\""),
+            })
+        }
+        "output" => {
+            let first = first?;
+            Some(TerraformBlockDecl {
+                kind: NodeKind::Variable,
+                name: first.clone(),
+                qualified_name: format!("output.{first}"),
+                signature: format!("output \"{first}\""),
+            })
+        }
+        "provider" => {
+            let first = first?;
+            Some(TerraformBlockDecl {
+                kind: NodeKind::Namespace,
+                name: first.clone(),
+                qualified_name: format!("provider.{first}"),
+                signature: format!("provider \"{first}\""),
+            })
+        }
+        _ => None,
+    }
+}
+
+/// Turn a reference head + attribute chain into qualified name(s). Ports
+/// `qualifyReference` (terraform.ts:158-189) MINUS the DEFERRED
+/// `module.M:output.<out>` scoped half — a `module.M.out` chain emits ONLY the
+/// plain `module.M`.
+fn qualify_terraform_reference(head: &str, attrs: &[String]) -> Vec<String> {
+    match head {
+        "var" => attrs
+            .first()
+            .map(|a| vec![format!("var.{a}")])
+            .unwrap_or_default(),
+        "local" => attrs
+            .first()
+            .map(|a| vec![format!("local.{a}")])
+            .unwrap_or_default(),
+        "module" => {
+            // Plain `module.M` only; the `module.M:output.<out>` scoped half is
+            // DEFERRED (it feeds the deferred TerraformResolver).
+            attrs
+                .first()
+                .map(|a| vec![format!("module.{a}")])
+                .unwrap_or_default()
+        }
+        "data" => match (attrs.first(), attrs.get(1)) {
+            (Some(t), Some(n)) => vec![format!("data.{t}.{n}")],
+            _ => Vec::new(),
+        },
+        _ => {
+            // <type>.<name>[.<attr>...] — managed resource (e.g.
+            // aws_s3_bucket.my). Skip a bare head with no dotted chain.
+            attrs
+                .first()
+                .map(|a| vec![format!("{head}.{a}")])
+                .unwrap_or_default()
+        }
+    }
+}
+
+/// Read a `variable_expr` head + its `get_attr` sibling chain and emit qualified
+/// reference(s). Ports `emitRefFromVariableExpr` (terraform.ts:126-155).
+fn emit_ref_from_variable_expr(
+    var_expr: Node<'_>,
+    source: &str,
+    out: &mut Vec<(String, u32, u32)>,
+) {
+    let Some(id) = var_expr
+        .named_children(&mut var_expr.walk())
+        .find(|c| c.kind() == "identifier")
+    else {
+        return;
+    };
+    let head = node_text(id, source);
+    if BUILTIN_HEADS.contains(&head.as_str()) || BUILTIN_KEYWORDS.contains(&head.as_str()) {
+        return;
+    }
+
+    let mut attrs: Vec<String> = Vec::new();
+    let mut cursor = var_expr.next_named_sibling();
+    while let Some(node) = cursor {
+        match node.kind() {
+            "get_attr" => {
+                let Some(attr_id) = node
+                    .named_children(&mut node.walk())
+                    .find(|c| c.kind() == "identifier")
+                else {
+                    break;
+                };
+                attrs.push(node_text(attr_id, source));
+                cursor = node.next_named_sibling();
+            }
+            "index" | "new_index" | "legacy_index" | "splat" | "attr_splat" | "full_splat" => {
+                // foo[0], foo[*], foo.* — keep walking, add no segment.
+                cursor = node.next_named_sibling();
+            }
+            _ => break,
+        }
+    }
+
+    let line = var_expr.start_position().row as u32 + 1;
+    let col = var_expr.start_position().column as u32;
+    for qname in qualify_terraform_reference(&head, &attrs) {
+        out.push((qname, line, col));
+    }
+}
+
+/// BFS an `expression` subtree, emitting a qualified reference for every dotted
+/// name whose head is a Terraform reference root. Ports `collectReferences`
+/// (terraform.ts:106-121). Returns `(qualified_name, line, col)` tuples in
+/// traversal order.
+pub(crate) fn collect_terraform_references(
+    expr: Node<'_>,
+    source: &str,
+) -> Vec<(String, u32, u32)> {
+    let mut out: Vec<(String, u32, u32)> = Vec::new();
+    let mut queue: std::collections::VecDeque<Node<'_>> = std::collections::VecDeque::new();
+    queue.push_back(expr);
+    while let Some(node) = queue.pop_front() {
+        if node.kind() == "variable_expr" {
+            emit_ref_from_variable_expr(node, source, &mut out);
+        }
+        for child in node.named_children(&mut node.walk()) {
+            queue.push_back(child);
+        }
+    }
+    out
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn parse(src: &str) -> tree_sitter::Tree {
+        let mut parser = tree_sitter::Parser::new();
+        parser
+            .set_language(&tree_sitter_hcl::LANGUAGE.into())
+            .unwrap();
+        parser.parse(src, None).unwrap()
+    }
+
+    fn first_of_kind<'t>(node: Node<'t>, kind: &str) -> Option<Node<'t>> {
+        if node.kind() == kind {
+            return Some(node);
+        }
+        for i in 0..node.named_child_count() as u32 {
+            let child = node.named_child(i)?;
+            if let Some(found) = first_of_kind(child, kind) {
+                return Some(found);
+            }
+        }
+        None
+    }
+
+    #[test]
+    fn terraform_spec_has_empty_type_sets() {
+        assert!(TERRAFORM_SPEC.function_types().is_empty());
+        assert!(TERRAFORM_SPEC.class_types().is_empty());
+        assert!(TERRAFORM_SPEC.method_types().is_empty());
+        assert!(TERRAFORM_SPEC.call_types().is_empty());
+        assert!(TERRAFORM_SPEC.variable_types().is_empty());
+        assert!(TERRAFORM_SPEC.import_types().is_empty());
+        assert_eq!(TERRAFORM_SPEC.language(), Language::Terraform);
+    }
+
+    #[test]
+    fn describe_terraform_block_resource() {
+        let decl = describe_terraform_block("resource", &["aws_s3_bucket".into(), "b".into()])
+            .expect("resource decl");
+        assert_eq!(decl.kind, NodeKind::Class);
+        assert_eq!(decl.name, "aws_s3_bucket.b");
+        assert_eq!(decl.qualified_name, "aws_s3_bucket.b");
+    }
+
+    #[test]
+    fn describe_terraform_block_data() {
+        let decl = describe_terraform_block("data", &["aws_ami".into(), "ubuntu".into()])
+            .expect("data decl");
+        assert_eq!(decl.kind, NodeKind::Class);
+        assert_eq!(decl.qualified_name, "data.aws_ami.ubuntu");
+    }
+
+    #[test]
+    fn describe_terraform_block_module() {
+        let decl = describe_terraform_block("module", &["vpc".into()]).expect("module decl");
+        assert_eq!(decl.kind, NodeKind::Module);
+        assert_eq!(decl.qualified_name, "module.vpc");
+    }
+
+    #[test]
+    fn describe_terraform_block_variable() {
+        let decl = describe_terraform_block("variable", &["region".into()]).expect("variable decl");
+        assert_eq!(decl.kind, NodeKind::Variable);
+        assert_eq!(decl.qualified_name, "var.region");
+    }
+
+    #[test]
+    fn describe_terraform_block_provider() {
+        let decl = describe_terraform_block("provider", &["aws".into()]).expect("provider decl");
+        assert_eq!(decl.kind, NodeKind::Namespace);
+        assert_eq!(decl.qualified_name, "provider.aws");
+    }
+
+    #[test]
+    fn describe_terraform_block_unknown_is_none() {
+        assert!(describe_terraform_block("terraform", &[]).is_none());
+        assert!(describe_terraform_block("resource", &["only_one".into()]).is_none());
+    }
+
+    #[test]
+    fn qualify_terraform_reference_module_output_is_plain() {
+        // A two-segment module.M.out chain emits ONLY plain module.M (the
+        // module.M:output.out scoped half is DEFERRED).
+        assert_eq!(
+            qualify_terraform_reference("module", &["vpc".into(), "id".into()]),
+            vec!["module.vpc".to_string()]
+        );
+    }
+
+    #[test]
+    fn terraform_parses_block() {
+        let tree = parse("resource \"aws_s3_bucket\" \"b\" { bucket = var.name }");
+        let root = tree.root_node();
+        let block = first_of_kind(root, "block").expect("a block node");
+        let (block_type, labels) = read_terraform_block_header(
+            block,
+            "resource \"aws_s3_bucket\" \"b\" { bucket = var.name }",
+        )
+        .expect("header");
+        assert_eq!(block_type, "resource");
+        assert_eq!(labels, vec!["aws_s3_bucket".to_string(), "b".to_string()]);
+    }
+}
diff --git a/crates/codegraph-extract/src/walker.rs b/crates/codegraph-extract/src/walker.rs
index 35fe0d6..3e0d61e 100644
--- a/crates/codegraph-extract/src/walker.rs
+++ b/crates/codegraph-extract/src/walker.rs
@@ -230,10 +230,152 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> {
             Language::Cpp => self.visit_cpp_node(node),
             Language::Solidity => self.visit_solidity_node(node),
             Language::Nix => self.visit_nix_node(node),
+            Language::Terraform => self.visit_terraform_node(node),
             _ => false,
         }
     }
 
+    /// Terraform/HCL extraction (upstream `terraformExtractor.visitNode`,
+    /// `terraform.ts`, #1173 — the extraction slice only). HCL's AST has no
+    /// C-family type-set node kinds — EVERY top-level construct is a `block`
+    /// distinguished only by its first `identifier` child — so `TERRAFORM_SPEC`
+    /// returns `&[]` for every type-set and this custom visitor (like
+    /// `visit_nix_node`) is the SOLE owner of the `block` kind. Every other kind
+    /// returns `false` → generic `visit_named_children` descent (so
+    /// `config_file`/`body` are walked into automatically, reaching each
+    /// `block`). Strictly `Language::Terraform`-guarded.
+    ///
+    /// Block-type dispatch: `resource`/`data` → Class, `module` → Module,
+    /// `variable`/`output` → Variable, `provider` → Namespace, `locals` →
+    /// Constant per attribute; body attribute-expression traversal refs
+    /// (`var.X`/`local.X`/`module.M`/`data.T.N`/`<type>.<name>`) →
+    /// `EdgeKind::References` with built-ins skipped. The `emitModuleWiring`
+    /// `:`-scoped refs and the `.tfvars` var ref are DEFERRED (they feed the
+    /// deferred `TerraformResolver`).
+    fn visit_terraform_node(&mut self, node: SyntaxNode<'tree>) -> bool {
+        if node.kind() != "block" {
+            // .tfvars top-level-assignment `var.X` ref DEFERRED — no special
+            // case; let the generic walker descend.
+            return false;
+        }
+
+        let Some((block_type, labels)) =
+            crate::lang::read_terraform_block_header(node, self.source)
+        else {
+            return false;
+        };
+        let body = crate::lang::terraform_block_body(node);
+
+        // locals: every body attribute becomes its own Constant.
+        if block_type == "locals" && labels.is_empty() {
+            self.visit_terraform_locals(body);
+            return true;
+        }
+
+        // terraform { ... } settings block — no symbols, no refs.
+        if block_type == "terraform" && labels.is_empty() {
+            return true;
+        }
+
+        // resource / data / module / variable / output / provider.
+        let Some(decl) = crate::lang::describe_terraform_block(&block_type, &labels) else {
+            // Unknown top-level block — generic descent.
+            return false;
+        };
+        let crate::lang::TerraformBlockDecl {
+            kind,
+            name,
+            qualified_name,
+            signature,
+        } = decl;
+        let is_exported = kind == NodeKind::Variable;
+        if let Some(created) = self.create_node(
+            kind,
+            &name,
+            node,
+            NodeExtra {
+                signature: Some(signature),
+                is_exported,
+                qualified_name: Some(qualified_name),
+                ..NodeExtra::default()
+            },
+        ) {
+            self.node_stack.push(created.id);
+            if let Some(body) = body {
+                self.emit_terraform_references_in_body(body);
+            }
+            // emitModuleWiring for `module` blocks DEFERRED.
+            self.node_stack.pop();
+        }
+        true
+    }
+
+    /// `locals { k = … }` → one `NodeKind::Constant` per body `attribute`
+    /// (qualified `local.<k>`), plus traversal refs inside each attr's
+    /// `expression`. Ports `emitLocals` (terraform.ts:437-524).
+    fn visit_terraform_locals(&mut self, body: Option<SyntaxNode<'tree>>) {
+        let Some(body) = body else {
+            return;
+        };
+        for attr in body.named_children(&mut body.walk()) {
+            if attr.kind() != "attribute" {
+                continue;
+            }
+            let Some(id_node) = attr
+                .named_children(&mut attr.walk())
+                .find(|c| c.kind() == "identifier")
+            else {
+                continue;
+            };
+            let name = node_text(id_node, self.source);
+            if let Some(created) = self.create_node(
+                NodeKind::Constant,
+                &name,
+                attr,
+                NodeExtra {
+                    signature: Some(format!("local.{name}")),
+                    qualified_name: Some(format!("local.{name}")),
+                    ..NodeExtra::default()
+                },
+            ) {
+                if let Some(expr) = attr
+                    .named_children(&mut attr.walk())
+                    .find(|c| c.kind() == "expression")
+                {
+                    let refs = crate::lang::collect_terraform_references(expr, self.source);
+                    for (qname, _line, _col) in refs {
+                        self.push_ref(&created.id, &qname, EdgeKind::References, expr);
+                    }
+                }
+            }
+        }
+    }
+
+    /// BFS the block body; when a node's kind is `expression`, run
+    /// `collect_terraform_references` and push each ref from the current owner,
+    /// then DON'T descend into that expression (the collector already walked
+    /// it). Ports `emitReferencesInBody` (terraform.ts:526-550).
+    fn emit_terraform_references_in_body(&mut self, body: SyntaxNode<'tree>) {
+        let Some(from_id) = self.node_stack.last().cloned() else {
+            return;
+        };
+        let mut queue: std::collections::VecDeque<SyntaxNode<'tree>> =
+            std::collections::VecDeque::new();
+        queue.push_back(body);
+        while let Some(node) = queue.pop_front() {
+            if node.kind() == "expression" {
+                let refs = crate::lang::collect_terraform_references(node, self.source);
+                for (qname, _line, _col) in refs {
+                    self.push_ref(&from_id, &qname, EdgeKind::References, node);
+                }
+                continue;
+            }
+            for child in node.named_children(&mut node.walk()) {
+                queue.push_back(child);
+            }
+        }
+    }
+
     /// Nix extraction (upstream `nixExtractor.visitNode`, `nix.ts`, #1190 — the
     /// extraction slice only). Nix's expression AST has no C-family type-set
     /// node kinds, so `NIX_SPEC` returns `&[]` for every type-set and this
@@ -5792,4 +5934,116 @@ public:
         assert!(has_node(&nodes, NodeKind::Variable, "lib"));
         assert!(has_node(&nodes, NodeKind::Variable, "pkgs"));
     }
+
+    // ---- Terraform / HCL (hook-driven; block-type dispatch) ----
+
+    #[test]
+    fn terraform_resource_is_class_qualified() {
+        let (nodes, _refs) = run(
+            "main.tf",
+            "resource \"aws_s3_bucket\" \"b\" {}",
+            Language::Terraform,
+        );
+        let n = node(&nodes, NodeKind::Class, "aws_s3_bucket.b");
+        assert_eq!(n.qualified_name, "aws_s3_bucket.b");
+    }
+
+    #[test]
+    fn terraform_variable_is_variable() {
+        let (nodes, _refs) = run("main.tf", "variable \"region\" {}", Language::Terraform);
+        let n = node(&nodes, NodeKind::Variable, "region");
+        assert_eq!(n.qualified_name, "var.region");
+        assert!(n.is_exported);
+    }
+
+    #[test]
+    fn terraform_output_is_variable_with_ref() {
+        let (nodes, refs) = run(
+            "main.tf",
+            "output \"arn\" { value = aws_s3_bucket.b.arn }",
+            Language::Terraform,
+        );
+        let n = node(&nodes, NodeKind::Variable, "arn");
+        assert_eq!(n.qualified_name, "output.arn");
+        assert!(has_ref(&refs, EdgeKind::References, "aws_s3_bucket.b"));
+    }
+
+    #[test]
+    fn terraform_module_is_module_no_scoped_ref() {
+        let (nodes, refs) = run(
+            "main.tf",
+            "module \"vpc\" { source = \"./vpc\" }",
+            Language::Terraform,
+        );
+        let n = node(&nodes, NodeKind::Module, "vpc");
+        assert_eq!(n.qualified_name, "module.vpc");
+        assert!(
+            !refs.iter().any(|r| r.reference_name.contains(':')),
+            "no :-scoped module wiring ref (emitModuleWiring DEFERRED): {refs:?}"
+        );
+    }
+
+    #[test]
+    fn terraform_provider_is_namespace() {
+        let (nodes, _refs) = run("main.tf", "provider \"aws\" {}", Language::Terraform);
+        let n = node(&nodes, NodeKind::Namespace, "aws");
+        assert_eq!(n.qualified_name, "provider.aws");
+    }
+
+    #[test]
+    fn terraform_locals_emit_constants() {
+        let (nodes, refs) = run(
+            "main.tf",
+            "locals {\n  name   = \"x\"\n  region = var.r\n}",
+            Language::Terraform,
+        );
+        assert_eq!(
+            node(&nodes, NodeKind::Constant, "name").qualified_name,
+            "local.name"
+        );
+        assert_eq!(
+            node(&nodes, NodeKind::Constant, "region").qualified_name,
+            "local.region"
+        );
+        assert!(has_ref(&refs, EdgeKind::References, "var.r"));
+    }
+
+    #[test]
+    fn terraform_body_ref_var() {
+        let (_nodes, refs) = run(
+            "main.tf",
+            "resource \"aws_s3_bucket\" \"b\" { bucket = var.name }",
+            Language::Terraform,
+        );
+        assert!(has_ref(&refs, EdgeKind::References, "var.name"));
+    }
+
+    #[test]
+    fn terraform_module_output_ref_is_plain() {
+        let (_nodes, refs) = run(
+            "main.tf",
+            "output \"vpc_id\" { value = module.vpc.id }",
+            Language::Terraform,
+        );
+        assert!(has_ref(&refs, EdgeKind::References, "module.vpc"));
+        assert!(
+            !refs.iter().any(|r| r.reference_name.contains(':')),
+            "the module.vpc:output.id scoped half is DEFERRED: {refs:?}"
+        );
+    }
+
+    #[test]
+    fn terraform_builtins_skipped() {
+        let (_nodes, refs) = run(
+            "main.tf",
+            "resource \"x\" \"y\" {\n  a = each.key\n  b = count.index\n  c = path.module\n}",
+            Language::Terraform,
+        );
+        assert!(
+            !refs
+                .iter()
+                .any(|r| r.reference_kind == EdgeKind::References),
+            "built-in heads (each/count/path) emit no ref: {refs:?}"
+        );
+    }
 }
diff --git a/crates/codegraph-store/src/queries.rs b/crates/codegraph-store/src/queries.rs
index b3e0c67..0cf3ca3 100644
--- a/crates/codegraph-store/src/queries.rs
+++ b/crates/codegraph-store/src/queries.rs
@@ -1526,6 +1526,7 @@ fn parse_language(value: String) -> rusqlite::Result<Language> {
         "r" => Language::R,
         "solidity" => Language::Solidity,
         "nix" => Language::Nix,
+        "terraform" => Language::Terraform,
         "yaml" => Language::Yaml,
         "twig" => Language::Twig,
         "xml" => Language::Xml,
@@ -2199,6 +2200,14 @@ mod tests {
         assert_eq!(parse_language("nix".to_string()).unwrap(), Language::Nix);
     }
 
+    #[test]
+    fn parse_language_accepts_terraform() {
+        assert_eq!(
+            parse_language("terraform".to_string()).unwrap(),
+            Language::Terraform
+        );
+    }
+
     #[test]
     fn edges_by_target_and_all_getters() {
         let mut store = store("edges-target");
diff --git a/docs/equivalence.md b/docs/equivalence.md
index 5e88a2e..8fc03fe 100644
--- a/docs/equivalence.md
+++ b/docs/equivalence.md
@@ -504,6 +504,59 @@ The `generated_golden_matches_committed_nix_fixture` and
 `nix_db_is_self_equivalent_to_nix_golden` tests in
 `crates/codegraph-bench/tests/equivalence.rs` enforce byte-stability.
 
+### Terraform fixture
+
+A tenth golden fixture, `reference/golden/terraform/`, guards Terraform/OpenTofu
+(HCL) extraction (upstream #1173 / `6c24f4b`, the extraction slice only).
+Terraform is a **new `Language::Terraform` variant** backed by a **dedicated
+`tree-sitter-hcl` grammar** (`.tf`/`.tfvars`/`.tofu` → `Language::Terraform`).
+HCL is intentionally generic — every top-level construct is a `block`
+distinguished only by its first `identifier` child — so `TERRAFORM_SPEC` has
+all-empty type-sets and extraction is driven by the `Language::Terraform`-guarded
+`visit_terraform_node` walker extension. The corpus
+(`crates/codegraph-bench/fixtures/terraform/main.tf`) is a single deterministic
+file with a `terraform {}` settings block, a `provider "aws"`, a
+`variable "region"`, a `locals` block, a `data "aws_ami" "ubuntu"`, a
+`resource "aws_s3_bucket" "b"`, a `module "vpc"`, and two `output` blocks. What
+it guards:
+
+- the `.tf` file with `"language": "terraform"`;
+- block-type dispatch: `resource`/`data` → `NodeKind::Class` (qualified `T.N` /
+  `data.T.N`), `module` → `NodeKind::Module` (`module.M`), `variable`/`output` →
+  `NodeKind::Variable` (`var.V` / `output.O`, `is_exported`), `provider` →
+  `NodeKind::Namespace` (`provider.P`), `locals` attributes → `NodeKind::Constant`
+  per attribute (`local.k`);
+- plain attribute-expression traversal refs
+  (`var.X`/`local.X`/`module.M`/`data.T.N`/`<type>.<name>`) → `References`, with
+  built-in heads (`each`/`count`/`self`/`path`/`terraform`) skipped.
+
+The plain traversal refs with a unique same-file target resolve via the existing
+generic qualified-name matcher: `var.region` ×3 → `variable "region"`,
+`aws_s3_bucket.b` → the resource, `module.vpc` → the module (each an EDGE, absent
+from `refs.json`). The undeclared `aws_kms_key.logs` stays the sole unresolved
+`refs.json` row. The module-boundary `TerraformResolver`, `emitModuleWiring`'s
+`:`-scoped refs (`module.M:file`/`:var.X`/`:output.X`), the `.tfvars`
+top-level-assignment `var.X` ref, and the `module.M:output.<out>` scoped half of
+`qualifyReference` are all **DEFERRED** — the port keeps its single
+`GodotResolver` — so no `:`-scoped ref is emitted. Adding the variant is
+byte-neutral for `colby.schema.sql` (language is a stored TEXT value, not DDL)
+and for the nine existing goldens (none holds a `.tf`/`.tfvars`/`.tofu` file).
+
+Regenerate reproducibly (identical recipe to the Nix fixture, substituting
+`terraform`):
+
+```bash
+rm -rf /tmp/cg-fixture-terraform && cp -r crates/codegraph-bench/fixtures/terraform /tmp/cg-fixture-terraform
+cargo build --release -p codegraph-rs
+CODEGRAPH_NO_DAEMON=1 CODEGRAPH_NO_WATCH=1 ./target/release/codegraph init /tmp/cg-fixture-terraform
+cp /tmp/cg-fixture-terraform/.codegraph/codegraph.db reference/golden/terraform/colby.db
+cargo run -p codegraph-bench --bin bench -- --gen-golden reference/golden/terraform/colby.db reference/golden/terraform
+```
+
+The `generated_golden_matches_committed_terraform_fixture` and
+`terraform_db_is_self_equivalent_to_terraform_golden` tests in
+`crates/codegraph-bench/tests/equivalence.rs` enforce byte-stability.
+
 ### KNOWN_DIFFS.md format
 
 Tier-3 differences are allowlisted by grep-able lines in repo-root
diff --git a/docs/languages.md b/docs/languages.md
index ab43097..2c30441 100644
--- a/docs/languages.md
+++ b/docs/languages.md
@@ -4,7 +4,7 @@ CodeGraph extracts code structure deterministically using tree-sitter grammars a
 embedded extractors. No AI, vectors, or embeddings are involved. The output is byte-stable
 across runs.
 
-**35 concrete languages** are supported, grouped into three extraction tiers based on what
+**36 concrete languages** are supported, grouped into three extraction tiers based on what
 the extractor produces.
 
 > **Note on TypeScript/JavaScript variants:** `typescript` and `tsx`, and `javascript` and
@@ -14,40 +14,41 @@ the extractor produces.
 
 ---
 
-## Tier 1 — Full symbol extraction (26 languages)
+## Tier 1 — Full symbol extraction (27 languages)
 
 Tree-sitter parses the file and extracts all symbols (functions, classes, structs, methods,
 variables, imports, etc.) plus call and dependency edges. This is the richest extraction
 level.
 
-| Language    | Extensions                                  | Extraction                | Notes                                                                                                                                                                                                                         |
-| ----------- | ------------------------------------------- | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| TypeScript  | `.ts` `.mts` `.cts`                         | Full tree-sitter          |                                                                                                                                                                                                                               |
-| TSX         | `.tsx`                                      | Full tree-sitter          | TypeScript grammar, JSX syntax                                                                                                                                                                                                |
-| JavaScript  | `.js` `.mjs` `.cjs` `.xsjs` `.xsjslib`      | Full tree-sitter          |                                                                                                                                                                                                                               |
-| JSX         | `.jsx`                                      | Full tree-sitter          | JavaScript grammar, JSX syntax                                                                                                                                                                                                |
-| ArkTS       | `.ets`                                      | Full tree-sitter          | HarmonyOS / OpenHarmony; `tree-sitter-arkts` grammar. `@Component struct` → struct symbol. ArkUI dynamic-dispatch bridges deferred. Plain `.ts` stays TypeScript                                                              |
-| Python      | `.py` `.pyw`                                | Full tree-sitter          |                                                                                                                                                                                                                               |
-| Go          | `.go`                                       | Full tree-sitter          |                                                                                                                                                                                                                               |
-| Rust        | `.rs`                                       | Full tree-sitter          |                                                                                                                                                                                                                               |
-| Java        | `.java`                                     | Full tree-sitter          |                                                                                                                                                                                                                               |
-| C           | `.c` `.h`                                   | Full tree-sitter          | `.h` may be promoted to C++ or Objective-C by heuristics                                                                                                                                                                      |
-| C++         | `.cpp` `.cc` `.cxx` `.hpp` `.hxx`           | Full tree-sitter          |                                                                                                                                                                                                                               |
-| C#          | `.cs`                                       | Full tree-sitter          |                                                                                                                                                                                                                               |
-| PHP         | `.php` `.module` `.install` `.theme` `.inc` | Full tree-sitter          |                                                                                                                                                                                                                               |
-| Ruby        | `.rb` `.rake`                               | Full tree-sitter          |                                                                                                                                                                                                                               |
-| Swift       | `.swift`                                    | Full tree-sitter          |                                                                                                                                                                                                                               |
-| Kotlin      | `.kt` `.kts`                                | Full tree-sitter          |                                                                                                                                                                                                                               |
-| Dart        | `.dart`                                     | Full tree-sitter          |                                                                                                                                                                                                                               |
-| Scala       | `.scala` `.sc`                              | Full tree-sitter          |                                                                                                                                                                                                                               |
-| Lua         | `.lua`                                      | Full tree-sitter          |                                                                                                                                                                                                                               |
-| Luau        | `.luau`                                     | Full tree-sitter          | Roblox Luau dialect                                                                                                                                                                                                           |
-| Objective-C | `.m` `.mm`                                  | Full tree-sitter          |                                                                                                                                                                                                                               |
-| R           | `.r`                                        | Full tree-sitter          |                                                                                                                                                                                                                               |
-| Solidity    | `.sol`                                      | Full tree-sitter          | `tree-sitter-solidity` grammar; contracts/libraries/interfaces, structs, enums, modifiers, events, errors; `is`-inheritance → Extends (resolver promotes to Implements for interfaces); emit/revert/modifier-guard call edges |
-| Nix         | `.nix`                                      | Full tree-sitter          | `tree-sitter-nix` grammar; `let`/attrset bindings, curried lambdas, `inherit`; `import`/`callPackage`/`imports`-list file imports; module-system option synthesizer deferred                                                  |
-| GDScript    | `.gd`                                       | Full tree-sitter          | Godot scripting; extracts functions, classes, enums, variables, signals, extends, preload. Dynamic dispatch edges (connect/get_node/$/%/call/group) added by the Godot resolver — see [`docs/godot.md`](godot.md)             |
-| Pascal      | `.pas` `.dpr` `.dpk` `.lpr` `.dfm` `.fmx`   | Full tree-sitter / custom | `.dfm`/`.fmx` form files use a custom path                                                                                                                                                                                    |
+| Language    | Extensions                                  | Extraction                | Notes                                                                                                                                                                                                                                                                                     |
+| ----------- | ------------------------------------------- | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| TypeScript  | `.ts` `.mts` `.cts`                         | Full tree-sitter          |                                                                                                                                                                                                                                                                                           |
+| TSX         | `.tsx`                                      | Full tree-sitter          | TypeScript grammar, JSX syntax                                                                                                                                                                                                                                                            |
+| JavaScript  | `.js` `.mjs` `.cjs` `.xsjs` `.xsjslib`      | Full tree-sitter          |                                                                                                                                                                                                                                                                                           |
+| JSX         | `.jsx`                                      | Full tree-sitter          | JavaScript grammar, JSX syntax                                                                                                                                                                                                                                                            |
+| ArkTS       | `.ets`                                      | Full tree-sitter          | HarmonyOS / OpenHarmony; `tree-sitter-arkts` grammar. `@Component struct` → struct symbol. ArkUI dynamic-dispatch bridges deferred. Plain `.ts` stays TypeScript                                                                                                                          |
+| Python      | `.py` `.pyw`                                | Full tree-sitter          |                                                                                                                                                                                                                                                                                           |
+| Go          | `.go`                                       | Full tree-sitter          |                                                                                                                                                                                                                                                                                           |
+| Rust        | `.rs`                                       | Full tree-sitter          |                                                                                                                                                                                                                                                                                           |
+| Java        | `.java`                                     | Full tree-sitter          |                                                                                                                                                                                                                                                                                           |
+| C           | `.c` `.h`                                   | Full tree-sitter          | `.h` may be promoted to C++ or Objective-C by heuristics                                                                                                                                                                                                                                  |
+| C++         | `.cpp` `.cc` `.cxx` `.hpp` `.hxx`           | Full tree-sitter          |                                                                                                                                                                                                                                                                                           |
+| C#          | `.cs`                                       | Full tree-sitter          |                                                                                                                                                                                                                                                                                           |
+| PHP         | `.php` `.module` `.install` `.theme` `.inc` | Full tree-sitter          |                                                                                                                                                                                                                                                                                           |
+| Ruby        | `.rb` `.rake`                               | Full tree-sitter          |                                                                                                                                                                                                                                                                                           |
+| Swift       | `.swift`                                    | Full tree-sitter          |                                                                                                                                                                                                                                                                                           |
+| Kotlin      | `.kt` `.kts`                                | Full tree-sitter          |                                                                                                                                                                                                                                                                                           |
+| Dart        | `.dart`                                     | Full tree-sitter          |                                                                                                                                                                                                                                                                                           |
+| Scala       | `.scala` `.sc`                              | Full tree-sitter          |                                                                                                                                                                                                                                                                                           |
+| Lua         | `.lua`                                      | Full tree-sitter          |                                                                                                                                                                                                                                                                                           |
+| Luau        | `.luau`                                     | Full tree-sitter          | Roblox Luau dialect                                                                                                                                                                                                                                                                       |
+| Objective-C | `.m` `.mm`                                  | Full tree-sitter          |                                                                                                                                                                                                                                                                                           |
+| R           | `.r`                                        | Full tree-sitter          |                                                                                                                                                                                                                                                                                           |
+| Solidity    | `.sol`                                      | Full tree-sitter          | `tree-sitter-solidity` grammar; contracts/libraries/interfaces, structs, enums, modifiers, events, errors; `is`-inheritance → Extends (resolver promotes to Implements for interfaces); emit/revert/modifier-guard call edges                                                             |
+| Nix         | `.nix`                                      | Full tree-sitter          | `tree-sitter-nix` grammar; `let`/attrset bindings, curried lambdas, `inherit`; `import`/`callPackage`/`imports`-list file imports; module-system option synthesizer deferred                                                                                                              |
+| Terraform   | `.tf` `.tfvars` `.tofu`                     | Full tree-sitter          | `tree-sitter-hcl` grammar (HCL/Terraform/OpenTofu); `resource`/`data`→class, `module`→module, `variable`/`output`→variable, `provider`→namespace, `locals`→constant; qualified names + `var`/`local`/`module`/`data`/resource traversal refs; module-boundary framework resolver deferred |
+| GDScript    | `.gd`                                       | Full tree-sitter          | Godot scripting; extracts functions, classes, enums, variables, signals, extends, preload. Dynamic dispatch edges (connect/get_node/$/%/call/group) added by the Godot resolver — see [`docs/godot.md`](godot.md)                                                                         |
+| Pascal      | `.pas` `.dpr` `.dpk` `.lpr` `.dfm` `.fmx`   | Full tree-sitter / custom | `.dfm`/`.fmx` form files use a custom path                                                                                                                                                                                                                                                |
 
 ---
 
diff --git a/reference/golden/terraform/colby.db b/reference/golden/terraform/colby.db
new file mode 100644
index 0000000000000000000000000000000000000000..1415c6536042325cba196aff74eab4f705ff3161
GIT binary patch
diff --git a/reference/golden/terraform/edges.json b/reference/golden/terraform/edges.json
new file mode 100644
index 0000000..dc860b9
--- /dev/null
+++ b/reference/golden/terraform/edges.json
@@ -0,0 +1,143 @@
+[
+  {
+    "col": 10,
+    "kind": "references",
+    "line": 25,
+    "metadata": {
+      "confidence": 0.95,
+      "resolvedBy": "qualified-name"
+    },
+    "provenance": null,
+    "source": "variable:60a98b4d776ea6b99ec109f16565e238",
+    "target": "class:9ea649776b48847a71f06911daf0119e"
+  },
+  {
+    "col": 10,
+    "kind": "references",
+    "line": 29,
+    "metadata": {
+      "confidence": 0.95,
+      "resolvedBy": "qualified-name"
+    },
+    "provenance": null,
+    "source": "variable:20d776b47f45d7da835d4d4e82010d17",
+    "target": "module:6387ce4b696974e8905686ae83527943"
+  },
+  {
+    "col": 11,
+    "kind": "references",
+    "line": 21,
+    "metadata": {
+      "confidence": 0.95,
+      "resolvedBy": "qualified-name"
+    },
+    "provenance": null,
+    "source": "module:6387ce4b696974e8905686ae83527943",
+    "target": "variable:161a5352b21594f9a56b14df3143a6c4"
+  },
+  {
+    "col": 11,
+    "kind": "references",
+    "line": 9,
+    "metadata": {
+      "confidence": 0.95,
+      "resolvedBy": "qualified-name"
+    },
+    "provenance": null,
+    "source": "constant:60a0bf4de8e88ec795bc637f794b133e",
+    "target": "variable:161a5352b21594f9a56b14df3143a6c4"
+  },
+  {
+    "col": 15,
+    "kind": "references",
+    "line": 15,
+    "metadata": {
+      "confidence": 0.95,
+      "resolvedBy": "qualified-name"
+    },
+    "provenance": null,
+    "source": "class:9ea649776b48847a71f06911daf0119e",
+    "target": "variable:161a5352b21594f9a56b14df3143a6c4"
+  },
+  {
+    "col": null,
+    "kind": "contains",
+    "line": null,
+    "metadata": null,
+    "provenance": null,
+    "source": "file:main.tf",
+    "target": "class:4dd4b9246c74373ca3146282f84872bd"
+  },
+  {
+    "col": null,
+    "kind": "contains",
+    "line": null,
+    "metadata": null,
+    "provenance": null,
+    "source": "file:main.tf",
+    "target": "class:9ea649776b48847a71f06911daf0119e"
+  },
+  {
+    "col": null,
+    "kind": "contains",
+    "line": null,
+    "metadata": null,
+    "provenance": null,
+    "source": "file:main.tf",
+    "target": "constant:30d18d17f1263f980b13bb367101b29e"
+  },
+  {
+    "col": null,
+    "kind": "contains",
+    "line": null,
+    "metadata": null,
+    "provenance": null,
+    "source": "file:main.tf",
+    "target": "constant:60a0bf4de8e88ec795bc637f794b133e"
+  },
+  {
+    "col": null,
+    "kind": "contains",
+    "line": null,
+    "metadata": null,
+    "provenance": null,
+    "source": "file:main.tf",
+    "target": "module:6387ce4b696974e8905686ae83527943"
+  },
+  {
+    "col": null,
+    "kind": "contains",
+    "line": null,
+    "metadata": null,
+    "provenance": null,
+    "source": "file:main.tf",
+    "target": "namespace:97d1ebc29d6e37a70a7ade3c6e6441c5"
+  },
+  {
+    "col": null,
+    "kind": "contains",
+    "line": null,
+    "metadata": null,
+    "provenance": null,
+    "source": "file:main.tf",
+    "target": "variable:161a5352b21594f9a56b14df3143a6c4"
+  },
+  {
+    "col": null,
+    "kind": "contains",
+    "line": null,
+    "metadata": null,
+    "provenance": null,
+    "source": "file:main.tf",
+    "target": "variable:20d776b47f45d7da835d4d4e82010d17"
+  },
+  {
+    "col": null,
+    "kind": "contains",
+    "line": null,
+    "metadata": null,
+    "provenance": null,
+    "source": "file:main.tf",
+    "target": "variable:60a98b4d776ea6b99ec109f16565e238"
+  }
+]
diff --git a/reference/golden/terraform/files.json b/reference/golden/terraform/files.json
new file mode 100644
index 0000000..549b157
--- /dev/null
+++ b/reference/golden/terraform/files.json
@@ -0,0 +1,10 @@
+[
+  {
+    "content_hash": "7df7854f67b585b19f9fb67712ff48434fc209255e89d426d2036200cd4d960c",
+    "errors": null,
+    "language": "terraform",
+    "node_count": 10,
+    "path": "main.tf",
+    "size": 387
+  }
+]
diff --git a/reference/golden/terraform/nodes.json b/reference/golden/terraform/nodes.json
new file mode 100644
index 0000000..200df10
--- /dev/null
+++ b/reference/golden/terraform/nodes.json
@@ -0,0 +1,222 @@
+[
+  {
+    "decorators": null,
+    "docstring": null,
+    "end_column": 26,
+    "end_line": 12,
+    "file_path": "main.tf",
+    "id": "class:4dd4b9246c74373ca3146282f84872bd",
+    "is_abstract": 0,
+    "is_async": 0,
+    "is_exported": 0,
+    "is_static": 0,
+    "kind": "class",
+    "language": "terraform",
+    "name": "aws_ami.ubuntu",
+    "qualified_name": "data.aws_ami.ubuntu",
+    "return_type": null,
+    "signature": "data \"aws_ami\" \"ubuntu\"",
+    "start_column": 0,
+    "start_line": 12,
+    "type_parameters": null,
+    "visibility": null
+  },
+  {
+    "decorators": null,
+    "docstring": null,
+    "end_column": 1,
+    "end_line": 17,
+    "file_path": "main.tf",
+    "id": "class:9ea649776b48847a71f06911daf0119e",
+    "is_abstract": 0,
+    "is_async": 0,
+    "is_exported": 0,
+    "is_static": 0,
+    "kind": "class",
+    "language": "terraform",
+    "name": "aws_s3_bucket.b",
+    "qualified_name": "aws_s3_bucket.b",
+    "return_type": null,
+    "signature": "resource \"aws_s3_bucket\" \"b\"",
+    "start_column": 0,
+    "start_line": 14,
+    "type_parameters": null,
+    "visibility": null
+  },
+  {
+    "decorators": null,
+    "docstring": null,
+    "end_column": 17,
+    "end_line": 8,
+    "file_path": "main.tf",
+    "id": "constant:30d18d17f1263f980b13bb367101b29e",
+    "is_abstract": 0,
+    "is_async": 0,
+    "is_exported": 0,
+    "is_static": 0,
+    "kind": "constant",
+    "language": "terraform",
+    "name": "name",
+    "qualified_name": "local.name",
+    "return_type": null,
+    "signature": "local.name",
+    "start_column": 2,
+    "start_line": 8,
+    "type_parameters": null,
+    "visibility": null
+  },
+  {
+    "decorators": null,
+    "docstring": null,
+    "end_column": 21,
+    "end_line": 9,
+    "file_path": "main.tf",
+    "id": "constant:60a0bf4de8e88ec795bc637f794b133e",
+    "is_abstract": 0,
+    "is_async": 0,
+    "is_exported": 0,
+    "is_static": 0,
+    "kind": "constant",
+    "language": "terraform",
+    "name": "tagged",
+    "qualified_name": "local.tagged",
+    "return_type": null,
+    "signature": "local.tagged",
+    "start_column": 2,
+    "start_line": 9,
+    "type_parameters": null,
+    "visibility": null
+  },
+  {
+    "decorators": null,
+    "docstring": null,
+    "end_column": 0,
+    "end_line": 31,
+    "file_path": "main.tf",
+    "id": "file:main.tf",
+    "is_abstract": 0,
+    "is_async": 0,
+    "is_exported": 0,
+    "is_static": 0,
+    "kind": "file",
+    "language": "terraform",
+    "name": "main.tf",
+    "qualified_name": "main.tf",
+    "return_type": null,
+    "signature": null,
+    "start_column": 0,
+    "start_line": 1,
+    "type_parameters": null,
+    "visibility": null
+  },
+  {
+    "decorators": null,
+    "docstring": null,
+    "end_column": 1,
+    "end_line": 22,
+    "file_path": "main.tf",
+    "id": "module:6387ce4b696974e8905686ae83527943",
+    "is_abstract": 0,
+    "is_async": 0,
+    "is_exported": 0,
+    "is_static": 0,
+    "kind": "module",
+    "language": "terraform",
+    "name": "vpc",
+    "qualified_name": "module.vpc",
+    "return_type": null,
+    "signature": "module \"vpc\"",
+    "start_column": 0,
+    "start_line": 19,
+    "type_parameters": null,
+    "visibility": null
+  },
+  {
+    "decorators": null,
+    "docstring": null,
+    "end_column": 17,
+    "end_line": 3,
+    "file_path": "main.tf",
+    "id": "namespace:97d1ebc29d6e37a70a7ade3c6e6441c5",
+    "is_abstract": 0,
+    "is_async": 0,
+    "is_exported": 0,
+    "is_static": 0,
+    "kind": "namespace",
+    "language": "terraform",
+    "name": "aws",
+    "qualified_name": "provider.aws",
+    "return_type": null,
+    "signature": "provider \"aws\"",
+    "start_column": 0,
+    "start_line": 3,
+    "type_parameters": null,
+    "visibility": null
+  },
+  {
+    "decorators": null,
+    "docstring": null,
+    "end_column": 20,
+    "end_line": 5,
+    "file_path": "main.tf",
+    "id": "variable:161a5352b21594f9a56b14df3143a6c4",
+    "is_abstract": 0,
+    "is_async": 0,
+    "is_exported": 1,
+    "is_static": 0,
+    "kind": "variable",
+    "language": "terraform",
+    "name": "region",
+    "qualified_name": "var.region",
+    "return_type": null,
+    "signature": "variable \"region\"",
+    "start_column": 0,
+    "start_line": 5,
+    "type_parameters": null,
+    "visibility": null
+  },
+  {
+    "decorators": null,
+    "docstring": null,
+    "end_column": 1,
+    "end_line": 30,
+    "file_path": "main.tf",
+    "id": "variable:20d776b47f45d7da835d4d4e82010d17",
+    "is_abstract": 0,
+    "is_async": 0,
+    "is_exported": 1,
+    "is_static": 0,
+    "kind": "variable",
+    "language": "terraform",
+    "name": "vpc_id",
+    "qualified_name": "output.vpc_id",
+    "return_type": null,
+    "signature": "output \"vpc_id\"",
+    "start_column": 0,
+    "start_line": 28,
+    "type_parameters": null,
+    "visibility": null
+  },
+  {
+    "decorators": null,
+    "docstring": null,
+    "end_column": 1,
+    "end_line": 26,
+    "file_path": "main.tf",
+    "id": "variable:60a98b4d776ea6b99ec109f16565e238",
+    "is_abstract": 0,
+    "is_async": 0,
+    "is_exported": 1,
+    "is_static": 0,
+    "kind": "variable",
+    "language": "terraform",
+    "name": "bucket_id",
+    "qualified_name": "output.bucket_id",
+    "return_type": null,
+    "signature": "output \"bucket_id\"",
+    "start_column": 0,
+    "start_line": 24,
+    "type_parameters": null,
+    "visibility": null
+  }
+]
diff --git a/reference/golden/terraform/refs.json b/reference/golden/terraform/refs.json
new file mode 100644
index 0000000..ec090be
--- /dev/null
+++ b/reference/golden/terraform/refs.json
@@ -0,0 +1,12 @@
+[
+  {
+    "candidates": null,
+    "col": 15,
+    "file_path": "main.tf",
+    "from_node_id": "class:9ea649776b48847a71f06911daf0119e",
+    "language": "terraform",
+    "line": 16,
+    "reference_kind": "references",
+    "reference_name": "aws_kms_key.logs"
+  }
+]
diff --git a/reference/golden/terraform/schema.sql b/reference/golden/terraform/schema.sql
new file mode 100644
index 0000000..d36218f
--- /dev/null
+++ b/reference/golden/terraform/schema.sql
@@ -0,0 +1,117 @@
+CREATE TABLE schema_versions (
+version INTEGER PRIMARY KEY,
+applied_at INTEGER NOT NULL,
+description TEXT
+);
+CREATE TABLE nodes (
+id TEXT PRIMARY KEY,
+kind TEXT NOT NULL,
+name TEXT NOT NULL,
+qualified_name TEXT NOT NULL,
+file_path TEXT NOT NULL,
+language TEXT NOT NULL,
+start_line INTEGER NOT NULL,
+end_line INTEGER NOT NULL,
+start_column INTEGER NOT NULL,
+end_column INTEGER NOT NULL,
+docstring TEXT,
+signature TEXT,
+visibility TEXT,
+is_exported INTEGER DEFAULT 0,
+is_async INTEGER DEFAULT 0,
+is_static INTEGER DEFAULT 0,
+is_abstract INTEGER DEFAULT 0,
+decorators TEXT, -- JSON array
+type_parameters TEXT, -- JSON array
+return_type TEXT, -- normalized return/result type name (e.g. C++ method return, for receiver-type inference)
+updated_at INTEGER NOT NULL
+);
+CREATE TABLE edges (
+id INTEGER PRIMARY KEY AUTOINCREMENT,
+source TEXT NOT NULL,
+target TEXT NOT NULL,
+kind TEXT NOT NULL,
+metadata TEXT, -- JSON object
+line INTEGER,
+col INTEGER,
+provenance TEXT DEFAULT NULL,
+FOREIGN KEY (source) REFERENCES nodes(id) ON DELETE CASCADE,
+FOREIGN KEY (target) REFERENCES nodes(id) ON DELETE CASCADE
+);
+CREATE TABLE sqlite_sequence(name,seq);
+CREATE TABLE files (
+path TEXT PRIMARY KEY,
+content_hash TEXT NOT NULL,
+language TEXT NOT NULL,
+size INTEGER NOT NULL,
+modified_at INTEGER NOT NULL,
+indexed_at INTEGER NOT NULL,
+node_count INTEGER DEFAULT 0,
+errors TEXT -- JSON array
+);
+CREATE TABLE unresolved_refs (
+id INTEGER PRIMARY KEY AUTOINCREMENT,
+from_node_id TEXT NOT NULL,
+reference_name TEXT NOT NULL,
+reference_kind TEXT NOT NULL,
+line INTEGER NOT NULL,
+col INTEGER NOT NULL,
+candidates TEXT, -- JSON array
+file_path TEXT NOT NULL DEFAULT '',
+language TEXT NOT NULL DEFAULT 'unknown',
+reference_subkind TEXT,
+FOREIGN KEY (from_node_id) REFERENCES nodes(id) ON DELETE CASCADE
+);
+CREATE INDEX idx_nodes_kind ON nodes(kind);
+CREATE INDEX idx_nodes_name ON nodes(name);
+CREATE INDEX idx_nodes_qualified_name ON nodes(qualified_name);
+CREATE INDEX idx_nodes_file_path ON nodes(file_path);
+CREATE INDEX idx_nodes_language ON nodes(language);
+CREATE INDEX idx_nodes_file_line ON nodes(file_path, start_line);
+CREATE INDEX idx_nodes_lower_name ON nodes(lower(name));
+CREATE VIRTUAL TABLE nodes_fts USING fts5(
+id,
+name,
+qualified_name,
+docstring,
+signature,
+content='nodes',
+content_rowid='rowid'
+)
+/* nodes_fts(id,name,qualified_name,docstring,signature) */;
+CREATE TABLE 'nodes_fts_data'(id INTEGER PRIMARY KEY, block BLOB);
+CREATE TABLE 'nodes_fts_idx'(segid, term, pgno, PRIMARY KEY(segid, term)) WITHOUT ROWID;
+CREATE TABLE 'nodes_fts_docsize'(id INTEGER PRIMARY KEY, sz BLOB);
+CREATE TABLE 'nodes_fts_config'(k PRIMARY KEY, v) WITHOUT ROWID;
+CREATE TRIGGER nodes_ai AFTER INSERT ON nodes BEGIN
+INSERT INTO nodes_fts(rowid, id, name, qualified_name, docstring, signature)
+VALUES (NEW.rowid, NEW.id, NEW.name, NEW.qualified_name, NEW.docstring, NEW.signature);
+END;
+CREATE TRIGGER nodes_ad AFTER DELETE ON nodes BEGIN
+INSERT INTO nodes_fts(nodes_fts, rowid, id, name, qualified_name, docstring, signature)
+VALUES ('delete', OLD.rowid, OLD.id, OLD.name, OLD.qualified_name, OLD.docstring, OLD.signature);
+END;
+CREATE TRIGGER nodes_au AFTER UPDATE ON nodes BEGIN
+INSERT INTO nodes_fts(nodes_fts, rowid, id, name, qualified_name, docstring, signature)
+VALUES ('delete', OLD.rowid, OLD.id, OLD.name, OLD.qualified_name, OLD.docstring, OLD.signature);
+INSERT INTO nodes_fts(rowid, id, name, qualified_name, docstring, signature)
+VALUES (NEW.rowid, NEW.id, NEW.name, NEW.qualified_name, NEW.docstring, NEW.signature);
+END;
+CREATE INDEX idx_edges_kind ON edges(kind);
+CREATE INDEX idx_edges_source_kind ON edges(source, kind);
+CREATE INDEX idx_edges_target_kind ON edges(target, kind);
+CREATE UNIQUE INDEX idx_edges_identity
+ON edges(source, target, kind, IFNULL(line, -1), IFNULL(col, -1));
+CREATE INDEX idx_files_language ON files(language);
+CREATE INDEX idx_files_modified_at ON files(modified_at);
+CREATE INDEX idx_unresolved_from_node ON unresolved_refs(from_node_id);
+CREATE INDEX idx_unresolved_name ON unresolved_refs(reference_name);
+CREATE INDEX idx_unresolved_file_path ON unresolved_refs(file_path);
+CREATE INDEX idx_unresolved_from_name ON unresolved_refs(from_node_id, reference_name);
+CREATE INDEX idx_edges_provenance ON edges(provenance);
+CREATE TABLE project_metadata (
+key TEXT PRIMARY KEY,
+value TEXT NOT NULL,
+updated_at INTEGER NOT NULL
+);
+CREATE TABLE sqlite_stat1(tbl,idx,stat);
