GitNexus/gitnexus/scripts/assert-publish-grammar-coverage.cjs
Gergő Magyar 2870aa6248
fix(grammars): load vendored tree-sitter grammars from vendor/ by absolute path (#2111) (#2144)
* fix(grammars): load vendored tree-sitter grammars from vendor/ by absolute path (#2111)

The recurring Windows `EPERM: operation not permitted, symlink` (errno -4048)
when adding the MCP server to Antigravity is NOT the #2101/#2110 module-load
crash — it is an install-time arborist failure during the `_npx` reify that the
MCP client triggers on every `npx gitnexus` launch.

Root cause: the `postinstall` materialize step copied each vendored grammar
(`vendor/tree-sitter-{c,dart,proto,swift,kotlin}`) into
`node_modules/gitnexus/node_modules/tree-sitter-*` as a real package so runtime
`require('tree-sitter-dart')` would resolve. Those packages are in no dependency
graph, so every subsequent npm/npx reify treats them as **extraneous** and
prunes/relocates them — on Windows the relocation goes through
`@npmcli/move-file`'s symlink path and throws EPERM (symlinks need Developer
Mode/admin), and on every OS the 2nd run silently deletes the grammars. This is
the same class as #1728, which the materialize step itself claimed to have
fixed.

Fix (the prebuildify + node-gyp-build ecosystem pattern): never copy grammars
into node_modules. Load each by absolute path from `vendor/<name>` via the new
`requireVendoredGrammar` helper — the grammar's own `bindings/node` runs
`node-gyp-build(<dir>)` and loads the committed `vendor/<name>/prebuilds/
<platform>-<arch>/…` directly (all 5 ship all 6 tuples). vendor/ is inside the
package but not a node_modules subtree, so arborist never sees the grammars and
the reify is idempotent — no EPERM, no silent deletion.

- new src/core/tree-sitter/vendored-grammars.ts (requireVendoredGrammar /
  vendoredGrammarDir / VENDORED_GRAMMAR_PACKAGES; VENDOR_ROOT stable in dev+dist)
- route all consumers through it: parser-loader, parse-worker, grpc proto,
  include-extractor (C), http-patterns kotlin, cli optional-grammars probe
- postinstall drops the materialize step; build-tree-sitter-grammars.cjs builds
  in-place under vendor/ (gitignored) and deletes materialize-vendor-grammars.cjs
- tests + grammar-introspection helper load grammars from vendor/ too (single
  source of truth); new vendored-grammars.test.ts guards against reintroducing a
  bare `require('tree-sitter-<vendored>')`

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(grammars): throw on a non-vendored name in requireVendoredGrammar

Drift guard (PR #2144 review, P3): validate the argument against
VENDORED_GRAMMAR_PACKAGES and fail loudly on an unknown name, so the three
grammar lists (package set / CLI probe / build registry) drifting out of sync
surfaces as a clear error instead of a confusing absolute-path require miss.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(grammars): prepack guard against stray vendor/<g>/build/ shadowing prebuilds

Publish hygiene (PR #2144 review, P2). Now that build-tree-sitter-grammars.cjs
source-builds into vendor/<name>/build/, a stray build dir would ship in the
tarball (files:["vendor"] overrides .gitignore/.npmignore) AND shadow the
committed prebuild — node-gyp-build resolves build/Release before prebuilds/.
assert-publish-grammar-coverage.cjs (prepack) now fails `npm pack` if any
vendor/*/build exists (findStrayBuildArtifacts), with a clear `rm -rf` fix hint.
Adds unit coverage for the new pure function.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(grammars): harden the #2111 no-bare-require regression guard

PR #2144 review (P2). The guard regex missed dynamic import(), side-effect
`import 'x'`, /subpath, and backtick loads, and only scanned src/. It now covers
every node_modules-forcing form (single/double/backtick quotes, optional
subpath), scans test/ too (excluding fixtures and the guard file itself), drops
the `//`-substring false-negative (leading-comment-only heuristic), and adds a
self-test asserting every load form is caught while prose mentions and
tree-sitter-cpp are ignored.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(grammars): correct stale vendored-grammar comments

PR #2144 review (P3). kotlin/query.ts called tree-sitter-kotlin an
"optionalDependency" — it is vendored and loaded from vendor/ by absolute path
(#2111). proto.ts now states its remaining `_require` is only for the real
`tree-sitter` dependency, not a vendored grammar (which goes through
requireVendoredGrammar). Comment-only; no behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 14:20:42 +01:00

204 lines
7.5 KiB
JavaScript

#!/usr/bin/env node
/**
* Publish guard: every vendored tree-sitter grammar must ship a loadable binding.
*
* The npm tarball includes gitnexus/vendor/ (package.json `files`). A grammar is
* "covered" on a platform-arch tuple if EITHER a prebuild ships for it OR the
* grammar's full source-build set ships (so the install can source-build it,
* toolchain permitting). A future lean publish — dropping the ~50 MB of generated
* source to ship prebuilds only — is safe ONLY once every grammar has all six
* prebuilds; doing it while any grammar still lacks a prebuild would ship a
* grammar with NO loadable binding (neither prebuild nor buildable source) → that
* language is silently dead for users.
*
* HOW SOURCE INCLUSION IS DECIDED. The `files` allow-list OVERRIDES `.npmignore`
* for the vendored subtree (verified: an active "vendor/(star-star)/src/parser.c"
* in .npmignore does NOT drop it from `npm pack`). So `.npmignore` can never
* exclude vendored source — the ONLY lever is the `files` field. A broad `vendor`
* ships the whole subtree (source + prebuilds); a lean publish narrows `files` to
* non-source subpaths. This guard therefore reads `files` directly rather than
* shelling out to `npm pack` (which, in prepack, would re-enter this guard and,
* on npm versions that don't honor --ignore-scripts for prepare/prepack, run the
* full build — slow enough to time out and fragile).
*
* Wired via `prepack`, so it fails `npm pack` / `npm publish` if the invariant is
* violated.
*/
const fs = require('fs');
const path = require('path');
const TUPLES = [
'linux-x64',
'linux-arm64',
'darwin-x64',
'darwin-arm64',
'win32-x64',
'win32-arm64',
];
// Source-build inputs (relative to vendor/<name>/) whose presence makes a grammar
// source-buildable. Per-grammar we only require the ones that exist on disk (e.g.
// tree-sitter-c has no external scanner.c).
const SOURCE_BUILD_REL = [
'binding.gyp',
'bindings/node/binding.cc',
'src/parser.c',
'src/scanner.c',
'src/tree_sitter/parser.h',
];
/**
* Does the package.json `files` allow-list ship the WHOLE vendor subtree (and
* therefore the vendored grammar source)? A bare `vendor` (optionally with a
* trailing slash or `/**`/`/*`) includes everything under vendor/. A lean publish
* replaces that with non-source subpaths, so this returns false and grammars must
* then rely on prebuilds.
*/
function filesShipsVendorSource(filesField) {
return (filesField || []).some((f) => {
const n = String(f)
.replace(/\\/g, '/')
.replace(/\/+$/, '')
.replace(/\/\*\*?$/, '');
return n === 'vendor';
});
}
/** The on-disk source-build inputs for a grammar (relative paths). */
function sourceBuildSet(grammarDir) {
return SOURCE_BUILD_REL.filter((rel) => fs.existsSync(path.join(grammarDir, rel)));
}
/** True when a grammar can be source-built from its vendored files (has gyp + parser). */
function isBuildableFromSource(grammarDir) {
const set = sourceBuildSet(grammarDir);
return set.includes('binding.gyp') && set.includes('src/parser.c');
}
/** Count platform-arch tuples with a committed prebuilt .node on disk. */
function countPrebuiltTuples(grammarDir) {
const pdir = path.join(grammarDir, 'prebuilds');
let n = 0;
for (const t of TUPLES) {
const td = path.join(pdir, t);
try {
if (fs.statSync(td).isDirectory() && fs.readdirSync(td).some((f) => f.endsWith('.node'))) {
n++;
}
} catch {
/* tuple dir absent — not covered */
}
}
return n;
}
/**
* Pure core (exported for tests). `grammars` is a list of
* `{ name, prebuilt: 0..6, shipsSource: boolean }`. Returns human-readable
* problem strings; an empty array means the pack is publish-safe.
*/
function findCoverageProblems({ grammars }) {
const problems = [];
for (const g of grammars) {
if (g.prebuilt < 6 && !g.shipsSource) {
const missing = 6 - g.prebuilt;
problems.push(
`${g.name}: ${g.prebuilt}/6 prebuilds and its vendored source is not shipped ` +
`(the package.json \`files\` field excludes it, or it is not buildable) — would ship ` +
`with no loadable binding on ${missing} platform-arch tuple(s).`,
);
}
}
return problems;
}
/**
* Stray local source-build outputs under `vendor/<name>/build/`. These would
* ship in the tarball (`files: ["vendor"]` overrides .gitignore/.npmignore) AND
* shadow the committed prebuilds — `node-gyp-build` resolves `build/Release`
* BEFORE `prebuilds/`, so a consumer on the publisher's platform would load the
* stray (possibly stale/wrong) binding instead of the curated prebuild. The
* build dir is gitignored and only appears if a maintainer source-built locally
* (e.g. on a no-prebuild platform); refuse to publish it. (#2144 review.)
*/
function findStrayBuildArtifacts(vendorDir) {
if (!fs.existsSync(vendorDir)) return [];
return fs
.readdirSync(vendorDir)
.filter((d) => /^tree-sitter-/.test(d))
.filter((d) => fs.existsSync(path.join(vendorDir, d, 'build')))
.map((d) => `vendor/${d}/build`);
}
function collectGrammars(vendorDir, shipsVendorSource) {
if (!fs.existsSync(vendorDir)) return [];
return fs
.readdirSync(vendorDir)
.filter((d) => /^tree-sitter-/.test(d))
.map((name) => {
const dir = path.join(vendorDir, name);
return {
name,
prebuilt: countPrebuiltTuples(dir),
// Source ships when `files` includes the vendor subtree AND the grammar
// actually carries a buildable source set on disk.
shipsSource: shipsVendorSource && isBuildableFromSource(dir),
};
});
}
function main() {
const gitnexusRoot = path.join(__dirname, '..');
const vendorDir = path.join(gitnexusRoot, 'vendor');
const pkg = JSON.parse(fs.readFileSync(path.join(gitnexusRoot, 'package.json'), 'utf8'));
const shipsVendorSource = filesShipsVendorSource(pkg.files);
const grammars = collectGrammars(vendorDir, shipsVendorSource);
if (grammars.length === 0) {
console.error(`[publish-guard] No vendored tree-sitter grammars found under ${vendorDir}.`);
process.exit(1);
}
const stray = findStrayBuildArtifacts(vendorDir);
if (stray.length > 0) {
console.error(
'[publish-guard] Refusing to publish — stray source-build output under vendor/ would\n' +
'ship and shadow the committed prebuilds (node-gyp-build loads build/Release before\n' +
'prebuilds/):',
);
for (const s of stray) console.error(` - ${s}`);
console.error('\nFix: remove it before packing, e.g. `rm -rf gitnexus/vendor/*/build`.');
process.exit(1);
}
const problems = findCoverageProblems({ grammars });
if (problems.length > 0) {
console.error('[publish-guard] Refusing to publish — a vendored grammar would ship unusable:');
for (const p of problems) console.error(` - ${p}`);
console.error(
'\nFix: either commit the missing prebuilds (run the build-tree-sitter-prebuilds\n' +
'workflow) or keep the vendored source in the package.json `files` field.',
);
process.exit(1);
}
const sourceShippers = grammars.filter((g) => g.shipsSource).length;
console.log(
`[publish-guard] OK — ${grammars.length} vendored grammar(s) covered ` +
`(${sourceShippers} shipping source, ${grammars.length - sourceShippers} prebuilds-only).`,
);
}
if (require.main === module) main();
module.exports = {
findCoverageProblems,
findStrayBuildArtifacts,
filesShipsVendorSource,
isBuildableFromSource,
sourceBuildSet,
countPrebuiltTuples,
collectGrammars,
TUPLES,
SOURCE_BUILD_REL,
};