From 195888d71f3ad053bfa725f24b3d3e9353c50e09 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Tue, 8 Sep 2026 10:29:53 -0500 Subject: [PATCH 1/2] fix(resolution): reject Python module collection method guesses (#1652) (#1776) Use same-file module variable initializers to filter builtin collection calls before class-name heuristics, and require a Python type node for the class escape. Preserve imported project module calls. Add callers/callees regressions for dict, list, set, tuple, and frozenset, with a real instance control and same-name bindings across files. Validation on Linux with Node 22.19.0: the new suite had 14 failures and one passing control on main at 8733c288; all 216 tests in the new suite, call-receiver-no-fabrication, and resolution now pass. TypeScript and copy-assets pass. Re-indexed /tmp/cg-1652-repro: callers get is empty and read_setting no longer calls a cache.py method. Co-authored-by: Colby McHenry --- CHANGELOG.md | 1 + ...on-module-scope-collection-methods.test.ts | 106 ++++++++++++++++++ src/resolution/index.ts | 21 +++- 3 files changed, 124 insertions(+), 4 deletions(-) create mode 100644 __tests__/python-module-scope-collection-methods.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ccebbf0e..1297d371e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -220,6 +220,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - **A C macro call written with designated initializers no longer swallows every function after it.** Betaflight resets each config struct with `RESET_CONFIG(type, dst, .field = value, …)`, a shape the C grammar cannot parse; past a hundred or so fields its error recovery ran the enclosing function to the end of the file, the next function vanished from the index and every later one was filed under the first, where name matching then treated it as an unreachable closure. The argument list of such a call is now blanked before parsing, offsets kept, so the file's functions come out with their real extents. On that tree 45 functions in `pid.c` alone moved back to top level and their 117 callers resolve at exact-match confidence. Re-index after upgrading. (#1729) - A method called on the result of another call — `d.setdefault(k, []).append(v)`, `make().run()` — no longer produces a call edge to an unrelated top-level function that merely shares the name, in Python and JavaScript/TypeScript. The receiver is kept so the inner call still resolves; the outer method stays unresolved rather than guessed. Re-index after upgrading. (#1683, #1681) - A Python call through an imported project module whose name collides with a builtin collection method — `ledger.append(row)` after `from . import ledger` — is no longer dropped as `list.append`. The builtin-method filter now lets the receiver through when it is an imported module that resolves to a file in the project, so `resolveViaImport` can attach the real edge; a stdlib/PyPI receiver (`os.remove`) still produces none. Re-index after upgrading. (#1681, via #1704) +- Python method calls on module-scope builtin collections no longer create false calls or file dependencies to unrelated project methods with the same name; re-index after upgrading. (#1652) - **A definition its language makes file-local no longer captures calls from other files.** A C `static` in another source file (`.c`/`.cc`/… — not a header's `static inline`, which is textually included), a Kotlin/Java/C#/Swift/Scala/Dart/PHP `private` member, a Go unexported name in another package, and a Rust non-`pub` item outside its module subtree cannot be what a name in another file means, but name matching accepted them whenever the names agreed: an Android `editor.apply()` onto an unrelated class's `private fun apply`, a JavaScript `fail(...)` onto a Go `func fail`, a Rust `.count()` onto a private `fn count` in another crate, and C USB helpers onto a `static` in a `.c` they never link. Such a target is now declined after the whole name-matching pipeline settles — the reference stays unresolved rather than falling through to a fuzzy namesake. Same-file definitions, a child Rust module reaching its ancestors' private items, and Rust `impl Trait for Type` methods stay resolvable. Re-index after upgrading. (#1730, #1731) - **A binding in a module that exports nothing is no longer a cross-file target.** On vite, every `import { defineConfig } from 'vite'` across the playground resolved onto a `const vite = await createServer(…)` sitting at module scope in `playground/ssr-html/test-stacktrace.js` — a file with an import and no export, so that binding is reachable from nowhere but itself. Name matching commits as soon as one candidate survives, and nothing asked whether an import could reach the survivor; that one binding took 157 edges. A JS/TS file holding an `import` and no export of any kind now offers its locals to no other file. Classic scripts, CommonJS (including `exports["x"] = …`), a later `export { … }`, and names contributed through `declare global` are all unaffected. Across vite this removed 320 wrong edges and added 18, each addition a reference that was previously ambiguous rather than newly invented. Re-index after upgrading. (#1719) - **A bare call inside a JavaScript or TypeScript method no longer resolves to the method itself.** When a method and a module-scope function share a name, `serialize(this.raw)` written inside `Record.serialize` means the function, but the nearest same-named definition won the tie and the graph recorded the method calling itself. A call written without a receiver can never reach a method in JS/TS, so methods are no longer candidates for it; `this.serialize()` and `other.serialize()` resolve as before. (#1714) diff --git a/__tests__/python-module-scope-collection-methods.test.ts b/__tests__/python-module-scope-collection-methods.test.ts new file mode 100644 index 000000000..e77079851 --- /dev/null +++ b/__tests__/python-module-scope-collection-methods.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; + +const collections = [ + { name: 'dict_literal', value: '{"answer": "42"}', method: 'get' }, + { name: 'empty_dict', value: '{}', method: 'get' }, + { name: 'dict_constructor', value: 'dict()', method: 'get' }, + { name: 'list_literal', value: '[1]', method: 'append' }, + { name: 'empty_list', value: '[]', method: 'append' }, + { name: 'list_constructor', value: 'list()', method: 'append' }, + { name: 'set_literal', value: '{1}', method: 'add' }, + { name: 'set_constructor', value: 'set()', method: 'add' }, + { name: 'tuple_literal', value: '(1,)', method: 'index' }, + { name: 'empty_tuple', value: '()', method: 'index' }, + { name: 'tuple_constructor', value: 'tuple()', method: 'index' }, + { name: 'frozenset_constructor', value: 'frozenset()', method: 'union' }, +]; + +let dir: string; +let cg: CodeGraph; + +beforeAll(async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-1652-')); + fs.writeFileSync(path.join(dir, 'settings.py'), `DEFAULTS = {"answer": "42"} + +def read_setting(name): + return DEFAULTS.get(name, None) +`); + fs.writeFileSync(path.join(dir, 'cache.py'), `class LRUCache: + def __init__(self): + self._store = {} + + def get(self, key): + return self._store.get(key) + +class ProjectCollection: + def append(self, item): + pass + def add(self, item): + pass + def index(self, item): + return 0 + def union(self, item): + return self +`); + for (const { name, value, method } of collections) { + // Capitalizing lRUCache matches a real class. Its name must not override + // the same-file binding's collection initializer (#1652). + fs.writeFileSync(path.join(dir, `${name}.py`), `lRUCache = ${value} + +def use_${name}(item): + return lRUCache.${method}(item) +`); + } + fs.writeFileSync(path.join(dir, 'unknown.py'), `UNKNOWN = load_defaults() + +def read_unknown(name): + return UNKNOWN.get(name) +`); + fs.writeFileSync(path.join(dir, 'client.py'), `from cache import LRUCache + +def read_cache(lRUCache: LRUCache, name): + return lRUCache.get(name) +`); + cg = await CodeGraph.init(dir, { index: true }); +}); + +afterAll(() => { + cg?.destroy(); + fs.rmSync(dir, { recursive: true, force: true }); +}); + +function expectNoMethodCall(callerName: string, file: string, methodName: string) { + const caller = cg.getNodesByName(callerName).find((n) => n.kind === 'function' && n.filePath === file); + const method = cg.getNodesByName(methodName).find((n) => n.kind === 'method' && n.filePath === 'cache.py'); + expect(caller).toBeDefined(); + expect(method).toBeDefined(); + expect(cg.getCallers(method!.id).map(({ node }) => node.id)).not.toContain(caller!.id); + expect(cg.getCallees(caller!.id).map(({ node }) => node.id)).not.toContain(method!.id); +} + +describe('Python module-scope collection methods (#1652)', () => { + it('does not connect DEFAULTS.get to the unrelated LRUCache.get method', () => { + expectNoMethodCall('read_setting', 'settings.py', 'get'); + }); + + it.each(collections)('keeps $name ($method) external even when the receiver resembles a class', ({ name, method }) => { + expectNoMethodCall(`use_${name}`, `${name}.py`, method); + }); + + it('does not treat an unrelated variable as evidence of a project class', () => { + expectNoMethodCall('read_unknown', 'unknown.py', 'get'); + }); + + it('preserves real instance calls despite same-named collections in other files', () => { + const caller = cg.getNodesByName('read_cache').find((n) => n.kind === 'function')!; + const method = cg.getNodesByName('get').find((n) => n.kind === 'method' && n.filePath === 'cache.py')!; + expect(caller).toBeDefined(); + expect(method).toBeDefined(); + expect(cg.getCallees(caller.id).map(({ node }) => node.id)).toContain(method.id); + expect(cg.getCallers(method.id).map(({ node }) => node.id)).toContain(caller.id); + }); +}); diff --git a/src/resolution/index.ts b/src/resolution/index.ts index eb962a505..f233b7fe1 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -2104,8 +2104,22 @@ export class ReferenceResolver { // project dependency, not `list.append` (#1681). Without this, the // qualified ref never reaches resolveViaImport / resolvePythonModuleMember. if (PYTHON_BUILT_IN_METHODS.has(method)) { + // A module-scope collection binding is stronger evidence than a + // coincidentally matching class name (#1652). Only use this file's + // binding: an unrelated module may reuse the receiver for a collection. + const isCollection = this.context.getNodesByName(receiver).some((node) => + node.language === 'python' && node.filePath === ref.filePath && + (node.kind === 'variable' || node.kind === 'constant') && + node.qualifiedName === receiver && + /^=\s*(?:[\[{]|(?:dict|list|set|tuple|frozenset)\s*\(|\(\s*\)|\([^()]*,)/.test(node.signature ?? '') + ); + if (isCollection) return true; + const capitalized = receiver.charAt(0).toUpperCase() + receiver.slice(1); - const isKnownClass = this.knownNames?.has(capitalized) ?? false; + const isKnownClass = this.context.getNodesByName(capitalized).some((node) => + node.language === 'python' && + (node.kind === 'class' || node.kind === 'struct' || node.kind === 'interface') + ); const isProjectModule = !isKnownClass && this.isPythonProjectModule(ref, receiver); if (!isKnownClass && !isProjectModule) { @@ -2116,9 +2130,8 @@ export class ReferenceResolver { // A bare name colliding with a builtin method (index, get, update, count…) // is only a builtin when NOTHING in the codebase declares it. A declared // symbol with that exact name — e.g. a Flask/FastAPI view `def index()` or - // `def get()` — is a real reference target. Mirrors the knownNames guard on - // the dotted branch above; without it, every handler named after a builtin - // method silently loses its route→handler edge. + // `def get()` — is a real reference target. Without this guard, every + // handler named after a builtin method silently loses its route→handler edge. if (PYTHON_BUILT_IN_METHODS.has(name) && !this.knownNames?.has(name)) { return true; } From 8c047342cdcb86e0ad4793b3d503eba19793cb02 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Tue, 8 Sep 2026 10:50:09 -0500 Subject: [PATCH 2/2] fix(lua): index assignment-style function definitions (#1650) (#1778) Apply upstream PR #1623 by danusha2345 (fix commit aa777063), which also addresses #1616, to the current main base. Preserve the upstream WASM and Rust implementations and regression coverage. Index assigned locals, table members, static string keys and nested callback tables as callable symbols, with calls owned by those symbols. Keep dynamic keys unguessed. Add #1650 to the Unreleased changelog and retain the existing re-index guidance without an extraction-version bump. Verified on Linux x64 with Node 22.19.0: - native kernel build, tsc, asset copy, executable CLI - issue repro: 3 nodes / 2 edges -> 4 nodes / 4 edges - EPR.PowerController::SyncHydroPower is indexed; its caller is client.lua - syncHydroPower depth-2 impact reaches client.lua - extraction/resolution/Lua parity: 844 tests passed (kernel expected) - forced-WASM Lua/Luau extraction/resolution: 20 tests passed Co-authored-by: Colby McHenry --- CHANGELOG.md | 1 + __tests__/extraction.test.ts | 52 +++++++ __tests__/fixtures/kernel-parity/torture.lua | 11 +- __tests__/kernel-lua-parity.test.ts | 16 +++ __tests__/resolution.test.ts | 26 ++++ codegraph-kernel/src/lua.rs | 144 +++++++++++++++++-- src/extraction/languages/lua.ts | 5 +- src/extraction/tree-sitter.ts | 96 ++++++++++++- 8 files changed, 332 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1297d371e..c6e48736c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -213,6 +213,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). #### Symbols, tests and the viewer +- Lua and Luau function expressions assigned to locals, table members, or keyed table fields are now indexed as callable nodes. Calls from `local f = function() ... end`, `M.f = function() ... end`, and callback tables such as `M.handlers = { onClick = function() ... end }` are attributed to the named function or method instead of collapsing onto the file node, so callers and impact no longer omit these handlers. Re-index after upgrading. (#1616, #1650) - **Functions bound with `const` inside another function are symbols now.** `const handleClear = () => {…}` inside a React component — every handler that skips `useCallback` — was invisible to `callers`, `callees` and impact, answering "Symbol not found" exactly the way a function with no callers would. It is indexed like its module-level twin, contained by the enclosing function, with its own calls. Re-index after upgrading. (#1669) - `codegraph callers`, `callees`, and `query` now clearly report when their result limit hides additional matches, including exact totals in callers/callees JSON output; the `codegraph_callers` and `codegraph_callees` MCP answers carry the same "showing N of M" note. (#1639, #1674) - CommonJS controllers written as `exports.getItems = async (req, res) => {…}` or `module.exports.x = function () {…}` are now indexed as exported functions, so `node`, `callers` and impact find every Express handler in that style and the calls inside them belong to the handler instead of the file. Re-index JavaScript projects after upgrading. (#1675) diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index e9588f064..f3d25ee13 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -8786,6 +8786,58 @@ function M:send(data) return self end const send = methods.find((m) => m.name === 'send'); expect(send?.qualifiedName).toBe('M::send'); }); + + it('should name function expressions from local, member, and table-field bindings', () => { + const code = ` +local function helper() return 1 end +local localFn = function() return helper() end +local M = { + callbacks = { + onStart = function() return helper() end, + ["onStop"] = function() return helper() end, + [DYNAMIC] = function() return helper() end, + }, +} +M.assignedFn = function() return helper() end +M["bracketFn"] = function() return helper() end +localFn() +`; + const result = extractFromSource('handlers.lua', code); + const localFn = result.nodes.find((n) => n.kind === 'function' && n.name === 'localFn'); + const assignedFn = result.nodes.find( + (n) => n.kind === 'method' && n.qualifiedName === 'M::assignedFn' + ); + const onStart = result.nodes.find( + (n) => n.kind === 'method' && n.qualifiedName === 'M.callbacks::onStart' + ); + const onStop = result.nodes.find( + (n) => n.kind === 'method' && n.qualifiedName === 'M.callbacks::onStop' + ); + const bracketFn = result.nodes.find( + (n) => n.kind === 'method' && n.qualifiedName === 'M::bracketFn' + ); + + expect(localFn).toBeDefined(); + expect(assignedFn).toBeDefined(); + expect(onStart).toBeDefined(); + expect(onStop).toBeDefined(); + expect(bracketFn).toBeDefined(); + expect(result.nodes.some((n) => n.name === 'DYNAMIC')).toBe(false); + expect(result.nodes.some((n) => n.kind === 'variable' && n.name === 'localFn')).toBe(false); + + for (const callable of [localFn, assignedFn, onStart, onStop, bracketFn]) { + expect( + result.unresolvedReferences.some( + (r) => r.fromNodeId === callable!.id && r.referenceKind === 'calls' && r.referenceName === 'helper' + ) + ).toBe(true); + } + expect( + result.unresolvedReferences.some( + (r) => r.referenceKind === 'calls' && r.referenceName === 'localFn' + ) + ).toBe(true); + }); }); describe('Variable extraction', () => { diff --git a/__tests__/fixtures/kernel-parity/torture.lua b/__tests__/fixtures/kernel-parity/torture.lua index 52da48ca0..1b3c21cfb 100644 --- a/__tests__/fixtures/kernel-parity/torture.lua +++ b/__tests__/fixtures/kernel-parity/torture.lua @@ -24,7 +24,7 @@ local function localFn(...) return select("#", ...) end --- doc for anonAssigned (variable, initializer invisible) +-- doc for anonAssigned (function named from its local binding) local anonAssigned = function(v) return hidden(v) end @@ -68,6 +68,15 @@ M.assigned = function(z) return topFn(z) end +M.callbacks = { + on_start = function() + return topFn(17) + end, + ["on_stop"] = function() + return topFn(18) + end, +} + M.handlers = { on_start = topFn, on_stop = localFn, skipped = missing } local tbl = { cb = topFn, [1] = localFn, nested = { deep_cb = topFn } } diff --git a/__tests__/kernel-lua-parity.test.ts b/__tests__/kernel-lua-parity.test.ts index 2e1e9b27d..e87b007e3 100644 --- a/__tests__/kernel-lua-parity.test.ts +++ b/__tests__/kernel-lua-parity.test.ts @@ -127,6 +127,22 @@ describe.skipIf(!kernelBuilt)('kernel Lua/Luau extraction parity', () => { // lua functions carry NO isExported (undefined — not false). const fn = result.nodes.find((n) => n.kind === 'function' && n.name === 'topFn'); expect(fn?.isExported).toBeUndefined(); + expect(result.nodes.some((n) => n.kind === 'function' && n.name === 'anonAssigned')).toBe(true); + expect(result.nodes.some((n) => n.kind === 'method' && n.qualifiedName === 'M::assigned')).toBe(true); + expect( + result.nodes.some((n) => n.kind === 'method' && n.qualifiedName === 'M.callbacks::on_start') + ).toBe(true); + expect( + result.nodes.some((n) => n.kind === 'method' && n.qualifiedName === 'M.callbacks::on_stop') + ).toBe(true); + for (const qualifiedName of ['M::assigned', 'M.callbacks::on_start', 'M.callbacks::on_stop']) { + const callable = result.nodes.find((n) => n.qualifiedName === qualifiedName)!; + expect( + refs.some( + (r) => r.fromNodeId === callable.id && r.referenceKind === 'calls' && r.referenceName === 'topFn' + ) + ).toBe(true); + } // variables DO carry isExported === false. const v = result.nodes.find((n) => n.kind === 'variable' && n.name === 'core'); expect(v?.isExported).toBe(false); diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index da132160e..5142edcf6 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -2055,6 +2055,32 @@ func main() { }); }); + describe('Lua function-expression resolution (#1616)', () => { + it('attributes helper calls to each assigned callable instead of the file node', async () => { + fs.writeFileSync( + path.join(tempDir, 'util.lua'), + `util = {}\nfunction util.helper() return 1 end\nreturn util\n` + ); + fs.writeFileSync( + path.join(tempDir, 'handlers.lua'), + `local M = {}\nfunction M.namedFn() return util.helper() end\nM.assignedFn = function() return util.helper() end\nM.callbacks = { onStart = function() return util.helper() end }\nreturn M\n` + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + cg.resolveReferences(); + + const helper = cg + .getNodesByKind('method') + .find((n) => n.qualifiedName === 'util::helper'); + expect(helper).toBeDefined(); + const callers = cg.getCallers(helper!.id).map((c) => c.node); + expect(callers.some((n) => n.qualifiedName === 'M::namedFn')).toBe(true); + expect(callers.some((n) => n.qualifiedName === 'M::assignedFn')).toBe(true); + expect(callers.some((n) => n.qualifiedName === 'M.callbacks::onStart')).toBe(true); + expect(callers.some((n) => n.kind === 'file' && n.filePath === 'handlers.lua')).toBe(false); + }); + }); + describe('Watchdog-safe resolution on collision-heavy repos (#1122)', () => { // On a large Java-style repo, per-ref resolution cost is unbounded in the // worst case (a colliding method name whose candidate set misses the LRU diff --git a/codegraph-kernel/src/lua.rs b/codegraph-kernel/src/lua.rs index b3cd32a76..af1245034 100644 --- a/codegraph-kernel/src/lua.rs +++ b/codegraph-kernel/src/lua.rs @@ -468,7 +468,7 @@ impl<'t> Walker<'t> { } // plain path returns false → children re-visited (the // typeof(require(...)) alias+import pair rides this). - } else if kind == "variable_declaration" { + } else if matches!(kind, "variable_declaration" | "assignment_statement") { self.extract_variable(node); // Initializer subtrees are never walked — candidates only. self.scan_fn_ref_subtree(node, 0); @@ -578,21 +578,38 @@ impl<'t> Walker<'t> { } None => Vec::new(), }; - let names: Vec> = match var_list { + let targets: Vec> = match var_list { Some(vl) => { let mut c = vl.walk(); - vl.named_children(&mut c).filter(|n| n.kind() == "identifier").collect() + vl.named_children(&mut c).collect() } None => Vec::new(), }; - for (i, name_node) in names.iter().enumerate() { - let name = self.text(*name_node); - if name.is_empty() { + for (i, name_node) in targets.iter().enumerate() { + let Some((name, receiver, full_name)) = self.lua_assignment_target(*name_node) else { + continue; + }; + let value = values.get(i).copied(); + if let Some(value) = value { + if value.kind() == "function_definition" { + self.extract_lua_function_value( + value, + name, + receiver, + docstring.clone(), + ); + continue; + } + if value.kind() == "table_constructor" { + self.extract_lua_table_functions(value, full_name); + } + } + // Dotted assignments update table members, not standalone vars. + if receiver.is_some() || node.kind() == "assignment_statement" { continue; } // Positional value pairing; a missing value → NO signature key. - let signature = values.get(i).map(|v| util::init_signature(self.text(*v))); - let name = name.to_string(); + let signature = value.map(|v| util::init_signature(self.text(v))); self.create_node( "variable", &name, @@ -607,6 +624,110 @@ impl<'t> Walker<'t> { } } + fn lua_assignment_target(&self, node: Node<'t>) -> Option<(String, Option, String)> { + if node.kind() == "identifier" { + let name = self.text(node).trim().to_string(); + if name.is_empty() { + return None; + } + return Some((name.clone(), None, name)); + } + if !matches!( + node.kind(), + "dot_index_expression" | "method_index_expression" | "bracket_index_expression" + ) { + return None; + } + let table = node.child_by_field_name("table")?; + let field = node + .child_by_field_name("field") + .or_else(|| node.child_by_field_name("method"))?; + let receiver = self.text(table).trim().to_string(); + let name = self.lua_static_field_name(field, node.kind() == "bracket_index_expression"); + if receiver.is_empty() || name.is_empty() { + return None; + } + let full_name = format!("{receiver}.{name}"); + Some((name, Some(receiver), full_name)) + } + + fn lua_static_field_name(&self, node: Node<'t>, bracketed: bool) -> String { + if node.kind() == "identifier" { + return if bracketed { + String::new() + } else { + self.text(node).trim().to_string() + }; + } + if node.kind() == "string" { + let mut cursor = node.walk(); + for child in node.named_children(&mut cursor) { + if child.kind() == "string_content" { + return self.text(child).trim().to_string(); + } + } + } + String::new() + } + + fn extract_lua_function_value( + &mut self, + node: Node<'t>, + name: String, + receiver: Option, + docstring: Option, + ) { + let signature = self.signature_of(node); + let (kind, qualified_name_override, is_exported) = match receiver { + Some(receiver) => ( + "method", + Some(format!("{receiver}::{name}")), + None, + ), + None => ("function", None, self.is_exported_of(node)), + }; + let row = self.create_node( + kind, + &name, + node, + Extra { + docstring, + signature, + qualified_name_override, + is_exported, + ..Default::default() + }, + ); + let Some(row) = row else { return }; + self.stack.push(Scope { row, kind, name }); + if let Some(body) = node.child_by_field_name("body") { + self.visit_body(body); + } + self.stack.pop(); + } + + fn extract_lua_table_functions(&mut self, table: Node<'t>, receiver: String) { + let mut cursor = table.walk(); + let fields: Vec> = table.named_children(&mut cursor).collect(); + for field in fields { + if field.kind() != "field" { + continue; + } + let Some(name_node) = field.child_by_field_name("name") else { continue }; + let Some(value) = field.child_by_field_name("value") else { continue }; + let bracketed = self.text(field).trim_start().starts_with('['); + let name = self.lua_static_field_name(name_node, bracketed); + if name.is_empty() { + continue; + } + if value.kind() == "function_definition" { + self.extract_lua_function_value(value, name, Some(receiver.clone()), None); + } else if value.kind() == "table_constructor" { + self.extract_lua_table_functions(value, format!("{receiver}.{name}")); + } + } + } + // --- extractTypeAlias (2890; plain path 2967-2991) — luau only -------- /// Returns skipChildren (always false on the plain path). @@ -790,13 +911,12 @@ impl<'t> Walker<'t> { return; } // Halt at nested function definitions (their bodies are walked — and - // attributed — by extractFunction). function_definition (anonymous) - // is deliberately NOT in the halt list — the scan descends into - // anonymous initializer bodies, attributing candidates to the file. + // attributed — by extractFunction). Lua function_definition values are + // now extracted from their assignment target and must stop this scan too. if depth > 0 && matches!( node.kind(), - "function_declaration" | "arrow_function" | "function_expression" + "function_declaration" | "function_definition" | "arrow_function" | "function_expression" | "lambda_literal" | "lambda_expression" ) { diff --git a/src/extraction/languages/lua.ts b/src/extraction/languages/lua.ts index 31094dc1a..ec59a50de 100644 --- a/src/extraction/languages/lua.ts +++ b/src/extraction/languages/lua.ts @@ -75,7 +75,10 @@ export const luaExtractor: LanguageExtractor = { typeAliasTypes: [], importTypes: [], // `require` is a function_call — handled in visitNode below callTypes: ['function_call'], - variableTypes: ['variable_declaration'], // see the `lua` branch in extractVariable + // Top-level assignments can introduce module members just as declarations do: + // `M.run = function() ... end`. The Lua branch in extractVariable ignores + // non-callable member assignments, but extracts function-valued targets. + variableTypes: ['variable_declaration', 'assignment_statement'], nameField: 'name', bodyField: 'body', paramsField: 'parameters', diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index c5cfb925b..7be3468cf 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -648,6 +648,7 @@ export class TreeSitterExtractor { const nodeType = node.type; if (depth > 0 && ( this.extractor?.functionTypes.includes(nodeType) || + ((this.language === 'lua' || this.language === 'luau') && nodeType === 'function_definition') || nodeType === 'arrow_function' || nodeType === 'function_expression' || nodeType === 'lambda_literal' || @@ -2905,14 +2906,28 @@ export class TreeSitterExtractor { const varList = assign.namedChildren.find((c) => c.type === 'variable_list'); const exprList = assign.namedChildren.find((c) => c.type === 'expression_list'); const values = exprList ? exprList.namedChildren : []; - const names = varList ? varList.namedChildren.filter((c) => c.type === 'identifier') : []; - names.forEach((nameNode, i) => { - const name = getNodeText(nameNode, this.source); - if (!name) return; + const targets = varList ? varList.namedChildren : []; + targets.forEach((nameNode, i) => { const valueNode = values[i]; + const target = this.luaAssignmentTarget(nameNode); + if (!target) return; + + if (valueNode?.type === 'function_definition') { + this.extractLuaFunctionValue(valueNode, target.name, target.receiver, docstring); + return; + } + + if (valueNode?.type === 'table_constructor') { + this.extractLuaTableFunctions(valueNode, target.fullName); + } + + // A dotted assignment updates a table member; it is not a standalone + // variable node. Function-valued members were handled above. + if (target.receiver || node.type === 'assignment_statement') return; + const initValue = valueNode ? getNodeText(valueNode, this.source).slice(0, 100) : undefined; const initSignature = initValue ? `= ${initValue}${initValue.length >= 100 ? '...' : ''}` : undefined; - this.createNode(kind, name, nameNode, { docstring, signature: initSignature, isExported }); + this.createNode(kind, target.name, nameNode, { docstring, signature: initSignature, isExported }); }); } else if (this.language === 'c') { // C: a `declaration` node's name nests inside the `declarator` field — @@ -2992,6 +3007,77 @@ export class TreeSitterExtractor { } } + /** Resolve a Lua assignment target into its callable name and optional table receiver. */ + private luaAssignmentTarget(node: SyntaxNode): { name: string; receiver?: string; fullName: string } | null { + if (node.type === 'identifier') { + const name = getNodeText(node, this.source).trim(); + return name ? { name, fullName: name } : null; + } + if ( + node.type !== 'dot_index_expression' && + node.type !== 'method_index_expression' && + node.type !== 'bracket_index_expression' + ) return null; + const table = getChildByField(node, 'table'); + const field = getChildByField(node, 'field') ?? getChildByField(node, 'method'); + if (!table || !field) return null; + const receiver = getNodeText(table, this.source).trim(); + const name = this.luaStaticFieldName(field, node.type === 'bracket_index_expression'); + if (!receiver || !name) return null; + return { name, receiver, fullName: `${receiver}.${name}` }; + } + + /** A statically-known Lua field name; dynamic bracket keys are not callable identities. */ + private luaStaticFieldName(node: SyntaxNode, bracketed: boolean): string { + if (node.type === 'identifier') { + return bracketed ? '' : getNodeText(node, this.source).trim(); + } + if (node.type === 'string') { + const content = node.namedChildren.find((child) => child.type === 'string_content'); + return content ? getNodeText(content, this.source).trim() : ''; + } + return ''; + } + + /** Extract an anonymous Lua function using the name supplied by its assignment target. */ + private extractLuaFunctionValue( + node: SyntaxNode, + name: string, + receiver?: string, + docstring?: string + ): void { + if (!this.extractor) return; + const signature = this.extractor.getSignature?.(node, this.source); + const extra: Partial = { docstring, signature }; + if (receiver) extra.qualifiedName = this.composeReceiverQualifiedName(receiver, name); + else extra.isExported = this.extractor.isExported?.(node, this.source); + + const functionNode = this.createNode(receiver ? 'method' : 'function', name, node, extra); + if (!functionNode) return; + this.nodeStack.push(functionNode.id); + const body = getChildByField(node, this.extractor.bodyField); + if (body) this.visitFunctionBody(body, functionNode.id); + this.nodeStack.pop(); + } + + /** Extract function-valued keyed fields from a Lua table, including nested tables. */ + private extractLuaTableFunctions(table: SyntaxNode, receiver: string): void { + for (const field of table.namedChildren) { + if (field.type !== 'field') continue; + const nameNode = getChildByField(field, 'name'); + const valueNode = getChildByField(field, 'value'); + if (!nameNode || !valueNode) continue; + const bracketed = getNodeText(field, this.source).trimStart().startsWith('['); + const name = this.luaStaticFieldName(nameNode, bracketed); + if (!name) continue; + if (valueNode.type === 'function_definition') { + this.extractLuaFunctionValue(valueNode, name, receiver); + } else if (valueNode.type === 'table_constructor') { + this.extractLuaTableFunctions(valueNode, `${receiver}.${name}`); + } + } + } + /** * Extract a type alias (e.g. `export type X = ...` in TypeScript). * For languages like Go, resolveTypeAliasKind detects when the type_spec