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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions crates/codegraph-core/src/extractors/lua.rs
Original file line number Diff line number Diff line change
Expand Up @@ -359,4 +359,66 @@ mod tests {
.iter()
.any(|c| c.name == "fakeOs" && c.dynamic_kind.as_deref() == Some("value-ref")));
}

// ── #1909: eval/computed-key dynamic-call detection ──────────────────────

#[test]
fn classifies_load_call_as_dynamic_eval() {
let s = parse_lua("load(chunk)()");
assert!(s.calls.iter().any(|c| c.name == "<dynamic:eval>"
&& c.dynamic == Some(true)
&& c.dynamic_kind.as_deref() == Some("eval")));
}

#[test]
fn classifies_loadstring_call_as_dynamic_eval() {
let s = parse_lua("loadstring(code)()");
assert!(s.calls.iter().any(|c| c.name == "<dynamic:eval>"
&& c.dynamic == Some(true)
&& c.dynamic_kind.as_deref() == Some("eval")));
}

#[test]
fn classifies_dofile_call_as_dynamic_eval() {
let s = parse_lua("dofile(\"script.lua\")");
assert!(s.calls.iter().any(|c| c.name == "<dynamic:eval>"
&& c.dynamic == Some(true)
&& c.dynamic_kind.as_deref() == Some("eval")));
}

#[test]
fn resolves_bracket_index_call_with_string_literal_key_directly() {
let s = parse_lua("t[\"handler\"]()");
assert!(s
.calls
.iter()
.any(|c| c.name == "handler" && c.receiver.as_deref() == Some("t") && c.dynamic.is_none()));
}

#[test]
fn resolves_bracket_index_call_with_single_quoted_string_literal_key_directly() {
let s = parse_lua("t['handler']()");
assert!(s
.calls
.iter()
.any(|c| c.name == "handler" && c.receiver.as_deref() == Some("t")));
}

#[test]
fn classifies_bracket_index_call_with_variable_key_as_computed_key() {
let s = parse_lua("t[k]()");
assert!(s.calls.iter().any(|c| c.name == "<dynamic:computed-key>"
&& c.dynamic == Some(true)
&& c.dynamic_kind.as_deref() == Some("computed-key")
&& c.key_expr.as_deref() == Some("k")
&& c.receiver.as_deref() == Some("t")));
}

#[test]
fn classifies_bracket_index_call_with_expression_key_as_computed_key() {
let s = parse_lua("handlers[eventName .. \"Handler\"]()");
assert!(s.calls.iter().any(|c| c.dynamic == Some(true)
&& c.dynamic_kind.as_deref() == Some("computed-key")
&& c.receiver.as_deref() == Some("handlers")));
}
}
49 changes: 49 additions & 0 deletions src/extractors/lua.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,10 +246,34 @@ function handleLuaAssignmentStatement(node: TreeSitterNode, ctx: ExtractorOutput
}
}

/**
* Lua string node types across grammar variants — `string` is the only kind
* the current `@tree-sitter-grammars/tree-sitter-lua` grammar (npm, used by
* this WASM extractor) produces; `string_literal` is included so this stays
* in lockstep with the native Rust extractor's own (equally defensive) check.
*/
const LUA_STRING_NODE_TYPES = new Set(['string', 'string_literal']);

function handleLuaFunctionCall(node: TreeSitterNode, ctx: ExtractorOutput): void {
const nameNode = node.childForFieldName('name');
if (!nameNode) return;

// load(chunk) / loadstring(chunk) / dofile(...) — dynamic code execution;
// undecidable statically. Mirrors handle_lua_function_call's `load` arm in
// crates/codegraph-core/src/extractors/lua.rs.
if (nameNode.type === 'identifier') {
const ident = nameNode.text;
if (ident === 'load' || ident === 'loadstring' || ident === 'dofile') {
ctx.calls.push({
name: '<dynamic:eval>',
line: node.startPosition.row + 1,
dynamic: true,
dynamicKind: 'eval',
});
return;
}
Comment on lines +266 to +274

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 loadfile omitted from eval detection

loadfile is listed in LUA_BUILTIN_GLOBALS on line 30 alongside load, loadstring, and dofile, and it performs the same dynamic-code-loading operation (it compiles a Lua file and returns a chunk function). The eval guard currently covers load, loadstring, and dofile but not loadfile, so loadfile("script.lua")() — and any call graph that passes through a loadfile result — will not be flagged as a dynamic eval site. This mirrors the native Rust extractor's equivalent omission, so no parity divergence is introduced here, but the shared gap is worth addressing in both extractors.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code

}

// Check for require() as import
if (nameNode.type === 'identifier' && nameNode.text === 'require') {
const args = node.childForFieldName('arguments');
Expand Down Expand Up @@ -279,6 +303,31 @@ function handleLuaFunctionCall(node: TreeSitterNode, ctx: ExtractorOutput): void
const field = nameNode.childForFieldName('field');
if (field) call.name = field.text;
if (table) call.receiver = table.text;
} else if (nameNode.type === 'bracket_index_expression') {
// t[k]() — bracket-index call; key may be a string literal (resolvable
// directly, same as a `.field`/`:method` call) or a variable/expression
// (undecidable statically — flagged `computed-key`). Mirrors
// handle_lua_function_call's `bracket_index_expression` arm in
// crates/codegraph-core/src/extractors/lua.rs.
const table = nameNode.childForFieldName('table');
const key = nameNode.childForFieldName('field');
if (key) {
if (LUA_STRING_NODE_TYPES.has(key.type)) {
call.name = key.text.replace(/^['"]|['"]$/g, '');
if (table) call.receiver = table.text;
} else {
const dynamicCall: Call = {
name: '<dynamic:computed-key>',
line: node.startPosition.row + 1,
dynamic: true,
dynamicKind: 'computed-key',
keyExpr: key.text,
};
if (table) dynamicCall.receiver = table.text;
ctx.calls.push(dynamicCall);
return;
}
}
} else {
call.name = nameNode.text;
}
Expand Down
62 changes: 62 additions & 0 deletions tests/parsers/lua.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,4 +144,66 @@ string.format("%s", name)`);
);
});
});

describe('eval/computed-key dynamic-call detection (#1909)', () => {
it('classifies load(...) as a dynamic eval call', () => {
const symbols = parseLua(`load(chunk)()`);
expect(symbols.calls).toContainEqual(
expect.objectContaining({ name: '<dynamic:eval>', dynamic: true, dynamicKind: 'eval' }),
);
});

it('classifies loadstring(...) as a dynamic eval call', () => {
const symbols = parseLua(`loadstring(code)()`);
expect(symbols.calls).toContainEqual(
expect.objectContaining({ name: '<dynamic:eval>', dynamic: true, dynamicKind: 'eval' }),
);
});

it('classifies dofile(...) as a dynamic eval call', () => {
const symbols = parseLua(`dofile("script.lua")`);
expect(symbols.calls).toContainEqual(
expect.objectContaining({ name: '<dynamic:eval>', dynamic: true, dynamicKind: 'eval' }),
);
});

it('resolves a bracket-index call with a string-literal key directly', () => {
const symbols = parseLua(`t["handler"]()`);
expect(symbols.calls).toContainEqual(
expect.objectContaining({ name: 'handler', receiver: 't' }),
);
expect(symbols.calls.some((c) => c.dynamicKind === 'computed-key')).toBe(false);
});

it('resolves a bracket-index call with a single-quoted string-literal key directly', () => {
const symbols = parseLua(`t['handler']()`);
expect(symbols.calls).toContainEqual(
expect.objectContaining({ name: 'handler', receiver: 't' }),
);
});

it('classifies a bracket-index call with a variable key as computed-key', () => {
const symbols = parseLua(`t[k]()`);
expect(symbols.calls).toContainEqual(
expect.objectContaining({
name: '<dynamic:computed-key>',
dynamic: true,
dynamicKind: 'computed-key',
keyExpr: 'k',
receiver: 't',
}),
);
});

it('classifies a bracket-index call with an expression key as computed-key', () => {
const symbols = parseLua(`handlers[eventName .. "Handler"]()`);
expect(symbols.calls).toContainEqual(
expect.objectContaining({
dynamic: true,
dynamicKind: 'computed-key',
receiver: 'handlers',
}),
);
});
});
});
Loading