diff --git a/crates/codegraph-core/src/graph/classifiers/roles.rs b/crates/codegraph-core/src/graph/classifiers/roles.rs index 06ae0c824..cd9cb3ddc 100644 --- a/crates/codegraph-core/src/graph/classifiers/roles.rs +++ b/crates/codegraph-core/src/graph/classifiers/roles.rs @@ -217,6 +217,16 @@ fn classify_node( median_fan_in: f64, median_fan_out: f64, ) -> &'static str { + // Declarative-only language (#2385) — never subject to call-graph + // dead-code detection; there is no call graph for it by design (e.g. + // Terraform/HCL — everything is a resource/module/data/variable/output + // block, never invoked or invoking anything). Distinct from a language + // where call resolution is merely not implemented yet. + let declarative_exts = [".tf", ".hcl"]; + if declarative_exts.iter().any(|ext| file.ends_with(ext)) { + return "leaf"; + } + // Interface/type members (#1723) — never subject to call-graph dead-code // detection, regardless of fan-in/fan-out/export status. if is_type_member { @@ -1796,6 +1806,23 @@ mod tests { assert_eq!(role_of(conn, 1).as_deref(), Some("dead-unresolved")); } + // ── Declarative-only language carve-out (#2385) ───────────────────── + + #[test] + fn hcl_resource_with_zero_fan_in_is_leaf_not_dead() { + let db = setup_db(); + let conn = db.conn().expect("connection should still be open"); + + // A Terraform resource with no incoming call edges — HCL never + // produces call edges by design, so fan_in == 0 carries no + // dead-code signal here, unlike a real function/method. + insert_node(conn, 1, "aws_kms_key.state", "resource", "main.tf", 0); + + do_classify_full(conn).expect("do_classify_full should succeed"); + + assert_eq!(role_of(conn, 1).as_deref(), Some("leaf")); + } + #[test] fn does_not_downgrade_when_reachable_via_a_real_call_chain() { let db = setup_db(); diff --git a/src/graph/classifiers/roles.ts b/src/graph/classifiers/roles.ts index 70a7d70c6..b9e447ea7 100644 --- a/src/graph/classifiers/roles.ts +++ b/src/graph/classifiers/roles.ts @@ -30,6 +30,10 @@ * never gain inbound call edges by construction, so call-graph reachability * doesn't apply to them either (#1723). * + * Every node whose file belongs to a declarative-only language (currently + * just Terraform/HCL) is likewise classified `leaf` unconditionally, via + * `isDeclarativeLanguageNode` — see its doc comment (#2385). + * * `entry` requires `kind IN ('function', 'method')` (plus the framework-prefix/ * Commander-dispatch shortcuts, which are already kind-appropriate by * construction). An exported interface/type/constant/class with zero fan-in is @@ -72,6 +76,26 @@ const TYPE_DEF_KINDS = new Set(['struct', 'enum', 'trait', 'type', 'interface', const FFI_EXTENSIONS = new Set(['.rs', '.c', '.cpp', '.h', '.go', '.java', '.cs']); +/** + * Extensions of declarative-only languages that have no functions, classes, + * or call graph by design (e.g. Terraform/HCL — everything is a + * resource/module/data/variable/output block; nothing is ever invoked or + * invokes anything). A `fanIn === 0` reading for these carries zero + * dead-code signal, unlike a language where call resolution is merely not + * implemented yet (see the resolution-benchmark's 0.0/0.0 thresholds for + * e.g. bash/ruby/lua, which DO have real dead code a future resolver could + * find). Emitting `dead-*` anyway invited destructive action on live + * infrastructure (#2385). + */ +const DECLARATIVE_EXTENSIONS = new Set(['.tf', '.hcl']); + +/** True when `node.file`'s extension belongs to a declarative-only language (#2385). */ +function isDeclarativeLanguageNode(node: { file?: string }): boolean { + if (!node.file) return false; + const dotIdx = node.file.lastIndexOf('.'); + return dotIdx !== -1 && DECLARATIVE_EXTENSIONS.has(node.file.slice(dotIdx)); +} + /** Path patterns indicating framework-dispatched entry points. */ const ENTRY_PATH_PATTERNS: readonly RegExp[] = [ /cli[/\\]commands[/\\]/, @@ -345,6 +369,10 @@ function classifyNodeRole( medFanOut: number, typeDefNamesByFile: Map>, ): Role { + // Declarative-only language (#2385) — never subject to call-graph dead-code + // detection; there is no call graph for it by design. + if (isDeclarativeLanguageNode(node)) return 'leaf'; + // Interface/type members (#1723) — never subject to call-graph dead-code // detection, regardless of fan-in/fan-out/export status. if (isTypeDeclarationMember(node, typeDefNamesByFile)) return 'leaf'; diff --git a/tests/graph/classifiers/roles.test.ts b/tests/graph/classifiers/roles.test.ts index 2a71afc36..f8e5058d9 100644 --- a/tests/graph/classifiers/roles.test.ts +++ b/tests/graph/classifiers/roles.test.ts @@ -213,6 +213,34 @@ describe('classifyRoles', () => { expect(roles.get('1')).toBe('dead-ffi'); }); + it('classifies HCL resources as leaf, never dead-* (#2385)', () => { + // Terraform never produces call edges by design — a fanIn === 0 reading + // carries zero dead-code signal here, unlike a real function/method. + const nodes = [ + { + id: '1', + name: 'aws_kms_key.state', + kind: 'resource', + file: 'main.tf', + fanIn: 0, + fanOut: 0, + isExported: false, + }, + { + id: '2', + name: 'bucket_name', + kind: 'output', + file: 'outputs.hcl', + fanIn: 0, + fanOut: 0, + isExported: true, + }, + ]; + const roles = classifyRoles(nodes); + expect(roles.get('1')).toBe('leaf'); + expect(roles.get('2')).toBe('leaf'); + }); + it('classifies execute/validate as entry (not dead-entry) in CLI command files (#1585)', () => { // Commander.js dispatch methods (execute, validate) in cli/commands/ are // confirmed entry points — promoted directly to `entry` so they don't