mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-07 08:26:11 +00:00
* fix(lbug): store exact symbol content snippets * fix(ingestion): emit 0-based line numbers for COBOL/JCL/scope/markdown nodes COBOL/JCL processors, the scope-graph emitter, and the markdown Section emitter stored 1-based startLine/endLine, unlike every tree-sitter node (0-based). The exact-content slice (#2379) then dropped each symbol's declaration line for those languages. Convert to 0-based at the graph-node emission boundary via toZeroBasedLine — leaving parser-internal .line values, L${line} node/edge IDs, and containment checks untouched. Refs #2377, #2379 * refactor(lbug): single source of truth for symbol-content labels Extract SYMBOL_NODE_LABELS so the exact-content label set can't drift the way the inline copy did in #2379. csv-generator derives EXACT_SYMBOL_CONTENT_LABELS from it; manifest-extractor's near-identical allowlist is left behavior-unchanged (intentional subset, #2325-test-locked) with a documented cross-reference. Refs #2379 * test(ingestion): cover 0-based emitter output and pin exact-content slicing - csv-pipeline: replace the blank-buffer fixture (a +/-1 shift silently passed) with directly-adjacent neighbors; add one-line-symbol and Section (+/-2 fallback) cases. - cobol resolver: assert COBOL Module and JCL job/step emit 0-based startLine. - markdown CRLF: update Section startLine/endLine expectations to 0-based. Refs #2377, #2379 * feat(mcp): present 1-based line numbers in context/query/impact tools GraphNode startLine/endLine are stored 0-based (tree-sitter rows), which surprised users querying them (they don't line up with editors/sed). Add toDisplayLine and apply it at the context/query/impact response boundaries so line numbers are editor/sed-aligned. Raw cypher stays 0-based (documented in the schema resource); BasicBlock/PDG statement lines (already 1-based) and internal join params are left untouched. Refs #2377 * test(mcp): assert 1-based tool exposure with raw cypher staying 0-based context() reports startLine+1 (editor/sed aligned); a raw cypher RETURN of the same node keeps the stored 0-based value. Guards against double-conversion and leaking the display shift into raw results. Refs #2377 * fix(mcp): stop query() double-converting BM25 line numbers bm25Search applied toDisplayLine to its result rows, and query()'s aggregation loop applied it again, so BM25-matched symbols reported lines shifted +2 (stored 0-based 41 read as 43, not 42) while semantic-matched symbols were correct. bm25Search is called only from query(); return raw 0-based rows and let the single aggregation-loop conversion handle both retrievers. Adds a query() BM25 regression test asserting stored 41 -> 42 (would be 43 if double-converted), which the prior mcp-line-display test — covering only context()+cypher — never exercised. (#2380, #2377) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(mcp): use ?? not || so first-line symbols keep their line number `sym.startLine || sym[4]` treated a legitimate 0-based startLine of 0 as absent, so context()/query() dropped startLine/endLine for every symbol on line 1 of its file — every COBOL Module (toZeroBasedLine(1) = 0) and markdown h1. `??` only falls through to the positional fallback on null/undefined, preserving a real 0. This also repairs the rename definition-edit path, which consumes context()'s value. Adds a context() first-line (startLine:0 -> 1) assertion. (#2380, #2377) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(mcp): make group/cross-repo trace line numbers 1-based consistently A group/cross-repo trace presented 1-based endpoints (via resolveSymbolForGroup) but 0-based hops (tagHops copies port.trace output verbatim), so one response mixed bases. Wrap the trace port adapter (traceForGroup) to convert hop lines to 1-based too, matching the endpoints. Single-repo trace dispatches directly (not through this port) and stays 0-based — full single-repo parity is a tracked follow-up. core/group stays display-agnostic (no mcp import). Extends the cross-trace e2e test to assert hops share the endpoints' base (checkout 10 -> 11, getUsers 1 -> 2). (#2380) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(mcp): present explain/pdg_query anchor line 1-based resolveBlockAnchor converted its ambiguous-candidate lines to 1-based but left the resolved-target anchor raw 0-based, so the same tool reported two bases depending on whether the target was ambiguous. Convert the display anchor to 1-based via toDisplayLine. The BasicBlock join param (symStart: sym.startLine + 1) is untouched — it targets the 1-based BasicBlock id space, not display. Asserts the resolved anchor is 1-based (targetFn stored 10 -> 11). (#2380) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(mcp): bump schema + PDG result versions for the line-number change The 0-based storage flip for COBOL/JCL/markdown/scope (#2377/#2379) changed on-disk line semantics, and the PDG result startLine is now 1-based (#2380). Neither shipped a version bump, so an incremental re-analyze would preserve old 1-based rows (mixed-base index rendered one line too high) and PDG consumers got no signal. - INCREMENTAL_SCHEMA_VERSION 5 -> 6 (forces a one-time full re-analyze) - PDG_RESULT_VERSION 1 -> 2 (result-shape discriminator) Updates the version-pinning tests, the pdgResultVersion result type, and the tools.ts PDG output-contract doc. (#2380) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(group): guard manifest label list against SYMBOL_NODE_LABELS drift manifest-extractor's CUSTOM_CONTRACT_RESOLVE_QUERY hand-lists the contract-resolvable labels as a deliberate subset of the shared SYMBOL_NODE_LABELS, guarded only by a comment — the same drift class (#2379) the shared-set refactor eliminated elsewhere. Derive the query's label set and assert it is a strict subset whose difference is exactly {Namespace, Variable, Module}, so adding a symbol label without a conscious manifest decision fails. Query string stays literal (#2325-test-locked). (#2380) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(mcp): document which tools present 1-based vs 0-based line numbers The schema-resource note listed only context/query/impact as 1-based. After the trace/anchor fixes it now enumerates the full set — context, query, impact, group/cross-repo trace, and explain/pdg_query anchors are 1-based; raw Cypher and single-repo trace stay 0-based (full single-repo-trace parity is a tracked follow-up); BasicBlock/PDG statement lines are separately 1-based. (#2377, #2380) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(mcp): pin impact() line-value display (close the coverage gap) The prior mcp-line-display test only asserted context() + raw cypher, which is why the query() double-conversion (#2380) shipped green. Adds an impact() line-value assertion via the ambiguous-candidate path (the only impact response that surfaces a per-candidate line): two same-name symbols force ambiguity and the candidate at stored 0-based 41 must read 42. (#2380) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(mcp): fix stale rename #2283 mock after 1-based context display rename resolves its symbol via context(), which now presents startLine 1-based (#2377), then subtracts 1 to recover the 0-based file index. The #2283 mock stored startLine:1 but put `oldName` on the file's line 0, so after the 1-based shift the definition edit no longer matched and the write-failure path never fired — the test read 'success' instead of 'partial'. Align the mock content to its stored line (oldName on 0-based line 1). Pre-existing failure surfaced once ubuntu/coverage completed on this branch. (#2380) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(mcp): consolidate line-display tests into one shared DB block The query()/BM25 case had spun up a second full LadybugDB + FTS setup; fold it into the single existing block (adding FTS + the Zqxwvbm seed there) so the file builds one DB, not two. Trims per-file setup cost — relevant to the Windows platform-sensitive suite's under-load 15-minute timeout. Same five assertions, all green. (#2380) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: kigland <shuaizhicheng336@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
383 lines
13 KiB
TypeScript
383 lines
13 KiB
TypeScript
/**
|
|
* U6 — Cross-repo trace, evaluation-first end-to-end.
|
|
*
|
|
* Stands up TWO real LadybugDB indexes (a "frontend" consumer repo and a
|
|
* "backend" provider repo), a real group bridge linking a consumer symbol to a
|
|
* provider symbol, and a real LocalBackend with both repos registered. Then it
|
|
* drives the public `callTool('trace', { repo: '@group', pdg: true })` and
|
|
* asserts the stitched cross-repo path AND the real REACHING_DEF data-flow
|
|
* enrichment — exercising every new query path against a real engine:
|
|
* resolveSymbolCandidates, _traceImpl, the bridge `listCrossingsBetween` pair
|
|
* query, and `_pdgFlowsForGroupImpl`.
|
|
*
|
|
* The two indexes are built sequentially with the writable core adapter (one
|
|
* open writer at a time) and read back through the MCP pool adapter the backend
|
|
* opens lazily. A real two-repo *analyze* pipeline is heavier than this gate
|
|
* needs; hand-persisting the minimal real graph keeps it deterministic while
|
|
* still hitting real LadybugDB Cypher.
|
|
*/
|
|
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { LocalBackend } from '../../../src/mcp/local/local-backend.js';
|
|
import { listRegisteredRepos } from '../../../src/storage/repo-manager.js';
|
|
import { writeBridge } from '../../../src/core/group/bridge-db.js';
|
|
import type { CrossLink } from '../../../src/core/group/types.js';
|
|
import { makeContract } from '../../unit/group/fixtures.js';
|
|
|
|
vi.mock('../../../src/storage/repo-manager.js', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('../../../src/storage/repo-manager.js')>();
|
|
return {
|
|
...actual,
|
|
listRegisteredRepos: vi.fn().mockResolvedValue([]),
|
|
cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }),
|
|
findSiblingClones: vi.fn().mockResolvedValue([]),
|
|
// No meta.json for the seeded DBs — pdgStampForMode degrades to the
|
|
// row-existence probe (the seeded-DB reality, like pdg-query.test.ts).
|
|
loadMeta: vi.fn().mockResolvedValue(null),
|
|
};
|
|
});
|
|
|
|
// LadybugDB close-then-reopen is Windows-flaky (file lock held until process
|
|
// exit); the bridge write+read and the sequential two-DB build both hit it.
|
|
const describeReopen = process.platform === 'win32' ? describe.skip : describe;
|
|
|
|
/** Restore an env var to a prior value, or unset it if there was none. */
|
|
function restoreEnvVar(key: string, prev: string | undefined): void {
|
|
if (prev === undefined) delete process.env[key];
|
|
else process.env[key] = prev;
|
|
}
|
|
|
|
interface NodeSpec {
|
|
label: 'Function' | 'BasicBlock';
|
|
props: Record<string, unknown>;
|
|
}
|
|
interface RelSpec {
|
|
type: 'CALLS' | 'REACHING_DEF';
|
|
srcLabel: 'Function' | 'BasicBlock';
|
|
dstLabel: 'Function' | 'BasicBlock';
|
|
src: string;
|
|
dst: string;
|
|
reason?: string;
|
|
}
|
|
|
|
/** Build a real lbug DB at `lbugPath`, seeding nodes + rels via the writer. */
|
|
async function buildRepoDB(lbugPath: string, nodes: NodeSpec[], rels: RelSpec[]): Promise<void> {
|
|
const core = await import('../../../src/core/lbug/lbug-adapter.js');
|
|
await core.initLbug(lbugPath); // creates the full schema
|
|
try {
|
|
for (const n of nodes) {
|
|
const assignments = Object.keys(n.props)
|
|
.map((k) => `${k}: $${k}`)
|
|
.join(', ');
|
|
await core.executePrepared(`CREATE (x:${n.label} {${assignments}})`, n.props);
|
|
}
|
|
for (const r of rels) {
|
|
await core.executePrepared(
|
|
`MATCH (a:${r.srcLabel} {id: $src}), (b:${r.dstLabel} {id: $dst})
|
|
CREATE (a)-[:CodeRelation {type: '${r.type}', confidence: 1.0, reason: $reason, step: 0}]->(b)`,
|
|
{ src: r.src, dst: r.dst, reason: r.reason ?? '' },
|
|
);
|
|
}
|
|
await core.flushWAL();
|
|
} finally {
|
|
await core.closeLbug();
|
|
}
|
|
}
|
|
|
|
describeReopen('cross-repo trace e2e (two real indexes + bridge)', () => {
|
|
let tmpHome: string;
|
|
let storageFE: string;
|
|
let storageBE: string;
|
|
let backend: LocalBackend;
|
|
let prevHome: string | undefined;
|
|
|
|
beforeAll(async () => {
|
|
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-cross-trace-e2e-'));
|
|
storageFE = path.join(tmpHome, 'fe-storage');
|
|
storageBE = path.join(tmpHome, 'be-storage');
|
|
fs.mkdirSync(storageFE, { recursive: true });
|
|
fs.mkdirSync(storageBE, { recursive: true });
|
|
|
|
// ── Frontend (consumer) index: checkout -> callUsers, with a REACHING_DEF
|
|
// data-flow inside callUsers (def line 3 -> use line 4 of `userId`). ──
|
|
await buildRepoDB(
|
|
path.join(storageFE, 'lbug'),
|
|
[
|
|
{
|
|
label: 'Function',
|
|
props: {
|
|
id: 'fn:checkout',
|
|
name: 'checkout',
|
|
filePath: 'src/checkout.ts',
|
|
startLine: 10,
|
|
endLine: 14,
|
|
},
|
|
},
|
|
{
|
|
label: 'Function',
|
|
props: {
|
|
id: 'fn:callUsers',
|
|
name: 'callUsers',
|
|
filePath: 'src/api.ts',
|
|
startLine: 2,
|
|
endLine: 6,
|
|
},
|
|
},
|
|
{
|
|
label: 'BasicBlock',
|
|
props: {
|
|
id: 'BasicBlock:src/api.ts:2:0:0',
|
|
filePath: 'src/api.ts',
|
|
startLine: 3,
|
|
endLine: 3,
|
|
text: 'const userId = req.params.id',
|
|
callees: '',
|
|
calleeIds: '',
|
|
},
|
|
},
|
|
{
|
|
label: 'BasicBlock',
|
|
props: {
|
|
id: 'BasicBlock:src/api.ts:2:0:1',
|
|
filePath: 'src/api.ts',
|
|
startLine: 4,
|
|
endLine: 4,
|
|
text: 'fetchUsers(userId)',
|
|
callees: '',
|
|
calleeIds: '',
|
|
},
|
|
},
|
|
],
|
|
[
|
|
{
|
|
type: 'CALLS',
|
|
srcLabel: 'Function',
|
|
dstLabel: 'Function',
|
|
src: 'fn:checkout',
|
|
dst: 'fn:callUsers',
|
|
},
|
|
{
|
|
type: 'REACHING_DEF',
|
|
srcLabel: 'BasicBlock',
|
|
dstLabel: 'BasicBlock',
|
|
src: 'BasicBlock:src/api.ts:2:0:0',
|
|
dst: 'BasicBlock:src/api.ts:2:0:1',
|
|
reason: 'userId',
|
|
},
|
|
],
|
|
);
|
|
|
|
// ── Backend (provider) index: handleUsers -> getUsers. No PDG layer. ──
|
|
await buildRepoDB(
|
|
path.join(storageBE, 'lbug'),
|
|
[
|
|
{
|
|
label: 'Function',
|
|
props: {
|
|
id: 'fn:handleUsers',
|
|
name: 'handleUsers',
|
|
filePath: 'src/routes.ts',
|
|
startLine: 5,
|
|
endLine: 9,
|
|
},
|
|
},
|
|
{
|
|
label: 'Function',
|
|
props: {
|
|
id: 'fn:getUsers',
|
|
name: 'getUsers',
|
|
filePath: 'src/users.ts',
|
|
startLine: 1,
|
|
endLine: 4,
|
|
},
|
|
},
|
|
],
|
|
[
|
|
{
|
|
type: 'CALLS',
|
|
srcLabel: 'Function',
|
|
dstLabel: 'Function',
|
|
src: 'fn:handleUsers',
|
|
dst: 'fn:getUsers',
|
|
},
|
|
],
|
|
);
|
|
|
|
// ── Group config + bridge (consumer callUsers -> provider handleUsers). ──
|
|
const groupDir = path.join(tmpHome, 'groups', 'grp');
|
|
fs.mkdirSync(groupDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(groupDir, 'group.yaml'),
|
|
`version: 1
|
|
name: grp
|
|
description: ""
|
|
repos:
|
|
app/frontend: reg-fe
|
|
app/backend: reg-be
|
|
links: []
|
|
packages: {}
|
|
detect:
|
|
http: true
|
|
matching:
|
|
bm25_threshold: 0.7
|
|
embedding_threshold: 0.65
|
|
max_candidates_per_step: 3
|
|
`,
|
|
);
|
|
const consumer = makeContract({
|
|
repo: 'app/frontend',
|
|
role: 'consumer',
|
|
symbolUid: 'fn:callUsers',
|
|
symbolRef: { filePath: 'src/api.ts', name: 'callUsers' },
|
|
symbolName: 'callUsers',
|
|
contractId: 'http::GET::/api/users',
|
|
});
|
|
const provider = makeContract({
|
|
repo: 'app/backend',
|
|
role: 'provider',
|
|
symbolUid: 'fn:handleUsers',
|
|
symbolRef: { filePath: 'src/routes.ts', name: 'handleUsers' },
|
|
symbolName: 'handleUsers',
|
|
contractId: 'http::GET::/api/users',
|
|
});
|
|
const link: CrossLink = {
|
|
from: { repo: 'app/frontend', symbolUid: 'fn:callUsers', symbolRef: consumer.symbolRef },
|
|
to: { repo: 'app/backend', symbolUid: 'fn:handleUsers', symbolRef: provider.symbolRef },
|
|
type: 'http',
|
|
contractId: 'http::GET::/api/users',
|
|
matchType: 'exact',
|
|
confidence: 0.9,
|
|
};
|
|
await writeBridge(groupDir, {
|
|
contracts: [consumer, provider],
|
|
crossLinks: [link],
|
|
repoSnapshots: {},
|
|
missingRepos: [],
|
|
});
|
|
|
|
// ── Register both repos + a real backend (lazy pool open). ──
|
|
vi.mocked(listRegisteredRepos).mockResolvedValue([
|
|
{
|
|
name: 'reg-fe',
|
|
path: path.join(tmpHome, 'fe-repo'),
|
|
storagePath: storageFE,
|
|
indexedAt: new Date(0).toISOString(),
|
|
lastCommit: 'fe',
|
|
stats: { files: 1, nodes: 2, communities: 0, processes: 0 },
|
|
},
|
|
{
|
|
name: 'reg-be',
|
|
path: path.join(tmpHome, 'be-repo'),
|
|
storagePath: storageBE,
|
|
indexedAt: new Date(0).toISOString(),
|
|
lastCommit: 'be',
|
|
stats: { files: 1, nodes: 2, communities: 0, processes: 0 },
|
|
},
|
|
]);
|
|
|
|
prevHome = process.env.GITNEXUS_HOME;
|
|
process.env.GITNEXUS_HOME = tmpHome;
|
|
backend = new LocalBackend();
|
|
await backend.init();
|
|
}, 120_000);
|
|
|
|
afterAll(async () => {
|
|
await backend?.dispose();
|
|
restoreEnvVar('GITNEXUS_HOME', prevHome);
|
|
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
}, 120_000);
|
|
|
|
it('stitches checkout -> getUsers across the bridge with real PDG enrichment', async () => {
|
|
const result = await backend.callTool('trace', {
|
|
repo: '@grp',
|
|
from: 'checkout',
|
|
to: 'getUsers',
|
|
pdg: true,
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
status: 'ok',
|
|
crossings: [
|
|
{
|
|
fromRepo: 'app/frontend',
|
|
toRepo: 'app/backend',
|
|
contractId: 'http::GET::/api/users',
|
|
matchType: 'exact',
|
|
},
|
|
],
|
|
});
|
|
|
|
// The stitched path spans both repos in order, tagged by member repo.
|
|
const hops = (result.hops as Array<{ name: string; repo: string }>).map((h) => ({
|
|
name: h.name,
|
|
repo: h.repo,
|
|
}));
|
|
expect(hops).toEqual([
|
|
{ name: 'checkout', repo: 'app/frontend' },
|
|
{ name: 'callUsers', repo: 'app/frontend' },
|
|
{ name: 'handleUsers', repo: 'app/backend' },
|
|
{ name: 'getUsers', repo: 'app/backend' },
|
|
]);
|
|
|
|
// #2380: the whole group-trace response is 1-based — the hops share the same
|
|
// base as the endpoints (before the fix, endpoints were 1-based via
|
|
// resolveSymbol while hops stayed 0-based, mixing bases in one response).
|
|
const hopLines = (result.hops as Array<{ startLine: number }>).map((h) => h.startLine);
|
|
expect(hopLines[0]).toBe(11); // checkout stored 10 -> display 11
|
|
expect(hopLines[3]).toBe(2); // getUsers stored 1 -> display 2
|
|
expect((result.from as { startLine: number }).startLine).toBe(hopLines[0]);
|
|
expect((result.to as { startLine: number }).startLine).toBe(hopLines[3]);
|
|
|
|
// The boundary hop carries the CONTRACT_LINK edge.
|
|
const edgeTypes = (result.edges as Array<{ relType: string }>).map((e) => e.relType);
|
|
expect(edgeTypes).toContain('CONTRACT_LINK');
|
|
|
|
// Real REACHING_DEF enrichment of the consumer segment (intra-procedural).
|
|
expect(result.dataFlow).toEqual([
|
|
expect.objectContaining({
|
|
repo: 'app/frontend',
|
|
variable: 'userId',
|
|
hops: expect.arrayContaining([expect.objectContaining({ line: 4, variable: 'userId' })]),
|
|
}),
|
|
]);
|
|
|
|
// The provider repo has no PDG layer → a degraded note, but the trace is ok.
|
|
expect(result.notes).toEqual(
|
|
expect.arrayContaining([expect.stringContaining('No PDG layer in app/backend')]),
|
|
);
|
|
});
|
|
|
|
// A SECOND @group call in the same process — exercises the bridge read-only
|
|
// reopen that previously failed (closeBridgeDb used to CHECKPOINT read-only
|
|
// handles, leaving a lock artifact). Now fixed, so repeated @group traces work.
|
|
it('omitting pdg yields the same stitched path with no data-flow enrichment', async () => {
|
|
const result = await backend.callTool('trace', {
|
|
repo: '@grp',
|
|
from: 'checkout',
|
|
to: 'getUsers',
|
|
});
|
|
expect(result.status).toBe('ok');
|
|
expect(result.dataFlow).toBeUndefined();
|
|
expect((result.crossings as unknown[]).length).toBe(1);
|
|
expect((result.hops as Array<{ name: string }>).map((h) => h.name)).toEqual([
|
|
'checkout',
|
|
'callUsers',
|
|
'handleUsers',
|
|
'getUsers',
|
|
]);
|
|
});
|
|
|
|
it('single-repo trace against one member is unchanged (no group routing)', async () => {
|
|
const result = await backend.callTool('trace', {
|
|
repo: 'reg-fe',
|
|
from: 'checkout',
|
|
to: 'callUsers',
|
|
});
|
|
expect(result.status).toBe('ok');
|
|
// Plain single-repo result shape — no crossings field.
|
|
expect(result.crossings).toBeUndefined();
|
|
expect(result.hops.map((h: { name: string }) => h.name)).toEqual(['checkout', 'callUsers']);
|
|
});
|
|
});
|