fix(query): keep process_symbols attaches per (id, process_id) (#3353)
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / ci (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run

* fix(query): keep process_symbols attaches per (id, process_id)

Hubs that belong to several processes were collapsed to one row by unique-id first-wins, so later process cards advertised symbol_count with nothing to expand. Slice, pair-key, and recount now live in one shaper so each listed process can open its own attaches.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor(query): drop leftover unique-id attach bind name

The shaper already returns process_symbols; keep that name through the query response so unique-id vocabulary does not linger at the call site.

Co-authored-by: Cursor <cursoragent@cursor.com>

* style(query): wrap process attach call for prettier

CI quality/format rejects the previous call shape.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(query): assert hub lines under each process card

A global duplicate count still passes when both lines render under one process.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(query): recount group symbol_count after the service prefix

The single-repo (id, process_id) join was readable as if @group results included process_symbols. Scope that sentence, and set symbol_count from the filtered attaches when a service prefix is set.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(query): document the attach contract and count prefix rows once

The Unreleased notes and query tool text now say how to join process_symbols, including the @group follow-up. Service-prefix filtering counts those rows in one pass.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Gergő Magyar 2026-09-23 18:58:11 +01:00 • committed by GitHub
parent c2ca132620
commit aa2d6aaf7d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 452 additions and 46 deletions

View file

@ -7,6 +7,7 @@ All notable changes to GitNexus will be documented in this file.
### Changed
- **MCP `query` / `context` / `impact` / `cypher` always attach a ref-carrying `staleness` field** — object results include it even when `status` is `current`. Absence is no longer the freshness signal: read `staleness.status` (`behind`/`diverged` vs `current`/`unknown`) and `branch`/`lastCommit` for which index answered. `list_repos` and the HTTP repo routes are unchanged (still omit `staleness` when current; the ref is top-level) (#3291, #3293)
- **MCP `query` keeps one `process_symbols` row per `(id, process_id)`** — a symbol in more than one execution flow stays on each process card, and `symbol_count` is the number of those emitted rows after `max_symbols`. A `repo` of `@<group>` returns `{ group, query, results, per_repo }` and does not include `process_symbols`; `results[].symbol_count` is that member's post-slice count, and a `service` prefix counts only attaches under the prefix. Query `@<group>/<memberPath>` for that member's attach rows (#3351)
## [1.6.12] - 2026-09-12

View file

@ -229,10 +229,17 @@ function filterQueryByServicePrefix(
servicePrefix,
),
);
const allowed = new Set(
symbols.map((s) => String((s as { process_id?: string }).process_id ?? '')).filter(Boolean),
);
const processes = (queryResult.processes || []).filter((p) => allowed.has(String(p.id)));
const countByProcess = new Map<string, number>();
for (const symbol of symbols) {
const pid = String((symbol as { process_id?: string }).process_id ?? '');
if (!pid) continue;
countByProcess.set(pid, (countByProcess.get(pid) ?? 0) + 1);
}
const processes = (queryResult.processes || []).filter((p) => countByProcess.has(String(p.id)));
for (const process of processes) {
const id = String(process.id ?? '');
if (id) process.symbol_count = countByProcess.get(id) ?? 0;
}
return { processes, process_symbols: symbols };
}

View file

@ -25,6 +25,7 @@ import { querySpringAopMetadata } from './aop-metadata.js';
import { queryConvexDispatchMetadata } from './convex-metadata.js';
import { isValidQueryParams } from '../../core/lbug/query-params.js';
import { toDisplayLine } from './line-display.js';
import { shapeQueryProcessAttaches } from './query-process-attaches.js';
import { LBUG_ID_PROBE_BATCH_SIZE, LBUG_QUERY_BATCH_SIZE } from '../../core/lbug/query-batch.js';
import { chunk, mapConcurrent } from '../../lib/utils.js';
import { pathSuffixOf } from './path-predicate.js';
@ -3547,38 +3548,9 @@ export class LocalBackend {
// Step 4: Build response
timer.start('formatting');
const processes = rankedProcesses.map((p) => ({
id: p.id,
summary: p.heuristicLabel || p.label,
priority: Math.round(p.priority * 1000) / 1000,
symbol_count: p.symbols.length,
process_type: p.processType,
step_count: p.stepCount,
...(p.routes && p.routes.length > 0
? {
route: p.routes[0].url,
method: p.routes[0].method || undefined,
routes: p.routes,
}
: {}),
...(chainByProcessId.has(p.id) ? { chain: chainByProcessId.get(p.id) } : {}),
}));
const processSymbols = rankedProcesses.flatMap((p) =>
p.symbols.slice(0, maxSymbolsPerProcess).map((s) => ({
...s,
// mark the entry-point symbol so an agent reading the
// process can tell procedure vs. workflow vs. helper at a glance.
...(p.entryPointId && s.id === p.entryPointId ? { is_entry_point: true } : {}),
})),
);
// Deduplicate process_symbols by id
const seen = new Set<string>();
const dedupedSymbols = processSymbols.filter((s) => {
if (seen.has(s.id)) return false;
seen.add(s.id);
return true;
const { processes, process_symbols } = shapeQueryProcessAttaches(rankedProcesses, {
maxSymbolsPerProcess,
chainByProcessId,
});
timer.stop(); // formatting
@ -3762,7 +3734,7 @@ export class LocalBackend {
return {
processes,
process_symbols: dedupedSymbols,
process_symbols,
definitions: definitions.slice(0, 20), // cap standalone definitions
timing,
...(contentAvailability ? { contentAvailability } : {}),

View file

@ -0,0 +1,93 @@
/**
* Shape ranked query processes into the MCP `process_symbols` attach list
* and honest per-process `symbol_count` (#3351).
*
* Aggregation already pushes a hub onto every owning process. This helper
* is the last shaping step: slice each process to `max_symbols`, keep one
* row per `(id, process_id)`, then set `symbol_count` from those rows.
*/
export type QueryProcessAttach = {
id: string;
process_id: string;
is_entry_point?: boolean;
} & Record<string, unknown>;
export type RankedQueryProcess<S extends QueryProcessAttach = QueryProcessAttach> = {
id: string;
label?: string;
heuristicLabel?: string;
processType?: string;
stepCount?: number;
entryPointId?: string;
priority: number;
symbols: S[];
routes?: Array<{ url: string; method?: string }>;
};
export type QueryProcessCard = {
id: string;
summary: string;
priority: number;
symbol_count: number;
process_type?: string;
step_count?: number;
route?: string;
method?: string;
routes?: Array<{ url: string; method?: string }>;
chain?: unknown;
};
/**
* Pair key for attach rows. Both `id` and `process_id` routinely contain
* colons (`func:validate`, `proc:login-flow`), so a `:` join would collide.
*/
function attachPairKey(id: string, processId: string): string {
return `${id}\0${processId}`;
}
export function shapeQueryProcessAttaches<S extends QueryProcessAttach>(
rankedProcesses: Array<RankedQueryProcess<S>>,
options: {
maxSymbolsPerProcess: number;
chainByProcessId?: ReadonlyMap<string, unknown>;
},
): { processes: QueryProcessCard[]; process_symbols: S[] } {
const { maxSymbolsPerProcess, chainByProcessId } = options;
const seen = new Set<string>();
const process_symbols: S[] = [];
const countByProcess = new Map<string, number>();
for (const p of rankedProcesses) {
for (const s of p.symbols.slice(0, maxSymbolsPerProcess)) {
const key = attachPairKey(s.id, s.process_id);
if (seen.has(key)) continue;
seen.add(key);
const row =
p.entryPointId && s.id === p.entryPointId ? ({ ...s, is_entry_point: true } as S) : s;
process_symbols.push(row);
countByProcess.set(s.process_id, (countByProcess.get(s.process_id) ?? 0) + 1);
}
}
const processes = rankedProcesses.map((p) => {
const card: QueryProcessCard = {
id: p.id,
summary: p.heuristicLabel || p.label || '',
priority: Math.round(p.priority * 1000) / 1000,
symbol_count: countByProcess.get(p.id) ?? 0,
process_type: p.processType,
step_count: p.stepCount,
};
if (p.routes && p.routes.length > 0) {
card.route = p.routes[0].url;
card.method = p.routes[0].method || undefined;
card.routes = p.routes;
}
const chain = chainByProcessId?.get(p.id);
if (chain !== undefined) card.chain = chain;
return card;
});
return { processes, process_symbols };
}

View file

@ -141,14 +141,14 @@ specify the "repo" parameter explicitly.`,
{
name: 'query',
description: `Query the code knowledge graph for execution flows related to a concept.
Returns processes (call chains) ranked by relevance, each with its symbols and file locations.
Returns ranked processes plus a flat process_symbols list. Join processes[].id to process_symbols[].process_id.
WHEN TO USE: Understanding how code works together. Use this when you need execution flows and relationships, not just file matches. Complements grep/IDE search.
AFTER THIS: Use context() on a specific symbol for 360-degree view (callers, callees, categorized refs).
Returns results grouped by process (execution flow):
- processes: ranked execution flows with relevance priority. When a process has an HTTP endpoint, each item includes route and method string aliases plus routes: [{ url, method? }] (same shape as context). When chain_depth > 0, each item also includes chain — layered upstream callers + downstream callees from the process entry symbol (same BFS as context({chain_depth})).
- process_symbols: search-hit symbols in those flows with file locations and module (functional area). When the process entry is among those hits, it is marked is_entry_point: true.
- process_symbols: search-hit symbols in those flows with file locations and module (functional area). On the single-repo envelope { processes, process_symbols, definitions }: One row per (id, process_id) — the same symbol id may appear under more than one process. Join a process to its rows by process_id; symbol_count is the number of those rows. When the process entry is among those hits, it is marked is_entry_point: true. A repo of "@<group>" returns { group, query, results, per_repo } and does not include process_symbols. results[].symbol_count is the member's post-slice attach count; when service is set, it counts only attaches under that prefix. To get process_symbols for one member, query again with repo "@<group>/<memberPath>" (member path from group.yaml, or results[]._repo).
- definitions: standalone types/interfaces not in any process. Keyword hits on Route URLs (route_fts) are bridged to their handler via HANDLES_ROUTE (handlerSymbolId, routes) when the edge exists; use route_map({route}) for the full HTTP surface.
Hybrid ranking: BM25 keyword + semantic vector search, ranked by Reciprocal Rank Fusion.

View file

@ -316,12 +316,23 @@ withTestLbugDB(
expect(processIds).toContain('proc:login-flow');
expect(processIds).toContain('proc:beta-flow');
// process_symbols dedups by id, so validate appears once carrying the
// pid+step of its top-ranked process — they must come from the SAME
// shifted row: login-flow⇒step 2, beta-flow⇒step 3.
const v = (res.process_symbols ?? []).find((s: any) => s.id === 'func:validate');
expect(v).toBeDefined();
expect(v.step_index).toBe(v.process_id === 'proc:beta-flow' ? 3 : 2);
// process_symbols keeps one row per (id, process_id): validate is a
// step in both flows and must appear under each, with that row's
// shifted pid↔step pairing (login-flow⇒step 2, beta-flow⇒step 3).
const validates = (res.process_symbols ?? []).filter((s: any) => s.id === 'func:validate');
expect(validates).toHaveLength(2);
const byProcess = Object.fromEntries(validates.map((s: any) => [s.process_id, s]));
expect(byProcess['proc:login-flow']?.step_index).toBe(2);
expect(byProcess['proc:beta-flow']?.step_index).toBe(3);
expect(byProcess['proc:login-flow']?.is_entry_point).toBeUndefined();
expect(byProcess['proc:beta-flow']?.is_entry_point).toBeUndefined();
for (const procId of ['proc:login-flow', 'proc:beta-flow'] as const) {
const card = (res.processes ?? []).find((p: any) => p.id === procId);
const attachCount = (res.process_symbols ?? []).filter(
(s: any) => s.process_id === procId,
).length;
expect(card?.symbol_count).toBe(attachCount);
}
// Ranking: 'login' surfaces proc:login-flow as the top process.
const loginRes = await backend.callTool('query', { query: 'login' });

View file

@ -93,6 +93,43 @@ describe('formatQueryResult', () => {
expect(result).toContain('No matching execution flows');
});
it('lists a shared hub under each process that has an attach (R7)', () => {
const result = formatQueryResult({
processes: [
{ id: 'proc:A', summary: 'User Login', step_count: 3, symbol_count: 1 },
{ id: 'proc:B', summary: 'Beta Flow', step_count: 3, symbol_count: 1 },
],
process_symbols: [
{
id: 'func:validate',
process_id: 'proc:A',
type: 'Function',
name: 'validate',
filePath: 'src/auth.ts',
startLine: 17,
},
{
id: 'func:validate',
process_id: 'proc:B',
type: 'Function',
name: 'validate',
filePath: 'src/beta.ts',
startLine: 17,
},
],
definitions: [],
});
expect(result).toContain('User Login (3 steps, 1 symbols)');
expect(result).toContain('Beta Flow (3 steps, 1 symbols)');
const sections = result.split(/\n\d+\. /);
const login = sections.find((section) => section.startsWith('User Login'));
const beta = sections.find((section) => section.startsWith('Beta Flow'));
expect(login).toContain('validate → src/auth.ts:17');
expect(login).not.toContain('src/beta.ts');
expect(beta).toContain('validate → src/beta.ts:17');
expect(beta).not.toContain('src/auth.ts');
});
it('formats processes with symbols', () => {
const result = formatQueryResult({
processes: [{ id: 'p1', summary: 'User Login Flow', step_count: 3, symbol_count: 2 }],

View file

@ -56,6 +56,97 @@ function makePort(overrides: Partial<GroupToolPort> = {}): GroupToolPort {
}
describe('GroupService group-mode API surface', () => {
it('groupQuery with service keeps a hub-only process after pair-key attaches (AE3)', async () => {
const { tmpDir, cleanup } = makeTmpGroup();
vi.stubEnv('GITNEXUS_HOME', tmpDir);
try {
const query = vi.fn(async () => ({
processes: [{ id: 'proc:A' }, { id: 'proc:B' }],
process_symbols: [
{ id: 'func:validate', process_id: 'proc:A', filePath: 'services/auth/a.ts' },
{ id: 'func:validate', process_id: 'proc:B', filePath: 'services/auth/b.ts' },
],
}));
const svc = new GroupService(makePort({ query }));
const r = (await svc.groupQuery({
name: 'test-group',
query: 'validate',
service: 'services/auth',
})) as {
results: Array<{ id?: string; symbol_count?: number }>;
process_symbols?: unknown;
};
const resultIds = r.results.map((row) => row.id);
expect(resultIds).toContain('proc:A');
expect(resultIds).toContain('proc:B');
expect(r.results.find((row) => row.id === 'proc:A')?.symbol_count).toBe(1);
expect(r.results.find((row) => row.id === 'proc:B')?.symbol_count).toBe(1);
expect(r).not.toHaveProperty('process_symbols');
} finally {
vi.unstubAllEnvs();
cleanup();
}
});
it('groupQuery counts only in-prefix attaches on a kept process', async () => {
const { tmpDir, cleanup } = makeTmpGroup();
vi.stubEnv('GITNEXUS_HOME', tmpDir);
try {
const query = vi.fn(async () => ({
processes: [{ id: 'proc:B', symbol_count: 9 }],
process_symbols: [
{ id: 'func:a', process_id: 'proc:B', filePath: 'services/auth/a.ts' },
{ id: 'func:b', process_id: 'proc:B', filePath: 'services/auth/b.ts' },
{ id: 'func:c', process_id: 'proc:B', filePath: 'other/c.ts' },
],
}));
const svc = new GroupService(makePort({ query }));
const r = (await svc.groupQuery({
name: 'test-group',
query: 'validate',
service: 'services/auth',
})) as { results: Array<{ id?: string; symbol_count?: number }> };
expect(r.results.every((row) => row.id === 'proc:B' && row.symbol_count === 2)).toBe(true);
} finally {
vi.unstubAllEnvs();
cleanup();
}
});
it('groupQuery drops a hub process whose only attach is outside the service prefix', async () => {
const { tmpDir, cleanup } = makeTmpGroup();
vi.stubEnv('GITNEXUS_HOME', tmpDir);
try {
const query = vi.fn(async () => ({
processes: [
{ id: 'proc:A', symbol_count: 5 },
{ id: 'proc:B', symbol_count: 5 },
],
process_symbols: [
{ id: 'func:validate', process_id: 'proc:A', filePath: 'other/outside.ts' },
{ id: 'func:validate', process_id: 'proc:B', filePath: 'services/auth/b.ts' },
],
}));
const svc = new GroupService(makePort({ query }));
const r = (await svc.groupQuery({
name: 'test-group',
query: 'validate',
service: 'services/auth',
})) as {
results: Array<{ id?: string; symbol_count?: number }>;
process_symbols?: unknown;
};
const resultIds = r.results.map((row) => row.id);
expect(resultIds).toContain('proc:B');
expect(resultIds).not.toContain('proc:A');
expect(r.results.every((row) => row.id === 'proc:B' && row.symbol_count === 1)).toBe(true);
expect(r).not.toHaveProperty('process_symbols');
} finally {
vi.unstubAllEnvs();
cleanup();
}
});
it('groupQuery uses name (never @-repo) and optional service filters processes', async () => {
const { tmpDir, cleanup } = makeTmpGroup();
vi.stubEnv('GITNEXUS_HOME', tmpDir);
@ -72,9 +163,10 @@ describe('GroupService group-mode API surface', () => {
name: 'test-group',
query: 'oauth',
service: 'services/auth',
})) as { results: Array<{ id?: string }> };
})) as { results: Array<{ id?: string; symbol_count?: number }> };
expect(query).toHaveBeenCalled();
expect(r.results.every((row) => row.id === 'p1')).toBe(true);
expect(r.results.every((row) => row.symbol_count === 1)).toBe(true);
} finally {
vi.unstubAllEnvs();
cleanup();

View file

@ -0,0 +1,179 @@
/**
* Pair-key query attach shaping (#3351).
*
* Locks AE1 / AE2 / KTD4 without Ladybug: unique-id first-wins must not return.
*/
import { describe, expect, it } from 'vitest';
import {
shapeQueryProcessAttaches,
type QueryProcessAttach,
type RankedQueryProcess,
} from '../../../src/mcp/local/query-process-attaches.js';
function attach(
id: string,
processId: string,
extras: Record<string, unknown> = {},
): QueryProcessAttach {
return { id, process_id: processId, ...extras };
}
function ranked(
id: string,
symbols: Array<{ id: string; process_id: string } & Record<string, unknown>>,
extras: Partial<RankedQueryProcess> = {},
): RankedQueryProcess {
return {
id,
label: id,
priority: 1,
symbols,
...extras,
};
}
describe('shapeQueryProcessAttaches', () => {
it('keeps a shared hub under every owning process (AE1)', () => {
const { processes, process_symbols } = shapeQueryProcessAttaches(
[
ranked('proc:login-flow', [attach('func:validate', 'proc:login-flow', { step_index: 2 })]),
ranked('proc:beta-flow', [attach('func:validate', 'proc:beta-flow', { step_index: 3 })]),
],
{ maxSymbolsPerProcess: 25 },
);
const pairs = process_symbols
.filter((s) => s.id === 'func:validate')
.map((s) => [s.process_id, s.step_index]);
expect(pairs).toEqual([
['proc:login-flow', 2],
['proc:beta-flow', 3],
]);
expect(processes.find((p) => p.id === 'proc:login-flow')?.symbol_count).toBe(1);
expect(processes.find((p) => p.id === 'proc:beta-flow')?.symbol_count).toBe(1);
});
it('sets each process symbol_count from emitted attaches, not pre-slice hits (AE2)', () => {
const hits = Array.from({ length: 40 }, (_, i) =>
attach(`func:hit-${i}`, 'proc:fat', { step_index: i }),
);
const { processes, process_symbols } = shapeQueryProcessAttaches([ranked('proc:fat', hits)], {
maxSymbolsPerProcess: 25,
});
expect(process_symbols).toHaveLength(25);
expect(process_symbols.map((s) => s.id)).toEqual(
Array.from({ length: 25 }, (_, i) => `func:hit-${i}`),
);
expect(process_symbols.every((s) => s.process_id === 'proc:fat')).toBe(true);
expect(processes[0]?.symbol_count).toBe(25);
});
it('keeps pairs that a colon join would collapse', () => {
const { process_symbols } = shapeQueryProcessAttaches(
[
ranked('login-flow', [attach('func:validate:proc', 'login-flow')]),
ranked('proc:login-flow', [attach('func:validate', 'proc:login-flow')]),
],
{ maxSymbolsPerProcess: 25 },
);
expect(process_symbols.map((s) => [s.id, s.process_id])).toEqual([
['func:validate:proc', 'login-flow'],
['func:validate', 'proc:login-flow'],
]);
});
it('keeps the first row when the same pair appears twice (KTD4)', () => {
const { processes, process_symbols } = shapeQueryProcessAttaches(
[
ranked('proc:login-flow', [
attach('func:validate', 'proc:login-flow', { step_index: 2, content: 'first' }),
attach('func:validate', 'proc:login-flow', { step_index: 99, content: 'second' }),
]),
],
{ maxSymbolsPerProcess: 25 },
);
const rows = process_symbols.filter((s) => s.id === 'func:validate');
expect(rows).toHaveLength(1);
expect(rows[0]?.step_index).toBe(2);
expect(rows[0]?.content).toBe('first');
expect(processes[0]?.symbol_count).toBe(1);
});
it('keeps include_content on every emitted hub attach (KTD5)', () => {
const { process_symbols } = shapeQueryProcessAttaches(
[
ranked('proc:login-flow', [
attach('func:validate', 'proc:login-flow', { content: 'function validate() {}' }),
]),
ranked('proc:beta-flow', [
attach('func:validate', 'proc:beta-flow', { content: 'function validate() {}' }),
]),
],
{ maxSymbolsPerProcess: 25 },
);
expect(process_symbols.map((s) => s.content)).toEqual([
'function validate() {}',
'function validate() {}',
]);
});
it('marks the entry-point hit and preserves process card extras', () => {
const chain = [{ direction: 'downstream' }];
const { processes, process_symbols } = shapeQueryProcessAttaches(
[
ranked('proc:login-flow', [attach('func:login', 'proc:login-flow')], {
heuristicLabel: 'User Login',
processType: 'intra_community',
stepCount: 2,
entryPointId: 'func:login',
priority: 1.23456,
routes: [{ url: '/login', method: 'POST' }],
}),
],
{ maxSymbolsPerProcess: 25, chainByProcessId: new Map([['proc:login-flow', chain]]) },
);
expect(process_symbols[0]?.is_entry_point).toBe(true);
const mixed = shapeQueryProcessAttaches(
[
ranked(
'proc:login-flow',
[attach('func:login', 'proc:login-flow'), attach('func:validate', 'proc:login-flow')],
{ entryPointId: 'func:login' },
),
ranked('proc:beta-flow', [attach('func:login', 'proc:beta-flow')], {
entryPointId: 'func:beta',
}),
],
{ maxSymbolsPerProcess: 25 },
);
const flagged = mixed.process_symbols.filter((s) => s.is_entry_point === true);
expect(flagged).toEqual([
expect.objectContaining({ id: 'func:login', process_id: 'proc:login-flow' }),
]);
expect(
mixed.process_symbols
.filter((s) => s.is_entry_point !== true)
.map((s) => [s.id, s.process_id]),
).toEqual([
['func:validate', 'proc:login-flow'],
['func:login', 'proc:beta-flow'],
]);
expect(processes[0]).toMatchObject({
id: 'proc:login-flow',
summary: 'User Login',
priority: 1.235,
symbol_count: 1,
process_type: 'intra_community',
step_count: 2,
route: '/login',
method: 'POST',
chain,
});
});
});

View file

@ -124,6 +124,20 @@ describe('GITNEXUS_TOOLS', () => {
}
});
it('query process_symbols description names the (id, process_id) join key (#3351)', () => {
const queryTool = GITNEXUS_TOOLS.find((t) => t.name === 'query')!;
expect(queryTool.description).toContain('One row per (id, process_id)');
expect(queryTool.description).toContain('Join a process to its rows by process_id');
expect(queryTool.description).toContain('symbol_count is the number of those rows');
expect(queryTool.description).toContain('single-repo envelope');
expect(queryTool.description).toContain('does not include process_symbols');
expect(queryTool.description).toContain('Join processes[].id to process_symbols[].process_id');
expect(queryTool.description).toContain(
'when service is set, it counts only attaches under that prefix',
);
expect(queryTool.description).toContain('query again with repo "@<group>/<memberPath>"');
});
it('query tool requires "search_query" parameter (renamed from "query" for #2175)', () => {
const queryTool = GITNEXUS_TOOLS.find((t) => t.name === 'query')!;
expect(queryTool.inputSchema.required).toContain('search_query');