diff --git a/Cargo.lock b/Cargo.lock
index b3d35f6..2d33d6e 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -432,7 +432,7 @@ dependencies = [
[[package]]
name = "codegraph-bench"
-version = "0.37.0"
+version = "0.38.0"
dependencies = [
"anyhow",
"clap",
@@ -447,7 +447,7 @@ dependencies = [
[[package]]
name = "codegraph-core"
-version = "0.37.0"
+version = "0.38.0"
dependencies = [
"anyhow",
"serde",
@@ -463,7 +463,7 @@ dependencies = [
[[package]]
name = "codegraph-daemon"
-version = "0.37.0"
+version = "0.38.0"
dependencies = [
"anyhow",
"codegraph-core",
@@ -483,7 +483,7 @@ dependencies = [
[[package]]
name = "codegraph-extract"
-version = "0.37.0"
+version = "0.38.0"
dependencies = [
"anyhow",
"codegraph-core",
@@ -497,6 +497,7 @@ dependencies = [
"tree-sitter-arkts",
"tree-sitter-c",
"tree-sitter-c-sharp",
+ "tree-sitter-cfml",
"tree-sitter-cpp",
"tree-sitter-css",
"tree-sitter-dart",
@@ -530,7 +531,7 @@ dependencies = [
[[package]]
name = "codegraph-graph"
-version = "0.37.0"
+version = "0.38.0"
dependencies = [
"anyhow",
"codegraph-core",
@@ -544,7 +545,7 @@ dependencies = [
[[package]]
name = "codegraph-mcp"
-version = "0.37.0"
+version = "0.38.0"
dependencies = [
"anyhow",
"axum",
@@ -567,7 +568,7 @@ dependencies = [
[[package]]
name = "codegraph-resolve"
-version = "0.37.0"
+version = "0.38.0"
dependencies = [
"anyhow",
"codegraph-core",
@@ -581,7 +582,7 @@ dependencies = [
[[package]]
name = "codegraph-rs"
-version = "0.37.0"
+version = "0.38.0"
dependencies = [
"anyhow",
"assert_cmd",
@@ -617,7 +618,7 @@ dependencies = [
[[package]]
name = "codegraph-store"
-version = "0.37.0"
+version = "0.38.0"
dependencies = [
"anyhow",
"codegraph-core",
@@ -629,7 +630,7 @@ dependencies = [
[[package]]
name = "codegraph-watch"
-version = "0.37.0"
+version = "0.38.0"
dependencies = [
"anyhow",
"codegraph-core",
@@ -3456,6 +3457,16 @@ dependencies = [
"tree-sitter-language",
]
+[[package]]
+name = "tree-sitter-cfml"
+version = "0.26.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "73c959e3728a4da88bff330340990ee2dbd9864901422760f7abfc4c93be6a95"
+dependencies = [
+ "cc",
+ "tree-sitter-language",
+]
+
[[package]]
name = "tree-sitter-cpp"
version = "0.23.4"
diff --git a/README.md b/README.md
index 380f1e6..1bb9b14 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 37 languages (TypeScript,
+**Does:** deterministic code-structure extraction across 38 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 **37 languages** grouped by extraction depth. Quick overview:
+CodeGraph supports **38 languages** grouped by extraction depth. Quick overview:
-- **Tier 1 — Full symbol extraction (28):** 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, Erlang, GDScript, Pascal.
+- **Tier 1 — Full symbol extraction (29):** 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, Erlang, CFML, 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/cfml/Base.cfc b/crates/codegraph-bench/fixtures/cfml/Base.cfc
new file mode 100644
index 0000000..0fcaff3
--- /dev/null
+++ b/crates/codegraph-bench/fixtures/cfml/Base.cfc
@@ -0,0 +1,5 @@
+component {
+ public function ping() {
+ return true;
+ }
+}
diff --git a/crates/codegraph-bench/fixtures/cfml/Gadget.cfs b/crates/codegraph-bench/fixtures/cfml/Gadget.cfs
new file mode 100644
index 0000000..91277d7
--- /dev/null
+++ b/crates/codegraph-bench/fixtures/cfml/Gadget.cfs
@@ -0,0 +1,6 @@
+component extends="Base" {
+ property name="x";
+ public function doThing() {
+ helper();
+ }
+}
diff --git a/crates/codegraph-bench/fixtures/cfml/Widget.cfm b/crates/codegraph-bench/fixtures/cfml/Widget.cfm
new file mode 100644
index 0000000..7655057
--- /dev/null
+++ b/crates/codegraph-bench/fixtures/cfml/Widget.cfm
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/crates/codegraph-bench/tests/equivalence.rs b/crates/codegraph-bench/tests/equivalence.rs
index 2f5346b..e499c82 100644
--- a/crates/codegraph-bench/tests/equivalence.rs
+++ b/crates/codegraph-bench/tests/equivalence.rs
@@ -230,6 +230,31 @@ fn erlang_db_is_self_equivalent_to_erlang_golden() {
assert_equivalent(&erlang_db(), &erlang_golden_dir()).unwrap();
}
+#[test]
+fn generated_golden_matches_committed_cfml_fixture() {
+ // Guards CFML extraction (upstream #1153, scope-B extraction slice): the
+ // `.cfc`/`.cfm`/`.cfs`->Cfml mapping, the dual-grammar dialect switch
+ // (`is_bare_script_cfml`: cfscript for bare-script, cfml tag grammar for
+ // tag files), a bare-script `component`->Class named from the FILE (unnamed
+ // in the grammar) + script-style `extends`->Extends, a tag
+ // ``->Class from `name` attr + ``->Method with
+ // access/returntype + tag `extends`->Extends. Both extends resolve to
+ // `Base.cfc`; `helper()` stays unresolved. The ``-in-tag delegation
+ // + cfquery SQL-body extraction + framework resolvers are DEFERRED.
+ let tempdir = TestDir::new("generated-golden-cfml");
+ write_golden(&cfml_db(), tempdir.path()).unwrap();
+
+ let expected = load_golden(&cfml_golden_dir()).unwrap();
+ let actual = load_golden(tempdir.path()).unwrap();
+
+ diff_canonical(&expected, &actual, None).unwrap();
+}
+
+#[test]
+fn cfml_db_is_self_equivalent_to_cfml_golden() {
+ assert_equivalent(&cfml_db(), &cfml_golden_dir()).unwrap();
+}
+
#[test]
fn tier1_node_drift_is_reported() {
let expected = load_golden(&mini_golden_dir()).unwrap();
@@ -364,6 +389,14 @@ fn erlang_golden_dir() -> PathBuf {
workspace_root().join("reference/golden/erlang")
}
+fn cfml_db() -> PathBuf {
+ workspace_root().join("reference/golden/cfml/colby.db")
+}
+
+fn cfml_golden_dir() -> PathBuf {
+ workspace_root().join("reference/golden/cfml")
+}
+
struct TestDir {
path: PathBuf,
}
diff --git a/crates/codegraph-core/src/types.rs b/crates/codegraph-core/src/types.rs
index d87c509..a03b254 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; 41] = [
+pub const LANGUAGE_STRINGS: [&str; 42] = [
"typescript",
"javascript",
"tsx",
@@ -82,6 +82,7 @@ pub const LANGUAGE_STRINGS: [&str; 41] = [
"nix",
"terraform",
"erlang",
+ "cfml",
"yaml",
"twig",
"xml",
@@ -335,6 +336,8 @@ pub enum Language {
Terraform,
#[serde(rename = "erlang")]
Erlang,
+ #[serde(rename = "cfml")]
+ Cfml,
#[serde(rename = "yaml")]
Yaml,
#[serde(rename = "twig")]
@@ -356,7 +359,7 @@ pub enum Language {
}
impl Language {
- pub const ALL: [Self; 41] = [
+ pub const ALL: [Self; 42] = [
Self::TypeScript,
Self::JavaScript,
Self::Tsx,
@@ -389,6 +392,7 @@ impl Language {
Self::Nix,
Self::Terraform,
Self::Erlang,
+ Self::Cfml,
Self::Yaml,
Self::Twig,
Self::Xml,
@@ -434,6 +438,7 @@ impl Language {
Self::Nix => "nix",
Self::Terraform => "terraform",
Self::Erlang => "erlang",
+ Self::Cfml => "cfml",
Self::Yaml => "yaml",
Self::Twig => "twig",
Self::Xml => "xml",
diff --git a/crates/codegraph-extract/Cargo.toml b/crates/codegraph-extract/Cargo.toml
index 42b3d52..463435f 100644
--- a/crates/codegraph-extract/Cargo.toml
+++ b/crates/codegraph-extract/Cargo.toml
@@ -26,6 +26,7 @@ tracing = { workspace = true }
tree-sitter-arkts = "0.2.0"
tree-sitter-c = "0.24.2"
tree-sitter-c-sharp = "0.23.5"
+tree-sitter-cfml = "0.26.30"
tree-sitter-cpp = "0.23.4"
tree-sitter-css = "0.25.0"
tree-sitter-dart = "0.2.0"
diff --git a/crates/codegraph-extract/src/engine.rs b/crates/codegraph-extract/src/engine.rs
index 5f0bc17..1e834e5 100644
--- a/crates/codegraph-extract/src/engine.rs
+++ b/crates/codegraph-extract/src/engine.rs
@@ -86,6 +86,7 @@ pub fn builtin_language_for_ext(ext: &str) -> Option {
"nix" => Language::Nix,
"tf" | "tfvars" | "tofu" => Language::Terraform,
"erl" | "hrl" => Language::Erlang,
+ "cfc" | "cfm" | "cfs" => Language::Cfml,
"yml" | "yaml" => Language::Yaml,
"twig" => Language::Twig,
"xml" => Language::Xml,
@@ -203,7 +204,15 @@ pub fn extract_source(
};
let mut parser = Parser::new();
- let ts_language = spec.tree_sitter_language();
+ // Run `pre_parse` first so the grammar selection can see the (possibly
+ // rewritten) source. Only CFML overrides `tree_sitter_language_for_source`
+ // to pick between its cfscript / cfml tag grammars per file dialect; every
+ // other spec inherits the default (`tree_sitter_language`), so this is a
+ // behavior-neutral reorder (their `pre_parse` is the identity default, and
+ // the two that override it — C++/embedded — don't override the grammar
+ // hook, so their parse path is byte-identical).
+ let parsed_source = spec.pre_parse(source, file_path);
+ let ts_language = spec.tree_sitter_language_for_source(&parsed_source);
if let Err(error) = parser.set_language(&ts_language) {
return ExtractionResult {
nodes: Vec::new(),
@@ -213,7 +222,6 @@ pub fn extract_source(
duration_ms: start.elapsed().as_millis() as i64,
};
}
- let parsed_source = spec.pre_parse(source, file_path);
let Some(tree) = parser.parse(&parsed_source, None) else {
return ExtractionResult {
nodes: Vec::new(),
@@ -874,7 +882,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(), 41);
+ assert_eq!(Language::ALL.len(), 42);
}
#[test]
@@ -905,6 +913,13 @@ mod tests {
assert_eq!(detect_language("defs.hrl"), Language::Erlang);
}
+ #[test]
+ fn cfml_extensions_map_to_cfml() {
+ assert_eq!(detect_language("Widget.cfc"), Language::Cfml);
+ assert_eq!(detect_language("page.cfm"), Language::Cfml);
+ assert_eq!(detect_language("Gadget.cfs"), Language::Cfml);
+ }
+
#[test]
fn plain_ts_stays_typescript() {
assert_eq!(detect_language("m.ts"), Language::TypeScript);
diff --git a/crates/codegraph-extract/src/lang/cfml.rs b/crates/codegraph-extract/src/lang/cfml.rs
new file mode 100644
index 0000000..4a8250c
--- /dev/null
+++ b/crates/codegraph-extract/src/lang/cfml.rs
@@ -0,0 +1,401 @@
+//! CFML / ColdFusion (`.cfc` / `.cfm` / `.cfs`) `LanguageSpec`.
+//!
+//! Extraction-tier port of `upstream extraction/cfml-extractor.ts` +
+//! `languages/cfscript.ts` (commit `816bacb`, #1153 — the extraction slice
+//! only, scope B). CFML is DUAL-GRAMMAR: the `tree-sitter-cfml` crate bundles a
+//! `cfscript` grammar (modern script style) and a `cfml` tag grammar. A file's
+//! dialect is chosen by a first-token sniff (`is_bare_script_cfml`): a `<`
+//! first token is a tag file, anything else is script. [`CfmlSpec`] wires the
+//! cfscript grammar as the DEFAULT and overrides
+//! [`LanguageSpec::tree_sitter_language_for_source`] to hand back the cfml tag
+//! grammar for tag files, so a `.cfc`/`.cfm`/`.cfs` file is parsed with EXACTLY
+//! one grammar.
+//!
+//! Bare-script files drive the generic type-set dispatch (`component` → Class,
+//! `function_declaration` → function/method, `property_declaration` → Property,
+//! `call_expression` → Calls, `import_statement`/`include_statement` → Import);
+//! the `Language::Cfml`-guarded `visit_cfml_node` walker extension owns the
+//! script `component` (file-name rename + script-style `extends`) and the tag
+//! grammar's `cf_component_open_tag`/`cf_function_tag`.
+//!
+//! DEFERRED (recorded follow-ups, consistent with H1–H5): the
+//! ``-in-tag-body re-parse delegation, the `cfquery` SQL-body
+//! extraction (`LANGUAGE_CFQUERY`), and the CFML framework RESOLVER bridges.
+
+use codegraph_core::types::Language;
+use tree_sitter::{Language as TsLanguage, Node};
+
+use crate::spec::{ImportInfo, LanguageSpec};
+use crate::walker::{child_by_field, node_text};
+
+pub struct CfmlSpec;
+
+pub static CFML_SPEC: CfmlSpec = CfmlSpec;
+
+impl LanguageSpec for CfmlSpec {
+ fn language(&self) -> Language {
+ Language::Cfml
+ }
+
+ fn tree_sitter_language(&self) -> TsLanguage {
+ tree_sitter_cfml::LANGUAGE_CFSCRIPT.into()
+ }
+
+ fn tree_sitter_language_for_source(&self, source: &str) -> TsLanguage {
+ if is_bare_script_cfml(source) {
+ tree_sitter_cfml::LANGUAGE_CFSCRIPT.into()
+ } else {
+ tree_sitter_cfml::LANGUAGE_CFML.into()
+ }
+ }
+
+ fn function_types(&self) -> &'static [&'static str] {
+ &[
+ "function_declaration",
+ "function_expression",
+ "arrow_function",
+ ]
+ }
+
+ fn class_types(&self) -> &'static [&'static str] {
+ // The script `component`/`interface` is handled directly by
+ // `visit_cfml_node` (file-name rename + script-style extends), which
+ // short-circuits this dispatch; declared for spec self-description.
+ &["component"]
+ }
+
+ fn method_types(&self) -> &'static [&'static str] {
+ &["function_declaration", "method_definition"]
+ }
+
+ 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] {
+ &["import_statement", "include_statement"]
+ }
+
+ fn call_types(&self) -> &'static [&'static str] {
+ &["call_expression"]
+ }
+
+ fn variable_types(&self) -> &'static [&'static str] {
+ &["variable_declaration"]
+ }
+
+ fn property_types(&self) -> &'static [&'static str] {
+ &["property_declaration"]
+ }
+
+ fn name_field(&self) -> &'static str {
+ "name"
+ }
+
+ fn body_field(&self) -> &'static str {
+ "body"
+ }
+
+ fn params_field(&self) -> &'static str {
+ "parameters"
+ }
+
+ fn return_field(&self) -> &'static str {
+ ""
+ }
+
+ fn classify_class_node(&self, node: Node<'_>) -> codegraph_core::types::NodeKind {
+ if node.child(0).map(|c| c.kind()) == Some("interface") {
+ codegraph_core::types::NodeKind::Interface
+ } else {
+ codegraph_core::types::NodeKind::Class
+ }
+ }
+
+ fn get_signature(&self, node: Node<'_>, source: &str) -> Option {
+ let params = child_by_field(node, "parameters")?;
+ Some(node_text(params, source))
+ }
+
+ fn get_visibility(&self, node: Node<'_>) -> Option {
+ let access = node
+ .named_children(&mut node.walk())
+ .find(|c| c.kind() == "access_type")?;
+ let keyword = access.child(0).map(|c| c.kind()).unwrap_or("");
+ let visibility = match keyword {
+ "private" => "private",
+ "package" => "internal",
+ "remote" | "public" => "public",
+ _ => return None,
+ };
+ Some(visibility.to_string())
+ }
+
+ fn extract_property_name(&self, node: Node<'_>, source: &str) -> Option {
+ cfml_property_name(node, source)
+ }
+
+ fn extract_import(&self, node: Node<'_>, source: &str) -> Option {
+ let module_name = if node.kind() == "include_statement" {
+ let string = node
+ .named_children(&mut node.walk())
+ .find(|c| c.kind() == "string")?;
+ cfml_unquote_string(string, source)
+ } else {
+ let source_node = child_by_field(node, "source")?;
+ match source_node.kind() {
+ "import_path" => {
+ let parts: Vec = source_node
+ .named_children(&mut source_node.walk())
+ .filter(|c| c.kind() == "identifier")
+ .map(|c| node_text(c, source))
+ .collect();
+ parts.join(".")
+ }
+ "string" => cfml_unquote_string(source_node, source),
+ _ => node_text(source_node, source),
+ }
+ };
+ if module_name.is_empty() {
+ return None;
+ }
+ Some(ImportInfo {
+ module_name,
+ signature: node_text(node, source).trim().chars().take(200).collect(),
+ handled_refs: false,
+ })
+ }
+}
+
+/// First-token dialect sniff. Skips a leading UTF-8 BOM, whitespace, and `//`
+/// and `/* */` comments to the first real token; returns `true` (bare script)
+/// when that token is NOT `<` (tag files open with `<`). Empty / all-trivia
+/// source → `true` (a script no-op). Ports `isBareScriptCfml`
+/// (cfml-extractor.ts).
+pub(crate) fn is_bare_script_cfml(source: &str) -> bool {
+ let bytes = source.as_bytes();
+ let mut i = 0;
+ if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) {
+ i = 3;
+ }
+ while i < bytes.len() {
+ let b = bytes[i];
+ if b.is_ascii_whitespace() {
+ i += 1;
+ continue;
+ }
+ if b == b'/' && i + 1 < bytes.len() {
+ match bytes[i + 1] {
+ b'/' => {
+ i += 2;
+ while i < bytes.len() && bytes[i] != b'\n' {
+ i += 1;
+ }
+ continue;
+ }
+ b'*' => {
+ i += 2;
+ while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
+ i += 1;
+ }
+ i += 2;
+ continue;
+ }
+ _ => {}
+ }
+ }
+ return b != b'<';
+ }
+ true
+}
+
+/// The component name from a file path: the basename with a `.cfc`/`.cfm`/`.cfs`
+/// extension stripped (case-insensitive). Ports `componentNameFromPath`.
+pub(crate) fn cfml_component_name_from_path(file_path: &str) -> String {
+ let base = file_path.rsplit(['/', '\\']).next().unwrap_or(file_path);
+ let lower = base.to_ascii_lowercase();
+ for ext in [".cfc", ".cfm", ".cfs"] {
+ if lower.ends_with(ext) {
+ return base[..base.len() - ext.len()].to_string();
+ }
+ }
+ base.to_string()
+}
+
+/// A tag attribute value by name (case-insensitive), for the tag grammar. Scans
+/// the tag's `cf_attribute` children — directly (`cf_function_tag`) and inside
+/// each `cf_tag_attributes` wrapper (`cf_component_open_tag`) — matching the
+/// `cf_attribute_name` leaf, and reads the value from the
+/// `quoted_cf_attribute_value` / `cf_attribute_value`'s inner `attribute_value`.
+/// Ports `tagAttr`.
+pub(crate) fn cfml_tag_attr(tag: Node<'_>, name: &str, source: &str) -> Option {
+ fn attr_value(attr: Node<'_>, source: &str) -> Option {
+ let value = attr
+ .named_children(&mut attr.walk())
+ .find(|c| matches!(c.kind(), "quoted_cf_attribute_value" | "cf_attribute_value"))?;
+ let inner = value
+ .named_children(&mut value.walk())
+ .find(|c| c.kind() == "attribute_value")
+ .unwrap_or(value);
+ Some(node_text(inner, source))
+ }
+ fn matches_name(attr: Node<'_>, name: &str, source: &str) -> bool {
+ attr.named_children(&mut attr.walk())
+ .find(|c| c.kind() == "cf_attribute_name")
+ .map(|n| node_text(n, source).eq_ignore_ascii_case(name))
+ .unwrap_or(false)
+ }
+ for child in tag.named_children(&mut tag.walk()) {
+ match child.kind() {
+ "cf_attribute" if matches_name(child, name, source) => {
+ return attr_value(child, source);
+ }
+ "cf_tag_attributes" => {
+ for attr in child.named_children(&mut child.walk()) {
+ if attr.kind() == "cf_attribute" && matches_name(attr, name, source) {
+ return attr_value(attr, source);
+ }
+ }
+ }
+ _ => {}
+ }
+ }
+ None
+}
+
+/// A script-style `component_attribute(identifier, string)` value by name
+/// (case-insensitive), for the cfscript grammar — used for script-style
+/// `extends="Base"`. Matches the `identifier` child and returns the `string`
+/// child's value with quotes stripped.
+pub(crate) fn cfml_string_attr_value(node: Node<'_>, name: &str, source: &str) -> Option {
+ for attr in node.named_children(&mut node.walk()) {
+ if attr.kind() != "component_attribute" {
+ continue;
+ }
+ let ident = attr
+ .named_children(&mut attr.walk())
+ .find(|c| c.kind() == "identifier");
+ let Some(ident) = ident else { continue };
+ if !node_text(ident, source).eq_ignore_ascii_case(name) {
+ continue;
+ }
+ if let Some(string) = attr
+ .named_children(&mut attr.walk())
+ .find(|c| c.kind() == "string")
+ {
+ let value = cfml_unquote_string(string, source);
+ if !value.is_empty() {
+ return Some(value);
+ }
+ }
+ }
+ None
+}
+
+/// The name of a `property_declaration`. Tries the optional `name` field (the
+/// typed form `property String x;`) then the attribute form `property name="x";`
+/// whose name lives in a `component_attribute(identifier="name", string)` pair.
+pub(crate) fn cfml_property_name(node: Node<'_>, source: &str) -> Option {
+ if let Some(name_node) = child_by_field(node, "name") {
+ let text = node_text(name_node, source);
+ if !text.is_empty() {
+ return Some(text);
+ }
+ }
+ cfml_string_attr_value(node, "name", source)
+}
+
+/// Strip the surrounding quotes from a cfscript `string` node, reading the
+/// `string_fragment` child when present.
+pub(crate) fn cfml_unquote_string(string: Node<'_>, source: &str) -> String {
+ if let Some(fragment) = string
+ .named_children(&mut string.walk())
+ .find(|c| c.kind() == "string_fragment")
+ {
+ return node_text(fragment, source);
+ }
+ node_text(string, source)
+ .trim()
+ .trim_matches(|c| c == '"' || c == '\'')
+ .to_string()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn parse(lang: TsLanguage, src: &str) -> tree_sitter::Tree {
+ let mut parser = tree_sitter::Parser::new();
+ parser.set_language(&lang).unwrap();
+ parser.parse(src, None).unwrap()
+ }
+
+ fn first_of_kind<'t>(node: Node<'t>, kind: &str) -> Option> {
+ 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 cfml_spec_dialect_switch() {
+ assert!(is_bare_script_cfml("component {}"));
+ assert!(!is_bare_script_cfml(""));
+ assert!(is_bare_script_cfml("\u{FEFF}// x\ncomponent{}"));
+ assert!(is_bare_script_cfml("/* c */ component {}"));
+ assert!(is_bare_script_cfml(""));
+ assert!(is_bare_script_cfml(" \n "));
+ }
+
+ #[test]
+ fn cfml_component_name_from_path_strips_ext() {
+ assert_eq!(cfml_component_name_from_path("a/Gadget.cfs"), "Gadget");
+ assert_eq!(cfml_component_name_from_path("X.CFC"), "X");
+ assert_eq!(cfml_component_name_from_path("dir\\Widget.cfm"), "Widget");
+ }
+
+ #[test]
+ fn cfml_script_component_parses() {
+ let tree = parse(
+ tree_sitter_cfml::LANGUAGE_CFSCRIPT.into(),
+ "component extends=\"Base\" { }",
+ );
+ let component = first_of_kind(tree.root_node(), "component").expect("component");
+ assert_eq!(
+ cfml_string_attr_value(component, "extends", "component extends=\"Base\" { }")
+ .as_deref(),
+ Some("Base")
+ );
+ }
+
+ #[test]
+ fn cfml_tag_component_parses() {
+ let src = "";
+ let tree = parse(tree_sitter_cfml::LANGUAGE_CFML.into(), src);
+ let open = first_of_kind(tree.root_node(), "cf_component_open_tag").expect("open tag");
+ assert_eq!(cfml_tag_attr(open, "name", src).as_deref(), Some("W"));
+ assert_eq!(cfml_tag_attr(open, "extends", src).as_deref(), Some("B"));
+ }
+}
diff --git a/crates/codegraph-extract/src/lang/mod.rs b/crates/codegraph-extract/src/lang/mod.rs
index bccc825..ade65d1 100644
--- a/crates/codegraph-extract/src/lang/mod.rs
+++ b/crates/codegraph-extract/src/lang/mod.rs
@@ -5,6 +5,7 @@
mod arkts;
mod c;
+mod cfml;
mod cpp;
mod csharp;
mod dart;
@@ -38,6 +39,10 @@ use crate::spec::LanguageSpec;
pub use arkts::ARKTS_SPEC;
pub use c::C_SPEC;
+pub use cfml::CFML_SPEC;
+pub(crate) use cfml::{
+ cfml_component_name_from_path, cfml_string_attr_value, cfml_tag_attr, is_bare_script_cfml,
+};
pub use cpp::CPP_SPEC;
pub(crate) use cpp::{ExportMacroClass, detect_export_macro_class};
pub use csharp::CSHARP_SPEC;
@@ -110,6 +115,7 @@ pub fn spec_for_language(language: Language) -> Option<&'static dyn LanguageSpec
Language::Nix => Some(&NIX_SPEC),
Language::Terraform => Some(&TERRAFORM_SPEC),
Language::Erlang => Some(&ERLANG_SPEC),
+ Language::Cfml => Some(&CFML_SPEC),
Language::Gdscript => Some(&GDSCRIPT_SPEC),
_ => None,
}
diff --git a/crates/codegraph-extract/src/spec.rs b/crates/codegraph-extract/src/spec.rs
index 2b87fe0..7a4b6aa 100644
--- a/crates/codegraph-extract/src/spec.rs
+++ b/crates/codegraph-extract/src/spec.rs
@@ -20,6 +20,15 @@ pub trait LanguageSpec: Sync {
fn language(&self) -> Language;
fn tree_sitter_language(&self) -> TsLanguage;
+ /// Select the tree-sitter grammar for a specific file's SOURCE, defaulting
+ /// to [`Self::tree_sitter_language`]. Only the dual-grammar CFML spec
+ /// overrides this (cfscript vs cfml tag grammar, chosen by a first-token
+ /// dialect sniff); every other spec inherits the default so its parse path
+ /// is byte-identical.
+ fn tree_sitter_language_for_source(&self, _source: &str) -> TsLanguage {
+ self.tree_sitter_language()
+ }
+
fn function_types(&self) -> &'static [&'static str];
fn class_types(&self) -> &'static [&'static str];
fn method_types(&self) -> &'static [&'static str];
diff --git a/crates/codegraph-extract/src/walker.rs b/crates/codegraph-extract/src/walker.rs
index 5c39053..2ea0795 100644
--- a/crates/codegraph-extract/src/walker.rs
+++ b/crates/codegraph-extract/src/walker.rs
@@ -49,6 +49,11 @@ pub struct TreeSitterWalker<'a, 'tree> {
/// clause and attaches to the existing node. Empty outside Erlang.
erlang_last_fn_name: Option,
erlang_last_fn_id: Option,
+ /// CFML tag-dialect state: the byte offset up to which a
+ /// `cf_component_open_tag`'s following siblings (the implicit-end-tag
+ /// component body) have already been consumed, so the top-level `program`
+ /// walk skips them instead of re-visiting. 0 outside CFML tag files.
+ cfml_consumed_until: usize,
}
impl<'a, 'tree> TreeSitterWalker<'a, 'tree> {
@@ -72,6 +77,7 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> {
namespace_prefix: Vec::new(),
erlang_last_fn_name: None,
erlang_last_fn_id: None,
+ cfml_consumed_until: 0,
}
}
@@ -239,6 +245,7 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> {
Language::Nix => self.visit_nix_node(node),
Language::Terraform => self.visit_terraform_node(node),
Language::Erlang => self.visit_erlang_node(node),
+ Language::Cfml => self.visit_cfml_node(node),
_ => false,
}
}
@@ -263,6 +270,168 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> {
/// so `-spec f(integer()) -> integer().` mints no bogus `integer` call ref.
/// The `-behaviour`, gen_server, spawn/apply MFA, and `.app` resource-tuple
/// bridges are DEFERRED.
+ /// CFML extraction (upstream `CfmlExtractor`, `cfml-extractor.ts`, #1153 —
+ /// scope-B extraction slice). A first-token sniff (`is_bare_script_cfml`)
+ /// selects the dialect. Bare-script: the cfscript type-set config drives the
+ /// generic dispatch, so this visitor owns only the unnamed script
+ /// `component`/`interface` (file-name rename + script-style `extends`). Tag:
+ /// owns `cf_component_open_tag`/`cf_function_tag`; `cf_script_tag`/
+ /// `cf_query_tag` inner-body delegation is DEFERRED. `Language::Cfml`-guarded.
+ fn visit_cfml_node(&mut self, node: SyntaxNode<'tree>) -> bool {
+ if crate::lang::is_bare_script_cfml(self.source) {
+ match node.kind() {
+ "component" | "interface" => {
+ self.emit_cfml_script_component(node);
+ true
+ }
+ _ => false,
+ }
+ } else {
+ if self.cfml_consumed_until > 0 && node.end_byte() <= self.cfml_consumed_until {
+ return true;
+ }
+ match node.kind() {
+ "cf_component_open_tag" => {
+ self.emit_cfml_tag_component(node);
+ true
+ }
+ "cf_function_tag" => {
+ self.emit_cfml_tag_function(node, false);
+ true
+ }
+ "cf_script_tag" | "cf_query_tag" => true,
+ _ => false,
+ }
+ }
+ }
+
+ /// Ports `extractBareScript`: the unnamed script `component`/`interface` is
+ /// named from the FILE, qualified `{file}::{name}`; script-style `extends`
+ /// becomes an `Extends` ref; the body is walked for the generic dispatch.
+ fn emit_cfml_script_component(&mut self, node: SyntaxNode<'tree>) {
+ let source = self.source;
+ let name = crate::lang::cfml_component_name_from_path(self.file_path);
+ let kind = match self.spec.classify_class_node(node) {
+ NodeKind::Interface => NodeKind::Interface,
+ _ => NodeKind::Class,
+ };
+ let qualified_name = format!("{}::{}", self.file_path, name);
+ let Some(created) = self.create_node(
+ kind,
+ &name,
+ node,
+ NodeExtra {
+ qualified_name: Some(qualified_name),
+ ..NodeExtra::default()
+ },
+ ) else {
+ return;
+ };
+ if let Some(extends) = crate::lang::cfml_string_attr_value(node, "extends", source) {
+ self.push_ref(&created.id, &extends, EdgeKind::Extends, node);
+ }
+ self.node_stack.push(created.id);
+ self.visit_named_children(node);
+ self.node_stack.pop();
+ }
+
+ /// Ports `extractComponent`: Class from the `name` tag-attr else file;
+ /// `extends`/`implements` tag-attr refs; the implicit-end-tag body is the
+ /// open tag's FOLLOWING SIBLINGS, walked to `cf_component_close_tag` with
+ /// each `cf_function_tag` a Method. `cfml_consumed_until` marks the consumed
+ /// range so the top-level walk skips it.
+ fn emit_cfml_tag_component(&mut self, node: SyntaxNode<'tree>) {
+ let source = self.source;
+ let name = crate::lang::cfml_tag_attr(node, "name", source)
+ .unwrap_or_else(|| crate::lang::cfml_component_name_from_path(self.file_path));
+ let qualified_name = format!("{}::{}", self.file_path, name);
+ let extends = crate::lang::cfml_tag_attr(node, "extends", source);
+ let implements = crate::lang::cfml_tag_attr(node, "implements", source);
+ let Some(created) = self.create_node(
+ NodeKind::Class,
+ &name,
+ node,
+ NodeExtra {
+ is_exported: true,
+ qualified_name: Some(qualified_name),
+ ..NodeExtra::default()
+ },
+ ) else {
+ return;
+ };
+ if let Some(extends) = extends {
+ let extends = extends.trim();
+ if !extends.is_empty() {
+ self.push_ref(&created.id, extends, EdgeKind::Extends, node);
+ }
+ }
+ if let Some(implements) = implements {
+ for iface in implements.split(',') {
+ let iface = iface.trim();
+ if !iface.is_empty() {
+ self.push_ref(&created.id, iface, EdgeKind::Implements, node);
+ }
+ }
+ }
+ self.node_stack.push(created.id);
+ let mut consumed_end = node.end_byte();
+ let mut sibling = node.next_named_sibling();
+ while let Some(current) = sibling {
+ if current.kind() == "cf_component_close_tag" {
+ consumed_end = current.end_byte();
+ break;
+ }
+ if current.kind() == "cf_function_tag" {
+ self.emit_cfml_tag_function(current, true);
+ }
+ consumed_end = current.end_byte();
+ sibling = current.next_named_sibling();
+ }
+ self.node_stack.pop();
+ self.cfml_consumed_until = consumed_end;
+ }
+
+ /// Ports `extractFunctionTag`: Method (in a component) or Function
+ /// (top-level) from the `name` tag-attr (skip if absent), visibility from
+ /// `access`, return type from `returntype`; the body is walked for calls.
+ fn emit_cfml_tag_function(&mut self, node: SyntaxNode<'tree>, is_method: bool) {
+ let source = self.source;
+ let Some(name) = crate::lang::cfml_tag_attr(node, "name", source) else {
+ return;
+ };
+ let visibility = crate::lang::cfml_tag_attr(node, "access", source).and_then(|access| {
+ match access.trim().to_ascii_lowercase().as_str() {
+ "private" => Some("private".to_string()),
+ "package" => Some("internal".to_string()),
+ "remote" | "public" => Some("public".to_string()),
+ _ => None,
+ }
+ });
+ let return_type = crate::lang::cfml_tag_attr(node, "returntype", source)
+ .map(|rt| rt.trim().to_string())
+ .filter(|rt| !rt.is_empty());
+ let kind = if is_method {
+ NodeKind::Method
+ } else {
+ NodeKind::Function
+ };
+ let Some(created) = self.create_node(
+ kind,
+ &name,
+ node,
+ NodeExtra {
+ visibility,
+ return_type,
+ ..NodeExtra::default()
+ },
+ ) else {
+ return;
+ };
+ self.node_stack.push(created.id);
+ self.visit_named_children(node);
+ self.node_stack.pop();
+ }
+
fn visit_erlang_node(&mut self, node: SyntaxNode<'tree>) -> bool {
match node.kind() {
"fun_decl" => {
@@ -6399,4 +6568,102 @@ g() ->\n\
assert!(has_node(&nodes, NodeKind::Constant, "X"));
assert!(has_node(&nodes, NodeKind::Namespace, "m"));
}
+
+ // ---- CFML (dual-grammar: cfscript for bare-script, cfml for tag) ----
+
+ #[test]
+ fn cfml_script_component_from_file_name() {
+ let (nodes, _refs) = run(
+ "Gadget.cfs",
+ "component { public function doThing(){} }",
+ Language::Cfml,
+ );
+ assert!(has_node(&nodes, NodeKind::Class, "Gadget"));
+ assert!(!has_node(&nodes, NodeKind::Class, ""));
+ assert!(
+ has_node(&nodes, NodeKind::Method, "doThing")
+ || has_node(&nodes, NodeKind::Function, "doThing")
+ );
+ }
+
+ #[test]
+ fn cfml_tag_component_is_class() {
+ let (nodes, refs) = run(
+ "Widget.cfm",
+ "",
+ Language::Cfml,
+ );
+ assert!(has_node(&nodes, NodeKind::Class, "Widget"));
+ assert!(has_ref(&refs, EdgeKind::Extends, "Base"));
+ }
+
+ #[test]
+ fn cfml_tag_function_is_method() {
+ let (nodes, _refs) = run(
+ "Widget.cfm",
+ "",
+ Language::Cfml,
+ );
+ assert!(has_node(&nodes, NodeKind::Method, "doThing"));
+ }
+
+ #[test]
+ fn cfml_script_extends_is_extends() {
+ let (_nodes, refs) = run(
+ "Gadget.cfs",
+ "component extends=\"Base\" { }",
+ Language::Cfml,
+ );
+ assert!(has_ref(&refs, EdgeKind::Extends, "Base"));
+ }
+
+ #[test]
+ fn cfml_script_property_is_property() {
+ let (nodes, _refs) = run(
+ "Gadget.cfs",
+ "component { property name=\"x\"; }",
+ Language::Cfml,
+ );
+ assert!(
+ has_node(&nodes, NodeKind::Property, "x") || has_node(&nodes, NodeKind::Field, "x")
+ );
+ }
+
+ #[test]
+ fn cfml_call_is_calls() {
+ let (_nodes, refs) = run(
+ "Gadget.cfs",
+ "component { public function doThing(){ helper(); } }",
+ Language::Cfml,
+ );
+ assert!(has_ref(&refs, EdgeKind::Calls, "helper"));
+ }
+
+ #[test]
+ fn cfml_implements_splits() {
+ let (_nodes, refs) = run(
+ "Widget.cfm",
+ "",
+ Language::Cfml,
+ );
+ assert!(has_ref(&refs, EdgeKind::Implements, "IFoo"));
+ assert!(has_ref(&refs, EdgeKind::Implements, "IBar"));
+ }
+
+ #[test]
+ fn cfml_import_is_import() {
+ let (nodes, refs) = run(
+ "Gadget.cfs",
+ "component { import com.foo.Bar; include \"sub.cfm\"; }",
+ Language::Cfml,
+ );
+ assert!(
+ has_node(&nodes, NodeKind::Import, "com.foo.Bar")
+ || has_ref(&refs, EdgeKind::Imports, "com.foo.Bar")
+ );
+ assert!(
+ has_node(&nodes, NodeKind::Import, "sub.cfm")
+ || has_ref(&refs, EdgeKind::Imports, "sub.cfm")
+ );
+ }
}
diff --git a/crates/codegraph-store/src/queries.rs b/crates/codegraph-store/src/queries.rs
index cfeb683..f92d220 100644
--- a/crates/codegraph-store/src/queries.rs
+++ b/crates/codegraph-store/src/queries.rs
@@ -1528,6 +1528,7 @@ fn parse_language(value: String) -> rusqlite::Result {
"nix" => Language::Nix,
"terraform" => Language::Terraform,
"erlang" => Language::Erlang,
+ "cfml" => Language::Cfml,
"yaml" => Language::Yaml,
"twig" => Language::Twig,
"xml" => Language::Xml,
@@ -2217,6 +2218,11 @@ mod tests {
);
}
+ #[test]
+ fn parse_language_accepts_cfml() {
+ assert_eq!(parse_language("cfml".to_string()).unwrap(), Language::Cfml);
+ }
+
#[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 06297b8..91d14d0 100644
--- a/docs/equivalence.md
+++ b/docs/equivalence.md
@@ -616,6 +616,53 @@ The `generated_golden_matches_committed_erlang_fixture` and
`erlang_db_is_self_equivalent_to_erlang_golden` tests in
`crates/codegraph-bench/tests/equivalence.rs` enforce byte-stability.
+### CFML fixture
+
+A twelfth golden fixture, `reference/golden/cfml/`, guards CFML / ColdFusion
+extraction (upstream #1153 / `816bacb`, the scope-B extraction slice only). CFML
+is a **new `Language::Cfml` variant** backed by the **dual-grammar
+`tree-sitter-cfml`** crate (`.cfc`/`.cfm`/`.cfs` → `Language::Cfml`). A file's
+dialect is picked by a first-token sniff (`is_bare_script_cfml`): script files
+parse with the bundled `cfscript` grammar and drive the generic type-set
+dispatch; tag files parse with the `cfml` tag grammar and are handled by the
+`Language::Cfml`-guarded `visit_cfml_node` walker extension. The corpus
+(`crates/codegraph-bench/fixtures/cfml/`) has three deterministic files — a
+script `Base.cfc`, a tag `Widget.cfm`, and a bare-script `Gadget.cfs`. What it
+guards:
+
+- all three files with `"language": "cfml"`;
+- `Base.cfc` (script) → `NodeKind::Class` `Base` (named from the FILE — the
+ cfscript `component` is unnamed) + `NodeKind::Function` `ping`;
+- `Widget.cfm` (tag) → `NodeKind::Class` `Widget` (from the `name` tag-attr) +
+ `NodeKind::Method` `doThing` (access `public`, returntype `void`), and a tag
+ `extends="Base"` → `Extends`;
+- `Gadget.cfs` (bare script) → `NodeKind::Class` `Gadget` (from the FILE) +
+ `NodeKind::Property` `x` + `NodeKind::Function` `doThing`, and a script-style
+ `extends="Base"` (`component_attribute`) → `Extends`;
+- both `extends Base` refs RESOLVE to the `Base.cfc` component (edges);
+ `Gadget.doThing`'s `helper()` call → an unresolved `helper` ref.
+
+The ``-in-tag-body re-parse delegation, the `cfquery` SQL-body
+extraction (`LANGUAGE_CFQUERY`), and the CFML framework RESOLVER bridges
+(FW/1 / ColdBox / CFWheels, dotted/relative inheritance, receiver-type inference)
+are all **DEFERRED**. Adding the variant is byte-neutral for `colby.schema.sql`
+(language is a stored TEXT value, not DDL) and for the eleven existing goldens
+(none holds a `.cfc`/`.cfm`/`.cfs` file).
+
+Regenerate reproducibly (identical recipe, substituting `cfml`):
+
+```bash
+rm -rf /tmp/cg-fixture-cfml && cp -r crates/codegraph-bench/fixtures/cfml /tmp/cg-fixture-cfml
+cargo build --release -p codegraph-rs
+CODEGRAPH_NO_DAEMON=1 CODEGRAPH_NO_WATCH=1 ./target/release/codegraph init /tmp/cg-fixture-cfml
+cp /tmp/cg-fixture-cfml/.codegraph/codegraph.db reference/golden/cfml/colby.db
+cargo run -p codegraph-bench --bin bench -- --gen-golden reference/golden/cfml/colby.db reference/golden/cfml
+```
+
+The `generated_golden_matches_committed_cfml_fixture` and
+`cfml_db_is_self_equivalent_to_cfml_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 9d58ea5..61b17cb 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.
-**37 concrete languages** are supported, grouped into three extraction tiers based on what
+**38 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,7 +14,7 @@ the extractor produces.
---
-## Tier 1 — Full symbol extraction (28 languages)
+## Tier 1 — Full symbol extraction (29 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
@@ -50,6 +50,7 @@ level.
| Erlang | `.erl` `.hrl` | Full tree-sitter | `tree-sitter-erlang` grammar (WhatsApp/ELP); `-module`→namespace (functions qualify as `m::f`), multi-clause `fun_decl`→one function (clause-merge), `-record`→struct+fields, `-define`→constant, `-type`/`-opaque`→type alias, `-include`→import; local/remote `mod:f`→calls, `fun f/1`/record usages→references; `-spec`/`-callback` type bodies mint no bogus refs; behaviour/gen_server/spawn MFA resolver bridges 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 |
+| CFML | `.cfc` `.cfm` `.cfs` | Full tree-sitter | ColdFusion; dual-grammar `tree-sitter-cfml` (cfscript + cfml tag), dialect chosen by first-token sniff. Bare-script `component`→class (name from file) + `extends`→extends; tag ``→class (name attr) + ``→method + `extends`/`implements`→refs. ``-in-tag body delegation and cfquery SQL-body extraction deferred |
---
diff --git a/reference/golden/cfml/colby.db b/reference/golden/cfml/colby.db
new file mode 100644
index 0000000..c7072ae
Binary files /dev/null and b/reference/golden/cfml/colby.db differ
diff --git a/reference/golden/cfml/edges.json b/reference/golden/cfml/edges.json
new file mode 100644
index 0000000..10a64e0
--- /dev/null
+++ b/reference/golden/cfml/edges.json
@@ -0,0 +1,89 @@
+[
+ {
+ "col": 0,
+ "kind": "extends",
+ "line": 1,
+ "metadata": {
+ "confidence": 0.9,
+ "resolvedBy": "exact-match"
+ },
+ "provenance": null,
+ "source": "class:93c39257dd3c90eb320fcd02f5ddbc01",
+ "target": "class:7b249660730529f301c3db8163759969"
+ },
+ {
+ "col": 0,
+ "kind": "extends",
+ "line": 1,
+ "metadata": {
+ "confidence": 0.9,
+ "resolvedBy": "exact-match"
+ },
+ "provenance": null,
+ "source": "class:b60688d316d54e324299d22677269906",
+ "target": "class:7b249660730529f301c3db8163759969"
+ },
+ {
+ "col": null,
+ "kind": "contains",
+ "line": null,
+ "metadata": null,
+ "provenance": null,
+ "source": "class:7b249660730529f301c3db8163759969",
+ "target": "method:54e7b7c2fdf0c76f0984142661a54479"
+ },
+ {
+ "col": null,
+ "kind": "contains",
+ "line": null,
+ "metadata": null,
+ "provenance": null,
+ "source": "class:93c39257dd3c90eb320fcd02f5ddbc01",
+ "target": "method:c432989f1e6e8a738827b8c560f31059"
+ },
+ {
+ "col": null,
+ "kind": "contains",
+ "line": null,
+ "metadata": null,
+ "provenance": null,
+ "source": "class:b60688d316d54e324299d22677269906",
+ "target": "method:5f4b8da59fedd244e30807cbc03a66b3"
+ },
+ {
+ "col": null,
+ "kind": "contains",
+ "line": null,
+ "metadata": null,
+ "provenance": null,
+ "source": "class:b60688d316d54e324299d22677269906",
+ "target": "property:0625c36d63c08952c8612ee18a665fe2"
+ },
+ {
+ "col": null,
+ "kind": "contains",
+ "line": null,
+ "metadata": null,
+ "provenance": null,
+ "source": "file:Base.cfc",
+ "target": "class:7b249660730529f301c3db8163759969"
+ },
+ {
+ "col": null,
+ "kind": "contains",
+ "line": null,
+ "metadata": null,
+ "provenance": null,
+ "source": "file:Gadget.cfs",
+ "target": "class:b60688d316d54e324299d22677269906"
+ },
+ {
+ "col": null,
+ "kind": "contains",
+ "line": null,
+ "metadata": null,
+ "provenance": null,
+ "source": "file:Widget.cfm",
+ "target": "class:93c39257dd3c90eb320fcd02f5ddbc01"
+ }
+]
diff --git a/reference/golden/cfml/files.json b/reference/golden/cfml/files.json
new file mode 100644
index 0000000..b27d561
--- /dev/null
+++ b/reference/golden/cfml/files.json
@@ -0,0 +1,26 @@
+[
+ {
+ "content_hash": "1def57c4a549fb3f0100b4c1a3da65eb5d88d7048a030d66004ce46d157b1aa2",
+ "errors": null,
+ "language": "cfml",
+ "node_count": 3,
+ "path": "Base.cfc",
+ "size": 62
+ },
+ {
+ "content_hash": "9fe3c477ccee5efc741c301c391bf45a9b153bf9e2c02593f310d452280699c2",
+ "errors": null,
+ "language": "cfml",
+ "node_count": 4,
+ "path": "Gadget.cfs",
+ "size": 98
+ },
+ {
+ "content_hash": "c618667f944b633d15c80bf1e274aac80504af2fb2a82266f54ec5c819f76d6e",
+ "errors": null,
+ "language": "cfml",
+ "node_count": 3,
+ "path": "Widget.cfm",
+ "size": 156
+ }
+]
diff --git a/reference/golden/cfml/nodes.json b/reference/golden/cfml/nodes.json
new file mode 100644
index 0000000..b8f0a99
--- /dev/null
+++ b/reference/golden/cfml/nodes.json
@@ -0,0 +1,222 @@
+[
+ {
+ "decorators": null,
+ "docstring": null,
+ "end_column": 1,
+ "end_line": 5,
+ "file_path": "Base.cfc",
+ "id": "class:7b249660730529f301c3db8163759969",
+ "is_abstract": 0,
+ "is_async": 0,
+ "is_exported": 0,
+ "is_static": 0,
+ "kind": "class",
+ "language": "cfml",
+ "name": "Base",
+ "qualified_name": "Base.cfc::Base",
+ "return_type": null,
+ "signature": null,
+ "start_column": 0,
+ "start_line": 1,
+ "type_parameters": null,
+ "visibility": null
+ },
+ {
+ "decorators": null,
+ "docstring": null,
+ "end_column": 42,
+ "end_line": 1,
+ "file_path": "Widget.cfm",
+ "id": "class:93c39257dd3c90eb320fcd02f5ddbc01",
+ "is_abstract": 0,
+ "is_async": 0,
+ "is_exported": 1,
+ "is_static": 0,
+ "kind": "class",
+ "language": "cfml",
+ "name": "Widget",
+ "qualified_name": "Widget.cfm::Widget",
+ "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": 6,
+ "file_path": "Gadget.cfs",
+ "id": "class:b60688d316d54e324299d22677269906",
+ "is_abstract": 0,
+ "is_async": 0,
+ "is_exported": 0,
+ "is_static": 0,
+ "kind": "class",
+ "language": "cfml",
+ "name": "Gadget",
+ "qualified_name": "Gadget.cfs::Gadget",
+ "return_type": null,
+ "signature": null,
+ "start_column": 0,
+ "start_line": 1,
+ "type_parameters": null,
+ "visibility": null
+ },
+ {
+ "decorators": null,
+ "docstring": null,
+ "end_column": 0,
+ "end_line": 6,
+ "file_path": "Base.cfc",
+ "id": "file:Base.cfc",
+ "is_abstract": 0,
+ "is_async": 0,
+ "is_exported": 0,
+ "is_static": 0,
+ "kind": "file",
+ "language": "cfml",
+ "name": "Base.cfc",
+ "qualified_name": "Base.cfc",
+ "return_type": null,
+ "signature": null,
+ "start_column": 0,
+ "start_line": 1,
+ "type_parameters": null,
+ "visibility": null
+ },
+ {
+ "decorators": null,
+ "docstring": null,
+ "end_column": 0,
+ "end_line": 7,
+ "file_path": "Gadget.cfs",
+ "id": "file:Gadget.cfs",
+ "is_abstract": 0,
+ "is_async": 0,
+ "is_exported": 0,
+ "is_static": 0,
+ "kind": "file",
+ "language": "cfml",
+ "name": "Gadget.cfs",
+ "qualified_name": "Gadget.cfs",
+ "return_type": null,
+ "signature": null,
+ "start_column": 0,
+ "start_line": 1,
+ "type_parameters": null,
+ "visibility": null
+ },
+ {
+ "decorators": null,
+ "docstring": null,
+ "end_column": 0,
+ "end_line": 6,
+ "file_path": "Widget.cfm",
+ "id": "file:Widget.cfm",
+ "is_abstract": 0,
+ "is_async": 0,
+ "is_exported": 0,
+ "is_static": 0,
+ "kind": "file",
+ "language": "cfml",
+ "name": "Widget.cfm",
+ "qualified_name": "Widget.cfm",
+ "return_type": null,
+ "signature": null,
+ "start_column": 0,
+ "start_line": 1,
+ "type_parameters": null,
+ "visibility": null
+ },
+ {
+ "decorators": null,
+ "docstring": null,
+ "end_column": 3,
+ "end_line": 4,
+ "file_path": "Base.cfc",
+ "id": "method:54e7b7c2fdf0c76f0984142661a54479",
+ "is_abstract": 0,
+ "is_async": 0,
+ "is_exported": 0,
+ "is_static": 0,
+ "kind": "method",
+ "language": "cfml",
+ "name": "ping",
+ "qualified_name": "Base::ping",
+ "return_type": null,
+ "signature": "()",
+ "start_column": 2,
+ "start_line": 2,
+ "type_parameters": null,
+ "visibility": "public"
+ },
+ {
+ "decorators": null,
+ "docstring": null,
+ "end_column": 3,
+ "end_line": 5,
+ "file_path": "Gadget.cfs",
+ "id": "method:5f4b8da59fedd244e30807cbc03a66b3",
+ "is_abstract": 0,
+ "is_async": 0,
+ "is_exported": 0,
+ "is_static": 0,
+ "kind": "method",
+ "language": "cfml",
+ "name": "doThing",
+ "qualified_name": "Gadget::doThing",
+ "return_type": null,
+ "signature": "()",
+ "start_column": 2,
+ "start_line": 3,
+ "type_parameters": null,
+ "visibility": "public"
+ },
+ {
+ "decorators": null,
+ "docstring": null,
+ "end_column": 15,
+ "end_line": 4,
+ "file_path": "Widget.cfm",
+ "id": "method:c432989f1e6e8a738827b8c560f31059",
+ "is_abstract": 0,
+ "is_async": 0,
+ "is_exported": 0,
+ "is_static": 0,
+ "kind": "method",
+ "language": "cfml",
+ "name": "doThing",
+ "qualified_name": "Widget::doThing",
+ "return_type": "void",
+ "signature": null,
+ "start_column": 2,
+ "start_line": 2,
+ "type_parameters": null,
+ "visibility": "public"
+ },
+ {
+ "decorators": null,
+ "docstring": null,
+ "end_column": 20,
+ "end_line": 2,
+ "file_path": "Gadget.cfs",
+ "id": "property:0625c36d63c08952c8612ee18a665fe2",
+ "is_abstract": 0,
+ "is_async": 0,
+ "is_exported": 0,
+ "is_static": 0,
+ "kind": "property",
+ "language": "cfml",
+ "name": "x",
+ "qualified_name": "Gadget::x",
+ "return_type": null,
+ "signature": "name=\"x\" x",
+ "start_column": 2,
+ "start_line": 2,
+ "type_parameters": null,
+ "visibility": null
+ }
+]
diff --git a/reference/golden/cfml/refs.json b/reference/golden/cfml/refs.json
new file mode 100644
index 0000000..cf9bc3f
--- /dev/null
+++ b/reference/golden/cfml/refs.json
@@ -0,0 +1,12 @@
+[
+ {
+ "candidates": null,
+ "col": 4,
+ "file_path": "Gadget.cfs",
+ "from_node_id": "method:5f4b8da59fedd244e30807cbc03a66b3",
+ "language": "cfml",
+ "line": 4,
+ "reference_kind": "calls",
+ "reference_name": "helper"
+ }
+]
diff --git a/reference/golden/cfml/schema.sql b/reference/golden/cfml/schema.sql
new file mode 100644
index 0000000..d36218f
--- /dev/null
+++ b/reference/golden/cfml/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);