From 2649949ad41d5d4dd62df8919df5fdb2e35ab659 Mon Sep 17 00:00:00 2001 From: taoxin <1015954373@qq.com> Date: Thu, 8 Oct 2026 19:08:39 +0800 Subject: [PATCH] fix(lua): preserve positional capture bindings --- .../core/ingestion/languages/lua/captures.ts | 100 +++++++++--------- .../integration/resolvers/lua-scope.test.ts | 80 ++++++++++++++ 2 files changed, 131 insertions(+), 49 deletions(-) diff --git a/gitnexus/src/core/ingestion/languages/lua/captures.ts b/gitnexus/src/core/ingestion/languages/lua/captures.ts index 92b0192c4..2b25cb3cc 100644 --- a/gitnexus/src/core/ingestion/languages/lua/captures.ts +++ b/gitnexus/src/core/ingestion/languages/lua/captures.ts @@ -126,24 +126,22 @@ function collectLuaImportCaptures(root: Parser.SyntaxNode): readonly CaptureMatc .filter((name): name is Parser.SyntaxNode => name?.type === 'identifier'); // Pair by source position. Never reuse an RHS for multiple LHS names. + const pairedRequires = new Set(); for (let i = 0; i < Math.min(names.length, expressions.length); i++) { const expression = expressions[i]; if (!isRequireCall(expression)) continue; const source = requireStringNode(expression); - if (source !== undefined) out.push(importCapture(node, source, names[i])); + if (source !== undefined) { + pairedRequires.add(expression); + out.push(importCapture(node, source, names[i])); + } } // Keep walking all initializer descendants. Direct require calls are // suppressed because they already have their positional local binding; // nested calls (function bodies, wrappers, and other expressions) still // need their own IMPORTS edge. - const directRequires = new Set(); - for (const expression of expressions) { - if (isRequireCall(expression) && requireStringNode(expression) !== undefined) { - directRequires.add(expression); - } - } - for (const child of node.namedChildren) visit(child, directRequires); + for (const child of node.namedChildren) visit(child, pairedRequires); return; } @@ -254,18 +252,23 @@ function collectLuaAssignmentMethodCaptures(root: Parser.SyntaxNode): readonly C const out: CaptureMatch[] = []; const visit = (node: Parser.SyntaxNode): void => { if (node.type === 'variable_assignment') { - const variable = node.namedChildren.find((child) => child.type === 'variable_list') - ?.namedChildren[0]; - const value = node.namedChildren.find((child) => child.type === 'expression_list') - ?.namedChildren[0]; - const owner = variable?.childForFieldName('table'); - const method = variable?.childForFieldName('field'); - if ( - variable?.type === 'variable' && - owner?.type === 'identifier' && - (method?.type === 'identifier' || method?.type === 'string') && - value?.type === 'function_definition' - ) { + const variables = + node.namedChildren.find((child) => child.type === 'variable_list')?.namedChildren ?? []; + const values = + node.namedChildren.find((child) => child.type === 'expression_list')?.namedChildren ?? []; + for (let index = 0; index < Math.min(variables.length, values.length); index++) { + const variable = variables[index]; + const value = values[index]; + const owner = variable?.childForFieldName('table'); + const method = variable?.childForFieldName('field'); + if ( + variable?.type !== 'variable' || + owner?.type !== 'identifier' || + (method?.type !== 'identifier' && method?.type !== 'string') || + value?.type !== 'function_definition' + ) { + continue; + } let enclosing = node.parent; let nestedInFunction = false; while (enclosing !== null) { @@ -280,8 +283,7 @@ function collectLuaAssignmentMethodCaptures(root: Parser.SyntaxNode): readonly C enclosing = enclosing.parent; } if (nestedInFunction) { - for (const child of node.namedChildren) visit(child); - return; + continue; } const match: Record = { '@scope.function': nodeToCapture('@scope.function', value), @@ -313,33 +315,33 @@ function collectLuaCallableAliases(root: Parser.SyntaxNode): readonly LuaCallabl !nextInFunction && (node.type === 'local_variable_declaration' || node.type === 'variable_assignment') ) { - const destination = node.namedChildren.find((child) => child.type === 'variable_list') - ?.namedChildren[0]; - const source = node.namedChildren.find((child) => child.type === 'expression_list') - ?.namedChildren[0]; - const destinationName = - destination?.type === 'variable' && - destination.childForFieldName('name')?.type === 'identifier' && - destination.childForFieldName('table') === null && - destination.childForFieldName('field') === null - ? destination.childForFieldName('name')?.text - : undefined; - const sourceIsStaticMember = - source?.type === 'variable' && - source.childForFieldName('table')?.type === 'identifier' && - (source.childForFieldName('field')?.type === 'identifier' || - source.childForFieldName('method')?.type === 'identifier'); - const sourceIsSimple = - source?.type === 'variable' && - source.childForFieldName('name')?.type === 'identifier' && - source.childForFieldName('table') === null && - source.childForFieldName('field') === null; - if ( - destinationName !== undefined && - source !== undefined && - (sourceIsStaticMember || sourceIsSimple) - ) { - aliases.push({ destination: destinationName, source: source.text }); + const destinations = + node.namedChildren.find((child) => child.type === 'variable_list')?.namedChildren ?? []; + const sources = + node.namedChildren.find((child) => child.type === 'expression_list')?.namedChildren ?? []; + for (let index = 0; index < Math.min(destinations.length, sources.length); index++) { + const destination = destinations[index]; + const source = sources[index]; + const destinationName = + destination?.type === 'variable' && + destination.childForFieldName('name')?.type === 'identifier' && + destination.childForFieldName('table') === null && + destination.childForFieldName('field') === null + ? destination.childForFieldName('name')?.text + : undefined; + const sourceIsStaticMember = + source?.type === 'variable' && + source.childForFieldName('table')?.type === 'identifier' && + (source.childForFieldName('field')?.type === 'identifier' || + source.childForFieldName('method')?.type === 'identifier'); + const sourceIsSimple = + source?.type === 'variable' && + source.childForFieldName('name')?.type === 'identifier' && + source.childForFieldName('table') === null && + source.childForFieldName('field') === null; + if (destinationName !== undefined && (sourceIsStaticMember || sourceIsSimple)) { + aliases.push({ destination: destinationName, source: source.text }); + } } } for (const child of node.namedChildren) visit(child, nextInFunction); diff --git a/gitnexus/test/integration/resolvers/lua-scope.test.ts b/gitnexus/test/integration/resolvers/lua-scope.test.ts index d36a45d34..a79e44137 100644 --- a/gitnexus/test/integration/resolvers/lua-scope.test.ts +++ b/gitnexus/test/integration/resolvers/lua-scope.test.ts @@ -113,6 +113,32 @@ describe('Lua scope resolver require syntax and positional bindings', () => { } }, 60000); + it('keeps unpaired RHS requires as wildcard imports', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lua-scope-extra-require-')); + try { + writeFixtureRepo(tmpDir, { + 'x.lua': 'return {}\n', + 'y.lua': 'return {}\n', + 'main.lua': 'local x = require("x"), require("y")\n', + }); + const captures = emitLuaScopeCaptures( + 'local x = require("x"), require("y")\n', + 'main.lua', + ).filter((capture) => capture['@import.source'] !== undefined); + expect(captures.map((capture) => capture['@import.source']?.text).sort()).toEqual([ + '"x"', + '"y"', + ]); + expect( + captures.find((capture) => capture['@import.source']?.text === '"y"')?.[ + '@import.localName' + ], + ).toBeUndefined(); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }, 60000); + it('recognizes parenthesis-free short and long-bracket requires', async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lua-scope-require-forms-')); try { @@ -560,6 +586,32 @@ return Dog } }, 60000); + it('pairs assignment-form methods by position', async () => { + const tmpDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'lua-scope-assignment-method-positional-'), + ); + try { + writeFixtureRepo(tmpDir, { + 'dog.lua': `local Dog = class("Dog") +local ignored +ignored, Dog.run = nil, function(self) + return "run" +end +return Dog +`, + }); + + const result = await runPipelineFromRepo(tmpDir, () => {}); + expect( + getRelationships(result, 'HAS_METHOD').some( + (edge) => edge.source === 'Dog' && edge.target === 'run', + ), + ).toBe(true); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }, 60000); + it('captures static string-key assignment-form methods', async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lua-scope-string-method-')); try { @@ -822,6 +874,34 @@ second() } }, 60000); + it('pairs callable aliases by position in multi-assignment', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lua-scope-callable-alias-positional-')); + try { + writeFixtureRepo(tmpDir, { + 'util.lua': `function answer() + return 42 +end +return { answer = answer } +`, + 'main.lua': `local util = require("util") +local ignored, answer = nil, util.answer +answer() +`, + }); + const result = await runPipelineFromRepo(tmpDir, () => {}); + expect( + getRelationships(result, 'CALLS').some( + (edge) => + edge.sourceFilePath?.endsWith('main.lua') && + edge.target === 'answer' && + edge.targetFilePath?.endsWith('util.lua'), + ), + ).toBe(true); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }, 60000); + it('fails closed for callable alias cycles, dynamic keys, and factory results', async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lua-scope-callable-alias-negative-')); try {