GitNexus/gitnexus/test/integration/mcp-line-display.test.ts
Gergő Magyar fbffa96554
fix(lbug/mcp): exact symbol content + 0-based line storage with 1-based MCP display (#2377, #2379) (#2380)
* 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>
2026-07-06 16:16:45 +01:00

126 lines
6.2 KiB
TypeScript

/**
* Integration test: MCP tools present 1-based line numbers (#2377), while raw
* `cypher` returns the stored 0-based value unchanged.
*
* GraphNode startLine/endLine are stored 0-based (the tree-sitter convention;
* see ingestion/utils/line-base.ts). Human/LLM-facing tools (context, query,
* impact) add 1 at the response boundary so the numbers line up with editors /
* `sed`; the raw `cypher` passthrough stays 0-based and is documented.
*
* One shared LadybugDB (with FTS) backs every case so query()'s BM25 path is
* exercised without a second full DB+FTS setup.
*/
import { describe, expect, it, vi } from 'vitest';
import { LocalBackend } from '../../src/mcp/local/local-backend.js';
import { listRegisteredRepos } from '../../src/storage/repo-manager.js';
import { withTestLbugDB } from '../helpers/test-indexed-db.js';
import { FTS_INDEXES } from '../../src/core/search/fts-schema.js';
const PRODUCTION_FTS_INDEXES = FTS_INDEXES.map((i) => ({
table: i.table,
indexName: i.indexName,
columns: [...i.properties],
}));
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([]),
};
});
// Stored 0-based: App occupies 0-based lines 41..58 (editor lines 42..59).
// TopFn sits on the file's first line (stored 0-based 0) — the #2380 falsy-`||`
// case where `sym.startLine || sym[4]` would drop the line entirely.
// Two DupFn symbols force impact()'s ambiguous branch, the only impact response
// that surfaces a per-candidate line. Zqxwvbm carries a distinctive content
// token so query()'s BM25/FTS retriever surfaces it (the #2380 P1 path).
const SEED = [
`CREATE (c:Class {id:'Class:src/app.ts:App', name:'App', filePath:'src/app.ts', startLine:41, endLine:58, content:'class App {}', description:''})`,
`CREATE (c:Class {id:'Class:src/top.ts:TopFn', name:'TopFn', filePath:'src/top.ts', startLine:0, endLine:0, content:'class TopFn {}', description:''})`,
`CREATE (f:Function {id:'Function:src/a.ts:DupFn', name:'DupFn', filePath:'src/a.ts', startLine:41, endLine:50, content:'function DupFn() {}', description:''})`,
`CREATE (f:Function {id:'Function:src/b.ts:DupFn', name:'DupFn', filePath:'src/b.ts', startLine:7, endLine:12, content:'function DupFn() {}', description:''})`,
`CREATE (c:Class {id:'Class:src/svc.ts:Zqxwvbm', name:'Zqxwvbm', filePath:'src/svc.ts', startLine:41, endLine:58, content:'class Zqxwvbm zqxwvbmtoken', description:'zqxwvbmtoken service'})`,
];
let backend: LocalBackend;
withTestLbugDB(
'mcp-line-display',
() => {
describe('MCP line-number display (#2377): tools 1-based, raw cypher 0-based', () => {
it('context() reports 1-based startLine/endLine (editor / sed aligned)', async () => {
const result = await backend.callTool('context', { uid: 'Class:src/app.ts:App' });
expect(result.status).toBe('found');
expect(result.symbol.startLine).toBe(42); // stored 0-based 41 -> display 42
expect(result.symbol.endLine).toBe(59); // stored 0-based 58 -> display 59
});
it('context() keeps a 0-based first-line symbol (startLine:0 -> 1, not dropped)', async () => {
// Before #2380 the falsy `sym.startLine || sym[4]` collapsed a valid 0 to
// undefined, so context() omitted startLine/endLine for first-line symbols
// (every COBOL Module, markdown h1). `??` preserves the 0.
const result = await backend.callTool('context', { uid: 'Class:src/top.ts:TopFn' });
expect(result.status).toBe('found');
expect(result.symbol.startLine).toBe(1); // stored 0-based 0 -> display 1
expect(result.symbol.endLine).toBe(1);
});
it('impact() ambiguous candidates report 1-based line (stored 41 -> 42)', async () => {
const result = await backend.callTool('impact', { target: 'DupFn' });
expect(result.status).toBe('ambiguous');
const cand = (result.candidates as Array<{ filePath: string; line: number }>).find(
(c) => c.filePath === 'src/a.ts',
);
expect(cand).toBeDefined();
expect(cand!.line).toBe(42); // stored 0-based 41 -> display 42
});
it('query() BM25 path converts the line exactly once (stored 41 -> 42, not 43)', async () => {
// bm25Search returns raw 0-based rows; query()'s aggregation applies
// toDisplayLine once. Before #2380 both converted -> 43 (#2380 P1).
type QuerySymbol = { id: string; startLine?: number; endLine?: number };
type QueryResult = { definitions?: QuerySymbol[]; process_symbols?: QuerySymbol[] };
const result: QueryResult = await backend.callTool('query', { query: 'zqxwvbmtoken' });
const sym = [...(result.process_symbols ?? []), ...(result.definitions ?? [])].find(
(s) => s.id === 'Class:src/svc.ts:Zqxwvbm',
);
expect(sym).toBeDefined();
expect(sym!.startLine).toBe(42); // 41 + 1, converted exactly once
expect(sym!.endLine).toBe(59); // 58 + 1
});
it('raw cypher returns the stored 0-based value unchanged', async () => {
const result = await backend.callTool('cypher', {
statement: "MATCH (n:Class {name:'App'}) RETURN n.startLine AS startLine",
});
expect(result).toHaveProperty('markdown');
// If display-conversion leaked into raw cypher this would read 42.
expect(result.markdown).toContain('41');
expect(result.markdown).not.toContain('42');
});
});
},
{
seed: SEED,
ftsIndexes: PRODUCTION_FTS_INDEXES,
poolAdapter: true,
afterSetup: async (handle) => {
vi.mocked(listRegisteredRepos).mockResolvedValue([
{
name: 'test-repo',
path: '/test/repo',
storagePath: handle.tmpHandle.dbPath,
indexedAt: new Date().toISOString(),
lastCommit: 'abc123',
stats: { files: 1, nodes: 5, communities: 0, processes: 0 },
},
]);
backend = new LocalBackend();
await backend.init();
},
},
);