diff --git a/gitnexus/src/core/ingestion/export-detection.ts b/gitnexus/src/core/ingestion/export-detection.ts index 597d17725..2e256c681 100644 --- a/gitnexus/src/core/ingestion/export-detection.ts +++ b/gitnexus/src/core/ingestion/export-detection.ts @@ -240,18 +240,65 @@ export const swiftExportChecker: ExportChecker = (node, _name) => { /** Ruby: all top-level definitions are public (no export syntax). */ export const rubyExportChecker: ExportChecker = (_node, _name) => true; -/** Lua: `function foo()` is global (reachable from other files via require/_G); - * `local function foo()` is module-private. */ -export const luaExportChecker: ExportChecker = (node, _name) => { +/** + * Lua module exports are structural rather than keyword-based: + * - `function foo()` is global and therefore visible across files; + * - `local function foo()` is private unless the module returns it directly + * or exposes it through a returned table (`return { foo = foo }`). + * + * The query passes the captured identifier node, not the declaration node, so + * the declaration must be recovered through the parent chain. Only direct + * module-level returns are considered; a return nested in another function + * does not make a nested local part of the module API. + */ +export const luaExportChecker: ExportChecker = (node, name) => { let current: SyntaxNode | null = node; + let declaration: SyntaxNode | null = null; while (current !== null) { - if (current.type === 'local_function_definition_statement') return false; + if (current.type === 'local_function_definition_statement') { + declaration = current; + break; + } if (current.type === 'function_definition_statement') return true; current = current.parent; } - return true; + + if (declaration === null) return true; + + const root = findLuaRoot(declaration); + if (declaration.parent !== root) return false; + return luaModuleReturnExposesName(root, name); }; +function findLuaRoot(node: SyntaxNode): SyntaxNode { + let root = node; + while (root.parent !== null) root = root.parent; + return root; +} + +function luaModuleReturnExposesName(root: SyntaxNode, name: string): boolean { + for (const statement of root.namedChildren ?? []) { + if (statement.type !== 'return_statement') continue; + const expressionList = statement.namedChildren?.find( + (child) => child.type === 'expression_list', + ); + const expression = expressionList?.namedChildren?.[0]; + if (expression === undefined) continue; + + const directName = expression.childForFieldName('name'); + if (directName?.type === 'identifier' && directName.text === name) return true; + + if (expression.type !== 'table') continue; + const fields = expression.namedChildren?.find((child) => child.type === 'field_list'); + for (const field of fields?.namedChildren ?? []) { + if (field.type !== 'field') continue; + const value = field.childForFieldName('value'); + const valueName = value?.childForFieldName('name'); + if (valueName?.type === 'identifier' && valueName.text === name) return true; + } + } + return false; +} /** Dart: public if no leading underscore (convention, same as Python). */ export const dartExportChecker: ExportChecker = (_node, name) => !name.startsWith('_'); diff --git a/gitnexus/test/integration/resolvers/lua-scope.test.ts b/gitnexus/test/integration/resolvers/lua-scope.test.ts index 00a71d682..7c7971b96 100644 --- a/gitnexus/test/integration/resolvers/lua-scope.test.ts +++ b/gitnexus/test/integration/resolvers/lua-scope.test.ts @@ -16,6 +16,7 @@ import os from 'node:os'; import { getRelationships, getNodesByLabel, + getNodesByLabelFull, runPipelineFromRepo, type PipelineResult, } from './helpers.js'; @@ -158,6 +159,43 @@ local value = wrap(require("wrapped")) }, 60000); }); +describe('Lua module export visibility', () => { + it('exports local functions only when the module return value exposes them', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lua-scope-exports-')); + try { + writeFixtureRepo(tmpDir, { + 'module.lua': `local function exposed() end +local function hidden() end +function global() end +return { exposed = exposed } +`, + }); + const result = await runPipelineFromRepo(tmpDir, () => {}); + const functions = getNodesByLabelFull(result, 'Function').filter((node) => + node.properties.filePath?.endsWith('module.lua'), + ); + expect(functions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'exposed', + properties: expect.objectContaining({ isExported: true }), + }), + expect.objectContaining({ + name: 'hidden', + properties: expect.objectContaining({ isExported: false }), + }), + expect.objectContaining({ + name: 'global', + properties: expect.objectContaining({ isExported: true }), + }), + ]), + ); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }, 60000); +}); + // --------------------------------------------------------------------------- // require("lib.util") + member call util.answer() across files // ---------------------------------------------------------------------------