diff --git a/gitnexus/src/core/ingestion/languages/lua/interpret.ts b/gitnexus/src/core/ingestion/languages/lua/interpret.ts index 6aa1cde5d..9e153d166 100644 --- a/gitnexus/src/core/ingestion/languages/lua/interpret.ts +++ b/gitnexus/src/core/ingestion/languages/lua/interpret.ts @@ -2,11 +2,20 @@ * Lua import interpretation (RFC #909). * * `require("a.b.c")` loads the module and (conventionally) returns its table. - * We emit a `wildcard` ParsedImport so the IMPORTS edge (this file → required - * file) is materialized and the target's exported names become resolvable as - * the module's local name. Call-target linking for `util.split` style member - * calls is handled by the scope-resolution registry against the import - * binding — refined in Phase B3 (receiver/arity polish). + * Two forms: + * - `local X = require("a.b.c")`: emits a `namespace` ParsedImport so the + * IMPORTS edge materializes AND `X.foo()` member calls resolve across files + * (collectNamespaceTargets registers `X → target file`; Case 1 of the + * receiver-bound-calls pass links `X.foo()` to the target's `foo`). + * - bare `require("a.b.c")` (side-effect, no binding): emits `wildcard`; the + * IMPORTS edge materializes but no receiver is bound. + * + * `importedName` is the last dot-segment of the module path (e.g. `util` for + * `lib.util`) — used by finalize to bind the target's self-named export when + * present; harmless when the module returns an unnamed table. + * + * Receiver/arity precision beyond the namespace-receiver path (e.g. resolving + * `local f = M.answer; f()`) remains Phase B3. */ import type { CaptureMatch, ParsedImport } from 'gitnexus-shared'; @@ -19,5 +28,11 @@ export function interpretLuaImport(captures: CaptureMatch): ParsedImport | null if (source === undefined) return null; const targetRaw = stripQuotes(source); if (!targetRaw) return null; + const localName = captures['@import.localName']?.text; + if (localName) { + const segments = targetRaw.split('.').filter(Boolean); + const importedName = segments[segments.length - 1] ?? localName; + return { kind: 'namespace', localName, importedName, targetRaw }; + } return { kind: 'wildcard', targetRaw }; } diff --git a/gitnexus/src/core/ingestion/languages/lua/query.ts b/gitnexus/src/core/ingestion/languages/lua/query.ts index 911c421a0..8ba408278 100644 --- a/gitnexus/src/core/ingestion/languages/lua/query.ts +++ b/gitnexus/src/core/ingestion/languages/lua/query.ts @@ -58,8 +58,21 @@ const LUA_SCOPE_QUERY = ` ;; ── Imports — require("a.b.c") ────────────────────────────────────────────── ;; require is a plain (call) whose function is a (variable name: (identifier)). -;; The string arg is captured as @import.source; interpretLuaImport strips -;; quotes and resolves it via the legacy luaRequireStrategy (suffixResolve). +;; Two forms: +;; 1. local X = require("a.b.c") — @import.localName captures the LHS binding +;; so interpretLuaImport emits a namespace import. This makes X.foo() +;; receiver-linkable: collectNamespaceTargets registers X -> target file, +;; and the member call resolves via Case 1 (namespace). +;; 2. bare require("a.b.c") (side-effect, no binding) — emits wildcard; +;; the IMPORTS edge still materializes but no receiver is bound. +(local_variable_declaration + (variable_list (variable name: (identifier) @import.localName)) + (expression_list + (call + function: (variable name: (identifier) @_req) + arguments: (argument_list (expression_list (string) @import.source))) + (#eq? @_req "require"))) @import.statement + (call function: (variable name: (identifier) @_req) arguments: (argument_list (expression_list (string) @import.source)) diff --git a/gitnexus/test/integration/resolvers/lua-scope.test.ts b/gitnexus/test/integration/resolvers/lua-scope.test.ts new file mode 100644 index 000000000..489897e54 --- /dev/null +++ b/gitnexus/test/integration/resolvers/lua-scope.test.ts @@ -0,0 +1,82 @@ +/** + * Lua scope-resolution integration tests. + * + * Validates the cross-file member-call contract: `local X = require("mod")` + * binds X as a namespace receiver so `X.foo()` resolves to `foo` in the + * target file via collectNamespaceTargets (Case 1 of receiver-bound-calls). + * + * Mirrors `ruby-scope.test.ts` (require_relative) and the cross-file-binding + * standard: a 2-file fixture indexed via runPipelineFromRepo with CALLS / + * IMPORTS edge assertions at the graph level, not just registration/ABI. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import path from 'path'; +import fs from 'node:fs'; +import os from 'node:os'; +import { + getRelationships, + getNodesByLabel, + runPipelineFromRepo, + type PipelineResult, +} from './helpers.js'; + +function writeFixtureRepo(root: string, files: Record): void { + for (const [rel, content] of Object.entries(files)) { + const abs = path.join(root, rel); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, content, 'utf8'); + } +} + +// --------------------------------------------------------------------------- +// require("lib.util") + member call util.answer() across files +// --------------------------------------------------------------------------- + +describe('Lua scope: require + cross-file member call', () => { + let result: PipelineResult; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lua-scope-imports-')); + writeFixtureRepo(tmpDir, { + 'lib/util.lua': `local M = {} +function M.answer() + return 42 +end +return M +`, + 'main.lua': `local util = require("lib.util") +local function run() + return util.answer() +end +run() +`, + }); + result = await runPipelineFromRepo(tmpDir, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('emits IMPORTS edge from main.lua to lib/util.lua', () => { + const imports = getRelationships(result, 'IMPORTS'); + const imp = imports.find( + (e) => e.sourceFilePath?.includes('main.lua') && e.targetFilePath?.includes('util.lua'), + ); + expect(imp).toBeDefined(); + }); + + it('resolves run -> util.answer() as CALLS edge to util.lua', () => { + const calls = getRelationships(result, 'CALLS'); + const answerCall = calls.find( + (c) => c.target === 'answer' && c.source === 'run' && c.targetFilePath?.includes('util.lua'), + ); + expect(answerCall).toBeDefined(); + }); + + it('detects answer as a Method node and run as a Function node', () => { + expect(getNodesByLabel(result, 'Method')).toContain('answer'); + expect(getNodesByLabel(result, 'Function')).toContain('run'); + }); +});