GitNexus/gitnexus/test/integration/lbug-load-overlap.test.ts
Parafee41 316aaed928
fix(indexing): keep full text file content searchable (#2323)
* 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>
2026-07-01 08:27:09 +01:00

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([]);
});
});