fix(lua): link require() member calls across files via namespace import

`local X = require("mod")` now emits a `namespace` ParsedImport (was
`wildcard`), so X.foo() resolves to foo in the target file through
collectNamespaceTargets (Case 1 of receiver-bound-calls). Bare
require("mod") side-effect calls stay `wildcard`.

- query.ts: capture @import.localName from the LHS of
  `local X = require(...)` (new local_variable_declaration pattern);
  keep the bare require() pattern for side-effect imports.
- interpret.ts: return kind:'namespace' + localName + importedName
  when @import.localName is present; correct the overstated comment
  that claimed util.split-style linking was already handled.
- lua-scope.test.ts: cross-file-binding test (mirrors ruby-scope)
  asserting run -[:CALLS]-> answer + IMPORTS edge — the graph-level
  gap a reviewer caught that registration/ABI tests cannot detect.

Co-Authored-By: Claude Sonnet 4 <noreply@anthropic.com>
This commit is contained in:
taoxin 2026-08-05 11:13:00 +08:00 • committed by taoxin
parent 354f6dc9f2
commit bb68601cdf
3 changed files with 117 additions and 7 deletions

View file

@ -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 };
}

View file

@ -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))

View file

@ -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<string, string>): 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');
});
});