fix(lua): address review findings

This commit is contained in:
taoxin 2026-08-21 16:42:32 +08:00
parent cc060f7ab1
commit 16f3641ca5
11 changed files with 99 additions and 20 deletions

View file

@ -66,7 +66,9 @@ const LUA_SCOPE_QUERY = `
;; receiver-linkable: collectNamespaceTargets registers X -> target file,
;; and the member call resolves via Case 1 (namespace).
;; 2. bare require("a.b.c") (side-effect, no binding) — emits wildcard;
;; the IMPORTS edge still materializes but no receiver is bound.
;; the IMPORTS edge still materializes but no receiver is bound. The
;; statement-level patterns below only accept direct chunk/block children,
;; so a bound require is not matched a second time through its nested call.
(local_variable_declaration
(variable_list (variable name: (identifier) @import.localName))
(expression_list
@ -75,10 +77,17 @@ const LUA_SCOPE_QUERY = `
arguments: (argument_list (expression_list (string) @import.source)))
(#eq? @_req "require"))) @import.statement
(call
function: (variable name: (identifier) @_req)
arguments: (argument_list (expression_list (string) @import.source))
(#eq? @_req "require")) @import.statement
(chunk
(call
function: (variable name: (identifier) @_req)
arguments: (argument_list (expression_list (string) @import.source))
(#eq? @_req "require")) @import.statement)
(block
(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 the require call — harmless: require has no def, so it stays an

View file

@ -57,8 +57,10 @@ const luaScopeResolver: ScopeResolver = {
_cachedIndex = buildSuffixIndex(list, list);
_cachedSet = allFilePaths;
}
const list = [...allFilePaths];
return suffixResolve(parts, list, list, _cachedIndex ?? undefined);
// 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);
},
// Lua: default local-first-then-imports merge (no language-specific precedence).

View file

@ -217,7 +217,7 @@ const loadCache = new Map<string, LoadResult>();
const logged = new Set<string>();
/**
* Runtime opt-out for genuinely-optional grammars (Swift/Dart/Kotlin).
* Runtime opt-out for genuinely-optional grammars (Swift/Dart/Kotlin/Lua).
*
* `GITNEXUS_SKIP_OPTIONAL_GRAMMARS` has historically been an *install-time*
* env only — the postinstall build scripts read it to skip building the
@ -231,7 +231,7 @@ const logged = new Set<string>();
* Accepts `1` / `true` / `all` / `*` (every skippable grammar), or a
* comma-separated list of language ids and/or package names
* (e.g. `swift,tree-sitter-dart`). Only grammars flagged `userSkippable` (the
* genuinely-optional swift/dart/kotlin) can be skipped — required dependencies
* genuinely-optional swift/dart/kotlin/lua) can be skipped — required dependencies
* routed through the optional machinery for ABI safety (C) carry no
* `userSkippable` and are never skippable here.
*/

View file

@ -44,8 +44,10 @@ export const vendoredGrammarDir = (packageName: string): string =>
* Load a vendored tree-sitter grammar by its absolute path under `vendor/`.
*
* GitNexus vendors six grammars (c/dart/proto/swift/kotlin/lua) inside its own
* package under `vendor/`, each shipping committed per-platform prebuilds. They
* are deliberately NOT npm dependencies and must NEVER be copied into
* package under `vendor/`. Grammars load a matching committed prebuild when
* available; Lua is source-built by the grammar workflow/postinstall because
* it does not currently ship committed prebuilds. They are deliberately NOT
* npm dependencies and must NEVER be copied into
* `node_modules`: an undeclared package under `node_modules` is "extraneous" to
* every subsequent `npm`/`npx` arborist reify, which prunes or relocates it.
* That is the root cause of #2111 / #1728 — on Windows the relocation throws

View file

@ -63,10 +63,10 @@ run()
it('emits IMPORTS edge from main.lua to lib/util.lua', () => {
const imports = getRelationships(result, 'IMPORTS');
const imp = imports.find(
const utilImports = imports.filter(
(e) => e.sourceFilePath?.includes('main.lua') && e.targetFilePath?.includes('util.lua'),
);
expect(imp).toBeDefined();
expect(utilImports).toHaveLength(1);
});
it('resolves run -> util.answer() as CALLS edge to util.lua', () => {
@ -83,6 +83,25 @@ run()
});
});
describe('Lua scope: bare require import', () => {
it('emits one IMPORTS edge for an unbound side-effect require', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lua-scope-bare-import-'));
try {
writeFixtureRepo(tmpDir, {
'lib/util.lua': 'return {}\n',
'main.lua': 'require("lib.util")\n',
});
const result = await runPipelineFromRepo(tmpDir, () => {});
const imports = getRelationships(result, 'IMPORTS').filter(
(e) => e.sourceFilePath?.includes('main.lua') && e.targetFilePath?.includes('util.lua'),
);
expect(imports).toHaveLength(1);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}, 60000);
});
// ---------------------------------------------------------------------------
// middleclass: class("Name", Parent) → EXTENDS + HAS_METHOD across files
// ---------------------------------------------------------------------------

View file

@ -1,5 +1,9 @@
import { describe, it, expect } from 'vitest';
import { isLanguageAvailable, loadLanguage } from '../../src/core/tree-sitter/parser-loader.js';
import {
isGrammarRuntimeSkipped,
isLanguageAvailable,
loadLanguage,
} from '../../src/core/tree-sitter/parser-loader.js';
import { SupportedLanguages } from '../../src/config/supported-languages.js';
describe('isLanguageAvailable', () => {
@ -23,8 +27,13 @@ describe('isLanguageAvailable', () => {
expect(isLanguageAvailable(SupportedLanguages.Swift)).toBe(true);
});
it('returns true for Lua in the default install (vendored grammar)', () => {
expect(isLanguageAvailable(SupportedLanguages.Lua)).toBe(true);
it('reports Lua availability according to the optional grammar state', () => {
const available = isLanguageAvailable(SupportedLanguages.Lua);
if (isGrammarRuntimeSkipped(SupportedLanguages.Lua)) {
expect(available).toBe(false);
} else {
expect(typeof available).toBe('boolean');
}
});
it('handles Kotlin based on optional dependency availability', () => {

View file

@ -168,4 +168,15 @@ describe('parser-loader ABI load-smoke (#1922)', () => {
expect(tree.rootNode.type).toBe(testCase.rootType);
});
}
it('repeatedly creates and parses Lua parsers without stale scanner state', () => {
const grammar = getLanguageGrammar(SupportedLanguages.Lua);
for (let i = 0; i < 16; i += 1) {
const parser = new Parser();
parser.setLanguage(grammar as Parameters<Parser['setLanguage']>[0]);
const snippet = i % 2 === 0 ? 'local x = "lua"\n' : '-- comment\nlocal x = [[lua]]\n';
const tree = parser.parse(snippet);
expect(tree.rootNode.type).toBe('chunk');
}
});
});

View file

@ -6,7 +6,7 @@ import { SupportedLanguages } from '../../src/config/supported-languages.js';
*
* `GITNEXUS_SKIP_OPTIONAL_GRAMMARS` used to be an install-time-only env (the
* postinstall build scripts read it). `parser-loader` now also honors it at
* analyze time: when set, genuinely-optional grammars (swift/dart/kotlin)
* analyze time: when set, genuinely-optional grammars (swift/dart/kotlin/lua)
* report unavailable so the ingestion pipeline skips their files, mirroring a
* genuinely-absent binding. Grammars that are required `dependencies` routed
* through the optional machinery for ABI safety (C — `severity: 'error'`) are
@ -39,6 +39,7 @@ describe('parser-loader GITNEXUS_SKIP_OPTIONAL_GRAMMARS runtime gate', () => {
expect(isLanguageAvailable(SupportedLanguages.Swift)).toBe(false);
expect(isLanguageAvailable(SupportedLanguages.Dart)).toBe(false);
expect(isLanguageAvailable(SupportedLanguages.Kotlin)).toBe(false);
expect(isLanguageAvailable(SupportedLanguages.Lua)).toBe(false);
});
it('skip=all/true/* also skip every optional grammar', async () => {
@ -47,6 +48,7 @@ describe('parser-loader GITNEXUS_SKIP_OPTIONAL_GRAMMARS runtime gate', () => {
expect(isLanguageAvailable(SupportedLanguages.Swift), `value=${v}`).toBe(false);
expect(isLanguageAvailable(SupportedLanguages.Dart), `value=${v}`).toBe(false);
expect(isLanguageAvailable(SupportedLanguages.Kotlin), `value=${v}`).toBe(false);
expect(isLanguageAvailable(SupportedLanguages.Lua), `value=${v}`).toBe(false);
}
});

View file

@ -312,6 +312,17 @@ const CASES: ReadonlyMap<SupportedLanguages, ConformanceCase> = new Map([
reachesDecoy: 'package:app/http.dart',
},
],
[
SupportedLanguages.Lua,
{
files: ['vendor/ghost.lua', 'lib/main.lua'],
fromFile: 'lib/main.lua',
resolutionConfig: undefined,
external: 'external.ghost',
decoy: 'vendor/ghost.lua',
reachesDecoy: 'vendor.ghost',
},
],
[
SupportedLanguages.Swift,
{
@ -376,6 +387,7 @@ const KNOWN_GAPS: ReadonlyMap<SupportedLanguages, string> = new Map<SupportedLan
[SupportedLanguages.Ruby, '`rails/generators` -> `lib/generators.rb`'],
[SupportedLanguages.PHP, '`Vendor\\Ghost\\Missing` -> `lib/Legacy/Missing.php`'],
[SupportedLanguages.Dart, '`package:http/http.dart` -> `lib/http.dart`'],
[SupportedLanguages.Lua, '`external.ghost` -> `vendor/ghost.lua`'],
[SupportedLanguages.Swift, '`Foundation` -> `Sources/Foundation/Thing.swift`'],
[SupportedLanguages.C, '`stdio.h` -> `src/stdio.h`'],
[SupportedLanguages.CPlusPlus, '`cstdio.h` -> `src/cstdio.h`'],

View file

@ -528,6 +528,19 @@ const FIXTURES: ReadonlyMap<SupportedLanguages, ImportTargetFixture> = new Map<
minimumParsedFileReads: 0,
},
],
[
SupportedLanguages.Lua,
{
files: ['lib/models.lua', 'lib/main.lua'],
fromFile: 'lib/main.lua',
resolutionConfig: undefined,
missTarget: (i) => `vendor${i}.ghost`,
hitTarget: 'lib.models',
parsedImport: IGNORES_CONTEXT,
minimumScans: 1,
minimumParsedFileReads: 0,
},
],
]);
/**

View file

@ -50,7 +50,7 @@ struct ScannerState
void *tree_sitter_lua_external_scanner_create()
{
return malloc(sizeof(struct ScannerState));
return calloc(1, sizeof(struct ScannerState));
}
void tree_sitter_lua_external_scanner_destroy(void *payload)
@ -278,12 +278,12 @@ bool tree_sitter_lua_external_scanner_scan(void *payload, TSLexer *lexer, const
if (valid_symbols[STRING_START])
{
// try to match a short single-quoted string's start (")
// try to match a short single-quoted string's start (')
if (consume_if(lexer, SQ_STRING_DELIMITER))
{
state->started = SHORT_SQ_STRING;
}
// try to match a short double-quoted string's start (')
// try to match a short double-quoted string's start (")
else if (consume_if(lexer, DQ_STRING_DELIMITER))
{
state->started = SHORT_DQ_STRING;