mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-10 03:27:59 +00:00
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>
This commit is contained in:
parent
177bbc89c3
commit
fbffa96554
26 changed files with 555 additions and 87 deletions
|
|
@ -15,6 +15,10 @@ export interface ManifestExtractResult {
|
|||
// reserved-keyword labels `Macro` and `Union`, and LadybugDB's parser rejects
|
||||
// a disjunction that names a reserved keyword (#2325) — which the resolver's
|
||||
// try/catch then swallowed. `labels(n) IN` has no such collision.
|
||||
// This list overlaps `ingestion/utils/symbol-labels.ts` (SYMBOL_NODE_LABELS) but
|
||||
// is a deliberate SUBSET — it omits `Namespace`/`Variable`/`Module`. Unifying the
|
||||
// two would widen which nodes resolve as contract symbols and must update the
|
||||
// #2325 test, so they are intentionally kept separate for now.
|
||||
export const CUSTOM_CONTRACT_RESOLVE_QUERY = `MATCH (n)
|
||||
WHERE labels(n) IN ['Function','Method','Class','Interface','Struct','Enum','Trait','Constructor','TypeAlias','Impl','Macro','Union','Typedef','Property','Record','Delegate','Annotation','Template','Const','Static','CodeElement']
|
||||
AND n.name = $symbolName
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
|
||||
import path from 'node:path';
|
||||
import { generateId } from '../../lib/utils.js';
|
||||
import { toZeroBasedLine } from './utils/line-base.js';
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import type { KnowledgeGraph } from '../graph/types.js';
|
||||
import {
|
||||
|
|
@ -359,8 +360,8 @@ function mapToGraph(
|
|||
properties: {
|
||||
name: extracted.programName,
|
||||
filePath,
|
||||
startLine: 1,
|
||||
endLine: lines.length,
|
||||
startLine: toZeroBasedLine(1),
|
||||
endLine: toZeroBasedLine(lines.length),
|
||||
language: SupportedLanguages.Cobol,
|
||||
isExported: true,
|
||||
description: metaDesc || undefined,
|
||||
|
|
@ -394,8 +395,8 @@ function mapToGraph(
|
|||
properties: {
|
||||
name: prog.name,
|
||||
filePath,
|
||||
startLine: prog.startLine,
|
||||
endLine: prog.endLine,
|
||||
startLine: toZeroBasedLine(prog.startLine),
|
||||
endLine: toZeroBasedLine(prog.endLine),
|
||||
language: SupportedLanguages.Cobol,
|
||||
isExported: true,
|
||||
description: `nested-program${prog.isCommon ? ' common' : ''}`,
|
||||
|
|
@ -442,8 +443,8 @@ function mapToGraph(
|
|||
properties: {
|
||||
name: sec.name,
|
||||
filePath,
|
||||
startLine: sec.line,
|
||||
endLine: nextLine,
|
||||
startLine: toZeroBasedLine(sec.line),
|
||||
endLine: toZeroBasedLine(nextLine),
|
||||
language: SupportedLanguages.Cobol,
|
||||
isExported: true,
|
||||
},
|
||||
|
|
@ -477,8 +478,8 @@ function mapToGraph(
|
|||
properties: {
|
||||
name: para.name,
|
||||
filePath,
|
||||
startLine: para.line,
|
||||
endLine: nextLine,
|
||||
startLine: toZeroBasedLine(para.line),
|
||||
endLine: toZeroBasedLine(nextLine),
|
||||
language: SupportedLanguages.Cobol,
|
||||
isExported: true,
|
||||
},
|
||||
|
|
@ -511,8 +512,8 @@ function mapToGraph(
|
|||
properties: {
|
||||
name: item.name,
|
||||
filePath,
|
||||
startLine: item.line,
|
||||
endLine: item.line,
|
||||
startLine: toZeroBasedLine(item.line),
|
||||
endLine: toZeroBasedLine(item.line),
|
||||
language: SupportedLanguages.Cobol,
|
||||
description: `level:${item.level} section:${item.section}${item.pic ? ` pic:${item.pic}` : ''}`,
|
||||
},
|
||||
|
|
@ -614,8 +615,8 @@ function mapToGraph(
|
|||
properties: {
|
||||
name: `CALL ${call.target}`,
|
||||
filePath,
|
||||
startLine: call.line,
|
||||
endLine: call.line,
|
||||
startLine: toZeroBasedLine(call.line),
|
||||
endLine: toZeroBasedLine(call.line),
|
||||
language: SupportedLanguages.Cobol,
|
||||
description: 'dynamic-call (target is a data item, not resolvable statically)',
|
||||
},
|
||||
|
|
@ -742,8 +743,8 @@ function mapToGraph(
|
|||
properties: {
|
||||
name: `EXEC SQL ${sql.operation}`,
|
||||
filePath,
|
||||
startLine: sql.line,
|
||||
endLine: sql.line,
|
||||
startLine: toZeroBasedLine(sql.line),
|
||||
endLine: toZeroBasedLine(sql.line),
|
||||
language: SupportedLanguages.Cobol,
|
||||
description: `tables:[${sql.tables.join(',')}] cursors:[${sql.cursors.join(',')}]`,
|
||||
},
|
||||
|
|
@ -817,8 +818,8 @@ function mapToGraph(
|
|||
properties: {
|
||||
name: `EXEC CICS ${cics.command}`,
|
||||
filePath,
|
||||
startLine: cics.line,
|
||||
endLine: cics.line,
|
||||
startLine: toZeroBasedLine(cics.line),
|
||||
endLine: toZeroBasedLine(cics.line),
|
||||
language: SupportedLanguages.Cobol,
|
||||
description:
|
||||
[
|
||||
|
|
@ -856,8 +857,8 @@ function mapToGraph(
|
|||
properties: {
|
||||
name: `CICS ${cics.command} ${cics.programName}`,
|
||||
filePath,
|
||||
startLine: cics.line,
|
||||
endLine: cics.line,
|
||||
startLine: toZeroBasedLine(cics.line),
|
||||
endLine: toZeroBasedLine(cics.line),
|
||||
language: SupportedLanguages.Cobol,
|
||||
description: `cics-dynamic-program (target is data item ${cics.programName})`,
|
||||
},
|
||||
|
|
@ -1029,8 +1030,8 @@ function mapToGraph(
|
|||
properties: {
|
||||
name: entry.name,
|
||||
filePath,
|
||||
startLine: entry.line,
|
||||
endLine: entry.line,
|
||||
startLine: toZeroBasedLine(entry.line),
|
||||
endLine: toZeroBasedLine(entry.line),
|
||||
language: SupportedLanguages.Cobol,
|
||||
isExported: true,
|
||||
description:
|
||||
|
|
@ -1176,8 +1177,8 @@ function mapToGraph(
|
|||
properties: {
|
||||
name: `EXEC DLI ${dli.verb}`,
|
||||
filePath,
|
||||
startLine: dli.line,
|
||||
endLine: dli.line,
|
||||
startLine: toZeroBasedLine(dli.line),
|
||||
endLine: toZeroBasedLine(dli.line),
|
||||
language: SupportedLanguages.Cobol,
|
||||
description:
|
||||
[
|
||||
|
|
@ -1316,8 +1317,8 @@ function mapToGraph(
|
|||
properties: {
|
||||
name: fd.selectName,
|
||||
filePath,
|
||||
startLine: fd.line,
|
||||
endLine: fd.line,
|
||||
startLine: toZeroBasedLine(fd.line),
|
||||
endLine: toZeroBasedLine(fd.line),
|
||||
language: SupportedLanguages.Cobol,
|
||||
description: `assign:${fd.assignTo}${fd.isOptional ? ' optional' : ''}${fd.organization ? ` org:${fd.organization}` : ''}${fd.access ? ` access:${fd.access}` : ''}`,
|
||||
},
|
||||
|
|
@ -1406,8 +1407,8 @@ function mapToGraph(
|
|||
properties: {
|
||||
name: `CANCEL ${cancel.target}`,
|
||||
filePath,
|
||||
startLine: cancel.line,
|
||||
endLine: cancel.line,
|
||||
startLine: toZeroBasedLine(cancel.line),
|
||||
endLine: toZeroBasedLine(cancel.line),
|
||||
language: SupportedLanguages.Cobol,
|
||||
description: 'dynamic-cancel (target is a data item, not resolvable statically)',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
import { parseJcl, type JclParseResults } from './jcl-parser.js';
|
||||
import type { KnowledgeGraph } from '../../graph/types.js';
|
||||
import { generateId } from '../../../lib/utils.js';
|
||||
import { toZeroBasedLine } from '../utils/line-base.js';
|
||||
|
||||
export interface JclProcessResult {
|
||||
jobCount: number;
|
||||
|
|
@ -98,8 +99,8 @@ function integrateJclResults(
|
|||
properties: {
|
||||
name: job.name,
|
||||
filePath,
|
||||
startLine: job.line,
|
||||
endLine: job.line,
|
||||
startLine: toZeroBasedLine(job.line),
|
||||
endLine: toZeroBasedLine(job.line),
|
||||
description: `jcl-job${classPart}${msgPart}`,
|
||||
},
|
||||
});
|
||||
|
|
@ -137,8 +138,8 @@ function integrateJclResults(
|
|||
properties: {
|
||||
name: step.name,
|
||||
filePath,
|
||||
startLine: step.line,
|
||||
endLine: step.line,
|
||||
startLine: toZeroBasedLine(step.line),
|
||||
endLine: toZeroBasedLine(step.line),
|
||||
description: `jcl-step${pgmPart}${procPart}`,
|
||||
},
|
||||
});
|
||||
|
|
@ -209,8 +210,8 @@ function integrateJclResults(
|
|||
properties: {
|
||||
name: dd.dataset,
|
||||
filePath,
|
||||
startLine: dd.line,
|
||||
endLine: dd.line,
|
||||
startLine: toZeroBasedLine(dd.line),
|
||||
endLine: toZeroBasedLine(dd.line),
|
||||
|
||||
description: `jcl-dataset${dispPart}`,
|
||||
},
|
||||
|
|
@ -244,8 +245,8 @@ function integrateJclResults(
|
|||
properties: {
|
||||
name: proc.name,
|
||||
filePath,
|
||||
startLine: proc.line,
|
||||
endLine: proc.line,
|
||||
startLine: toZeroBasedLine(proc.line),
|
||||
endLine: toZeroBasedLine(proc.line),
|
||||
description: 'jcl-proc-instream',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ import type {
|
|||
} from 'gitnexus-shared';
|
||||
import type { KnowledgeGraph } from '../graph/types.js';
|
||||
import type { ScopeResolutionIndexes } from './model/scope-resolution-indexes.js';
|
||||
import { toZeroBasedLine } from './utils/line-base.js';
|
||||
|
||||
// ─── Public API ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -140,8 +141,8 @@ export function emitScopeGraph(input: {
|
|||
properties: {
|
||||
name: scope.kind,
|
||||
filePath: scope.filePath,
|
||||
startLine: scope.range.startLine,
|
||||
endLine: scope.range.endLine,
|
||||
startLine: toZeroBasedLine(scope.range.startLine),
|
||||
endLine: toZeroBasedLine(scope.range.endLine),
|
||||
description: `Scope: ${scope.kind}`,
|
||||
} as unknown as Parameters<KnowledgeGraph['addNode']>[0]['properties'],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
import path from 'node:path';
|
||||
import { generateId } from '../../lib/utils.js';
|
||||
import { toZeroBasedLine } from './utils/line-base.js';
|
||||
import type { GraphNode } from 'gitnexus-shared';
|
||||
import { KnowledgeGraph } from '../graph/types.js';
|
||||
|
||||
|
|
@ -81,8 +82,8 @@ export const processMarkdown = (
|
|||
properties: {
|
||||
name: heading,
|
||||
filePath: file.path,
|
||||
startLine: lineNum,
|
||||
endLine,
|
||||
startLine: toZeroBasedLine(lineNum),
|
||||
endLine: toZeroBasedLine(endLine),
|
||||
level,
|
||||
description: `h${level}`,
|
||||
},
|
||||
|
|
|
|||
20
gitnexus/src/core/ingestion/utils/line-base.ts
Normal file
20
gitnexus/src/core/ingestion/utils/line-base.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
/**
|
||||
* Convert a 1-based source line number to the 0-based convention used by
|
||||
* GraphNode `startLine`/`endLine`.
|
||||
*
|
||||
* The graph layer stores line numbers 0-based (tree-sitter `startPosition.row`),
|
||||
* and this is load-bearing: the taint/PDG/CFG join and the MCP consumers all add
|
||||
* `+ 1` to recover 1-based (see `summary-harvest-driver.ts` — "Function/Method
|
||||
* node startLine is 0-based"). Most emitters get 0-based for free from
|
||||
* tree-sitter. The exceptions are the regex-based COBOL/JCL processors (their
|
||||
* parsers use `lineNum = i + 1`) and the scope-capture path (`Capture` ranges
|
||||
* are 1-based per RFC §2.1). Those must convert to 0-based when they build a
|
||||
* graph node, or the exact-content slice in `csv-generator.ts` drops the
|
||||
* symbol's declaration line (#2379) and reported line numbers are off (#2377).
|
||||
*
|
||||
* Apply this ONLY at the graph-node `startLine:`/`endLine:` assignment. The
|
||||
* parser-internal 1-based values (`.line`, `prog.startLine`) stay 1-based —
|
||||
* they feed `L${line}` node/edge IDs and line-range containment checks that
|
||||
* must not shift. The clamp guards degenerate inputs (line 0 / empty files).
|
||||
*/
|
||||
export const toZeroBasedLine = (oneBasedLine: number): number => Math.max(0, oneBasedLine - 1);
|
||||
47
gitnexus/src/core/ingestion/utils/symbol-labels.ts
Normal file
47
gitnexus/src/core/ingestion/utils/symbol-labels.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import type { NodeLabel } from 'gitnexus-shared';
|
||||
|
||||
/**
|
||||
* Graph-node labels that represent a resolvable code symbol — a definition with
|
||||
* its own source span (function, type, member, module-like container).
|
||||
*
|
||||
* These get EXACT source-span content in the FTS index: `csv-generator.ts`
|
||||
* slices exactly `[startLine, endLine]` for them (no ±2 padding), while every
|
||||
* other label keeps the context window. That exactness depends on the 0-based
|
||||
* `startLine`/`endLine` invariant enforced by `line-base.ts` — the slice is only
|
||||
* correct because all emitters store 0-based lines. Keep the two together.
|
||||
*
|
||||
* Single source of truth so the set can't silently drift the way the inline copy
|
||||
* did in #2379.
|
||||
*
|
||||
* NOTE: `group/extractors/manifest-extractor.ts`'s `CUSTOM_CONTRACT_RESOLVE_QUERY`
|
||||
* carries a near-identical hand-list that is intentionally a SUBSET — it excludes
|
||||
* `Namespace`, `Variable`, `Module`. Unifying the two needs a contract-resolution
|
||||
* behavior check (would widen which nodes resolve as contract symbols), so it is
|
||||
* deliberately left separate for now.
|
||||
*/
|
||||
export const SYMBOL_NODE_LABELS: ReadonlySet<NodeLabel> = new Set<NodeLabel>([
|
||||
'Function',
|
||||
'Method',
|
||||
'Class',
|
||||
'Interface',
|
||||
'CodeElement',
|
||||
'Struct',
|
||||
'Enum',
|
||||
'Macro',
|
||||
'Typedef',
|
||||
'Union',
|
||||
'Namespace',
|
||||
'Trait',
|
||||
'Impl',
|
||||
'TypeAlias',
|
||||
'Const',
|
||||
'Static',
|
||||
'Variable',
|
||||
'Property',
|
||||
'Record',
|
||||
'Delegate',
|
||||
'Annotation',
|
||||
'Constructor',
|
||||
'Template',
|
||||
'Module',
|
||||
]);
|
||||
|
|
@ -20,6 +20,7 @@ import { KnowledgeGraph } from '../graph/types.js';
|
|||
import { NodeTableName, NODE_TABLES } from './schema.js';
|
||||
import { RelPairRouter } from './rel-pair-routing.js';
|
||||
import { parseTruthyEnv } from '../ingestion/utils/env.js';
|
||||
import { SYMBOL_NODE_LABELS } from '../ingestion/utils/symbol-labels.js';
|
||||
import { applyCjkSegmentationIfEnabled } from '../search/cjk-segmentation.js';
|
||||
|
||||
/**
|
||||
|
|
@ -212,6 +213,11 @@ export const normalizeFtsText = (text: string): string => text.replace(/[\r\n\t]
|
|||
const formatFtsDescription = (description: string): string =>
|
||||
normalizeFtsText(applyCjkSegmentationIfEnabled(description));
|
||||
|
||||
// Labels that get exact source-span content (no ±2 window). Single source of
|
||||
// truth in `symbol-labels.ts` — see there for why the exactness depends on the
|
||||
// 0-based line invariant. Kept as a named alias to read intent at the use site.
|
||||
const EXACT_SYMBOL_CONTENT_LABELS = SYMBOL_NODE_LABELS;
|
||||
|
||||
const extractContent = async (node: GraphNode, contentCache: FileContentCache): Promise<string> => {
|
||||
const filePath = node.properties.filePath;
|
||||
const content = await contentCache.get(filePath);
|
||||
|
|
@ -233,8 +239,9 @@ const extractContent = async (node: GraphNode, contentCache: FileContentCache):
|
|||
if (startLine === undefined || endLine === undefined) return '';
|
||||
|
||||
const lines = content.split('\n');
|
||||
const start = Math.max(0, startLine - 2);
|
||||
const end = Math.min(lines.length - 1, endLine + 2);
|
||||
const exactSymbolContent = EXACT_SYMBOL_CONTENT_LABELS.has(node.label);
|
||||
const start = Math.max(0, exactSymbolContent ? startLine : startLine - 2);
|
||||
const end = Math.min(lines.length - 1, exactSymbolContent ? endLine : endLine + 2);
|
||||
const snippet = lines.slice(start, end + 1).join('\n');
|
||||
const MAX_SNIPPET = 5000;
|
||||
const capped =
|
||||
|
|
|
|||
25
gitnexus/src/mcp/local/line-display.ts
Normal file
25
gitnexus/src/mcp/local/line-display.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
/**
|
||||
* Convert a 0-based GraphNode `startLine`/`endLine` to the 1-based line number
|
||||
* shown to humans and LLMs in MCP tool output.
|
||||
*
|
||||
* Storage is 0-based (tree-sitter `startPosition.row`; see
|
||||
* `ingestion/utils/line-base.ts`), which matches editors/`sed`/`less -N` only
|
||||
* after `+ 1`. The `context`, `query`, and `impact` tools present line numbers a
|
||||
* user cross-references against source, so they convert here at the response
|
||||
* boundary (#2377).
|
||||
*
|
||||
* Apply ONLY to a symbol node's 0-based `startLine`/`endLine`. Do NOT apply to:
|
||||
* - BasicBlock / CFG `functionStartLine` and PDG statement lines — already
|
||||
* 1-based (they use `startPosition.row + 1`);
|
||||
* - the internal `sym.startLine + 1` join params that target the 1-based
|
||||
* BasicBlock id space;
|
||||
* - raw `cypher` results, which pass LadybugDB columns through verbatim and
|
||||
* stay 0-based (documented).
|
||||
*
|
||||
* `undefined`/`null` pass through so optional line fields stay absent.
|
||||
*/
|
||||
export function toDisplayLine(zeroBasedLine: number): number;
|
||||
export function toDisplayLine(zeroBasedLine: number | null | undefined): number | undefined;
|
||||
export function toDisplayLine(zeroBasedLine: number | null | undefined): number | undefined {
|
||||
return typeof zeroBasedLine === 'number' ? zeroBasedLine + 1 : undefined;
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ import {
|
|||
isLbugReady,
|
||||
} from '../../core/lbug/pool-adapter.js';
|
||||
import { isValidQueryParams } from '../../core/lbug/query-params.js';
|
||||
import { toDisplayLine } from './line-display.js';
|
||||
import { isWalCorruptionError, WAL_RECOVERY_SUGGESTION } from '../../core/lbug/lbug-config.js';
|
||||
// Embedding imports are lazy (dynamic import) to avoid loading onnxruntime-node
|
||||
// at MCP server startup — crashes on unsupported Node ABI versions (#89)
|
||||
|
|
@ -702,7 +703,7 @@ export class LocalBackend {
|
|||
query: (r, p) => this.query(r as RepoHandle, p),
|
||||
impactByUid: (id, uid, d, o) => this.impactByUid(id, uid, d, o),
|
||||
context: (r, p) => this.context(r as RepoHandle, p),
|
||||
trace: (r, p) => this.trace(r as RepoHandle, p),
|
||||
trace: (r, p) => this.traceForGroup(r as RepoHandle, p),
|
||||
resolveSymbol: (r, q) => this.resolveSymbolForGroup(r as RepoHandle, q),
|
||||
pdgFlows: (r, anchor, opts) => this.pdgFlowsForGroup(r as RepoHandle, anchor, opts),
|
||||
};
|
||||
|
|
@ -711,6 +712,24 @@ export class LocalBackend {
|
|||
return this.groupToolSvc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapt local `trace` to the group port. The assembled group/cross-repo trace
|
||||
* presents 1-based endpoints (via resolveSymbolForGroup), so convert the hop
|
||||
* lines here too — otherwise one response mixes 1-based endpoints with 0-based
|
||||
* hops (#2380). Single-repo `trace` dispatches directly (not through this
|
||||
* port) and stays 0-based (documented full-parity follow-up).
|
||||
*/
|
||||
private async traceForGroup(repo: RepoHandle, params: TraceParams): Promise<unknown> {
|
||||
const result = await this.trace(repo, params);
|
||||
const hops = (result as { hops?: Array<{ startLine?: number | null }> }).hops;
|
||||
if (Array.isArray(hops)) {
|
||||
for (const hop of hops) {
|
||||
hop.startLine = toDisplayLine(hop.startLine);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapt the shared symbol resolver to the GroupToolPort contract. Used by the
|
||||
* cross-repo trace path to locate which member repo an endpoint lives in and
|
||||
|
|
@ -735,8 +754,8 @@ export class LocalBackend {
|
|||
name: s.name,
|
||||
type: s.type,
|
||||
filePath: s.filePath,
|
||||
startLine: s.startLine,
|
||||
endLine: s.endLine,
|
||||
startLine: toDisplayLine(s.startLine),
|
||||
endLine: toDisplayLine(s.endLine),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -748,7 +767,7 @@ export class LocalBackend {
|
|||
name: c.name,
|
||||
type: c.type,
|
||||
filePath: c.filePath,
|
||||
startLine: c.startLine,
|
||||
startLine: toDisplayLine(c.startLine),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
|
@ -1987,8 +2006,8 @@ export class LocalBackend {
|
|||
name: sym.name,
|
||||
type: sym.type,
|
||||
filePath: sym.filePath,
|
||||
startLine: sym.startLine,
|
||||
endLine: sym.endLine,
|
||||
startLine: toDisplayLine(sym.startLine),
|
||||
endLine: toDisplayLine(sym.endLine),
|
||||
...(module ? { module } : {}),
|
||||
...(includeContent && content ? { content } : {}),
|
||||
};
|
||||
|
|
@ -2255,8 +2274,11 @@ export class LocalBackend {
|
|||
name: sym.name || sym[1],
|
||||
type: sym.type || sym[2],
|
||||
filePath: sym.filePath || sym[3],
|
||||
startLine: sym.startLine || sym[4],
|
||||
endLine: sym.endLine || sym[5],
|
||||
// Raw 0-based here — `bm25Search` is only called from `query()`,
|
||||
// whose aggregation loop applies `toDisplayLine` once (see below).
|
||||
// Converting here too would double-shift BM25-matched lines (#2380).
|
||||
startLine: sym.startLine ?? sym[4],
|
||||
endLine: sym.endLine ?? sym[5],
|
||||
bm25Score: bm25Result.score,
|
||||
});
|
||||
}
|
||||
|
|
@ -2989,7 +3011,7 @@ export class LocalBackend {
|
|||
name: c.name,
|
||||
kind: c.type,
|
||||
filePath: c.filePath,
|
||||
line: c.startLine,
|
||||
line: toDisplayLine(c.startLine),
|
||||
score: Number(c.score.toFixed(2)),
|
||||
})),
|
||||
};
|
||||
|
|
@ -3246,8 +3268,8 @@ export class LocalBackend {
|
|||
name: sym.name || sym[1],
|
||||
kind: symKind,
|
||||
filePath: sym.filePath || sym[3],
|
||||
startLine: sym.startLine || sym[4],
|
||||
endLine: sym.endLine || sym[5],
|
||||
startLine: toDisplayLine(sym.startLine ?? sym[4]),
|
||||
endLine: toDisplayLine(sym.endLine ?? sym[5]),
|
||||
...(include_content && (sym.content || sym[6]) ? { content: sym.content || sym[6] } : {}),
|
||||
...(methodMetadata ? { methodMetadata } : {}),
|
||||
},
|
||||
|
|
@ -3334,7 +3356,7 @@ export class LocalBackend {
|
|||
name: c.name,
|
||||
kind: c.type,
|
||||
filePath: c.filePath,
|
||||
line: c.startLine,
|
||||
line: toDisplayLine(c.startLine),
|
||||
score: Number(c.score.toFixed(2)),
|
||||
})),
|
||||
},
|
||||
|
|
@ -3355,11 +3377,14 @@ export class LocalBackend {
|
|||
anchorClause:
|
||||
'a.id STARTS WITH $idPrefix AND a.startLine >= $symStart AND a.startLine <= $symEnd',
|
||||
queryParams: { idPrefix, symStart: sym.startLine + 1, symEnd: sym.endLine + 1 },
|
||||
// Display anchor is 1-based, matching the ambiguous-candidate branch and
|
||||
// the context/query/impact tools (#2380). This is display-only — the
|
||||
// BasicBlock join above uses the raw `sym.startLine + 1` in `symStart`.
|
||||
anchor: {
|
||||
file: sym.filePath,
|
||||
symbol: sym.name,
|
||||
startLine: sym.startLine,
|
||||
endLine: sym.endLine,
|
||||
startLine: toDisplayLine(sym.startLine),
|
||||
endLine: toDisplayLine(sym.endLine),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -4951,7 +4976,7 @@ export class LocalBackend {
|
|||
name: c.name,
|
||||
kind: c.type,
|
||||
filePath: c.filePath,
|
||||
line: c.startLine,
|
||||
line: toDisplayLine(c.startLine),
|
||||
score: Number(c.score.toFixed(2)),
|
||||
})),
|
||||
};
|
||||
|
|
@ -5016,7 +5041,7 @@ export class LocalBackend {
|
|||
name: c.name,
|
||||
kind: c.type,
|
||||
filePath: c.filePath,
|
||||
line: c.startLine,
|
||||
line: toDisplayLine(c.startLine),
|
||||
score: Number(c.score.toFixed(2)),
|
||||
impactedCount: summary?.impactedCount ?? 0,
|
||||
risk: summary?.risk ?? 'UNKNOWN',
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import type { executeParameterized } from '../../core/lbug/pool-adapter.js';
|
|||
import { loadMeta } from '../../storage/repo-manager.js';
|
||||
import { IMPACT_MAX_DEPTH, PDG_QUERY_DEFAULT_LIMIT, PDG_QUERY_MAX_LIMIT } from '../tools.js';
|
||||
import { CALLEES_TRUNCATED_SENTINEL, CALLEE_ID_SEP } from '../../core/ingestion/cfg/emit.js';
|
||||
import { toDisplayLine } from './line-display.js';
|
||||
import { decodeCallSummary } from '../../core/ingestion/taint/call-summary-codec.js';
|
||||
import { decodeReachingDefReason } from '../../core/ingestion/cfg/reaching-def-reason-codec.js';
|
||||
import { getProviderForFile } from '../../core/ingestion/languages/index.js';
|
||||
|
|
@ -90,8 +91,10 @@ export function splitCalleeIds(raw: unknown): string[] {
|
|||
* Contract version of the mode:'pdg' impact result shape. A stable discriminator
|
||||
* for external MCP/agent consumers — distinct from the DB INCREMENTAL_SCHEMA_VERSION.
|
||||
* Bump on any breaking change to the PDG result fields.
|
||||
* v2: `startLine` in the result is now 1-based display (#2380), matching the
|
||||
* context/query/impact tools (was 0-based).
|
||||
*/
|
||||
export const PDG_RESULT_VERSION = 1 as const;
|
||||
export const PDG_RESULT_VERSION = 2 as const;
|
||||
|
||||
/** A reachable dependence block resolved to its source statement. */
|
||||
export interface PdgStatement {
|
||||
|
|
@ -582,7 +585,7 @@ export interface PdgInterproceduralImpact {
|
|||
export interface PdgImpactBaseResult extends PdgImpactParityFields {
|
||||
mode: 'pdg';
|
||||
/** Contract version of the mode:'pdg' impact result shape; bump on any breaking change to the PDG result fields. */
|
||||
pdgResultVersion: 1;
|
||||
pdgResultVersion: 2;
|
||||
target: PdgImpactTarget;
|
||||
direction: 'upstream' | 'downstream';
|
||||
impactedCount: number;
|
||||
|
|
@ -655,7 +658,7 @@ export interface PdgImpactDegradedResult extends PdgImpactBaseResult {
|
|||
export interface PdgImpactErrorResult {
|
||||
mode?: 'pdg';
|
||||
/** Contract version of the mode:'pdg' impact result shape; bump on any breaking change to the PDG result fields. */
|
||||
pdgResultVersion: 1;
|
||||
pdgResultVersion: 2;
|
||||
error: string;
|
||||
target: PdgImpactTarget;
|
||||
direction: 'upstream' | 'downstream';
|
||||
|
|
@ -809,7 +812,7 @@ function assemblePdgImpactResult(input: {
|
|||
name: s.name,
|
||||
type: s.type,
|
||||
filePath: s.filePath,
|
||||
...(s.startLine !== undefined ? { startLine: s.startLine } : {}),
|
||||
...(s.startLine !== undefined ? { startLine: toDisplayLine(s.startLine) } : {}),
|
||||
...(s.ambiguous ? { ambiguous: true } : {}),
|
||||
...(s.id === null ? { unresolved: true } : {}),
|
||||
pdgEvidence: (s.id === null ? 'degraded' : 'owner-projection') as PdgImpactEvidence,
|
||||
|
|
|
|||
|
|
@ -450,6 +450,7 @@ additional_node_types: "Multi-language: Struct, Enum, Macro, Typedef, Union, Nam
|
|||
|
||||
node_properties:
|
||||
common: "name (STRING), filePath (STRING), startLine (INT32), endLine (INT32)"
|
||||
line_numbers: "startLine/endLine on symbol nodes are 0-BASED (tree-sitter rows) in storage AND in raw Cypher results. The context, query, impact, group/cross-repo trace, and explain/pdg_query (symbol anchor) tools present them 1-BASED (editor / sed / less -N aligned), so a symbol spans editor lines (startLine+1)..(endLine+1) — e.g. sed '<startLine+1>,<endLine+1>!d' <file>. Single-repo trace symbol lines stay 0-BASED for now (full-parity follow-up). content holds the exact symbol span. (BasicBlock / PDG statement lines are separately 1-based.) (#2377, #2380)"
|
||||
Method: "parameterCount (INT32), returnType (STRING), isVariadic (BOOL), visibility (STRING), isStatic (BOOL), isAbstract (BOOL), isFinal (BOOL), isVirtual (BOOL), isOverride (BOOL), isAsync (BOOL), isPartial (BOOL), requiredParameterCount (INT32), parameterTypes (STRING[]), annotations (STRING[])"
|
||||
Function: "parameterCount (INT32), returnType (STRING), isVariadic (BOOL), visibility (STRING), isStatic (BOOL), isAbstract (BOOL), isFinal (BOOL), isAsync (BOOL), parameterTypes (STRING[]), annotations (STRING[])"
|
||||
Property: "declaredType (STRING) — the field's type annotation (e.g., 'Address', 'City'). Used for field-access chain resolution."
|
||||
|
|
|
|||
|
|
@ -427,7 +427,7 @@ MODE (opt-in): "callgraph" (default) walks symbol→symbol edges (CALLS/IMPORTS/
|
|||
|
||||
STATEMENT-ANCHORED PDG SLICE: with mode:'pdg', pass "line" (1-based source line within the target symbol) to seed the dependence slice on the statement at that line and return what depends on it in affectedStatements (line + text). Inter-procedural symbols are still reported through interproceduralByDepth/pdgInterprocedural and the compatibility byDepth bucket. Without "line", pdg returns whole-symbol inter-procedural reach plus local whole-symbol PDG diagnostics.
|
||||
|
||||
PDG OUTPUT CONTRACT: every mode:'pdg' result (success, empty, degraded, or error) carries pdgResultVersion:1 — a stable discriminator for external consumers that bumps on any breaking change to the PDG result shape (distinct from the DB schema version). Successful PDG results include mode:'pdg', a full target envelope (id/name/type/filePath), affectedStatements, affectedStatementCount, interproceduralByDepth/pdgInterprocedural for cross-function reach, compatibility byDepth/byDepthCounts, risk:'UNKNOWN', and a note describing the unified contract. Degraded PDG results (no-layer, sub-layer-missing, unknown) keep mode:'pdg', pdgResultVersion:1, target metadata when the target resolves, risk:'UNKNOWN', note/remediation, and empty byDepth parity fields — never a false-safe zero. If depth and limit both bound the slice, truncatedByReasons reports both causes while truncatedBy remains scalar.
|
||||
PDG OUTPUT CONTRACT: every mode:'pdg' result (success, empty, degraded, or error) carries pdgResultVersion:2 — a stable discriminator for external consumers that bumps on any breaking change to the PDG result shape (distinct from the DB schema version). Successful PDG results include mode:'pdg', a full target envelope (id/name/type/filePath), affectedStatements, affectedStatementCount, interproceduralByDepth/pdgInterprocedural for cross-function reach, compatibility byDepth/byDepthCounts, risk:'UNKNOWN', and a note describing the unified contract. Degraded PDG results (no-layer, sub-layer-missing, unknown) keep mode:'pdg', pdgResultVersion:2, target metadata when the target resolves, risk:'UNKNOWN', note/remediation, and empty byDepth parity fields — never a false-safe zero. If depth and limit both bound the slice, truncatedByReasons reports both causes while truncatedBy remains scalar.
|
||||
|
||||
WHEN TO USE: Before making code changes — especially refactoring, renaming, or modifying shared code. Shows what would break.
|
||||
AFTER THIS: Review d=1 items (WILL BREAK). Use context() on high-risk symbols.
|
||||
|
|
|
|||
|
|
@ -271,8 +271,13 @@ export interface RepoMeta {
|
|||
* URL-only id). The incremental writeback preserves unchanged-file rows, so a
|
||||
* top-up against a pre-v5 index would strand old url-keyed Route nodes alongside
|
||||
* new composite-keyed ones — force a full re-analyze instead.
|
||||
* v6: line-number storage flipped to uniform 0-based for the last 1-based
|
||||
* GraphNode emitters — COBOL/JCL/markdown/scope (#2377/#2379/#2380). Incremental
|
||||
* writeback preserves unchanged-file rows, so a top-up against a pre-v6 index
|
||||
* would MIX old 1-based rows with new 0-based ones — and the 1-based MCP display
|
||||
* would render the stale rows one line too high — so force a full re-analyze.
|
||||
*/
|
||||
export const INCREMENTAL_SCHEMA_VERSION = 5;
|
||||
export const INCREMENTAL_SCHEMA_VERSION = 6;
|
||||
|
||||
export interface IndexedRepo {
|
||||
repoPath: string;
|
||||
|
|
|
|||
|
|
@ -171,6 +171,109 @@ describe('streamAllCSVsToDisk', () => {
|
|||
expect(content).toContain('"index.ts"');
|
||||
});
|
||||
|
||||
it('stores exact symbol content, pinned against a ±1 boundary shift', async () => {
|
||||
// Neighbors sit DIRECTLY adjacent to the [2,4] span (no blank buffer), so a
|
||||
// one-line slice shift at either edge — the #2379 COBOL/JCL failure mode —
|
||||
// pulls a guard line into the snippet and fails an assertion.
|
||||
await fs.writeFile(
|
||||
path.join(repoDir, 'src', 'symbol-window.ts'),
|
||||
[
|
||||
'const guardTop = 0;',
|
||||
'const before = 1;',
|
||||
'export function target() {',
|
||||
' return before;',
|
||||
'}',
|
||||
'const after = 2;',
|
||||
'const guardBottom = 3;',
|
||||
].join('\n'),
|
||||
);
|
||||
const graph = buildTestGraph([
|
||||
{
|
||||
id: 'func:target',
|
||||
label: 'Function',
|
||||
name: 'target',
|
||||
filePath: 'src/symbol-window.ts',
|
||||
startLine: 2,
|
||||
endLine: 4,
|
||||
isExported: true,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await streamAllCSVsToDisk(graph, repoDir, csvDir);
|
||||
const functionCsv = result.nodeFiles.get('Function');
|
||||
expect(functionCsv).toBeDefined();
|
||||
const content = await fs.readFile(functionCsv!.csvPath, 'utf-8');
|
||||
expect(content).toContain('export function target()');
|
||||
expect(content).toContain('return before;');
|
||||
// Directly-adjacent neighbors must NOT leak — catches an off-by-one either way.
|
||||
expect(content).not.toContain('const before = 1;');
|
||||
expect(content).not.toContain('const after = 2;');
|
||||
});
|
||||
|
||||
it('stores exact content for a one-line symbol (startLine === endLine)', async () => {
|
||||
await fs.writeFile(
|
||||
path.join(repoDir, 'src', 'one-line.ts'),
|
||||
['AAA_TOP', 'BBB_BEFORE', 'const only = 1;', 'CCC_AFTER', 'DDD_BOTTOM'].join('\n'),
|
||||
);
|
||||
const graph = buildTestGraph([
|
||||
{
|
||||
id: 'func:only',
|
||||
label: 'Function',
|
||||
name: 'only',
|
||||
filePath: 'src/one-line.ts',
|
||||
startLine: 2,
|
||||
endLine: 2,
|
||||
isExported: true,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await streamAllCSVsToDisk(graph, repoDir, csvDir);
|
||||
const functionCsv = result.nodeFiles.get('Function');
|
||||
expect(functionCsv).toBeDefined();
|
||||
const content = await fs.readFile(functionCsv!.csvPath, 'utf-8');
|
||||
expect(content).toContain('const only = 1;');
|
||||
expect(content).not.toContain('BBB_BEFORE');
|
||||
expect(content).not.toContain('CCC_AFTER');
|
||||
});
|
||||
|
||||
it('keeps ±2 neighbor context for non-exact labels (Section)', async () => {
|
||||
// `Section` is NOT in EXACT_SYMBOL_CONTENT_LABELS, so it retains the ±2
|
||||
// context window — the fallback branch the exact-content change left in place.
|
||||
await fs.writeFile(
|
||||
path.join(repoDir, 'src', 'section-window.ts'),
|
||||
[
|
||||
's0_alpha',
|
||||
's1_bravo',
|
||||
's2_charlie',
|
||||
's3_delta',
|
||||
's4_echo',
|
||||
's5_foxtrot',
|
||||
's6_golf',
|
||||
's7_hotel',
|
||||
].join('\n'),
|
||||
);
|
||||
const graph = buildTestGraph([
|
||||
{
|
||||
id: 'sec:s',
|
||||
label: 'Section',
|
||||
name: 's',
|
||||
filePath: 'src/section-window.ts',
|
||||
startLine: 4,
|
||||
endLine: 4,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await streamAllCSVsToDisk(graph, repoDir, csvDir);
|
||||
const sectionCsv = result.nodeFiles.get('Section');
|
||||
expect(sectionCsv).toBeDefined();
|
||||
const content = await fs.readFile(sectionCsv!.csvPath, 'utf-8');
|
||||
expect(content).toContain('s4_echo'); // the section's own line
|
||||
expect(content).toContain('s2_charlie'); // startLine - 2
|
||||
expect(content).toContain('s6_golf'); // endLine + 2
|
||||
expect(content).not.toContain('s1_bravo'); // outside the ±2 window
|
||||
expect(content).not.toContain('s7_hotel');
|
||||
});
|
||||
|
||||
it('keeps full text file content searchable past 10KB', async () => {
|
||||
const lateNeedle = 'late_text_file_needle_after_10kb';
|
||||
await fs.writeFile(
|
||||
|
|
|
|||
|
|
@ -321,6 +321,15 @@ matching:
|
|||
{ 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');
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
* "complete" result.
|
||||
*
|
||||
* This golden asserts the EXACT degraded envelope (not just non-crash):
|
||||
* - the result is still mode:'pdg' with pdgResultVersion:1 (the contract
|
||||
* - the result is still mode:'pdg' with pdgResultVersion:2 (the contract
|
||||
* discriminator);
|
||||
* - the intra slice is PRESENT (CALL_SUMMARY is NOT a required sub-layer — the
|
||||
* index is `ready`, pdgLayer is undefined, risk is UNKNOWN, epistemic is the
|
||||
|
|
@ -75,14 +75,14 @@ withTestLbugDB(
|
|||
});
|
||||
|
||||
describe('CALL_SUMMARY-absent (v3 / pre-FU-C index): the ascent is silent but the user is TOLD', () => {
|
||||
it('returns the EXACT degraded envelope — mode:pdg, pdgResultVersion:1, intra slice present, risk UNKNOWN', async () => {
|
||||
it('returns the EXACT degraded envelope — mode:pdg, pdgResultVersion:2, intra slice present, risk UNKNOWN', async () => {
|
||||
const result = await slice();
|
||||
// Golden envelope: the index is `ready` (CALL_SUMMARY is NOT a required
|
||||
// sub-layer), so this is a real traversal result — NOT a pdgLayer
|
||||
// degradation early-return. The intra slice ran and risk stays UNKNOWN.
|
||||
expect(result).toMatchObject({
|
||||
mode: 'pdg',
|
||||
pdgResultVersion: 1,
|
||||
pdgResultVersion: 2,
|
||||
risk: 'UNKNOWN',
|
||||
epistemic: 'pdg-intra-procedural',
|
||||
target: { id: 'func:fnA', name: 'fnA' },
|
||||
|
|
|
|||
|
|
@ -58,8 +58,8 @@ describe('markdown-processor CRLF tolerance', () => {
|
|||
const sections = getMarkdownSections(graph, filePath);
|
||||
expect(sections.map((s) => s.properties.name)).toEqual(['Title', 'Sub', 'SubSub']);
|
||||
expect(sections.map((s) => s.properties.level)).toEqual([1, 2, 3]);
|
||||
expect(sections.map((s) => s.properties.startLine)).toEqual([1, 3, 5]);
|
||||
expect(sections.map((s) => s.properties.endLine)).toEqual([7, 7, 7]);
|
||||
expect(sections.map((s) => s.properties.startLine)).toEqual([0, 2, 4]);
|
||||
expect(sections.map((s) => s.properties.endLine)).toEqual([6, 6, 6]);
|
||||
for (const s of sections) {
|
||||
expect(String(s.properties.name)).not.toMatch(/\r/);
|
||||
}
|
||||
|
|
@ -81,8 +81,8 @@ describe('markdown-processor CRLF tolerance', () => {
|
|||
const sections = getMarkdownSections(graph, filePath);
|
||||
expect(sections.map((s) => s.properties.name)).toEqual(['Title', 'Sub', 'SubSub']);
|
||||
expect(sections.map((s) => s.properties.level)).toEqual([1, 2, 3]);
|
||||
expect(sections.map((s) => s.properties.startLine)).toEqual([1, 3, 5]);
|
||||
expect(sections.map((s) => s.properties.endLine)).toEqual([7, 7, 7]);
|
||||
expect(sections.map((s) => s.properties.startLine)).toEqual([0, 2, 4]);
|
||||
expect(sections.map((s) => s.properties.endLine)).toEqual([6, 6, 6]);
|
||||
for (const s of sections) {
|
||||
expect(String(s.properties.name)).not.toMatch(/\r/);
|
||||
}
|
||||
|
|
@ -103,8 +103,8 @@ describe('markdown-processor CRLF tolerance', () => {
|
|||
const sections = getMarkdownSections(graph, filePath);
|
||||
expect(sections.map((s) => s.properties.name)).toEqual(['Title', 'Sub']);
|
||||
expect(sections.map((s) => s.properties.level)).toEqual([1, 2]);
|
||||
expect(sections.map((s) => s.properties.startLine)).toEqual([1, 3]);
|
||||
expect(sections.map((s) => s.properties.endLine)).toEqual([5, 5]);
|
||||
expect(sections.map((s) => s.properties.startLine)).toEqual([0, 2]);
|
||||
expect(sections.map((s) => s.properties.endLine)).toEqual([4, 4]);
|
||||
for (const s of sections) {
|
||||
expect(String(s.properties.name)).not.toMatch(/\r/);
|
||||
}
|
||||
|
|
@ -124,8 +124,8 @@ describe('markdown-processor CRLF tolerance', () => {
|
|||
const sections = getMarkdownSections(graph, filePath);
|
||||
expect(sections.map((s) => s.properties.name)).toEqual(['LF Title', 'CRLF Sub', 'Trailing LF']);
|
||||
expect(sections.map((s) => s.properties.level)).toEqual([1, 2, 3]);
|
||||
expect(sections.map((s) => s.properties.startLine)).toEqual([1, 3, 5]);
|
||||
expect(sections.map((s) => s.properties.endLine)).toEqual([7, 7, 7]);
|
||||
expect(sections.map((s) => s.properties.startLine)).toEqual([0, 2, 4]);
|
||||
expect(sections.map((s) => s.properties.endLine)).toEqual([6, 6, 6]);
|
||||
for (const s of sections) {
|
||||
expect(String(s.properties.name)).not.toMatch(/\r/);
|
||||
}
|
||||
|
|
@ -138,7 +138,8 @@ describe('markdown-processor CRLF tolerance', () => {
|
|||
it('reports correct startLine and endLine for CRLF content', () => {
|
||||
const filePath = 'crlf-lines.md';
|
||||
const graph = setupGraphWithFile(filePath);
|
||||
// Lines 1, 3, 5 are headings (1-indexed)
|
||||
// Headings sit on physical lines 1, 3, 5; graph nodes store 0-based
|
||||
// startLine/endLine (the GraphNode convention, #2377) — so 0, 2, 4.
|
||||
const content = '# T\r\nbody\r\n## Sub\r\nmore\r\n### SubSub\r\ntail\r\n';
|
||||
|
||||
processMarkdown(graph, [{ path: filePath, content }], new Set([filePath]));
|
||||
|
|
@ -148,11 +149,11 @@ describe('markdown-processor CRLF tolerance', () => {
|
|||
const subSection = sections.find((s) => s.properties.name === 'Sub');
|
||||
const subSubSection = sections.find((s) => s.properties.name === 'SubSub');
|
||||
|
||||
expect(titleSection?.properties.startLine).toBe(1);
|
||||
expect(titleSection?.properties.endLine).toBe(7);
|
||||
expect(subSection?.properties.startLine).toBe(3);
|
||||
expect(subSection?.properties.endLine).toBe(7);
|
||||
expect(subSubSection?.properties.startLine).toBe(5);
|
||||
expect(subSubSection?.properties.endLine).toBe(7);
|
||||
expect(titleSection?.properties.startLine).toBe(0);
|
||||
expect(titleSection?.properties.endLine).toBe(6);
|
||||
expect(subSection?.properties.startLine).toBe(2);
|
||||
expect(subSection?.properties.endLine).toBe(6);
|
||||
expect(subSubSection?.properties.startLine).toBe(4);
|
||||
expect(subSubSection?.properties.endLine).toBe(6);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
126
gitnexus/test/integration/mcp-line-display.test.ts
Normal file
126
gitnexus/test/integration/mcp-line-display.test.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
/**
|
||||
* 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();
|
||||
},
|
||||
},
|
||||
);
|
||||
|
|
@ -315,6 +315,9 @@ withTestLbugDB(
|
|||
target: 'targetFn',
|
||||
});
|
||||
expect(result).not.toHaveProperty('error');
|
||||
// #2380: the display anchor is 1-based, matching context/query/impact —
|
||||
// targetFn stored 0-based 10 -> 11 (the BasicBlock join is unaffected).
|
||||
expect((result.anchor as { startLine: number }).startLine).toBe(11);
|
||||
// Only targetFn's own control edge — the neighbor's line-10 edge is out
|
||||
// of the [11,15] window after the lower-bound +1 fix.
|
||||
expect(result.results).toHaveLength(1);
|
||||
|
|
|
|||
|
|
@ -9,11 +9,13 @@
|
|||
* CUSTDAT.cpy, COPYLIB.cpy, RUNJOBS.jcl
|
||||
*/
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import {
|
||||
FIXTURES,
|
||||
getRelationships,
|
||||
getNodesByLabel,
|
||||
getNodesByLabelFull,
|
||||
edgeSet,
|
||||
runPipelineFromRepo,
|
||||
type PipelineResult,
|
||||
|
|
@ -752,4 +754,44 @@ describe('COBOL full system extraction', () => {
|
|||
expect(parsedFile!.moduleScope.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// LINE-BASE CONVENTION — COBOL/JCL emit 0-based startLine (#2377 / #2379)
|
||||
// Regex-based processors carry 1-based line numbers; they must convert to
|
||||
// the 0-based GraphNode convention at emission or the exact-content slice
|
||||
// drops each symbol's declaration line. These lock that in.
|
||||
// ---------------------------------------------------------------------
|
||||
describe('line-base convention: 0-based startLine (#2377 / #2379)', () => {
|
||||
it('primary program Module starts at 0-based line 0', () => {
|
||||
const custupdt = getNodesByLabelFull(result, 'Module').find((m) => m.name === 'CUSTUPDT');
|
||||
expect(custupdt).toBeDefined();
|
||||
expect(custupdt!.properties.startLine).toBe(0);
|
||||
});
|
||||
|
||||
// NOTE: COBOL paragraph lines can't be cross-checked against the raw file —
|
||||
// the preprocessor expands COPY statements, so `startLine` is in expanded
|
||||
// coordinates (a separate, pre-existing content-alignment concern, out of
|
||||
// scope for the 0-based conversion). JCL has no such expansion, so a JCL
|
||||
// step gives a clean 0-based proof for a NON-line-0 symbol — ruling out a
|
||||
// "conversion always yields 0" false pass.
|
||||
it('JCL step CodeElement startLine is the 0-based declaration line', async () => {
|
||||
const source = await fs.readFile(path.join(FIXTURES, 'cobol-app', 'RUNJOBS.jcl'), 'utf-8');
|
||||
const lines = source.split('\n');
|
||||
const expectedIdx = lines.findIndex((l) => l.includes('STEP1'));
|
||||
expect(expectedIdx).toBeGreaterThan(0); // not line 0 — proves a real conversion
|
||||
const step1 = getNodesByLabelFull(result, 'CodeElement').find((n) => n.name === 'STEP1');
|
||||
expect(step1).toBeDefined();
|
||||
expect(step1!.properties.startLine).toBe(expectedIdx);
|
||||
expect(lines[step1!.properties.startLine]).toContain('STEP1');
|
||||
});
|
||||
|
||||
it('JCL job CodeElement starts at 0-based line 0', async () => {
|
||||
const source = await fs.readFile(path.join(FIXTURES, 'cobol-app', 'RUNJOBS.jcl'), 'utf-8');
|
||||
const lines = source.split('\n');
|
||||
const custjob = getNodesByLabelFull(result, 'CodeElement').find((n) => n.name === 'CUSTJOB');
|
||||
expect(custjob).toBeDefined();
|
||||
expect(custjob!.properties.startLine).toBe(0);
|
||||
expect(lines[custjob!.properties.startLine]).toContain('CUSTJOB');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -73,8 +73,8 @@ describe('CALL_SUMMARY relation-type exclusion (U-C1)', () => {
|
|||
});
|
||||
|
||||
describe('CALL_SUMMARY incremental reuse gate (U-C5)', () => {
|
||||
it('INCREMENTAL_SCHEMA_VERSION is bumped to 5 (multi-verb Route identity re-index window)', () => {
|
||||
expect(INCREMENTAL_SCHEMA_VERSION).toBe(5);
|
||||
it('INCREMENTAL_SCHEMA_VERSION is bumped to 6 (uniform 0-based line storage re-index window)', () => {
|
||||
expect(INCREMENTAL_SCHEMA_VERSION).toBe(6);
|
||||
});
|
||||
|
||||
it('a pre-current stamp fails the `=== INCREMENTAL_SCHEMA_VERSION` reuse gate → forces full re-analyze', () => {
|
||||
|
|
@ -91,7 +91,11 @@ describe('CALL_SUMMARY incremental reuse gate (U-C5)', () => {
|
|||
expect(passesReuseGate(4)).toBe(false);
|
||||
// A legacy stamp with no schemaVersion at all is likewise rejected.
|
||||
expect(passesReuseGate(undefined)).toBe(false);
|
||||
// A pre-v6 (v5) index predates the uniform 0-based line-storage flip → its
|
||||
// COBOL/JCL/markdown/scope rows are still 1-based, so an incremental top-up
|
||||
// would mix bases → must NOT reuse.
|
||||
expect(passesReuseGate(5)).toBe(false);
|
||||
// A current-version stamp passes the gate (incremental top-up eligible).
|
||||
expect(passesReuseGate(5)).toBe(true);
|
||||
expect(passesReuseGate(6)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1352,9 +1352,14 @@ describe('LocalBackend.callTool', () => {
|
|||
backend = new LocalBackend();
|
||||
await backend.init();
|
||||
|
||||
// The symbol is stored at 0-based startLine 1; context() presents it 1-based
|
||||
// (line 2) and rename subtracts 1 to recover the 0-based file index (1), so
|
||||
// `oldName` must sit on the file's 0-based line 1 for the definition edit to
|
||||
// fire. (#2380: the mock previously put it on line 0, which stopped matching
|
||||
// once context() went 1-based.)
|
||||
const readSpy = vi
|
||||
.spyOn(fsPromises, 'readFile')
|
||||
.mockResolvedValue('function oldName() {}\n' as unknown as Buffer);
|
||||
.mockResolvedValue('\nfunction oldName() {}\n' as unknown as Buffer);
|
||||
const writeSpy = vi
|
||||
.spyOn(fsPromises, 'writeFile')
|
||||
.mockRejectedValue(new Error('EACCES: permission denied'));
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ function pdgFindings(overrides: Record<string, unknown> = {}): Record<string, un
|
|||
];
|
||||
return {
|
||||
mode: 'pdg',
|
||||
pdgResultVersion: 1,
|
||||
pdgResultVersion: 2,
|
||||
target: {
|
||||
id: 'Function:src/svc.ts:computeTotal',
|
||||
name: 'computeTotal',
|
||||
|
|
@ -136,7 +136,7 @@ describe('formatImpactResult — PDG (mode:pdg) rendering', () => {
|
|||
// The PDG result family advertises a contract version (FIX #2) so external
|
||||
// MCP/agent consumers can version against future shape evolution. It is a
|
||||
// mode:'pdg'-only field — never on the default callgraph result.
|
||||
expect(pdgFindings()).toMatchObject({ mode: 'pdg', pdgResultVersion: 1 });
|
||||
expect(pdgFindings()).toMatchObject({ mode: 'pdg', pdgResultVersion: 2 });
|
||||
});
|
||||
|
||||
it('surfaces ambiguous-projection and unresolved block counts honestly', () => {
|
||||
|
|
|
|||
34
gitnexus/test/unit/group/manifest-label-drift.test.ts
Normal file
34
gitnexus/test/unit/group/manifest-label-drift.test.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
/**
|
||||
* #2380: manifest-extractor's CUSTOM_CONTRACT_RESOLVE_QUERY hand-lists the graph
|
||||
* labels that resolve as contract symbols. It is a deliberate SUBSET of the
|
||||
* shared SYMBOL_NODE_LABELS (ingestion/utils/symbol-labels.ts) — omitting
|
||||
* Namespace/Variable/Module, which would widen contract resolution and is
|
||||
* #2325-test-locked. A comment asserts that relationship but nothing enforced
|
||||
* it, so adding a label to SYMBOL_NODE_LABELS could silently diverge the two.
|
||||
* This locks it: the query string stays literal; the test derives its label set.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { CUSTOM_CONTRACT_RESOLVE_QUERY } from '../../../src/core/group/extractors/manifest-extractor.js';
|
||||
import { SYMBOL_NODE_LABELS } from '../../../src/core/ingestion/utils/symbol-labels.js';
|
||||
|
||||
describe('manifest contract-resolve label list vs SYMBOL_NODE_LABELS (#2380)', () => {
|
||||
const match = CUSTOM_CONTRACT_RESOLVE_QUERY.match(/labels\(n\) IN \[([^\]]+)\]/);
|
||||
const manifestLabels = new Set(
|
||||
(match?.[1] ?? '').split(',').map((t) => t.trim().replace(/^'|'$/g, '')),
|
||||
);
|
||||
const symbolLabels = new Set<string>(SYMBOL_NODE_LABELS);
|
||||
|
||||
it('extracts a non-empty label allowlist from the query', () => {
|
||||
expect(manifestLabels.size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('every manifest label is a member of SYMBOL_NODE_LABELS (strict subset)', () => {
|
||||
const extra = [...manifestLabels].filter((l) => !symbolLabels.has(l));
|
||||
expect(extra).toEqual([]);
|
||||
});
|
||||
|
||||
it('the difference is exactly {Namespace, Variable, Module}', () => {
|
||||
const diff = [...symbolLabels].filter((l) => !manifestLabels.has(l)).sort();
|
||||
expect(diff).toEqual(['Module', 'Namespace', 'Variable']);
|
||||
});
|
||||
});
|
||||
|
|
@ -36,7 +36,7 @@ const local = (
|
|||
impactedCount: number,
|
||||
): PdgImpactSuccessResult => ({
|
||||
mode: 'pdg',
|
||||
pdgResultVersion: 1,
|
||||
pdgResultVersion: 2,
|
||||
target: { id: 'T', name: 'criterion', type: 'Function', filePath: 'src/a.ts' },
|
||||
direction: 'downstream',
|
||||
risk: 'UNKNOWN',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue