mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-06 02:49:56 +00:00
fix(mcp): discover positional SDK tool registrations (#3450)
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
This commit is contained in:
parent
a8f18f00b9
commit
f99dde8aa3
16 changed files with 1252 additions and 38 deletions
|
|
@ -188,7 +188,8 @@
|
|||
"capture_groups_fp": 680
|
||||
},
|
||||
"typescript": {
|
||||
"fingerprint": "77c9b4ea654123a64972db8348190ec250b669472b150db65ea8c7f20b355467",
|
||||
"fingerprint": "a7972d87abd253583dafa37443bee8ddf5c635d41b4dfbb0c831efda71d82b47",
|
||||
"_rebaselined_3446_mcp_tools_fixture": "#3446 adds typescript-mcp-tools/src/{handlers,server,tools}.ts: corpus growth only, with fixture_count 171 -> 174 and capture_groups_fp 2753 -> 2894. Excluding only that fixture directory restores the prior fingerprint 77c9b4ea654123a64972db8348190ec250b669472b150db65ea8c7f20b355467 exactly. The scope emitter, synthetic capture counts (4503/14403), scaling budget, and other language baselines are unchanged.",
|
||||
"_rebaselined_3190": "Capture matches now retain explicit ESM export/private evidence, including synthesized default HOCs; CommonJS surfaces remain undecided. Capture group counts unchanged. Scaling budget unchanged.",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined_2934_import_type_only": "#2934: `import-decomposer.ts` attaches a presence-only `@import.type-only` synthetic capture to specifiers `tsc` erases, so `check --cycles` can stop counting type-only edges as initialization cycles. DIGEST DRIFT ONLY, NOT A CAPTURE-SET CHANGE \u2014 the tag is added to import matches that already existed, never a new match, the same shape as the #2747 receiver-chain rebaseline. Every count is unchanged: capture_groups_fp 2414, fixture_count 155, capture_groups_small/large 4503/14403 (those measure the SYNTHETIC scaling source, which has no imports at all). The fingerprint moves because `canonicalizeMatch` in measure.mjs hashes every TAG on every match, synthetics included, so one extra presence-only tag on an existing match rewrites that match's canonical string. Attribution is exact, not inferred: neutralizing ONLY the `m['@import.type-only'] = \u2026` assignment in import-decomposer.ts and re-running returns the fingerprint to c2fbf8a89e5686dd\u2026 byte-for-byte, so nothing else in the TypeScript capture stream moved. All 14 other languages report ok. Scaling 0.997 < 1.5. NOTE ON THE CONTROL: javascript did not move (2026993b\u2026, 43 fixtures), but it is a WEAK control here \u2014 `import type` is TypeScript-only syntax, so a JS corpus cannot express the construct and could not have drifted either way. It evidences no collateral damage, not the correctness of the TS change; the exact-attribution check above is what does that. Prior c2fbf8a89e5686dd1ff3659b20d41d8b05ebcc9790356e3653ee0c8ca5d365c8 -> f719163eb03a447c9e40ca316a905dd76cee82192a75a403df478ebbdc13e98f.",
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ import type {
|
|||
RepoConstants,
|
||||
} from './route-extractors/constant-resolver.js';
|
||||
import type Parser from 'tree-sitter';
|
||||
import type { ExtractedDecoratorRoute } from './workers/parse-worker.js';
|
||||
import type { ExtractedDecoratorRoute, ExtractedToolDef } from './workers/parse-worker.js';
|
||||
import type { SemanticModel } from './model/semantic-model.js';
|
||||
|
||||
/** What a provider's {@link LanguageProviderConfig.resolveRouteHandler} can see. */
|
||||
|
|
@ -546,6 +546,15 @@ interface LanguageProviderConfig {
|
|||
*/
|
||||
readonly extractTextRoutes?: (filePath: string, content: string) => ExtractedRoute[];
|
||||
|
||||
/** Extract tool registrations after captures, using only emitted callable identities.
|
||||
* The map keys are declaration-name AST node IDs, local to this parsed tree. */
|
||||
readonly extractToolDefinitions?: (
|
||||
tree: Parser.Tree,
|
||||
filePath: string,
|
||||
lineOffset: number,
|
||||
callableBindings: ReadonlyMap<number, string>,
|
||||
) => ExtractedToolDef[];
|
||||
|
||||
/**
|
||||
* Extract routes that a parsed file declares in its own AST.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -130,6 +130,7 @@ import { extractDataRouteTableRoutes } from '../route-extractors/data-route-tabl
|
|||
import { extractNestRoutes } from '../route-extractors/nest.js';
|
||||
import { extractTrpcRoutes, shouldScanForTrpcRoutes } from '../route-extractors/trpc.js';
|
||||
import { extractConvexEndpointProperties } from './typescript/convex-endpoint-metadata.js';
|
||||
import { extractToolDefinitions } from './typescript/tool-definitions.js';
|
||||
|
||||
const extractJsTsRoutes = (...args: Parameters<typeof extractDispatchGuardRoutes>) => [
|
||||
...extractDispatchGuardRoutes(...args),
|
||||
|
|
@ -494,6 +495,7 @@ export const typescriptProvider = defineLanguage({
|
|||
// Content-based (not AST): tRPC procedure routers are scanned from source text.
|
||||
// Path-gate lives here (language provider), not in the shared parse worker.
|
||||
extractTextRoutes: extractJsTsTextRoutes,
|
||||
extractToolDefinitions,
|
||||
});
|
||||
|
||||
export const javascriptProvider = defineLanguage({
|
||||
|
|
@ -577,4 +579,5 @@ export const javascriptProvider = defineLanguage({
|
|||
extractDecoratorRoutes: extractJsTsRoutes,
|
||||
// Content-based (not AST): tRPC procedure routers are scanned from source text.
|
||||
extractTextRoutes: extractJsTsTextRoutes,
|
||||
extractToolDefinitions,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,365 @@
|
|||
import type Parser from 'tree-sitter';
|
||||
import type { SyntaxNode } from 'tree-sitter';
|
||||
import type { ExtractedToolDef } from '../../workers/parse-worker.js';
|
||||
import { plainString, propertyName } from '../../route-extractors/data-route-table.js';
|
||||
|
||||
interface Scope {
|
||||
parent?: Scope;
|
||||
functionScope: boolean;
|
||||
bindings: Map<string, Binding>;
|
||||
}
|
||||
|
||||
interface Binding {
|
||||
name: SyntaxNode;
|
||||
scope: Scope;
|
||||
kind: 'unknown' | 'sdk' | 'sdk-namespace' | 'variable' | 'parameter' | 'function';
|
||||
value?: SyntaxNode;
|
||||
type?: SyntaxNode;
|
||||
typeOnly?: boolean;
|
||||
immutable?: boolean;
|
||||
invalid?: boolean;
|
||||
}
|
||||
|
||||
const FUNCTIONS = new Set([
|
||||
'function_declaration',
|
||||
'generator_function_declaration',
|
||||
'function_expression',
|
||||
'generator_function',
|
||||
'arrow_function',
|
||||
'method_definition',
|
||||
]);
|
||||
const BLOCKS = new Set([
|
||||
'statement_block',
|
||||
'for_statement',
|
||||
'for_in_statement',
|
||||
'switch_body',
|
||||
'catch_clause',
|
||||
'class_body',
|
||||
]);
|
||||
const SDK_MODULES = new Set([
|
||||
'@modelcontextprotocol/sdk/server/mcp.js',
|
||||
'@modelcontextprotocol/sdk/server/mcp',
|
||||
]);
|
||||
|
||||
function lookup(scope: Scope, name: string): Binding | undefined {
|
||||
for (let current: Scope | undefined = scope; current; current = current.parent) {
|
||||
const binding = current.bindings.get(name);
|
||||
if (binding) return binding;
|
||||
}
|
||||
}
|
||||
|
||||
/** Only binding/assignment patterns: never descend into keys, types or defaults.
|
||||
* Member assignment targets are reported separately from binding names. */
|
||||
function patternNames(pattern: SyntaxNode, onMember?: (member: SyntaxNode) => void): SyntaxNode[] {
|
||||
const names: SyntaxNode[] = [];
|
||||
const pending = [pattern];
|
||||
while (pending.length) {
|
||||
const node = pending.pop()!;
|
||||
if (node.type === 'identifier' || node.type === 'shorthand_property_identifier_pattern') {
|
||||
names.push(node);
|
||||
} else if (node.type === 'member_expression' || node.type === 'subscript_expression') {
|
||||
onMember?.(node);
|
||||
} else if (node.type === 'pair_pattern') {
|
||||
const value = node.childForFieldName('value');
|
||||
if (value) pending.push(value);
|
||||
} else if (node.type === 'assignment_pattern' || node.type === 'object_assignment_pattern') {
|
||||
const left = node.childForFieldName('left');
|
||||
if (left) pending.push(left);
|
||||
} else if (
|
||||
node.type === 'array_pattern' ||
|
||||
node.type === 'object_pattern' ||
|
||||
node.type === 'rest_pattern'
|
||||
) {
|
||||
pending.push(...node.namedChildren);
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
function declare(scope: Scope, name: SyntaxNode, details: Partial<Binding> = {}): void {
|
||||
const previous = scope.bindings.get(name.text);
|
||||
if (previous) {
|
||||
previous.invalid = true;
|
||||
} else {
|
||||
scope.bindings.set(name.text, { name, scope, kind: 'unknown', ...details });
|
||||
}
|
||||
}
|
||||
|
||||
function variableScope(scope: Scope): Scope {
|
||||
while (!scope.functionScope && scope.parent) scope = scope.parent;
|
||||
return scope;
|
||||
}
|
||||
|
||||
function collectBindings(root: SyntaxNode) {
|
||||
const moduleScope: Scope = { functionScope: true, bindings: new Map() };
|
||||
const scopes = new Map<number, Scope>();
|
||||
const calls: SyntaxNode[] = [];
|
||||
const writes: SyntaxNode[] = [];
|
||||
const pending = [{ node: root, scope: moduleScope }];
|
||||
while (pending.length) {
|
||||
const entry = pending.pop()!;
|
||||
const node = entry.node;
|
||||
let scope = entry.scope;
|
||||
const isFunction = FUNCTIONS.has(node.type);
|
||||
const name = node.childForFieldName('name');
|
||||
if (node.type === 'function_declaration' || node.type === 'generator_function_declaration') {
|
||||
if (name) declare(scope, name, { kind: 'function', value: node, immutable: true });
|
||||
} else if (
|
||||
[
|
||||
'class_declaration',
|
||||
'interface_declaration',
|
||||
'type_alias_declaration',
|
||||
'enum_declaration',
|
||||
].includes(node.type)
|
||||
) {
|
||||
if (name) declare(scope, name);
|
||||
}
|
||||
if (
|
||||
isFunction ||
|
||||
BLOCKS.has(node.type) ||
|
||||
node.type === 'class' ||
|
||||
node.type === 'class_declaration'
|
||||
) {
|
||||
scope = { parent: scope, functionScope: isFunction, bindings: new Map() };
|
||||
}
|
||||
scopes.set(node.id, scope);
|
||||
|
||||
if (node.type === 'class' && name) declare(scope, name);
|
||||
|
||||
if (isFunction) {
|
||||
if (name && (node.type === 'function_expression' || node.type === 'generator_function')) {
|
||||
declare(scope, name, { kind: 'function', value: node, immutable: true });
|
||||
}
|
||||
const parameters = node.childForFieldName('parameters');
|
||||
const single = node.childForFieldName('parameter');
|
||||
for (const parameter of parameters?.namedChildren ?? (single ? [single] : [])) {
|
||||
const pattern = parameter.childForFieldName('pattern') ?? parameter;
|
||||
const type = parameter.childForFieldName('type')?.namedChildren[0];
|
||||
for (const bindingName of patternNames(pattern)) {
|
||||
declare(scope, bindingName, {
|
||||
kind: 'parameter',
|
||||
...(pattern.type === 'identifier' && type ? { type } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (node.type === 'import_statement') {
|
||||
const source = node.childForFieldName('source');
|
||||
const sdk = source !== null && SDK_MODULES.has(plainString(source) ?? '');
|
||||
const typeOnly = node.children.some((child) => child.type === 'type');
|
||||
const clause = node.namedChildren.find((child) => child.type === 'import_clause');
|
||||
for (const child of clause?.namedChildren ?? []) {
|
||||
if (child.type === 'identifier') declare(scope, child);
|
||||
else if (child.type === 'namespace_import') {
|
||||
const local = child.namedChildren[0];
|
||||
if (local) declare(scope, local, { kind: sdk ? 'sdk-namespace' : 'unknown', typeOnly });
|
||||
} else if (child.type === 'named_imports') {
|
||||
for (const specifier of child.namedChildren) {
|
||||
const imported = specifier.childForFieldName('name');
|
||||
const local = specifier.childForFieldName('alias') ?? imported;
|
||||
if (local)
|
||||
declare(scope, local, {
|
||||
kind: sdk && imported?.text === 'McpServer' ? 'sdk' : 'unknown',
|
||||
typeOnly: typeOnly || specifier.children.some((part) => part.type === 'type'),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (node.type === 'variable_declarator') {
|
||||
if (name) {
|
||||
const target = node.parent?.type === 'variable_declaration' ? variableScope(scope) : scope;
|
||||
for (const bindingName of patternNames(name)) {
|
||||
declare(target, bindingName, {
|
||||
kind: 'variable',
|
||||
immutable: node.parent?.childForFieldName('kind')?.type === 'const',
|
||||
...(name.type === 'identifier'
|
||||
? { value: node.childForFieldName('value') ?? undefined }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (node.type === 'catch_clause') {
|
||||
const parameter = node.childForFieldName('parameter');
|
||||
if (parameter) for (const bindingName of patternNames(parameter)) declare(scope, bindingName);
|
||||
} else if (node.type === 'for_in_statement') {
|
||||
const left = node.childForFieldName('left');
|
||||
const kind = node.childForFieldName('kind');
|
||||
if (left && kind) {
|
||||
const target = kind.type === 'var' ? variableScope(scope) : scope;
|
||||
for (const bindingName of patternNames(left)) declare(target, bindingName);
|
||||
} else if (left) writes.push(left);
|
||||
} else if (node.type === 'type_parameter') {
|
||||
if (name) declare(scope, name);
|
||||
}
|
||||
if (node.type === 'call_expression') calls.push(node);
|
||||
if (node.type === 'assignment_expression' || node.type === 'augmented_assignment_expression') {
|
||||
const left = node.childForFieldName('left');
|
||||
if (left) writes.push(left);
|
||||
} else if (
|
||||
node.type === 'update_expression' ||
|
||||
(node.type === 'unary_expression' && node.children.some((child) => child.type === 'delete'))
|
||||
) {
|
||||
const argument = node.childForFieldName('argument');
|
||||
if (argument) writes.push(argument);
|
||||
}
|
||||
for (let index = node.namedChildCount - 1; index >= 0; index--) {
|
||||
pending.push({ node: node.namedChild(index)!, scope });
|
||||
}
|
||||
}
|
||||
// Resolve writes after declarations so later declarations also shadow outer names.
|
||||
while (writes.length) {
|
||||
let target = writes.pop()!;
|
||||
const scope = scopes.get(target.id)!;
|
||||
const members: Array<string | null> = [];
|
||||
while (target.type === 'member_expression' || target.type === 'subscript_expression') {
|
||||
const object = target.childForFieldName('object');
|
||||
if (!object) break;
|
||||
const property = target.childForFieldName('property');
|
||||
const index = target.childForFieldName('index');
|
||||
members.push(property ? propertyName(property) : index ? plainString(index) : null);
|
||||
target = object;
|
||||
}
|
||||
// Nested member targets must pass the same guard as direct property writes.
|
||||
for (const name of patternNames(target, (member) => writes.push(member))) {
|
||||
const binding = lookup(scope, name.text);
|
||||
// Lifecycle callbacks and other known properties do not replace the receiver
|
||||
// or its registration methods. Unknown keys and constructor mutations remain unsafe.
|
||||
if (
|
||||
binding?.kind !== 'sdk' &&
|
||||
binding?.kind !== 'sdk-namespace' &&
|
||||
members.length > 0 &&
|
||||
members.every((member) => member !== null) &&
|
||||
!['tool', 'registerTool', '__proto__'].includes(members[members.length - 1]!)
|
||||
)
|
||||
continue;
|
||||
if (binding) binding.invalid = true;
|
||||
}
|
||||
}
|
||||
return { scopes, calls };
|
||||
}
|
||||
|
||||
function sdkBinding(node: SyntaxNode, scope: Scope, forType = false): boolean {
|
||||
let kind: Binding['kind'] = 'sdk';
|
||||
if (node.type === (forType ? 'nested_type_identifier' : 'member_expression')) {
|
||||
const namespace = node.childForFieldName(forType ? 'module' : 'object');
|
||||
const member = node.childForFieldName(forType ? 'name' : 'property');
|
||||
if (namespace?.type !== 'identifier' || member?.text !== 'McpServer') return false;
|
||||
node = namespace;
|
||||
kind = 'sdk-namespace';
|
||||
}
|
||||
if (node.type !== 'identifier' && node.type !== 'type_identifier') return false;
|
||||
const binding = lookup(scope, node.text);
|
||||
return binding?.kind === kind && !binding.invalid && (forType || !binding.typeOnly);
|
||||
}
|
||||
|
||||
function sdkReceiver(node: SyntaxNode, scope: Scope, scopes: ReadonlyMap<number, Scope>): boolean {
|
||||
if (node.type !== 'identifier') return false;
|
||||
const binding = lookup(scope, node.text);
|
||||
if (!binding || binding.invalid) return false;
|
||||
if (binding.kind === 'parameter' && binding.type) {
|
||||
return sdkBinding(binding.type, binding.scope, true);
|
||||
}
|
||||
const value = binding.value;
|
||||
if (binding.kind !== 'variable' || value?.type !== 'new_expression') return false;
|
||||
if (value.endIndex > node.startIndex && variableScope(binding.scope) === variableScope(scope))
|
||||
return false;
|
||||
const constructor = value.childForFieldName('constructor');
|
||||
return constructor !== null && sdkBinding(constructor, scopes.get(value.id)!);
|
||||
}
|
||||
|
||||
/** An unknown later property can replace description; a later explicit property restores proof. */
|
||||
function descriptionFromConfig(config: SyntaxNode): string {
|
||||
let description = '';
|
||||
if (config.type !== 'object') return description;
|
||||
for (const child of config.namedChildren) {
|
||||
if (child.type === 'comment') continue;
|
||||
const key = child.childForFieldName('key') ?? child.childForFieldName('name');
|
||||
const name =
|
||||
child.type === 'shorthand_property_identifier' ? child.text : key && propertyName(key);
|
||||
if (child.type === 'pair' && name === 'description') {
|
||||
const value = child.childForFieldName('value');
|
||||
description = value ? (plainString(value) ?? '') : '';
|
||||
} else if (!name || name === 'description') {
|
||||
description = '';
|
||||
}
|
||||
}
|
||||
return description;
|
||||
}
|
||||
|
||||
function handlerNodeId(
|
||||
node: SyntaxNode,
|
||||
scope: Scope,
|
||||
callableBindings: ReadonlyMap<number, string> | undefined,
|
||||
): string | undefined {
|
||||
if (node.type !== 'identifier') return undefined;
|
||||
const binding = lookup(scope, node.text);
|
||||
if (!binding || binding.invalid) return undefined;
|
||||
if (binding.kind === 'variable') {
|
||||
const value = binding.value;
|
||||
if (
|
||||
!binding.immutable ||
|
||||
!value ||
|
||||
(value.type !== 'arrow_function' && value.type !== 'function_expression')
|
||||
)
|
||||
return undefined;
|
||||
if (value.endIndex > node.startIndex && variableScope(binding.scope) === variableScope(scope))
|
||||
return undefined;
|
||||
} else if (binding.kind !== 'function') return undefined;
|
||||
return callableBindings?.get(binding.name.id);
|
||||
}
|
||||
|
||||
/** Direct SDK registrations only; no wrapper, alias-chain or runtime-value inference. */
|
||||
export function extractToolDefinitions(
|
||||
tree: Parser.Tree,
|
||||
filePath: string,
|
||||
lineOffset = 0,
|
||||
callableBindings?: ReadonlyMap<number, string>,
|
||||
): ExtractedToolDef[] {
|
||||
// Ordinary files need no lexical walk; every supported receiver originates here.
|
||||
const importsSdk = tree.rootNode.namedChildren.some((node) => {
|
||||
if (node.type !== 'import_statement') return false;
|
||||
const source = node.childForFieldName('source');
|
||||
return source !== null && SDK_MODULES.has(plainString(source) ?? '');
|
||||
});
|
||||
if (!importsSdk) return [];
|
||||
|
||||
const { scopes, calls } = collectBindings(tree.rootNode);
|
||||
const definitions: ExtractedToolDef[] = [];
|
||||
for (const call of calls) {
|
||||
const callee = call.childForFieldName('function');
|
||||
if (call.hasError || callee?.type !== 'member_expression') continue;
|
||||
const receiver = callee.childForFieldName('object');
|
||||
const method = callee.childForFieldName('property');
|
||||
if (
|
||||
!receiver ||
|
||||
method?.type !== 'property_identifier' ||
|
||||
(method.text !== 'registerTool' && method.text !== 'tool') ||
|
||||
!sdkReceiver(receiver, scopes.get(call.id)!, scopes)
|
||||
)
|
||||
continue;
|
||||
const args =
|
||||
call
|
||||
.childForFieldName('arguments')
|
||||
?.namedChildren.filter((child) => child.type !== 'comment') ?? [];
|
||||
if (args.some((arg) => arg.type === 'spread_element')) continue;
|
||||
if (method.text === 'registerTool' ? args.length !== 3 : args.length < 2 || args.length > 5)
|
||||
continue;
|
||||
const toolName = plainString(args[0]);
|
||||
if (toolName === null) continue;
|
||||
const description =
|
||||
method.text === 'registerTool'
|
||||
? descriptionFromConfig(args[1])
|
||||
: args.length > 2
|
||||
? (plainString(args[1]) ?? '')
|
||||
: '';
|
||||
const handler = handlerNodeId(args[args.length - 1], scopes.get(call.id)!, callableBindings);
|
||||
definitions.push({
|
||||
filePath,
|
||||
toolName,
|
||||
description,
|
||||
lineNumber: call.startPosition.row + 1 + lineOffset,
|
||||
...(handler !== undefined ? { handlerNodeId: handler } : {}),
|
||||
allowFileFallback: false,
|
||||
});
|
||||
}
|
||||
return definitions;
|
||||
}
|
||||
|
|
@ -323,6 +323,7 @@ export const processesPhase: PipelinePhase<ProcessesOutput> = {
|
|||
const toolsByHandlerId = new Map<string, string[]>();
|
||||
const toolsWithoutHandlerByFile = new Map<string, string[]>();
|
||||
for (const td of toolDefs) {
|
||||
if (!td.handlerNodeId && td.allowFileFallback === false) continue;
|
||||
const key = td.handlerNodeId ?? td.filePath;
|
||||
const targetMap = td.handlerNodeId ? toolsByHandlerId : toolsWithoutHandlerByFile;
|
||||
let list = targetMap.get(key);
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ export interface ToolDef {
|
|||
filePath: string;
|
||||
description: string;
|
||||
handlerNodeId?: string;
|
||||
allowFileFallback?: false;
|
||||
}
|
||||
|
||||
export interface ToolsOutput {
|
||||
|
|
@ -51,6 +52,7 @@ export const toolsPhase: PipelinePhase<ToolsOutput> = {
|
|||
filePath: td.filePath,
|
||||
description: td.description,
|
||||
...(handlerNodeId !== undefined ? { handlerNodeId } : {}),
|
||||
...(td.allowFileFallback === false ? { allowFileFallback: false as const } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -439,6 +439,8 @@ export interface ExtractedToolDef {
|
|||
description: string;
|
||||
lineNumber: number;
|
||||
handlerNodeId?: string;
|
||||
/** Unresolved registrations must not inherit unrelated same-file flows. */
|
||||
allowFileFallback?: false;
|
||||
}
|
||||
|
||||
export interface ExtractedORMQuery {
|
||||
|
|
@ -1685,6 +1687,7 @@ const processFileGroup = (
|
|||
// node id → graph node id for classes THIS file's capture loop materialized.
|
||||
// Keyed by in-memory AST identity (never persisted); filled below.
|
||||
const classOwnersByNodeId = new Map<number, string>();
|
||||
const callableBindings = new Map<number, string>();
|
||||
|
||||
// #2687: ONE pass over `matches` yields both suppression sets — the
|
||||
// definition-name claims by rank (callable > Property > value), so the dedup
|
||||
|
|
@ -3123,6 +3126,11 @@ const processFileGroup = (
|
|||
}),
|
||||
});
|
||||
|
||||
// Keep actual emitted identities; providers must not reconstruct graph IDs.
|
||||
if (nameNode && (nodeLabel === 'Function' || nodeLabel === 'Method')) {
|
||||
callableBindings.set(nameNode.id, nodeId);
|
||||
}
|
||||
|
||||
// enclosingClassId already computed above (before nodeId generation)
|
||||
const ownerId = enclosingClassId ?? objectLiteralOwnerInfo?.ownerId;
|
||||
|
||||
|
|
@ -3225,6 +3233,23 @@ const processFileGroup = (
|
|||
}
|
||||
}
|
||||
|
||||
if (provider.extractToolDefinitions) {
|
||||
// Distinct lexical declarations can share a graph ID (for example, sibling
|
||||
// block-scoped functions). Such IDs cannot prove which handler owns a tool.
|
||||
const seenCallableIds = new Set<string>();
|
||||
const ambiguousCallableIds = new Set<string>();
|
||||
for (const nodeId of callableBindings.values()) {
|
||||
if (seenCallableIds.has(nodeId)) ambiguousCallableIds.add(nodeId);
|
||||
seenCallableIds.add(nodeId);
|
||||
}
|
||||
for (const [bindingId, nodeId] of callableBindings) {
|
||||
if (ambiguousCallableIds.has(nodeId)) callableBindings.delete(bindingId);
|
||||
}
|
||||
result.toolDefs.push(
|
||||
...provider.extractToolDefinitions(tree, file.path, lineOffset, callableBindings),
|
||||
);
|
||||
}
|
||||
|
||||
// Extract framework routes via provider detection (e.g., Laravel routes.php)
|
||||
if (provider.isRouteFile?.(file.path)) {
|
||||
const extractedRoutes = extractLaravelRoutes(tree, file.path);
|
||||
|
|
|
|||
|
|
@ -822,7 +822,14 @@ import { copyV8CacheIfPresent, tryLoadV8Cache, writeV8CacheFile } from './v8-sid
|
|||
// `handlerReceiver` hint. Warm v123 Go worker results carry no routes.
|
||||
// v125 (#3402): Go route hints now honor lexical declarations and captured writes;
|
||||
// namespace imports retain whether their local name comes from the package clause.
|
||||
const SCHEMA_BUMP = 125;
|
||||
// v126 (#3446): SDK positional tool registrations now emit tool definitions,
|
||||
// exact handler identities, and an opt-out from unrelated file-level flows.
|
||||
// Warm v125 worker results omit these definitions and must be re-extracted.
|
||||
// v127 (#3450): Destructured member writes invalidate SDK registration evidence.
|
||||
// Warm v126 worker results can retain false tools after a method replacement.
|
||||
// v128 (#3450): SDK namespace imports now prove positional tool receivers.
|
||||
// Warm v127 worker results omit these definitions and must be re-extracted.
|
||||
const SCHEMA_BUMP = 128;
|
||||
const GITNEXUS_PKG_VERSION = (() => {
|
||||
try {
|
||||
// package.json sits at gitnexus/package.json — two levels up from
|
||||
|
|
|
|||
1
gitnexus/test/fixtures/lang-resolution/typescript-mcp-tools/src/handlers.ts
vendored
Normal file
1
gitnexus/test/fixtures/lang-resolution/typescript-mcp-tools/src/handlers.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
export function importedHandler() { return 'imported'; }
|
||||
5
gitnexus/test/fixtures/lang-resolution/typescript-mcp-tools/src/server.js
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/typescript-mcp-tools/src/server.js
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { McpServer as Server } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
|
||||
const server = new Server({ name: 'javascript', version: '1' });
|
||||
function jsPing() { return 'pong'; }
|
||||
server.registerTool('js_ping', { description: 'Ping JavaScript' }, jsPing);
|
||||
52
gitnexus/test/fixtures/lang-resolution/typescript-mcp-tools/src/server.ts
vendored
Normal file
52
gitnexus/test/fixtures/lang-resolution/typescript-mcp-tools/src/server.ts
vendored
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { importedHandler } from './handlers.js';
|
||||
|
||||
const server = new McpServer({ name: 'fixture', version: '1' });
|
||||
|
||||
function formatSearch(query: string) { return query; }
|
||||
function lookupSearch(query: string) { return formatSearch(query); }
|
||||
export function searchFiles(query: string) { return lookupSearch(query); }
|
||||
|
||||
function formatFile(file: string) { return file; }
|
||||
function lookupFile(file: string) { return formatFile(file); }
|
||||
export const readFile = (file: string) => lookupFile(file);
|
||||
|
||||
function formatOther() { return 'other'; }
|
||||
function lookupOther() { return formatOther(); }
|
||||
export function unrelatedEntry() { return lookupOther(); }
|
||||
|
||||
server.registerTool('search-files', { description: 'Search files' }, searchFiles);
|
||||
server.tool('read_file', 'Read a file', {}, readFile);
|
||||
server.registerTool('inline_callback', {}, async () => 'inline');
|
||||
server.tool('imported_callback', importedHandler);
|
||||
|
||||
function install(server: McpServer, searchFiles: () => string) {
|
||||
server.registerTool('parameter_callback', {}, searchFiles);
|
||||
}
|
||||
|
||||
let mutable = () => 'mutable';
|
||||
server.tool('mutable_callback', mutable);
|
||||
|
||||
function replaced() { return 'before'; }
|
||||
replaced = () => 'after';
|
||||
server.tool('reassigned_callback', replaced);
|
||||
|
||||
{
|
||||
const searchFiles = 'not callable';
|
||||
server.tool('shadowed_callback', searchFiles);
|
||||
}
|
||||
|
||||
const alias = readFile;
|
||||
server.tool('alias_callback', alias);
|
||||
|
||||
const expressionHandler = function () { return 'expression'; };
|
||||
server.registerTool('function_expression_tool', {}, expressionHandler);
|
||||
|
||||
{
|
||||
const handler = () => lookupSearch('first');
|
||||
server.tool('first_block_callback', handler);
|
||||
}
|
||||
{
|
||||
const handler = () => lookupFile('second');
|
||||
server.tool('second_block_callback', handler);
|
||||
}
|
||||
4
gitnexus/test/fixtures/lang-resolution/typescript-mcp-tools/src/tools.ts
vendored
Normal file
4
gitnexus/test/fixtures/lang-resolution/typescript-mcp-tools/src/tools.ts
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export const tools = [
|
||||
{ name: 'manifest_tool', description: 'Existing object manifest', inputSchema: {} },
|
||||
{ name: 'read_file', description: 'Duplicate manifest entry', inputSchema: {} },
|
||||
];
|
||||
283
gitnexus/test/integration/resolvers/typescript-mcp-tools.test.ts
Normal file
283
gitnexus/test/integration/resolvers/typescript-mcp-tools.test.ts
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import {
|
||||
loadParseCache,
|
||||
PARSE_CACHE_VERSION,
|
||||
pruneCache,
|
||||
saveParseCache,
|
||||
type ParseCache,
|
||||
} from '../../../src/storage/parse-cache.js';
|
||||
import {
|
||||
getDurableParsedFileDir,
|
||||
pruneAndSaveDurableParsedFileStore,
|
||||
} from '../../../src/storage/parsedfile-store.js';
|
||||
import {
|
||||
FIXTURES,
|
||||
findDanglingEdges,
|
||||
getNodesByLabel,
|
||||
getNodesByLabelFull,
|
||||
getRelationships,
|
||||
runPipelineFromRepo,
|
||||
type PipelineResult,
|
||||
} from './helpers.js';
|
||||
|
||||
describe('JavaScript and TypeScript SDK tool registrations', () => {
|
||||
let result: PipelineResult;
|
||||
const unresolved = [
|
||||
'inline_callback',
|
||||
'imported_callback',
|
||||
'parameter_callback',
|
||||
'mutable_callback',
|
||||
'reassigned_callback',
|
||||
'shadowed_callback',
|
||||
'alias_callback',
|
||||
'first_block_callback',
|
||||
'second_block_callback',
|
||||
];
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(path.join(FIXTURES, 'typescript-mcp-tools'), () => {});
|
||||
}, 60000);
|
||||
|
||||
it.each(['ts', 'js'])(
|
||||
'does not emit tools for a destructured method replacement in %s',
|
||||
async (extension) => {
|
||||
const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-mcp-tool-write-'));
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
path.join(repo, `server.${extension}`),
|
||||
`
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
const replaced = new McpServer({ name: 'replaced', version: '1' });
|
||||
({ registerTool: replaced.registerTool } = { registerTool: () => undefined });
|
||||
replaced.registerTool('fake', {}, () => ({ content: [] }));
|
||||
const actual = new McpServer({ name: 'actual', version: '1' });
|
||||
actual.registerTool('real', {}, () => ({ content: [] }));
|
||||
`,
|
||||
);
|
||||
const pipeline = await runPipelineFromRepo(repo, () => {}, { workerPoolSize: 1 });
|
||||
expect(getNodesByLabel(pipeline, 'Tool')).toEqual(['real']);
|
||||
expect(findDanglingEdges(pipeline, ['HANDLES_TOOL', 'ENTRY_POINT_OF'])).toEqual([]);
|
||||
} finally {
|
||||
fs.rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['ts', 'js'])(
|
||||
'preserves namespace registrations through cold/warm %s parsing',
|
||||
async (extension) => {
|
||||
const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-mcp-namespace-'));
|
||||
const storageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-mcp-namespace-cache-'));
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
path.join(repo, `server.${extension}`),
|
||||
`
|
||||
import * as SDK from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
const server = new SDK.McpServer({});
|
||||
function handleNamespace() { return { content: [] }; }
|
||||
server.registerTool('namespace', { description: 'Namespace tool' }, handleNamespace);
|
||||
const replaced = new SDK.McpServer({});
|
||||
({ method: replaced.registerTool } = other);
|
||||
replaced.registerTool('fake', {}, handleNamespace);
|
||||
${extension === 'ts' ? "function install(typed: SDK.McpServer) { typed.tool('typed_namespace', handleNamespace); }" : ''}
|
||||
`,
|
||||
);
|
||||
const cache: ParseCache = {
|
||||
version: PARSE_CACHE_VERSION,
|
||||
entries: new Map(),
|
||||
usedKeys: new Set(),
|
||||
storagePath: storageDir,
|
||||
onDiskKeys: new Set(),
|
||||
};
|
||||
const cold = await runPipelineFromRepo(repo, () => {}, {
|
||||
parseCache: cache,
|
||||
workerPoolSize: 1,
|
||||
});
|
||||
expect(cold.usedWorkerPool).toBe(true);
|
||||
pruneCache(cache, cache.usedKeys);
|
||||
const keys = await saveParseCache(storageDir, cache);
|
||||
await pruneAndSaveDurableParsedFileStore(
|
||||
getDurableParsedFileDir(storageDir),
|
||||
PARSE_CACHE_VERSION,
|
||||
new Set(keys),
|
||||
);
|
||||
const warmCache = await loadParseCache(storageDir);
|
||||
expect(warmCache).not.toBeNull();
|
||||
const warm = await runPipelineFromRepo(repo, () => {}, {
|
||||
parseCache: warmCache!,
|
||||
workerPoolSize: 1,
|
||||
});
|
||||
expect(warm.usedWorkerPool).toBe(false);
|
||||
for (const pipeline of [cold, warm]) {
|
||||
expect(getNodesByLabel(pipeline, 'Tool')).toEqual(
|
||||
extension === 'ts' ? ['namespace', 'typed_namespace'] : ['namespace'],
|
||||
);
|
||||
expect(
|
||||
getNodesByLabelFull(pipeline, 'Tool').find((tool) => tool.name === 'namespace')
|
||||
?.properties.description,
|
||||
).toBe('Namespace tool');
|
||||
expect(
|
||||
getRelationships(pipeline, 'HANDLES_TOOL').filter(
|
||||
(edge) => edge.target === 'namespace',
|
||||
),
|
||||
).toMatchObject([{ source: 'handleNamespace', sourceLabel: 'Function' }]);
|
||||
expect(findDanglingEdges(pipeline, ['HANDLES_TOOL', 'ENTRY_POINT_OF'])).toEqual([]);
|
||||
}
|
||||
expect(getNodesByLabelFull(warm, 'Tool')).toEqual(getNodesByLabelFull(cold, 'Tool'));
|
||||
expect(getRelationships(warm, 'HANDLES_TOOL')).toEqual(
|
||||
getRelationships(cold, 'HANDLES_TOOL'),
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(repo, { recursive: true, force: true });
|
||||
fs.rmSync(storageDir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
120_000,
|
||||
);
|
||||
|
||||
it('discovers ordinary server files alongside deduplicated object manifests', () => {
|
||||
expect(getNodesByLabel(result, 'Tool')).toEqual(
|
||||
[
|
||||
...unresolved,
|
||||
'search-files',
|
||||
'read_file',
|
||||
'function_expression_tool',
|
||||
'js_ping',
|
||||
'manifest_tool',
|
||||
].sort(),
|
||||
);
|
||||
const tools = new Map(
|
||||
getNodesByLabelFull(result, 'Tool').map((tool) => [tool.name, tool.properties]),
|
||||
);
|
||||
expect(tools.get('search-files')).toMatchObject({
|
||||
filePath: 'src/server.ts',
|
||||
description: 'Search files',
|
||||
});
|
||||
expect(tools.get('read_file')).toMatchObject({
|
||||
filePath: 'src/server.ts',
|
||||
description: 'Read a file',
|
||||
});
|
||||
expect(tools.get('js_ping')).toMatchObject({
|
||||
filePath: 'src/server.js',
|
||||
description: 'Ping JavaScript',
|
||||
});
|
||||
expect(tools.get('manifest_tool')).toMatchObject({
|
||||
filePath: 'src/tools.ts',
|
||||
description: 'Existing object manifest',
|
||||
});
|
||||
expect(tools.get('inline_callback')?.description).toBe('');
|
||||
});
|
||||
|
||||
it('uses actual emitted callable nodes for supported local handlers', () => {
|
||||
const edges = getRelationships(result, 'HANDLES_TOOL');
|
||||
for (const [tool, handler] of [
|
||||
['search-files', 'searchFiles'],
|
||||
['read_file', 'readFile'],
|
||||
['function_expression_tool', 'expressionHandler'],
|
||||
['js_ping', 'jsPing'],
|
||||
]) {
|
||||
expect(edges.filter((edge) => edge.target === tool)).toMatchObject([
|
||||
{ source: handler, sourceLabel: 'Function' },
|
||||
]);
|
||||
}
|
||||
expect(findDanglingEdges(result, ['HANDLES_TOOL', 'ENTRY_POINT_OF'])).toEqual([]);
|
||||
});
|
||||
|
||||
it('links each same-file named handler only to its own execution flow', () => {
|
||||
const edges = getRelationships(result, 'ENTRY_POINT_OF').filter(
|
||||
(edge) => edge.sourceLabel === 'Tool',
|
||||
);
|
||||
for (const [tool, handler] of [
|
||||
['search-files', 'searchFiles'],
|
||||
['read_file', 'readFile'],
|
||||
]) {
|
||||
const flows = edges.filter((edge) => edge.source === tool);
|
||||
expect(flows).toHaveLength(1);
|
||||
const process = result.graph.getNode(flows[0].rel.targetId)!;
|
||||
const entry = result.graph.getNode(process.properties.entryPointId as string)!;
|
||||
expect(entry.properties.name).toBe(handler);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps unresolved callbacks at file attribution without unrelated same-file flows', () => {
|
||||
const handles = getRelationships(result, 'HANDLES_TOOL');
|
||||
const flows = getRelationships(result, 'ENTRY_POINT_OF');
|
||||
expect(
|
||||
getNodesByLabelFull(result, 'Process').some((process) => {
|
||||
const entry = result.graph.getNode(process.properties.entryPointId as string);
|
||||
return entry?.properties.name === 'unrelatedEntry';
|
||||
}),
|
||||
).toBe(true);
|
||||
for (const name of unresolved) {
|
||||
expect(handles.filter((edge) => edge.target === name)).toMatchObject([
|
||||
{ sourceLabel: 'File', sourceFilePath: 'src/server.ts' },
|
||||
]);
|
||||
expect(flows.filter((edge) => edge.sourceLabel === 'Tool' && edge.source === name)).toEqual(
|
||||
[],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves tool metadata, handler identities, and flow attribution on warm replay', async () => {
|
||||
const storageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-mcp-tools-cache-'));
|
||||
try {
|
||||
const cold: ParseCache = {
|
||||
version: PARSE_CACHE_VERSION,
|
||||
entries: new Map(),
|
||||
usedKeys: new Set(),
|
||||
storagePath: storageDir,
|
||||
onDiskKeys: new Set(),
|
||||
};
|
||||
const fixture = path.join(FIXTURES, 'typescript-mcp-tools');
|
||||
const initial = await runPipelineFromRepo(fixture, () => {}, {
|
||||
parseCache: cold,
|
||||
workerPoolSize: 1,
|
||||
});
|
||||
expect(initial.usedWorkerPool).toBe(true);
|
||||
pruneCache(cold, cold.usedKeys);
|
||||
const savedKeys = await saveParseCache(storageDir, cold);
|
||||
await pruneAndSaveDurableParsedFileStore(
|
||||
getDurableParsedFileDir(storageDir),
|
||||
PARSE_CACHE_VERSION,
|
||||
new Set(savedKeys),
|
||||
);
|
||||
const warm = await loadParseCache(storageDir);
|
||||
expect(warm).not.toBeNull();
|
||||
const replay = await runPipelineFromRepo(fixture, () => {}, {
|
||||
parseCache: warm!,
|
||||
workerPoolSize: 1,
|
||||
});
|
||||
expect(replay.usedWorkerPool).toBe(false);
|
||||
|
||||
const project = (pipeline: PipelineResult) => ({
|
||||
tools: getNodesByLabelFull(pipeline, 'Tool'),
|
||||
edges: [
|
||||
...getRelationships(pipeline, 'HANDLES_TOOL'),
|
||||
...getRelationships(pipeline, 'ENTRY_POINT_OF').filter(
|
||||
(edge) => edge.sourceLabel === 'Tool',
|
||||
),
|
||||
]
|
||||
.map(({ rel, source }) => ({
|
||||
type: rel.type,
|
||||
source,
|
||||
sourceId: rel.sourceId,
|
||||
targetId: rel.targetId,
|
||||
}))
|
||||
.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))),
|
||||
});
|
||||
const expected = project(initial);
|
||||
expect(expected.tools).toHaveLength(unresolved.length + 5);
|
||||
expect(project(replay)).toEqual(expected);
|
||||
for (const name of unresolved) {
|
||||
expect(
|
||||
expected.edges.filter((edge) => edge.type === 'ENTRY_POINT_OF' && edge.source === name),
|
||||
).toEqual([]);
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(storageDir, { recursive: true, force: true });
|
||||
}
|
||||
}, 120_000);
|
||||
});
|
||||
|
|
@ -302,8 +302,11 @@ describe('PARSE_CACHE_VERSION', () => {
|
|||
// Moved 121 -> 122 for #3414 restoring helper calls.
|
||||
// Moved 122 -> 123 for #3408 FastAPI nested router-prefix capture fields.
|
||||
// Moved 123 -> 124 for #3402 Go gin/echo decorator routes.
|
||||
it('pins SCHEMA_BUMP to 125 so concurrent bumps cannot silently collide (#2766, #3015, #3088, #2885, #3128, #2865, #3130, #1432, #3161, #3179, #3219, #3190, #3253, #3273, #3339, #3354, #3371, #2965, #3390, #3398, #3396, #3394, #3399, #3414, #3408, #3402)', () => {
|
||||
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(125);
|
||||
// Moved 125 -> 126 for #3446: SDK positional tool definitions and attribution.
|
||||
// Moved 126 -> 127 for #3450: reject destructured SDK registration-method writes.
|
||||
// Moved 127 -> 128 for #3450: recognize SDK namespace imports.
|
||||
it('pins SCHEMA_BUMP to 128 so concurrent bumps cannot silently collide (#2766, #3015, #3088, #2885, #3128, #2865, #3130, #1432, #3161, #3179, #3219, #3190, #3253, #3273, #3339, #3354, #3371, #2965, #3390, #3398, #3396, #3394, #3399, #3414, #3408, #3402, #3446, #3450)', () => {
|
||||
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(128);
|
||||
expect(PARSE_CACHE_BUCKET_COUNT).toBe(128);
|
||||
// The PREVIOUS version must fail the reuse gate, not merely differ from the
|
||||
// current one — a hardcoded number outside the conflict hunk rebases cleanly
|
||||
|
|
@ -313,7 +316,7 @@ describe('PARSE_CACHE_VERSION', () => {
|
|||
59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81,
|
||||
82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103,
|
||||
104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122,
|
||||
123, 124,
|
||||
123, 124, 125, 126, 127,
|
||||
]) {
|
||||
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(taken);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,41 +55,50 @@ function addCall(graph: KnowledgeGraph, sourceId: string, targetId: string) {
|
|||
}
|
||||
|
||||
describe('Tool handler and process linking phases', () => {
|
||||
it('falls back to the file node when a parsed tool handler is missing from the graph', async () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
addNode(graph, 'File:src/tools.py', 'File', 'tools.py', 'src/tools.py');
|
||||
it.each([undefined, false] as const)(
|
||||
'retains file attribution policy %s when a parsed handler is missing',
|
||||
async (allowFileFallback) => {
|
||||
const graph = createKnowledgeGraph();
|
||||
addNode(graph, 'File:src/tools.py', 'File', 'tools.py', 'src/tools.py');
|
||||
|
||||
const output = await toolsPhase.execute(
|
||||
makeCtx(graph),
|
||||
new Map([
|
||||
[
|
||||
'parse',
|
||||
phaseResult('parse', {
|
||||
allToolDefs: [
|
||||
{
|
||||
filePath: 'src/tools.py',
|
||||
toolName: 'stale_tool',
|
||||
description: 'Stale handler',
|
||||
lineNumber: 1,
|
||||
handlerNodeId: 'Function:src/tools.py:missing',
|
||||
},
|
||||
],
|
||||
allPaths: [],
|
||||
}),
|
||||
],
|
||||
]),
|
||||
);
|
||||
const output = await toolsPhase.execute(
|
||||
makeCtx(graph),
|
||||
new Map([
|
||||
[
|
||||
'parse',
|
||||
phaseResult('parse', {
|
||||
allToolDefs: [
|
||||
{
|
||||
filePath: 'src/tools.py',
|
||||
toolName: 'stale_tool',
|
||||
description: 'Stale handler',
|
||||
lineNumber: 1,
|
||||
handlerNodeId: 'Function:src/tools.py:missing',
|
||||
...(allowFileFallback === false ? { allowFileFallback } : {}),
|
||||
},
|
||||
],
|
||||
allPaths: [],
|
||||
}),
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
expect(output.toolDefs).toEqual([
|
||||
{ name: 'stale_tool', filePath: 'src/tools.py', description: 'Stale handler' },
|
||||
]);
|
||||
expect(output.toolDefs).toEqual([
|
||||
{
|
||||
name: 'stale_tool',
|
||||
filePath: 'src/tools.py',
|
||||
description: 'Stale handler',
|
||||
...(allowFileFallback === false ? { allowFileFallback } : {}),
|
||||
},
|
||||
]);
|
||||
|
||||
const edge = graph.relationships.find((rel) => rel.type === 'HANDLES_TOOL');
|
||||
expect(edge).toMatchObject({
|
||||
sourceId: 'File:src/tools.py',
|
||||
targetId: 'Tool:stale_tool',
|
||||
});
|
||||
});
|
||||
const edge = graph.relationships.find((rel) => rel.type === 'HANDLES_TOOL');
|
||||
expect(edge).toMatchObject({
|
||||
sourceId: 'File:src/tools.py',
|
||||
targetId: 'Tool:stale_tool',
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('does not attach file-level fallback tools to handler-specific processes', async () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
|
|
@ -110,6 +119,7 @@ describe('Tool handler and process linking phases', () => {
|
|||
addNode(graph, fileLeaf, 'Function', 'fileLeaf', filePath);
|
||||
addNode(graph, 'Tool:alpha', 'Tool', 'alpha', filePath);
|
||||
addNode(graph, 'Tool:fallback_tool', 'Tool', 'fallback_tool', filePath);
|
||||
addNode(graph, 'Tool:unresolved_tool', 'Tool', 'unresolved_tool', filePath);
|
||||
addCall(graph, alpha, alphaHelper);
|
||||
addCall(graph, alphaHelper, alphaLeaf);
|
||||
addCall(graph, fileEntry, fileHelper);
|
||||
|
|
@ -127,6 +137,7 @@ describe('Tool handler and process linking phases', () => {
|
|||
toolDefs: [
|
||||
{ name: 'alpha', filePath, description: '', handlerNodeId: alpha },
|
||||
{ name: 'fallback_tool', filePath, description: '' },
|
||||
{ name: 'unresolved_tool', filePath, description: '', allowFileFallback: false },
|
||||
],
|
||||
}),
|
||||
],
|
||||
|
|
@ -147,5 +158,6 @@ describe('Tool handler and process linking phases', () => {
|
|||
|
||||
expect(linkedEntriesByTool.get('Tool:alpha')).toEqual([alpha]);
|
||||
expect(linkedEntriesByTool.get('Tool:fallback_tool')).toEqual([fileEntry]);
|
||||
expect(linkedEntriesByTool.has('Tool:unresolved_tool')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
441
gitnexus/test/unit/typescript-tool-definitions.test.ts
Normal file
441
gitnexus/test/unit/typescript-tool-definitions.test.ts
Normal file
|
|
@ -0,0 +1,441 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import Parser from 'tree-sitter';
|
||||
import JavaScript from 'tree-sitter-javascript';
|
||||
import TypeScript from 'tree-sitter-typescript';
|
||||
import { extractToolDefinitions } from '../../src/core/ingestion/languages/typescript/tool-definitions.js';
|
||||
|
||||
const tsParser = new Parser();
|
||||
tsParser.setLanguage(TypeScript.typescript);
|
||||
const jsParser = new Parser();
|
||||
jsParser.setLanguage(JavaScript);
|
||||
|
||||
const sdkImport = `import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';`;
|
||||
const server = `${sdkImport}\nconst server = new McpServer({ name: 'example', version: '1' });`;
|
||||
const extract = (source: string, parser = tsParser, filePath = 'src/server.ts', offset = 0) =>
|
||||
extractToolDefinitions(parser.parse(source), filePath, offset);
|
||||
const metadata = (source: string) =>
|
||||
extract(source).map(({ toolName, description }) => ({ toolName, description }));
|
||||
|
||||
describe('SDK tool registration extraction', () => {
|
||||
it.each([
|
||||
['TypeScript', tsParser, 'src/server.ts'],
|
||||
['JavaScript', jsParser, 'src/server.js'],
|
||||
] as const)(
|
||||
'extracts modern %s registrations in ordinary server files',
|
||||
(_language, parser, filePath) => {
|
||||
expect(
|
||||
extract(
|
||||
`${server}\nserver.registerTool('search', { description: 'Search files' }, handler);`,
|
||||
parser,
|
||||
filePath,
|
||||
10,
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
filePath,
|
||||
toolName: 'search',
|
||||
description: 'Search files',
|
||||
lineNumber: 13,
|
||||
allowFileFallback: false,
|
||||
},
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
['TypeScript', tsParser, 'src/server.ts'],
|
||||
['JavaScript', jsParser, 'src/server.js'],
|
||||
] as const)('recognizes namespace imports in %s', (_language, parser, filePath) => {
|
||||
expect(
|
||||
extract(
|
||||
`
|
||||
import * as SDK from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
const server = new SDK.McpServer({});
|
||||
server.registerTool('modern', { description: 'Namespace tool' }, handler);
|
||||
server.tool('legacy', handler);
|
||||
`,
|
||||
parser,
|
||||
filePath,
|
||||
).map(({ toolName }) => toolName),
|
||||
).toEqual(['modern', 'legacy']);
|
||||
});
|
||||
|
||||
it.each(['', 'type '])('recognizes %snamespace imports in directly typed helpers', (typeOnly) => {
|
||||
expect(
|
||||
metadata(`
|
||||
import ${typeOnly}* as SDK from '@modelcontextprotocol/sdk/server/mcp';
|
||||
function install(server: SDK.McpServer) { server.tool('typed', handler); }
|
||||
`),
|
||||
).toEqual([{ toolName: 'typed', description: '' }]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"function install(SDK) { const server = new SDK.McpServer({}); server.tool('fake', handler); }",
|
||||
"function install<SDK>(server: SDK.McpServer) { server.tool('fake', handler); }",
|
||||
"SDK = other; const server = new SDK.McpServer({}); server.tool('fake', handler);",
|
||||
"SDK.McpServer = other; const server = new SDK.McpServer({}); server.tool('fake', handler);",
|
||||
"({ value: SDK.McpServer } = other); const server = new SDK.McpServer({}); server.tool('fake', handler);",
|
||||
"SDK.McpServer.prototype.tool = other; const server = new SDK.McpServer({}); server.tool('fake', handler);",
|
||||
"SDK[key] = other; const server = new SDK.McpServer({}); server.tool('fake', handler);",
|
||||
"const server = new SDK.McpServer({}); ({ method: server.tool } = other); server.tool('fake', handler);",
|
||||
"const alias = SDK; const server = new alias.McpServer({}); server.tool('fake', handler);",
|
||||
"const server = new SDK.OtherServer({}); server.tool('fake', handler);",
|
||||
])('rejects unproven namespace receivers: %s', (source) => {
|
||||
expect(
|
||||
metadata(`import * as SDK from '@modelcontextprotocol/sdk/server/mcp.js'; ${source}`),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects type-only namespace construction and unrelated namespace imports', () => {
|
||||
expect(
|
||||
metadata(`
|
||||
import type * as SDK from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import * as Other from 'unrelated';
|
||||
const first = new SDK.McpServer({}); first.tool('type-only', handler);
|
||||
const second = new Other.McpServer({}); second.tool('unrelated', handler);
|
||||
`),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not require description or inputSchema, and ignores nested descriptions', () => {
|
||||
expect(
|
||||
metadata(`${server}
|
||||
server.registerTool('empty', {}, () => {});
|
||||
server.registerTool('nested', { inputSchema: { description: 'Schema decoy' } }, handler);
|
||||
server.registerTool('dynamic-description', { description: getDescription() }, handler);
|
||||
`),
|
||||
).toEqual([
|
||||
{ toolName: 'empty', description: '' },
|
||||
{ toolName: 'nested', description: '' },
|
||||
{ toolName: 'dynamic-description', description: '' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('recognizes legacy callback-last overloads with descriptions, schemas and annotations', () => {
|
||||
expect(
|
||||
metadata(`${server}
|
||||
server.tool('bare', handler);
|
||||
server.tool('described', 'Human description', handler);
|
||||
server.tool('schema', { query: z.string().describe('Field decoy') }, handler);
|
||||
server.tool('annotated', { readOnlyHint: true }, handler);
|
||||
server.tool('full', 'Full description', { query: z.string() }, { readOnlyHint: true }, handler);
|
||||
`),
|
||||
).toEqual([
|
||||
{ toolName: 'bare', description: '' },
|
||||
{ toolName: 'described', description: 'Human description' },
|
||||
{ toolName: 'schema', description: '' },
|
||||
{ toolName: 'annotated', description: '' },
|
||||
{ toolName: 'full', description: 'Full description' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('decodes static names and quoted description keys without distance or property-order limits', () => {
|
||||
expect(
|
||||
metadata(`${server}
|
||||
server.registerTool(\`find\\x2ditems!?\`, {
|
||||
inputSchema: { description: 'Nested decoy', example: '${'x'.repeat(2000)}' },
|
||||
'descr\\u0069ption': 'Line\\nwith \\"quotes\\" and \\u{1F680}',
|
||||
}, handler);
|
||||
server.tool('legacy\\u002fname', \`Static description\`, handler);
|
||||
`),
|
||||
).toEqual([
|
||||
{ toolName: 'find-items!?', description: 'Line\nwith "quotes" and 🚀' },
|
||||
{ toolName: 'legacy/name', description: 'Static description' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('recognizes SDK aliases, locally constructed instances and directly typed helper parameters', () => {
|
||||
expect(
|
||||
metadata(`
|
||||
import { McpServer as Server } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
function install(server: Server) { server.registerTool('helper', {}, handler); }
|
||||
const add = (server: Server) => server.tool('arrow-helper', handler);
|
||||
function start() {
|
||||
const local = new Server({ name: 'local', version: '1' });
|
||||
local.registerTool('local', {}, handler);
|
||||
}
|
||||
`),
|
||||
).toEqual([
|
||||
{ toolName: 'helper', description: '' },
|
||||
{ toolName: 'arrow-helper', description: '' },
|
||||
{ toolName: 'local', description: '' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores dynamic names, comments, string decoys and unrelated receivers', () => {
|
||||
expect(
|
||||
metadata(`${server}
|
||||
// server.registerTool('comment', { description: 'decoy' }, handler);
|
||||
const decoy = "server.tool('string', handler)";
|
||||
server.registerTool(runtimeName, {}, handler);
|
||||
server.tool(\`dynamic-\${runtimeName}\`, handler);
|
||||
server.registerTool('prefix' + suffix, {}, handler);
|
||||
unrelated.registerTool('unrelated', {}, handler);
|
||||
const alias = server;
|
||||
alias.tool('alias', handler);
|
||||
`),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects shadowed SDK names and receivers, including declarations later in the scope', () => {
|
||||
expect(
|
||||
metadata(`${server}
|
||||
function parameter(server) { server.tool('parameter', handler); }
|
||||
function constructor(McpServer) {
|
||||
const fake = new McpServer();
|
||||
fake.tool('constructor', handler);
|
||||
}
|
||||
{
|
||||
server.registerTool('temporal-shadow', {}, handler);
|
||||
const server = unrelated;
|
||||
}
|
||||
function localType() {
|
||||
class McpServer {}
|
||||
function helper(server: McpServer) { server.tool('type-shadow', handler); }
|
||||
}
|
||||
`),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects reassigned receivers and SDK constructors', () => {
|
||||
expect(
|
||||
metadata(`${sdkImport}
|
||||
let changed = new McpServer();
|
||||
changed = unrelated;
|
||||
changed.tool('changed', handler);
|
||||
const instance = new McpServer();
|
||||
function replace() { McpServer = OtherServer; }
|
||||
instance.registerTool('constructor-mutated', {}, handler);
|
||||
`),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('uses decoded type-only imports for helper parameters, but not construction', () => {
|
||||
expect(
|
||||
metadata(`
|
||||
import type { McpServer as Server } from '@modelcontextprotocol/\\u0073dk/server/mcp.js';
|
||||
function install(server: Server) { server.tool('typed', handler); }
|
||||
const fake = new Server();
|
||||
fake.tool('type-only-constructor', handler);
|
||||
`),
|
||||
).toEqual([{ toolName: 'typed', description: '' }]);
|
||||
});
|
||||
|
||||
it.each(['before', 'after'])('allows SDK lifecycle configuration %s registration', (when) => {
|
||||
const configure = `server.server.oninitialized = () => {}; server.server.onerror = () => {};`;
|
||||
const registration = `server.registerTool('visible', {}, handler);`;
|
||||
expect(
|
||||
metadata(
|
||||
`${server}\n${when === 'before' ? configure + registration : registration + configure}`,
|
||||
),
|
||||
).toEqual([{ toolName: 'visible', description: '' }]);
|
||||
});
|
||||
|
||||
describe.each([
|
||||
['TypeScript', tsParser],
|
||||
['JavaScript', jsParser],
|
||||
] as const)('%s destructuring writes', (_language, parser) => {
|
||||
it.each([
|
||||
['object member', `({ registerTool: server.registerTool } = replacement);`],
|
||||
['array member', `[server.tool] = replacement;`],
|
||||
['nested quoted member', `({ nested: [server['registerTool']] } = replacement);`],
|
||||
['defaulted member', `({ registerTool: server.registerTool = fallback } = replacement);`],
|
||||
['rest member', `[...server.tool] = replacement;`],
|
||||
['computed member', `[server[method]] = replacement;`],
|
||||
['loop target', `for ({ registerTool: server.registerTool } of replacements) {}`],
|
||||
['constructor member', `[McpServer.prototype.registerTool] = replacement;`],
|
||||
])('rejects registrations after a write to an %s target', (_name, write) => {
|
||||
expect(
|
||||
extract(`${server}\n${write}\nserver.registerTool('fake', {}, handler);`, parser),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('preserves lifecycle writes and ignores pattern keys and default-value reads', () => {
|
||||
expect(
|
||||
extract(
|
||||
`${server}
|
||||
({ oninitialized: server.server.oninitialized } = callbacks);
|
||||
({ [server.registerTool]: ignored } = source);
|
||||
({ untouched = server.registerTool } = source);
|
||||
server.registerTool('visible', {}, handler);
|
||||
`,
|
||||
parser,
|
||||
).map((tool) => tool.toolName),
|
||||
).toEqual(['visible']);
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'different package',
|
||||
`import { McpServer } from 'unrelated'; const server = new McpServer(); server.tool('fake', h);`,
|
||||
],
|
||||
[
|
||||
'destructured parameter',
|
||||
`${server} function install({ server }) { server.tool('fake', h); }`,
|
||||
],
|
||||
['destructured local', `${server} { const { other: server } = obj; server.tool('fake', h); }`],
|
||||
['catch parameter', `${server} try {} catch (server) { server.tool('fake', h); }`],
|
||||
['loop binding', `${server} for (const server of other) { server.tool('fake', h); }`],
|
||||
[
|
||||
'hoisted var',
|
||||
`${server} function install() { server.tool('fake', h); { var server = other; } }`,
|
||||
],
|
||||
[
|
||||
'generic type',
|
||||
`${sdkImport} function install<McpServer>(server: McpServer) { server.tool('fake', h); }`,
|
||||
],
|
||||
[
|
||||
'named class expression',
|
||||
`${sdkImport} const Other = class McpServer { install() { const server = new McpServer(); server.tool('fake', h); } };`,
|
||||
],
|
||||
['method write', `${server} server.tool = unrelated; server.tool('fake', h);`],
|
||||
['quoted method write', `${server} server['tool'] = unrelated; server.tool('fake', h);`],
|
||||
['computed method write', `${server} server[method] = unrelated; server.tool('fake', h);`],
|
||||
['method delete', `${server} delete server.registerTool; server.registerTool('fake', {}, h);`],
|
||||
['destructured write', `${server} ({ server } = other); server.tool('fake', h);`],
|
||||
[
|
||||
'spread arguments',
|
||||
`${server} server.registerTool('fake', ...args); server.tool('fake', ...args);`,
|
||||
],
|
||||
])('rejects %s', (_name, source) => {
|
||||
expect(metadata(source)).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps evidence outside shadowing scopes and in closures declared before the instance', () => {
|
||||
expect(
|
||||
metadata(`${sdkImport}
|
||||
function install() { server.tool('closure', handler); }
|
||||
const server = new McpServer();
|
||||
{ const server = unrelated; server.tool('decoy', handler); }
|
||||
server.registerTool('outer', {}, handler);
|
||||
`),
|
||||
).toEqual([
|
||||
{ toolName: 'closure', description: '' },
|
||||
{ toolName: 'outer', description: '' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('bounds descriptions to top-level properties and respects property overrides', () => {
|
||||
expect(
|
||||
metadata(`${server}
|
||||
server.registerTool('shorthand', { description: 'Kept', title }, handler);
|
||||
server.registerTool('spread-after', { description: 'Unproven', ...config }, handler);
|
||||
server.registerTool('spread-before', { ...config, description: 'Known' }, handler);
|
||||
server.registerTool('last-wins', { description: 'Old', description: 'New' }, handler);
|
||||
server.registerTool('comments', /* first */ 'not an object', /* callback */ handler);
|
||||
`),
|
||||
).toEqual([
|
||||
{ toolName: 'shorthand', description: 'Kept' },
|
||||
{ toolName: 'spread-after', description: '' },
|
||||
{ toolName: 'spread-before', description: 'Known' },
|
||||
{ toolName: 'last-wins', description: 'New' },
|
||||
{ toolName: 'comments', description: '' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('resolves hoisted declarations and immutable callable bindings using supplied graph IDs', () => {
|
||||
const tree = tsParser.parse(`${server}
|
||||
server.tool('declared', declaration);
|
||||
function declaration() {}
|
||||
const arrow = () => {};
|
||||
const expression = function () {};
|
||||
server.registerTool('arrow', {}, arrow);
|
||||
server.tool('expression', expression);
|
||||
server.tool('inline', () => {});
|
||||
`);
|
||||
const bindings = new Map<number, string>();
|
||||
for (const declaration of tree.rootNode.descendantsOfType([
|
||||
'function_declaration',
|
||||
'variable_declarator',
|
||||
])) {
|
||||
const name = declaration.childForFieldName('name')!;
|
||||
bindings.set(name.id, `existing-graph-id:${name.text}`);
|
||||
}
|
||||
expect(
|
||||
extractToolDefinitions(tree, 'server.ts', 0, bindings).map((tool) => [
|
||||
tool.toolName,
|
||||
tool.handlerNodeId,
|
||||
]),
|
||||
).toEqual([
|
||||
['declared', 'existing-graph-id:declaration'],
|
||||
['arrow', 'existing-graph-id:arrow'],
|
||||
['expression', 'existing-graph-id:expression'],
|
||||
['inline', undefined],
|
||||
]);
|
||||
expect(
|
||||
extractToolDefinitions(tree, 'server.ts').every((tool) => tool.handlerNodeId === undefined),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('uses the nearest callable binding without conflating same-name declarations', () => {
|
||||
const tree = tsParser.parse(`${server}
|
||||
function handler() {}
|
||||
function install() {
|
||||
const handler = () => {};
|
||||
server.tool('inner', handler);
|
||||
}
|
||||
server.tool('outer', handler);
|
||||
`);
|
||||
const outer = tree.rootNode
|
||||
.descendantsOfType('function_declaration')[0]
|
||||
.childForFieldName('name')!;
|
||||
const inner = tree.rootNode
|
||||
.descendantsOfType('variable_declarator')
|
||||
.find((node) => node.childForFieldName('name')?.text === 'handler')!
|
||||
.childForFieldName('name')!;
|
||||
const bindings = new Map([
|
||||
[outer.id, 'emitted-outer'],
|
||||
[inner.id, 'emitted-inner'],
|
||||
]);
|
||||
expect(
|
||||
extractToolDefinitions(tree, 'server.ts', 0, bindings).map((tool) => [
|
||||
tool.toolName,
|
||||
tool.handlerNodeId,
|
||||
]),
|
||||
).toEqual([
|
||||
['inner', 'emitted-inner'],
|
||||
['outer', 'emitted-outer'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps ambiguous, shadowed, mutable and noncallable handler bindings unresolved', () => {
|
||||
const tree = tsParser.parse(`${server}
|
||||
import { imported } from './handlers';
|
||||
function handler() {}
|
||||
function parameter(handler) { server.tool('parameter', handler); }
|
||||
{ const handler = 42; server.tool('shadowed', handler); }
|
||||
const alias = handler;
|
||||
server.tool('alias', alias);
|
||||
server.tool('imported', imported);
|
||||
let mutable = () => {};
|
||||
server.tool('mutable', mutable);
|
||||
const reassigned = () => {};
|
||||
({ reassigned } = replacements);
|
||||
server.tool('reassigned', reassigned);
|
||||
function duplicate() {}
|
||||
function duplicate() {}
|
||||
server.tool('duplicate', duplicate);
|
||||
server.tool('before-initialization', later);
|
||||
const later = () => {};
|
||||
`);
|
||||
const bindings = new Map<number, string>();
|
||||
// Even graph nodes sharing these names cannot establish a safe callback binding.
|
||||
for (const node of tree.rootNode.descendantsOfType('identifier'))
|
||||
bindings.set(node.id, `emitted:${node.text}`);
|
||||
const tools = extractToolDefinitions(tree, 'server.ts', 0, bindings);
|
||||
expect(tools.map((tool) => tool.toolName)).toEqual([
|
||||
'parameter',
|
||||
'shadowed',
|
||||
'alias',
|
||||
'imported',
|
||||
'mutable',
|
||||
'reassigned',
|
||||
'duplicate',
|
||||
'before-initialization',
|
||||
]);
|
||||
expect(
|
||||
tools.every((tool) => tool.handlerNodeId === undefined && tool.allowFileFallback === false),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue