mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-08 22:22:52 +00:00
* fix(fts): keep binary payloads out of the indexed description column Issue #2889 reports embedded binary and serialized data reaching LadybugDB through `description`. The vector is real, but not for the reason the report gives, and the detector that was supposed to stop it cannot see it. Every file enters the pipeline through a lossy `utf-8` decode — the CSV emitter's own content cache reads with `fs.readFile(path, 'utf-8')`, and so does the parse worker. An invalid byte sequence therefore never survives as invalid bytes; it is replaced with U+FFFD. `isBinaryContent` counted control bytes and DEL only, and charCode 0xFFFD is neither, so a wholly corrupt payload scored as clean text: on a real repro, a Vue/JS file carrying a class file constant pool produced the description `用户服务 handles 数据 <7×U+FFFD>MethCw` and the detector returned false. Counting U+FFFD toward the existing 10% threshold is what makes the function see the case it exists for. A legitimate source file carries no replacement characters at all unless it was mis-decoded, and a handful still score far under the bar. `formatFtsDescription` then gates on it. `content` has always been gated inside `extractContent`; `description` never was, so a symbol whose doc comment is really a slice of an embedded payload had that payload copied verbatim into an FTS-indexed column. Empty string rather than a sentinel: unlike `content`, a description has no reader that needs to be told why it is missing. This does not address the `Failed calling LOWER: Invalid UTF-8` build error itself. That error cannot originate in this layer — every value handed to COPY is encoded from a JS string, which is always well-formed UTF-8. The two other gaps the issue names are a no-op and dead code respectively; see the pull request for the evidence. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017oEY2i74d1HLa5FuGVuPiT * fix(fts): confine an unbuildable index to its own table One untokenizable row cost far more than its own table's index. `createSearchFTSIndexes` let the first rejection leave the loop, and by then `dropFTSIndex` had already run for that table — so the failing table ended with no index, and every table after it in `FTS_INDEXES` order was never reached. On a fresh build, or on the incremental path where `dropSearchFTSIndexes` clears all of them up front, those later tables ended with no index either. `verifySearchFTSIndexes` never ran to report it, because the throw skipped it. That is the mechanism behind the multi-table degradation in #2889: the report lists Function, Method, Property and Variable as failing together, which is loop control flow, not four independent bad rows. It also explains why `--repair-fts` felt useless — repair runs the same loop, so it stopped at the same table and left everything after it unbuilt, then failed with a list of missing indexes and no reason attached. Each index now builds inside its own try/catch and the run continues, so the damage stops at the table that actually holds the bad row and repair can recover everything else. Failures are returned rather than thrown so the caller sees all of them instead of the first: `buildSearchIndexesOrDegrade` names every failing table with its raw LadybugDB message, and repair appends those reasons to the missing-index error. The aggregate failure class is computed per failure, with integrity winning. Classification checks capability signatures first, so folding the messages into one string would have let an untokenizable row mask a genuinely broken write and downgrade an abort into a degrade. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017oEY2i74d1HLa5FuGVuPiT * refactor(fts): verify before reporting, and fold the derivable state away Cleanup pass over the two #2889 commits. No behaviour change except the verification ordering, which was a real placement error. `buildSearchIndexesOrDegrade` reported build failures and returned BEFORE `verifySearchFTSIndexes` ran. A partial build is exactly when "the other tables are fine" needs proving rather than asserting, and a stale name+content-only index succeeds at build time while leaving description search broken (#2299). Verification now always runs, and a table that failed to build is subtracted from the missing list so it is reported once, with its reason, instead of twice. `FtsIndexBuildFailure.failureClass` was `classifyFtsBuildError(error)` stored beside the string it derives from — two fields that had to agree, and a test about loop isolation that broke if classification rules changed. Classify at the one place that asks. `describeFtsIndexBuildFailures` becomes `summarizeFtsIndexBuildFailures` and owns the whole sentence, including the denominator only this module knows. Analyze and `--repair-fts` were rendering the same failure two different ways. `isBinaryContent` drops the `slice` for a bounded loop and folds the U+FFFD arm into the existing predicate — the two arms had identical bodies over provably disjoint conditions. Measured on this box: 349ns vs 388ns per 200 character description, and it skips a SlicedString allocation past 1000 characters. Its doc moves onto the exported function whose contract changed. Tests: three isolation tests collapse into one (same setup, three channels), the duplicate capability-class test folds into the existing single-rejection test, the two integration tests become one graph covering both emission branches, and the CJK unit case goes — an equality check on one code point cannot be reached by a CJK character, so it could not fail. `afterEach` uses `resetAllMocks` so every mock's `...Once` queue is drained, not just one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017oEY2i74d1HLa5FuGVuPiT --------- Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
959 lines
38 KiB
TypeScript
959 lines
38 KiB
TypeScript
/**
|
||
* P1 Integration Tests: CSV Pipeline
|
||
*
|
||
* Tests: streamAllCSVsToDisk with real graph data.
|
||
* Covers hardening fixes: LRU cache (#24), BufferedCSVWriter flush
|
||
*/
|
||
import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach, vi } from 'vitest';
|
||
import fs from 'fs/promises';
|
||
import { readdirSync } from 'node:fs';
|
||
import { finished } from 'stream/promises';
|
||
import path from 'path';
|
||
import { constants as bufferConstants } from 'node:buffer';
|
||
import { createTempDir, type TestDBHandle } from '../helpers/test-db.js';
|
||
import { buildTestGraph, type TestNodeInput, type TestRelInput } from '../helpers/test-graph.js';
|
||
import {
|
||
streamAllCSVsToDisk,
|
||
buildRelRow,
|
||
REL_CSV_HEADER,
|
||
shouldFlushCSVBuffer,
|
||
FLUSH_BYTES,
|
||
} from '../../src/core/lbug/csv-generator.js';
|
||
import { splitRelCsvByLabelPair } from '../../src/core/lbug/lbug-adapter.js';
|
||
import { getNodeLabel } from '../../src/core/lbug/rel-pair-routing.js';
|
||
import { NODE_TABLES } from '../../src/core/lbug/schema.js';
|
||
import { TREE_SITTER_MAX_BUFFER } from '../../src/core/ingestion/constants.js';
|
||
import { CJK_BIGRAM_WORST_CASE_GROWTH_FACTOR } from '../../src/core/search/cjk-segmentation.js';
|
||
|
||
let tmpHandle: TestDBHandle;
|
||
let csvDir: string;
|
||
let repoDir: string;
|
||
|
||
/** Data rows (header dropped) of one CSV file's text. */
|
||
const dataRowsOf = (csv: string): string[] =>
|
||
csv
|
||
.trim()
|
||
.split('\n')
|
||
.slice(1)
|
||
.filter((l) => l.length > 0);
|
||
|
||
/** Concatenate data rows from every per-pair rel file (#2203 U2), pair keys
|
||
* sorted so the concatenation order is deterministic regardless of map order. */
|
||
const readAllRelRows = async (
|
||
relsByPair: Map<string, { csvPath: string; rows: number }>,
|
||
): Promise<string[]> => {
|
||
const rows: string[] = [];
|
||
for (const key of [...relsByPair.keys()].sort()) {
|
||
rows.push(...dataRowsOf(await fs.readFile(relsByPair.get(key)!.csvPath, 'utf-8')));
|
||
}
|
||
return rows;
|
||
};
|
||
|
||
beforeAll(async () => {
|
||
tmpHandle = await createTempDir('csv-pipeline-test-');
|
||
csvDir = path.join(tmpHandle.dbPath, 'csv');
|
||
repoDir = path.join(tmpHandle.dbPath, 'repo');
|
||
|
||
// Create a fake repo directory with source files
|
||
await fs.mkdir(path.join(repoDir, 'src'), { recursive: true });
|
||
await fs.writeFile(
|
||
path.join(repoDir, 'src', 'index.ts'),
|
||
'export function main() {\n console.log("hello");\n helper();\n}\n\nexport class App {\n run() {}\n}\n',
|
||
);
|
||
await fs.writeFile(
|
||
path.join(repoDir, 'src', 'utils.ts'),
|
||
'export function helper() {\n return 42;\n}\n',
|
||
);
|
||
});
|
||
|
||
afterAll(async () => {
|
||
try {
|
||
await tmpHandle.cleanup();
|
||
} catch {
|
||
/* best-effort */
|
||
}
|
||
});
|
||
|
||
describe('streamAllCSVsToDisk', () => {
|
||
it('generates CSV files for all node types in the graph', async () => {
|
||
const graph = buildTestGraph(
|
||
[
|
||
{ id: 'file:src/index.ts', label: 'File', name: 'index.ts', filePath: 'src/index.ts' },
|
||
{ id: 'file:src/utils.ts', label: 'File', name: 'utils.ts', filePath: 'src/utils.ts' },
|
||
{
|
||
id: 'func:main',
|
||
label: 'Function',
|
||
name: 'main',
|
||
filePath: 'src/index.ts',
|
||
startLine: 1,
|
||
endLine: 4,
|
||
isExported: true,
|
||
},
|
||
{
|
||
id: 'func:helper',
|
||
label: 'Function',
|
||
name: 'helper',
|
||
filePath: 'src/utils.ts',
|
||
startLine: 1,
|
||
endLine: 3,
|
||
isExported: true,
|
||
},
|
||
{
|
||
id: 'class:App',
|
||
label: 'Class',
|
||
name: 'App',
|
||
filePath: 'src/index.ts',
|
||
startLine: 6,
|
||
endLine: 8,
|
||
isExported: true,
|
||
},
|
||
{ id: 'folder:src', label: 'Folder', name: 'src', filePath: 'src' },
|
||
],
|
||
[
|
||
{ sourceId: 'Function:main', targetId: 'Function:helper', type: 'CALLS' },
|
||
{ sourceId: 'File:src/index.ts', targetId: 'Function:main', type: 'CONTAINS' },
|
||
{ sourceId: 'File:src/utils.ts', targetId: 'Function:helper', type: 'CONTAINS' },
|
||
],
|
||
);
|
||
|
||
const result = await streamAllCSVsToDisk(graph, repoDir, csvDir);
|
||
|
||
// Check that CSV files were created
|
||
expect(result.nodeFiles.size).toBeGreaterThan(0);
|
||
expect(result.totalValidRels).toBe(3);
|
||
expect(result.skippedRels).toBe(0);
|
||
|
||
// Verify File CSV
|
||
const fileCsv = result.nodeFiles.get('File');
|
||
expect(fileCsv).toBeDefined();
|
||
expect(fileCsv!.rows).toBe(2);
|
||
|
||
// Verify Function CSV
|
||
const funcCsv = result.nodeFiles.get('Function');
|
||
expect(funcCsv).toBeDefined();
|
||
expect(funcCsv!.rows).toBe(2);
|
||
|
||
// Verify Class CSV
|
||
const classCsv = result.nodeFiles.get('Class');
|
||
expect(classCsv).toBeDefined();
|
||
expect(classCsv!.rows).toBe(1);
|
||
|
||
// Verify Folder CSV
|
||
const folderCsv = result.nodeFiles.get('Folder');
|
||
expect(folderCsv).toBeDefined();
|
||
expect(folderCsv!.rows).toBe(1);
|
||
|
||
// Relationships are routed to per-FROM→TO-label-pair files (#2203 U2):
|
||
// Function→Function (CALLS) + File→Function (2× CONTAINS).
|
||
expect(result.relsByPair.has('Function|Function')).toBe(true);
|
||
expect(result.relsByPair.has('File|Function')).toBe(true);
|
||
expect(result.relsByPair.get('File|Function')!.rows).toBe(2);
|
||
expect(await readAllRelRows(result.relsByPair)).toHaveLength(3);
|
||
});
|
||
|
||
it('CSV content is properly escaped', async () => {
|
||
const graph = buildTestGraph([
|
||
{
|
||
id: 'file:src/index.ts',
|
||
label: 'File',
|
||
name: 'index.ts',
|
||
filePath: 'src/index.ts',
|
||
},
|
||
]);
|
||
|
||
const result = await streamAllCSVsToDisk(graph, repoDir, csvDir);
|
||
const fileCsv = result.nodeFiles.get('File');
|
||
expect(fileCsv).toBeDefined();
|
||
|
||
const content = await fs.readFile(fileCsv!.csvPath, 'utf-8');
|
||
// Content should be properly quoted
|
||
expect(content).toContain('"file:src/index.ts"');
|
||
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(
|
||
path.join(repoDir, 'src', 'large.txt'),
|
||
`${'filler line for large text indexing\n'.repeat(400)}${lateNeedle}\n`,
|
||
);
|
||
const graph = buildTestGraph([
|
||
{
|
||
id: 'file:src/large.txt',
|
||
label: 'File',
|
||
name: 'large.txt',
|
||
filePath: 'src/large.txt',
|
||
},
|
||
]);
|
||
|
||
const result = await streamAllCSVsToDisk(graph, repoDir, csvDir);
|
||
const fileCsv = result.nodeFiles.get('File');
|
||
expect(fileCsv).toBeDefined();
|
||
|
||
const content = await fs.readFile(fileCsv!.csvPath, 'utf-8');
|
||
expect(content).toContain(lateNeedle);
|
||
expect(content).not.toContain('[truncated]');
|
||
});
|
||
|
||
describe('GITNEXUS_FTS_CJK_SEGMENTATION (#2331)', () => {
|
||
afterEach(() => {
|
||
vi.unstubAllEnvs();
|
||
});
|
||
|
||
// The description phrase is deliberately different from anything in the
|
||
// file's own source text (and the function's startLine/endLine keep the
|
||
// extracted content snippet away from the file-level comment). If
|
||
// description and content were segmented via the same accidental code
|
||
// path, or formatFtsDescription silently used the wrong property, a
|
||
// description-only phrase could not appear in either CSV row.
|
||
const FILE_CJK_PHRASE = '采购订单自动审批流程';
|
||
const DESCRIPTION_CJK_PHRASE = '库存管理系统更新';
|
||
|
||
it('leaves File content and Function description byte-identical by default (mode: none)', async () => {
|
||
const cjkContent = `// ${FILE_CJK_PHRASE}\nexport function approve() {\n return true;\n}\n`;
|
||
await fs.writeFile(path.join(repoDir, 'src', 'cjk.ts'), cjkContent);
|
||
const graph = buildTestGraph([
|
||
{ id: 'file:src/cjk.ts', label: 'File', name: 'cjk.ts', filePath: 'src/cjk.ts' },
|
||
{
|
||
id: 'func:approve',
|
||
label: 'Function',
|
||
name: 'approve',
|
||
filePath: 'src/cjk.ts',
|
||
extra: { description: DESCRIPTION_CJK_PHRASE, startLine: 3, endLine: 3 },
|
||
},
|
||
]);
|
||
|
||
const result = await streamAllCSVsToDisk(graph, repoDir, csvDir);
|
||
const fileContent = await fs.readFile(result.nodeFiles.get('File')!.csvPath, 'utf-8');
|
||
const funcContent = await fs.readFile(result.nodeFiles.get('Function')!.csvPath, 'utf-8');
|
||
expect(fileContent).toContain(FILE_CJK_PHRASE);
|
||
expect(funcContent).toContain(DESCRIPTION_CJK_PHRASE);
|
||
expect(funcContent).not.toContain(FILE_CJK_PHRASE);
|
||
// No bigram-separator spaces inserted into the CJK run.
|
||
expect(fileContent).not.toContain('采购 购订');
|
||
expect(funcContent).not.toContain('库存 存管');
|
||
});
|
||
|
||
it('bigram-segments both File content and Function description when enabled', async () => {
|
||
vi.stubEnv('GITNEXUS_FTS_CJK_SEGMENTATION', 'bigram');
|
||
const cjkContent = `// ${FILE_CJK_PHRASE}\nexport function approve() {\n return true;\n}\n`;
|
||
await fs.writeFile(path.join(repoDir, 'src', 'cjk-bigram.ts'), cjkContent);
|
||
const graph = buildTestGraph([
|
||
{
|
||
id: 'file:src/cjk-bigram.ts',
|
||
label: 'File',
|
||
name: 'cjk-bigram.ts',
|
||
filePath: 'src/cjk-bigram.ts',
|
||
},
|
||
{
|
||
id: 'func:approve-bigram',
|
||
label: 'Function',
|
||
name: 'approveBigram',
|
||
filePath: 'src/cjk-bigram.ts',
|
||
extra: { description: DESCRIPTION_CJK_PHRASE, startLine: 3, endLine: 3 },
|
||
},
|
||
]);
|
||
|
||
const result = await streamAllCSVsToDisk(graph, repoDir, csvDir);
|
||
const fileContent = await fs.readFile(result.nodeFiles.get('File')!.csvPath, 'utf-8');
|
||
const funcContent = await fs.readFile(result.nodeFiles.get('Function')!.csvPath, 'utf-8');
|
||
// Every expected overlapping bigram from the issue's own example must
|
||
// be present as a real, space-delimited FTS token in the File row.
|
||
const expectedFileBigrams = [
|
||
'采购',
|
||
'购订',
|
||
'订单',
|
||
'单自',
|
||
'自动',
|
||
'动审',
|
||
'审批',
|
||
'批流',
|
||
'流程',
|
||
];
|
||
for (const bigram of expectedFileBigrams) {
|
||
expect(fileContent).toContain(bigram);
|
||
}
|
||
// The Function row's description column is segmented independently —
|
||
// proven against its own (distinct) phrase, not the file's.
|
||
const expectedDescriptionBigrams = ['库存', '存管', '管理', '理系', '系统', '统更', '更新'];
|
||
for (const bigram of expectedDescriptionBigrams) {
|
||
expect(funcContent).toContain(bigram);
|
||
}
|
||
// #2339: every one of the bigram substrings above is ALSO a literal
|
||
// substring of the original unsegmented phrase (bigrams are
|
||
// overlapping substrings by construction), so the positive assertions
|
||
// alone would pass even if applyCjkSegmentationIfEnabled silently
|
||
// became a no-op. Mirror the `mode: none` test's negative-assertion
|
||
// pattern above: the original contiguous run must NOT survive intact.
|
||
expect(fileContent).not.toContain(FILE_CJK_PHRASE);
|
||
expect(funcContent).not.toContain(DESCRIPTION_CJK_PHRASE);
|
||
});
|
||
});
|
||
|
||
describe('binary descriptions (#2889)', () => {
|
||
// What an embedded binary payload actually looks like by the time it
|
||
// reaches the emitter: every read decodes `utf-8`, so the invalid bytes
|
||
// are already gone, replaced with U+FFFD. `MethCw` is the readable tail of
|
||
// a Java constant-pool run — the marker that proves the payload, not just
|
||
// the corruption around it, stayed out of the indexed column.
|
||
const DECODED_BINARY_DESCRIPTION = `用户服务 ${'<27>'.repeat(24)}MethCw`;
|
||
const CLEAN_DESCRIPTION = 'approves an inventory transfer';
|
||
|
||
// `Method` has its own emission branch; `Function` falls to the `default:`
|
||
// one. Both call the same helper, so one graph carrying both proves the gate
|
||
// is in the helper rather than in one branch's copy of the call.
|
||
const BRANCHES = ['Function', 'Method'] as const;
|
||
|
||
it('drops a binary description on every emission branch, keeping the row', async () => {
|
||
await fs.writeFile(
|
||
path.join(repoDir, 'src', 'payload.ts'),
|
||
'export function fromPayload() {\n return 1;\n}\nexport class Svc {\n run() {\n return 1;\n }\n}\n',
|
||
);
|
||
const graph = buildTestGraph([
|
||
{
|
||
id: 'file:src/payload.ts',
|
||
label: 'File',
|
||
name: 'payload.ts',
|
||
filePath: 'src/payload.ts',
|
||
},
|
||
{
|
||
id: 'func:fromPayload',
|
||
label: 'Function',
|
||
name: 'fromPayload',
|
||
filePath: 'src/payload.ts',
|
||
extra: { description: DECODED_BINARY_DESCRIPTION, startLine: 0, endLine: 2 },
|
||
},
|
||
{
|
||
id: 'func:clean',
|
||
label: 'Function',
|
||
name: 'cleanFn',
|
||
filePath: 'src/payload.ts',
|
||
extra: { description: CLEAN_DESCRIPTION, startLine: 0, endLine: 2 },
|
||
},
|
||
{
|
||
id: 'method:Svc.run',
|
||
label: 'Method',
|
||
name: 'run',
|
||
filePath: 'src/payload.ts',
|
||
extra: { description: DECODED_BINARY_DESCRIPTION, startLine: 4, endLine: 6 },
|
||
},
|
||
{
|
||
id: 'method:Svc.clean',
|
||
label: 'Method',
|
||
name: 'cleanRun',
|
||
filePath: 'src/payload.ts',
|
||
extra: { description: CLEAN_DESCRIPTION, startLine: 4, endLine: 6 },
|
||
},
|
||
]);
|
||
|
||
const result = await streamAllCSVsToDisk(graph, repoDir, csvDir);
|
||
|
||
for (const label of BRANCHES) {
|
||
const csv = await fs.readFile(result.nodeFiles.get(label)!.csvPath, 'utf-8');
|
||
expect(csv, label).not.toContain('MethCw');
|
||
expect(csv, label).not.toContain('<27>');
|
||
// A clean description on the same branch is untouched, so the gate
|
||
// cannot pass by emptying the column for everyone.
|
||
expect(csv, label).toContain(CLEAN_DESCRIPTION);
|
||
}
|
||
// The symbols themselves still ship — only their descriptions were dropped.
|
||
const functionCsv = await fs.readFile(result.nodeFiles.get('Function')!.csvPath, 'utf-8');
|
||
const methodCsv = await fs.readFile(result.nodeFiles.get('Method')!.csvPath, 'utf-8');
|
||
expect(functionCsv).toContain('fromPayload');
|
||
expect(methodCsv).toContain('"run"');
|
||
});
|
||
});
|
||
|
||
it('handles community nodes with keywords', async () => {
|
||
const graph = buildTestGraph([
|
||
{
|
||
id: 'comm:auth',
|
||
label: 'Community' as any,
|
||
name: 'Auth',
|
||
filePath: '',
|
||
extra: {
|
||
heuristicLabel: 'Authentication',
|
||
keywords: ['auth', 'login', 'pass,word'],
|
||
description: 'Auth module',
|
||
enrichedBy: 'heuristic',
|
||
cohesion: 0.85,
|
||
symbolCount: 5,
|
||
},
|
||
},
|
||
]);
|
||
|
||
const result = await streamAllCSVsToDisk(graph, repoDir, csvDir);
|
||
const commCsv = result.nodeFiles.get('Community');
|
||
expect(commCsv).toBeDefined();
|
||
expect(commCsv!.rows).toBe(1);
|
||
|
||
const content = await fs.readFile(commCsv!.csvPath, 'utf-8');
|
||
// Keywords with commas should be escaped with \,
|
||
expect(content).toContain('pass\\,word');
|
||
});
|
||
|
||
it('handles process nodes', async () => {
|
||
const graph = buildTestGraph([
|
||
{
|
||
id: 'proc:flow',
|
||
label: 'Process' as any,
|
||
name: 'LoginFlow',
|
||
filePath: '',
|
||
extra: {
|
||
heuristicLabel: 'User Login',
|
||
processType: 'intra_community',
|
||
stepCount: 3,
|
||
communities: ['auth'],
|
||
entryPointId: 'func:login',
|
||
terminalId: 'func:validate',
|
||
},
|
||
},
|
||
]);
|
||
|
||
const result = await streamAllCSVsToDisk(graph, repoDir, csvDir);
|
||
const procCsv = result.nodeFiles.get('Process');
|
||
expect(procCsv).toBeDefined();
|
||
expect(procCsv!.rows).toBe(1);
|
||
});
|
||
|
||
it('deduplicates File nodes', async () => {
|
||
const graph = buildTestGraph([
|
||
{ id: 'file:src/index.ts', label: 'File', name: 'index.ts', filePath: 'src/index.ts' },
|
||
// Duplicate (same id) — should not appear twice
|
||
]);
|
||
// Add the same node again manually
|
||
graph.addNode({
|
||
id: 'file:src/index.ts',
|
||
label: 'File',
|
||
properties: { name: 'index.ts', filePath: 'src/index.ts' },
|
||
});
|
||
|
||
const result = await streamAllCSVsToDisk(graph, repoDir, csvDir);
|
||
const fileCsv = result.nodeFiles.get('File');
|
||
expect(fileCsv).toBeDefined();
|
||
expect(fileCsv!.rows).toBe(1);
|
||
});
|
||
|
||
// ─── Unhappy paths ──────────────────────────────────────────────────
|
||
|
||
it('handles empty graph (zero nodes)', async () => {
|
||
const graph = buildTestGraph([], []);
|
||
const result = await streamAllCSVsToDisk(graph, repoDir, csvDir);
|
||
expect(result.nodeFiles.size).toBe(0);
|
||
expect(result.totalValidRels).toBe(0);
|
||
expect(result.relsByPair.size).toBe(0);
|
||
});
|
||
|
||
it('handles node with empty string properties', async () => {
|
||
const graph = buildTestGraph([{ id: 'file:empty', label: 'File', name: '', filePath: '' }]);
|
||
|
||
const result = await streamAllCSVsToDisk(graph, repoDir, csvDir);
|
||
const fileCsv = result.nodeFiles.get('File');
|
||
expect(fileCsv).toBeDefined();
|
||
expect(fileCsv!.rows).toBe(1);
|
||
});
|
||
|
||
it('crosses the BufferedCSVWriter FLUSH_BYTES boundary without losing rows', async () => {
|
||
// FLUSH_BYTES=8MB; real File content totalling >8MB forces ≥1 mid-stream
|
||
// flush, exercising addRow's flush-promise return + the loop's
|
||
// `if (pending) await pending` path that the small fixtures above never
|
||
// reach (only the bench did).
|
||
const N = 10;
|
||
const CONTENT_SIZE = 1024 * 1024; // 1MB/file, 10MB total > FLUSH_BYTES
|
||
const bigContent = 'x'.repeat(CONTENT_SIZE);
|
||
await fs.mkdir(path.join(repoDir, 'src', 'big'), { recursive: true });
|
||
const nodes: TestNodeInput[] = [];
|
||
for (let i = 0; i < N; i++) {
|
||
const filePath = `src/big/f${i}.ts`;
|
||
await fs.writeFile(path.join(repoDir, filePath), bigContent);
|
||
nodes.push({ id: `File:${filePath}`, label: 'File', name: `f${i}.ts`, filePath });
|
||
}
|
||
const result = await streamAllCSVsToDisk(buildTestGraph(nodes), repoDir, csvDir);
|
||
|
||
const fileCsv = result.nodeFiles.get('File');
|
||
expect(fileCsv).toBeDefined();
|
||
expect(fileCsv!.rows).toBe(N); // no rows dropped/duplicated at the flush boundary
|
||
const dataRows = dataRowsOf(await fs.readFile(fileCsv!.csvPath, 'utf-8'));
|
||
expect(dataRows).toHaveLength(N);
|
||
expect(new Set(dataRows).size).toBe(N); // all distinct — no flush-boundary corruption
|
||
});
|
||
|
||
it('flushes the buffered CSV chunk once the byte threshold is reached', () => {
|
||
expect(shouldFlushCSVBuffer(FLUSH_BYTES - 1)).toBe(false);
|
||
expect(shouldFlushCSVBuffer(FLUSH_BYTES)).toBe(true);
|
||
});
|
||
|
||
it('shouldFlushCSVBuffer stays within the V8 string-length ceiling', () => {
|
||
// One more max-size row (TREE_SITTER_MAX_BUFFER, hard-clamped — see
|
||
// max-file-size.ts) can land right after the buffer was just under
|
||
// FLUSH_BYTES. Two transforms can each grow that row before it's joined:
|
||
// applyCjkSegmentationIfEnabled (#2331, ~7/3x worst case on an all-CJK
|
||
// row with GITNEXUS_FTS_CJK_SEGMENTATION=bigram) and escapeCSVField's
|
||
// quote-doubling (2x). The resulting join() must stay well under Node's
|
||
// MAX_STRING_LENGTH, or BufferedCSVWriter.flush() throws
|
||
// `RangeError: Invalid string length`.
|
||
const worstCaseJoinSize =
|
||
FLUSH_BYTES + 2 * CJK_BIGRAM_WORST_CASE_GROWTH_FACTOR * TREE_SITTER_MAX_BUFFER;
|
||
expect(worstCaseJoinSize).toBeLessThan(bufferConstants.MAX_STRING_LENGTH / 2);
|
||
});
|
||
});
|
||
|
||
/**
|
||
* Deterministic output — `GITNEXUS_SORT_GRAPH_OUTPUT` makes the CSV a pure function of the
|
||
* graph's node/edge SET (id-sorted) instead of of insertion order. This is the
|
||
* structural enabler for the out-of-core / windowed resolve: with it on,
|
||
* a windowed emit that produces the same edge set in a different order yields
|
||
* byte-identical CSV. Default off = today's insertion-order bytes exactly.
|
||
*/
|
||
describe('streamAllCSVsToDisk — deterministic output ordering', () => {
|
||
// Folder nodes: single-line CSV rows (no multi-line `content` column), so the
|
||
// id is the first comma-separated field and split('\n') is safe. ids are
|
||
// deliberately NOT in insertion order (c, a, b).
|
||
// ids use the `Folder:` prefix so getNodeLabel derives the valid `Folder`
|
||
// table — edges route to rel_Folder_Folder.csv (#2203 U2). Deliberately NOT
|
||
// in insertion order (c, a, b).
|
||
const NODES: TestNodeInput[] = [
|
||
{ id: 'Folder:c', label: 'Folder', name: 'c', filePath: 'c' },
|
||
{ id: 'Folder:a', label: 'Folder', name: 'a', filePath: 'a' },
|
||
{ id: 'Folder:b', label: 'Folder', name: 'b', filePath: 'b' },
|
||
];
|
||
const RELS: TestRelInput[] = [
|
||
{ sourceId: 'Folder:c', targetId: 'Folder:a', type: 'CONTAINS' },
|
||
{ sourceId: 'Folder:a', targetId: 'Folder:b', type: 'CONTAINS' },
|
||
{ sourceId: 'Folder:b', targetId: 'Folder:c', type: 'CONTAINS' },
|
||
];
|
||
const dataRows = (csv: string): string[] =>
|
||
csv
|
||
.trim()
|
||
.split('\n')
|
||
.slice(1)
|
||
.filter((l) => l.length > 0);
|
||
const firstCol = (row: string): string => row.split(',')[0];
|
||
|
||
const run = async (
|
||
nodes: TestNodeInput[],
|
||
rels: TestRelInput[],
|
||
sorted: boolean,
|
||
sub: string,
|
||
): Promise<{ folderIds: string[]; relRows: string[] }> => {
|
||
if (sorted) process.env.GITNEXUS_SORT_GRAPH_OUTPUT = '1';
|
||
else delete process.env.GITNEXUS_SORT_GRAPH_OUTPUT;
|
||
try {
|
||
const result = await streamAllCSVsToDisk(
|
||
buildTestGraph(nodes, rels),
|
||
repoDir,
|
||
path.join(csvDir, sub),
|
||
);
|
||
const folderCsv = result.nodeFiles.get('Folder');
|
||
const folderIds = folderCsv
|
||
? dataRows(await fs.readFile(folderCsv.csvPath, 'utf-8')).map(firstCol)
|
||
: [];
|
||
const relRows = await readAllRelRows(result.relsByPair);
|
||
return { folderIds, relRows };
|
||
} finally {
|
||
delete process.env.GITNEXUS_SORT_GRAPH_OUTPUT;
|
||
}
|
||
};
|
||
|
||
it('default off: node rows follow graph insertion order (not id-sorted)', async () => {
|
||
const { folderIds } = await run(NODES, RELS, false, 'u6a-off');
|
||
expect(folderIds).not.toEqual([...folderIds].sort()); // insertion order c, a, b
|
||
});
|
||
|
||
it('flag on: node rows are sorted by id', async () => {
|
||
const { folderIds } = await run(NODES, RELS, true, 'u6a-on');
|
||
expect(folderIds).toEqual([...folderIds].sort());
|
||
});
|
||
|
||
it('flag on makes output independent of graph insertion order; off does not', async () => {
|
||
const nodesRev = [...NODES].reverse();
|
||
const relsRev = [...RELS].reverse();
|
||
|
||
const onFwd = await run(NODES, RELS, true, 'u6a-on-fwd');
|
||
const onRev = await run(nodesRev, relsRev, true, 'u6a-on-rev');
|
||
// SORTED: byte-for-byte identical regardless of insertion order — the deterministic-output property.
|
||
expect(onRev.folderIds).toEqual(onFwd.folderIds);
|
||
expect(onRev.relRows).toEqual(onFwd.relRows);
|
||
|
||
const offFwd = await run(NODES, RELS, false, 'u6a-off-fwd');
|
||
const offRev = await run(nodesRev, relsRev, false, 'u6a-off-rev');
|
||
// UNSORTED: insertion order leaks into the bytes (today's behavior).
|
||
expect(offRev.folderIds).not.toEqual(offFwd.folderIds);
|
||
|
||
// SAME node/edge SET in both modes — sorting reorders rows, never adds/drops.
|
||
expect([...onFwd.folderIds].sort()).toEqual([...offFwd.folderIds].sort());
|
||
expect([...onFwd.relRows].sort()).toEqual([...offFwd.relRows].sort());
|
||
});
|
||
});
|
||
|
||
/**
|
||
* #2203 U2 byte-identity: for all quote-free ids the direct per-pair emit must
|
||
* produce per-pair files byte-for-byte identical to the legacy
|
||
* splitRelCsvByLabelPair oracle run over an equivalent monolithic relations.csv
|
||
* from the same graph. This is the load-bearing guard for "byte-identical graph
|
||
* content" (issue acceptance). The ONE intentional divergence — ids containing a
|
||
* double-quote, where the router (raw-id label) is more correct than the oracle
|
||
* (regex over the escaped row) — is asserted explicitly in its own test below.
|
||
*/
|
||
describe('streamAllCSVsToDisk — direct per-pair emit matches the split oracle', () => {
|
||
// The oracle always emits in graph.iterRelationships() (unsorted) order; the
|
||
// production path honours GITNEXUS_SORT_GRAPH_OUTPUT. Clear it so a value
|
||
// leaked from a prior test can't desync the two and produce a spurious diff.
|
||
beforeEach(() => {
|
||
delete process.env.GITNEXUS_SORT_GRAPH_OUTPUT;
|
||
});
|
||
|
||
it('produces byte-identical per-pair files + identical skip/total accounting', async () => {
|
||
// Multiple valid pairs, getNodeLabel special prefixes (comm_ AND proc_), and
|
||
// one invalid-label edge that BOTH paths must skip identically.
|
||
const graph = buildTestGraph(
|
||
[
|
||
{ id: 'File:a.ts', label: 'File', name: 'a.ts', filePath: 'a.ts' },
|
||
{ id: 'Function:a.ts:f:1', label: 'Function', name: 'f', filePath: 'a.ts' },
|
||
{ id: 'Function:a.ts:g:5', label: 'Function', name: 'g', filePath: 'a.ts' },
|
||
{ id: 'comm_1', label: 'Community' as never, name: 'c1', filePath: '' },
|
||
{ id: 'proc_1', label: 'Process' as never, name: 'p1', filePath: '' },
|
||
],
|
||
[
|
||
{ sourceId: 'File:a.ts', targetId: 'Function:a.ts:f:1', type: 'CONTAINS' },
|
||
{ sourceId: 'File:a.ts', targetId: 'Function:a.ts:g:5', type: 'CONTAINS' },
|
||
{ sourceId: 'Function:a.ts:f:1', targetId: 'Function:a.ts:g:5', type: 'CALLS' },
|
||
// comm_ target prefix → Community label (getNodeLabel special case);
|
||
// Function→Community is a real schema pair.
|
||
{ sourceId: 'Function:a.ts:f:1', targetId: 'comm_1', type: 'MEMBER_OF' },
|
||
// proc_ target prefix → Process label; Function→Process is likewise
|
||
// declared in the production relation schema.
|
||
{ sourceId: 'Function:a.ts:g:5', targetId: 'proc_1', type: 'STEP_IN_PROCESS' },
|
||
// Invalid FROM label ('Bogus' ∉ NODE_TABLES) — skipped by both paths.
|
||
{ sourceId: 'Bogus:x', targetId: 'File:a.ts', type: 'CONTAINS' },
|
||
// Invalid TO label — exercises the OTHER branch of the skip condition.
|
||
{ sourceId: 'File:a.ts', targetId: 'Bogus:y', type: 'CONTAINS' },
|
||
],
|
||
);
|
||
|
||
const directDir = path.join(csvDir, 'diff-direct');
|
||
const oracleDir = path.join(csvDir, 'diff-oracle');
|
||
await fs.mkdir(oracleDir, { recursive: true });
|
||
|
||
// Direct emit (production path).
|
||
const direct = await streamAllCSVsToDisk(graph, repoDir, directDir);
|
||
|
||
// Oracle: build the monolithic relations.csv this graph would have produced
|
||
// (same insertion order, same row bytes via buildRelRow), then split it.
|
||
const relCsv = path.join(oracleDir, 'relations.csv');
|
||
const lines = [REL_CSV_HEADER];
|
||
for (const rel of graph.iterRelationships()) lines.push(buildRelRow(rel));
|
||
await fs.writeFile(relCsv, lines.join('\n') + '\n', 'utf-8');
|
||
|
||
const split = await splitRelCsvByLabelPair(
|
||
relCsv,
|
||
oracleDir,
|
||
new Set<string>(NODE_TABLES),
|
||
getNodeLabel,
|
||
);
|
||
await Promise.all(
|
||
Array.from(split.pairWriteStreams.values()).map(async (ws) => {
|
||
ws.end();
|
||
await finished(ws);
|
||
}),
|
||
);
|
||
|
||
// Identical accounting.
|
||
expect(direct.totalValidRels).toBe(split.totalValidRels);
|
||
expect(direct.totalValidRels).toBe(5);
|
||
expect(direct.skippedRels).toBe(split.skippedRels);
|
||
expect(direct.skippedRels).toBe(2); // invalid-FROM + invalid-TO, both skipped
|
||
expect(direct.relHeader).toBe(split.relHeader);
|
||
|
||
// Identical pair set.
|
||
expect([...direct.relsByPair.keys()].sort()).toEqual([...split.relsByPairMeta.keys()].sort());
|
||
|
||
// Byte-identical per-pair file contents.
|
||
for (const key of direct.relsByPair.keys()) {
|
||
const directContent = await fs.readFile(direct.relsByPair.get(key)!.csvPath, 'utf-8');
|
||
const oracleContent = await fs.readFile(split.relsByPairMeta.get(key)!.csvPath, 'utf-8');
|
||
expect(directContent, `pair ${key}`).toBe(oracleContent);
|
||
}
|
||
});
|
||
|
||
it('quote-in-id edge: router routes it (raw-id label) while the oracle drops it — intended divergence', async () => {
|
||
// A node id with an embedded double-quote (legal in a POSIX filePath). The
|
||
// router derives the label from the RAW id (`File`), so it routes the edge;
|
||
// the oracle re-derives the label via /"([^"]*)","([^"]*)"/ over the ESCAPED
|
||
// row (`"File:a""b.ts",...`), mis-reads the field, and drops it. This locks
|
||
// the intended divergence so a future change can't silently revert the
|
||
// router to the buggy regex semantics.
|
||
const graph = buildTestGraph(
|
||
[
|
||
{ id: 'File:clean.ts', label: 'File', name: 'clean.ts', filePath: 'clean.ts' },
|
||
{ id: 'File:a"b.ts', label: 'File', name: 'a"b.ts', filePath: 'a"b.ts' },
|
||
{ id: 'Function:a.ts:f:1', label: 'Function', name: 'f', filePath: 'a.ts' },
|
||
],
|
||
[
|
||
{ sourceId: 'File:clean.ts', targetId: 'Function:a.ts:f:1', type: 'CONTAINS' },
|
||
{ sourceId: 'File:a"b.ts', targetId: 'Function:a.ts:f:1', type: 'CONTAINS' },
|
||
],
|
||
);
|
||
|
||
const directDir = path.join(csvDir, 'qd-direct');
|
||
const oracleDir = path.join(csvDir, 'qd-oracle');
|
||
await fs.mkdir(oracleDir, { recursive: true });
|
||
|
||
const direct = await streamAllCSVsToDisk(graph, repoDir, directDir);
|
||
|
||
const relCsv = path.join(oracleDir, 'relations.csv');
|
||
const lines = [REL_CSV_HEADER];
|
||
for (const rel of graph.iterRelationships()) lines.push(buildRelRow(rel));
|
||
await fs.writeFile(relCsv, lines.join('\n') + '\n', 'utf-8');
|
||
const split = await splitRelCsvByLabelPair(
|
||
relCsv,
|
||
oracleDir,
|
||
new Set<string>(NODE_TABLES),
|
||
getNodeLabel,
|
||
);
|
||
await Promise.all(
|
||
Array.from(split.pairWriteStreams.values()).map(async (ws) => {
|
||
ws.end();
|
||
await finished(ws);
|
||
}),
|
||
);
|
||
|
||
// Router routes BOTH edges — the raw-id label `File` is valid for both.
|
||
expect(direct.totalValidRels).toBe(2);
|
||
expect(direct.skippedRels).toBe(0);
|
||
expect(direct.relsByPair.get('File|Function')!.rows).toBe(2);
|
||
|
||
// Oracle DIVERGES: its regex mis-reads the quote-in-id row and drops that
|
||
// edge, so it routes strictly fewer edges. Asserted robustly — we do NOT
|
||
// pin the oracle's exact mis-derived label.
|
||
expect(split.totalValidRels).toBeLessThan(direct.totalValidRels);
|
||
expect(split.skippedRels).toBeGreaterThan(direct.skippedRels);
|
||
});
|
||
|
||
it('sorted path (GITNEXUS_SORT_GRAPH_OUTPUT=1): per-pair files byte-identical to the oracle', async () => {
|
||
// The earlier differential test covers the default (insertion-order) path.
|
||
// Here the sorted emit path must also match the oracle — fed the SAME
|
||
// id-sorted order orderedRelationships() uses (sort by rel.id).
|
||
process.env.GITNEXUS_SORT_GRAPH_OUTPUT = '1';
|
||
try {
|
||
const graph = buildTestGraph(
|
||
[
|
||
{ id: 'File:a.ts', label: 'File', name: 'a.ts', filePath: 'a.ts' },
|
||
{ id: 'Function:a.ts:f:1', label: 'Function', name: 'f', filePath: 'a.ts' },
|
||
{ id: 'Function:a.ts:g:5', label: 'Function', name: 'g', filePath: 'a.ts' },
|
||
],
|
||
// Deliberately NOT in id-sorted order so the sort actually reorders rows.
|
||
[
|
||
{ sourceId: 'Function:a.ts:f:1', targetId: 'Function:a.ts:g:5', type: 'CALLS' },
|
||
{ sourceId: 'File:a.ts', targetId: 'Function:a.ts:g:5', type: 'CONTAINS' },
|
||
{ sourceId: 'File:a.ts', targetId: 'Function:a.ts:f:1', type: 'CONTAINS' },
|
||
],
|
||
);
|
||
|
||
const directDir = path.join(csvDir, 'sorted-direct');
|
||
const oracleDir = path.join(csvDir, 'sorted-oracle');
|
||
await fs.mkdir(oracleDir, { recursive: true });
|
||
|
||
const direct = await streamAllCSVsToDisk(graph, repoDir, directDir);
|
||
|
||
// Oracle fed the same id-sorted order the sorted emit produces.
|
||
const sortedRels = [...graph.iterRelationships()].sort((a, b) =>
|
||
a.id < b.id ? -1 : a.id > b.id ? 1 : 0,
|
||
);
|
||
const relCsv = path.join(oracleDir, 'relations.csv');
|
||
const lines = [REL_CSV_HEADER];
|
||
for (const rel of sortedRels) lines.push(buildRelRow(rel));
|
||
await fs.writeFile(relCsv, lines.join('\n') + '\n', 'utf-8');
|
||
const split = await splitRelCsvByLabelPair(
|
||
relCsv,
|
||
oracleDir,
|
||
new Set<string>(NODE_TABLES),
|
||
getNodeLabel,
|
||
);
|
||
await Promise.all(
|
||
Array.from(split.pairWriteStreams.values()).map(async (ws) => {
|
||
ws.end();
|
||
await finished(ws);
|
||
}),
|
||
);
|
||
|
||
expect([...direct.relsByPair.keys()].sort()).toEqual([...split.relsByPairMeta.keys()].sort());
|
||
for (const key of direct.relsByPair.keys()) {
|
||
const directContent = await fs.readFile(direct.relsByPair.get(key)!.csvPath, 'utf-8');
|
||
const oracleContent = await fs.readFile(split.relsByPairMeta.get(key)!.csvPath, 'utf-8');
|
||
expect(directContent, `pair ${key} (sorted)`).toBe(oracleContent);
|
||
}
|
||
} finally {
|
||
delete process.env.GITNEXUS_SORT_GRAPH_OUTPUT;
|
||
}
|
||
});
|
||
});
|
||
|
||
// The overlap leg (#2203) needs to start COPY-ing nodes while relationship CSVs
|
||
// are still being written. streamAllCSVsToDisk exposes that boundary via an
|
||
// onNodePhaseComplete callback. These tests pin the contract: it fires once,
|
||
// after node CSVs exist and before any rel CSV does, and supplying it does not
|
||
// change the emitted output.
|
||
describe('onNodePhaseComplete hook (#2203 overlap boundary)', () => {
|
||
const hookGraph = () =>
|
||
buildTestGraph(
|
||
[
|
||
{ id: 'File:src/index.ts', label: 'File', name: 'index.ts', filePath: 'src/index.ts' },
|
||
{
|
||
id: 'Function:src/index.ts:main:1',
|
||
label: 'Function',
|
||
name: 'main',
|
||
filePath: 'src/index.ts',
|
||
startLine: 1,
|
||
endLine: 3,
|
||
},
|
||
],
|
||
[
|
||
{
|
||
sourceId: 'File:src/index.ts',
|
||
targetId: 'Function:src/index.ts:main:1',
|
||
type: 'DEFINES',
|
||
},
|
||
],
|
||
);
|
||
|
||
it('fires exactly once, after node CSVs are flushed and before any rel CSV exists', async () => {
|
||
const hookCsvDir = path.join(tmpHandle.dbPath, 'csv-hook-timing');
|
||
let calls = 0;
|
||
let nodeCsvsPresent = false;
|
||
let relCsvsPresent = true;
|
||
let handedKeys: string[] = [];
|
||
|
||
const result = await streamAllCSVsToDisk(hookGraph(), repoDir, hookCsvDir, (nodeFiles) => {
|
||
calls++;
|
||
handedKeys = [...nodeFiles.keys()].sort();
|
||
const entries = readdirSync(hookCsvDir);
|
||
nodeCsvsPresent = entries.includes('file.csv') && entries.includes('function.csv');
|
||
relCsvsPresent = entries.some((f) => f.startsWith('rel_'));
|
||
});
|
||
|
||
expect(calls).toBe(1);
|
||
expect(nodeCsvsPresent).toBe(true);
|
||
// No relationship CSV may exist yet — the rel pass starts after the hook.
|
||
expect(relCsvsPresent).toBe(false);
|
||
// The manifest handed to the callback is the one returned to the caller.
|
||
expect(handedKeys).toEqual([...result.nodeFiles.keys()].sort());
|
||
expect(result.totalValidRels).toBe(1);
|
||
});
|
||
|
||
it('supplying the callback does not change the node manifest (no behavior change)', async () => {
|
||
const withDir = path.join(tmpHandle.dbPath, 'csv-hook-with');
|
||
const withoutDir = path.join(tmpHandle.dbPath, 'csv-hook-without');
|
||
const withCb = await streamAllCSVsToDisk(hookGraph(), repoDir, withDir, () => {});
|
||
const without = await streamAllCSVsToDisk(hookGraph(), repoDir, withoutDir);
|
||
|
||
const manifest = (r: typeof withCb) =>
|
||
[...r.nodeFiles.entries()].map(([k, v]) => `${k}:${v.rows}`).sort();
|
||
expect(manifest(withCb)).toEqual(manifest(without));
|
||
expect(withCb.totalValidRels).toBe(without.totalValidRels);
|
||
expect(withCb.skippedRels).toBe(without.skippedRels);
|
||
});
|
||
});
|