mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-07 02:58:02 +00:00
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:
parent
354f6dc9f2
commit
bb68601cdf
3 changed files with 117 additions and 7 deletions
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
82
gitnexus/test/integration/resolvers/lua-scope.test.ts
Normal file
82
gitnexus/test/integration/resolvers/lua-scope.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue