fix(lua): clear stale heritage on reanalysis; wire Lua prebuild workflow

Three paths flagged in review (azizur100389):

1. clearLuaHeritageFacts() had no caller, and captures.ts only set
   heritage facts when non-empty — a re-capture of a file that lost its
   middleclass class left prior EXTENDS/HAS_METHOD facts for
   collectLuaCaptureSideChannel to snapshot as stale edges. Add a per-file
   delete on empty recapture (clearLuaHeritageFactsForFile) and wire
   clearLuaHeritageFacts() into loadResolutionConfig (mirrors Java/Kotlin).

2. Lua was absent from build-tree-sitter-prebuilds.yml despite the PR
   vendoring tree-sitter-lua source-only (parser.c/scanner.c/binding.gyp)
   — toolchain-less install failed on platforms the upstream npm package
   doesn't cover. Add lua to the REGISTRY (kind 'vendored') + a parse
   snippet for the validate step; commit-fork-prebuilds.yml is generic.

3. Add a lifecycle regression: emitLuaScopeCaptures with no heritage
   after a prior heritage capture must leave collectLuaCaptureSideChannel
   undefined (verified the test fails without the per-file delete).
This commit is contained in:
taoxin 2026-08-07 10:46:28 +08:00 • committed by taoxin
parent d983e87e7b
commit 52b74d02b3
5 changed files with 79 additions and 2 deletions

View file

@ -7,7 +7,7 @@ name: Build tree-sitter prebuilds
#
# Grammars covered here (the at-risk set — everything else already ships 6
# upstream prebuilds AND stays dependency-review-tracked, so it is left alone).
# All five are vendored under gitnexus/vendor/; `kind` (below) only picks where
# All six are vendored under gitnexus/vendor/; `kind` (below) only picks where
# the build job fetches the C source to compile:
# - tree-sitter-c (vendored prebuild-only; built from the published npm
# package — closes upstream's 4/6 ARM gap #2116 for a
@ -20,6 +20,9 @@ name: Build tree-sitter prebuilds
# - tree-sitter-swift (vendored source; built from gitnexus/vendor/ — its
# prebuilds were originally upstream-shipped, now
# GitNexus-cross-built like the rest for uniformity)
# - tree-sitter-lua (vendored source; built from gitnexus/vendor/ — the
# upstream npm package ships source-only for the
# relevant tuples, so all 6 are cross-built here)
#
# Output: gitnexus/vendor/<grammar>/prebuilds/<platform-arch>/<grammar>.node for
# all 6 targets ({linux,darwin,win32}-{x64,arm64}). tree-sitter grammars are
@ -55,7 +58,7 @@ on:
workflow_dispatch:
inputs:
grammars:
description: 'Comma-separated grammar shortnames to build (c,dart,proto,kotlin,swift), or "all".'
description: 'Comma-separated grammar shortnames to build (c,dart,proto,kotlin,swift,lua), or "all".'
required: false
type: string
default: 'all'
@ -157,6 +160,11 @@ jobs:
// so it builds from gitnexus/vendor/ like dart/proto. Its prebuilds
// were originally upstream-shipped; rebuilding them here unifies it.
swift: { name: 'tree-sitter-swift', kind: 'vendored' },
// lua is vendored WITH its source (parser.c/scanner.c/binding.gyp);
// the upstream npm package ships SOURCE ONLY for the relevant
// platform tuples, so GitNexus cross-builds all 6 prebuilds here
// like dart/proto/swift to keep install toolchain-free everywhere.
lua: { name: 'tree-sitter-lua', kind: 'vendored' },
};
const PLATFORMS = [
{ platform_arch: 'linux-x64', os: 'ubuntu-24.04' },
@ -467,6 +475,7 @@ jobs:
proto: "syntax = \"proto3\";\nmessage M { int32 id = 1; }",
kotlin: "fun main() { println(\"hi\") }",
swift: "func greet() { print(\"hi\") }",
lua: "local x = 1\nreturn x",
};
const lang = require("node-gyp-build")(process.cwd());
const Parser = require("tree-sitter");

View file

@ -48,3 +48,10 @@ export function collectLuaCaptureSideChannel(filePath: string): LuaCaptureSideCh
export function clearLuaHeritageFacts(): void {
_facts.clear();
}
/** Drop this file's facts so a re-capture that produces no heritage (the file
* lost its middleclass class between passes) does not leave stale EXTENDS /
* HAS_METHOD facts for `collectLuaCaptureSideChannel` to snapshot. */
export function clearLuaHeritageFactsForFile(filePath: string): void {
_facts.delete(filePath);
}

View file

@ -23,6 +23,7 @@ import { nodeToCapture } from '../../utils/ast-helpers.js';
import { getLuaParser, getLuaScopeQuery, getHeritageQuery, getMethodOwnerQuery } from './query.js';
import {
setLuaHeritageFacts,
clearLuaHeritageFactsForFile,
type LuaExtendsPair,
type LuaMethodOwnerPair,
} from './capture-side-channel.js';
@ -95,6 +96,11 @@ export function emitLuaScopeCaptures(
}
if (extendsPairs.length > 0 || methodOwners.length > 0) {
setLuaHeritageFacts(filePath, { kind: 'lua', extendsPairs, methodOwners });
} else {
// Re-capture produced no heritage — drop any prior facts for this file so
// reanalysis of a file that lost its middleclass class does not emit stale
// EXTENDS / HAS_METHOD edges from the previous pass.
clearLuaHeritageFactsForFile(filePath);
}
return out;

View file

@ -18,6 +18,7 @@ import { buildSuffixIndex, suffixResolve, type SuffixIndex } from '../../import-
import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js';
import { luaProvider } from '../lua.js';
import { emitLuaHeritageEdges } from './heritage.js';
import { clearLuaHeritageFacts } from './capture-side-channel.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
@ -32,6 +33,16 @@ const luaScopeResolver: ScopeResolver = {
languageProvider: luaProvider,
importEdgeReason: 'lua-scope: require',
// Worker capture facts are process-local and can outlive a single analysis in
// server mode. Runs once before each Lua workspace pass, mirroring the
// Java/Kotlin `loadResolutionConfig` clear. The per-file delete in
// `captures.ts` is the operative fix for the empty-recapture stale path;
// this clear-all is the belt-and-suspenders lifecycle hook.
loadResolutionConfig: () => {
clearLuaHeritageFacts();
return undefined;
},
// 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.

View file

@ -19,6 +19,8 @@ import {
runPipelineFromRepo,
type PipelineResult,
} from './helpers.js';
import { emitLuaScopeCaptures } from '../../../src/core/ingestion/languages/lua/index.js';
import { collectLuaCaptureSideChannel } from '../../../src/core/ingestion/languages/lua/capture-side-channel.js';
function writeFixtureRepo(root: string, files: Record<string, string>): void {
for (const [rel, content] of Object.entries(files)) {
@ -133,3 +135,45 @@ return Dog
expect(classes).toContain('Animal');
});
});
// ---------------------------------------------------------------------------
// heritage lifecycle: re-capture with no middleclass must not retain stale
// EXTENDS / HAS_METHOD facts from a prior pass (reanalysis regression)
// ---------------------------------------------------------------------------
describe('Lua scope: heritage lifecycle (no stale facts on reanalysis)', () => {
// The capture side channel is a module-level map populated by
// `emitLuaScopeCaptures` (worker) and snapshotted by
// `collectLuaCaptureSideChannel`. Calling both here in the test process
// exercises the same module instance, so a re-capture that produces no
// heritage must drop the prior facts — otherwise reanalysis of a file that
// lost its middleclass class would emit stale EXTENDS / HAS_METHOD edges.
const heritageSrc = `local class = require("lib.class")
local Animal = class("Animal")
function Animal:speak() return "..." end
local Dog = class("Dog", Animal)
function Dog:bark() return "woof" end
return Dog
`;
const noHeritageSrc = `local x = 1
return x
`;
it('populates facts on a middleclass capture', () => {
emitLuaScopeCaptures(heritageSrc, 'lifecycle.lua');
const facts = collectLuaCaptureSideChannel('lifecycle.lua');
expect(facts).toBeDefined();
expect(facts?.extendsPairs.length).toBeGreaterThan(0);
expect(facts?.methodOwners.length).toBeGreaterThan(0);
});
it('clears facts on a subsequent no-heritage capture (no stale state)', () => {
// First capture establishes heritage facts for the file.
emitLuaScopeCaptures(heritageSrc, 'lifecycle.lua');
expect(collectLuaCaptureSideChannel('lifecycle.lua')).toBeDefined();
// Re-capture with no middleclass — the prior facts must be dropped, not
// retained for collectLuaCaptureSideChannel to snapshot as stale edges.
emitLuaScopeCaptures(noHeritageSrc, 'lifecycle.lua');
expect(collectLuaCaptureSideChannel('lifecycle.lua')).toBeUndefined();
});
});