Skip to content

Commit 8d23951

Browse files
committed
test(graph): add Lua extractor + whitelist-discovery tests
Address maintainer review on PR giancarloerra#67. - Add a dedicated `describe("Lua")` block locking in qualified `Table.method` / `T:m()` names, `local function`, both `T.f = function() … end` and `local f = function() … end` assignment forms, call attribution to the enclosing function, top-level calls falling back to `<module>`, and dotted-callee resolution. - Move the former "handles Lua via regex fallback" test out of the regex-fallback block (Lua now routes to extractFromLua) and give it real assertions; drop "Lua" from that block's title. - Add a whitelist `.gitignore` regression test (`/*` then `!/src/`) asserting files under the re-included dir are discovered; export getGraphableFiles so the test can exercise discovery directly. - Match the dynamic-grammar style for the Lua parse call (`parse("lua" as unknown as Lang, source)`).
1 parent 5b044ff commit 8d23951

4 files changed

Lines changed: 149 additions & 16 deletions

File tree

src/services/code-graph.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -579,7 +579,7 @@ export function getAstGrepLang(ext: string): Lang | string | null {
579579
* Get all source files in a project for graph analysis.
580580
* Includes files with known AST grammars and any extra extensions.
581581
*/
582-
async function getGraphableFiles(
582+
export async function getGraphableFiles(
583583
projectPath: string,
584584
extraExts?: Set<string>,
585585
): Promise<string[]> {

src/services/graph-symbols.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ function extractFromLua(
178178
language: string,
179179
moduleSym: SymbolNode,
180180
): ExtractedSymbols {
181-
const root = parse("lua", source).root();
181+
const root = parse("lua" as unknown as Lang, source).root();
182182
const symbols: SymbolNode[] = [moduleSym];
183183
const scopes: ScopeFrame[] = [];
184184
const NAME = new Set(["dot_index_expression", "method_index_expression", "identifier"]);

tests/unit/graph-discovery.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// SPDX-License-Identifier: AGPL-3.0-only
2+
// Copyright (C) 2026 Giancarlo Erra - Altaire Limited
3+
4+
import fs from "node:fs";
5+
import os from "node:os";
6+
import path from "node:path";
7+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
8+
import { ensureDynamicLanguages, getGraphableFiles } from "../../src/services/code-graph.js";
9+
10+
// Regression for the whitelist .gitignore discovery fix: a `/*` then `!/src/`
11+
// pattern ignores everything at the root but re-includes `src/`. The old walk
12+
// passed `src` (no trailing slash) to shouldIgnore, which `/*` matched, so the
13+
// walk bailed and produced an empty graph. Passing `src/` lets it descend and
14+
// the files under the re-included directory are actually picked up.
15+
describe("getGraphableFiles — whitelist .gitignore", () => {
16+
let root: string;
17+
18+
beforeAll(() => {
19+
ensureDynamicLanguages();
20+
root = fs.mkdtempSync(path.join(os.tmpdir(), "socraticode-discovery-"));
21+
fs.mkdirSync(path.join(root, "src"), { recursive: true });
22+
fs.writeFileSync(path.join(root, ".gitignore"), "/*\n!/src/\n");
23+
fs.writeFileSync(
24+
path.join(root, "src", "mod.lua"),
25+
"local function f()\n return 1\nend\nreturn f\n",
26+
);
27+
// A root-level file the `/*` pattern should keep ignored.
28+
fs.writeFileSync(path.join(root, "ignored.lua"), "return 1\n");
29+
});
30+
31+
afterAll(() => {
32+
try {
33+
fs.rmSync(root, { recursive: true, force: true });
34+
} catch {
35+
// ignore cleanup errors
36+
}
37+
});
38+
39+
it("descends into re-included src/ and discovers its files", async () => {
40+
const files = await getGraphableFiles(root);
41+
expect(files).toContain("src/mod.lua");
42+
// The `/*` pattern still ignores top-level entries that are not re-included.
43+
expect(files).not.toContain("ignored.lua");
44+
});
45+
});

tests/unit/graph-symbols.test.ts

Lines changed: 102 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -370,26 +370,48 @@ main() {
370370
});
371371
});
372372

373-
describe("Regex fallback (Dart, Lua, Svelte, Vue, unknown)", () => {
374-
it("handles Dart via regex fallback", () => {
373+
describe("Lua", () => {
374+
it("extracts namespace-table, method, local, and assignment function forms", () => {
375375
const src = `
376-
String greet(String name) {
377-
return 'Hi $name';
378-
}
376+
local T = {}
379377
380-
class Foo {
381-
int bar() => 1;
382-
}
378+
function T.method(a)
379+
return a
380+
end
381+
382+
function T:m()
383+
return self
384+
end
385+
386+
local function helper()
387+
return 1
388+
end
389+
390+
T.f = function()
391+
return 2
392+
end
393+
394+
return T
383395
`;
384-
const out = extractSymbolsAndCalls(src, "dart" as unknown as Lang, ".dart", "main.dart");
385-
// regex fallback should at least produce <module> and not throw
386-
expect(out.symbols.some((s) => s.name === "<module>")).toBe(true);
396+
const out = extractSymbolsAndCalls(src, "lua" as unknown as Lang, ".lua", "init.lua");
387397
const names = out.symbols.map((s) => s.name);
388-
// best-effort detection: should find at least one named symbol
389-
expect(names.length).toBeGreaterThanOrEqual(1);
398+
const qnames = out.symbols.map((s) => s.qualifiedName);
399+
expect(names).toContain("<module>");
400+
// qualified Table.method / T:m() forms resolve to precise method symbols
401+
expect(qnames).toContain("T.method");
402+
expect(qnames).toContain("T:m");
403+
// `local function` keeps its bare name
404+
expect(names).toContain("helper");
405+
// `T.f = function() … end` assignment form
406+
expect(qnames).toContain("T.f");
407+
const kinds = out.symbols.filter((s) => s.qualifiedName === "T.method").map((s) => s.kind);
408+
expect(kinds).toContain("method");
409+
// colon-call form is also a method; the plain local function is not
410+
expect(out.symbols.find((s) => s.qualifiedName === "T:m")?.kind).toBe("method");
411+
expect(out.symbols.find((s) => s.qualifiedName === "helper")?.kind).toBe("function");
390412
});
391413

392-
it("handles Lua via regex fallback", () => {
414+
it("attributes calls to the enclosing function", () => {
393415
const src = `
394416
function greet(name)
395417
return "hi " .. name
@@ -401,6 +423,72 @@ end
401423
`;
402424
const out = extractSymbolsAndCalls(src, "lua" as unknown as Lang, ".lua", "init.lua");
403425
expect(out.symbols.some((s) => s.name === "<module>")).toBe(true);
426+
const greetCall = out.rawCalls.find((c) => c.calleeName === "greet");
427+
expect(greetCall).toBeDefined();
428+
expect(greetCall?.callerId).toContain("::helper#");
429+
});
430+
431+
it("extracts the `local f = function() … end` assignment form", () => {
432+
const src = `
433+
local f = function()
434+
return 1
435+
end
436+
437+
local g = function()
438+
return f()
439+
end
440+
`;
441+
const out = extractSymbolsAndCalls(src, "lua" as unknown as Lang, ".lua", "init.lua");
442+
const names = out.symbols.map((s) => s.name);
443+
expect(names).toContain("f");
444+
expect(names).toContain("g");
445+
expect(out.symbols.find((s) => s.qualifiedName === "f")?.kind).toBe("function");
446+
// call to `f` lives inside `g`, so it is attributed to `g`
447+
const fCall = out.rawCalls.find((c) => c.calleeName === "f");
448+
expect(fCall?.callerId).toContain("::g#");
449+
});
450+
451+
it("attributes top-level calls to <module> and resolves dotted callees", () => {
452+
const src = `
453+
local M = require("mod")
454+
455+
M.setup()
456+
457+
local function run()
458+
M.start()
459+
end
460+
`;
461+
const out = extractSymbolsAndCalls(src, "lua" as unknown as Lang, ".lua", "init.lua");
462+
// calls outside any function fall back to the synthetic <module> scope
463+
const requireCall = out.rawCalls.find((c) => c.calleeName === "require");
464+
expect(requireCall?.callerId).toContain("::<module>#");
465+
// dotted callee `M.setup` resolves to the trailing identifier
466+
const setupCall = out.rawCalls.find((c) => c.calleeName === "setup");
467+
expect(setupCall).toBeDefined();
468+
expect(setupCall?.callerId).toContain("::<module>#");
469+
// `M.start()` lives inside `run`, so it is attributed there
470+
const startCall = out.rawCalls.find((c) => c.calleeName === "start");
471+
expect(startCall?.callerId).toContain("::run#");
472+
});
473+
});
474+
475+
describe("Regex fallback (Dart, Svelte, Vue, unknown)", () => {
476+
it("handles Dart via regex fallback", () => {
477+
const src = `
478+
String greet(String name) {
479+
return 'Hi $name';
480+
}
481+
482+
class Foo {
483+
int bar() => 1;
484+
}
485+
`;
486+
const out = extractSymbolsAndCalls(src, "dart" as unknown as Lang, ".dart", "main.dart");
487+
// regex fallback should at least produce <module> and not throw
488+
expect(out.symbols.some((s) => s.name === "<module>")).toBe(true);
489+
const names = out.symbols.map((s) => s.name);
490+
// best-effort detection: should find at least one named symbol
491+
expect(names.length).toBeGreaterThanOrEqual(1);
404492
});
405493

406494
it("handles unknown language without throwing", () => {

0 commit comments

Comments
 (0)