mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-03 02:21:44 +00:00
* 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>
291 lines
11 KiB
TypeScript
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');
|
|
});
|
|
});
|