mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-07 02:58:02 +00:00
feat(lbug): loadGraphFromLbug, queryImporters, deleteAllCommunitiesAndProcesses
Three new primitives in lbug-adapter.ts to support incremental indexing: * loadGraphFromLbug(graph, unchangedFilePaths) — streams all nodes for files in the set across every hydratable node table (excludes Community/Process — graph-wide, regenerated downstream). Then loads edges where both endpoints belong to loaded nodes, excluding MEMBER_OF / STEP_IN_PROCESS edges (also graph-wide). FilePaths chunked at 200 per query to keep statement size bounded on huge repos. Endpoint-level join filters by source-side filePath in the query, target-side checked JS-side via the loadedNodeIds set. * queryImporters(targetFilePath) — returns DISTINCT a.filePath where a -[IMPORTS]-> b and b.filePath = target. Powers closure expansion: when a changed file's surface signature changes, all its importers must be re-parsed. * deleteAllCommunitiesAndProcesses() — drops Community/Process nodes (and their edges via DETACH DELETE) at the start of each incremental run so the communities/processes phases regenerate them from the fully-merged graph. Required for the 'Leiden runs on full graph' correctness invariant. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
aa8d7ae3f7
commit
98bb893d00
1 changed files with 239 additions and 0 deletions
|
|
@ -1204,6 +1204,245 @@ export const deleteNodesForFile = async (
|
||||||
|
|
||||||
export const getEmbeddingTableName = (): string => EMBEDDING_TABLE_NAME;
|
export const getEmbeddingTableName = (): string => EMBEDDING_TABLE_NAME;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Incremental indexing: DB → KnowledgeGraph hydration
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Node tables that have a `filePath` column and are eligible for hydration.
|
||||||
|
* `Community` and `Process` are graph-wide (no filePath) and are ALWAYS
|
||||||
|
* regenerated by the communities/processes phases — we never load them back.
|
||||||
|
*/
|
||||||
|
const HYDRATABLE_NODE_TABLES: readonly NodeTableName[] = NODE_TABLES.filter(
|
||||||
|
(t) => t !== 'Community' && t !== 'Process',
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Per-table extra columns to project beyond the base (id, name, filePath). */
|
||||||
|
const TABLE_EXTRA_COLUMNS: Record<string, string[]> = {
|
||||||
|
File: ['content'],
|
||||||
|
Folder: [],
|
||||||
|
Function: ['startLine', 'endLine', 'isExported', 'content', 'description'],
|
||||||
|
Class: ['startLine', 'endLine', 'isExported', 'content', 'description'],
|
||||||
|
Interface: ['startLine', 'endLine', 'isExported', 'content', 'description'],
|
||||||
|
Method: [
|
||||||
|
'startLine',
|
||||||
|
'endLine',
|
||||||
|
'isExported',
|
||||||
|
'content',
|
||||||
|
'description',
|
||||||
|
'parameterCount',
|
||||||
|
'returnType',
|
||||||
|
],
|
||||||
|
CodeElement: ['startLine', 'endLine', 'isExported', 'content', 'description'],
|
||||||
|
Section: ['startLine', 'endLine', 'level', 'content', 'description'],
|
||||||
|
Property: ['startLine', 'endLine', 'content', 'description', 'declaredType'],
|
||||||
|
Route: ['responseKeys', 'errorKeys', 'middleware'],
|
||||||
|
Tool: ['description'],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** All other tables share the CODE_ELEMENT_BASE schema (startLine/endLine/content/description). */
|
||||||
|
const DEFAULT_EXTRA_COLUMNS = ['startLine', 'endLine', 'content', 'description'];
|
||||||
|
|
||||||
|
const escapeFilePath = (s: string): string =>
|
||||||
|
s.replace(/\\/g, '\\\\').replace(/'/g, "''");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hydrate `graph` with all nodes (and their relationships) belonging to files
|
||||||
|
* in `unchangedFilePaths`. Used by the incremental-indexing pipeline to
|
||||||
|
* pre-populate the in-memory graph with everything that didn't change, so
|
||||||
|
* downstream phases (mro, communities, processes) see a complete picture.
|
||||||
|
*
|
||||||
|
* Skips Community/Process labels and their MEMBER_OF / STEP_IN_PROCESS edges
|
||||||
|
* — those are graph-wide and will be regenerated from scratch by the
|
||||||
|
* pipeline's downstream phases.
|
||||||
|
*
|
||||||
|
* Returns counts for logging/diagnostics.
|
||||||
|
*/
|
||||||
|
export const loadGraphFromLbug = async (
|
||||||
|
graph: KnowledgeGraph,
|
||||||
|
unchangedFilePaths: ReadonlySet<string>,
|
||||||
|
): Promise<{ nodesLoaded: number; edgesLoaded: number }> => {
|
||||||
|
if (!conn) {
|
||||||
|
throw new Error('LadybugDB not initialized. Call initLbug first.');
|
||||||
|
}
|
||||||
|
if (unchangedFilePaths.size === 0) {
|
||||||
|
return { nodesLoaded: 0, edgesLoaded: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
let nodesLoaded = 0;
|
||||||
|
let edgesLoaded = 0;
|
||||||
|
|
||||||
|
// Track which node IDs we successfully loaded so the relationship pass
|
||||||
|
// can verify both endpoints are present (cheaper than a DB-side join).
|
||||||
|
const loadedNodeIds = new Set<string>();
|
||||||
|
|
||||||
|
// ── 1. Hydrate nodes per table ─────────────────────────────────────────
|
||||||
|
// Chunk filePaths to keep query size manageable on huge repos.
|
||||||
|
const CHUNK = 200;
|
||||||
|
const filePathArr = [...unchangedFilePaths];
|
||||||
|
|
||||||
|
for (const tableName of HYDRATABLE_NODE_TABLES) {
|
||||||
|
const t = escapeTableName(tableName);
|
||||||
|
const extras = TABLE_EXTRA_COLUMNS[tableName] ?? DEFAULT_EXTRA_COLUMNS;
|
||||||
|
// Always include base columns: id, name, filePath
|
||||||
|
const cols = ['id', 'name', 'filePath', ...extras];
|
||||||
|
const projection = cols.map((c) => `n.${c} AS ${c}`).join(', ');
|
||||||
|
|
||||||
|
for (let i = 0; i < filePathArr.length; i += CHUNK) {
|
||||||
|
const batch = filePathArr.slice(i, i + CHUNK);
|
||||||
|
const inList = batch.map((p) => `'${escapeFilePath(p)}'`).join(',');
|
||||||
|
const cypher = `MATCH (n:${t}) WHERE n.filePath IN [${inList}] RETURN ${projection}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const queryResult = await conn.query(cypher);
|
||||||
|
const result = Array.isArray(queryResult) ? queryResult[0] : queryResult;
|
||||||
|
const rows = await result.getAll();
|
||||||
|
for (const row of rows) {
|
||||||
|
const id = typeof row.id === 'string' ? row.id : String(row.id ?? '');
|
||||||
|
if (!id) continue;
|
||||||
|
const properties: Record<string, unknown> = {};
|
||||||
|
for (const c of cols) {
|
||||||
|
const v = row[c];
|
||||||
|
if (v !== undefined && v !== null) properties[c] = v;
|
||||||
|
}
|
||||||
|
// Required for downstream phases: ensure name/filePath always set.
|
||||||
|
if (properties.name === undefined) properties.name = '';
|
||||||
|
graph.addNode({
|
||||||
|
id,
|
||||||
|
label: tableName as unknown as import('gitnexus-shared').NodeLabel,
|
||||||
|
properties: properties as import('gitnexus-shared').NodeProperties,
|
||||||
|
});
|
||||||
|
loadedNodeIds.add(id);
|
||||||
|
nodesLoaded++;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Some tables may not exist in the schema or may be empty — that's fine.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2. Hydrate relationships ───────────────────────────────────────────
|
||||||
|
// We pull all CodeRelation rows whose endpoints are nodes we just loaded.
|
||||||
|
// Using SRC.filePath IN [...] AND TGT.filePath IN [...] is precise but
|
||||||
|
// requires LadybugDB to traverse with both endpoint constraints. We
|
||||||
|
// exclude graph-wide edge types (MEMBER_OF, STEP_IN_PROCESS) entirely —
|
||||||
|
// they'll be regenerated.
|
||||||
|
//
|
||||||
|
// Strategy: for each (src filePath chunk × tgt filePath chunk) we'd risk
|
||||||
|
// O(n²) chunking. Instead, we fetch by source-chunk, then verify the
|
||||||
|
// target endpoint is in `loadedNodeIds` JS-side. This keeps the DB query
|
||||||
|
// bounded by source chunks while preserving correctness.
|
||||||
|
for (let i = 0; i < filePathArr.length; i += CHUNK) {
|
||||||
|
const batch = filePathArr.slice(i, i + CHUNK);
|
||||||
|
const inList = batch.map((p) => `'${escapeFilePath(p)}'`).join(',');
|
||||||
|
const cypher = `
|
||||||
|
MATCH (a)-[r:${REL_TABLE_NAME}]->(b)
|
||||||
|
WHERE a.filePath IN [${inList}]
|
||||||
|
AND r.type <> 'MEMBER_OF'
|
||||||
|
AND r.type <> 'STEP_IN_PROCESS'
|
||||||
|
RETURN a.id AS src, b.id AS dst, r.type AS type,
|
||||||
|
r.confidence AS confidence, r.reason AS reason, r.step AS step
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const queryResult = await conn.query(cypher);
|
||||||
|
const result = Array.isArray(queryResult) ? queryResult[0] : queryResult;
|
||||||
|
const rows = await result.getAll();
|
||||||
|
for (const row of rows) {
|
||||||
|
const src = typeof row.src === 'string' ? row.src : '';
|
||||||
|
const dst = typeof row.dst === 'string' ? row.dst : '';
|
||||||
|
if (!src || !dst) continue;
|
||||||
|
// The other endpoint must be a node we loaded — otherwise the edge
|
||||||
|
// crosses into a closure file (will be re-emitted) or a dropped
|
||||||
|
// graph-wide node (Community/Process), and we skip it.
|
||||||
|
if (!loadedNodeIds.has(dst)) continue;
|
||||||
|
const relType = typeof row.type === 'string' ? row.type : '';
|
||||||
|
if (!relType) continue;
|
||||||
|
const relId = `${src}_${relType}_${dst}`;
|
||||||
|
graph.addRelationship({
|
||||||
|
id: relId,
|
||||||
|
sourceId: src,
|
||||||
|
targetId: dst,
|
||||||
|
type: relType as import('gitnexus-shared').RelationshipType,
|
||||||
|
confidence: typeof row.confidence === 'number' ? row.confidence : 1.0,
|
||||||
|
reason: typeof row.reason === 'string' ? row.reason : '',
|
||||||
|
step:
|
||||||
|
typeof row.step === 'number' && row.step !== 0 ? row.step : undefined,
|
||||||
|
});
|
||||||
|
edgesLoaded++;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Continue on chunk failure — best-effort hydration.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { nodesLoaded, edgesLoaded };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query the IMPORTS edge table for all files that import `targetFilePath`.
|
||||||
|
* Used by the incremental-indexing closure-expansion logic to find files
|
||||||
|
* whose resolution may be stale when `targetFilePath`'s public surface
|
||||||
|
* changes.
|
||||||
|
*
|
||||||
|
* Returns repo-relative paths (i.e. the source file's `filePath`), de-duplicated.
|
||||||
|
*/
|
||||||
|
export const queryImporters = async (targetFilePath: string): Promise<string[]> => {
|
||||||
|
if (!conn) {
|
||||||
|
throw new Error('LadybugDB not initialized. Call initLbug first.');
|
||||||
|
}
|
||||||
|
const escaped = escapeFilePath(targetFilePath);
|
||||||
|
const cypher = `
|
||||||
|
MATCH (a)-[r:${REL_TABLE_NAME}]->(b)
|
||||||
|
WHERE r.type = 'IMPORTS' AND b.filePath = '${escaped}'
|
||||||
|
RETURN DISTINCT a.filePath AS importer
|
||||||
|
`;
|
||||||
|
try {
|
||||||
|
const queryResult = await conn.query(cypher);
|
||||||
|
const result = Array.isArray(queryResult) ? queryResult[0] : queryResult;
|
||||||
|
const rows = await result.getAll();
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
const p = row.importer;
|
||||||
|
if (typeof p === 'string' && p.length > 0) out.push(p);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete all Community and Process nodes and their MEMBER_OF /
|
||||||
|
* STEP_IN_PROCESS edges. Used at the start of an incremental run so the
|
||||||
|
* communities/processes phases can regenerate them on the merged graph.
|
||||||
|
*/
|
||||||
|
export const deleteAllCommunitiesAndProcesses = async (): Promise<{
|
||||||
|
nodesDeleted: number;
|
||||||
|
}> => {
|
||||||
|
if (!conn) {
|
||||||
|
throw new Error('LadybugDB not initialized. Call initLbug first.');
|
||||||
|
}
|
||||||
|
let nodesDeleted = 0;
|
||||||
|
for (const label of ['Community', 'Process']) {
|
||||||
|
try {
|
||||||
|
const countResult = await conn.query(
|
||||||
|
`MATCH (n:${label}) RETURN count(n) AS cnt`,
|
||||||
|
);
|
||||||
|
const result = Array.isArray(countResult) ? countResult[0] : countResult;
|
||||||
|
const rows = await result.getAll();
|
||||||
|
const count = Number(rows[0]?.cnt ?? rows[0]?.[0] ?? 0);
|
||||||
|
if (count > 0) {
|
||||||
|
await conn.query(`MATCH (n:${label}) DETACH DELETE n`);
|
||||||
|
nodesDeleted += count;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// table may not exist yet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { nodesDeleted };
|
||||||
|
};
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Full-Text Search (FTS) Functions
|
// Full-Text Search (FTS) Functions
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue