mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-09 03:17:54 +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>
263 lines
11 KiB
TypeScript
263 lines
11 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
// Hoisted so the vi.mock factory can close over the shared call log.
|
|
const { calls } = vi.hoisted(() => ({ calls: [] as string[] }));
|
|
|
|
vi.mock('../../src/core/lbug/lbug-adapter.js', () => ({
|
|
DEFAULT_FTS_STEMMER: 'porter',
|
|
// Row accessors and the snapshot resolver are PURE — mirror the real
|
|
// implementations rather than stubbing them, or `verifySearchFTSIndexes`
|
|
// and `dropSearchFTSIndexes` read `undefined` out of every catalog row and
|
|
// the suite passes for the wrong reason. (#2841 cleanup moved these reads
|
|
// behind named accessors so the LadybugDB column contract has one home; a
|
|
// whole-module mock has to follow.)
|
|
indexRowTable: (row: Record<string, unknown> | undefined) => row?.table_name ?? row?.[0],
|
|
indexRowName: (row: Record<string, unknown> | undefined) => row?.index_name ?? row?.[1],
|
|
indexRowType: (row: Record<string, unknown> | undefined) => row?.index_type ?? row?.[2],
|
|
readIndexCatalogRows: vi.fn(async () => undefined),
|
|
resolveGateRows: vi.fn(async (rows?: unknown) =>
|
|
rows === undefined ? undefined : (rows as unknown[]),
|
|
),
|
|
dropFTSIndex: vi.fn(async (table: string, indexName: string) => {
|
|
calls.push(`drop:${table}.${indexName}`);
|
|
}),
|
|
createFTSIndex: vi.fn(
|
|
async (table: string, indexName: string, _props: string[], stemmer: string) => {
|
|
calls.push(`create:${table}.${indexName}:${stemmer}`);
|
|
},
|
|
),
|
|
}));
|
|
|
|
const {
|
|
buildSearchIndexesOrDegrade,
|
|
createSearchFTSIndexes,
|
|
getSearchFTSStemmer,
|
|
initialiseSearchFTSStemmer,
|
|
missingSearchFTSIndexTables,
|
|
} = await import('../../src/core/search/fts-indexes.js');
|
|
const { FTS_INDEXES } = await import('../../src/core/search/fts-schema.js');
|
|
const { createFTSIndex } = await import('../../src/core/lbug/lbug-adapter.js');
|
|
|
|
/** SHOW_INDEXES rows covering every configured FTS index's expected properties. */
|
|
const fullCoverageRows = () =>
|
|
FTS_INDEXES.map((i) => ({ index_name: i.indexName, property_names: [...i.properties] }));
|
|
|
|
/** The row-level tokenizer error of #2544/#2546/#2889, verbatim. */
|
|
const POISON = 'Runtime exception: Failed calling LOWER: Invalid UTF-8.';
|
|
|
|
afterEach(() => {
|
|
calls.length = 0;
|
|
// `reset`, not `clear`: only reset drains the `…Once` queue, and a test that
|
|
// queues more rejections than the code consumes would otherwise leak the
|
|
// leftovers into whichever test runs next. Vitest 4's reset restores the
|
|
// implementation each `vi.fn(impl)` was created with, so the factory's
|
|
// recording defaults survive.
|
|
vi.resetAllMocks();
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
describe('createSearchFTSIndexes', () => {
|
|
it('drops each index before (re)creating it, in order, for every entry', async () => {
|
|
await createSearchFTSIndexes();
|
|
const expected = FTS_INDEXES.flatMap((i) => [
|
|
`drop:${i.table}.${i.indexName}`,
|
|
`create:${i.table}.${i.indexName}:porter`,
|
|
]);
|
|
expect(calls).toEqual(expected);
|
|
});
|
|
|
|
it('rebuilds only the requested tables when options.tables is set (#3016)', async () => {
|
|
await createSearchFTSIndexes({ tables: new Set(['File', 'Function']) });
|
|
expect(calls).toEqual([
|
|
'drop:File.file_fts',
|
|
'create:File.file_fts:porter',
|
|
'drop:Function.function_fts',
|
|
'create:Function.function_fts:porter',
|
|
]);
|
|
});
|
|
|
|
it('invokes onIndexStart/onIndexReady once per index', async () => {
|
|
const started: string[] = [];
|
|
const ready: string[] = [];
|
|
await createSearchFTSIndexes({
|
|
onIndexStart: (_t, name) => started.push(name),
|
|
onIndexReady: (_t, name) => ready.push(name),
|
|
});
|
|
const expectedNames = FTS_INDEXES.map((i) => i.indexName);
|
|
expect(started).toEqual(expectedNames);
|
|
expect(ready).toEqual(expectedNames);
|
|
});
|
|
|
|
it('passes the configured FTS stemmer to every index', async () => {
|
|
vi.stubEnv('GITNEXUS_FTS_STEMMER', ' none ');
|
|
|
|
await createSearchFTSIndexes();
|
|
|
|
expect(calls.filter((call) => call.startsWith('create:'))).toEqual(
|
|
FTS_INDEXES.map((i) => `create:${i.table}.${i.indexName}:none`),
|
|
);
|
|
});
|
|
|
|
it('rejects unsupported stemmer names before creating indexes', async () => {
|
|
vi.stubEnv('GITNEXUS_FTS_STEMMER', "none'); DROP TABLE File; --");
|
|
|
|
await expect(createSearchFTSIndexes()).rejects.toThrow('Invalid GITNEXUS_FTS_STEMMER');
|
|
expect(calls).toEqual([]);
|
|
});
|
|
|
|
// #2889 — one untokenizable row used to cost the indexes of its own table AND
|
|
// every table after it in FTS_INDEXES order, because the first rejection left
|
|
// the loop. The `drop` for the failing table has already run by then, so the
|
|
// damage was never confined to "the index we could not rebuild".
|
|
it('isolates a failing index: others build, all drop, the failed one is not ready (#2889)', async () => {
|
|
vi.mocked(createFTSIndex).mockRejectedValueOnce(new Error(POISON));
|
|
const ready: string[] = [];
|
|
|
|
const failures = await createSearchFTSIndexes({ onIndexReady: (_t, name) => ready.push(name) });
|
|
|
|
const [poisoned, ...survivors] = FTS_INDEXES;
|
|
expect(failures).toEqual([
|
|
{ table: poisoned.table, indexName: poisoned.indexName, error: POISON },
|
|
]);
|
|
// The drop for the failing table still ran — that is why letting the
|
|
// rejection leave the loop cost the table its index as well as the rebuild.
|
|
expect(calls.filter((call) => call.startsWith('drop:'))).toEqual(
|
|
FTS_INDEXES.map((i) => `drop:${i.table}.${i.indexName}`),
|
|
);
|
|
expect(calls.filter((call) => call.startsWith('create:'))).toEqual(
|
|
survivors.map((i) => `create:${i.table}.${i.indexName}:porter`),
|
|
);
|
|
expect(ready).toEqual(survivors.map((i) => i.indexName));
|
|
});
|
|
});
|
|
|
|
describe('buildSearchIndexesOrDegrade', () => {
|
|
it('returns ok:true when every index builds and verifies (#2544/#2546)', async () => {
|
|
const executeQuery = vi.fn(async () => fullCoverageRows());
|
|
|
|
const result = await buildSearchIndexesOrDegrade(executeQuery);
|
|
|
|
expect(result).toEqual({ ok: true });
|
|
});
|
|
|
|
it('returns ok:false instead of throwing when a single index build rejects (#2544/#2546)', async () => {
|
|
vi.mocked(createFTSIndex).mockRejectedValueOnce(new Error(POISON));
|
|
const executeQuery = vi.fn(async () => fullCoverageRows());
|
|
|
|
const result = await buildSearchIndexesOrDegrade(executeQuery);
|
|
|
|
expect(result.ok).toBe(false);
|
|
expect(result.error).toContain('Invalid UTF-8');
|
|
// A row-level tokenizer error degrades; it must never escalate to an abort.
|
|
expect(result.failureClass).toBe('capability');
|
|
// #2889: verification still runs on a partial build — the surviving indexes
|
|
// are the whole point of isolating the failure, so they get proven, not
|
|
// assumed. (One SHOW_INDEXES read.)
|
|
expect(executeQuery).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('names every failing table, not just the first (#2889)', async () => {
|
|
vi.mocked(createFTSIndex)
|
|
.mockRejectedValueOnce(new Error(POISON))
|
|
.mockRejectedValueOnce(new Error(POISON));
|
|
const executeQuery = vi.fn(async () => fullCoverageRows());
|
|
|
|
const result = await buildSearchIndexesOrDegrade(executeQuery);
|
|
|
|
expect(result.ok).toBe(false);
|
|
expect(result.error).toContain(FTS_INDEXES[0].table);
|
|
expect(result.error).toContain(FTS_INDEXES[1].table);
|
|
expect(result.error).toContain(`2 of ${FTS_INDEXES.length} tables`);
|
|
});
|
|
|
|
it('escalates the aggregate to integrity when any single failure is integrity (#2889)', async () => {
|
|
// Capability signatures are checked first, so aggregating the raw messages
|
|
// into one string would have let an untokenizable row mask a broken write.
|
|
vi.mocked(createFTSIndex)
|
|
.mockRejectedValueOnce(new Error(POISON))
|
|
.mockRejectedValueOnce(new Error('IO exception: checkpoint failed'));
|
|
const executeQuery = vi.fn(async () => fullCoverageRows());
|
|
|
|
const result = await buildSearchIndexesOrDegrade(executeQuery);
|
|
|
|
expect(result.failureClass).toBe('integrity');
|
|
});
|
|
|
|
it('returns ok:false when verification finds a missing index, without throwing', async () => {
|
|
const executeQuery = vi.fn(async () => fullCoverageRows().slice(1));
|
|
|
|
const result = await buildSearchIndexesOrDegrade(executeQuery);
|
|
|
|
expect(result.ok).toBe(false);
|
|
expect(result.error).toContain('missing indexes');
|
|
});
|
|
|
|
it('reports a failed table once, with its reason, not twice (#2889)', async () => {
|
|
// The failing table is missing from the catalog too — verification would
|
|
// name it a second time, with no reason attached, if the report did not
|
|
// subtract what the build already explained.
|
|
vi.mocked(createFTSIndex).mockRejectedValueOnce(new Error(POISON));
|
|
const executeQuery = vi.fn(async () => fullCoverageRows().slice(1));
|
|
|
|
const result = await buildSearchIndexesOrDegrade(executeQuery);
|
|
|
|
const failed = `${FTS_INDEXES[0].table}.${FTS_INDEXES[0].indexName}`;
|
|
expect(result.error).toContain(`${failed} (${POISON})`);
|
|
expect(result.error).not.toContain('missing indexes');
|
|
});
|
|
});
|
|
|
|
describe('missingSearchFTSIndexTables (#3016)', () => {
|
|
const catalogRow = (i: { table: string; indexName: string }) => ({
|
|
table_name: i.table,
|
|
index_name: i.indexName,
|
|
});
|
|
|
|
it('reports nothing missing when the catalog carries every configured index', async () => {
|
|
const missing = await missingSearchFTSIndexTables(FTS_INDEXES.map(catalogRow));
|
|
expect(missing).toEqual(new Set());
|
|
});
|
|
|
|
it('names every table when the catalog is empty (a prior escalation dropped them all)', async () => {
|
|
const missing = await missingSearchFTSIndexTables([]);
|
|
expect(missing).toEqual(new Set(FTS_INDEXES.map((i) => i.table)));
|
|
});
|
|
|
|
it('names only the tables whose index is absent', async () => {
|
|
const rows = FTS_INDEXES.filter((i) => i.table !== 'Function').map(catalogRow);
|
|
expect(await missingSearchFTSIndexTables(rows)).toEqual(new Set(['Function']));
|
|
});
|
|
|
|
it('answers undefined when the catalog could not be read, so callers do not narrow', async () => {
|
|
expect(await missingSearchFTSIndexTables(undefined)).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('getSearchFTSStemmer', () => {
|
|
it('defaults to porter when unset', () => {
|
|
expect(getSearchFTSStemmer()).toBe('porter');
|
|
});
|
|
|
|
it('normalizes configured stemmer names', () => {
|
|
vi.stubEnv('GITNEXUS_FTS_STEMMER', ' German ');
|
|
|
|
expect(getSearchFTSStemmer()).toBe('german');
|
|
});
|
|
});
|
|
|
|
// Caches module state via initialise; keep last so no later test reads it.
|
|
describe('initialiseSearchFTSStemmer', () => {
|
|
it('throws on an unsupported stemmer', () => {
|
|
vi.stubEnv('GITNEXUS_FTS_STEMMER', 'porterr');
|
|
|
|
expect(() => initialiseSearchFTSStemmer()).toThrow('Invalid GITNEXUS_FTS_STEMMER');
|
|
});
|
|
|
|
it('resolves once so later reads ignore a changed env', () => {
|
|
vi.stubEnv('GITNEXUS_FTS_STEMMER', 'german');
|
|
expect(initialiseSearchFTSStemmer()).toBe('german');
|
|
|
|
vi.stubEnv('GITNEXUS_FTS_STEMMER', 'french');
|
|
expect(getSearchFTSStemmer()).toBe('german');
|
|
});
|
|
});
|