GitNexus/gitnexus/test/unit/prebuild-coverage.test.ts
Gergő Magyar c5c4fbe43c
fix(zig): vendor tree-sitter-zig so npm i -g no longer warns on peers (#3180)
* fix(zig): vendor tree-sitter-zig so npm i -g no longer warns on peers

Published overrides do not apply to dependents, so the Zig optionalDependency
kept warning that tree-sitter@0.21.1 does not satisfy peerOptional ^0.22.1.
Load it from vendor/ like Dart/Kotlin/Swift instead.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ci): add zig parse snippet to prebuild validate

The six zig prebuild jobs failed at "Validate the .node loads and parses"
because snippets[GRAMMAR] was undefined and tree-sitter threw
"Input must be a function".

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore: drop Unreleased changelog note from the Zig vendor PR

CHANGELOG.md is owned by the release process, not individual PRs.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(vendor): rebuild native prebuilds (tree-sitter-zig)

Built by https://github.com/abhigyanpatwari/GitNexus/actions/runs/33949409205

* chore(vendor): rebuild native prebuilds (tree-sitter-zig)

Built by https://github.com/abhigyanpatwari/GitNexus/actions/runs/33949616521

* chore(vendor): rebuild native prebuilds (tree-sitter-zig)

Built by https://github.com/abhigyanpatwari/GitNexus/actions/runs/33949829377

* chore(vendor): rebuild native prebuilds (tree-sitter-zig)

Built by https://github.com/abhigyanpatwari/GitNexus/actions/runs/33950025077

* chore(vendor): rebuild native prebuilds (tree-sitter-zig)

Built by https://github.com/abhigyanpatwari/GitNexus/actions/runs/33950220655

* chore(vendor): rebuild native prebuilds (tree-sitter-zig)

Built by https://github.com/abhigyanpatwari/GitNexus/actions/runs/33950400275

* chore(vendor): rebuild native prebuilds (tree-sitter-zig)

Built by https://github.com/abhigyanpatwari/GitNexus/actions/runs/33950607933

* chore(vendor): rebuild native prebuilds (tree-sitter-zig)

Built by https://github.com/abhigyanpatwari/GitNexus/actions/runs/33950882912

* chore(vendor): rebuild native prebuilds (tree-sitter-zig)

Built by https://github.com/abhigyanpatwari/GitNexus/actions/runs/33951170483

* chore(vendor): rebuild native prebuilds (tree-sitter-zig)

Built by https://github.com/abhigyanpatwari/GitNexus/actions/runs/33951386477

* chore(vendor): rebuild native prebuilds (tree-sitter-zig)

Built by https://github.com/abhigyanpatwari/GitNexus/actions/runs/33951624305

* chore(vendor): rebuild native prebuilds (tree-sitter-zig)

Built by https://github.com/abhigyanpatwari/GitNexus/actions/runs/33951813452

* chore(vendor): rebuild native prebuilds (tree-sitter-zig)

Built by https://github.com/abhigyanpatwari/GitNexus/actions/runs/33951998309

* chore(vendor): rebuild native prebuilds (tree-sitter-zig)

Built by https://github.com/abhigyanpatwari/GitNexus/actions/runs/33952225172

* chore(vendor): rebuild native prebuilds (tree-sitter-zig)

Built by https://github.com/abhigyanpatwari/GitNexus/actions/runs/33952524393

* fix(ci): stop native prebuild rebuild loops

PR path filters and source checks see the cumulative diff, so generated binaries kept rebuilding the original source change. Skip output-only synchronize events using their exact before/head range, failing closed when Git cannot compare it. Exercise the workflow against real commit histories, including multi-commit source pushes and merge-ref drift.

* chore(vendor): rebuild native prebuilds (tree-sitter-zig)

Built by https://github.com/abhigyanpatwari/GitNexus/actions/runs/33953226606

* fix: address Zig PR review feedback (#3180)

Check for the vendored package without loading its native binding so a
broken installed Zig grammar fails the parsing test instead of skipping.
Match the optional child descriptor in the Zig metadata declaration and
include Zig in the two optional/vendored grammar comments.

Validation: 159 targeted tests, TypeScript, metadata type fixture, and
formatting passed. Injected native-load failure now fails instead of
skipping; explicit Zig opt-out still skips.

* chore(vendor): rebuild native prebuilds (tree-sitter-zig)

Built by https://github.com/abhigyanpatwari/GitNexus/actions/runs/33956342308

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: gitnexus-release-bot[bot] <gitnexus-release-bot[bot]@users.noreply.github.com>
2026-09-05 10:35:33 +01:00

374 lines
16 KiB
TypeScript

import { afterEach, beforeEach, describe, it, expect } from 'vitest';
import {
existsSync,
mkdtempSync,
mkdirSync,
readdirSync,
readFileSync,
rmSync,
statSync,
writeFileSync,
} from 'node:fs';
import { execFileSync, spawnSync } from 'node:child_process';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { load } from 'js-yaml';
/**
* 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;
* Swift's were originally upstream-shipped, now rebuilt the same way). Each
* one that does NOT also vendor its build source 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, #2116 — but
* it is now VENDORED with GitNexus-built prebuilds for all 6, so it falls under
* the vendored cohort below, not here.)
*/
const KNOWN_NPM_GAPS: Record<string, string[]> = {};
/**
* Vendored grammars declared "fully prebuilt": GitNexus has committed 6/6
* prebuilds for them, so they MUST keep all six even though they also vendor
* source (binding.gyp). Without this list the strict 6/6 assertion is dormant for
* every grammar that carries source — a dropped prebuild would pass CI silently.
* Grammars graduate into this set as the build-tree-sitter-prebuilds workflow
* lands their binaries (today only Swift ships 6/6; c/dart/proto/kotlin are
* source-build-only until the workflow runs).
*/
const FULLY_PREBUILT = new Set<string>(['tree-sitter-swift']);
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));
// A grammar that vendors its build sources (binding.gyp) can source-build the
// gaps on any toolchain host (e.g. CI), so an incomplete prebuild set is
// tolerated for it — the build-tree-sitter-prebuilds workflow fills the
// prebuilds to make it toolchain-free. Every grammar GitNexus currently
// vendors carries its source (incl. swift, unified with the rest), so the
// strict branch below is defensive: a hypothetical prebuild-only grammar (no
// binding.gyp) MUST ship all six, or it is dead on the missing platform.
const hasSourceFallback = existsSync(path.join(grammarDir, 'binding.gyp'));
// A declared-fully-prebuilt grammar must ship all six EVEN THOUGH it has a
// source fallback — otherwise the strict 6/6 assertion is dormant for every
// source-carrying grammar and a dropped prebuild slips through CI.
const mustBeFullyPrebuilt = FULLY_PREBUILT.has(grammar);
it(
mustBeFullyPrebuilt
? `${grammar}: ships an N-API prebuild for ALL 6 tuples (declared fully-prebuilt)`
: hasSourceFallback
? `${grammar}: present prebuilds are N-API (source-build fallback covers any gaps)`
: `${grammar}: ships an N-API prebuild for all 6 platform-arch tuples`,
() => {
// Any prebuild that IS present must be a loadable N-API binary — always.
expect(nonNapi, `${grammar} has non-N-API prebuilds: ${nonNapi.join(', ')}`).toEqual([]);
if (mustBeFullyPrebuilt || !hasSourceFallback) {
// Either declared fully-prebuilt, or prebuild-only (no source fallback):
// all six are required. Run the build-tree-sitter-prebuilds workflow to
// (re)generate any that are missing.
expect(
missing,
`${grammar} is missing prebuilds for: ${missing.join(', ') || 'none'} ` +
(mustBeFullyPrebuilt
? `(declared fully-prebuilt in FULLY_PREBUILT — its 6/6 set must stay complete)`
: `(prebuild-only — run the build-tree-sitter-prebuilds workflow)`),
).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([]);
});
}
});
describe('prebuild workflow validate snippets cover every REGISTRY grammar', () => {
// The "Validate the .node loads and parses" step looks up snippets[GRAMMAR].
// A missing key yields undefined, and tree-sitter's parse() throws
// "Input must be a function" — the six zig jobs on #3180.
const workflow = readFileSync(
path.join(GITNEXUS_ROOT, '..', '.github/workflows/build-tree-sitter-prebuilds.yml'),
'utf8',
);
const registry = [...workflow.matchAll(/^\s{12}(\w+):\s+\{\s+name:\s+'tree-sitter-/gm)].map(
(m) => m[1],
);
const snippets = [...workflow.matchAll(/^\s{14}(\w+):\s+"/gm)].map((m) => m[1]);
it('REGISTRY and snippets are both non-empty (the regex still matches the workflow)', () => {
expect(registry.length).toBeGreaterThan(0);
expect(snippets.length).toBeGreaterThan(0);
});
it('every REGISTRY grammar has a parse snippet', () => {
expect(snippets.sort(), 'add a snippets.<grammar> entry when extending REGISTRY').toEqual(
[...registry].sort(),
);
});
});
describe('prebuild workflow rebuild loop guard', () => {
const workflow = load(
readFileSync(
path.join(GITNEXUS_ROOT, '..', '.github/workflows/build-tree-sitter-prebuilds.yml'),
'utf8',
),
) as {
jobs: { guard: { steps: { id?: string; run?: string; env?: Record<string, string> }[] } };
};
const decide = workflow.jobs.guard.steps.find((step) => step.id === 'decide');
if (!decide?.run) throw new Error('Missing prebuild workflow Decide script');
// Execute the actual workflow script against real commits, including the
// cumulative PR diff that remains after generated binaries are committed.
const script = decide.run.match(/node --input-type=module - <<'NODE'\n([\s\S]*?)\nNODE/)?.[1];
if (!script) throw new Error('Missing prebuild workflow Decide Node heredoc');
let root: string;
let base: string;
let source: string;
const vendor = 'gitnexus/vendor/tree-sitter-zig';
function git(...args: string[]): string {
return execFileSync('git', args, { cwd: root, encoding: 'utf8' }).trim();
}
function write(rel: string, contents: string): void {
const dest = path.join(root, rel);
mkdirSync(path.dirname(dest), { recursive: true });
writeFileSync(dest, contents);
}
function commit(message: string): string {
git('add', 'gitnexus');
git('-c', 'commit.gpgsign=false', 'commit', '-qm', message);
return git('rev-parse', 'HEAD');
}
function runGuard(env: Record<string, string> = {}) {
const runnerTemp = mkdtempSync(path.join(root, 'runner-'));
const output = path.join(runnerTemp, 'output');
const result = spawnSync(process.execPath, ['--input-type=module', '-'], {
cwd: root,
input: script,
encoding: 'utf8',
env: {
...process.env,
EVENT: 'pull_request',
ACTION: 'synchronize',
BASE_SHA: base,
BEFORE_SHA: source,
HEAD_SHA: git('rev-parse', 'HEAD'),
INPUT_GRAMMARS: '',
INPUT_REF: '',
FORCE: 'false',
RUNNER_TEMP: runnerTemp,
GITHUB_OUTPUT: output,
...env,
},
});
return { ...result, output: existsSync(output) ? readFileSync(output, 'utf8') : '' };
}
function expectBuild(env: Record<string, string> = {}): void {
const result = runGuard(env);
expect(result.status, result.stderr).toBe(0);
expect(result.output).toContain('any=true\n');
const matrix = JSON.parse(result.output.split('matrix=')[1]);
expect(matrix.include).toHaveLength(6);
expect(matrix.include.every((entry: { grammar: string }) => entry.grammar === 'zig')).toBe(
true,
);
}
beforeEach(() => {
root = mkdtempSync(path.join(tmpdir(), 'gitnexus-prebuild-guard-'));
git('init', '-q');
git('config', 'user.name', 'Prebuild test');
git('config', 'user.email', 'prebuild-test@example.invalid');
mkdirSync(path.join(root, 'hooks'));
git('config', 'core.hooksPath', path.join(root, 'hooks'));
write('gitnexus/package.json', '{}');
base = commit('base without vendored zig');
write(`${vendor}/package.json`, '{"version":"1.1.2"}');
write(`${vendor}/src/parser.c`, 'original source');
source = commit('vendor zig source');
write(`${vendor}/prebuilds/win32-x64/tree-sitter-zig.node`, 'binary build 1');
write(`${vendor}/prebuilds/SHA256SUMS`, 'checksum 1');
commit('generated prebuilds');
});
afterEach(() => rmSync(root, { recursive: true, force: true }));
it('wires the event action and exact push endpoints into the guard', () => {
expect(decide.env).toMatchObject({
ACTION: '${{ github.event.action }}',
BEFORE_SHA: '${{ github.event.before }}',
HEAD_SHA: '${{ github.event.pull_request.head.sha }}',
});
});
it('stops repeated binary-only updates while the PR still contains new source', () => {
let before = source;
for (let build = 2; build <= 4; build++) {
const result = runGuard({ BEFORE_SHA: before });
expect(result.status, result.stderr).toBe(0);
expect(result.output).toBe('any=false\nmatrix={"include":[]}\n');
before = git('rev-parse', 'HEAD');
write(`${vendor}/prebuilds/win32-x64/tree-sitter-zig.node`, `binary build ${build}`);
write(`${vendor}/prebuilds/SHA256SUMS`, `checksum ${build}`);
commit('generated prebuilds');
}
});
it('builds a newly opened PR and supports manual recuts', () => {
expectBuild({ ACTION: 'opened', BEFORE_SHA: '' });
expectBuild({ EVENT: 'workflow_dispatch', ACTION: '', BEFORE_SHA: '', INPUT_GRAMMARS: 'zig' });
});
it('builds source changes even when the last commit in the push only updates binaries', () => {
expectBuild({ BEFORE_SHA: base });
const before = git('rev-parse', 'HEAD');
write(`${vendor}/src/parser.c`, 'updated source without a version bump');
commit('edit parser');
write(`${vendor}/prebuilds/SHA256SUMS`, 'checksum 2');
commit('generated prebuilds');
expectBuild({ BEFORE_SHA: before });
});
it('still builds after unrelated updates that may have cancelled an earlier build', () => {
const before = git('rev-parse', 'HEAD');
write('gitnexus/package.json', '{"description":"updated"}');
commit('update package');
expectBuild({ BEFORE_SHA: before });
});
it('uses event endpoints even when the checkout contains additional base-branch changes', () => {
const head = git('rev-parse', 'HEAD');
write(`${vendor}/src/parser.c`, 'source from an advanced PR merge ref');
commit('simulate merge ref changes');
const result = runGuard({ HEAD_SHA: head });
expect(result.status, result.stderr).toBe(0);
expect(result.output).toBe('any=false\nmatrix={"include":[]}\n');
});
it('does not hide source removal when a source file moves into prebuilds', () => {
const before = git('rev-parse', 'HEAD');
git('mv', `${vendor}/src/parser.c`, `${vendor}/prebuilds/parser.c`);
commit('move source');
expectBuild({ BEFORE_SHA: before });
});
it.each(['', 'not-a-sha', '0'.repeat(40)])(
'fails closed for an unavailable push endpoint: %s',
(before) => {
const result = runGuard({ BEFORE_SHA: before });
expect(result.status).not.toBe(0);
expect(result.output).not.toContain('any=true');
},
);
});