GitNexus/gitnexus/test/integration/resolvers/kotlin-coverage.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

179 lines
7.7 KiB
TypeScript

/**
* Regression tests for Kotlin parsing-layer coverage gaps (issue #1919).
*
* Mirrors dart-coverage.test.ts / python-parsing-coverage.test.ts: the scope-layer
* assertions exercise emitKotlinScopeCaptures directly; F49 also exercises the
* legacy KOTLIN_QUERIES structure-query bank (the live spurious-edge source).
*
* F47 — callable references (`::method`, `Type::new`, `obj::method`) were never
* captured: KOTLIN_SCOPE_QUERY had no callable_reference rule, so they never
* participated in call-graph resolution.
*
* F49 — the legacy KOTLIN_QUERIES infix rule captured ALL three simple_identifier
* children of an infix_expression (`a to b` → `a`, `to`, `b`) as @call.name,
* emitting spurious call references for the operands. The fix anchors the
* capture to the operator (the middle child) only.
*/
import { describe, it, expect, beforeAll } from 'vitest';
import path from 'path';
import Parser from 'tree-sitter';
import { requireVendoredGrammar } from '../../../src/core/tree-sitter/vendored-grammars.js';
import { emitKotlinScopeCaptures } from '../../../src/core/ingestion/languages/kotlin/captures.js';
// Vendored grammar — loaded from vendor/ by absolute path, never node_modules (#2111).
const Kotlin = requireVendoredGrammar('tree-sitter-kotlin');
import { KOTLIN_QUERIES } from '../../../src/core/ingestion/tree-sitter-queries.js';
import { FIXTURES, getRelationships, runPipelineFromRepo, type PipelineResult } from './helpers.js';
import type { CaptureMatch } from 'gitnexus-shared';
// ---------------------------------------------------------------------------
// F47 — callable references (scope layer)
// ---------------------------------------------------------------------------
const CALLABLE_REFS = `fun useCallableRefs() {
val a = ::topLevelFn
val b = String::length
val c = obj::method
val d = Type::new
}`;
/** All call references emitted for the source, as { name, receiver, form }. */
function callReferences(
src: string,
): Array<{ name: string; receiver?: string; form: 'free' | 'member' }> {
const matches = emitKotlinScopeCaptures(src, 'test.kt') as CaptureMatch[];
const out: Array<{ name: string; receiver?: string; form: 'free' | 'member' }> = [];
for (const m of matches) {
if (m['@reference.call.free'] !== undefined && m['@reference.name'] !== undefined) {
out.push({ name: m['@reference.name'].text, form: 'free' });
} else if (m['@reference.call.member'] !== undefined && m['@reference.name'] !== undefined) {
out.push({
name: m['@reference.name'].text,
receiver: m['@reference.receiver']?.text,
form: 'member',
});
}
}
return out;
}
describe('F47 — Kotlin callable references (scope layer)', () => {
it('captures a bare `::topLevelFn` reference as a free call', () => {
const refs = callReferences(CALLABLE_REFS);
const free = refs.find((r) => r.name === 'topLevelFn');
expect(free).toBeDefined();
expect(free!.form).toBe('free');
});
it('captures `String::length` as a member call with receiver String', () => {
const refs = callReferences(CALLABLE_REFS);
const ref = refs.find((r) => r.name === 'length');
expect(ref).toBeDefined();
expect(ref!.form).toBe('member');
expect(ref!.receiver).toBe('String');
});
it('captures `obj::method` as a member call with receiver obj', () => {
const refs = callReferences(CALLABLE_REFS);
const ref = refs.find((r) => r.name === 'method');
expect(ref).toBeDefined();
expect(ref!.form).toBe('member');
expect(ref!.receiver).toBe('obj');
});
it('captures `Type::new` (constructor reference) as a member call with receiver Type', () => {
const refs = callReferences(CALLABLE_REFS);
const ref = refs.find((r) => r.name === 'new');
expect(ref).toBeDefined();
expect(ref!.form).toBe('member');
expect(ref!.receiver).toBe('Type');
});
it('emits exactly one call reference per callable_reference (no double-match)', () => {
const refs = callReferences(CALLABLE_REFS).filter((r) =>
['topLevelFn', 'length', 'method', 'new'].includes(r.name),
);
expect(refs.map((r) => r.name).sort()).toEqual(['length', 'method', 'new', 'topLevelFn']);
});
it('does not capture the receiver type as its own free call', () => {
const refs = callReferences(CALLABLE_REFS);
// String / Type / obj are receivers, never standalone call targets.
expect(refs.some((r) => r.form === 'free' && r.name === 'String')).toBe(false);
expect(refs.some((r) => r.form === 'free' && r.name === 'Type')).toBe(false);
});
});
// ---------------------------------------------------------------------------
// F47 — callable references resolve to CALLS edges end-to-end (worker path)
// ---------------------------------------------------------------------------
describe('F47 — Kotlin callable references (end-to-end)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'kotlin-coverage'), () => {});
}, 60000);
it('resolves a bare `::topLevelFn` reference to a CALLS edge on the local function', () => {
const calls = getRelationships(result, 'CALLS');
const ref = calls.find((c) => c.source === 'useCallableRefs' && c.target === 'topLevelFn');
expect(ref).toBeDefined();
});
it('resolves an `obj::method` member reference to a CALLS edge on Obj.method', () => {
const calls = getRelationships(result, 'CALLS');
const ref = calls.find((c) => c.source === 'useCallableRefs' && c.target === 'method');
expect(ref).toBeDefined();
});
it('runs through the worker pool (parity: capture edits survive the worker boundary)', () => {
expect(result.usedWorkerPool).toBe(true);
});
});
// ---------------------------------------------------------------------------
// F49 — infix-call query captures only the operator (legacy structure bank)
// ---------------------------------------------------------------------------
//
// Characterized end-to-end (issue #1919): the live spurious @call.name edges
// for `a to b` originate from the legacy KOTLIN_QUERIES bank (still wired as
// provider.treeSitterQueries / used by the worker structure phase). The
// registry KOTLIN_SCOPE_QUERY has no infix rule, so the fix is in
// tree-sitter-queries.ts only. These tests compile that live query and assert
// the call captures directly.
/** @call.name capture texts produced by the live KOTLIN_QUERIES structure bank. */
function structureCallNames(src: string): string[] {
const parser = new Parser();
parser.setLanguage(Kotlin as Parameters<Parser['setLanguage']>[0]);
const query = new Parser.Query(Kotlin as Parameters<Parser['setLanguage']>[0], KOTLIN_QUERIES);
const tree = parser.parse(src);
const names: string[] = [];
for (const match of query.matches(tree.rootNode)) {
for (const c of match.captures) {
if (c.name === 'call.name') names.push(c.node.text);
}
}
return names;
}
describe('F49 — Kotlin infix call captures only the operator', () => {
it('`val p = a to b` captures exactly one call (`to`), zero for the operands', () => {
const names = structureCallNames(`fun f() {\n val p = a to b\n}`);
expect(names).toEqual(['to']);
});
it('`a to b to c` captures only the `to` operators, never the operands', () => {
const names = structureCallNames(`fun f() {\n val q = a to b to c\n}`);
expect(names.sort()).toEqual(['to', 'to']);
expect(names.includes('a')).toBe(false);
expect(names.includes('b')).toBe(false);
expect(names.includes('c')).toBe(false);
});
it('a normal call `foo(a, b)` still produces exactly one call to `foo`', () => {
const names = structureCallNames(`fun f() {\n foo(a, b)\n}`);
expect(names).toEqual(['foo']);
});
});