mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-09 22:33:39 +00:00
* fix(indexing): keep full text file content searchable * fix(indexing): flush CSV chunks by byte size * fix(fts): flatten newlines/tabs in indexed content so multiline files are searchable (#2317) The end-to-end FTS test (review follow-up F2) exposed that removing the 10KB cap alone does NOT fix #2317: Ladybug's FTS tokenizer splits ONLY on the space character — \n, \r, and \t are not delimiters. So multiline file/symbol content indexes as a few giant cross-line tokens that no word query matches; full content is stored but stays unsearchable. (Verified: identical 8KB content is fully searchable when space-separated and entirely unsearchable when newline-separated.) The existing fts-description-search test never caught this because all its seed content is single-line. Collapse \r\n\t -> single space in the FTS-indexed text (extractContent's File and snippet content, plus the description column) via normalizeFtsText. This rewrites the stored column too, so File content returned via the graph API is space-flattened — an accepted trade for making file/symbol text searchable. Add the real end-to-end guard test/integration/fts-fullfile-search.test.ts: write a >16KB file, load it through the real streamAllCSVsToDisk -> COPY -> createSearchFTSIndexes path, and assert searchFTSFromLbug returns a needle past 10KB (plus a short-content no-regression and a stored-cell-not-truncated guard). It drives the COPY path a Cypher-seed test would bypass, reusing withTestLbugDB's FTS-availability gating via a new before-FTS load hook. * docs(lbug): note the deliberate File-unbounded / snippet-capped asymmetry The File branch returns full content (whitespace-normalized for FTS, bounded upstream by the walker cap) while the symbol snippet path 11 lines down stays MAX_SNIPPET-capped. Comment the intent so the uncapped File branch doesn't read as a forgotten guard. No behavior change. * test(lbug): update #2203 overlap round-trip for FTS whitespace normalization The newline/tab→space normalization (a170915a, #2317) flattens stored File content, so the #2203 overlap test's "File content == original multiline source" assertion no longer holds. The test's actual invariant — overlap path == serial path, byte-for-byte — is unchanged and still asserted; BasicBlock text (not FTS-indexed) still round-trips raw. Update only the File-content expectation to the whitespace-flattened form and document why. * fix(lbug): collapse CSV flush to a single byte threshold BufferedCSVWriter flushed on row-count (FLUSH_EVERY=500) OR byte-count (FLUSH_BYTES=8MB) — two independent triggers for one job. Byte count is the only one tied to the actual risk (an unbounded buffer.join('\n') string), so drop FLUSH_EVERY and make shouldFlushCSVBuffer single-arg. Rather than tune FLUSH_BYTES by guesswork or expose it as an env knob, derive its safety margin from constants the codebase already hard-enforces: a single row is capped at TREE_SITTER_MAX_BUFFER (32MB, clamped regardless of GITNEXUS_MAX_FILE_SIZE) and at most doubled by escapeCSVField's quote-escaping, so the worst-case joined chunk (FLUSH_BYTES + 2 * TREE_SITTER_MAX_BUFFER ≈ 72MB) sits >7x under Node's MAX_STRING_LENGTH (~512MB) — the ceiling that throws RangeError: Invalid string length. A new test pins that margin numerically so it can't erode unnoticed, which covers the "configurable" alternative better than a knob would: there's no evidence any deployment needs a different value, and an unbounded env var would let an operator silently walk the margin back into the danger zone. Also updates the two tests tied to the removed row-count path: the FLUSH_EVERY-boundary integration test now crosses FLUSH_BYTES with real oversized File content instead of relying on row count, and the shouldFlushCSVBuffer unit test drops to the new single-arg signature. --------- Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
214 lines
7.9 KiB
TypeScript
214 lines
7.9 KiB
TypeScript
/**
|
|
* Integration test: the node-COPY ‖ relationship-emit overlap (#2203) persists
|
|
* BYTE-IDENTICAL graph content to the legacy strictly-serial path.
|
|
*
|
|
* This is the acceptance gate for the #2203 "parallelized emit" follow-up: the
|
|
* overlap reorders *scheduling* (node COPY runs while relationship CSVs are
|
|
* still being written), never the data. We prove that by loading one fixture
|
|
* graph into two fresh DBs — once via the default overlap path, once via the
|
|
* GITNEXUS_SERIAL_LBUG_LOAD=1 escape hatch — and asserting the two databases are
|
|
* content-equivalent: same per-table node counts, same per-type edge counts,
|
|
* same multiline `content`/`text` field bytes, and the same loadGraphToLbug
|
|
* return accounting.
|
|
*
|
|
* Needs a real LadybugDB connection (initLbug), so it lives under integration.
|
|
*/
|
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
import fs from 'fs/promises';
|
|
import path from 'path';
|
|
import os from 'os';
|
|
import { buildTestGraph } from '../helpers/test-graph.js';
|
|
|
|
let tmpBase: string;
|
|
let repoDir: string;
|
|
|
|
// Source-code-shaped content with embedded newlines AND quotes — the exact
|
|
// shape PARALLEL=false exists to handle. Read from disk for the File node and
|
|
// carried inline for the BasicBlock node, so both content sources are checked.
|
|
const FILE_SRC = 'export function f() {\n const s = "a,b\\"c";\n return s;\n}\n';
|
|
const BB_TEXT = 'if (cond) {\n log("x = " + x);\n}\nreturn "done";';
|
|
|
|
const BB1 = 'BasicBlock:src/a.ts:0';
|
|
const BB2 = 'BasicBlock:src/a.ts:1';
|
|
|
|
const NODE_TABLES_CHECKED = ['File', 'Function', 'Class', 'BasicBlock'] as const;
|
|
const EDGE_TYPES_CHECKED = ['DEFINES', 'CALLS', 'CFG', 'REACHING_DEF'] as const;
|
|
|
|
const buildFixture = () =>
|
|
buildTestGraph(
|
|
[
|
|
{ id: 'File:src/a.ts', label: 'File', name: 'a.ts', filePath: 'src/a.ts' },
|
|
{
|
|
id: 'Function:src/a.ts:f:1',
|
|
label: 'Function',
|
|
name: 'f',
|
|
filePath: 'src/a.ts',
|
|
startLine: 1,
|
|
endLine: 4,
|
|
isExported: true,
|
|
},
|
|
{
|
|
id: 'Class:src/a.ts:C:6',
|
|
label: 'Class',
|
|
name: 'C',
|
|
filePath: 'src/a.ts',
|
|
startLine: 6,
|
|
endLine: 8,
|
|
},
|
|
{
|
|
id: BB1,
|
|
label: 'BasicBlock',
|
|
name: '',
|
|
filePath: 'src/a.ts',
|
|
startLine: 1,
|
|
endLine: 3,
|
|
extra: { text: BB_TEXT },
|
|
},
|
|
{ id: BB2, label: 'BasicBlock', name: '', filePath: 'src/a.ts', startLine: 4, endLine: 4 },
|
|
],
|
|
[
|
|
{ sourceId: 'File:src/a.ts', targetId: 'Function:src/a.ts:f:1', type: 'DEFINES' },
|
|
{ sourceId: 'File:src/a.ts', targetId: 'Class:src/a.ts:C:6', type: 'DEFINES' },
|
|
{ sourceId: 'Function:src/a.ts:f:1', targetId: 'Class:src/a.ts:C:6', type: 'CALLS' },
|
|
{ sourceId: BB1, targetId: BB2, type: 'CFG', reason: 'cfg' },
|
|
{ sourceId: BB1, targetId: BB2, type: 'REACHING_DEF', reason: 's' },
|
|
],
|
|
);
|
|
|
|
interface Snapshot {
|
|
nodeCounts: Record<string, number>;
|
|
edgeCounts: Record<string, number>;
|
|
bbText: string | undefined;
|
|
fileContent: string | undefined;
|
|
ret: { insertedRels: number; skippedRels: number; warnings: string[] };
|
|
}
|
|
|
|
const snapshotDb = async (
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
adapter: any,
|
|
ret: Snapshot['ret'],
|
|
): Promise<Snapshot> => {
|
|
const nodeCounts: Record<string, number> = {};
|
|
for (const t of NODE_TABLES_CHECKED) {
|
|
const rows = await adapter.executeQuery(`MATCH (n:\`${t}\`) RETURN count(n) AS c`);
|
|
nodeCounts[t] = Number(rows[0].c);
|
|
}
|
|
const edgeCounts: Record<string, number> = {};
|
|
for (const ty of EDGE_TYPES_CHECKED) {
|
|
const rows = await adapter.executeQuery(
|
|
`MATCH ()-[r:CodeRelation {type: '${ty}'}]->() RETURN count(r) AS c`,
|
|
);
|
|
edgeCounts[ty] = Number(rows[0].c);
|
|
}
|
|
const bbRows = await adapter.executeQuery(
|
|
`MATCH (n:BasicBlock {id: '${BB1}'}) RETURN n.text AS text`,
|
|
);
|
|
const fileRows = await adapter.executeQuery(
|
|
"MATCH (n:File {id: 'File:src/a.ts'}) RETURN n.content AS content",
|
|
);
|
|
return {
|
|
nodeCounts,
|
|
edgeCounts,
|
|
bbText: bbRows[0]?.text,
|
|
fileContent: fileRows[0]?.content,
|
|
ret,
|
|
};
|
|
};
|
|
|
|
const loadAndSnapshot = async (label: string, serial: boolean): Promise<Snapshot> => {
|
|
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
|
const storagePath = path.join(tmpBase, label, '.gitnexus');
|
|
const dbPath = path.join(storagePath, 'lbug');
|
|
await fs.mkdir(dbPath, { recursive: true });
|
|
|
|
if (serial) process.env.GITNEXUS_SERIAL_LBUG_LOAD = '1';
|
|
else delete process.env.GITNEXUS_SERIAL_LBUG_LOAD;
|
|
try {
|
|
await adapter.initLbug(dbPath);
|
|
const ret = await adapter.loadGraphToLbug(buildFixture(), repoDir, storagePath);
|
|
const snap = await snapshotDb(adapter, {
|
|
insertedRels: ret.insertedRels,
|
|
skippedRels: ret.skippedRels,
|
|
warnings: ret.warnings,
|
|
});
|
|
await adapter.closeLbug();
|
|
return snap;
|
|
} finally {
|
|
delete process.env.GITNEXUS_SERIAL_LBUG_LOAD;
|
|
}
|
|
};
|
|
|
|
let overlapSnap: Snapshot;
|
|
let serialSnap: Snapshot;
|
|
|
|
beforeAll(async () => {
|
|
// mkdtemp (not a predictable os.tmpdir join) — secure unique dir, and the
|
|
// `gitnexus-lbug-` prefix is in TEST_FIXTURE_PREFIXES so the stale-sidecar
|
|
// sweep recognizes it on Windows (lbug-config.ts).
|
|
tmpBase = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-lbug-overlap-'));
|
|
repoDir = path.join(tmpBase, 'repo');
|
|
await fs.mkdir(path.join(repoDir, 'src'), { recursive: true });
|
|
await fs.writeFile(path.join(repoDir, 'src', 'a.ts'), FILE_SRC);
|
|
|
|
// Default overlap path first, then the serial escape hatch into a fresh DB.
|
|
overlapSnap = await loadAndSnapshot('overlap', false);
|
|
serialSnap = await loadAndSnapshot('serial', true);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
try {
|
|
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
|
await adapter.closeLbug();
|
|
} catch {
|
|
/* may already be closed */
|
|
}
|
|
if (tmpBase) {
|
|
for (let attempt = 0; attempt < 5; attempt++) {
|
|
try {
|
|
await fs.rm(tmpBase, { recursive: true, force: true });
|
|
return;
|
|
} catch {
|
|
if (attempt < 4) await new Promise((r) => setTimeout(r, 200 * (attempt + 1)));
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
describe('node-COPY ‖ rel-emit overlap persists identical content (#2203)', () => {
|
|
it('per-table node counts are identical between overlap and serial', () => {
|
|
expect(overlapSnap.nodeCounts).toEqual(serialSnap.nodeCounts);
|
|
// Sanity: the fixture actually populated every checked table.
|
|
for (const t of NODE_TABLES_CHECKED) {
|
|
expect(overlapSnap.nodeCounts[t], `${t} should have rows`).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
|
|
it('per-type edge counts are identical between overlap and serial', () => {
|
|
expect(overlapSnap.edgeCounts).toEqual(serialSnap.edgeCounts);
|
|
for (const ty of EDGE_TYPES_CHECKED) {
|
|
expect(overlapSnap.edgeCounts[ty], `${ty} should have edges`).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
|
|
it('multiline content/text fields round-trip identically (byte-for-byte)', () => {
|
|
// The #2203 invariant — overlap path == serial path — holds byte-for-byte
|
|
// for both fields regardless of any content transform.
|
|
expect(overlapSnap.bbText).toBe(serialSnap.bbText);
|
|
expect(overlapSnap.fileContent).toBe(serialSnap.fileContent);
|
|
|
|
// BasicBlock text is NOT FTS-indexed, so it round-trips raw (newlines kept).
|
|
expect(overlapSnap.bbText).toBe(BB_TEXT);
|
|
// File content IS FTS-indexed and is whitespace-normalized for the
|
|
// space-only tokenizer (#2317): newlines/tabs collapse to single spaces.
|
|
// So it round-trips as the source with intra-text whitespace flattened,
|
|
// not byte-identical to the original multiline source.
|
|
expect(overlapSnap.fileContent).toBe(FILE_SRC.replace(/[\r\n\t]+/g, ' '));
|
|
});
|
|
|
|
it('loadGraphToLbug accounting (insertedRels/skippedRels/warnings) is identical', () => {
|
|
expect(overlapSnap.ret).toEqual(serialSnap.ret);
|
|
expect(overlapSnap.ret.insertedRels).toBe(5);
|
|
expect(overlapSnap.ret.skippedRels).toBe(0);
|
|
expect(overlapSnap.ret.warnings).toEqual([]);
|
|
});
|
|
});
|