Files
socraticode/tests/unit/dynamic-language-loader.test.ts
Giancarlo Erra e075d6ffa4 fix(graph): pre-validate ast-grep grammar libraryPath to survive missing prebuilds
Resolves #43. On Linux/Node combinations where one ast-grep grammar
package's prebuilt parser binary is missing for the host architecture
(reporter: Ubuntu 24.04 + WSL2 + Node 24, missing the cpp prebuild),
the existing loader silently failed to register every dynamic grammar,
not just the broken one.

Mechanism: registerDynamicLanguage iterates the modules it receives
and accesses the lazy libraryPath getter on each. If that getter
throws (because the prebuild lookup fails), the entire batch call
aborts atomically — none of the 13 dynamic grammars get registered.
The empty inner `catch {}` in ensureDynamicLanguages discarded the
underlying reason, and the failure surfaced only at WARN as a generic
"Failed to register dynamic ast-grep languages", with no indication
of which grammar was the culprit.

Empirically verified against @ast-grep/napi@0.40.5 in a clean Node
environment:

- Sequential register({A}); register({B}) calls REPLACE rather than
  accumulate; only the last call's grammar survives. So the obvious
  alternative of "register one at a time" is actively broken — it
  would silently leave only the last grammar in langPackages
  registered (php, since it's last in our list). On a polyglot
  codebase that's a regression.

- Batch register({A, B, C}) with one entry whose libraryPath getter
  throws aborts atomically — none of A/B/C end up registered.

The fix is to pre-validate each grammar's libraryPath getter inside
our own per-grammar try/catch, exclude any that throw, then make ONE
batch registerDynamicLanguage call with only the survivors:

- src/services/code-graph.ts: ensureDynamicLanguages now touches
  mod.libraryPath inside the inner try/catch so a missing-prebuild
  failure is contained to that one grammar. Adds module-level
  loadedDynamicLanguages and failedDynamicLanguages state plus a
  public getDynamicLanguageStatus() introspection API. The empty
  `catch {}` becomes `catch (err) { ... }` capturing the actual
  reason. Per-grammar failures and the (now-rare) catch-all log at
  warn level with the underlying message.

- src/tools/graph-tools.ts: codebase_graph_status now appends an
  "AST grammars: Loaded / Failed" block listing both sets, with the
  failure reason for each unloaded grammar. Users see the loader
  state without needing to enable debug logging.

- src/services/graph-symbols.ts: extractSymbolsAndCalls bumps its
  failure log from DEBUG to WARN with one-shot dedupe per language —
  one warn per language for the lifetime of the process, not one
  warn per file (potentially hundreds).

- src/services/graph-imports.ts: extractImports gets the same
  one-shot dedupe per language treatment for consistency.

- tests/unit/dynamic-language-loader.test.ts: nine new tests covering
  the sync contract, idempotence, loaded/failed sort order, disjoint
  sets, and a sanity check that at least one expected grammar
  registers in the test environment.

The loader stays sync. createRequire stays. No call-site signature
changes, no test fixture rewrites. Existing 721 tests pass; with the
new file, 730 tests pass. typecheck and biome clean. CodeRabbit
review of the diff returned no findings.

Co-authored-by: X-Adam <X-Adam@users.noreply.github.com>
2026-05-04 18:50:26 +01:00

110 lines
4.1 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Giancarlo Erra - Altaire Limited
//
// Tests for the dynamic ast-grep grammar loader.
//
// Background: in 1.8.3 the loader called `registerDynamicLanguage` once with
// every loaded grammar in a single batch. Per `@ast-grep/napi`, that call is
// atomic: a single throwing `libraryPath` getter aborts the whole batch and
// no grammar gets registered. On environments where one prebuilt binary is
// missing for the host architecture, this manifested as all dynamic
// languages being silently broken (issue #43).
//
// 1.8.4 fixes this by pre-validating each grammar's `libraryPath` getter in
// the inner per-grammar try/catch so a missing prebuild is contained to that
// one grammar. The `getDynamicLanguageStatus` API exposes which grammars
// registered successfully and which failed (with the underlying reason),
// surfaced in `codebase_graph_status`.
import { beforeAll, describe, expect, it } from "vitest";
import {
ensureDynamicLanguages,
getDynamicLanguageStatus,
} from "../../src/services/code-graph.js";
beforeAll(() => {
ensureDynamicLanguages();
});
describe("dynamic-language-loader", () => {
describe("ensureDynamicLanguages", () => {
it("is synchronous (returns void, not a Promise)", () => {
const result = ensureDynamicLanguages();
expect(result).toBeUndefined();
});
it("is idempotent — calling repeatedly does not change registration state", () => {
const before = getDynamicLanguageStatus();
ensureDynamicLanguages();
ensureDynamicLanguages();
const after = getDynamicLanguageStatus();
expect(after.loaded).toEqual(before.loaded);
expect(after.failed.map((f) => f.name)).toEqual(before.failed.map((f) => f.name));
});
});
describe("getDynamicLanguageStatus", () => {
it("returns loaded and failed arrays", () => {
const status = getDynamicLanguageStatus();
expect(status).toHaveProperty("loaded");
expect(status).toHaveProperty("failed");
expect(Array.isArray(status.loaded)).toBe(true);
expect(Array.isArray(status.failed)).toBe(true);
});
it("loaded entries are unique strings", () => {
const status = getDynamicLanguageStatus();
const seen = new Set<string>();
for (const name of status.loaded) {
expect(typeof name).toBe("string");
expect(seen.has(name)).toBe(false);
seen.add(name);
}
});
it("failed entries each include name and error", () => {
const status = getDynamicLanguageStatus();
for (const f of status.failed) {
expect(typeof f.name).toBe("string");
expect(typeof f.error).toBe("string");
expect(f.error.length).toBeGreaterThan(0);
}
});
it("loaded list is sorted alphabetically", () => {
const status = getDynamicLanguageStatus();
const sorted = [...status.loaded].sort();
expect(status.loaded).toEqual(sorted);
});
it("failed list is sorted alphabetically by name", () => {
const status = getDynamicLanguageStatus();
const names = status.failed.map((f) => f.name);
const sorted = [...names].sort();
expect(names).toEqual(sorted);
});
it("loaded and failed sets are disjoint", () => {
const status = getDynamicLanguageStatus();
const failedNames = new Set(status.failed.map((f) => f.name));
for (const loaded of status.loaded) {
expect(failedNames.has(loaded)).toBe(false);
}
});
it("at least one expected dynamic grammar registers in this environment", () => {
// On every supported dev environment for this repo (macOS, common
// Linux distros), the pre-validation step should let at least a few
// of these load successfully. If this fails, either the environment
// is unusual (and we want to know) or there is a real regression in
// the loader.
const status = getDynamicLanguageStatus();
const loaded = new Set(status.loaded);
const expected = ["python", "go", "java", "php", "ruby"];
const found = expected.filter((n) => loaded.has(n));
expect(found.length).toBeGreaterThan(0);
});
});
});