GitNexus/gitnexus/test/unit/call-form.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

472 lines
19 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import {
inferCallForm,
extractReceiverName,
} from '../../src/core/ingestion/utils/call-analysis.js';
import type { SyntaxNode } from '../../src/core/ingestion/utils/ast-helpers.js';
import { createSymbolTable } from '../../src/core/ingestion/model/symbol-table.js';
import Parser from 'tree-sitter';
import TypeScript from 'tree-sitter-typescript';
import Python from 'tree-sitter-python';
import Java from 'tree-sitter-java';
import CSharp from 'tree-sitter-c-sharp';
import { requireVendoredGrammar } from '../../src/core/tree-sitter/vendored-grammars.js';
import Go from 'tree-sitter-go';
import Rust from 'tree-sitter-rust';
import CPP from 'tree-sitter-cpp';
import PHP from 'tree-sitter-php';
import { SupportedLanguages } from '../../src/config/supported-languages.js';
import { getProvider } from '../../src/core/ingestion/languages/index.js';
// Vendored grammar — loaded from vendor/ by absolute path, never node_modules (#2111).
const Kotlin = requireVendoredGrammar('tree-sitter-kotlin');
/**
* Helper: parse code, run the language query, and return all @call captures
* as { callNode, nameNode } pairs.
*/
function extractCallCaptures(
parser: Parser,
code: string,
language: string,
): Array<{ callNode: SyntaxNode; nameNode: SyntaxNode; calledName: string }> {
const provider = getProvider(language as SupportedLanguages);
const queryStr = provider.treeSitterQueries;
if (!queryStr) throw new Error(`No query for ${language}`);
const tree = parser.parse(code);
const lang = parser.getLanguage();
const query = new Parser.Query(lang, queryStr);
const matches = query.matches(tree.rootNode);
const results: Array<{ callNode: SyntaxNode; nameNode: SyntaxNode; calledName: string }> = [];
for (const match of matches) {
const captureMap: Record<string, SyntaxNode> = {};
for (const c of match.captures) {
captureMap[c.name] = c.node;
}
if (captureMap['call'] && captureMap['call.name']) {
results.push({
callNode: captureMap['call'],
nameNode: captureMap['call.name'],
calledName: captureMap['call.name'].text,
});
}
}
return results;
}
describe('inferCallForm', () => {
const parser = new Parser();
describe('TypeScript', () => {
it('detects free call', () => {
parser.setLanguage(TypeScript.typescript);
const captures = extractCallCaptures(parser, 'doStuff()', SupportedLanguages.TypeScript);
const match = captures.find((c) => c.calledName === 'doStuff');
expect(match).toBeDefined();
expect(inferCallForm(match!.callNode, match!.nameNode)).toBe('free');
});
it('detects member call', () => {
parser.setLanguage(TypeScript.typescript);
const captures = extractCallCaptures(parser, 'user.save()', SupportedLanguages.TypeScript);
const match = captures.find((c) => c.calledName === 'save');
expect(match).toBeDefined();
expect(inferCallForm(match!.callNode, match!.nameNode)).toBe('member');
});
});
describe('Python', () => {
it('detects free call', () => {
parser.setLanguage(Python);
const captures = extractCallCaptures(parser, 'print_result()', SupportedLanguages.Python);
const match = captures.find((c) => c.calledName === 'print_result');
expect(match).toBeDefined();
expect(inferCallForm(match!.callNode, match!.nameNode)).toBe('free');
});
it('detects member call', () => {
parser.setLanguage(Python);
const captures = extractCallCaptures(parser, 'self.save()', SupportedLanguages.Python);
const match = captures.find((c) => c.calledName === 'save');
expect(match).toBeDefined();
expect(inferCallForm(match!.callNode, match!.nameNode)).toBe('member');
});
});
describe('Java', () => {
it('detects free call (no object)', () => {
parser.setLanguage(Java);
const code = `class Foo { void run() { doStuff(); } }`;
const captures = extractCallCaptures(parser, code, SupportedLanguages.Java);
const match = captures.find((c) => c.calledName === 'doStuff');
expect(match).toBeDefined();
expect(inferCallForm(match!.callNode, match!.nameNode)).toBe('free');
});
it('detects member call (with object)', () => {
parser.setLanguage(Java);
const code = `class Foo { void run() { user.save(); } }`;
const captures = extractCallCaptures(parser, code, SupportedLanguages.Java);
const match = captures.find((c) => c.calledName === 'save');
expect(match).toBeDefined();
expect(inferCallForm(match!.callNode, match!.nameNode)).toBe('member');
});
});
describe('C#', () => {
it('detects free call', () => {
parser.setLanguage(CSharp);
const code = `class Foo { void Run() { DoStuff(); } }`;
const captures = extractCallCaptures(parser, code, SupportedLanguages.CSharp);
const match = captures.find((c) => c.calledName === 'DoStuff');
expect(match).toBeDefined();
expect(inferCallForm(match!.callNode, match!.nameNode)).toBe('free');
});
it('detects member call', () => {
parser.setLanguage(CSharp);
const code = `class Foo { void Run() { user.Save(); } }`;
const captures = extractCallCaptures(parser, code, SupportedLanguages.CSharp);
const match = captures.find((c) => c.calledName === 'Save');
expect(match).toBeDefined();
expect(inferCallForm(match!.callNode, match!.nameNode)).toBe('member');
});
});
describe('Go', () => {
it('detects free call', () => {
parser.setLanguage(Go);
const code = `package main\nfunc main() { doStuff() }`;
const captures = extractCallCaptures(parser, code, SupportedLanguages.Go);
const match = captures.find((c) => c.calledName === 'doStuff');
expect(match).toBeDefined();
expect(inferCallForm(match!.callNode, match!.nameNode)).toBe('free');
});
it('detects member call via selector', () => {
parser.setLanguage(Go);
const code = `package main\nfunc main() { user.Save() }`;
const captures = extractCallCaptures(parser, code, SupportedLanguages.Go);
const match = captures.find((c) => c.calledName === 'Save');
expect(match).toBeDefined();
expect(inferCallForm(match!.callNode, match!.nameNode)).toBe('member');
});
});
describe('Rust', () => {
it('detects free call', () => {
parser.setLanguage(Rust);
const code = `fn main() { do_stuff(); }`;
const captures = extractCallCaptures(parser, code, SupportedLanguages.Rust);
const match = captures.find((c) => c.calledName === 'do_stuff');
expect(match).toBeDefined();
expect(inferCallForm(match!.callNode, match!.nameNode)).toBe('free');
});
it('detects member call via field_expression', () => {
parser.setLanguage(Rust);
const code = `fn main() { user.save(); }`;
const captures = extractCallCaptures(parser, code, SupportedLanguages.Rust);
const match = captures.find((c) => c.calledName === 'save');
expect(match).toBeDefined();
expect(inferCallForm(match!.callNode, match!.nameNode)).toBe('member');
});
it('detects scoped call as free (Foo::new)', () => {
parser.setLanguage(Rust);
const code = `fn main() { Foo::new(); }`;
const captures = extractCallCaptures(parser, code, SupportedLanguages.Rust);
const match = captures.find((c) => c.calledName === 'new');
expect(match).toBeDefined();
expect(inferCallForm(match!.callNode, match!.nameNode)).toBe('free');
});
});
describe('C++', () => {
it('detects free call', () => {
parser.setLanguage(CPP);
const code = `void main() { doStuff(); }`;
const captures = extractCallCaptures(parser, code, SupportedLanguages.CPlusPlus);
const match = captures.find((c) => c.calledName === 'doStuff');
expect(match).toBeDefined();
expect(inferCallForm(match!.callNode, match!.nameNode)).toBe('free');
});
it('detects member call via field_expression', () => {
parser.setLanguage(CPP);
const code = `void main() { obj.run(); }`;
const captures = extractCallCaptures(parser, code, SupportedLanguages.CPlusPlus);
const match = captures.find((c) => c.calledName === 'run');
expect(match).toBeDefined();
expect(inferCallForm(match!.callNode, match!.nameNode)).toBe('member');
});
});
describe('PHP', () => {
it('detects free function call', () => {
parser.setLanguage(PHP.php);
const code = `<?php doStuff(); ?>`;
const captures = extractCallCaptures(parser, code, SupportedLanguages.PHP);
const match = captures.find((c) => c.calledName === 'doStuff');
expect(match).toBeDefined();
expect(inferCallForm(match!.callNode, match!.nameNode)).toBe('free');
});
it('detects member call', () => {
parser.setLanguage(PHP.php);
const code = `<?php $user->save(); ?>`;
const captures = extractCallCaptures(parser, code, SupportedLanguages.PHP);
const match = captures.find((c) => c.calledName === 'save');
expect(match).toBeDefined();
expect(inferCallForm(match!.callNode, match!.nameNode)).toBe('member');
});
it('detects static call as member', () => {
parser.setLanguage(PHP.php);
const code = `<?php Foo::bar(); ?>`;
const captures = extractCallCaptures(parser, code, SupportedLanguages.PHP);
const match = captures.find((c) => c.calledName === 'bar');
expect(match).toBeDefined();
expect(inferCallForm(match!.callNode, match!.nameNode)).toBe('member');
});
});
describe('Kotlin', () => {
it('detects free call', () => {
parser.setLanguage(Kotlin);
const code = `fun main() { doStuff() }`;
const captures = extractCallCaptures(parser, code, SupportedLanguages.Kotlin);
const match = captures.find((c) => c.calledName === 'doStuff');
expect(match).toBeDefined();
expect(inferCallForm(match!.callNode, match!.nameNode)).toBe('free');
});
it('detects member call via navigation_expression', () => {
parser.setLanguage(Kotlin);
const code = `fun main() { user.save() }`;
const captures = extractCallCaptures(parser, code, SupportedLanguages.Kotlin);
const match = captures.find((c) => c.calledName === 'save');
expect(match).toBeDefined();
expect(inferCallForm(match!.callNode, match!.nameNode)).toBe('member');
});
it('Foo() is a free call (constructor_invocation only in heritage context)', () => {
parser.setLanguage(Kotlin);
const code = `fun main() { val x = Foo() }`;
const captures = extractCallCaptures(parser, code, SupportedLanguages.Kotlin);
const match = captures.find((c) => c.calledName === 'Foo');
expect(match).toBeDefined();
// Kotlin Foo() is syntactically a call_expression, not constructor_invocation
// Constructor discrimination happens in Phase 2 via symbol kind matching
expect(inferCallForm(match!.callNode, match!.nameNode)).toBe('free');
});
it('detects constructor_invocation in heritage delegation as constructor', () => {
parser.setLanguage(Kotlin);
const code = `open class Base\nclass Derived : Base()`;
const captures = extractCallCaptures(parser, code, SupportedLanguages.Kotlin);
const match = captures.find((c) => c.calledName === 'Base');
// constructor_invocation is captured by heritage queries, not call queries
// If it happens to be captured, it should be 'constructor'
if (match) {
expect(inferCallForm(match.callNode, match.nameNode)).toBe('constructor');
}
});
});
});
describe('extractReceiverName', () => {
const parser = new Parser();
describe('TypeScript', () => {
it('extracts simple identifier receiver', () => {
parser.setLanguage(TypeScript.typescript);
const captures = extractCallCaptures(parser, 'user.save()', SupportedLanguages.TypeScript);
const match = captures.find((c) => c.calledName === 'save');
expect(match).toBeDefined();
expect(extractReceiverName(match!.nameNode)).toBe('user');
});
it('extracts "this" as receiver', () => {
parser.setLanguage(TypeScript.typescript);
const code = `class Foo { run() { this.save(); } }`;
const captures = extractCallCaptures(parser, code, SupportedLanguages.TypeScript);
const match = captures.find((c) => c.calledName === 'save');
expect(match).toBeDefined();
expect(extractReceiverName(match!.nameNode)).toBe('this');
});
it('returns undefined for chained call receiver', () => {
parser.setLanguage(TypeScript.typescript);
const captures = extractCallCaptures(
parser,
'getUser().save()',
SupportedLanguages.TypeScript,
);
const match = captures.find((c) => c.calledName === 'save');
expect(match).toBeDefined();
expect(extractReceiverName(match!.nameNode)).toBeUndefined();
});
it('returns undefined for free call', () => {
parser.setLanguage(TypeScript.typescript);
const captures = extractCallCaptures(parser, 'doStuff()', SupportedLanguages.TypeScript);
const match = captures.find((c) => c.calledName === 'doStuff');
expect(match).toBeDefined();
expect(extractReceiverName(match!.nameNode)).toBeUndefined();
});
it('extracts receiver from optional chain call user?.save()', () => {
parser.setLanguage(TypeScript.typescript);
const captures = extractCallCaptures(parser, 'user?.save()', SupportedLanguages.TypeScript);
const match = captures.find((c) => c.calledName === 'save');
expect(match).toBeDefined();
expect(extractReceiverName(match!.nameNode)).toBe('user');
});
it('extracts "this" from optional chain call this?.save()', () => {
parser.setLanguage(TypeScript.typescript);
const code = `class Foo { run() { this?.save(); } }`;
const captures = extractCallCaptures(parser, code, SupportedLanguages.TypeScript);
const match = captures.find((c) => c.calledName === 'save');
expect(match).toBeDefined();
expect(extractReceiverName(match!.nameNode)).toBe('this');
});
});
describe('Python', () => {
it('extracts simple identifier receiver', () => {
parser.setLanguage(Python);
const captures = extractCallCaptures(parser, 'user.save()', SupportedLanguages.Python);
const match = captures.find((c) => c.calledName === 'save');
expect(match).toBeDefined();
expect(extractReceiverName(match!.nameNode)).toBe('user');
});
it('extracts "self" as receiver', () => {
parser.setLanguage(Python);
const captures = extractCallCaptures(parser, 'self.save()', SupportedLanguages.Python);
const match = captures.find((c) => c.calledName === 'save');
expect(match).toBeDefined();
expect(extractReceiverName(match!.nameNode)).toBe('self');
});
});
describe('Java', () => {
it('extracts receiver from method_invocation', () => {
parser.setLanguage(Java);
const code = `class Foo { void run() { user.save(); } }`;
const captures = extractCallCaptures(parser, code, SupportedLanguages.Java);
const match = captures.find((c) => c.calledName === 'save');
expect(match).toBeDefined();
expect(extractReceiverName(match!.nameNode)).toBe('user');
});
});
describe('Go', () => {
it('extracts receiver from selector_expression', () => {
parser.setLanguage(Go);
const code = `package main\nfunc main() { user.Save() }`;
const captures = extractCallCaptures(parser, code, SupportedLanguages.Go);
const match = captures.find((c) => c.calledName === 'Save');
expect(match).toBeDefined();
expect(extractReceiverName(match!.nameNode)).toBe('user');
});
});
describe('Rust', () => {
it('extracts receiver from field_expression', () => {
parser.setLanguage(Rust);
const code = `fn main() { user.save(); }`;
const captures = extractCallCaptures(parser, code, SupportedLanguages.Rust);
const match = captures.find((c) => c.calledName === 'save');
expect(match).toBeDefined();
expect(extractReceiverName(match!.nameNode)).toBe('user');
});
});
describe('C#', () => {
it('extracts receiver from member_access_expression', () => {
parser.setLanguage(CSharp);
const code = `class Foo { void Run() { user.Save(); } }`;
const captures = extractCallCaptures(parser, code, SupportedLanguages.CSharp);
const match = captures.find((c) => c.calledName === 'Save');
expect(match).toBeDefined();
expect(extractReceiverName(match!.nameNode)).toBe('user');
});
it('captures null-conditional user?.Save() and extracts receiver', () => {
parser.setLanguage(CSharp);
const code = `class Foo { void Run() { user?.Save(); } }`;
const captures = extractCallCaptures(parser, code, SupportedLanguages.CSharp);
const match = captures.find((c) => c.calledName === 'Save');
// C# conditional_access_expression (user?.Save()) is now captured via member_binding_expression
expect(match).toBeDefined();
expect(inferCallForm(match!.callNode, match!.nameNode)).toBe('member');
expect(extractReceiverName(match!.nameNode)).toBe('user');
});
});
describe('Kotlin', () => {
it('extracts receiver from navigation_expression', () => {
parser.setLanguage(Kotlin);
const code = `fun main() { user.save() }`;
const captures = extractCallCaptures(parser, code, SupportedLanguages.Kotlin);
const match = captures.find((c) => c.calledName === 'save');
expect(match).toBeDefined();
expect(extractReceiverName(match!.nameNode)).toBe('user');
});
it('extracts receiver from safe navigation user?.save()', () => {
parser.setLanguage(Kotlin);
const code = `fun main() { user?.save() }`;
const captures = extractCallCaptures(parser, code, SupportedLanguages.Kotlin);
const match = captures.find((c) => c.calledName === 'save');
expect(match).toBeDefined();
expect(extractReceiverName(match!.nameNode)).toBe('user');
});
});
});
describe('ownerId on SymbolDefinition', () => {
it('is set for Method symbols via symbolTable.add()', () => {
const st = createSymbolTable();
st.add('src/foo.ts', 'save', 'Method:src/foo.ts:save', 'Method', {
parameterCount: 1,
ownerId: 'Class:src/foo.ts:User',
});
const def = st.lookupExactFull('src/foo.ts', 'save');
expect(def).toBeDefined();
expect(def!.ownerId).toBe('Class:src/foo.ts:User');
expect(def!.parameterCount).toBe(1);
});
it('is undefined for Function symbols (no owner)', () => {
const st = createSymbolTable();
st.add('src/foo.ts', 'helper', 'Function:src/foo.ts:helper', 'Function');
const def = st.lookupExactFull('src/foo.ts', 'helper');
expect(def).toBeDefined();
expect(def!.ownerId).toBeUndefined();
});
it('propagates ownerId through a free Function registration', () => {
// Post-A4 Unit 4, Method is no longer in FREE_CALLABLE_TYPES so this test
// exercises ownerId propagation through the free-callable index using
// a Function label. Method-with-ownerId propagation is covered via
// methodsByName in method-registry.test.ts.
const st = createSymbolTable();
st.add('src/foo.ts', 'save', 'Function:src/foo.ts:save', 'Function', {
ownerId: 'Class:src/foo.ts:User',
});
const defs = st.lookupCallableByName('save');
expect(defs).toHaveLength(1);
expect(defs[0].ownerId).toBe('Class:src/foo.ts:User');
});
});