diff --git a/crates/codegraph-core/src/extractors/lua.rs b/crates/codegraph-core/src/extractors/lua.rs index 5629d09e..24b5812d 100644 --- a/crates/codegraph-core/src/extractors/lua.rs +++ b/crates/codegraph-core/src/extractors/lua.rs @@ -5,6 +5,21 @@ use crate::types::*; use super::helpers::*; use super::SymbolExtractor; +/// Lua base-library global function names and standard-library module +/// tables. Mirrors `LUA_BUILTIN_GLOBALS` in `src/extractors/lua.ts` — see +/// `handle_lua_assignment_statement` (this file) and that file's +/// `handleLuaAssignmentStatement` for the full rationale (issue #1776). +const LUA_BUILTIN_GLOBALS: &[&str] = &[ + "assert", "collectgarbage", "dofile", "error", "getfenv", "getmetatable", + "ipairs", "load", "loadfile", "loadstring", "module", "next", "pairs", + "pcall", "print", "rawequal", "rawget", "rawlen", "rawset", "require", + "select", "setfenv", "setmetatable", "tonumber", "tostring", "type", + "unpack", "xpcall", + // Standard-library module tables — wholesale replacement (e.g. sandboxing) + // is the same "escapes local scope" shape as a single builtin function. + "string", "table", "math", "io", "os", "coroutine", "debug", "utf8", "bit32", +]; + pub struct LuaExtractor; impl SymbolExtractor for LuaExtractor { @@ -20,6 +35,7 @@ fn match_lua_node(node: &Node, source: &[u8], symbols: &mut FileSymbols, _depth: match node.kind() { "function_declaration" => handle_lua_function_decl(node, source, symbols), "function_call" => handle_lua_function_call(node, source, symbols), + "assignment_statement" => handle_lua_assignment_statement(node, source, symbols), _ => {} } } @@ -88,6 +104,52 @@ fn extract_lua_params(func_node: &Node, source: &[u8]) -> Vec { params } +/// Detect ` = ` assignments — a locally declared +/// function bound to a Lua global/builtin identifier (e.g. +/// `require = traced_require`), the monkey-patch pattern from issue #1776. +/// Mirrors `handleLuaAssignmentStatement` in `src/extractors/lua.ts` — see +/// that function's doc comment for the full rationale. +/// +/// Emits a dynamic `value-ref` call for the RHS identifier, restricted to +/// plain `identifier = identifier` pairs where the LHS matches +/// `LUA_BUILTIN_GLOBALS`. `value-ref` is resolved downstream +/// (build_edges.rs) against function/method-kind targets only, so a +/// builtin reassigned to a non-function value is silently dropped rather +/// than fabricating a nonsensical edge. +/// +/// Multi-assignment (`a, b = f, g`) is handled positionally: each side is +/// indexed independently by position (not pre-filtered to identifiers +/// first), so mixed variable kinds (`t.b, a = f, g`) do not shift the +/// pairing. +fn handle_lua_assignment_statement(node: &Node, source: &[u8], symbols: &mut FileSymbols) { + let Some(variable_list) = find_child(node, "variable_list") else { return }; + let Some(expression_list) = find_child(node, "expression_list") else { return }; + + let pair_count = variable_list + .named_child_count() + .min(expression_list.named_child_count()); + + for i in 0..pair_count { + let Some(lhs) = variable_list.named_child(i) else { continue }; + let Some(rhs) = expression_list.named_child(i) else { continue }; + if lhs.kind() != "identifier" || rhs.kind() != "identifier" { + continue; + } + let lhs_text = node_text(&lhs, source); + let rhs_text = node_text(&rhs, source); + if !LUA_BUILTIN_GLOBALS.contains(&lhs_text) || LUA_BUILTIN_GLOBALS.contains(&rhs_text) { + continue; + } + symbols.calls.push(Call { + name: rhs_text.to_string(), + line: start_line(&rhs), + dynamic: Some(true), + dynamic_kind: Some("value-ref".to_string()), + ..Default::default() + }); + } +} + fn handle_lua_function_call(node: &Node, source: &[u8], symbols: &mut FileSymbols) { let name_node = match node.child_by_field_name("name") { Some(n) => n, @@ -199,3 +261,102 @@ fn handle_lua_function_call(node: &Node, source: &[u8], symbols: &mut FileSymbol } } } + +#[cfg(test)] +mod tests { + use super::*; + use tree_sitter::Parser; + + fn parse_lua(code: &str) -> FileSymbols { + let mut parser = Parser::new(); + parser + .set_language(&tree_sitter_lua::LANGUAGE.into()) + .unwrap(); + let tree = parser.parse(code.as_bytes(), None).unwrap(); + LuaExtractor.extract(&tree, code.as_bytes(), "test.lua") + } + + // ── #1776: builtin/global reassignment value-ref extraction ───────────── + + #[test] + fn extracts_value_ref_call_for_function_assigned_to_builtin_global() { + let s = parse_lua( + "local function traced_require(modname)\n return modname\nend\nrequire = traced_require", + ); + let value_refs: Vec<_> = s + .calls + .iter() + .filter(|c| c.dynamic_kind.as_deref() == Some("value-ref")) + .collect(); + assert!(value_refs.iter().any(|c| c.name == "traced_require")); + assert!(value_refs.iter().all(|c| c.dynamic == Some(true))); + } + + #[test] + fn extracts_value_ref_call_for_local_shadow_of_builtin() { + let s = parse_lua( + "local function traced_require(modname)\n return modname\nend\nlocal require = traced_require", + ); + assert!(s + .calls + .iter() + .any(|c| c.name == "traced_require" && c.dynamic_kind.as_deref() == Some("value-ref"))); + } + + #[test] + fn no_value_ref_call_when_lhs_is_not_a_recognized_builtin() { + let s = parse_lua("local function helper() end\nmyCustomGlobal = helper"); + assert!(s.calls.iter().all(|c| c.dynamic_kind.as_deref() != Some("value-ref"))); + } + + #[test] + fn no_value_ref_call_when_rhs_is_itself_a_builtin() { + let s = parse_lua("print = tostring"); + assert!(s.calls.iter().all(|c| c.dynamic_kind.as_deref() != Some("value-ref"))); + } + + #[test] + fn no_value_ref_call_for_local_non_builtin_alias() { + let s = parse_lua("local function helper() end\nlocal orig_helper = helper"); + assert!(s.calls.iter().all(|c| c.dynamic_kind.as_deref() != Some("value-ref"))); + } + + #[test] + fn no_value_ref_call_when_rhs_is_a_call_expression() { + let s = parse_lua("require = wrapRequire(require)"); + assert!(s.calls.iter().all(|c| c.dynamic_kind.as_deref() != Some("value-ref"))); + } + + #[test] + fn no_value_ref_call_when_rhs_is_a_member_expression() { + let s = parse_lua("require = mymodule.customRequire"); + assert!(s.calls.iter().all(|c| c.dynamic_kind.as_deref() != Some("value-ref"))); + } + + #[test] + fn pairs_multi_assignment_positionally() { + // `t.b` occupies position 0 (a dot_index_expression, not a plain + // identifier) — pairing must not shift, or `require` (position 1) + // would incorrectly pair with `helperA` (position 0). + let s = parse_lua( + "local function helperA() end\nlocal function helperB() end\nt.b, require = helperA, helperB", + ); + let value_refs: Vec<&str> = s + .calls + .iter() + .filter(|c| c.dynamic_kind.as_deref() == Some("value-ref")) + .map(|c| c.name.as_str()) + .collect(); + assert!(value_refs.contains(&"helperB")); + assert!(!value_refs.contains(&"helperA")); + } + + #[test] + fn extracts_value_ref_call_for_stdlib_module_table_reassignment() { + let s = parse_lua("local function fakeOs() end\nos = fakeOs"); + assert!(s + .calls + .iter() + .any(|c| c.name == "fakeOs" && c.dynamic_kind.as_deref() == Some("value-ref"))); + } +} diff --git a/docs/architecture/decisions/002-dynamic-call-resolution.md b/docs/architecture/decisions/002-dynamic-call-resolution.md index 5159f7b9..c017742e 100644 --- a/docs/architecture/decisions/002-dynamic-call-resolution.md +++ b/docs/architecture/decisions/002-dynamic-call-resolution.md @@ -77,9 +77,12 @@ export type DynamicKind = | 'reflection' // .call/.apply/.bind, getattr, Method.invoke, $obj->$m() | 'eval' // eval(), new Function() — undecidable | 'unresolved-dynamic' // detected dynamic call we cannot resolve - | 'value-ref'; // bare identifier used as an object-literal property value - // (dispatch tables, e.g. `{ resolve: someFn }`) — resolvable - // against function/method-kind targets only (#1771) + | 'value-ref'; // bare identifier used as a value reference, not a call site — + // object-literal property value (dispatch tables, e.g. + // `{ resolve: someFn }`, #1771) or assignment to a Lua + // global/builtin identifier (e.g. `require = tracedRequire`, + // #1776) — resolvable against function/method-kind targets + // only ``` `dynamic?: boolean` is kept to avoid churning every `call.dynamic ? 1 : 0` site. @@ -90,6 +93,18 @@ plain data reference like `{ name: SOME_CONSTANT }`), that's the common case, no an undecidable dynamic call site — so it's silently dropped rather than flagged, unlike `eval`/`computed-key`/`unresolved-dynamic`. +`value-ref` is deliberately syntax-position-agnostic: any bare identifier that +names a known function/method and appears somewhere other than a call site +qualifies, regardless of which language or grammar shape produced it. +Object-literal property values (#1771) and Lua assignment to a +global/builtin identifier (#1776, `require = tracedRequire` — a builtin name +isn't a locally-scoped variable that alias/points-to resolution could ever +observe, so this is the narrow, language-specific case where a plain +reference edge is the correct substitute for real alias tracking) are two +independent extraction sites feeding the same resolution/filtering logic +downstream; new languages/positions can add a third without touching +`build-edges.ts` / `incremental.ts` / `build_edges.rs` again. + ### Sink edges reuse `kind='calls'`, not a new edge kind A new `EdgeKind` would ripple through every edge-kind switch, role classifier, exporter, MCP tool, and the viewer — high blast radius. Instead: DB migration adds `dynamic_kind TEXT` column to `edges`; sink edges use `kind='calls'`, `dynamic=1`, `dynamic_kind=`, `confidence=0.0`. Confidence below `DEFAULT_MIN_CONFIDENCE=0.5` means they never pollute normal queries or exports but remain queryable when explicitly requested. diff --git a/src/extractors/lua.ts b/src/extractors/lua.ts index 85c99b82..013aa843 100644 --- a/src/extractors/lua.ts +++ b/src/extractors/lua.ts @@ -5,7 +5,60 @@ import type { TreeSitterNode, TreeSitterTree, } from '../types.js'; -import { findChild, nodeEndLine } from './helpers.js'; +import { findChild, nodeEndLine, nodeStartLine } from './helpers.js'; + +/** + * Lua base-library global function names and standard-library module + * tables. A plain `identifier = identifier` assignment whose LHS is one of + * these escapes local/lexical scoping entirely: the LHS names a language + * builtin rather than a locally declared variable that scope-based tracking + * could follow, so a function assigned here (`require = tracedRequire`, the + * monkey-patch pattern from issue #1776) becomes reachable through every + * later unqualified use of the builtin name — anywhere in the codebase, + * since it's a genuine global, not just within this file's call graph. + * Mirrors `LUA_BUILTIN_GLOBALS` in `crates/codegraph-core/src/extractors/lua.rs`. + */ +export const LUA_BUILTIN_GLOBALS: Set = new Set([ + 'assert', + 'collectgarbage', + 'dofile', + 'error', + 'getfenv', + 'getmetatable', + 'ipairs', + 'load', + 'loadfile', + 'loadstring', + 'module', + 'next', + 'pairs', + 'pcall', + 'print', + 'rawequal', + 'rawget', + 'rawlen', + 'rawset', + 'require', + 'select', + 'setfenv', + 'setmetatable', + 'tonumber', + 'tostring', + 'type', + 'unpack', + 'xpcall', + // Standard-library module tables — wholesale replacement (e.g. sandboxing) + // is the same "escapes local scope" shape as a single builtin function. + 'string', + 'table', + 'math', + 'io', + 'os', + 'coroutine', + 'debug', + 'utf8', + 'bit32', +]); /** * Extract symbols from Lua files. @@ -35,6 +88,9 @@ function walkLuaNode(node: TreeSitterNode, ctx: ExtractorOutput): void { case 'function_call': handleLuaFunctionCall(node, ctx); break; + case 'assignment_statement': + handleLuaAssignmentStatement(node, ctx); + break; } for (let i = 0; i < node.childCount; i++) { @@ -128,6 +184,68 @@ function checkForRequire(node: TreeSitterNode, ctx: ExtractorOutput): void { } } +/** + * Detect ` = ` assignments — a locally declared + * function bound to a Lua global/builtin identifier (e.g. + * `require = tracedRequire`), the monkey-patch pattern from issue #1776. + * Every later unqualified call to the builtin name (`require(...)`) + * anywhere in the codebase actually invokes the RHS function, but that call + * site names the builtin, never the RHS function directly — so without + * this, the RHS function has no inbound edge at all and is misclassified + * dead-unresolved. + * + * Emits a dynamic `value-ref` call for the RHS identifier — the same + * classification #1771 uses for bare identifiers referenced as + * object-literal property values: a bare identifier used in a value + * position, not a call site. Resolution downstream (build-edges.ts / + * incremental.ts / build_edges.rs) already restricts `value-ref` calls to + * function/method-kind targets only, so a builtin reassigned to a + * non-function value (`unpack = someTable`) is silently dropped rather than + * fabricating a nonsensical edge — no further changes needed there. + * + * Scoped narrowly to plain `identifier = identifier` pairs where the LHS + * matches a known Lua builtin/stdlib-module name (`LUA_BUILTIN_GLOBALS`). + * General local-to-local variable aliasing (`local a = someFunc; a()`) is a + * much larger points-to/alias-tracking problem this fix does not attempt to + * solve — see #1776 for the scoping rationale. + * + * Handles both the bare top-level form (`require = tracedRequire`, a + * standalone `assignment_statement`) and the `local`-declared form + * (`local require = tracedRequire`, an `assignment_statement` nested inside + * `variable_declaration`) identically: shadowing a builtin name with + * `local` is the same "redirect every later unqualified use" idiom, just + * lexically scoped rather than truly global. + * + * Multi-assignment (`a, b = f, g`) is handled positionally, matching Lua's + * own assignment semantics — mixed variable kinds (`t.b, a = f, g`) do not + * shift the pairing, since each side is indexed independently by position + * rather than pre-filtered to identifiers first. + */ +function handleLuaAssignmentStatement(node: TreeSitterNode, ctx: ExtractorOutput): void { + const variableList = findChild(node, 'variable_list'); + const expressionList = findChild(node, 'expression_list'); + if (!variableList || !expressionList) return; + + const variables = variableList.namedChildren; + const expressions = expressionList.namedChildren; + const pairCount = Math.min(variables.length, expressions.length); + + for (let i = 0; i < pairCount; i++) { + const lhs = variables[i]; + const rhs = expressions[i]; + if (!lhs || !rhs) continue; + if (lhs.type !== 'identifier' || rhs.type !== 'identifier') continue; + if (!LUA_BUILTIN_GLOBALS.has(lhs.text) || LUA_BUILTIN_GLOBALS.has(rhs.text)) continue; + + ctx.calls.push({ + name: rhs.text, + line: nodeStartLine(rhs), + dynamic: true, + dynamicKind: 'value-ref', + }); + } +} + function handleLuaFunctionCall(node: TreeSitterNode, ctx: ExtractorOutput): void { const nameNode = node.childForFieldName('name'); if (!nameNode) return; diff --git a/src/types.ts b/src/types.ts index c352bb0b..7b90058e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -488,7 +488,7 @@ export type DynamicKind = | 'reflection' // .call/.apply/.bind / Reflect.* / callable-ref — resolved when target is in codebase; sink edge emitted if unresolved | 'eval' // eval() / new Function() — undecidable; always flagged | 'unresolved-dynamic' // any other detected dynamic pattern; flagged - | 'value-ref'; // bare identifier used as an object-literal property value (dispatch-table pattern, e.g. `{ resolve: someFn }`) — resolved only against function/method-kind targets; unresolved (e.g. plain data references) are dropped silently, NOT flagged + | 'value-ref'; // bare identifier used as a value reference rather than a call site — object-literal property value (dispatch-table pattern, e.g. `{ resolve: someFn }`, #1771) or assignment to a Lua global/builtin identifier (e.g. `require = tracedRequire`, #1776) — resolved only against function/method-kind targets; unresolved (e.g. plain data references) are dropped silently, NOT flagged /** A function/method call detected by an extractor. */ export interface Call { diff --git a/tests/integration/issue-1776-lua-builtin-reassignment.test.ts b/tests/integration/issue-1776-lua-builtin-reassignment.test.ts new file mode 100644 index 00000000..a9d14fd2 --- /dev/null +++ b/tests/integration/issue-1776-lua-builtin-reassignment.test.ts @@ -0,0 +1,227 @@ +/** + * Integration test for #1776: a Lua function assigned to a global/builtin + * identifier (the `require = tracedRequire` monkey-patch pattern) was + * misclassified `dead-unresolved` despite being genuinely invoked at + * runtime through every later unqualified use of the builtin name. + * + * Root cause: the function is never called by its own name — it's assigned + * to the `require` builtin, then invoked only via subsequent `require(...)` + * call sites. Codegraph's static resolver had no extraction logic at all + * for bare (non-`local`) `assignment_statement` nodes, so `require = + * tracedRequire` produced zero edges: not a call, and the assignment itself + * was never even inspected. + * + * Fix: both engines now emit a dynamic `calls` edge (dynamic=1, + * dynamicKind/dynamic_kind = 'value-ref' — the same classification #1771 + * uses for object-literal property-value references) from the enclosing + * scope to the RHS function, for any plain `identifier = identifier` + * assignment whose LHS matches a recognized Lua builtin/stdlib-module name. + * Deliberately scoped to that pattern only — reassigning a locally-declared + * (non-builtin) global, or aliasing via a `local` variable that isn't + * itself a builtin name, is a general points-to/alias-tracking problem this + * fix does not attempt to solve, and remains dead-unresolved as before. + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import Database from 'better-sqlite3'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { buildGraph } from '../../src/domain/graph/builder.js'; +import { isNativeAvailable } from '../../src/infrastructure/native.js'; + +// Mirrors the real-world fixture from issue #1776 +// (tests/benchmarks/resolution/tracer/lua-tracer.lua): `traced_require` is +// declared as a local function, then assigned to the global `require` +// builtin so every later `require(...)` call site actually invokes it. +// +// `aliasedToCustomGlobal` and `trulyDeadFn` are negative controls: assigning +// a function to a global that is NOT a recognized Lua builtin/stdlib-module +// name must NOT rescue it (scope-discipline check — this fix targets the +// reported builtin-reassignment pattern specifically, not general aliasing). +const FIXTURE = { + 'main.lua': ` +local orig_require = require +local function traced_require(modname) + local mod = orig_require(modname) + return mod +end +require = traced_require + +local function aliasedToCustomGlobal() + return 42 +end +myCustomGlobal = aliasedToCustomGlobal + +local function trulyDeadFn() + return 99 +end + +local function entryPoint() + local m = require("some.module") + return m +end +`, +}; + +const DEAD_ROLES = new Set(['dead-unresolved', 'dead-leaf', 'dead-entry', 'dead-ffi']); + +function readNodesWithRoles(dbPath: string) { + const db = new Database(dbPath, { readonly: true }); + try { + return db.prepare('SELECT name, kind, role FROM nodes ORDER BY name').all() as Array<{ + name: string; + kind: string; + role: string | null; + }>; + } finally { + db.close(); + } +} + +function countCallEdgesTo(dbPath: string, targetName: string): number { + const db = new Database(dbPath, { readonly: true }); + try { + const row = db + .prepare( + `SELECT COUNT(*) AS cnt + FROM edges e + JOIN nodes t ON e.target_id = t.id + WHERE e.kind = 'calls' AND t.name = ?`, + ) + .get(targetName) as { cnt: number }; + return row.cnt; + } finally { + db.close(); + } +} + +// Mirrors the #1771 integration test's rationale: a call site that DOES +// resolve (as value-ref calls into a real function target always do here) +// is persisted as a plain `dynamic=1` `calls` edge — `dynamic_kind` is only +// persisted on unresolved sink edges. So a resolved value-ref edge is +// identified by `dynamic = 1`, not by the `dynamic_kind` column. +function readDynamicCallEdges(dbPath: string) { + const db = new Database(dbPath, { readonly: true }); + try { + return db + .prepare( + `SELECT s.name AS src, t.name AS tgt + FROM edges e + JOIN nodes s ON e.source_id = s.id + JOIN nodes t ON e.target_id = t.id + WHERE e.kind = 'calls' AND e.dynamic = 1 + ORDER BY s.name, t.name`, + ) + .all() as Array<{ src: string; tgt: string }>; + } finally { + db.close(); + } +} + +describe('Lua builtin-reassignment value-ref edges (#1776) — WASM', () => { + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-1776-')); + for (const [rel, content] of Object.entries(FIXTURE)) { + fs.writeFileSync(path.join(tmpDir, rel), content); + } + await buildGraph(tmpDir, { engine: 'wasm', incremental: false, skipRegistry: true }); + }); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('emits a value-ref calls edge from the enclosing scope to the reassigned function', () => { + const dbPath = path.join(tmpDir, '.codegraph', 'graph.db'); + const edges = readDynamicCallEdges(dbPath); + expect( + edges.some((e) => e.tgt === 'traced_require'), + `Expected a value-ref edge into traced_require; got: ${JSON.stringify(edges)}`, + ).toBe(true); + }); + + it('gives the reassigned function at least one inbound calls edge (fan-in >= 1)', () => { + const dbPath = path.join(tmpDir, '.codegraph', 'graph.db'); + expect(countCallEdgesTo(dbPath, 'traced_require')).toBeGreaterThan(0); + }); + + it('does not classify the reassigned function as dead', () => { + const dbPath = path.join(tmpDir, '.codegraph', 'graph.db'); + const nodes = readNodesWithRoles(dbPath); + const node = nodes.find((n) => n.name === 'traced_require' && n.kind === 'function'); + expect(node, 'traced_require node not found').toBeDefined(); + expect( + node!.role, + `traced_require was classified as ${node!.role} — expected a non-dead role now that a real edge exists`, + ).not.toBe(undefined); + expect(DEAD_ROLES.has(node!.role ?? '')).toBe(false); + }); + + it('does not rescue a function aliased to a non-builtin global (scope-discipline control)', () => { + const dbPath = path.join(tmpDir, '.codegraph', 'graph.db'); + const edges = readDynamicCallEdges(dbPath); + expect(edges.some((e) => e.tgt === 'aliasedToCustomGlobal')).toBe(false); + const nodes = readNodesWithRoles(dbPath); + const node = nodes.find((n) => n.name === 'aliasedToCustomGlobal' && n.kind === 'function'); + expect(node, 'aliasedToCustomGlobal node not found').toBeDefined(); + expect(DEAD_ROLES.has(node!.role ?? '')).toBe(true); + }); + + it('leaves an unrelated, genuinely unreferenced function classified dead (baseline control)', () => { + const dbPath = path.join(tmpDir, '.codegraph', 'graph.db'); + const nodes = readNodesWithRoles(dbPath); + const node = nodes.find((n) => n.name === 'trulyDeadFn' && n.kind === 'function'); + expect(node, 'trulyDeadFn node not found').toBeDefined(); + expect(DEAD_ROLES.has(node!.role ?? '')).toBe(true); + }); +}); + +// ── Native engine parity ──────────────────────────────────────────────────── +// Skipped when the native addon is not installed. + +describe.skipIf(!isNativeAvailable())( + 'Lua builtin-reassignment value-ref edges (#1776) — native', + () => { + let nativeTmpDir: string; + + beforeAll(async () => { + nativeTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-1776-native-')); + for (const [rel, content] of Object.entries(FIXTURE)) { + fs.writeFileSync(path.join(nativeTmpDir, rel), content); + } + await buildGraph(nativeTmpDir, { engine: 'native', incremental: false, skipRegistry: true }); + }, 60_000); + + afterAll(() => { + fs.rmSync(nativeTmpDir, { recursive: true, force: true }); + }); + + it('emits a value-ref calls edge from the enclosing scope to the reassigned function', () => { + const dbPath = path.join(nativeTmpDir, '.codegraph', 'graph.db'); + const edges = readDynamicCallEdges(dbPath); + expect( + edges.some((e) => e.tgt === 'traced_require'), + `Expected a native value-ref edge into traced_require; got: ${JSON.stringify(edges)}`, + ).toBe(true); + }); + + it('does not classify the reassigned function as dead', () => { + const dbPath = path.join(nativeTmpDir, '.codegraph', 'graph.db'); + const nodes = readNodesWithRoles(dbPath); + const node = nodes.find((n) => n.name === 'traced_require' && n.kind === 'function'); + expect(node, 'traced_require node not found (native)').toBeDefined(); + expect(DEAD_ROLES.has(node!.role ?? '')).toBe(false); + }); + + it('does not rescue a function aliased to a non-builtin global (scope-discipline control)', () => { + const dbPath = path.join(nativeTmpDir, '.codegraph', 'graph.db'); + const nodes = readNodesWithRoles(dbPath); + const node = nodes.find((n) => n.name === 'aliasedToCustomGlobal' && n.kind === 'function'); + expect(node, 'aliasedToCustomGlobal node not found (native)').toBeDefined(); + expect(DEAD_ROLES.has(node!.role ?? '')).toBe(true); + }); + }, +); diff --git a/tests/parsers/lua.test.ts b/tests/parsers/lua.test.ts index 7872c9ff..b1a99987 100644 --- a/tests/parsers/lua.test.ts +++ b/tests/parsers/lua.test.ts @@ -1,5 +1,9 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { beforeAll, describe, expect, it } from 'vitest'; import { createParsers, extractLuaSymbols } from '../../src/domain/parser.js'; +import { LUA_BUILTIN_GLOBALS } from '../../src/extractors/lua.js'; describe('Lua parser', () => { let parsers: any; @@ -52,4 +56,129 @@ end`); string.format("%s", name)`); expect(symbols.calls).toContainEqual(expect.objectContaining({ name: 'print' })); }); + + describe('builtin/global reassignment value-ref extraction (#1776)', () => { + it('extracts a value-ref call for a function assigned to a builtin global', () => { + const symbols = parseLua(` + local function traced_require(modname) + return modname + end + require = traced_require + `); + expect(symbols.calls).toContainEqual( + expect.objectContaining({ + name: 'traced_require', + dynamic: true, + dynamicKind: 'value-ref', + }), + ); + }); + + it('extracts a value-ref call for the local-shadow form of the same pattern', () => { + const symbols = parseLua(` + local function traced_require(modname) + return modname + end + local require = traced_require + `); + expect(symbols.calls).toContainEqual( + expect.objectContaining({ + name: 'traced_require', + dynamic: true, + dynamicKind: 'value-ref', + }), + ); + }); + + it('does not extract a value-ref call when the LHS is not a recognized builtin', () => { + const symbols = parseLua(` + local function helper() end + myCustomGlobal = helper + `); + expect(symbols.calls.filter((c) => c.dynamicKind === 'value-ref')).toHaveLength(0); + }); + + it('does not extract a value-ref call when the RHS is itself a builtin', () => { + const symbols = parseLua(`print = tostring`); + expect(symbols.calls.filter((c) => c.dynamicKind === 'value-ref')).toHaveLength(0); + }); + + it('does not extract a value-ref call for a local (non-builtin) alias', () => { + const symbols = parseLua(` + local function helper() end + local orig_helper = helper + `); + expect(symbols.calls.filter((c) => c.dynamicKind === 'value-ref')).toHaveLength(0); + }); + + it('does not extract a value-ref call when the RHS is a call expression', () => { + const symbols = parseLua(`require = wrapRequire(require)`); + expect(symbols.calls.filter((c) => c.dynamicKind === 'value-ref')).toHaveLength(0); + }); + + it('does not extract a value-ref call when the RHS is a member expression', () => { + const symbols = parseLua(`require = mymodule.customRequire`); + expect(symbols.calls.filter((c) => c.dynamicKind === 'value-ref')).toHaveLength(0); + }); + + it('pairs multi-assignment positionally, matching Lua assignment semantics', () => { + // `t.b` (a dot_index_expression, not a plain identifier) occupies + // position 0 — pairing must not shift, or `require` (position 1) + // would incorrectly pair with `helperA` (position 0) instead of + // `helperB` (position 1). + const symbols = parseLua(` + local function helperA() end + local function helperB() end + t.b, require = helperA, helperB + `); + const valueRefs = symbols.calls.filter((c) => c.dynamicKind === 'value-ref'); + expect(valueRefs).toContainEqual( + expect.objectContaining({ name: 'helperB', dynamicKind: 'value-ref' }), + ); + expect(valueRefs.some((c) => c.name === 'helperA')).toBe(false); + }); + + it('extracts a value-ref call for a standard-library module table reassignment', () => { + const symbols = parseLua(` + local function fakeOs() end + os = fakeOs + `); + expect(symbols.calls).toContainEqual( + expect.objectContaining({ name: 'fakeOs', dynamic: true, dynamicKind: 'value-ref' }), + ); + }); + }); +}); + +describe('LUA_BUILTIN_GLOBALS cross-engine parity', () => { + // Greptile follow-up (#1912): the allowlist is duplicated in lua.rs and + // lua.ts with only a prose comment linking them. If one drifts, native and + // WASM silently disagree on which reassignments produce a value-ref edge. + // This reads the Rust list as text (it can't be imported into a JS test) + // and diffs it against the real TS Set, so any future one-sided edit fails + // CI instead of shipping a silent engine divergence. + it('matches the Rust extractor allowlist exactly', () => { + const __filename = fileURLToPath(import.meta.url); + const __dirname = path.dirname(__filename); + const rustSource = fs.readFileSync( + path.join(__dirname, '../../crates/codegraph-core/src/extractors/lua.rs'), + 'utf8', + ); + const match = /const LUA_BUILTIN_GLOBALS: &\[&str\] = &\[([\s\S]*?)\];/.exec(rustSource); + expect(match, 'LUA_BUILTIN_GLOBALS array not found in lua.rs').not.toBeNull(); + + const rustNames = [...match![1].matchAll(/"([a-zA-Z0-9_]+)"/g)].map((m) => m[1]); + expect( + rustNames.length, + 'regex extracted zero names from lua.rs — pattern likely stale', + ).toBeGreaterThan(0); + + const rustSet = new Set(rustNames); + expect(rustSet.size, 'lua.rs has duplicate entries').toBe(rustNames.length); + + const missingFromRust = [...LUA_BUILTIN_GLOBALS].filter((n) => !rustSet.has(n)); + const missingFromTs = rustNames.filter((n) => !LUA_BUILTIN_GLOBALS.has(n)); + expect(missingFromRust, 'present in lua.ts but missing from lua.rs').toEqual([]); + expect(missingFromTs, 'present in lua.rs but missing from lua.ts').toEqual([]); + }); });