Merge branch 'main' into fix/claude-config-dir

This commit is contained in:
Gergő Magyar 2026-10-03 08:43:11 +01:00 • committed by GitHub
commit a5f96093a7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
45 changed files with 2668 additions and 137 deletions

View file

@ -119,7 +119,8 @@
"_rebaselined_3354_callable_alternatives": "#3354 callable alternatives: a callable chosen by a value-selecting source now flows every branch it can yield (`a ?? b`, `a || b`, `a or b`, `c ? a : b`, statement `if`, elvis), and an operator branch (`x == f || g`) stays opaque instead of seeding a qualified name. Verified by running the BASE (merge-base 233ca2849) and HEAD emitters over the SAME HEAD fixture corpus: every added or removed match is an `@callable-flow.*` match on one of those sources, and the pre-existing corpus is byte-identical (all other languages: zero delta). The rest of the drift is corpus growth from this PR's regression fixture, which this bench globs. Corpus growth: ruby-callable-alternatives/app.rb (+1 file, +58 groups). Emitter delta: +5 / -0: seeds for single-statement `if` / `elsif` branches (run_then, run_else, run_sweep, run_a, run_b); the multi-statement branch contributes nothing. capture_groups_fp 1358 -> 1416, fixture_count 91 -> 92; synthetic counts unchanged; scaling 1.04 < 1.5. Prior 1c8c9c4b54036fa24c2a81e39ea530e938645c856d369075e5f437da78218c57 -> 45e65d9fa8a9e5e905ddb596b179b77c86ed13ab5139c6b17ccaaf7315dfe46a."
},
"swift": {
"fingerprint": "d56406c2645637042899cfcc8dc73f603d77caef0a9a2bac179ffbec848258a3",
"fingerprint": "2507ac75ba6fb47c272a5caa97b5e8b9f6ebc5098c15295d05c7bff3cabc321a",
"_rebaselined_3425_injected_closure": "Swift capture fixture growth: 83 to 85 fixtures, 1436 to 1553 capture groups; scaling budget unchanged.",
"_rebaselined_3355_xcode_and_import_fixtures": "#3355 follow-up: new swift-xcode-targets fixture (four sources) and two sources added to swift-nested-packages (a Docs/Net folder decoy and an `import Net` caller). The capture query is unchanged; this is fixture-corpus growth only. capture_groups_fp 1393 -> 1436 and fixture_count 77 -> 83; synthetic scale counts remain 5012/16012. Prior aea33bf12f57561be7e3125929fb98cec7aed5a681e25aad435c7bb558747853 -> d56406c2645637042899cfcc8dc73f603d77caef0a9a2bac179ffbec848258a3; measured scaling 1.036 < 1.5.",
"_rebaselined_3355_nested_packages": "#3355: new swift-nested-packages fixture (three nested Package.swift manifests, six sources) for nested-package module grouping. The capture query is unchanged; this is fixture-corpus growth only. capture_groups_fp 1333 -> 1393 and fixture_count 68 -> 77; synthetic scale counts remain 5012/16012. Prior c9fc553662f0db18027fba882e3ff730744f6153cc46eca7b00667fabd6a0df8 -> aea33bf12f57561be7e3125929fb98cec7aed5a681e25aad435c7bb558747853; measured scaling 1.023 < 1.5.",
"scaling_budget": 1.5,
@ -187,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.",

View file

@ -4,16 +4,18 @@
* against a real group, which is how `STALE (-1 commits behind)` went unnoticed
* (#3256).
*/
import type { StalenessStatus } from '../core/staleness-status.js';
/** The fields of a `groupStatus` repo row the index column reads. */
export interface GroupRepoIndexRow {
indexStale: boolean;
commitsBehind?: number;
status?: StalenessStatus;
}
/**
* The index column of a `group status` row. The output is unchanged except
* for one case: a count that is not a real count renders as `?`.
* The index column of a `group status` row. Diverged indexes differ from HEAD
* without a forward commit count; an unavailable count renders as `?`.
*
* `group/service.ts` has always reported a repo with no recorded commit as
* `{ indexStale: true, commitsBehind: -1 }`. The previous `?? '?'` fallback
@ -21,6 +23,7 @@ export interface GroupRepoIndexRow {
*/
export const formatIndexStatusCell = (row: GroupRepoIndexRow): string => {
if (!row.indexStale) return 'OK ';
if (row.status === 'diverged') return 'STALE (index differs from HEAD)';
const n = row.commitsBehind;
const count = typeof n === 'number' && n >= 0 ? String(n) : '?';
return `STALE (${count} commits behind)`;

View file

@ -17,8 +17,8 @@ export type { StalenessInfo, StalenessStatus } from './staleness-status.js';
const execFileAsync = promisify(execFile);
/**
* Ceiling for one `git rev-list` staleness probe. Generous for the local
* history walk this is, and short enough that an unresponsive working tree
* Per-command ceiling for async `git rev-list` and both HEAD probes.
* Generous for local Git queries, and short enough that an unresponsive working tree
* degrades to "not stale" quickly rather than holding a request open.
*/
const STALENESS_TIMEOUT_MS = 5_000;
@ -33,12 +33,23 @@ const behindHint = (n: number): string =>
const DIVERGED_HINT =
"⚠️ Index is not at HEAD and the commit gap could not be counted — the recorded commit may no longer be in this clone's history. Run analyze tool to update.";
// `rev-list --count lastCommit..HEAD` answering 0 does NOT mean "HEAD is the
// indexed commit" — it means "HEAD has no commits lastCommit lacks", which is
// also true when HEAD is an *ancestor* of lastCommit (the working tree checked
// out an older commit than the one indexed, or switched to a line of history
// behind it). That read a rollback as `current` until this hint existed.
const REGRESSED_HINT =
'⚠️ Index is not at HEAD — the indexed commit is not reachable from the checked-out commit (the working tree may have checked out an older commit, or a different line of history). Run analyze tool to update.';
const unknown = (): StalenessInfo => ({ isStale: false, commitsBehind: 0, status: 'unknown' });
const fromCount = (commitsBehind: number): StalenessInfo =>
commitsBehind > 0
? { isStale: true, commitsBehind, hint: behindHint(commitsBehind), status: 'behind' }
: { isStale: false, commitsBehind: 0, status: 'current' };
// Called only once a positive HEAD-only count is in hand.
const behind = (commitsBehind: number): StalenessInfo => ({
isStale: true,
commitsBehind,
hint: behindHint(commitsBehind),
status: 'behind',
});
/**
* `rev-list` could not answer. Asking for HEAD alone needs no history walk and
@ -53,6 +64,26 @@ const fromHead = (head: string | null, lastCommit: string): StalenessInfo => {
return { isStale: false, commitsBehind: 0, hint: DIVERGED_HINT, status: 'diverged' };
};
/**
* `rev-list --left-right --count lastCommit...HEAD` measures both sides in
* one process, so a later HEAD change cannot mix two snapshots. The left
* count identifies a rollback even when the HEAD-only (right) count is 0.
* Positive right counts keep the existing `behind` behavior, including when
* both sides have commits (divergent branches or a re-shallowed clone).
*/
const fromCounts = (output: string): StalenessInfo => {
const counts = /^(\d+)\s+(\d+)$/.exec(output.trim());
if (!counts) return unknown();
const indexedOnly = Number(counts[1]);
const headOnly = Number(counts[2]);
if (!Number.isSafeInteger(indexedOnly) || !Number.isSafeInteger(headOnly)) return unknown();
if (headOnly > 0) return behind(headOnly);
if (indexedOnly > 0) {
return { isStale: true, commitsBehind: 0, hint: REGRESSED_HINT, status: 'diverged' };
}
return { isStale: false, commitsBehind: 0, status: 'current' };
};
const readHeadSync = (repoPath: string): string | null => {
try {
return (
@ -61,6 +92,7 @@ const readHeadSync = (repoPath: string): string | null => {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true,
timeout: STALENESS_TIMEOUT_MS,
}).trim() || null
);
} catch {
@ -89,14 +121,18 @@ export function checkStaleness(repoPath: string, lastCommit: string): StalenessI
// No recorded commit is not "at HEAD": there is nothing to measure against.
if (!lastCommit) return unknown();
try {
const result = execFileSync('git', ['rev-list', '--count', `${lastCommit}..HEAD`], {
cwd: repoPath,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true,
}).trim();
const result = execFileSync(
'git',
['rev-list', '--left-right', '--count', `${lastCommit}...HEAD`],
{
cwd: repoPath,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true,
},
);
return fromCount(parseInt(result, 10) || 0);
return fromCounts(result);
} catch {
return fromHead(readHeadSync(repoPath), lastCommit);
}
@ -115,21 +151,25 @@ export async function checkStalenessAsync(
try {
// Note: promisified execFile captures stdout/stderr by default (no stdio option needed,
// unlike the sync variant which requires explicit stdio: ['pipe','pipe','pipe']).
const { stdout } = await execFileAsync('git', ['rev-list', '--count', `${lastCommit}..HEAD`], {
cwd: repoPath,
encoding: 'utf-8',
windowsHide: true,
// The catch below fails closed on every git ERROR, but a hang is not an
// error — it is silence, and without a bound this await never settles.
// A working tree on a disconnected network mount or behind a stuck lock
// does exactly that, and `/api/repos` fans this out once per registered
// repo, so one unreachable mount could hold the whole listing open
// (#3232 review). The timeout kills the child and rejects, and the catch
// below reports it as `unknown` — still the fail-closed `isStale: false`.
timeout: STALENESS_TIMEOUT_MS,
});
const { stdout } = await execFileAsync(
'git',
['rev-list', '--left-right', '--count', `${lastCommit}...HEAD`],
{
cwd: repoPath,
encoding: 'utf-8',
windowsHide: true,
// The catch below fails closed on every git ERROR, but a hang is not an
// error — it is silence, and without a bound this await never settles.
// A working tree on a disconnected network mount or behind a stuck lock
// does exactly that, and `/api/repos` fans this out once per registered
// repo, so one unreachable mount could hold the whole listing open
// (#3232 review). The timeout kills the child and rejects, and the catch
// below reports it as `unknown` — still the fail-closed `isStale: false`.
timeout: STALENESS_TIMEOUT_MS,
},
);
return fromCount(parseInt(stdout.trim(), 10) || 0);
return fromCounts(stdout);
} catch (err) {
// A rev-list that timed out means the working tree is not answering. Asking
// it again for HEAD would only double the bound #3232 put on a hung mount.

View file

@ -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.
*

View file

@ -0,0 +1,78 @@
import type { ParsedFile, ScopeId, SymbolDefinition, TypeRef } from 'gitnexus-shared';
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
import { findClassBindingInScope } from '../../scope-resolution/scope/walkers.js';
export function swiftIsCallableVisibleFromCaller(ctx: {
readonly candidate: SymbolDefinition;
readonly callerParsed?: ParsedFile;
readonly callArity?: number;
readonly callerScope?: ScopeId;
readonly scopes?: ScopeResolutionIndexes;
}): boolean {
const indexes = ctx.scopes;
if (ctx.callerScope === undefined || indexes === undefined || ctx.callArity !== 0) return true;
const name = ctx.candidate.qualifiedName?.split('.').at(-1);
if (name === undefined) return true;
let scopeId: ScopeId | null = ctx.callerScope;
let selfType: TypeRef | undefined;
while (scopeId !== null) {
const scope = indexes.scopeTree.getScope(scopeId);
if (scope === undefined) break;
// A local function selected inside the caller wins before member lookup.
if (
scope.kind !== 'Class' &&
scope.ownedDefs.some((def) => def.nodeId === ctx.candidate.nodeId)
)
return true;
selfType ??= scope.typeBindings.get('self');
if (scope.kind === 'Class') {
const classDef =
scope.ownedDefs.find((def) => def.type === 'Class') ??
(selfType === undefined
? undefined
: findClassBindingInScope(ctx.callerScope, selfType.rawName, indexes, undefined, {
uniqueQualifiedNameFallback: false,
}));
const propertyOnOwner = (ownerId: string, ownerName: string): boolean =>
indexes.qualifiedNames.get(`${ownerName}.${name}`).some((defId) => {
const def = indexes.defs.get(defId);
return def?.type === 'Property' && def.ownerId === ownerId;
});
const ownProperty =
scope.ownedDefs.some(
(def) => def.type === 'Property' && def.qualifiedName?.split('.').at(-1) === name,
) ||
(classDef !== undefined &&
classDef.qualifiedName !== undefined &&
propertyOnOwner(classDef.nodeId, classDef.qualifiedName));
const inheritedProperty =
classDef !== undefined &&
indexes.methodDispatch.mroFor(classDef.nodeId).some((ownerId) => {
const ownerName = indexes.defs.get(ownerId)?.qualifiedName;
return ownerName !== undefined && propertyOnOwner(ownerId, ownerName);
});
if (!ownProperty && !inheritedProperty) return true;
// Swift's nested function is owned by its own Function scope. A
// same-file sibling must not count as a nearer lexical binding.
const declarationScope = ctx.callerParsed?.scopes.find((candidateScope) =>
candidateScope.ownedDefs.some((def) => def.nodeId === ctx.candidate.nodeId),
);
if (
declarationScope !== undefined &&
declarationScope.kind === 'Function' &&
(declarationScope.id === ctx.callerScope ||
indexes.scopeTree.getAncestors(declarationScope.id).includes(ctx.callerScope))
)
return true;
// Scope defs do not carry Swift access modifiers. A method positively
// selected on this type must not be vetoed by an uncertain ancestor.
return classDef !== undefined && ctx.candidate.ownerId === classDef.nodeId;
}
scopeId = scope.parent;
}
return true;
}

View file

@ -70,6 +70,7 @@ import {
import { stripSwiftTypePreservingDecoration } from './interpret.js';
import { groupSwiftFilesByModule } from './target-grouping.js';
import { swiftIsGlobalNameFallbackPlausible } from './name-fallback-visibility.js';
import { swiftIsCallableVisibleFromCaller } from './callable-visibility.js';
const ZERO_RANGE = { startLine: 0, startCol: 0, endLine: 0, endCol: 0 } as const;
@ -155,6 +156,7 @@ const swiftScopeResolver: ScopeResolver = {
// no-`new` constructor + cross-file free-call shape).
allowGlobalFreeCallFallback: true,
isGlobalNameFallbackPlausible: swiftIsGlobalNameFallbackPlausible,
isCallableVisibleFromCaller: swiftIsCallableVisibleFromCaller,
// Swift's call graph models `Type(...)` as a reference to the type
// itself, not its `init` — both the legacy DAG and this test suite link

View file

@ -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,
});

View file

@ -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;
}

View file

@ -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);

View file

@ -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 } : {}),
});
}

View file

@ -1235,6 +1235,9 @@ export interface ScopeResolver {
readonly isCallableVisibleFromCaller?: (ctx: {
readonly callerParsed: ParsedFile;
readonly candidate: SymbolDefinition;
/** Arity of the actual call, when known. A visibility veto must not
* infer applicability from a name match alone. */
readonly callArity?: number;
/** Caller's enclosing scope id. Languages that gate visibility on
* caller scope (e.g. C++ two-phase template lookup) consult it;
* others ignore. Optional so existing implementations stay valid. */

View file

@ -84,12 +84,7 @@ export function emitFreeCallFallback(
* appended to its reason. See `ScopeResolver.markConstructionSites`. */
readonly markConstructionSites?: boolean;
readonly isFileLocalDef?: (def: SymbolDefinition) => boolean;
readonly isCallableVisibleFromCaller?: (ctx: {
readonly callerParsed: ParsedFile;
readonly candidate: SymbolDefinition;
readonly callerScope?: ScopeId;
readonly scopes?: ScopeResolutionIndexes;
}) => boolean;
readonly isCallableVisibleFromCaller?: ScopeResolver['isCallableVisibleFromCaller'];
readonly resolveAdlCandidates?: (
site: {
readonly name: string;
@ -620,6 +615,7 @@ export function emitFreeCallFallback(
options.isCallableVisibleFromCaller!({
callerParsed: parsed,
candidate,
callArity: site.arity,
callerScope: site.inScope,
scopes,
})
@ -698,6 +694,7 @@ export function emitFreeCallFallback(
!options.isCallableVisibleFromCaller({
callerParsed: parsed,
candidate: fnDef,
callArity: site.arity,
callerScope: site.inScope,
scopes,
})

View file

@ -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);

View file

@ -4044,6 +4044,7 @@ export const buildFtsQueryCypher = (
* @param query - Search query string
* @param limit - Maximum results
* @param conjunctive - If true, all terms must match (AND); if false, any term matches (OR)
* @param missingIndex - Preserve the empty-result default, or propagate missing indexes for diagnostics
* @returns Array of { node properties, score }
*/
export const queryFTS = async (
@ -4052,6 +4053,7 @@ export const queryFTS = async (
query: string,
limit: number = 20,
conjunctive: boolean = false,
missingIndex: 'empty' | 'throw' = 'empty',
): Promise<
Array<{ nodeId: string; name: string; filePath: string; score: number; [key: string]: any }>
> => {
@ -4082,7 +4084,7 @@ export const queryFTS = async (
// NEW-6 — this used to be a bare `.includes('does not exist')` check
// that could not tell the two apart).
const message = e instanceof Error ? e.message : String(e);
if (classifyFtsQueryError(message) === 'missing-index') {
if (missingIndex === 'empty' && classifyFtsQueryError(message) === 'missing-index') {
return [];
}
throw e;

View file

@ -47,6 +47,8 @@ export interface FTSSearchResponse {
* which only does so when every table failed).
*/
nonBenignErrors?: string[];
/** Configured table.index names that could not be queried because their index is missing. */
missingIndexes?: string[];
}
/**
@ -142,6 +144,7 @@ export const searchFTSFromLbug = async (
const resultsByIndex: any[][] = [];
let queriesSucceeded = 0;
const nonBenignErrors: string[] = [];
const missingIndexes: string[] = [];
const ftsExtension = getExtensionCapabilities().find((c) => c.name === 'fts');
if (ftsExtension && !ftsExtension.loaded) {
@ -171,24 +174,28 @@ export const searchFTSFromLbug = async (
if (outcome.rows) {
queriesSucceeded++;
resultsByIndex.push(outcome.rows);
} else if (!outcome.benign) {
} else if (outcome.benign) {
missingIndexes.push(`${table}.${indexName}`);
} else {
nonBenignErrors.push(redactPaths(outcome.message ?? 'Unknown FTS query error'));
}
}
} else {
// Use core lbug adapter (CLI / pipeline context) — also sequential for safety.
// tri-review Residual-1: `queryFTS` itself only swallows a genuinely-missing
// index (via the SAME classifyFtsQueryError this module re-exports); a
// missing-table or real query error rethrows here — track it the same way
// the MCP pool path does instead of a bare `catch {}` that dropped it.
// Opt into missing-index propagation so absent indexes cannot masquerade
// as successful zero-match queries. Keep core/pool classification identical.
for (const { table, indexName } of FTS_INDEXES) {
try {
const result = await queryFTS(table, indexName, searchQuery, limit, false);
const result = await queryFTS(table, indexName, searchQuery, limit, false, 'throw');
queriesSucceeded++;
resultsByIndex.push(result);
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
nonBenignErrors.push(redactPaths(message));
if (classifyFtsQueryError(message) === 'missing-index') {
missingIndexes.push(`${table}.${indexName}`);
} else {
nonBenignErrors.push(redactPaths(message));
}
}
}
}
@ -234,5 +241,6 @@ export const searchFTSFromLbug = async (
})),
ftsAvailable,
...(nonBenignErrors.length > 0 && { nonBenignErrors }),
...(missingIndexes.length > 0 && { missingIndexes }),
};
};

View file

@ -110,19 +110,21 @@ export const ftsDegradedWarning = (
};
/**
* Warning for when the FTS extension is loaded and indexes exist, but every
* configured table's query failed for a real, non-benign reason (timeout,
* connection reset, native fault) — as opposed to `ftsDegradedWarning`'s
* missing-index case. `--repair-fts` will not fix a query/connection error,
* so this deliberately does NOT suggest it: reusing the missing-index
* message here would reproduce, for this cause, the exact misleading
* "run --repair-fts" guidance #2767 itself was about (tri-review NEW-1).
* Warning when no FTS query succeeded and at least one failed for a real,
* non-benign reason (timeout, connection reset, native fault). `--repair-fts`
* will not fix those errors. If indexes are also missing, the caller composes
* their repair guidance separately; do not deny that additional failure cause.
*/
export const ftsQueryFailedWarning = (context: FtsWarningContext): string =>
export const ftsQueryFailedWarning = (
context: FtsWarningContext,
hasMissingIndexes = false,
): string =>
'FTS keyword search failed — every configured index query returned an error' +
(context.lastErrorRedacted ? ` (${context.lastErrorRedacted})` : '') +
'; results do not include keyword matches. This is not a missing-index ' +
'condition — see server logs for details.' +
'; results do not include keyword matches. ' +
(hasMissingIndexes
? 'See server logs for query error details.'
: 'This is not a missing-index condition — see server logs for details.') +
` (resolved: ${formatResolvedSuffix(context)})`;
// Stemmers shipped by the LadybugDB FTS extension. Mirrors the lowercase token

View file

@ -18,21 +18,35 @@
* provably stale index indistinguishable from a fresh one. `status` is the
* additive channel that separates them for a caller that wants to act on it:
*
* - `current` — the index is at HEAD: `rev-list` answered 0, or it could not
* answer but HEAD alone resolved to the indexed commit.
* - `behind` — `rev-list` answered N > 0; `commitsBehind` is N.
* - `diverged` — `rev-list` could not answer, but HEAD resolved and is not the
* indexed commit. The index is provably not at HEAD; only the count is
* unknown. A branch-pinned `serve` clone reaches this once git prunes the
* commit a failed re-index left behind — the pinned update is a
* `fetch --depth 1`, which orphans it — and a rewritten history reaches it
* directly. It is the rule the Claude hook already applies:
* HEAD !== lastCommit.
* - `unknown` — HEAD could not be resolved at all: not a git repository, git
* timed out, or no commit was recorded.
* The successful probe is `rev-list --left-right --count lastCommit...HEAD`:
* the left count is indexed-only commits, and the right is HEAD-only commits.
* Both counts come from one HEAD snapshot, without a follow-up process.
*
* `isStale` and `commitsBehind` keep their historical values in every case, so
* no existing consumer changes behaviour unless it reads `status`.
* - `current` — both counts are 0, or `rev-list` could not answer but a
* fallback `rev-parse HEAD` resolved to the indexed commit.
* - `behind` — the right count is N > 0; `commitsBehind` is N, including
* when the left count is positive too (divergent or shallow history).
* - `diverged` — the index is provably not at HEAD, reached two different ways:
* - The left count is positive and the right is 0: HEAD is an ancestor
* of the indexed commit (#3127). The working tree checked out an older
* commit than the one indexed, or a release branch behind the indexed
* tip. The mismatch is established: `isStale` is `true` and
* `commitsBehind` stays 0 (there is no forward count to report).
* - `rev-list` could not answer at all, but HEAD resolved and is not the
* indexed commit: only the count is unknown. A branch-pinned `serve`
* clone reaches this once git prunes the commit a failed re-index left
* behind — the pinned update is a `fetch --depth 1`, which orphans it —
* and a rewritten history reaches it directly. This arm keeps the
* historical fail-open `isStale: false` (see below).
* - `unknown` — the probe could not establish the relationship: no readable
* HEAD, a timeout, no recorded commit, or malformed count output.
*
* `isStale` and `commitsBehind` keep their historical fail-open values
* (`false` / `0`) whenever the check could not fully answer — every `unknown`,
* and the `rev-list`-failure arm of `diverged` — so no existing consumer
* changes behaviour there unless it reads `status`. The other arm of
* `diverged` (the confirmed rollback) is a successful, computed
* answer rather than a failure, so `isStale` reflects it (`true`) instead.
*/
export type StalenessStatus = 'current' | 'behind' | 'diverged' | 'unknown';

View file

@ -3172,6 +3172,7 @@ export class LocalBackend {
// regardless of whether OTHER tables succeeded — previously a real error
// on N-1 of N tables while one succeeded left zero diagnostic trail.
const ftsQueryErrors = bm25SearchResult?.nonBenignErrors;
const ftsMissingIndexes = bm25SearchResult?.missingIndexes;
if (ftsQueryErrors) {
// tri-review NEW-5: these strings are already classified non-benign by
// classifyFtsQueryError — do NOT route them through logQueryError,
@ -3661,13 +3662,15 @@ export class LocalBackend {
branch: repo.branch,
indexedAt: this.lastObservedPoolState.get(repo.lbugPath)?.indexedAt ?? repo.indexedAt,
};
// tri-review NEW-1: every table failing for a REAL error (timeout,
// connection reset) is not a missing-index condition — `ftsDegradedWarning`'s
// "run --repair-fts" headline won't fix it. Route to a dedicated message
// instead of burying the real cause as a trailing suffix on bad advice.
// Real errors (timeout, connection reset) need their own diagnosis.
// When some indexes are also missing, preserve both causes and append
// their repair guidance below even though no FTS query succeeded.
warnings.push(
ftsQueryErrors
? ftsQueryFailedWarning({ ...warningContext, lastErrorRedacted: ftsQueryErrors[0] })
? ftsQueryFailedWarning(
{ ...warningContext, lastErrorRedacted: ftsQueryErrors[0] },
!!ftsMissingIndexes?.length,
)
: ftsDegradedWarning(warningContext, ftsDisabledReason),
);
} else if (ftsQueryErrors) {
@ -3680,6 +3683,12 @@ export class LocalBackend {
`FTS keyword search partially failed — ${ftsQueryErrors.length} of the configured indexes hit a query error and were skipped; results may be missing matches from those node types (see server logs).`,
);
}
if (ftsMissingIndexes?.length && (ftsUsed || ftsQueryErrors)) {
warnings.push(
`FTS keyword search is incomplete: missing configured indexes (${ftsMissingIndexes.join(', ')}). ` +
'Results may be missing matches from those node types. Run `gitnexus analyze --repair-fts`.',
);
}
// #2331: a CJK query against a server process resolving
// GITNEXUS_FTS_CJK_SEGMENTATION to 'none' silently misses sub-phrase
// matches with no other signal — this is the only place an agent driving
@ -3811,7 +3820,7 @@ export class LocalBackend {
// #2767: a partial FTS failure (some tables ok, one or more real errors)
// is as much a "results may be incomplete" signal as enrichmentDegraded —
// flag it the same way rather than only via the warning string.
const ftsPartial = ftsUsed && !!ftsQueryErrors;
const ftsPartial = ftsUsed && (!!ftsQueryErrors || !!ftsMissingIndexes?.length);
return {
processes,
@ -3832,7 +3841,12 @@ export class LocalBackend {
query: string,
limit: number,
disabledReason?: FtsDisabledReason,
): Promise<{ results: any[]; ftsUsed: boolean; nonBenignErrors?: string[] }> {
): Promise<{
results: any[];
ftsUsed: boolean;
nonBenignErrors?: string[];
missingIndexes?: string[];
}> {
if (disabledReason) return { results: [], ftsUsed: false };
let searchFTSFromLbug;
try {
@ -3866,6 +3880,7 @@ export class LocalBackend {
const bm25Results = ftsResponse?.results ?? [];
const ftsUsed = ftsResponse?.ftsAvailable ?? false;
const nonBenignErrors = ftsResponse?.nonBenignErrors;
const missingIndexes = ftsResponse?.missingIndexes;
const results: any[] = [];
@ -3939,7 +3954,12 @@ export class LocalBackend {
}
}
return { results, ftsUsed, ...(nonBenignErrors && { nonBenignErrors }) };
return {
results,
ftsUsed,
...(nonBenignErrors && { nonBenignErrors }),
...(missingIndexes && { missingIndexes }),
};
}
/**

View file

@ -351,7 +351,7 @@ async function getContextResource(backend: LocalBackend, repoName?: string): Pro
// Check staleness using the current on-disk lastCommit (not the cached handle)
const repoPath = repo.repoPath;
const lastCommit = freshMeta?.lastCommit ?? repo.lastCommit ?? 'HEAD';
const lastCommit = freshMeta?.lastCommit ?? repo.lastCommit ?? '';
const staleness = repoPath
? checkStaleness(repoPath, lastCommit)
: { isStale: false, commitsBehind: 0 };

View file

@ -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

View file

@ -0,0 +1,54 @@
import Foundation
enum Example {
static func runScenario() -> Int {
makeValue(input: 1)
}
}
final class Service {
private let clock: () -> Date
init(clock: @escaping () -> Date) {
self.clock = clock
}
func refreshValue() -> Date {
clock()
}
func refreshWithLocalClock() -> Int {
func clock() -> Int { 2 }
return clock()
}
}
class BaseService {
let clock: () -> Date
init(clock: @escaping () -> Date) {
self.clock = clock
}
}
final class DerivedService: BaseService {
func refreshInheritedValue() -> Date {
clock()
}
}
final class LabeledService {
let first: Int = 1
func first(where value: Bool) -> Int {
value ? 2 : 0
}
func refreshLabeled() -> Int {
first(where: true)
}
}
class PrivateBase {
private let clock: () -> Int = { 1 }
}

View file

@ -0,0 +1,39 @@
import Foundation
extension Example {
static func makeValue(input: Int) -> Int {
input + 1
}
}
enum Other {
private static func makeValue(other: String) -> Int {
-1
}
static func clock() -> Date {
Date.distantPast
}
}
extension DerivedService {
func refreshInheritedFromExtension() -> Date {
clock()
}
}
extension BaseService {
func refreshOwnFromExtension() -> Date {
clock()
}
}
final class PrivateDerived: PrivateBase {
func clock() -> Int {
2
}
func refreshPrivateAncestor() -> Int {
clock()
}
}

View file

@ -0,0 +1 @@
export function importedHandler() { return 'imported'; }

View 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);

View 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);
}

View file

@ -0,0 +1,4 @@
export const tools = [
{ name: 'manifest_tool', description: 'Existing object manifest', inputSchema: {} },
{ name: 'read_file', description: 'Duplicate manifest entry', inputSchema: {} },
];

View file

@ -1,4 +1,5 @@
import type { FTSIndexDef } from '../helpers/test-indexed-db.js';
import { FTS_INDEXES } from '../../src/core/search/fts-schema.js';
export const LOCAL_BACKEND_SEED_DATA = [
// Files
@ -60,9 +61,8 @@ export const LOCAL_BACKEND_SEED_DATA = [
CREATE (c)-[:CodeRelation {type: 'HAS_METHOD', confidence: 1.0, reason: 'class-method', step: 0}]->(m)`,
];
export const LOCAL_BACKEND_FTS_INDEXES: FTSIndexDef[] = [
{ table: 'Function', indexName: 'function_fts', columns: ['name', 'content', 'description'] },
{ table: 'Class', indexName: 'class_fts', columns: ['name', 'content', 'description'] },
{ table: 'Method', indexName: 'method_fts', columns: ['name', 'content', 'description'] },
{ table: 'File', indexName: 'file_fts', columns: ['name', 'content'] },
];
// Healthy-backend fixtures must mirror the complete configured search index set.
// The old four-table subset is now correctly reported as partial FTS coverage.
export const LOCAL_BACKEND_FTS_INDEXES: FTSIndexDef[] = FTS_INDEXES.map(
({ table, indexName, properties }) => ({ table, indexName, columns: [...properties] }),
);

View file

@ -127,6 +127,14 @@
"captureGroups": 9,
"digest": "c4d442b67247406de3b1158b958f6f3e294f11c0bbb360ed32489582769a1187"
},
"swift-injected-closure-call/Caller.swift": {
"captureGroups": 69,
"digest": "6768d62722a6b173dbe953507fec53661ea6c9786a49d8edf7caedb0a3cef0e4"
},
"swift-injected-closure-call/Helpers.swift": {
"captureGroups": 48,
"digest": "6e6bfe83084bf189a7789702cfccb5fcc5753986b0de3c0d7f81fc3ae59270b2"
},
"swift-member-write-access/App.swift": {
"captureGroups": 14,
"digest": "7be68b7e510fefac99c94a4ebab3eb96556a0e587155b6c82b7602705e270987"

View file

@ -207,4 +207,26 @@ describe('context resource freshness — out-of-process analyze (#2438)', () =>
expect(result).toContain('symbols: 333');
expect(result).toContain('processes: 4');
});
it('does not claim divergence when disk metadata and the cached handle have no indexed commit', async function missingIndexedCommitDoesNotClaimDivergence() {
writeFileSync(path.join(repoPath, 'a.ts'), 'export const a = 1;\n');
runGit(repoPath, 'add', 'a.ts');
runGit(repoPath, 'commit', '-m', 'c1');
// A legacy metadata/registry entry can omit lastCommit despite RepoMeta's
// required field; seed that persisted shape rather than a symbolic ref.
await seedIndexedRepo(repoPath, storagePath, {
repoPath,
indexedAt: '2024-01-01T00:00:00Z',
stats: { files: 1, nodes: 1, processes: 0 },
} as RepoMeta);
const backend = new LocalBackend();
await backend.init();
expect((await backend.resolveRepo('test-repo')).lastCommit).toBeUndefined();
const result = await readResource('gitnexus://repo/test-repo/context', backend);
expect(result).not.toContain('staleness:');
expect(result).toContain(' commit: ""');
});
});

View file

@ -32,6 +32,7 @@ const REQUIRE_FTS = process.env.GITNEXUS_REQUIRE_FTS === '1';
type QueryResult = {
error?: unknown;
warning?: string;
partial?: boolean;
definitions?: Array<{ id: string }>;
process_symbols?: Array<{ id: string }>;
};
@ -40,7 +41,8 @@ const matchedIds = (r: QueryResult): string[] =>
[...(r.process_symbols ?? []), ...(r.definitions ?? [])].map((s) => s.id);
const ftsMissing = (r: QueryResult): boolean =>
typeof r.warning === 'string' && /FTS indexes missing/i.test(r.warning);
typeof r.warning === 'string' &&
/FTS indexes missing|missing configured indexes/i.test(r.warning);
/**
* Poll the SAME warm `LocalBackend` until it stops reporting FTS-missing, or
@ -90,14 +92,14 @@ describe('warm MCP session observes an in-place --repair-fts rebuild (#2767)', (
await tmpHandle.cleanup();
});
it(
'a warm session transitions from FTS-unavailable to FTS-available without restarting, after an out-of-band --repair-fts',
it.each(['all', 'Function'] as const)(
'a warm session repairs missing %s indexes without restarting',
{ timeout: 60_000 },
async (ctx) => {
async (missing, ctx) => {
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
const { createSearchFTSIndexes } = await import('../../src/core/search/fts-indexes.js');
// ── Step 1: build the index WITHOUT FTS (analyzed before repair) ────
// ── Step 1: build an index with all or just one FTS index missing ──
await adapter.initLbug(lbugPath);
const ftsAvailable = await adapter.loadFTSExtension(undefined, {
@ -118,6 +120,10 @@ describe('warm MCP session observes an in-place --repair-fts rebuild (#2767)', (
await adapter.executeQuery(
`CREATE (n:Function {id: 'func:login', name: 'login', filePath: 'src/auth.ts', startLine: 1, endLine: 3, content: 'function login() { return true; }'})`,
);
if (missing === 'Function') {
await createSearchFTSIndexes();
await adapter.dropFTSIndex('Function', 'function_fts');
}
await adapter.flushWAL();
await adapter.closeLbug();
@ -129,7 +135,10 @@ describe('warm MCP session observes an in-place --repair-fts rebuild (#2767)', (
stats: { files: 1, nodes: 1 },
capabilities: {
graph: { provider: 'ladybugdb', status: 'available' },
fts: { provider: 'ladybugdb-fts', status: 'unavailable' },
fts: {
provider: 'ladybugdb-fts',
status: missing === 'all' ? 'unavailable' : 'available',
},
vectorSearch: { provider: 'exact-scan', status: 'unavailable', exactScanLimit: 0 },
},
};
@ -142,6 +151,10 @@ describe('warm MCP session observes an in-place --repair-fts rebuild (#2767)', (
const before = await backend.callTool('query', { query: 'login' });
expect(before.error).toBeUndefined();
expect(ftsMissing(before)).toBe(true);
if (missing === 'Function') {
expect(before.partial).toBe(true);
expect(before.warning).toContain('Function.function_fts');
}
// ── Step 3: out-of-band --repair-fts (separate writable session) ────
// Same production functions the repair-fts branch of runFullAnalysis
@ -163,6 +176,7 @@ describe('warm MCP session observes an in-place --repair-fts rebuild (#2767)', (
const after = await waitForFtsRecognized(backend, 'login');
expect(after.error).toBeUndefined();
expect(ftsMissing(after)).toBe(false);
expect(after.partial).toBeUndefined();
expect(matchedIds(after)).toContain('func:login');
},
);

View file

@ -359,6 +359,95 @@ describe.skipIf(!swiftAvailable)('Swift protocol-extension implicit self (#3273)
});
});
describe.skipIf(!swiftAvailable)('Swift injected closure property call (#3425)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'swift-injected-closure-call'),
() => {},
);
}, 60000);
it('does not resolve an injected closure call to an unrelated method', () => {
expect(
getNodesByLabelFull(result, 'Property').some(
(node) => node.name === 'clock' && node.properties.filePath === 'Caller.swift',
),
).toBe(true);
expect(
getNodesByLabelFull(result, 'Function').some(
(node) => node.name === 'clock' && node.properties.filePath === 'Helpers.swift',
),
).toBe(true);
const calls = getRelationships(result, 'CALLS').filter((c) => c.source === 'refreshValue');
expect(calls.filter((c) => c.target === 'clock')).toEqual([]);
});
it('does not resolve an inherited closure property call to the unrelated method', () => {
const extendsEdges = getRelationships(result, 'EXTENDS');
expect(
extendsEdges.some(
(edge) => edge.source === 'DerivedService' && edge.target === 'BaseService',
),
).toBe(true);
const calls = getRelationships(result, 'CALLS').filter(
(c) => c.source === 'refreshInheritedValue',
);
expect(calls.filter((c) => c.target === 'clock')).toEqual([]);
});
it('keeps inherited closure calls in extensions unlinked', () => {
expect(
getNodesByLabelFull(result, 'Function').some(
(node) =>
node.name === 'refreshInheritedFromExtension' &&
node.properties.filePath === 'Helpers.swift',
),
).toBe(true);
const calls = getRelationships(result, 'CALLS').filter(
(c) => c.source === 'refreshInheritedFromExtension',
);
expect(calls.filter((c) => c.target === 'clock')).toEqual([]);
});
it('keeps same-type closure calls in extensions unlinked', () => {
const calls = getRelationships(result, 'CALLS').filter(
(c) => c.source === 'refreshOwnFromExtension',
);
expect(calls.filter((c) => c.target === 'clock')).toEqual([]);
});
it('still resolves the concrete-type extension call', () => {
const calls = getRelationships(result, 'CALLS').filter((c) => c.source === 'runScenario');
expect(calls.map((c) => c.rel.targetId)).toEqual([
'Function:Helpers.swift:Example.makeValue#1',
]);
});
it('keeps a nested function that shadows the stored closure', () => {
const calls = getRelationships(result, 'CALLS').filter(
(c) => c.source === 'refreshWithLocalClock',
);
expect(calls.filter((c) => c.target === 'clock')).toHaveLength(1);
expect(calls.find((c) => c.target === 'clock')?.targetFilePath).toBe('Caller.swift');
});
it('keeps a labeled method selected alongside a same-name property', () => {
const calls = getRelationships(result, 'CALLS').filter((c) => c.source === 'refreshLabeled');
expect(calls.map((c) => c.target)).toContain('first');
expect(calls.find((c) => c.target === 'first')?.targetFilePath).toBe('Caller.swift');
});
it('keeps a derived method despite an inaccessible ancestor property', () => {
const calls = getRelationships(result, 'CALLS').filter(
(c) => c.source === 'refreshPrivateAncestor',
);
expect(calls.map((c) => c.target)).toContain('clock');
expect(calls.find((c) => c.target === 'clock')?.targetFilePath).toBe('Helpers.swift');
});
});
// ---------------------------------------------------------------------------
// Constructor fallback: Swift constructors look like free function calls
// (no `new` keyword). The resolver retries with constructor form when

View 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);
});

View file

@ -647,3 +647,125 @@ describe('BM25 search', () => {
});
});
});
describe('FTS index completeness', () => {
beforeEach(async () => {
resetExtensionState();
mockExecuteParameterized.mockReset();
const { queryFTS } = await import('../../src/core/lbug/lbug-adapter.js');
vi.mocked(queryFTS).mockReset();
});
const missing = (table: string, indexName: string) =>
new Error(`Binder exception: Table ${table} doesn't have an index with name ${indexName}.`);
for (const pooled of [false, true]) {
const mode = pooled ? 'pool' : 'core';
const repo = pooled ? 'completeness-test' : undefined;
async function useOutcome(outcome: (table: string, indexName: string) => Promise<any[]>) {
const { queryFTS } = await import('../../src/core/lbug/lbug-adapter.js');
vi.mocked(queryFTS).mockImplementation((table, indexName) => outcome(table, indexName));
mockExecuteParameterized.mockImplementation(async (_repo: string, cypher: string) => {
const match = cypher.match(/QUERY_FTS_INDEX\('([^']+)', '([^']+)'/);
return outcome(match![1], match![2]);
});
}
it(`${mode}: distinguishes healthy zero matches from missing indexes`, async () => {
await useOutcome(async () => []);
const result = await searchFTSFromLbug('no matches', 5, repo);
expect(result).toEqual({ results: [], ftsAvailable: true });
});
it(`${mode}: reports one missing index while retaining successful matches`, async () => {
await useOutcome(async (table, indexName) => {
if (table === 'Function') throw missing(table, indexName);
if (table !== 'File') return [];
return pooled
? [{ node: { filePath: 'src/auth.ts', id: 'file:auth' }, score: 3 }]
: [{ filePath: 'src/auth.ts', nodeId: 'file:auth', score: 3 }];
});
const result = await searchFTSFromLbug('auth', 5, repo);
expect(result.ftsAvailable).toBe(true);
expect(result.results[0]).toMatchObject({ filePath: 'src/auth.ts', score: 3 });
expect(result.missingIndexes).toEqual(['Function.function_fts']);
expect(result.nonBenignErrors).toBeUndefined();
});
it(`${mode}: reports all missing indexes without claiming FTS is available`, async () => {
await useOutcome(async (table, indexName) => {
throw missing(table, indexName);
});
const result = await searchFTSFromLbug('auth', 5, repo);
expect(result.ftsAvailable).toBe(false);
expect(result.results).toEqual([]);
if (!pooled) {
const { queryFTS } = await import('../../src/core/lbug/lbug-adapter.js');
for (const { table, indexName } of FTS_INDEXES) {
expect(queryFTS).toHaveBeenCalledWith(table, indexName, 'auth', 5, false, 'throw');
}
}
expect(result.missingIndexes).toEqual(
FTS_INDEXES.map(({ table, indexName }) => `${table}.${indexName}`),
);
expect(result.nonBenignErrors).toBeUndefined();
});
it(`${mode}: preserves redacted real errors alongside missing-index diagnostics`, async () => {
await useOutcome(async (table, indexName) => {
if (table === 'Function') throw missing(table, indexName);
if (table === 'Class')
throw new Error('connection reset at /home/alice/private/index.lbug');
return [];
});
const result = await searchFTSFromLbug('auth', 5, repo);
expect(result.ftsAvailable).toBe(true);
expect(result.missingIndexes).toEqual(['Function.function_fts']);
expect(result.nonBenignErrors).toHaveLength(1);
expect(result.nonBenignErrors![0]).toContain('connection reset');
expect(JSON.stringify(result)).not.toContain('/home/alice');
});
it(`${mode}: retains both failure causes when no configured index query succeeds`, async () => {
await useOutcome(async (table, indexName) => {
if (table === 'Function') throw missing(table, indexName);
throw new Error('connection reset at /home/alice/private/index.lbug');
});
const result = await searchFTSFromLbug('auth', 5, repo);
expect(result.ftsAvailable).toBe(false);
expect(result.results).toEqual([]);
expect(result.missingIndexes).toEqual(['Function.function_fts']);
expect(result.nonBenignErrors).toEqual(
Array(FTS_INDEXES.length - 1).fill('connection reset at <path>'),
);
});
it(`${mode}: clears missing-index diagnostics after a repaired query`, async () => {
await useOutcome(async (table, indexName) => {
if (table === 'Function') throw missing(table, indexName);
return [];
});
expect((await searchFTSFromLbug('auth', 5, repo)).missingIndexes).toEqual([
'Function.function_fts',
]);
await useOutcome(async () => []);
expect(await searchFTSFromLbug('auth', 5, repo)).toEqual({ results: [], ftsAvailable: true });
});
it(`${mode}: explicit opt-out makes no queries and emits no missing-index diagnosis`, async () => {
await useOutcome(async (table, indexName) => {
throw missing(table, indexName);
});
expect(await searchFTSFromLbug('auth', 5, repo, 'disabled-by-flag')).toEqual({
results: [],
ftsAvailable: false,
});
const { queryFTS } = await import('../../src/core/lbug/lbug-adapter.js');
expect(queryFTS).not.toHaveBeenCalled();
expect(mockExecuteParameterized).not.toHaveBeenCalled();
});
}
});

View file

@ -27,6 +27,29 @@ describe('formatIndexStatusCell', () => {
);
});
it.each([0, 3])(
'renders a stale diverged row without a commits-behind count of %i',
(commitsBehind) => {
const row = { indexStale: true, commitsBehind, status: 'diverged' as const };
expect(formatIndexStatusCell(row)).toBe('STALE (index differs from HEAD)');
},
);
it('renders an explicit behind status with its counted gap', () => {
const row = { indexStale: true, commitsBehind: 3, status: 'behind' as const };
expect(formatIndexStatusCell(row)).toBe('STALE (3 commits behind)');
});
it('renders an explicit current status as OK', () => {
const row = { indexStale: false, commitsBehind: 0, status: 'current' as const };
expect(formatIndexStatusCell(row)).toBe('OK ');
});
it('preserves the fail-open cell when a diverged probe did not confirm staleness', () => {
const row = { indexStale: false, commitsBehind: 0, status: 'diverged' as const };
expect(formatIndexStatusCell(row)).toBe('OK ');
});
it('keeps the OK cell byte-identical, padding included', () => {
expect(formatIndexStatusCell({ indexStale: false, commitsBehind: 0 })).toBe('OK ');
});

View file

@ -2,13 +2,17 @@ import { describe, it, expect, vi } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { execFileSync } from 'node:child_process';
import {
GroupService,
type GroupToolPort,
type GroupRepoHandle,
} from '../../../src/core/group/service.js';
import { writeContractRegistry } from '../../../src/core/group/storage.js';
import { formatIndexStatusCell } from '../../../src/cli/group-status-format.js';
import {
formatIndexStatusCell,
type GroupRepoIndexRow,
} from '../../../src/cli/group-status-format.js';
import type { ContractRegistry, StoredContract, CrossLink } from '../../../src/core/group/types.js';
function makeTmpGroup(): { tmpDir: string; groupDir: string; cleanup: () => void } {
@ -717,10 +721,7 @@ repos:
const svc = new GroupService(port);
const result = (await svc.groupStatus({ name: 'test-group' })) as {
repos: Record<
string,
{ indexStale: boolean; commitsBehind?: number; status?: string; missing: boolean }
>;
repos: Record<string, GroupRepoIndexRow & { missing: boolean }>;
};
const row = result.repos['app/backend'];
@ -736,5 +737,67 @@ repos:
cleanup();
}
});
it('renders a real rollback as an index that differs from HEAD', async () => {
const { cleanup, tmpDir } = makeTmpGroup();
try {
vi.stubEnv('GITNEXUS_HOME', tmpDir);
const repoPath = path.join(tmpDir, 'rollback');
fs.mkdirSync(repoPath);
const git = (...args: string[]): string =>
execFileSync(
'git',
[
'-c',
'user.email=t@example.com',
'-c',
'user.name=T',
'-c',
'commit.gpgsign=false',
...args,
],
{ cwd: repoPath, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] },
).trim();
git('init', '-q', '--initial-branch=main');
git('commit', '--allow-empty', '-qm', 'first');
const firstCommit = git('rev-parse', 'HEAD');
git('commit', '--allow-empty', '-qm', 'indexed');
const indexedCommit = git('rev-parse', 'HEAD');
git('checkout', '-q', '--detach', firstCommit);
const storagePath = path.join(repoPath, '.gitnexus');
fs.mkdirSync(storagePath);
fs.writeFileSync(
path.join(storagePath, 'gitnexus.json'),
JSON.stringify({ lastCommit: indexedCommit, indexedAt: '2026-01-01T00:00:00.000Z' }),
);
const port = makePort({
resolveRepo: vi.fn(
async (name?: string): Promise<GroupRepoHandle> => ({
id: name || 'test',
name: name || 'test',
repoPath,
storagePath,
}),
),
});
const svc = new GroupService(port);
const result = (await svc.groupStatus({ name: 'test-group' })) as {
repos: Record<string, GroupRepoIndexRow & { missing: boolean }>;
};
const row = result.repos['app/backend'];
expect(row).toMatchObject({
missing: false,
indexStale: true,
commitsBehind: 0,
status: 'diverged',
});
expect(formatIndexStatusCell(row)).toBe('STALE (index differs from HEAD)');
} finally {
vi.unstubAllEnvs();
cleanup();
}
});
});
});

View file

@ -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);
}

View file

@ -42,6 +42,7 @@ vi.mock('../../src/storage/repo-manager.js', async (importOriginal) => {
});
import { LocalBackend } from '../../src/mcp/local/local-backend';
import { resetExtensionState } from '../../src/core/lbug/extension-loader.js';
// A backend whose hybrid search yields exactly one matched symbol, so the
// enrichment chunk loop runs and can be made to fail. `ftsUsed` is parameterized
@ -415,3 +416,100 @@ describe('query: degraded-enrichment signal', () => {
}
});
});
describe('query: partial missing FTS indexes', () => {
beforeEach(() => {
vi.clearAllMocks();
loadMetaMock.mockResolvedValue(null);
executeParameterizedMock.mockResolvedValue([]);
});
afterEach(() => vi.unstubAllEnvs());
it('returns successful symbols with a repair hint and partial flag when another index is missing', async () => {
const b = makeBackend(true) as any;
const resultWithMissing = await b.backend.bm25Search();
b.backend.bm25Search.mockResolvedValue({
...resultWithMissing,
missingIndexes: ['Function.function_fts'],
});
const result = await runQuery(b);
expect(result.definitions.map((d: any) => d.id)).toContain('func:x');
expect(result.partial).toBe(true);
expect(result.warning).toContain('Function.function_fts');
expect(result.warning).toContain('repair-fts');
});
it('composes missing indexes with real FTS errors and enrichment failures', async () => {
const b = makeBackend(true, ['connection reset']) as any;
const resultWithMissing = await b.backend.bm25Search();
b.backend.bm25Search.mockResolvedValue({
...resultWithMissing,
missingIndexes: ['Function.function_fts'],
});
executeParameterizedMock.mockImplementation(async (_repo: string, cypher: string) => {
if (cypher.includes('STEP_IN_PROCESS')) throw new Error('timed out');
return [];
});
const result = await runQuery(b);
expect(result.partial).toBe(true);
expect(result.warning).toContain('Function.function_fts');
expect(result.warning).toContain('FTS keyword search partially failed');
expect(result.warning).toContain('enrichment');
});
});
it('propagates missing-index diagnostics through the real bm25Search helper into query', async () => {
vi.clearAllMocks();
loadMetaMock.mockResolvedValue(null);
executeParameterizedMock.mockResolvedValue([]);
const search = await import('../../src/core/search/bm25-index.js');
const spy = vi.spyOn(search, 'searchFTSFromLbug').mockResolvedValue({
results: [],
ftsAvailable: true,
missingIndexes: ['Function.function_fts'],
});
try {
const b = makeBackend(true) as any;
b.backend.bm25Search = (LocalBackend.prototype as any).bm25Search;
const result = await runQuery(b);
expect(result.partial).toBe(true);
expect(result.warning).toContain('Function.function_fts');
} finally {
spy.mockRestore();
}
});
it('reports missing indexes and real errors when every FTS query fails through the search boundary', async () => {
vi.clearAllMocks();
resetExtensionState();
loadMetaMock.mockResolvedValue(null);
executeParameterizedMock.mockImplementation(async (_repo: string, cypher: string) => {
if (cypher.includes("QUERY_FTS_INDEX('Function'")) {
throw new Error(
"Binder exception: Table Function doesn't have an index with name function_fts.",
);
}
if (cypher.includes('QUERY_FTS_INDEX')) {
throw new Error('connection reset at /home/alice/private/index.lbug');
}
return [];
});
const b = makeBackend(false) as any;
b.backend.bm25Search = (LocalBackend.prototype as any).bm25Search;
const result = await runQuery(b);
expect(result.definitions).toEqual([]);
expect(result.warning).toContain('FTS keyword search failed');
expect(result.warning).toContain('connection reset at <path>');
expect(result.warning).toContain('Function.function_fts');
expect(result.warning).toContain('gitnexus analyze --repair-fts');
expect(result.warning).toContain('resolved: repo1');
expect(result.warning).not.toContain('not a missing-index');
expect(result.warning).not.toContain('partially failed');
expect(JSON.stringify(result)).not.toContain('/home/alice');
});

View file

@ -0,0 +1,114 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
const native = vi.hoisted(() => ({
error: undefined as string | undefined,
params: [] as unknown[],
}));
// Control the native statement result, while exercising the real adapter's
// connection lock, prepared-query path and missing-index error handling.
vi.mock('@ladybugdb/core', () => {
const emptyResult = {
getAll: async () => [],
close: async () => {},
isSuccess: () => true,
getErrorMessage: async () => '',
};
class Database {
async init() {}
async close() {}
}
class Connection {
async query() {
return emptyResult;
}
async prepare() {
return {
isSuccess: () => native.error === undefined,
getErrorMessage: async () => native.error ?? '',
};
}
async execute(_statement: unknown, params: unknown) {
native.params.push(params);
return emptyResult;
}
async close() {}
}
const mod = { Database, Connection };
return { ...mod, default: mod, lbug: mod };
});
import { closeLbug, queryFTS, withLbugDb } from '../../src/core/lbug/lbug-adapter.js';
const MISSING = "Binder exception: Table Function doesn't have an index with name function_fts.";
describe('queryFTS missing-index diagnostics', () => {
let dir: string;
let dbPath: string;
beforeEach(async () => {
native.error = undefined;
native.params = [];
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-test-'));
dbPath = path.join(dir, 'lbug');
await fs.writeFile(dbPath, '');
});
afterEach(async () => {
await closeLbug();
await fs.rm(dir, { recursive: true, force: true });
});
it('retains the existing empty-result default for a missing index', async () => {
await withLbugDb(
dbPath,
async () => {
native.error = MISSING;
expect(await queryFTS('Function', 'function_fts', 'auth')).toEqual([]);
},
{ readOnly: true, skipFts: true },
);
});
it('can propagate a missing index so search does not count it as a successful zero-match query', async () => {
await withLbugDb(
dbPath,
async () => {
native.error = MISSING;
await expect(
queryFTS('Function', 'function_fts', 'auth', 5, false, 'throw'),
).rejects.toThrow(MISSING);
},
{ readOnly: true, skipFts: true },
);
});
it('preserves prepared query binding and successful empty results in diagnostic mode', async () => {
await withLbugDb(
dbPath,
async () => {
const query = "auth' DELETE n";
expect(await queryFTS('Function', 'function_fts', query, 5, false, 'throw')).toEqual([]);
expect(native.params).toEqual([{ query }]);
},
{ readOnly: true, skipFts: true },
);
});
it('still propagates real failures in either missing-index mode', async () => {
await withLbugDb(
dbPath,
async () => {
native.error = 'Runtime exception: connection reset';
await expect(queryFTS('Function', 'function_fts', 'auth')).rejects.toThrow(
'connection reset',
);
await expect(
queryFTS('Function', 'function_fts', 'auth', 5, false, 'throw'),
).rejects.toThrow('connection reset');
},
{ readOnly: true, skipFts: true },
);
});
});

View file

@ -0,0 +1,267 @@
import {
buildDefIndex,
buildMethodDispatchIndex,
buildQualifiedNameIndex,
buildScopeTree,
type Scope,
type SymbolDefinition,
} from 'gitnexus-shared';
import { describe, expect, it } from 'vitest';
import { swiftIsCallableVisibleFromCaller } from '../../../../src/core/ingestion/languages/swift/callable-visibility.js';
import type { ScopeResolutionIndexes } from '../../../../src/core/ingestion/model/scope-resolution-indexes.js';
const filePath = 'Service.swift';
const moduleRange = { startLine: 1, startCol: 0, endLine: 12, endCol: 0 };
const classRange = { startLine: 2, startCol: 0, endLine: 10, endCol: 0 };
const functionRange = { startLine: 5, startCol: 0, endLine: 8, endCol: 0 };
function scope(
id: string,
parent: string | null,
kind: Scope['kind'],
ownedDefs: SymbolDefinition[],
range: Scope['range'],
): Scope {
return {
id,
parent,
kind,
range,
filePath,
ownedDefs,
bindings: new Map(),
imports: [],
typeBindings: new Map(),
};
}
const classDef: SymbolDefinition = {
nodeId: 'Service',
filePath,
type: 'Class',
qualifiedName: 'Service',
};
const property: SymbolDefinition = {
nodeId: 'Service.clock',
filePath,
type: 'Property',
qualifiedName: 'Service.clock',
ownerId: 'Service',
};
const method: SymbolDefinition = {
nodeId: 'Service.refresh',
filePath,
type: 'Method',
qualifiedName: 'Service.refresh',
ownerId: 'Service',
};
const scopes = {
scopeTree: buildScopeTree([
scope('module', null, 'Module', [], moduleRange),
scope('class', 'module', 'Class', [classDef, property], classRange),
scope('function', 'class', 'Function', [method], functionRange),
]),
defs: buildDefIndex([classDef, property, method]),
qualifiedNames: buildQualifiedNameIndex([classDef, property, method]),
methodDispatch: buildMethodDispatchIndex({
owners: [classDef.nodeId],
computeMro: () => [],
implementsOf: () => [],
}),
} as ScopeResolutionIndexes;
describe('Swift caller-side callable visibility', () => {
it('rejects an unrelated same-name method shadowed by a stored property', () => {
expect(
swiftIsCallableVisibleFromCaller({
candidate: {
nodeId: 'Other.clock',
filePath: 'Other.swift',
type: 'Method',
qualifiedName: 'Other.clock',
},
callerScope: 'function',
callArity: 0,
scopes,
}),
).toBe(false);
});
it('keeps differently named methods and unknown caller scopes eligible', () => {
const candidate: SymbolDefinition = {
nodeId: 'Other.run',
filePath: 'Other.swift',
type: 'Method',
qualifiedName: 'Other.run',
};
expect(
swiftIsCallableVisibleFromCaller({
candidate,
callerScope: 'function',
callArity: 0,
scopes,
}),
).toBe(true);
expect(
swiftIsCallableVisibleFromCaller({
candidate: { ...candidate, qualifiedName: 'Other.clock' },
}),
).toBe(true);
});
it('preserves a selected local function before checking the enclosing property', () => {
const local: SymbolDefinition = {
nodeId: 'local.clock',
filePath,
type: 'Function',
qualifiedName: 'Service.refresh.clock',
};
const localScopes = {
...scopes,
scopeTree: buildScopeTree([
scope('module', null, 'Module', [], moduleRange),
scope('class', 'module', 'Class', [classDef, property], classRange),
scope('function', 'class', 'Function', [method], functionRange),
scope('local', 'function', 'Function', [local], {
startLine: 6,
startCol: 0,
endLine: 7,
endCol: 0,
}),
]),
} as ScopeResolutionIndexes;
expect(
swiftIsCallableVisibleFromCaller({
candidate: local,
callerParsed: {
filePath,
moduleScope: 'module',
scopes: [...localScopes.scopeTree.byId.values()],
localDefs: [classDef, property, method, local],
parsedImports: [],
referenceSites: [],
},
callerScope: 'function',
callArity: 0,
scopes: localScopes,
}),
).toBe(true);
});
it('preserves a selected method owned by the current type', () => {
expect(
swiftIsCallableVisibleFromCaller({
candidate: { ...method, nodeId: 'Service.clock', qualifiedName: 'Service.clock' },
callerScope: 'function',
callArity: 0,
scopes,
}),
).toBe(true);
});
it('does not veto a call with arguments or unknown arity', () => {
const candidate: SymbolDefinition = {
nodeId: 'Other.clock',
filePath: 'Other.swift',
type: 'Method',
qualifiedName: 'Other.clock',
};
expect(
swiftIsCallableVisibleFromCaller({
candidate,
callerScope: 'function',
callArity: 1,
scopes,
}),
).toBe(true);
expect(swiftIsCallableVisibleFromCaller({ candidate, callerScope: 'function', scopes })).toBe(
true,
);
});
it('rejects a decoy when a superclass owns the closure property', () => {
const base: SymbolDefinition = {
nodeId: 'BaseService',
filePath,
type: 'Class',
qualifiedName: 'BaseService',
};
const derived: SymbolDefinition = {
nodeId: 'DerivedService',
filePath,
type: 'Class',
qualifiedName: 'DerivedService',
};
const inheritedProperty: SymbolDefinition = {
nodeId: 'BaseService.clock',
filePath,
type: 'Property',
qualifiedName: 'BaseService.clock',
ownerId: base.nodeId,
};
const inheritedScopes = {
scopeTree: buildScopeTree([
scope('derivedModule', null, 'Module', [], moduleRange),
scope('derivedClass', 'derivedModule', 'Class', [derived], classRange),
scope('derivedFunction', 'derivedClass', 'Function', [method], functionRange),
]),
defs: buildDefIndex([base, derived, inheritedProperty]),
qualifiedNames: buildQualifiedNameIndex([base, derived, inheritedProperty]),
methodDispatch: buildMethodDispatchIndex({
owners: [derived.nodeId],
computeMro: () => [base.nodeId],
implementsOf: () => [],
}),
} as ScopeResolutionIndexes;
expect(
swiftIsCallableVisibleFromCaller({
candidate: {
nodeId: 'Other.clock',
filePath: 'Other.swift',
type: 'Method',
qualifiedName: 'Other.clock',
},
callerScope: 'derivedFunction',
callArity: 0,
scopes: inheritedScopes,
}),
).toBe(false);
const extensionScopes = {
...inheritedScopes,
bindings: new Map([
[
'extensionModule',
new Map([['DerivedService', [{ def: derived, origin: 'local' as const }]]]),
],
]),
bindingAugmentations: new Map(),
scopeTree: buildScopeTree([
scope('extensionModule', null, 'Module', [], moduleRange),
scope('extensionClass', 'extensionModule', 'Class', [], classRange),
{
...scope('extensionFunction', 'extensionClass', 'Function', [method], functionRange),
typeBindings: new Map([
[
'self',
{ rawName: 'DerivedService', declaredAtScope: 'extensionFunction', source: 'self' },
],
]),
},
]),
} as ScopeResolutionIndexes;
expect(
swiftIsCallableVisibleFromCaller({
candidate: {
nodeId: 'Other.clock',
filePath: 'Other.swift',
type: 'Method',
qualifiedName: 'Other.clock',
},
callerScope: 'extensionFunction',
callArity: 0,
scopes: extensionScopes,
}),
).toBe(false);
});
});

View file

@ -3,7 +3,7 @@
* OTHER than a timeout. `staleness.test.ts` reaches `diverged` and `unknown`
* against real repositories, but not the third arm of `fromHead`: HEAD still
* resolves to the indexed commit, so the index is at HEAD however `rev-list`
* failed. Real git cannot fail `<sha>..HEAD` while HEAD prints that same SHA
* failed. Real git cannot fail `<sha>...HEAD` while HEAD prints that same SHA
* without a corrupted object store, so this drives it through a mock.
*
* Its own file for the same reason as `staleness-timeout.test.ts`: the mock
@ -12,14 +12,21 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { plan, spawnedArgs } = vi.hoisted(() => ({
plan: { head: null as string | null },
plan: { head: null as string | null, enoent: false },
spawnedArgs: [] as string[][],
}));
// `rev-list` exits 128 the way git does for a missing object; `rev-parse HEAD`
// answers `plan.head`, or fails when it is null.
// answers `plan.head`, or fails when it is null. `plan.enoent` instead fails
// every invocation the way Node reports a missing executable (git not on
// PATH) — no exit code, `code: 'ENOENT'` — to check that shape of failure is
// caught by the same `catch` as a present-but-failing git (#3127).
const answer = (args: readonly string[]): { error: Error | null; stdout: string } => {
spawnedArgs.push([...args]);
if (plan.enoent) {
const failure = Object.assign(new Error('spawn git ENOENT'), { code: 'ENOENT' });
return { error: failure, stdout: '' };
}
if (args[0] === 'rev-parse' && plan.head) return { error: null, stdout: `${plan.head}\n` };
const failure = Object.assign(new Error(`Command failed: git ${args.join(' ')}`), {
code: 128,
@ -54,7 +61,7 @@ vi.mock('node:child_process', async (importOriginal) => {
import { checkStaleness, checkStalenessAsync } from '../../src/core/git-staleness.js';
const INDEXED_COMMIT = 'a'.repeat(40);
const REV_LIST = ['rev-list', '--count', `${INDEXED_COMMIT}..HEAD`];
const REV_LIST = ['rev-list', '--left-right', '--count', `${INDEXED_COMMIT}...HEAD`];
const REV_PARSE = ['rev-parse', 'HEAD'];
const bothHelpers = {
@ -65,6 +72,7 @@ const bothHelpers = {
describe('staleness after a failed (not timed-out) rev-list (#3256)', () => {
beforeEach(() => {
plan.head = null;
plan.enoent = false;
spawnedArgs.length = 0;
});
@ -95,6 +103,19 @@ describe('staleness after a failed (not timed-out) rev-list (#3256)', () => {
expect(result).toEqual({ isStale: false, commitsBehind: 0, status: 'unknown' });
expect(spawnedArgs).toEqual([REV_LIST, REV_PARSE]);
});
it('reports unknown — never current — when git itself is not on PATH', async () => {
// nikolai-vysotskyi (#3127): "git not on PATH" specifically, as a
// distinct failure shape (ENOENT, no exit code) from a present git
// that fails against a bad ref. Same requirement either way: it must
// never be silently read as fresh.
plan.enoent = true;
const result = await check('/repo', INDEXED_COMMIT);
expect(result).toEqual({ isStale: false, commitsBehind: 0, status: 'unknown' });
expect(spawnedArgs).toEqual([REV_LIST, REV_PARSE]);
});
});
}
});

View file

@ -43,6 +43,8 @@ describe('checkStalenessAsync — timed-out rev-list (#3256)', () => {
expect(result).toEqual({ status: 'unknown', isStale: false, commitsBehind: 0 });
// Exactly one spawn. A `rev-parse HEAD` follow-up here is the regression:
// it doubles the hung-mount bound and can flip this answer.
expect(spawnedArgs).toEqual([['rev-list', '--count', `${INDEXED_COMMIT}..HEAD`]]);
expect(spawnedArgs).toEqual([
['rev-list', '--left-right', '--count', `${INDEXED_COMMIT}...HEAD`],
]);
});
});

View file

@ -0,0 +1,162 @@
/**
* Count both sides in one Git process to distinguish freshness from rollback
* without mixing HEAD snapshots (#3127).
* Keep the child-process mock isolated from tests that use real repositories.
*/
import type { ExecFileOptions } from 'node:child_process';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { plan, invocations } = vi.hoisted(() => ({
plan: { counts: '0\t0\n' as string | null, head: 'failure', advanceHead: false },
invocations: [] as { args: string[]; options: ExecFileOptions }[],
}));
const answer = (args: readonly string[], options: ExecFileOptions): string => {
invocations.push({ args: [...args], options });
if (args[0] === 'rev-list') {
if (plan.counts === null) {
throw Object.assign(new Error('Command failed: git rev-list'), { code: 128, killed: false });
}
// Simulate a checkout/commit after Git measured the relationship. A second
// process would see this different HEAD and could misclassify the result.
if (plan.advanceHead) plan.head = 'b'.repeat(40);
return plan.counts;
}
if (plan.head === 'empty') return ' \n';
if (plan.head === 'timeout') {
throw Object.assign(new Error('Command failed: git rev-parse HEAD'), {
killed: true,
signal: 'SIGTERM',
});
}
if (plan.head === 'failure') {
throw Object.assign(new Error('Command failed: git rev-parse HEAD'), {
code: 128,
killed: false,
});
}
return `${plan.head}\n`;
};
vi.mock('node:child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:child_process')>();
const { promisify } = await import('node:util');
const execFile = (
_file: string,
args: readonly string[],
options: ExecFileOptions,
callback: (error: Error | null, stdout: string, stderr: string) => void,
): void => {
try {
callback(null, answer(args, options), '');
} catch (error) {
callback(error as Error, '', '');
}
};
// Real execFile has a custom promisifier returning both output streams.
Object.defineProperty(execFile, promisify.custom, {
value: async (_file: string, args: readonly string[], options: ExecFileOptions) => ({
stdout: answer(args, options),
stderr: '',
}),
});
const execFileSync = (_file: string, args: readonly string[], options: ExecFileOptions): string =>
answer(args, options);
return {
...actual,
execFile: execFile as unknown as typeof actual.execFile,
execFileSync: execFileSync as unknown as typeof actual.execFileSync,
};
});
import { checkStaleness, checkStalenessAsync } from '../../src/core/git-staleness.js';
const INDEXED_COMMIT = 'a'.repeat(40);
const REV_LIST = ['rev-list', '--left-right', '--count', `${INDEXED_COMMIT}...HEAD`];
const REV_PARSE = ['rev-parse', 'HEAD'];
const bothHelpers = {
checkStaleness: async (repo: string, lastCommit: string) => checkStaleness(repo, lastCommit),
checkStalenessAsync,
};
describe('staleness from one relationship query (#3127)', () => {
beforeEach(() => {
plan.counts = '0\t0\n';
plan.head = 'failure';
plan.advanceHead = false;
invocations.length = 0;
});
for (const [name, check] of Object.entries(bothHelpers)) {
describe(name, () => {
it.each([
{ counts: '0\t0\n', status: 'current', isStale: false, commitsBehind: 0 },
{ counts: '2\t0\n', status: 'diverged', isStale: true, commitsBehind: 0 },
{ counts: '0\t3\n', status: 'behind', isStale: true, commitsBehind: 3 },
{ counts: '2\t3\n', status: 'behind', isStale: true, commitsBehind: 3 },
])('reports $status from $counts in one process', async ({ counts, ...expected }) => {
plan.counts = counts;
const result = await check('/repo', INDEXED_COMMIT);
expect(result).toMatchObject(expected);
expect(invocations.map(({ args }) => args)).toEqual([REV_LIST]);
});
it('keeps the count snapshot when HEAD advances after the query', async () => {
plan.head = INDEXED_COMMIT;
plan.advanceHead = true;
const result = await check('/repo', INDEXED_COMMIT);
expect(plan.head).not.toBe(INDEXED_COMMIT);
expect(result).toEqual({ status: 'current', isStale: false, commitsBehind: 0 });
expect(invocations.map(({ args }) => args)).toEqual([REV_LIST]);
});
it.each(['', '0\n', '0\tbogus\n'])(
'reports unknown for malformed counts %j',
async (counts) => {
plan.counts = counts;
const result = await check('/repo', INDEXED_COMMIT);
expect(result).toEqual({ status: 'unknown', isStale: false, commitsBehind: 0 });
expect(invocations.map(({ args }) => args)).toEqual([REV_LIST]);
},
);
it('reports unknown when the follow-up HEAD command fails', async () => {
plan.counts = null;
const result = await check('/repo', INDEXED_COMMIT);
expect(result).toEqual({ status: 'unknown', isStale: false, commitsBehind: 0 });
expect(invocations.map(({ args }) => args)).toEqual([REV_LIST, REV_PARSE]);
});
it('reports unknown when the follow-up HEAD command returns no commit', async () => {
plan.counts = null;
plan.head = 'empty';
const result = await check('/repo', INDEXED_COMMIT);
expect(result).toEqual({ status: 'unknown', isStale: false, commitsBehind: 0 });
expect(invocations.map(({ args }) => args)).toEqual([REV_LIST, REV_PARSE]);
});
it('bounds the HEAD command and reports unknown without retrying after its timeout', async () => {
plan.counts = null;
plan.head = 'timeout';
const result = await check('/repo', INDEXED_COMMIT);
expect(result).toEqual({ status: 'unknown', isStale: false, commitsBehind: 0 });
expect(invocations.map(({ args }) => args)).toEqual([REV_LIST, REV_PARSE]);
const timeout = invocations[1].options.timeout;
expect(Number.isFinite(timeout)).toBe(true);
expect(timeout).toBeGreaterThan(0);
});
});
}
});

View file

@ -335,3 +335,52 @@ describe('branch-pinned serve clone once git prunes the indexed commit (#3256)',
});
});
});
// ── #3127: a 0 forward count is not "the index matches this tree" ───────────
//
// nikolai-vysotskyi (issue #3127, comment on the `--stale-policy` proposal):
// `git rev-list --count lastCommit..HEAD` answers "commits reachable from
// HEAD but not lastCommit", which is also 0 when HEAD is an *ancestor* of
// lastCommit — i.e. the working tree checked out an older commit than the one
// indexed, or a release branch behind the indexed tip. Before this fix that
// read as `current`; a `--stale-policy error`-style caller would exit 0
// exactly when the index is provably wrong about the checked-out tree.
describe('staleness when the checkout has regressed behind the indexed commit (#3127)', () => {
let root: string;
let fixture: ReturnType<typeof makeRepo>;
beforeAll(() => {
root = mkdtempSync(join(tmpdir(), 'gitnexus-staleness-regressed-'));
fixture = makeRepo(root, 'repo');
// Roll the working tree back to c1. `rev-list --count c3..HEAD` alone
// answers 0 here (HEAD/c1 has no commits c3 lacks) — exactly the count a
// pre-fix caller read as "index matches HEAD".
git(fixture.repo, 'checkout', '-q', fixture.c1);
});
afterAll(() => removeTree(root));
for (const [name, check] of Object.entries(bothHelpers)) {
describe(name, () => {
it('reports diverged, not current, when HEAD is behind the indexed commit', async () => {
const result = await check(fixture.repo, fixture.c3);
expect(result.status).toBe('diverged');
// Established by a positive indexed-only count and a zero HEAD-only
// count (not a `rev-list` failure), so — unlike the failure-path
// `diverged` above — `isStale` reflects the mismatch instead of the
// historical fail-open `false`.
expect(result.isStale).toBe(true);
expect(result.commitsBehind).toBe(0);
expect(result.hint).toContain('not reachable from the checked-out commit');
});
it('still reports current for the commit actually checked out', async () => {
expect(await check(fixture.repo, fixture.c1)).toMatchObject({
status: 'current',
isStale: false,
commitsBehind: 0,
});
});
});
}
});

View file

@ -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);
});
});

View 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);
});
});