diff --git a/.github/vendored-grammars.json b/.github/vendored-grammars.json index 945164348..6f7957d7a 100644 --- a/.github/vendored-grammars.json +++ b/.github/vendored-grammars.json @@ -22,6 +22,11 @@ "proto": { "name": "tree-sitter-proto", "upstream": { "github": "coder3101/tree-sitter-proto" } + }, + "lua": { + "name": "tree-sitter-lua", + "upstream": { "npm": "tree-sitter-lua" }, + "hold": "tree-sitter-lua@2.1.3 ships nan-based node bindings incompatible with the pinned tree-sitter@0.21.1 runtime (setLanguage rejects with 'Invalid language object'); vendored with a Napi binding.cc rebuilt from source. Needs the tree-sitter runtime upgrade (#858) plus an upstream migration to node-addon-api before the monitor can auto-bump" } } } diff --git a/gitnexus-shared/src/language-detection.ts b/gitnexus-shared/src/language-detection.ts index 6d031d761..73c6562b4 100644 --- a/gitnexus-shared/src/language-detection.ts +++ b/gitnexus-shared/src/language-detection.ts @@ -52,6 +52,7 @@ const EXTENSION_MAP: Record = { [SupportedLanguages.Swift]: ['.swift'], [SupportedLanguages.Dart]: ['.dart'], [SupportedLanguages.Vue]: ['.vue'], + [SupportedLanguages.Lua]: ['.lua'], [SupportedLanguages.Cobol]: ['.cbl', '.cob', '.cpy', '.cobol'], } satisfies Record; // Ensure exhaustiveness @@ -120,6 +121,7 @@ const SYNTAX_MAP: Record = { [SupportedLanguages.Swift]: 'swift', [SupportedLanguages.Dart]: 'dart', [SupportedLanguages.Vue]: 'typescript', + [SupportedLanguages.Lua]: 'lua', [SupportedLanguages.Cobol]: 'cobol', } satisfies Record; // Ensure exhaustiveness diff --git a/gitnexus-shared/src/languages.ts b/gitnexus-shared/src/languages.ts index 29edb8d1e..7deb3a220 100644 --- a/gitnexus-shared/src/languages.ts +++ b/gitnexus-shared/src/languages.ts @@ -20,6 +20,7 @@ export enum SupportedLanguages { Swift = 'swift', Dart = 'dart', Vue = 'vue', + Lua = 'lua', /** Standalone regex processor — no tree-sitter, no LanguageProvider. */ Cobol = 'cobol', } diff --git a/gitnexus-shared/src/scope-resolution/language-classification.ts b/gitnexus-shared/src/scope-resolution/language-classification.ts index 10c556cda..4941a0081 100644 --- a/gitnexus-shared/src/scope-resolution/language-classification.ts +++ b/gitnexus-shared/src/scope-resolution/language-classification.ts @@ -10,7 +10,9 @@ * - production: javascript, typescript, python, java, c, cpp, csharp, go, * ruby, rust, php, kotlin, swift, dart * - experimental: vue (embedded-language / SFC complexity), - * cobol (regex-provider path) + * cobol (regex-provider path), + * lua (definition-only legacy DAG path; scope-resolution + * hooks pending — Phase B) * - quarantined: (none) */ @@ -39,6 +41,7 @@ export const LanguageClassifications: Readonly { /** Ruby: all top-level definitions are public (no export syntax). */ export const rubyExportChecker: ExportChecker = (_node, _name) => true; +/** Lua: `function foo()` is global (reachable from other files via require/_G); + * `local function foo()` is module-private. */ +export const luaExportChecker: ExportChecker = (node, _name) => + node.type !== 'local_function_definition_statement'; + /** Dart: public if no leading underscore (convention, same as Python). */ export const dartExportChecker: ExportChecker = (_node, name) => !name.startsWith('_'); diff --git a/gitnexus/src/core/ingestion/import-resolvers/configs/lua.ts b/gitnexus/src/core/ingestion/import-resolvers/configs/lua.ts new file mode 100644 index 000000000..7e17226d6 --- /dev/null +++ b/gitnexus/src/core/ingestion/import-resolvers/configs/lua.ts @@ -0,0 +1,34 @@ +/** + * Lua import resolution config. + * + * `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. + */ +import { SupportedLanguages } from 'gitnexus-shared'; +import type { ImportResolutionConfig, ImportResolverStrategy } from '../types.js'; +import { suffixResolve } from '../utils.js'; + +export const luaRequireStrategy: ImportResolverStrategy = (rawImportPath, _filePath, ctx) => { + 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, + ); + return resolved ? { kind: 'files', files: [resolved] } : null; +}; + +export const luaImportConfig: ImportResolutionConfig = { + language: SupportedLanguages.Lua, + strategies: [luaRequireStrategy], +}; diff --git a/gitnexus/src/core/ingestion/import-resolvers/utils.ts b/gitnexus/src/core/ingestion/import-resolvers/utils.ts index 5dec720bc..0a035f045 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/utils.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/utils.ts @@ -22,6 +22,9 @@ export const EXTENSIONS = [ // Python '.py', '/__init__.py', + // Lua — require("a.b.c") resolves to a/b/c.lua or a/b/c/init.lua + '.lua', + '/init.lua', // Java '.java', // Kotlin diff --git a/gitnexus/src/core/ingestion/languages/index.ts b/gitnexus/src/core/ingestion/languages/index.ts index a67f8bba2..63f683b1e 100644 --- a/gitnexus/src/core/ingestion/languages/index.ts +++ b/gitnexus/src/core/ingestion/languages/index.ts @@ -25,6 +25,7 @@ import { swiftProvider } from './swift.js'; import { dartProvider } from './dart.js'; import { vueProvider } from './vue.js'; import { cobolProvider } from './cobol.js'; +import { luaProvider } from './lua.js'; export const providers = { [SupportedLanguages.JavaScript]: javascriptProvider, @@ -43,6 +44,7 @@ export const providers = { [SupportedLanguages.Dart]: dartProvider, [SupportedLanguages.Vue]: vueProvider, [SupportedLanguages.Cobol]: cobolProvider, + [SupportedLanguages.Lua]: luaProvider, } satisfies Record; /** Get provider by language enum (always succeeds for SupportedLanguages). */ diff --git a/gitnexus/src/core/ingestion/languages/lua.ts b/gitnexus/src/core/ingestion/languages/lua.ts new file mode 100644 index 000000000..dd6817674 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/lua.ts @@ -0,0 +1,41 @@ +/** + * Lua language provider. + * + * Phase A (legacy DAG): emits Function/Method DEFINITION nodes from + * LUA_QUERIES — `function foo()`, `local function foo()`, `function Obj:m()` + * / `function Obj.m()`. + * + * 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 + * `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. + */ +import { SupportedLanguages } from 'gitnexus-shared'; +import { defineLanguage } from '../language-provider.js'; +import { LUA_QUERIES } from '../tree-sitter-queries.js'; +import { typeConfig as luaTypeConfig } from '../type-extractors/lua.js'; +import { luaExportChecker } from '../export-detection.js'; +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 { emitLuaScopeCaptures, interpretLuaImport } from './lua/index.js'; + +export const luaProvider = defineLanguage({ + id: SupportedLanguages.Lua, + extensions: ['.lua'], + treeSitterQueries: LUA_QUERIES, + typeConfig: luaTypeConfig, + exportChecker: luaExportChecker, + importResolver: createImportResolver(luaImportConfig), + callExtractor: createCallExtractor(luaCallConfig), + emitScopeCaptures: emitLuaScopeCaptures, + interpretImport: interpretLuaImport, +}); diff --git a/gitnexus/src/core/ingestion/languages/lua/captures.ts b/gitnexus/src/core/ingestion/languages/lua/captures.ts new file mode 100644 index 000000000..faa1135ad --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/lua/captures.ts @@ -0,0 +1,59 @@ +/** + * Lua scope-capture emitter (RFC #909 Ring 3). + * + * 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). + * + * The central ScopeExtractor partitions the output by capture prefix + * (@scope.* / @declaration.* / @import.* / @reference.*) and builds the scope + * tree, declarations, imports, and reference sites that finalize turns into + * CALLS + IMPORTS edges. + */ +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 { getTreeSitterBufferSize } from '../../constants.js'; +import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js'; + +export function emitLuaScopeCaptures( + sourceText: string, + _filePath: string, + cachedTree?: unknown, +): readonly CaptureMatch[] { + let tree: Parser.Tree; + if (cachedTree !== undefined && cachedTree !== null) { + tree = cachedTree as Parser.Tree; + } else { + tree = parseSourceSafe(getLuaParser(), sourceText, undefined, { + bufferSize: getTreeSitterBufferSize(sourceText), + }); + } + + const out: CaptureMatch[] = []; + for (const match of getLuaScopeQuery().matches(tree.rootNode)) { + const grouped: Record = {}; + for (const c of match.captures) { + const tag = '@' + c.name; + // Skip tree-sitter predicate captures (e.g. @_req used by #eq?). + if (tag.startsWith('@_')) continue; + if (grouped[tag] === undefined) grouped[tag] = nodeToCapture(tag, c.node); + } + if (Object.keys(grouped).length === 0) continue; + // middleclass `class("Name", ...)`: strip surrounding quotes from the name + // so the Class node is named `BattleSkill`, not `"BattleSkill"`. tree-sitter-lua's + // string node has no content child, so .text carries the quotes. + const nameCap = grouped['@declaration.name']; + if (grouped['@declaration.class'] !== undefined && nameCap !== undefined) { + const stripped = nameCap.text.replace(/^["']|["']$/g, ''); + if (stripped !== nameCap.text) { + grouped['@declaration.name'] = { ...nameCap, text: stripped }; + } + } + out.push(grouped); + } + return out; +} diff --git a/gitnexus/src/core/ingestion/languages/lua/heritage.ts b/gitnexus/src/core/ingestion/languages/lua/heritage.ts new file mode 100644 index 000000000..357b594fa --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/lua/heritage.ts @@ -0,0 +1,182 @@ +/** + * Lua middleclass inheritance edges (EXTENDS). + * + * 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`. + * + * Mirrors `emitRubyMixinEdges` (the only other `emitHeritageEdges` impl), but + * reads the heritage pair straight from the AST rather than threading markers + * 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 { 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 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, ''); +} + +export function emitLuaHeritageEdges( + graph: KnowledgeGraph, + parsedFiles: readonly ParsedFile[], + nodeLookup: GraphNodeLookup, +): 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(); + // (filePath, name) → graphId (per-file, for child resolution). + const graphIdByFileAndName = new Map(); + for (const parsed of parsedFiles) { + for (const def of parsed.localDefs) { + if (!isClassLike(def.type)) continue; + const gid = resolveDefGraphId(parsed.filePath, def, nodeLookup); + if (gid === undefined) continue; + const qn = def.qualifiedName ?? ''; + if (qn.length > 0) { + graphIdByFileAndName.set(`${parsed.filePath}::${qn}`, gid); + if (!graphIdByName.has(qn)) graphIdByName.set(qn, gid); + } + } + } + + 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; + } + + // ── 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; + const childGid = + graphIdByFileAndName.get(`${parsed.filePath}::${childName}`) ?? + graphIdByName.get(childName); + const parentGid = graphIdByName.get(parentName); + if (childGid === undefined || parentGid === undefined) continue; + const edgeKey = `${childGid}->${parentGid}`; + if (emittedExtends.has(edgeKey)) continue; + emittedExtends.add(edgeKey); + graph.addRelationship({ + id: generateId('EXTENDS', edgeKey), + sourceId: childGid, + targetId: parentGid, + type: 'EXTENDS', + confidence: 0.85, + reason: 'lua-scope: middleclass inherits', + }); + } + + // ── 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; + const classGid = + graphIdByFileAndName.get(`${parsed.filePath}::${ownerName}`) ?? + graphIdByName.get(ownerName); + 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), + ); + if (methodGid === undefined) continue; + const edgeKey = `${classGid}->${methodGid}`; + if (emittedHasMethod.has(edgeKey)) continue; + emittedHasMethod.add(edgeKey); + graph.addRelationship({ + id: generateId('HAS_METHOD', edgeKey), + sourceId: classGid, + targetId: methodGid, + type: 'HAS_METHOD', + confidence: 0.85, + reason: 'lua-scope: middleclass method owner', + }); + } + } +} diff --git a/gitnexus/src/core/ingestion/languages/lua/index.ts b/gitnexus/src/core/ingestion/languages/lua/index.ts new file mode 100644 index 000000000..6708cb6e8 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/lua/index.ts @@ -0,0 +1,7 @@ +/** + * Lua scope-resolution hooks (RFC #909 Ring 3). Barrel. + */ +export { emitLuaScopeCaptures } from './captures.js'; +export { interpretLuaImport } from './interpret.js'; +export { getLuaParser, getLuaScopeQuery } from './query.js'; +export { emitLuaHeritageEdges } from './heritage.js'; diff --git a/gitnexus/src/core/ingestion/languages/lua/interpret.ts b/gitnexus/src/core/ingestion/languages/lua/interpret.ts new file mode 100644 index 000000000..6aa1cde5d --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/lua/interpret.ts @@ -0,0 +1,23 @@ +/** + * 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). + */ +import type { CaptureMatch, ParsedImport } from 'gitnexus-shared'; + +function stripQuotes(s: string): string { + return s.replace(/^["']|["']$/g, ''); +} + +export function interpretLuaImport(captures: CaptureMatch): ParsedImport | null { + const source = captures['@import.source']?.text; + if (source === undefined) return null; + const targetRaw = stripQuotes(source); + if (!targetRaw) return null; + return { kind: 'wildcard', targetRaw }; +} diff --git a/gitnexus/src/core/ingestion/languages/lua/query.ts b/gitnexus/src/core/ingestion/languages/lua/query.ts new file mode 100644 index 000000000..e0c1b7be0 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/lua/query.ts @@ -0,0 +1,110 @@ +/** + * Lua scope-resolution query (RFC #909). + * + * Captures the structural skeleton the central ScopeExtractor consumes: + * - scopes: (chunk) root + function bodies + * - declarations: function / local function / method (function Obj:m()) + * - imports: require("a.b.c") — the call + its string arg + * - references: free calls foo() + member calls obj:m() / obj.f() + * + * `tree-sitter-lua` is a vendored grammar (loaded lazily via parser-loader, NOT + * statically imported) — mirroring languages/dart/query.ts: a top-level + * `import Lua from 'tree-sitter-lua'` would throw ERR_MODULE_NOT_FOUND on the + * main thread (where the vendored grammar is not in node_modules) and crash + * analyze even for repos with no Lua files (#2091/#2093). + * + * Node names verified against the vendored tree-sitter-lua grammar via the + * smoke test in ~/gitnexus-lua-work/abi-test/smoke2.cjs: + * (chunk) (function_definition_statement name: (identifier|variable)) + * (local_function_definition_statement name: (identifier)) + * (call function: (variable name:|table:+method:|table:+field:) arguments: (argument_list (expression_list ...))) + */ +import Parser from 'tree-sitter'; +import { SupportedLanguages } from 'gitnexus-shared'; +import { getLanguageGrammar } from '../../../tree-sitter/parser-loader.js'; + +const LUA_SCOPE_QUERY = ` +;; ── Scopes ─────────────────────────────────────────────────────────────────── +(chunk) @scope.module + +(function_definition_statement) @scope.function +(local_function_definition_statement) @scope.function + +;; ── Declarations — functions ───────────────────────────────────────────────── +(function_definition_statement + name: (identifier) @declaration.name) @declaration.function + +(local_function_definition_statement + name: (identifier) @declaration.name) @declaration.function + +;; ── Declarations — methods: function Obj:method() / function Obj.field() ──── +;; name is a (variable) with table + method (colon) or table + field (dot). +(function_definition_statement + name: (variable + method: (identifier) @declaration.name)) @declaration.method + +(function_definition_statement + name: (variable + field: (identifier) @declaration.name)) @declaration.method + +;; ── 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). +(call + function: (variable name: (identifier) @_class) + arguments: (argument_list (expression_list (string) @declaration.name)) + (#eq? @_class "class")) @declaration.class + +;; ── 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). +(call + function: (variable name: (identifier) @_req) + arguments: (argument_list (expression_list (string) @import.source)) + (#eq? @_req "require")) @import.statement + +;; ── References — free calls: foo() ─────────────────────────────────────────── +;; Also matches require(...) — harmless: require has no def, so it stays an +;; unresolved reference (no CALLS edge) while the @import.statement pattern +;; above emits the IMPORTS edge. +(call + function: (variable + name: (identifier) @reference.name)) @reference.call.free + +;; ── References — member calls: obj:method() (table + method) ───────────────── +(call + function: (variable + table: (_) @reference.receiver + method: (identifier) @reference.name)) @reference.call.member + +;; ── References — field calls: obj.field() (table + field) ──────────────────── +(call + function: (variable + table: (_) @reference.receiver + field: (identifier) @reference.name)) @reference.call.member +`; + +let _parser: Parser | null = null; +let _query: Parser.Query | null = null; + +export function getLuaParser(): Parser { + if (_parser === null) { + _parser = new Parser(); + _parser.setLanguage( + getLanguageGrammar(SupportedLanguages.Lua) as Parameters[0], + ); + } + return _parser; +} + +export function getLuaScopeQuery(): Parser.Query { + if (_query === null) { + _query = new Parser.Query( + getLanguageGrammar(SupportedLanguages.Lua) as Parameters[0], + LUA_SCOPE_QUERY, + ); + } + return _query; +} diff --git a/gitnexus/src/core/ingestion/languages/lua/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/lua/scope-resolver.ts new file mode 100644 index 000000000..9e24ef4e3 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/lua/scope-resolver.ts @@ -0,0 +1,74 @@ +/** + * Lua `ScopeResolver` registered in `SCOPE_RESOLVERS` and consumed by the + * generic `runScopeResolution` orchestrator. + * + * 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. + * + * Reference: `languages/cobol/scope-resolver.ts` (minimal) + `languages/go/`. + */ +import type { ParsedFile } from 'gitnexus-shared'; +import { SupportedLanguages } from 'gitnexus-shared'; +import { populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js'; +import { buildSuffixIndex, suffixResolve, type SuffixIndex } from '../../import-resolvers/utils.js'; +import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js'; +import { luaProvider } from '../lua.js'; +import { emitLuaHeritageEdges } from './heritage.js'; + +// Cache the suffix index across calls within one analyze run — `allFilePaths` +// is the same ReadonlySet for every Lua import in the run, so keying on its +// reference avoids rebuilding a 500-entry index per require(). Must use the +// index path: the linear-scan fallback in suffixResolve prepends `/` to the +// suffix, which fails to match root-level files like `middleclass.lua`. +let _cachedSet: ReadonlySet | null = null; +let _cachedIndex: SuffixIndex | null = null; + +const luaScopeResolver: ScopeResolver = { + language: SupportedLanguages.Lua, + languageProvider: luaProvider, + importEdgeReason: 'lua-scope: require', + + // require("a.b.c") → module path a/b/c (+ EXTENSIONS: .lua / /init.lua). + // 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); + if (parts.length === 0) return null; + if (_cachedSet !== allFilePaths) { + const list = [...allFilePaths]; + _cachedIndex = buildSuffixIndex(list, list); + _cachedSet = allFilePaths; + } + const list = [...allFilePaths]; + return suffixResolve(parts, list, list, _cachedIndex ?? undefined); + }, + + // Lua: default local-first-then-imports merge (no language-specific precedence). + mergeBindings: (existing) => [...existing], + + // Lua varargs (...) + optional params make static arity checks unreliable — + // 'unknown' (no signal) is the safe minimal choice. + arityCompatibility: () => 'unknown', + + // middleclass single inheritance — MRO populated in Phase B2 (buildMro + + // defaultLinearize). Empty for now (no Class scopes yet). + buildMro: () => new Map(), + + populateOwners: (parsed: ParsedFile) => populateClassOwnedMembers(parsed), + + // Lua has no super-call construct (middleclass __base access is Phase B2). + isSuperReceiver: () => false, + + // 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. + emitHeritageEdges: emitLuaHeritageEdges, + + // Lua has globals (`function foo()` is global) — let unresolved free calls + // fall back to the global symbol table. + allowGlobalFreeCallFallback: true, +}; + +export { luaScopeResolver }; diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/registry.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/registry.ts index cd225d9a2..76a219d07 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/registry.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/registry.ts @@ -27,6 +27,7 @@ import { cobolScopeResolver } from '../../languages/cobol/scope-resolver.js'; import { swiftScopeResolver } from '../../languages/swift/scope-resolver.js'; import { dartScopeResolver } from '../../languages/dart/scope-resolver.js'; import { vueScopeResolver } from '../../languages/vue/scope-resolver.js'; +import { luaScopeResolver } from '../../languages/lua/scope-resolver.js'; /** Map of `SupportedLanguages` → `ScopeResolver`. The scope-resolution phase * iterates this map directly — every registered resolver runs. This is the @@ -51,4 +52,5 @@ export const SCOPE_RESOLVERS: ReadonlyMap = n [SupportedLanguages.Swift, swiftScopeResolver], [SupportedLanguages.Dart, dartScopeResolver], [SupportedLanguages.Vue, vueScopeResolver], + [SupportedLanguages.Lua, luaScopeResolver], ]); diff --git a/gitnexus/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index bd0ae6700..b32abe8ff 100644 --- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts @@ -2447,6 +2447,67 @@ export const DART_QUERIES = ` right: (_)) @assignment `; +// Lua queries — works with vendored tree-sitter-lua (rebuilt against the +// tree-sitter@0.21.1 runtime + Napi convention; see vendor/tree-sitter-lua). +// AST node names verified by parsing a sample with the smoke test in +// ~/gitnexus-lua-work/abi-test/smoke2.cjs. +// +// Phase A (legacy DAG): definition NODES + call SITES. NOTE: CALLS and +// IMPORTS EDGES are NOT emitted by the legacy path — call/import resolution +// was migrated to the scope-resolution pipeline (RFC #909, gated on +// emitScopeCaptures; see call-processor.ts:4-7 + parse-worker.ts:1654). The +// @call captures here produce call sites in parse-worker, but those sites +// only become CALLS edges once lua's scope query (lua/query.ts) + the +// emitScopeCaptures hooks land (Phase B). The definition captures DO produce +// Function/Method nodes regardless. +export const LUA_QUERIES = ` +; ── Functions: function foo() ... end (name is a bare identifier) ──────────── +(function_definition_statement + name: (identifier) @name) @definition.function + +; ── Methods: function Obj:method() (name is a variable: table + method) ────── +(function_definition_statement + name: (variable + method: (identifier) @name)) @definition.method + +; ── Dot-methods: function Obj.method() (name is a variable: table + field) ─── +(function_definition_statement + name: (variable + field: (identifier) @name)) @definition.method + +; ── Local functions: local function foo() ... end ───────────────────────────── +(local_function_definition_statement + name: (identifier) @name) @definition.function + +; ── middleclass classes: local Foo = class("Foo"[, Parent]) ───────────────────── +; class() is a plain call; the Class node's name comes from the local var +; (quote-free). EXTENDS (Parent arg) + HAS_METHOD (function Foo:method() → Foo) +; need scope-resolution receiver modeling and the heritage marker; middleclass +; methods are file-top-level (not class-body nested), so the class is not a +; lexical scope — deferred (Phase B2b/B3). +(local_variable_declaration + (variable_list (variable name: (identifier) @name)) + (expression_list + value: (call + function: (variable name: (identifier) @_class) + (#eq? @_class "class")))) @definition.class + +; ── Calls: foo(), obj:bar(), obj.baz() ──────────────────────────────────────── +; call.function is a (variable); @call.name lands on the invoked identifier +; so the generic call extractor can derive calledName + callForm + receiver. +(call + function: (variable + name: (identifier) @call.name)) @call + +(call + function: (variable + method: (identifier) @call.name)) @call + +(call + function: (variable + field: (identifier) @call.name)) @call +`; + import { SupportedLanguages } from 'gitnexus-shared'; export const LANGUAGE_QUERIES: Record = { @@ -2464,6 +2525,7 @@ export const LANGUAGE_QUERIES: Record = { [SupportedLanguages.Ruby]: RUBY_QUERIES, [SupportedLanguages.Swift]: SWIFT_QUERIES, [SupportedLanguages.Dart]: DART_QUERIES, + [SupportedLanguages.Lua]: LUA_QUERIES, [SupportedLanguages.Vue]: TYPESCRIPT_QUERIES, // Vue