From ebbaa4a7ae2265d0b4e8019366f6f432066e3a9e Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Tue, 9 Jun 2026 09:39:29 +0000 Subject: [PATCH] test(install): guard 6/6 N-API prebuild coverage for every grammar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- gitnexus/test/unit/prebuild-coverage.test.ts | 141 +++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 gitnexus/test/unit/prebuild-coverage.test.ts diff --git a/gitnexus/test/unit/prebuild-coverage.test.ts b/gitnexus/test/unit/prebuild-coverage.test.ts new file mode 100644 index 000000000..a4f36e53c --- /dev/null +++ b/gitnexus/test/unit/prebuild-coverage.test.ts @@ -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 = { + 'tree-sitter-c': ['linux-arm64', 'win32-arm64'], +}; + +function isNapiBinary(file: string): boolean { + return readFileSync(file).includes(NAPI_SYMBOL); +} + +function prebuiltTuples(grammarDir: string): { covered: Set; nonNapi: string[] } { + const pdir = path.join(grammarDir, 'prebuilds'); + const covered = new Set(); + 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([]); + }); + } +});