fix(lua): resolve heritage parents through imports

This commit is contained in:
taoxin 2026-08-20 18:59:28 +08:00
parent 52b74d02b3
commit cc060f7ab1
6 changed files with 89 additions and 19 deletions

View file

@ -342,7 +342,7 @@ GitNexus supports indexing multiple repositories. Each `gitnexus analyze` regist
## Supported Languages
TypeScript, JavaScript, Python, Java, C, C++, C#, Go, Rust, PHP, Kotlin, Swift, Ruby, Dart
TypeScript, JavaScript, Python, Java, C, C++, C#, Go, Rust, PHP, Kotlin, Swift, Ruby, Dart, Lua
### Language Feature Matrix
@ -362,6 +362,11 @@ TypeScript, JavaScript, Python, Java, C, C++, C#, Go, Rust, PHP, Kotlin, Swift,
| C | — | — | ✓ | — | ✓ | ✓ | — | ✓ | ✓ |
| C++ | — | — | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
| Dart | ✓ | — | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
| Lua | ✓ | ✓ | ✓ | ✓* | — | — | — | — | — |
Lua heritage currently covers middleclass-style `EXTENDS` and `HAS_METHOD`
edges. It does not yet claim complete MRO, `__base` super-call, indirect
receiver, or arity-precision support.
**Imports** — cross-file import resolution · **Named Bindings** — `import { X as Y }` / re-export tracking · **Exports** — public/exported symbol detection · **Heritage** — class inheritance, interfaces, mixins · **Type Annotations** — explicit type extraction for receiver resolution · **Constructor Inference** — infer receiver type from constructor calls (`self`/`this` resolution included for all languages) · **Config** — language toolchain config parsing (tsconfig, go.mod, etc.) · **Frameworks** — AST-based framework pattern detection · **Entry Points** — entry point scoring heuristics
@ -520,7 +525,7 @@ results until you re-run `gitnexus analyze --repair-fts` from a shell where the
### Installation fails with native module errors
Some optional language grammars (Dart, Proto, Swift, Kotlin) require native compilation. If they fail, GitNexus still works — those languages will be skipped. To skip them intentionally (no C++ toolchain needed), set `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` before installing.
Some optional language grammars (Dart, Proto, Swift, Kotlin, Lua) require native compilation. If they fail, GitNexus still works — those languages will be skipped. To skip them intentionally (no C++ toolchain needed), set `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` before installing.
If `npm install -g gitnexus` fails on native modules:

View file

@ -9,7 +9,7 @@
* was live) and emits:
* - EXTENDS from the child Class node to the parent Class node, and
* - HAS_METHOD from a class's Class node to its file-top-level Method nodes.
* Both resolve via `nodeLookup` / `graphIdByName`. NO file re-read or re-parse
* Both resolve via `nodeLookup` and finalized scope bindings. NO file re-read or re-parse
* (#1983 no-main-thread-re-parse contract).
*
* Mirrors `emitRubyMixinEdges` (the only other `emitHeritageEdges` impl), but
@ -18,7 +18,10 @@
* decomposition, and the parent is a bare identifier in the source.
*/
import { type ParsedFile, type NodeLabel } from 'gitnexus-shared';
import { isClassLike } from '../../scope-resolution/scope/walkers.js';
import {
isClassLike,
resolveInheritanceBaseInScope,
} from '../../scope-resolution/scope/walkers.js';
import { resolveDefGraphId } from '../../scope-resolution/graph-bridge/ids.js';
import {
positionKey,
@ -27,17 +30,14 @@ import {
import type { KnowledgeGraph } from '../../../graph/types.js';
import { generateId } from '../../../../lib/utils.js';
import type { LuaCaptureSideChannel } from './capture-side-channel.js';
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
export function emitLuaHeritageEdges(
graph: KnowledgeGraph,
parsedFiles: readonly ParsedFile[],
nodeLookup: GraphNodeLookup,
scopes?: ScopeResolutionIndexes,
): void {
// name → graphId (global, for parent resolution). First-wins; same-named
// classes in different files are rare in middleclass codebases, and a
// collision here only risks a wrong parent — acceptable for a 0.85-confidence
// heuristic edge (better an imperfect EXTENDS than none).
const graphIdByName = new Map<string, string>();
// (filePath, name) → graphId (per-file, for child resolution).
const graphIdByFileAndName = new Map<string, string>();
for (const parsed of parsedFiles) {
@ -48,7 +48,6 @@ export function emitLuaHeritageEdges(
const qn = def.qualifiedName ?? '';
if (qn.length > 0) {
graphIdByFileAndName.set(`${parsed.filePath}::${qn}`, gid);
if (!graphIdByName.has(qn)) graphIdByName.set(qn, gid);
}
}
}
@ -61,10 +60,12 @@ export function emitLuaHeritageEdges(
// ── EXTENDS: class("Name", Parent) ──────────────────────────────────────
for (const { child, parent } of channel.extendsPairs) {
const childGid =
graphIdByFileAndName.get(`${parsed.filePath}::${child}`) ?? graphIdByName.get(child);
const parentGid = graphIdByName.get(parent);
if (childGid === undefined || parentGid === undefined) continue;
const childGid = graphIdByFileAndName.get(`${parsed.filePath}::${child}`);
if (childGid === undefined || scopes === undefined) continue;
const parentDef = resolveInheritanceBaseInScope(parsed.moduleScope, parent, scopes);
if (parentDef === undefined) continue;
const parentGid = resolveDefGraphId(parentDef.filePath, parentDef, nodeLookup);
if (parentGid === undefined) continue;
const edgeKey = `${childGid}->${parentGid}`;
if (emittedExtends.has(edgeKey)) continue;
emittedExtends.add(edgeKey);
@ -80,8 +81,7 @@ export function emitLuaHeritageEdges(
// ── HAS_METHOD: function ClassName:method() / function ClassName.method() ─
for (const { owner, method, defRow } of channel.methodOwners) {
const classGid =
graphIdByFileAndName.get(`${parsed.filePath}::${owner}`) ?? graphIdByName.get(owner);
const classGid = graphIdByFileAndName.get(`${parsed.filePath}::${owner}`);
if (classGid === undefined) continue;
// Resolve the Method graph node by position (0-based row + simple name).
const methodGid = nodeLookup.get(

View file

@ -79,7 +79,7 @@ const luaScopeResolver: ScopeResolver = {
// middleclass `class("Name", Parent)` — emits EXTENDS. middleclass has no
// syntactic class body, so lexical heritage cannot produce these; the hook
// re-parses for the class() call's parent arg.
// consumes the capture side-channel's class() facts.
emitHeritageEdges: emitLuaHeritageEdges,
// Lua has globals (`function foo()` is global) — let unresolved free calls

View file

@ -43,7 +43,7 @@ export const vendoredGrammarDir = (packageName: string): string =>
/**
* Load a vendored tree-sitter grammar by its absolute path under `vendor/`.
*
* GitNexus vendors five grammars (c/dart/proto/swift/kotlin) inside its own
* GitNexus vendors six grammars (c/dart/proto/swift/kotlin/lua) inside its own
* package under `vendor/`, each shipping committed per-platform prebuilds. They
* are deliberately NOT npm dependencies and must NEVER be copied into
* `node_modules`: an undeclared package under `node_modules` is "extraneous" to

View file

@ -136,6 +136,71 @@ return Dog
});
});
// ---------------------------------------------------------------------------
// middleclass heritage: duplicate class names must follow imports or decline
// ---------------------------------------------------------------------------
describe('Lua scope: middleclass heritage name collisions', () => {
it('resolves an imported duplicate parent to the imported file', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lua-scope-heritage-collision-'));
try {
writeFixtureRepo(tmpDir, {
'lib/a.lua': `local class = require("lib.class")
local Animal = class("Animal")
return Animal
`,
'lib/b.lua': `local class = require("lib.class")
local Animal = class("Animal")
return Animal
`,
'dog.lua': `local class = require("lib.class")
local Animal = require("lib.a")
local Dog = class("Dog", Animal)
return Dog
`,
});
const result = await runPipelineFromRepo(tmpDir, () => {});
const dogExtendsAnimal = getRelationships(result, 'EXTENDS').find(
(edge) => edge.source === 'Dog' && edge.target === 'Animal',
);
expect(dogExtendsAnimal).toBeDefined();
expect(dogExtendsAnimal?.targetFilePath).toContain(path.join('lib', 'a.lua'));
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}, 60000);
it('does not guess when duplicate parents are not disambiguated by imports', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lua-scope-heritage-ambiguous-'));
try {
writeFixtureRepo(tmpDir, {
'lib/a.lua': `local class = require("lib.class")
local Animal = class("Animal")
return Animal
`,
'lib/b.lua': `local class = require("lib.class")
local Animal = class("Animal")
return Animal
`,
'dog.lua': `local class = require("lib.class")
local Dog = class("Dog", Animal)
return Dog
`,
});
const result = await runPipelineFromRepo(tmpDir, () => {});
expect(
getRelationships(result, 'EXTENDS').some(
(edge) => edge.source === 'Dog' && edge.target === 'Animal',
),
).toBe(false);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}, 60000);
});
// ---------------------------------------------------------------------------
// heritage lifecycle: re-capture with no middleclass must not retain stale
// EXTENDS / HAS_METHOD facts from a prior pass (reanalysis regression)

View file

@ -14,7 +14,7 @@ import { SupportedLanguages } from '../../src/config/supported-languages.js';
*
* - Required grammars MUST load and parse — an ABI-incompatible native
* binding (the #1242-class failure) fails here loudly.
* - Optional / vendored grammars (swift/dart/kotlin) must either load OR
* - Optional / vendored grammars (swift/dart/kotlin/lua) must either load OR
* cleanly report unavailable — never hard-crash the process.
*
* Swift is prebuilt-only (no introspectable parser.c) so the static Python