mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-03 02:21:44 +00:00
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
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:
parent
c2ca132620
commit
aa2d6aaf7d
10 changed files with 452 additions and 46 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
|
|
|
|||
93
gitnexus/src/mcp/local/query-process-attaches.ts
Normal file
93
gitnexus/src/mcp/local/query-process-attaches.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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 }],
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
179
gitnexus/test/unit/mcp/query-process-attaches.test.ts
Normal file
179
gitnexus/test/unit/mcp/query-process-attaches.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue