diff --git a/gitnexus/src/core/ingestion/import-resolvers/configs/lua.ts b/gitnexus/src/core/ingestion/import-resolvers/configs/lua.ts index 7e17226d6..874784c17 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/configs/lua.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/configs/lua.ts @@ -1,15 +1,17 @@ /** - * Lua import resolution config. + * Lua import resolution config (legacy Phase A path). * * `require("a.b.c")` → module path `a/b/c`, resolved via suffixResolve * (tries .lua then /init.lua — see EXTENSIONS in utils.ts). Mirrors * rubyRequireStrategy but splits on `.` (Lua's module separator) and strips * surrounding quotes (the @import.source capture is a string literal). * - * NOTE: inert in Phase A. The legacy parse-worker skips @import captures - * (parse-worker.ts:1654) — IMPORTS edges are emitted by the scope-resolution - * phase once lua/query.ts + emitScopeCaptures land (Phase B). This config is - * provided because LanguageProvider.importResolver is a required field. + * NOTE: bypassed when scope-resolution is active (Phase B1). IMPORTS edges are + * emitted by the scope-resolution phase via `interpretLuaImport` + + * `ScopeResolver.resolveImportTarget` (lua/scope-resolver.ts), not this legacy + * `importResolver`. This config is provided because `LanguageProvider. + * importResolver` is a required field; the suffix-resolve logic is shared + * with the scope-resolution path through `suffixResolve`. */ import { SupportedLanguages } from 'gitnexus-shared'; import type { ImportResolutionConfig, ImportResolverStrategy } from '../types.js'; @@ -19,12 +21,7 @@ export const luaRequireStrategy: ImportResolverStrategy = (rawImportPath, _fileP const cleaned = rawImportPath.replace(/^["']|["']$/g, ''); const pathParts = cleaned.split('.').filter(Boolean); if (pathParts.length === 0) return null; - const resolved = suffixResolve( - pathParts, - ctx.normalizedFileList, - ctx.allFileList, - ctx.index, - ); + const resolved = suffixResolve(pathParts, ctx.normalizedFileList, ctx.allFileList, ctx.index); return resolved ? { kind: 'files', files: [resolved] } : null; }; diff --git a/gitnexus/src/core/ingestion/languages/lua.ts b/gitnexus/src/core/ingestion/languages/lua.ts index dd6817674..2da94185a 100644 --- a/gitnexus/src/core/ingestion/languages/lua.ts +++ b/gitnexus/src/core/ingestion/languages/lua.ts @@ -8,14 +8,20 @@ * Phase B1 (scope resolution): `emitScopeCaptures` (lua/captures.ts) runs the * scope query (lua/query.ts) and the central ScopeExtractor builds the scope * tree + declarations + imports + reference sites. `interpretImport` turns - * require()'s `@import.source` into a wildcard ParsedImport; the legacy + * require()'s `@import.source` into a `namespace` ParsedImport when a local + * binding is captured (`local X = require(...)`) so `X.foo()` resolves across + * files, or `wildcard` for bare side-effect requires; the legacy * `importResolver` (luaRequireStrategy / suffixResolve) is bridged to resolve * `targetRaw` → file (no separate resolveImportTarget needed, mirroring * Ruby). This unlocks CALLS edges (from @reference.call.*) and IMPORTS edges. * - * Still Phase B (pending): middleclass `class("Name", Parent)` → Class nodes - * + EXTENDS + HAS_METHOD (call-router pattern); receiverBinding/arity polish - * for higher-quality call-target linking. + * `collectCaptureSideChannel` snapshots middleclass heritage pairs + * (`class("Name", Parent)` + `function Obj:m()`) collected in the worker onto + * `ParsedFile.captureSideChannel`, so `emitLuaHeritageEdges` emits EXTENDS + + * HAS_METHOD on the main thread without re-reading or re-parsing (#1983). + * + * Pending: middleclass MRO + `__base` super-call resolution (Phase B2); + * indirect value-receiver + arity precision (Phase B3). */ import { SupportedLanguages } from 'gitnexus-shared'; import { defineLanguage } from '../language-provider.js'; @@ -26,6 +32,8 @@ import { createImportResolver } from '../import-resolvers/resolver-factory.js'; import { luaImportConfig } from '../import-resolvers/configs/lua.js'; import { createCallExtractor } from '../call-extractors/generic.js'; import { luaCallConfig } from '../call-extractors/configs/lua.js'; +import { assertCloneable } from '../workers/clone-safety.js'; +import { collectLuaCaptureSideChannel } from './lua/capture-side-channel.js'; import { emitLuaScopeCaptures, interpretLuaImport } from './lua/index.js'; export const luaProvider = defineLanguage({ @@ -37,5 +45,6 @@ export const luaProvider = defineLanguage({ importResolver: createImportResolver(luaImportConfig), callExtractor: createCallExtractor(luaCallConfig), emitScopeCaptures: emitLuaScopeCaptures, + collectCaptureSideChannel: (filePath) => assertCloneable(collectLuaCaptureSideChannel(filePath)), interpretImport: interpretLuaImport, }); diff --git a/gitnexus/src/core/ingestion/languages/lua/capture-side-channel.ts b/gitnexus/src/core/ingestion/languages/lua/capture-side-channel.ts new file mode 100644 index 000000000..e3e075101 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/lua/capture-side-channel.ts @@ -0,0 +1,50 @@ +/** + * Lua capture-time side channel — heritage pairs collected in the parse worker + * (where the tree-sitter AST is live) and snapshotted onto + * `ParsedFile.captureSideChannel` so the main-thread `emitLuaHeritageEdges` + * hook emits EXTENDS + HAS_METHOD edges WITHOUT re-reading or re-parsing the + * file (the #1983 no-main-thread-re-parse contract). + * + * Mirrors `languages/java/capture-side-channel.ts` and the C/CPP equivalents: + * `emitScopeCaptures` populates this map as a side effect, then + * `LanguageProvider.collectCaptureSideChannel` snapshots it per file. + */ +export interface LuaExtendsPair { + /** Child class name (quotes stripped from the `class("Name", ...)` string arg). */ + readonly child: string; + /** Parent class identifier (bare `variable name:` in the 2nd arg). */ + readonly parent: string; +} + +export interface LuaMethodOwnerPair { + /** Receiver/table identifier — the class that owns the method. */ + readonly owner: string; + readonly method: string; + /** 0-based row of the `function_definition_statement` (for Method node lookup). */ + readonly defRow: number; +} + +export interface LuaCaptureSideChannel { + readonly kind: 'lua'; + readonly extendsPairs: readonly LuaExtendsPair[]; + readonly methodOwners: readonly LuaMethodOwnerPair[]; +} + +const _facts = new Map(); + +/** Populate from `emitLuaScopeCaptures` (worker). Overwrites per file per run. */ +export function setLuaHeritageFacts(filePath: string, facts: LuaCaptureSideChannel): void { + _facts.set(filePath, facts); +} + +/** Snapshot hook for `LanguageProvider.collectCaptureSideChannel`. Returns + * `undefined` when no heritage pairs were collected (no middleclass in the + * file), so the field is omitted from the ParsedFile. */ +export function collectLuaCaptureSideChannel(filePath: string): LuaCaptureSideChannel | undefined { + return _facts.get(filePath); +} + +/** Clear facts retained by a prior workspace pass in a long-lived process. */ +export function clearLuaHeritageFacts(): void { + _facts.clear(); +} diff --git a/gitnexus/src/core/ingestion/languages/lua/captures.ts b/gitnexus/src/core/ingestion/languages/lua/captures.ts index faa1135ad..84d73f0fb 100644 --- a/gitnexus/src/core/ingestion/languages/lua/captures.ts +++ b/gitnexus/src/core/ingestion/languages/lua/captures.ts @@ -4,10 +4,15 @@ * Minimal grouping: parse (or reuse the worker's cached AST) → run the scope * query → group each match's captures into a CaptureMatch keyed by `@name`. * No Ruby-style decomposition (Lua's require is captured directly in the query - * as @import.statement + @import.source), no YARD, no receiver synthesis — - * those are deferred (receiver/arity polish, Phase B3). + * as @import.statement + @import.source + @import.localName), no YARD, no + * receiver synthesis — those are deferred (receiver/arity polish, Phase B3). * - * The central ScopeExtractor partitions the output by capture prefix + * Side effect: also runs the heritage + method-owner queries against the same + * AST and stashes the pairs into the capture-side-channel map, so the main- + * thread `emitLuaHeritageEdges` hook can emit EXTENDS + HAS_METHOD edges + * WITHOUT re-reading or re-parsing the file (#1983 no-main-thread-re-parse). + * + * The central ScopeExtractor partitions the capture output by prefix * (@scope.* / @declaration.* / @import.* / @reference.*) and builds the scope * tree, declarations, imports, and reference sites that finalize turns into * CALLS + IMPORTS edges. @@ -15,13 +20,22 @@ import Parser from 'tree-sitter'; import type { Capture, CaptureMatch } from 'gitnexus-shared'; import { nodeToCapture } from '../../utils/ast-helpers.js'; -import { getLuaParser, getLuaScopeQuery } from './query.js'; +import { getLuaParser, getLuaScopeQuery, getHeritageQuery, getMethodOwnerQuery } from './query.js'; +import { + setLuaHeritageFacts, + type LuaExtendsPair, + type LuaMethodOwnerPair, +} from './capture-side-channel.js'; import { getTreeSitterBufferSize } from '../../constants.js'; import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js'; +function stripQuotes(s: string): string { + return s.replace(/^["']|["']$/g, ''); +} + export function emitLuaScopeCaptures( sourceText: string, - _filePath: string, + filePath: string, cachedTree?: unknown, ): readonly CaptureMatch[] { let tree: Parser.Tree; @@ -55,5 +69,33 @@ export function emitLuaScopeCaptures( } out.push(grouped); } + + // Heritage pairs (middleclass EXTENDS + HAS_METHOD) — collected here in the + // worker where the AST is live, snapshotted onto ParsedFile.captureSideChannel + // by `collectLuaCaptureSideChannel`, consumed by `emitLuaHeritageEdges`. + const extendsPairs: LuaExtendsPair[] = []; + for (const m of getHeritageQuery().matches(tree.rootNode)) { + const caps: Record = {}; + for (const c of m.captures) caps[c.name] = c.node; + const child = stripQuotes(caps['child.name']?.text ?? ''); + const parent = caps['parent.name']?.text; + if (child.length > 0 && parent !== undefined) { + extendsPairs.push({ child, parent }); + } + } + const methodOwners: LuaMethodOwnerPair[] = []; + for (const m of getMethodOwnerQuery().matches(tree.rootNode)) { + const caps: Record = {}; + for (const c of m.captures) caps[c.name] = c.node; + const owner = caps['method.owner']?.text; + const method = caps['method.name']?.text; + const defNode = caps['method.def']; + if (owner === undefined || method === undefined || defNode === undefined) continue; + methodOwners.push({ owner, method, defRow: defNode.startPosition.row }); + } + if (extendsPairs.length > 0 || methodOwners.length > 0) { + setLuaHeritageFacts(filePath, { kind: 'lua', extendsPairs, methodOwners }); + } + return out; } diff --git a/gitnexus/src/core/ingestion/languages/lua/heritage.ts b/gitnexus/src/core/ingestion/languages/lua/heritage.ts index 357b594fa..eae77be5f 100644 --- a/gitnexus/src/core/ingestion/languages/lua/heritage.ts +++ b/gitnexus/src/core/ingestion/languages/lua/heritage.ts @@ -1,82 +1,32 @@ /** - * Lua middleclass inheritance edges (EXTENDS). + * Lua middleclass heritage edges — EXTENDS + HAS_METHOD. * * middleclass has no syntactic class body — `class("Name", Parent)` is a plain * call, and methods are file-top-level `function Name:method()`. So neither - * lexical heritage nor lexical HAS_METHOD applies. This hook re-parses each - * file for `class("Name", Parent)` calls and emits EXTENDS from the child Class - * graph node to the parent Class graph node, resolving both via `nodeLookup`. + * lexical heritage nor lexical HAS_METHOD applies. This hook reads the + * heritage pairs that `emitLuaScopeCaptures` stashed onto + * `ParsedFile.captureSideChannel` (collected in the parse worker where the AST + * 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 + * (#1983 no-main-thread-re-parse contract). * * Mirrors `emitRubyMixinEdges` (the only other `emitHeritageEdges` impl), but - * reads the heritage pair straight from the AST rather than threading markers + * the heritage pair arrives via the capture side channel rather than threaded * through `parsedImports` — middleclass's single-arg form needs no marker * decomposition, and the parent is a bare identifier in the source. */ -import { readFileSync } from 'node:fs'; -import Parser from 'tree-sitter'; -import { SupportedLanguages, type ParsedFile, type NodeLabel } from 'gitnexus-shared'; -import { getLanguageGrammar } from '../../../tree-sitter/parser-loader.js'; +import { type ParsedFile, type NodeLabel } from 'gitnexus-shared'; import { isClassLike } from '../../scope-resolution/scope/walkers.js'; import { resolveDefGraphId } from '../../scope-resolution/graph-bridge/ids.js'; -import { positionKey, type GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-lookup.js'; +import { + positionKey, + type GraphNodeLookup, +} from '../../scope-resolution/graph-bridge/node-lookup.js'; import type { KnowledgeGraph } from '../../../graph/types.js'; import { generateId } from '../../../../lib/utils.js'; -import { getLuaParser } from './query.js'; - -// `local Foo = class("Foo", Parent)` — the 2nd arg is a bare (variable name:). -// `class("Foo")` (no parent) does not match: this pattern requires the trailing -// variable, so parentless classes correctly emit no EXTENDS edge. -const HERITAGE_QUERY = ` -(call - function: (variable name: (identifier) @_class) - arguments: (argument_list - (expression_list - (string) @child.name - (variable name: (identifier) @parent.name))) - (#eq? @_class "class")) @heritage -`; - -// `function ClassName:method()` (colon) and `function ClassName.method()` (dot). -// The receiver (table) is captured as @method.owner; resolves to a Class node -// for HAS_METHOD. middleclass methods are file-top-level (not in a class body), -// so lexical HAS_METHOD cannot produce these. -const METHOD_OWNER_QUERY = ` -(function_definition_statement - name: (variable - table: (identifier) @method.owner - method: (identifier) @method.name)) @method.def - -(function_definition_statement - name: (variable - table: (identifier) @method.owner - field: (identifier) @method.name)) @method.def -`; - -let _heritageQuery: Parser.Query | null = null; -function getHeritageQuery(): Parser.Query { - if (_heritageQuery === null) { - _heritageQuery = new Parser.Query( - getLanguageGrammar(SupportedLanguages.Lua) as Parameters[0], - HERITAGE_QUERY, - ); - } - return _heritageQuery; -} - -let _methodOwnerQuery: Parser.Query | null = null; -function getMethodOwnerQuery(): Parser.Query { - if (_methodOwnerQuery === null) { - _methodOwnerQuery = new Parser.Query( - getLanguageGrammar(SupportedLanguages.Lua) as Parameters[0], - METHOD_OWNER_QUERY, - ); - } - return _methodOwnerQuery; -} - -function stripQuotes(s: string): string { - return s.replace(/^["']|["']$/g, ''); -} +import type { LuaCaptureSideChannel } from './capture-side-channel.js'; export function emitLuaHeritageEdges( graph: KnowledgeGraph, @@ -105,35 +55,15 @@ export function emitLuaHeritageEdges( const emittedExtends = new Set(); const emittedHasMethod = new Set(); - const parser = getLuaParser(); - const heritageQuery = getHeritageQuery(); - const methodOwnerQuery = getMethodOwnerQuery(); for (const parsed of parsedFiles) { - let src: string; - try { - src = readFileSync(parsed.filePath, 'utf8'); - } catch { - continue; - } - let tree; - try { - tree = parser.parse(src); - } catch { - continue; - } + const channel = parsed.captureSideChannel as LuaCaptureSideChannel | undefined; + if (channel === undefined || channel.kind !== 'lua') continue; // ── EXTENDS: class("Name", Parent) ────────────────────────────────────── - const heritageMatches = heritageQuery.matches(tree.rootNode); - for (const m of heritageMatches) { - const caps: Record = {}; - for (const c of m.captures) caps[c.name] = c.node; - const childName = stripQuotes(caps['child.name']?.text ?? ''); - const parentName = caps['parent.name']?.text; - if (childName.length === 0 || parentName === undefined) continue; + for (const { child, parent } of channel.extendsPairs) { const childGid = - graphIdByFileAndName.get(`${parsed.filePath}::${childName}`) ?? - graphIdByName.get(childName); - const parentGid = graphIdByName.get(parentName); + graphIdByFileAndName.get(`${parsed.filePath}::${child}`) ?? graphIdByName.get(child); + const parentGid = graphIdByName.get(parent); if (childGid === undefined || parentGid === undefined) continue; const edgeKey = `${childGid}->${parentGid}`; if (emittedExtends.has(edgeKey)) continue; @@ -149,21 +79,13 @@ export function emitLuaHeritageEdges( } // ── HAS_METHOD: function ClassName:method() / function ClassName.method() ─ - const methodMatches = methodOwnerQuery.matches(tree.rootNode); - for (const m of methodMatches) { - const caps: Record = {}; - for (const c of m.captures) caps[c.name] = c.node; - const ownerName = caps['method.owner']?.text; - const methodName = caps['method.name']?.text; - const defNode = caps['method.def']; - if (ownerName === undefined || methodName === undefined || defNode === undefined) continue; + for (const { owner, method, defRow } of channel.methodOwners) { const classGid = - graphIdByFileAndName.get(`${parsed.filePath}::${ownerName}`) ?? - graphIdByName.get(ownerName); + graphIdByFileAndName.get(`${parsed.filePath}::${owner}`) ?? graphIdByName.get(owner); if (classGid === undefined) continue; // Resolve the Method graph node by position (0-based row + simple name). const methodGid = nodeLookup.get( - positionKey(parsed.filePath, 'Method' as NodeLabel, defNode.startPosition.row, methodName), + positionKey(parsed.filePath, 'Method' as NodeLabel, defRow, method), ); if (methodGid === undefined) continue; const edgeKey = `${classGid}->${methodGid}`; diff --git a/gitnexus/src/core/ingestion/languages/lua/query.ts b/gitnexus/src/core/ingestion/languages/lua/query.ts index 8ba408278..0ac552336 100644 --- a/gitnexus/src/core/ingestion/languages/lua/query.ts +++ b/gitnexus/src/core/ingestion/languages/lua/query.ts @@ -49,8 +49,10 @@ const LUA_SCOPE_QUERY = ` ;; ── Declarations — middleclass classes: class("Name"[, Parent]) ────────────── ;; local Foo = class("Foo", Parent) — class() is a plain call; the first -;; string arg is the class name (quotes stripped in captures.ts). The second -;; arg (Parent) → EXTENDS is Phase B2b (heritage marker). +;; string arg is the class name (quotes stripped in captures.ts) and emits a +;; Class node. The parent arg (Parent) → EXTENDS is captured by the separate +;; HERITAGE_QUERY below (snapshotted via the capture side channel; see +;; captures.ts + heritage.ts) — this pattern only matches the 1-arg form. (call function: (variable name: (identifier) @_class) arguments: (argument_list (expression_list (string) @declaration.name)) @@ -99,8 +101,43 @@ const LUA_SCOPE_QUERY = ` field: (identifier) @reference.name)) @reference.call.member `; +// ── Heritage queries (run in the parse worker where the AST is live; the +// pairs are snapshotted onto ParsedFile.captureSideChannel so the main- +// thread heritage hook emits edges WITHOUT re-reading/re-parsing). ──────── + +// local Foo = class("Foo", Parent) — 2nd arg is a bare (variable name:). +// class("Foo") (no parent) does not match: this pattern requires the trailing +// variable, so parentless classes correctly emit no EXTENDS edge. +const HERITAGE_QUERY = ` +(call + function: (variable name: (identifier) @_class) + arguments: (argument_list + (expression_list + (string) @child.name + (variable name: (identifier) @parent.name))) + (#eq? @_class "class")) @heritage +`; + +// function ClassName:method() (colon) and function ClassName.method() (dot). +// The receiver (table) is captured as @method.owner; resolves to a Class node +// for HAS_METHOD. middleclass methods are file-top-level (not in a class body), +// so lexical HAS_METHOD cannot produce these. +const METHOD_OWNER_QUERY = ` +(function_definition_statement + name: (variable + table: (identifier) @method.owner + method: (identifier) @method.name)) @method.def + +(function_definition_statement + name: (variable + table: (identifier) @method.owner + field: (identifier) @method.name)) @method.def +`; + let _parser: Parser | null = null; let _query: Parser.Query | null = null; +let _heritageQuery: Parser.Query | null = null; +let _methodOwnerQuery: Parser.Query | null = null; export function getLuaParser(): Parser { if (_parser === null) { @@ -121,3 +158,23 @@ export function getLuaScopeQuery(): Parser.Query { } return _query; } + +export function getHeritageQuery(): Parser.Query { + if (_heritageQuery === null) { + _heritageQuery = new Parser.Query( + getLanguageGrammar(SupportedLanguages.Lua) as Parameters[0], + HERITAGE_QUERY, + ); + } + return _heritageQuery; +} + +export function getMethodOwnerQuery(): Parser.Query { + if (_methodOwnerQuery === null) { + _methodOwnerQuery = new Parser.Query( + getLanguageGrammar(SupportedLanguages.Lua) as Parameters[0], + METHOD_OWNER_QUERY, + ); + } + return _methodOwnerQuery; +} diff --git a/gitnexus/src/core/ingestion/languages/lua/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/lua/scope-resolver.ts index 9e24ef4e3..6b9524c4a 100644 --- a/gitnexus/src/core/ingestion/languages/lua/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/lua/scope-resolver.ts @@ -4,8 +4,10 @@ * * Minimal Phase B1 wiring: Lua's scope model (Module + Function/Method, no * static types) plugs into `runScopeResolution` with the least configuration - * that unlocks CALLS + IMPORTS edges. middleclass class inheritance (EXTENDS, - * HAS_METHOD, MRO) is Phase B2; receiver/arity polish is Phase B3. + * that unlocks CALLS + IMPORTS edges. middleclass EXTENDS + HAS_METHOD are + * emitted by `emitLuaHeritageEdges` (heritage.ts), fed by the capture side + * channel; middleclass MRO + `__base` super calls are Phase B2; the + * receiver/arity polish for indirect value receivers is Phase B3. * * Reference: `languages/cobol/scope-resolver.ts` (minimal) + `languages/go/`. */ @@ -34,7 +36,10 @@ const luaScopeResolver: ScopeResolver = { // targetRaw arrives quote-stripped (interpretLuaImport); Lua's module // separator is `.`, so split on it before joining to a path. resolveImportTarget: (targetRaw, _fromFile, allFilePaths) => { - const parts = targetRaw.replace(/^["']|["']$/g, '').split('.').filter(Boolean); + const parts = targetRaw + .replace(/^["']|["']$/g, '') + .split('.') + .filter(Boolean); if (parts.length === 0) return null; if (_cachedSet !== allFilePaths) { const list = [...allFilePaths]; diff --git a/gitnexus/test/integration/resolvers/lua-scope.test.ts b/gitnexus/test/integration/resolvers/lua-scope.test.ts index 489897e54..9c905f902 100644 --- a/gitnexus/test/integration/resolvers/lua-scope.test.ts +++ b/gitnexus/test/integration/resolvers/lua-scope.test.ts @@ -80,3 +80,56 @@ run() expect(getNodesByLabel(result, 'Function')).toContain('run'); }); }); + +// --------------------------------------------------------------------------- +// middleclass: class("Name", Parent) → EXTENDS + HAS_METHOD across files +// --------------------------------------------------------------------------- + +describe('Lua scope: middleclass EXTENDS + HAS_METHOD', () => { + let result: PipelineResult; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lua-scope-heritage-')); + writeFixtureRepo(tmpDir, { + 'base.lua': `local class = require("lib.class") +local Animal = class("Animal") +function Animal:speak() + return "..." +end +return Animal +`, + 'dog.lua': `local class = require("lib.class") +local Animal = require("base") +local Dog = class("Dog", Animal) +function Dog:bark() + return "woof" +end +return Dog +`, + }); + result = await runPipelineFromRepo(tmpDir, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('emits EXTENDS from Dog to Animal across files', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + const dogExtendsAnimal = extends_.find((e) => e.source === 'Dog' && e.target === 'Animal'); + expect(dogExtendsAnimal).toBeDefined(); + }); + + it('emits HAS_METHOD from each class to its methods', () => { + const hasMethod = getRelationships(result, 'HAS_METHOD'); + expect(hasMethod.find((e) => e.source === 'Animal' && e.target === 'speak')).toBeDefined(); + expect(hasMethod.find((e) => e.source === 'Dog' && e.target === 'bark')).toBeDefined(); + }); + + it('detects Dog and Animal as Class nodes', () => { + const classes = getNodesByLabel(result, 'Class'); + expect(classes).toContain('Dog'); + expect(classes).toContain('Animal'); + }); +});