mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +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>
159 lines
7.1 KiB
TypeScript
159 lines
7.1 KiB
TypeScript
/**
|
|
* Regression test for CRLF-encoded markdown heading extraction.
|
|
*
|
|
* Files with CRLF line endings (Windows-authored markdown) previously
|
|
* produced zero Section nodes because `split('\n')` left a trailing `\r`
|
|
* on each line, and the heading regex `/^(#{1,6})\s+(.+)$/` (anchored
|
|
* with `$`) failed to match `## Heading\r` because `$` only matches at
|
|
* end-of-string while `.+` does not consume the trailing `\r`.
|
|
*
|
|
* Fix: split on `/\r\n|\r|\n/` so all line-ending conventions are
|
|
* normalized at split time. See markdown-processor.ts line 39.
|
|
*/
|
|
|
|
import { describe, it, expect } from 'vitest';
|
|
import { processMarkdown } from '../../src/core/ingestion/markdown-processor.js';
|
|
import { createKnowledgeGraph } from '../../src/core/graph/graph.js';
|
|
import { generateId } from '../../src/lib/utils.js';
|
|
import type { GraphNode } from 'gitnexus-shared';
|
|
import type { KnowledgeGraph } from '../../src/core/graph/types.js';
|
|
|
|
function getMarkdownSections(graph: KnowledgeGraph, filePath: string): GraphNode[] {
|
|
return [...graph.iterNodes()]
|
|
.filter((n) => n.label === 'Section' && n.properties.filePath === filePath)
|
|
.sort(
|
|
(a, b) =>
|
|
((a.properties.startLine as number | undefined) ?? 0) -
|
|
((b.properties.startLine as number | undefined) ?? 0),
|
|
);
|
|
}
|
|
|
|
function expectContainsEdge(graph: KnowledgeGraph, sourceId: string, targetId: string) {
|
|
const found = [...graph.iterRelationshipsByType('CONTAINS')].some(
|
|
(r) => r.sourceId === sourceId && r.targetId === targetId,
|
|
);
|
|
expect(found).toBe(true);
|
|
}
|
|
|
|
function setupGraphWithFile(filePath: string) {
|
|
const graph = createKnowledgeGraph();
|
|
const fileNode: GraphNode = {
|
|
id: generateId('File', filePath),
|
|
label: 'File',
|
|
properties: { name: filePath, filePath },
|
|
};
|
|
graph.addNode(fileNode);
|
|
return graph;
|
|
}
|
|
|
|
describe('markdown-processor CRLF tolerance', () => {
|
|
it('extracts headings from LF-encoded markdown (baseline)', () => {
|
|
const filePath = 'lf.md';
|
|
const graph = setupGraphWithFile(filePath);
|
|
const content = '# Title\nbody line 1\n## Sub\nbody line 2\n### SubSub\nmore\n';
|
|
|
|
const stats = processMarkdown(graph, [{ path: filePath, content }], new Set([filePath]));
|
|
|
|
expect(stats.sections).toBe(3);
|
|
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([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/);
|
|
}
|
|
const fileId = generateId('File', filePath);
|
|
expectContainsEdge(graph, fileId, sections[0]!.id);
|
|
expectContainsEdge(graph, sections[0]!.id, sections[1]!.id);
|
|
expectContainsEdge(graph, sections[1]!.id, sections[2]!.id);
|
|
});
|
|
|
|
it('extracts headings from CRLF-encoded markdown (the regression)', () => {
|
|
const filePath = 'crlf.md';
|
|
const graph = setupGraphWithFile(filePath);
|
|
const content = '# Title\r\nbody line 1\r\n## Sub\r\nbody line 2\r\n### SubSub\r\nmore\r\n';
|
|
|
|
const stats = processMarkdown(graph, [{ path: filePath, content }], new Set([filePath]));
|
|
|
|
// Pre-fix: this returned 0 because `## Sub\r` failed the heading regex.
|
|
expect(stats.sections).toBe(3);
|
|
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([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/);
|
|
}
|
|
const fileId = generateId('File', filePath);
|
|
expectContainsEdge(graph, fileId, sections[0]!.id);
|
|
expectContainsEdge(graph, sections[0]!.id, sections[1]!.id);
|
|
expectContainsEdge(graph, sections[1]!.id, sections[2]!.id);
|
|
});
|
|
|
|
it('extracts headings from CR-only-encoded markdown (old Mac OS Classic)', () => {
|
|
const filePath = 'cr.md';
|
|
const graph = setupGraphWithFile(filePath);
|
|
const content = '# Title\rbody line 1\r## Sub\rbody line 2\r';
|
|
|
|
const stats = processMarkdown(graph, [{ path: filePath, content }], new Set([filePath]));
|
|
|
|
expect(stats.sections).toBe(2);
|
|
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([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/);
|
|
}
|
|
const fileId = generateId('File', filePath);
|
|
expectContainsEdge(graph, fileId, sections[0]!.id);
|
|
expectContainsEdge(graph, sections[0]!.id, sections[1]!.id);
|
|
});
|
|
|
|
it('extracts headings from mixed CRLF + LF markdown', () => {
|
|
const filePath = 'mixed.md';
|
|
const graph = setupGraphWithFile(filePath);
|
|
const content = '# LF Title\nbody\r\n## CRLF Sub\r\nmore\n### Trailing LF\nend\n';
|
|
|
|
const stats = processMarkdown(graph, [{ path: filePath, content }], new Set([filePath]));
|
|
|
|
expect(stats.sections).toBe(3);
|
|
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([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/);
|
|
}
|
|
const fileId = generateId('File', filePath);
|
|
expectContainsEdge(graph, fileId, sections[0]!.id);
|
|
expectContainsEdge(graph, sections[0]!.id, sections[1]!.id);
|
|
expectContainsEdge(graph, sections[1]!.id, sections[2]!.id);
|
|
});
|
|
|
|
it('reports correct startLine and endLine for CRLF content', () => {
|
|
const filePath = 'crlf-lines.md';
|
|
const graph = setupGraphWithFile(filePath);
|
|
// 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]));
|
|
|
|
const sections = getMarkdownSections(graph, filePath);
|
|
const titleSection = sections.find((s) => s.properties.name === 'T');
|
|
const subSection = sections.find((s) => s.properties.name === 'Sub');
|
|
const subSubSection = sections.find((s) => s.properties.name === 'SubSub');
|
|
|
|
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);
|
|
});
|
|
});
|