From f192855ba449b451f128a4e9dce70c6eea912065 Mon Sep 17 00:00:00 2001 From: taoxin <1015954373@qq.com> Date: Mon, 24 Aug 2026 10:19:27 +0800 Subject: [PATCH] fix(lua): address resolver and scanner review findings --- .../ingestion/import-resolvers/configs/lua.ts | 12 ++++++-- .../core/ingestion/import-resolvers/utils.ts | 14 +++++---- .../ingestion/languages/lua/scope-resolver.ts | 13 +++++--- .../integration/resolvers/lua-scope.test.ts | 28 +++++++++++++++++ .../unit/esm-extension-resolution.test.ts | 6 ++++ gitnexus/test/unit/parser-loader-abi.test.ts | 18 +++++++++-- gitnexus/vendor/tree-sitter-lua/src/scanner.c | 30 +++++++++++++++---- 7 files changed, 101 insertions(+), 20 deletions(-) diff --git a/gitnexus/src/core/ingestion/import-resolvers/configs/lua.ts b/gitnexus/src/core/ingestion/import-resolvers/configs/lua.ts index 874784c17..ab1b64e9c 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/configs/lua.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/configs/lua.ts @@ -2,7 +2,7 @@ * 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 + * (tries .lua then /init.lua — see LUA_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). * @@ -15,13 +15,19 @@ */ import { SupportedLanguages } from 'gitnexus-shared'; import type { ImportResolutionConfig, ImportResolverStrategy } from '../types.js'; -import { suffixResolve } from '../utils.js'; +import { LUA_EXTENSIONS, 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); + const resolved = suffixResolve( + pathParts, + ctx.normalizedFileList, + ctx.allFileList, + ctx.index, + LUA_EXTENSIONS, + ); return resolved ? { kind: 'files', files: [resolved] } : null; }; diff --git a/gitnexus/src/core/ingestion/import-resolvers/utils.ts b/gitnexus/src/core/ingestion/import-resolvers/utils.ts index 0a035f045..4a10d79c9 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/utils.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/utils.ts @@ -22,9 +22,6 @@ 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 @@ -57,6 +54,9 @@ export const EXTENSIONS = [ '.rb', ]; +/** Lua module extensions used only by Lua import resolvers. */ +export const LUA_EXTENSIONS = ['', '.lua', '/init.lua'] as const; + /** * Try to match a path (with extensions) against the known file set. * Returns the matched file path or null. @@ -64,8 +64,9 @@ export const EXTENSIONS = [ export function tryResolveWithExtensions( basePath: string, allFiles: ReadonlySet, + extensions: readonly string[] = EXTENSIONS, ): string | null { - for (const ext of EXTENSIONS) { + for (const ext of extensions) { const candidate = basePath + ext; if (allFiles.has(candidate)) return candidate; } @@ -392,11 +393,12 @@ export function suffixResolve( normalizedFileList: readonly string[], allFileList: readonly string[], index?: SuffixIndex, + extensions: readonly string[] = EXTENSIONS, ): string | null { if (index) { for (let i = 0; i < pathParts.length; i++) { const suffix = pathParts.slice(i).join('/'); - for (const ext of EXTENSIONS) { + for (const ext of extensions) { const suffixWithExt = suffix + ext; const result = index.get(suffixWithExt) || index.getInsensitive(suffixWithExt); if (result) return result; @@ -408,7 +410,7 @@ export function suffixResolve( // Fallback: linear scan (for backward compatibility) for (let i = 0; i < pathParts.length; i++) { const suffix = pathParts.slice(i).join('/'); - for (const ext of EXTENSIONS) { + for (const ext of extensions) { const suffixWithExt = suffix + ext; const suffixPattern = '/' + suffixWithExt; const matchIdx = normalizedFileList.findIndex( diff --git a/gitnexus/src/core/ingestion/languages/lua/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/lua/scope-resolver.ts index c0abc65b9..5d720a27c 100644 --- a/gitnexus/src/core/ingestion/languages/lua/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/lua/scope-resolver.ts @@ -14,7 +14,12 @@ 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 { + buildSuffixIndex, + LUA_EXTENSIONS, + 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'; @@ -43,7 +48,7 @@ const luaScopeResolver: ScopeResolver = { return undefined; }, - // require("a.b.c") → module path a/b/c (+ EXTENSIONS: .lua / /init.lua). + // require("a.b.c") → module path a/b/c (+ Lua 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) => { @@ -60,11 +65,11 @@ const luaScopeResolver: ScopeResolver = { // Once the suffix index exists, suffixResolve only consults the index; // passing a spread copy here would still traverse the entire workspace on // every require() despite the cache (#2909 contract). - return suffixResolve(parts, [], [], _cachedIndex ?? undefined); + return suffixResolve(parts, [], [], _cachedIndex ?? undefined, LUA_EXTENSIONS); }, // Lua: default local-first-then-imports merge (no language-specific precedence). - mergeBindings: (existing) => [...existing], + mergeBindings: (existing, incoming) => [...existing, ...incoming], // Lua varargs (...) + optional params make static arity checks unreliable — // 'unknown' (no signal) is the safe minimal choice. diff --git a/gitnexus/test/integration/resolvers/lua-scope.test.ts b/gitnexus/test/integration/resolvers/lua-scope.test.ts index c14021c65..27815e570 100644 --- a/gitnexus/test/integration/resolvers/lua-scope.test.ts +++ b/gitnexus/test/integration/resolvers/lua-scope.test.ts @@ -19,8 +19,10 @@ import { runPipelineFromRepo, type PipelineResult, } from './helpers.js'; +import { SupportedLanguages, type BindingRef, type ScopeId } from 'gitnexus-shared'; import { emitLuaScopeCaptures } from '../../../src/core/ingestion/languages/lua/index.js'; import { collectLuaCaptureSideChannel } from '../../../src/core/ingestion/languages/lua/capture-side-channel.js'; +import { luaScopeResolver } from '../../../src/core/ingestion/languages/lua/scope-resolver.js'; function writeFixtureRepo(root: string, files: Record): void { for (const [rel, content] of Object.entries(files)) { @@ -30,6 +32,32 @@ function writeFixtureRepo(root: string, files: Record): void { } } +describe('Lua scope resolver binding merge', () => { + it('retains imported bindings when layering them onto existing bindings', () => { + const local = { + def: { nodeId: 'local', filePath: 'main.lua', type: 'Variable', qualifiedName: 'local' }, + origin: 'local', + } satisfies BindingRef; + const imported = { + def: { nodeId: 'imported', filePath: 'lib.lua', type: 'Variable', qualifiedName: 'imported' }, + origin: 'import', + } satisfies BindingRef; + + expect(luaScopeResolver.mergeBindings([local], [imported], 'scope:main' as ScopeId)).toEqual([ + local, + imported, + ]); + expect(luaScopeResolver.language).toBe(SupportedLanguages.Lua); + }); +}); + +describe('Lua scope resolver import extensions', () => { + it('prefers the Lua module when another language has the same module stem', () => { + const files = new Set(['main.lua', 'foo.ts', 'foo.lua']); + expect(luaScopeResolver.resolveImportTarget('foo', 'main.lua', files)).toBe('foo.lua'); + }); +}); + // --------------------------------------------------------------------------- // require("lib.util") + member call util.answer() across files // --------------------------------------------------------------------------- diff --git a/gitnexus/test/unit/esm-extension-resolution.test.ts b/gitnexus/test/unit/esm-extension-resolution.test.ts index 794888049..4e2684f45 100644 --- a/gitnexus/test/unit/esm-extension-resolution.test.ts +++ b/gitnexus/test/unit/esm-extension-resolution.test.ts @@ -88,6 +88,12 @@ describe('TypeScript ESM .js extension resolution', () => { expect(result).toBe('src/utils.ts'); }); + it('does not resolve a JavaScript extensionless import to a Lua file', () => { + const ctx = makeCtx(['src/index.js', 'src/utils.lua']); + const result = resolve('src/index.js', './utils', SupportedLanguages.JavaScript, ctx); + expect(result).toBeNull(); + }); + it('does NOT apply ESM fallback for non-TS/JS languages', () => { const ctx = makeCtx(['src/main.py', 'src/utils.ts']); const result = resolve('src/main.py', './utils.js', SupportedLanguages.Python, ctx); diff --git a/gitnexus/test/unit/parser-loader-abi.test.ts b/gitnexus/test/unit/parser-loader-abi.test.ts index 3b19b66cf..f915c9677 100644 --- a/gitnexus/test/unit/parser-loader-abi.test.ts +++ b/gitnexus/test/unit/parser-loader-abi.test.ts @@ -170,13 +170,27 @@ describe('parser-loader ABI load-smoke (#1922)', () => { } it('repeatedly creates and parses Lua parsers without stale scanner state', () => { - const grammar = getLanguageGrammar(SupportedLanguages.Lua); + let grammar: unknown; + try { + grammar = getLanguageGrammar(SupportedLanguages.Lua); + } catch (err) { + // Lua is a user-skippable vendored grammar. Its dedicated smoke must + // have the same unavailable-is-okay behavior as the matrix above. + expect(err).toBeInstanceOf(Error); + return; + } + + const longEquals = '='.repeat(300); for (let i = 0; i < 16; i += 1) { const parser = new Parser(); parser.setLanguage(grammar as Parameters[0]); - const snippet = i % 2 === 0 ? 'local x = "lua"\n' : '-- comment\nlocal x = [[lua]]\n'; + const snippet = + i % 2 === 0 + ? 'local x = "lua"\n' + : `-- comment\nlocal x = [${longEquals}[lua]${longEquals}]\n`; const tree = parser.parse(snippet); expect(tree.rootNode.type).toBe('chunk'); + expect(tree.rootNode.hasError).toBe(false); } }); }); diff --git a/gitnexus/vendor/tree-sitter-lua/src/scanner.c b/gitnexus/vendor/tree-sitter-lua/src/scanner.c index 3a06e6218..644098cea 100644 --- a/gitnexus/vendor/tree-sitter-lua/src/scanner.c +++ b/gitnexus/vendor/tree-sitter-lua/src/scanner.c @@ -48,6 +48,12 @@ struct ScannerState unsigned int depth; }; +// Keep the scanner state portable across parser checkpoints. The previous +// format stored depth in one byte, which truncated long-bracket delimiters +// with more than 255 '=' characters. Length 2 remains readable for old +// checkpoints; new checkpoints use a full 32-bit little-endian depth. +#define LUA_SCANNER_SERIALIZED_LENGTH 5 + void *tree_sitter_lua_external_scanner_create() { return calloc(1, sizeof(struct ScannerState)); @@ -62,17 +68,31 @@ unsigned int tree_sitter_lua_external_scanner_serialize(void *payload, char *buf { struct ScannerState *state = payload; buffer[0] = state->started; - buffer[1] = state->depth; - return 2; + buffer[1] = (char)(state->depth & 0xffu); + buffer[2] = (char)((state->depth >> 8) & 0xffu); + buffer[3] = (char)((state->depth >> 16) & 0xffu); + buffer[4] = (char)((state->depth >> 24) & 0xffu); + return LUA_SCANNER_SERIALIZED_LENGTH; } void tree_sitter_lua_external_scanner_deserialize(void *payload, const char *buffer, unsigned int length) { + struct ScannerState *state = payload; + state->started = 0; + state->depth = 0; + if (length == 2) { - struct ScannerState *state = payload; - state->started = buffer[0]; - state->depth = buffer[1]; + state->started = (enum StartedToken)(unsigned char)buffer[0]; + state->depth = (unsigned int)(unsigned char)buffer[1]; + } + else if (length >= LUA_SCANNER_SERIALIZED_LENGTH) + { + state->started = (enum StartedToken)(unsigned char)buffer[0]; + state->depth = (unsigned int)(unsigned char)buffer[1] | + ((unsigned int)(unsigned char)buffer[2] << 8) | + ((unsigned int)(unsigned char)buffer[3] << 16) | + ((unsigned int)(unsigned char)buffer[4] << 24); } }