GitNexus/gitnexus/test/unit/cli-commands.test.ts
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

200 lines
9.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, it, expect, vi } from 'vitest';
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
async function readRepoJson<T>(relativePath: string): Promise<T> {
return JSON.parse(await fs.readFile(path.join(REPO_ROOT, relativePath), 'utf8')) as T;
}
// Mock all the heavy imports before importing index
vi.mock('../../src/cli/analyze.js', () => ({
analyzeCommand: vi.fn(),
}));
vi.mock('../../src/cli/mcp.js', () => ({
mcpCommand: vi.fn(),
}));
vi.mock('../../src/cli/setup.js', () => ({
setupCommand: vi.fn(),
}));
vi.mock('../../src/cli/publish.js', () => ({
publishCommand: vi.fn(),
}));
describe('CLI commands', () => {
describe('version', () => {
it('package.json has a valid version string', async () => {
const pkg = await import('../../package.json', { with: { type: 'json' } });
expect(pkg.default.version).toMatch(/^\d+\.\d+\.\d+/);
});
it('keeps Claude plugin manifests aligned with the gitnexus release version', async () => {
const pkg = await import('../../package.json', { with: { type: 'json' } });
const pluginManifest = await readRepoJson<{ version: string }>(
'gitnexus-claude-plugin/.claude-plugin/plugin.json',
);
const marketplaceManifest = await readRepoJson<{
plugins?: Array<{ name: string; version: string }>;
}>('.claude-plugin/marketplace.json');
expect(Array.isArray(marketplaceManifest.plugins)).toBe(true);
const gitnexusEntries = (marketplaceManifest.plugins ?? []).filter(
(plugin) => plugin.name === 'gitnexus',
);
expect(gitnexusEntries).toHaveLength(1);
expect(pluginManifest.version).toBe(pkg.default.version);
expect(gitnexusEntries[0]?.version).toBe(pkg.default.version);
});
});
describe('package.json scripts', () => {
it('has test scripts configured', async () => {
const pkg = await import('../../package.json', { with: { type: 'json' } });
expect(pkg.default.scripts.test).toBeDefined();
expect(pkg.default.scripts['test:integration']).toBeDefined();
expect(pkg.default.scripts['test:unit']).toBeDefined();
});
it('has build script', async () => {
const pkg = await import('../../package.json', { with: { type: 'json' } });
expect(pkg.default.scripts.build).toBeDefined();
});
});
describe('package.json bin entry', () => {
it('exposes gitnexus binary', async () => {
const pkg = await import('../../package.json', { with: { type: 'json' } });
expect(pkg.default.bin).toBeDefined();
expect(pkg.default.bin.gitnexus || pkg.default.bin).toBeDefined();
});
});
describe('optional parser dependencies', () => {
it('loads vendored grammars from vendor/ — never file: optionalDependencies (#1728) nor a node_modules copy (#2111)', async () => {
const pkg = await import('../../package.json', { with: { type: 'json' } });
const optional = pkg.default.optionalDependencies ?? {};
expect(optional['tree-sitter-dart']).toBeUndefined();
expect(optional['tree-sitter-proto']).toBeUndefined();
expect(optional['tree-sitter-swift']).toBeUndefined();
// #2111: the grammars MUST NOT be copied into node_modules at install — an
// undeclared node_modules package is "extraneous" to every subsequent
// npm/npx reify, which prunes/relocates it (Windows EPERM symlink + silent
// deletion on the 2nd run). They are loaded from vendor/ by absolute path
// (vendored-grammars.ts), so postinstall no longer materializes anything.
expect(pkg.default.scripts.postinstall).not.toContain('materialize-vendor-grammars.cjs');
expect(pkg.default.scripts.postinstall).toContain('build-tree-sitter-grammars.cjs');
expect(pkg.default.files).toContain('vendor');
});
it('declares node-gyp-build/node-addon-api as regular dependencies (runtime-load contract)', async () => {
// Every vendored grammar's index.js does `require("node-gyp-build")` at
// runtime to load even a prebuilt .node, so node-gyp-build must always be
// present. They were optionalDependencies (surviving --omit=optional only
// via tree-sitter's transitive edge); promote them so the contract is
// explicit and robust to a future tree-sitter change.
const pkg = await import('../../package.json', { with: { type: 'json' } });
const deps = pkg.default.dependencies ?? {};
const optional = (pkg.default as { optionalDependencies?: Record<string, string> })
.optionalDependencies;
expect(deps['node-gyp-build']).toBeDefined();
expect(deps['node-addon-api']).toBeDefined();
// No grammar/native-build entries linger in optionalDependencies.
expect(optional?.['node-gyp-build']).toBeUndefined();
expect(optional?.['node-addon-api']).toBeUndefined();
});
it('keeps vendored Swift runtime with vendored source + GitNexus-built prebuilds and hoisted activation script', async () => {
const pkg = await import('../../package.json', { with: { type: 'json' } });
const swiftPkg = await import('../../vendor/tree-sitter-swift/package.json', {
with: { type: 'json' },
});
// Exact pin (no caret) — #1922 holds the runtime at 0.21.1 so the ABI
// gate's assumptions (setTimeoutMicros semantics, ABI 1314 grammar
// range) can't drift under a minor bump.
expect(pkg.default.dependencies['tree-sitter']).toBe('0.21.1');
expect(pkg.default.scripts.postinstall).toContain('build-tree-sitter-grammars.cjs');
expect(swiftPkg.default.version).toBe('0.7.1');
// No scripts.install / dependencies inside vendor/ (#836 / #1728 hygiene).
expect(swiftPkg.default.scripts?.install).toBeUndefined();
expect(swiftPkg.default.dependencies).toBeUndefined();
expect(swiftPkg.default.peerDependencies['tree-sitter']).toContain('^0.21.1');
// Swift is now unified with Dart/Proto/Kotlin/C: the grammar SOURCE is
// vendored so build-tree-sitter-grammars.cjs can source-build the binding
// when no committed prebuild matches (e.g. CI before prebuilds land).
const bindingGyp = await fs.readFile(
path.join(REPO_ROOT, 'gitnexus/vendor/tree-sitter-swift/binding.gyp'),
'utf8',
);
expect(bindingGyp).toContain('tree_sitter_swift_binding');
expect(bindingGyp).toContain('src/parser.c');
await expect(
fs.stat(path.join(REPO_ROOT, 'gitnexus/vendor/tree-sitter-swift/src/parser.c')),
).resolves.toBeDefined();
});
it('keeps vendored Kotlin runtime with GitNexus-built prebuilds and hoisted activation script (#2107)', async () => {
const pkg = await import('../../package.json', { with: { type: 'json' } });
const kotlinPkg = await import('../../vendor/tree-sitter-kotlin/package.json', {
with: { type: 'json' },
});
const optional = pkg.default.optionalDependencies ?? {};
// Kotlin is now VENDORED (like Swift/Dart/Proto), not a third-party npm
// optionalDependency. Its prebuilds are GitNexus-cross-built (upstream
// ships source only) and loaded from vendor/ by absolute path (#2111).
expect(optional['tree-sitter-kotlin']).toBeUndefined();
expect(pkg.default.scripts.postinstall).toContain('build-tree-sitter-grammars.cjs');
expect(kotlinPkg.default.version).toBe('0.3.8');
// No scripts.install / dependencies inside vendor/ (#836 / #1728 hygiene).
expect(kotlinPkg.default.scripts?.install).toBeUndefined();
expect(kotlinPkg.default.dependencies).toBeUndefined();
expect(kotlinPkg.default.peerDependencies['tree-sitter']).toContain('^0.21');
});
it('vendors tree-sitter-c prebuild-only at the 0.21.4 ABI pin instead of an npm dependency (#2116/#1242)', async () => {
const pkg = await import('../../package.json', { with: { type: 'json' } });
const cPkg = await import('../../vendor/tree-sitter-c/package.json', {
with: { type: 'json' },
});
// c is a REQUIRED grammar that hard-fails install on toolchain-less ARM
// (upstream ships 4/6). Vendored with GitNexus-built prebuilds for all 6,
// held at 0.21.4 for ABI safety (#1242) — so it is NOT an npm dependency.
expect(pkg.default.dependencies['tree-sitter-c']).toBeUndefined();
expect(pkg.default.scripts.postinstall).toContain('build-tree-sitter-grammars.cjs');
expect(cPkg.default.version).toBe('0.21.4');
expect(cPkg.default.scripts?.install).toBeUndefined();
expect(cPkg.default.dependencies).toBeUndefined();
});
});
describe('analyzeCommand', () => {
it('is a function', async () => {
const { analyzeCommand } = await import('../../src/cli/analyze.js');
expect(typeof analyzeCommand).toBe('function');
});
});
describe('mcpCommand', () => {
it('is a function', async () => {
const { mcpCommand } = await import('../../src/cli/mcp.js');
expect(typeof mcpCommand).toBe('function');
});
});
describe('setupCommand', () => {
it('is a function', async () => {
const { setupCommand } = await import('../../src/cli/setup.js');
expect(typeof setupCommand).toBe('function');
});
});
describe('publishCommand', () => {
it('is a function', async () => {
const { publishCommand } = await import('../../src/cli/publish.js');
expect(typeof publishCommand).toBe('function');
});
});
});