mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-11 22:53:04 +00:00
* fix(analyze): make incremental analyze skip the derived layers it can reuse (#3016) A warm incremental run only ever wrote a handful of files, but it still paid for the whole graph on the way out: Leiden ran over every node, flow extraction re-derived every process, and all FTS indexes were dropped and rebuilt from scratch. On a small edit that tail dominated the run, which is why "incremental" did not feel incremental. Reuse what the previous run already derived when the write plan allows it. The pipeline holds back community detection and flow extraction whenever the persisted metadata says this run is a candidate for a surgical write; the DB keeps its Community/Process rows instead of a wipe-and-rewrite; and the FTS sweep is narrowed to the indexes the run actually has to touch. The bet is placed before the pipeline and settled after it. Any plan that turns out to need a freshly derived layer — full rebuild, escalated write, or an incremental diff with deleted files — runs the held-back phases through `runDeferredDerivedPhases`, against the same graph and phase outputs, so its output is identical to never having skipped them. Correctness details worth naming, since each one silently loses data if got wrong: - The MEMBER_OF / STEP_IN_PROCESS edges of the changed files are snapshotted before the DETACH DELETE and reattached after the subgraph load. Both endpoints are matched by explicit label: `labels(n)[0]` over an unlabelled match returns an empty string on this engine, which produced a snapshot that restored nothing. - The FTS narrowing unions three sets — what the writeback deletes (a DB probe, because a symbol the edit removed is in no fresh graph but is still a row), what it inserts (the fresh graph), and what is missing right now (else a prior escalation's dropped indexes would never come back). An unreadable index catalog withdraws the narrowing entirely. - Deletions disqualify reuse outright: persisted derived rows can reference nodes this run removes, and nothing short of re-deriving can tell which. Covered by the existing incremental suites, including the incremental-equals-force byte-equivalence test and the #2589 drop-before-delete ordering test, plus unit tests for the new helpers. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(analyze): address #3102 review on derived reuse and FTS narrowing Re-run Leiden/flows unless the file-hash diff is empty, restore ENTRY_POINT_OF on the preserve path, always drop class_fts before Spring synthetic Class DML, and reject seeded duplicate phase names. Prettier and exact FTS drop-ordering assertions unblock CI and pin the #2589/#3016 contract. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor(analyze): reuse FileHashDiff for derived-layer preserve Drop the count DTO, share phase-name uniqueness, and remove the File FTS sentinel that Class already makes unreachable. Refs #3102 Co-authored-by: Cursor <cursoragent@cursor.com> * style(analyze): prettier-wrap shouldPreservePersistedDerivedGraph quality / format failed on the Pick<FileHashDiff> signature wrapping. Refs #3102 Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
154 lines
6.8 KiB
TypeScript
154 lines
6.8 KiB
TypeScript
/**
|
|
* #2589: the incremental writeback must drop every FTS index BEFORE
|
|
* `deleteNodesForFiles` runs its batched DETACH DELETE — not only in
|
|
* Phase 3, after the delete already ran against a table still carrying the
|
|
* PREVIOUS run's index. This drives the real `runFullAnalysis` incremental
|
|
* path (real git repo, real LadybugDB, real FTS extension) and asserts,
|
|
* at the moment `deleteNodesForFiles` is invoked, that `SHOW_INDEXES()`
|
|
* already reports FTS indexes for tables that will be DML'd as absent
|
|
* (#2589 drop-before-delete). #3016: empty-language FTS tables may remain.
|
|
*/
|
|
import { readFile, writeFile } from 'fs/promises';
|
|
import { execSync } from 'child_process';
|
|
import path from 'path';
|
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { setupMiniRepo } from '../helpers/mini-repo.js';
|
|
import { getStoragePaths } from '../../src/storage/repo-manager.js';
|
|
import { FTS_INDEXES } from '../../src/core/search/fts-schema.js';
|
|
import { createTempDir } from '../helpers/test-db.js';
|
|
import { resolveAnalyzeInstallPolicy } from '../../src/core/lbug/extension-loader.js';
|
|
|
|
const ftsMustBeAvailable = process.env.GITNEXUS_REQUIRE_FTS === '1';
|
|
|
|
describe('runFullAnalysis incremental writeback — FTS drop-before-delete ordering (#2589)', () => {
|
|
let ftsAvailable = true;
|
|
let skipWarned = false;
|
|
|
|
beforeAll(async () => {
|
|
const lbugAdapter = await import('../../src/core/lbug/lbug-adapter.js');
|
|
// Cheap standalone probe — matches the withTestLbugDB/lbug-vector-extension
|
|
// convention of checking availability once, up front, rather than deep
|
|
// inside the (expensive) test body.
|
|
const probe = await createTempDir('gitnexus-2589-fts-probe-');
|
|
try {
|
|
await lbugAdapter.initLbug(probe.dbPath);
|
|
ftsAvailable = await lbugAdapter.loadFTSExtension(undefined, {
|
|
policy: resolveAnalyzeInstallPolicy(),
|
|
});
|
|
} finally {
|
|
await lbugAdapter.closeLbug();
|
|
await probe.cleanup();
|
|
}
|
|
}, 120_000);
|
|
|
|
// Skip VISIBLY (ctx.skip() marks the test as skipped, not passed) when the
|
|
// extension is unavailable — silently `return`ing from inside `it()` would
|
|
// report a false pass and hide a regression in the drop-before-delete
|
|
// ordering in exactly the environments least likely to have a human notice.
|
|
beforeEach((ctx) => {
|
|
if (!ftsAvailable) {
|
|
if (ftsMustBeAvailable) {
|
|
throw new Error(
|
|
'GITNEXUS_REQUIRE_FTS=1 but the FTS extension is unavailable — cannot verify the #2589 ordering fix.',
|
|
);
|
|
}
|
|
if (!skipWarned) {
|
|
skipWarned = true;
|
|
console.warn(
|
|
'[incremental-fts-drop-ordering] Skipping — the LadybugDB FTS extension is unavailable.',
|
|
);
|
|
}
|
|
ctx.skip();
|
|
}
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.doUnmock('../../src/core/lbug/lbug-adapter.js');
|
|
vi.resetModules();
|
|
});
|
|
|
|
it('SHOW_INDEXES() reports the FTS indexes of every table being written as absent by the time deleteNodesForFiles runs', async () => {
|
|
const lbugAdapter = await import('../../src/core/lbug/lbug-adapter.js');
|
|
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
|
|
|
const repo = await setupMiniRepo('gitnexus-2589-fts-order-');
|
|
try {
|
|
// First run: full rebuild, builds every FTS index for real.
|
|
await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} });
|
|
|
|
// runFullAnalysis closes its own connection on return — open a fresh
|
|
// one just to probe SHOW_INDEXES(), then close it before the second
|
|
// run opens its own (LadybugDB is single-writer/single-connection).
|
|
const { lbugPath } = getStoragePaths(repo.dbPath);
|
|
await lbugAdapter.initLbug(lbugPath);
|
|
const showIndexNames = async (): Promise<string[]> => {
|
|
const rows = (await lbugAdapter.executeQuery('CALL SHOW_INDEXES() RETURN *')) as Array<
|
|
Record<string, unknown>
|
|
>;
|
|
return rows.map((r) => r.index_name).filter((n): n is string => typeof n === 'string');
|
|
};
|
|
const beforeChange = await showIndexNames();
|
|
await lbugAdapter.closeLbug();
|
|
|
|
// Hard assertion, not a soft skip: the beforeEach gate already proved
|
|
// the extension loads, so every index failing to build here is a real
|
|
// bug in the full-rebuild FTS phase, not an environment gap.
|
|
for (const { indexName } of FTS_INDEXES) {
|
|
expect(beforeChange).toContain(indexName);
|
|
}
|
|
|
|
// Spy on the real deleteNodesForFiles, recording the FTS index list at
|
|
// the exact moment it's invoked (before it does anything), then
|
|
// delegating to the real implementation so the run completes normally.
|
|
let indexNamesAtDeleteTime: string[] | undefined;
|
|
const originalDeleteNodesForFiles = lbugAdapter.deleteNodesForFiles;
|
|
vi.spyOn(lbugAdapter, 'deleteNodesForFiles').mockImplementation(async (filePaths, opts) => {
|
|
indexNamesAtDeleteTime = await showIndexNames();
|
|
return originalDeleteNodesForFiles(filePaths, opts);
|
|
});
|
|
|
|
// Small change to a single file — stays well under the escalation
|
|
// threshold (50 files) on this 7-file mini-repo, so it takes the
|
|
// non-escalated (surgical) incremental branch this test targets.
|
|
const handlerPath = path.join(repo.dbPath, 'src', 'handler.ts');
|
|
await writeFile(
|
|
handlerPath,
|
|
(await readFile(handlerPath, 'utf-8')) + '\n// #2589 ordering-test touch\n',
|
|
'utf-8',
|
|
);
|
|
execSync('git -c user.name=test -c user.email=t@t -c commit.gpgsign=false add -A', {
|
|
cwd: repo.dbPath,
|
|
stdio: 'pipe',
|
|
});
|
|
execSync(
|
|
'git -c user.name=test -c user.email=t@t -c commit.gpgsign=false commit -q -m "#2589 ordering touch"',
|
|
{ cwd: repo.dbPath, stdio: 'pipe' },
|
|
);
|
|
|
|
await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} });
|
|
|
|
expect(indexNamesAtDeleteTime).toBeDefined();
|
|
// handler.ts writes File/Function/Class/Method. The incremental write set
|
|
// also pulls importer-expanded mini-repo files (index.ts re-exports
|
|
// handler; validator.ts holds Interface + Property), so those FTS
|
|
// indexes must be down before DETACH DELETE (#2589). Every other
|
|
// configured FTS index must still be live (#3016 narrowing).
|
|
const down = new Set([
|
|
'file_fts',
|
|
'function_fts',
|
|
'class_fts',
|
|
'method_fts',
|
|
'interface_fts',
|
|
'property_fts',
|
|
]);
|
|
const configured = FTS_INDEXES.map((i) => i.indexName);
|
|
const ftsAtDelete = indexNamesAtDeleteTime!.filter((name) => configured.includes(name));
|
|
expect([...ftsAtDelete].sort()).toEqual(configured.filter((name) => !down.has(name)).sort());
|
|
for (const name of down) {
|
|
expect(ftsAtDelete).not.toContain(name);
|
|
}
|
|
} finally {
|
|
await repo.cleanup();
|
|
}
|
|
}, 300_000);
|
|
});
|