mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-07 02:58:02 +00:00
fix(lua): emit middleclass EXTENDS/HAS_METHOD via capture side channel
`emitLuaHeritageEdges` read each file via `readFileSync(parsed.filePath)`,
but `filePath` is relative — for `analyze <non-cwd-path>` (the normal
case, including `--skip-git`), readFileSync resolved against the project
root and ENOENT-failed on every file, silently skipping EXTENDS and
HAS_METHOD emission. The edges only appeared when `analyze .` was run
from the repo root by accident.
Rewrite to the capture-side-channel pattern (mirrors Java/C/C++): the
heritage pairs (class("Name", Parent) + function Obj:m()) are collected
in `emitLuaScopeCaptures` (parse worker, where the AST is live), snap-
shotted onto `ParsedFile.captureSideChannel` via
`collectCaptureSideChannel`, and read by `emitLuaHeritageEdges` on the
main thread — no file re-read, no re-parse (#1983 no-main-thread-reparse).
- query.ts: move HERITAGE_QUERY + METHOD_OWNER_QUERY + getters here.
- capture-side-channel.ts (new): LuaCaptureSideChannel type + module map
+ collectLuaCaptureSideChannel.
- captures.ts: run the heritage queries against the same tree, stash pairs.
- heritage.ts: read parsed.captureSideChannel, emit EXTENDS + HAS_METHOD;
drop readFileSync + parser + local queries.
- lua.ts: wire collectCaptureSideChannel (assertCloneable).
- lua-scope.test.ts: middleclass fixture asserting Dog -[:EXTENDS]-> Animal
+ Animal/Dog -[:HAS_METHOD]-> their methods.
- Correct stale comments (EXTENDS/HAS_METHOD are B1, not B2/pending) in
scope-resolver.ts, lua.ts, query.ts, import-resolvers/configs/lua.ts.
Verified: lua-scope 6/6 (require + middleclass); scope-resolution 1496/1496;
fixture graph shows Dog -[:EXTENDS]-> Animal + HAS_METHOD under non-cwd analyze.
Co-Authored-By: Claude Sonnet 4 <noreply@anthropic.com>
This commit is contained in:
parent
bb68601cdf
commit
d983e87e7b
8 changed files with 262 additions and 127 deletions
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string, LuaCaptureSideChannel>();
|
||||
|
||||
/** 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();
|
||||
}
|
||||
|
|
@ -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<string, Parser.SyntaxNode> = {};
|
||||
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<string, Parser.SyntaxNode> = {};
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Parser['setLanguage']>[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<Parser['setLanguage']>[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<string>();
|
||||
const emittedHasMethod = new Set<string>();
|
||||
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<string, Parser.SyntaxNode> = {};
|
||||
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<string, Parser.SyntaxNode> = {};
|
||||
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}`;
|
||||
|
|
|
|||
|
|
@ -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<Parser['setLanguage']>[0],
|
||||
HERITAGE_QUERY,
|
||||
);
|
||||
}
|
||||
return _heritageQuery;
|
||||
}
|
||||
|
||||
export function getMethodOwnerQuery(): Parser.Query {
|
||||
if (_methodOwnerQuery === null) {
|
||||
_methodOwnerQuery = new Parser.Query(
|
||||
getLanguageGrammar(SupportedLanguages.Lua) as Parameters<Parser['setLanguage']>[0],
|
||||
METHOD_OWNER_QUERY,
|
||||
);
|
||||
}
|
||||
return _methodOwnerQuery;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue