GitNexus/gitnexus/test/unit/resolve-enclosing-owner.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

291 lines
11 KiB
TypeScript

/**
* Regression tests for the provider-driven resolveEnclosingOwner hook.
*
* Verifies that:
* 1. findEnclosingClassInfo delegates to the resolveEnclosingOwner hook
* 2. Ruby's resolveEnclosingOwner correctly remaps singleton_class → class/module
* 3. The hook returns null to skip containers (keep walking up)
* 4. Without the hook, the generic behavior is preserved
*/
import { describe, it, expect } from 'vitest';
import Parser from 'tree-sitter';
import Ruby from 'tree-sitter-ruby';
import { findEnclosingClassInfo } from '../../src/core/ingestion/utils/ast-helpers.js';
import { rubyProvider } from '../../src/core/ingestion/languages/ruby.js';
import { requireVendoredGrammar } from '../../src/core/tree-sitter/vendored-grammars.js';
// Vendored grammar — loaded from vendor/ by absolute path, never node_modules (#2111).
let Kotlin: unknown;
try {
Kotlin = requireVendoredGrammar('tree-sitter-kotlin');
} catch {
// Kotlin grammar may not have a prebuild for this platform
}
const parser = new Parser();
const parseRuby = (code: string) => {
parser.setLanguage(Ruby);
return parser.parse(code);
};
const parseKotlin = (code: string) => {
parser.setLanguage(Kotlin as Parser.Language);
return parser.parse(code);
};
// ---------------------------------------------------------------------------
// Ruby resolveEnclosingOwner hook
// ---------------------------------------------------------------------------
describe('Ruby resolveEnclosingOwner', () => {
it('remaps singleton_class to enclosing class for findEnclosingClassInfo', () => {
const tree = parseRuby(`
class Animal
class << self
def from_habitat(habitat)
end
end
end
`);
// Navigate to the method node inside singleton_class
const classNode = tree.rootNode.child(0)!;
const bodyStmt = classNode.namedChildren.find((c) => c.type === 'body_statement')!;
const singletonClass = bodyStmt.namedChildren.find((c) => c.type === 'singleton_class')!;
const innerBody = singletonClass.namedChildren.find((c) => c.type === 'body_statement')!;
const methodNode = innerBody.namedChildren.find((c) => c.type === 'method')!;
const info = findEnclosingClassInfo(
methodNode,
'animal.rb',
rubyProvider.resolveEnclosingOwner,
);
expect(info).not.toBeNull();
expect(info!.className).toBe('Animal');
expect(info!.classId).toContain('Animal');
});
it('remaps singleton_class inside module to enclosing module', () => {
const tree = parseRuby(`
module Helpers
class << self
def greet
end
end
end
`);
const moduleNode = tree.rootNode.child(0)!;
const bodyStmt = moduleNode.namedChildren.find((c) => c.type === 'body_statement')!;
const singletonClass = bodyStmt.namedChildren.find((c) => c.type === 'singleton_class')!;
const innerBody = singletonClass.namedChildren.find((c) => c.type === 'body_statement')!;
const methodNode = innerBody.namedChildren.find((c) => c.type === 'method')!;
const info = findEnclosingClassInfo(
methodNode,
'helpers.rb',
rubyProvider.resolveEnclosingOwner,
);
expect(info).not.toBeNull();
expect(info!.className).toBe('Helpers');
// Ruby modules are labeled `Trait` so mixin heritage resolves through
// the class-like type registry; the enclosing class id switches labels
// in lockstep with the structure-phase label.
expect(info!.classId).toContain('Trait');
});
it('returns null for file-level singleton_class without enclosing class', () => {
const tree = parseRuby(`
class << self
def orphan
end
end
`);
const singletonClass = tree.rootNode.child(0)!;
const innerBody = singletonClass.namedChildren.find((c) => c.type === 'body_statement')!;
const methodNode = innerBody.namedChildren.find((c) => c.type === 'method')!;
const info = findEnclosingClassInfo(
methodNode,
'orphan.rb',
rubyProvider.resolveEnclosingOwner,
);
// No enclosing class/module — should return null
expect(info).toBeNull();
});
it('non-singleton containers pass through unchanged', () => {
const tree = parseRuby(`
class Dog
def bark
end
end
`);
const classNode = tree.rootNode.child(0)!;
const bodyStmt = classNode.namedChildren.find((c) => c.type === 'body_statement')!;
const methodNode = bodyStmt.namedChildren.find((c) => c.type === 'method')!;
const info = findEnclosingClassInfo(methodNode, 'dog.rb', rubyProvider.resolveEnclosingOwner);
expect(info).not.toBeNull();
expect(info!.className).toBe('Dog');
});
});
// ---------------------------------------------------------------------------
// Kotlin: findEnclosingClassInfo without resolveEnclosingOwner
// ---------------------------------------------------------------------------
describe('Kotlin enclosing owner resolution (no resolveEnclosingOwner needed)', () => {
(Kotlin ? it : it.skip)('companion_object methods resolve to the companion object name', () => {
const tree = parseKotlin(`
class UserService {
companion object Factory {
fun create(): UserService = UserService()
}
}
`);
// Navigate to the function_declaration inside companion object
const classNode = tree.rootNode.child(0)!;
const classBody = classNode.namedChild(1)!;
const companion = classBody.namedChild(0)!;
const companionBody = companion.namedChildren.find((c) => c.type === 'class_body')!;
const funcDecl = companionBody.namedChildren.find((c) => c.type === 'function_declaration')!;
const info = findEnclosingClassInfo(funcDecl, 'service.kt');
expect(info).not.toBeNull();
// companion_object is a valid CLASS_CONTAINER_TYPES — its name resolves via generic logic
expect(info!.className).toBe('Factory');
});
(Kotlin ? it : it.skip)('object_declaration methods resolve to the object name', () => {
const tree = parseKotlin(`
object Singleton {
fun instance(): Singleton = Singleton()
}
`);
const objDecl = tree.rootNode.child(0)!;
const objBody = objDecl.namedChildren.find((c) => c.type === 'class_body')!;
const funcDecl = objBody.namedChildren.find((c) => c.type === 'function_declaration')!;
const info = findEnclosingClassInfo(funcDecl, 'singleton.kt');
expect(info).not.toBeNull();
expect(info!.className).toBe('Singleton');
});
});
// ---------------------------------------------------------------------------
// Future-proofing: invariants of the resolveEnclosingOwner hook contract
// ---------------------------------------------------------------------------
//
// A future provider implementer might:
// (a) Return a non-container node by mistake (e.g. a raw identifier).
// (b) Return `current` (identity), expecting "use this container as-is".
//
// Neither case must produce an infinite loop. These tests pin the contract.
describe('findEnclosingClassInfo: hook contract guards', () => {
it('handles a hook that returns a non-container node without infinite-looping', () => {
// Ruby: class Outer { class Inner { def foo } }
// Hook will redirect from `Inner` (a CLASS_CONTAINER_TYPES node) to a
// raw `identifier`/`constant` node, which is NOT in CLASS_CONTAINER_TYPES.
const tree = parseRuby(`
class Outer
class Inner
def foo
end
end
end
`);
const outerClass = tree.rootNode.child(0)!;
const outerBody = outerClass.namedChildren.find((c) => c.type === 'body_statement')!;
const innerClass = outerBody.namedChildren.find((c) => c.type === 'class')!;
const innerBody = innerClass.namedChildren.find((c) => c.type === 'body_statement')!;
const methodNode = innerBody.namedChildren.find((c) => c.type === 'method')!;
let calls = 0;
const start = Date.now();
// Resolve-hook returns a non-container child node (the class name identifier/constant).
// Per the documented contract, the walk must not infinite-loop: after the hook
// remaps, the next iteration sees a non-container node and continues walking
// up via `current = current.parent` at the end of the loop body.
const info = findEnclosingClassInfo(methodNode, 'nested.rb', (node) => {
calls += 1;
// Bail out if the contract is broken — fail fast rather than hang the suite.
if (calls > 50) throw new Error('hook called too many times — possible infinite loop');
// Always redirect to the class's name node (a 'constant' in tree-sitter-ruby),
// which is NOT in CLASS_CONTAINER_TYPES.
const nameNode = node.childForFieldName?.('name');
return nameNode ?? node;
});
const elapsed = Date.now() - start;
// Must complete quickly — no hang.
expect(elapsed).toBeLessThan(1000);
// Hook was exercised.
expect(calls).toBeGreaterThan(0);
// The function should return null (no resolvable container) rather than loop.
// It should NOT throw, and behavior is well-defined.
expect(info === null || (info && typeof info.className === 'string')).toBe(true);
});
it('handles a hook that returns the input node (identity) without infinite-looping', () => {
// Identity return is the documented "use this container as-is" branch.
// The existing `resolved === current` short-circuit must keep the algorithm
// moving forward (no re-evaluation), and the container is used directly.
const tree = parseRuby(`
class Wolf
def howl
end
end
`);
const classNode = tree.rootNode.child(0)!;
const bodyStmt = classNode.namedChildren.find((c) => c.type === 'body_statement')!;
const methodNode = bodyStmt.namedChildren.find((c) => c.type === 'method')!;
let calls = 0;
const start = Date.now();
const info = findEnclosingClassInfo(methodNode, 'wolf.rb', (node) => {
calls += 1;
if (calls > 50) throw new Error('hook called too many times — possible infinite loop');
return node; // identity — equivalent to "no remap"
});
const elapsed = Date.now() - start;
expect(elapsed).toBeLessThan(1000);
expect(info).not.toBeNull();
expect(info!.className).toBe('Wolf');
// Hook should be called exactly once per CLASS_CONTAINER_TYPES node visited
// (here: just `class Wolf`). If the identity branch re-entered the hook, calls > 1.
expect(calls).toBe(1);
});
});
// ---------------------------------------------------------------------------
// Generic behavior: no hook → container used as-is
// ---------------------------------------------------------------------------
describe('findEnclosingClassInfo without resolveEnclosingOwner', () => {
it('returns the first matching container without any remapping', () => {
const tree = parseRuby(`
class Dog
def bark
end
end
`);
const classNode = tree.rootNode.child(0)!;
const bodyStmt = classNode.namedChildren.find((c) => c.type === 'body_statement')!;
const methodNode = bodyStmt.namedChildren.find((c) => c.type === 'method')!;
// Without the hook, generic behavior applies
const info = findEnclosingClassInfo(methodNode, 'dog.rb');
expect(info).not.toBeNull();
expect(info!.className).toBe('Dog');
});
});