From 52b74d02b326bad2af731baad8ba1bdc5ecedafb Mon Sep 17 00:00:00 2001 From: taoxin <> Date: Fri, 7 Aug 2026 10:46:28 +0800 Subject: [PATCH] fix(lua): clear stale heritage on reanalysis; wire Lua prebuild workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../workflows/build-tree-sitter-prebuilds.yml | 13 +++++- .../languages/lua/capture-side-channel.ts | 7 +++ .../core/ingestion/languages/lua/captures.ts | 6 +++ .../ingestion/languages/lua/scope-resolver.ts | 11 +++++ .../integration/resolvers/lua-scope.test.ts | 44 +++++++++++++++++++ 5 files changed, 79 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-tree-sitter-prebuilds.yml b/.github/workflows/build-tree-sitter-prebuilds.yml index 2411748c5..c24c82210 100644 --- a/.github/workflows/build-tree-sitter-prebuilds.yml +++ b/.github/workflows/build-tree-sitter-prebuilds.yml @@ -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//prebuilds//.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"); diff --git a/gitnexus/src/core/ingestion/languages/lua/capture-side-channel.ts b/gitnexus/src/core/ingestion/languages/lua/capture-side-channel.ts index e3e075101..95e4d5395 100644 --- a/gitnexus/src/core/ingestion/languages/lua/capture-side-channel.ts +++ b/gitnexus/src/core/ingestion/languages/lua/capture-side-channel.ts @@ -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); +} diff --git a/gitnexus/src/core/ingestion/languages/lua/captures.ts b/gitnexus/src/core/ingestion/languages/lua/captures.ts index 84d73f0fb..77b1090cc 100644 --- a/gitnexus/src/core/ingestion/languages/lua/captures.ts +++ b/gitnexus/src/core/ingestion/languages/lua/captures.ts @@ -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; diff --git a/gitnexus/src/core/ingestion/languages/lua/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/lua/scope-resolver.ts index 6b9524c4a..aeb98190c 100644 --- a/gitnexus/src/core/ingestion/languages/lua/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/lua/scope-resolver.ts @@ -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. diff --git a/gitnexus/test/integration/resolvers/lua-scope.test.ts b/gitnexus/test/integration/resolvers/lua-scope.test.ts index 9c905f902..e472f5fc7 100644 --- a/gitnexus/test/integration/resolvers/lua-scope.test.ts +++ b/gitnexus/test/integration/resolvers/lua-scope.test.ts @@ -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): 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(); + }); +});