test(install): guard 6/6 N-API prebuild coverage for every grammar

Regression guard so a toolchain-less install can never silently lose a tree-sitter
language on a supported platform-arch:

- Vendored grammars (vendor/tree-sitter-*): every one MUST ship a loadable N-API
  prebuild for all 6 tuples {linux,darwin,win32}-{x64,arm64}. Asserts the
  napi_register_module_v1 entry symbol in each .node (cross-platform, no need to
  run the binary). Currently RED for dart/proto/kotlin until the
  build-tree-sitter-prebuilds workflow populates their prebuilds/ — this is the
  must-fill-before-merge gate (swift already passes 6/6).
- npm-dependency grammars: asserts upstream ships 6/6 N-API too, catching a
  future platform drop. tree-sitter-c is allow-listed at 4/6 (missing
  linux-arm64/win32-arm64) pending #2116; the guard also fails if that gap is
  silently closed (prompting allow-list removal).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergo Magyar 2026-06-09 09:39:29 +00:00
parent eb70c46d0f
commit ebbaa4a7ae

View file

@ -0,0 +1,141 @@
import { describe, it, expect } from 'vitest';
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
/**
* Regression guard: every tree-sitter grammar GitNexus ships must provide a
* loadable native binding for EVERY platform-arch we support, on the ABI we
* support — so a toolchain-less install never silently loses a language.
*
* "The ABI we support":
* - Node native ABI: engines.node >= 22 → all grammars are N-API
* (node-addon-api), i.e. one ABI-stable `.node` per platform-arch loads
* across Node majors. We assert each prebuilt binary exports the N-API
* entry symbol `napi_register_module_v1` (a node-ABI-pinned binary would
* not) — this works cross-platform because the symbol name is an ASCII
* string in the binary on linux/macOS/Windows alike.
* - tree-sitter language ABI: pinned `tree-sitter@0.21.1` (#1922) — verified
* by the load+parse smoke in parser-loader-abi.test.ts.
*
* Two cohorts:
* 1. VENDORED grammars (gitnexus/vendor/tree-sitter-*) — GitNexus owns these
* prebuilds (cross-built by .github/workflows/build-tree-sitter-prebuilds.yml,
* or copied from upstream for Swift). Every one MUST cover all 6 tuples.
* 2. npm-dependency grammars — upstream owns their prebuilds. We assert 6/6
* too, with documented exceptions (see KNOWN_NPM_GAPS).
*/
const TUPLES = [
'linux-x64',
'linux-arm64',
'darwin-x64',
'darwin-arm64',
'win32-x64',
'win32-arm64',
];
const NAPI_SYMBOL = 'napi_register_module_v1';
const GITNEXUS_ROOT = fileURLToPath(new URL('../..', import.meta.url));
const VENDOR_DIR = path.join(GITNEXUS_ROOT, 'vendor');
const NODE_MODULES = path.join(GITNEXUS_ROOT, 'node_modules');
/**
* Known, tracked upstream coverage gaps for npm-dependency grammars. Each entry
* is the EXACT set of tuples the upstream package omits — the test fails if a
* grammar drops MORE than its allow-listed gap (a new silent regression) OR if
* an allow-listed gap is closed upstream (prompting allow-list removal).
*
* tree-sitter-c@0.21.4 ships only 4/6 (no linux-arm64 / win32-arm64); pinned to
* the 0.21.x line pending the tree-sitter 0.21->0.23 runtime upgrade. See #2116.
*/
const KNOWN_NPM_GAPS: Record<string, string[]> = {
'tree-sitter-c': ['linux-arm64', 'win32-arm64'],
};
function isNapiBinary(file: string): boolean {
return readFileSync(file).includes(NAPI_SYMBOL);
}
function prebuiltTuples(grammarDir: string): { covered: Set<string>; nonNapi: string[] } {
const pdir = path.join(grammarDir, 'prebuilds');
const covered = new Set<string>();
const nonNapi: string[] = [];
if (!existsSync(pdir)) return { covered, nonNapi };
for (const tuple of TUPLES) {
const td = path.join(pdir, tuple);
if (!existsSync(td) || !statSync(td).isDirectory()) continue;
const nodes = readdirSync(td).filter((f) => f.endsWith('.node'));
if (nodes.length === 0) continue;
covered.add(tuple);
for (const n of nodes) if (!isNapiBinary(path.join(td, n))) nonNapi.push(`${tuple}/${n}`);
}
return { covered, nonNapi };
}
const vendoredGrammars = existsSync(VENDOR_DIR)
? readdirSync(VENDOR_DIR).filter((d) => /^tree-sitter-/.test(d))
: [];
describe('vendored grammar prebuild coverage (toolchain-free on every supported platform)', () => {
it('discovers the vendored grammars', () => {
// Sanity: if vendor/ ever empties, the per-grammar assertions would vacuously
// pass — fail loudly instead.
expect(vendoredGrammars.length).toBeGreaterThan(0);
});
for (const grammar of vendoredGrammars) {
const grammarDir = path.join(VENDOR_DIR, grammar);
const { covered, nonNapi } = prebuiltTuples(grammarDir);
const missing = TUPLES.filter((t) => !covered.has(t));
it(`${grammar}: ships an N-API prebuild for all 6 platform-arch tuples`, () => {
// GitNexus owns these prebuilds — run the build-tree-sitter-prebuilds
// workflow to (re)generate any that are missing.
expect(
missing,
`${grammar} is missing prebuilds for: ${missing.join(', ') || 'none'} ` +
`(run the build-tree-sitter-prebuilds workflow)`,
).toEqual([]);
expect(nonNapi, `${grammar} has non-N-API prebuilds: ${nonNapi.join(', ')}`).toEqual([]);
});
}
});
describe('npm-dependency grammar prebuild coverage', () => {
const pkg = JSON.parse(readFileSync(path.join(GITNEXUS_ROOT, 'package.json'), 'utf8'));
const npmGrammars = Object.keys(pkg.dependencies ?? {})
.filter((d) => /^tree-sitter-/.test(d))
.sort();
it('discovers the npm grammar dependencies', () => {
expect(npmGrammars.length).toBeGreaterThan(0);
});
for (const grammar of npmGrammars) {
const grammarDir = path.join(NODE_MODULES, grammar);
it(`${grammar}: upstream ships N-API prebuilds for all 6 tuples (minus tracked gaps)`, () => {
if (!existsSync(grammarDir)) {
// node_modules must be installed for this check (CI coverage job / local).
throw new Error(`${grammar} not installed at ${grammarDir} — run npm install`);
}
const { covered, nonNapi } = prebuiltTuples(grammarDir);
const allowedGap = new Set(KNOWN_NPM_GAPS[grammar] ?? []);
const unexpectedMissing = TUPLES.filter((t) => !covered.has(t) && !allowedGap.has(t));
const unexpectedlyClosed = [...allowedGap].filter((t) => covered.has(t));
expect(
unexpectedMissing,
`${grammar} is missing prebuilds for: ${unexpectedMissing.join(', ')} ` +
`(new gap — upstream dropped a platform, or pin a version that ships it)`,
).toEqual([]);
expect(
unexpectedlyClosed,
`${grammar} now ships prebuilds for ${unexpectedlyClosed.join(', ')} — ` +
`remove it from KNOWN_NPM_GAPS (and close the tracking issue)`,
).toEqual([]);
expect(nonNapi, `${grammar} has non-N-API prebuilds: ${nonNapi.join(', ')}`).toEqual([]);
});
}
});