Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions __tests__/sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,34 @@ describe('Sync Module', () => {
expect(result.filesRemoved).toBe(0);
expect(result.filesChecked).toBeGreaterThan(0);
});

it('should re-resolve affected caller files when an exported symbol is renamed', async () => {
fs.writeFileSync(
path.join(testDir, 'src', 'api.ts'),
`export function hello() { return 'world'; }`
);
fs.writeFileSync(
path.join(testDir, 'src', 'consumer.ts'),
`import { hello } from './api';\nexport function run() { return hello(); }`
);
await cg.sync();

fs.writeFileSync(
path.join(testDir, 'src', 'api.ts'),
`export function goodbye() { return 'world'; }`
);
fs.writeFileSync(
path.join(testDir, 'src', 'consumer.ts'),
`import { goodbye } from './api';\nexport function run() { return goodbye(); }`
);

const result = await cg.sync();

expect(result.filesModified).toBeGreaterThanOrEqual(1);
expect(result.filesReindexed).toBeGreaterThanOrEqual(result.filesModified);
expect(result.resolutionMode).toMatch(/changed-only|affected-set/);
expect(cg.searchNodes('goodbye').length).toBeGreaterThan(0);
});
});
});

Expand Down
10 changes: 10 additions & 0 deletions src/bin/codegraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -639,13 +639,21 @@ program

if (totalChanges === 0) {
clack.log.info('Already up to date');
if (result.filesAffected > 0) {
clack.log.info(
`Re-resolved ${formatNumber(result.filesAffected)} affected files ` +
`(${result.resolutionMode}, ${result.detectionMode})`
);
}
} else {
clack.log.success(`Synced ${formatNumber(totalChanges)} changed files`);
const details: string[] = [];
if (result.filesAdded > 0) details.push(`Added: ${result.filesAdded}`);
if (result.filesModified > 0) details.push(`Modified: ${result.filesModified}`);
if (result.filesRemoved > 0) details.push(`Removed: ${result.filesRemoved}`);
if (result.filesAffected > 0) details.push(`Affected: ${result.filesAffected}`);
clack.log.info(`${details.join(', ')} ${getGlyphs().dash} ${formatNumber(result.nodesUpdated)} nodes in ${formatDuration(result.durationMs)}`);
clack.log.info(`Resolution: ${result.resolutionMode} ${getGlyphs().dash} Detection: ${result.detectionMode}`);
}

clack.outro('Done');
Expand Down Expand Up @@ -704,6 +712,7 @@ program
dbSizeBytes: stats.dbSizeBytes,
backend,
journalMode,
syncDetection: 'fast-path',
nodesByKind: stats.nodesByKind,
languages: Object.entries(stats.filesByLanguage).filter(([, count]) => count > 0).map(([lang]) => lang),
pendingChanges: {
Expand Down Expand Up @@ -746,6 +755,7 @@ program
? chalk.green('wal')
: chalk.yellow(`${journalMode || 'unknown'} ${getGlyphs().dash} WAL inactive; reads can block on writes`);
console.log(` Journal: ${journalLabel}`);
console.log(` Sync: fast-path ${getGlyphs().dash} mtime/size prefilter + content hash confirm`);
console.log();

// Node breakdown
Expand Down
29 changes: 28 additions & 1 deletion src/db/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { SqliteDatabase } from './sqlite-adapter';
/**
* Current schema version
*/
export const CURRENT_SCHEMA_VERSION = 4;
export const CURRENT_SCHEMA_VERSION = 5;

/**
* Migration definition
Expand Down Expand Up @@ -65,6 +65,33 @@ const migrations: Migration[] = [
`);
},
},
{
version: 5,
description: 'Add persisted reference facts for affected-file incremental re-resolution',
up: (db) => {
db.exec(`
CREATE TABLE IF NOT EXISTS reference_facts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
from_node_id TEXT NOT NULL,
reference_name TEXT NOT NULL,
reference_kind TEXT NOT NULL,
line INTEGER NOT NULL,
col INTEGER NOT NULL,
candidates TEXT,
file_path TEXT NOT NULL DEFAULT '',
language TEXT NOT NULL DEFAULT 'unknown',
FOREIGN KEY (from_node_id) REFERENCES nodes(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_reference_facts_from_node ON reference_facts(from_node_id);
CREATE INDEX IF NOT EXISTS idx_reference_facts_name ON reference_facts(reference_name);
CREATE INDEX IF NOT EXISTS idx_reference_facts_file_path ON reference_facts(file_path);
CREATE INDEX IF NOT EXISTS idx_reference_facts_from_name ON reference_facts(from_node_id, reference_name);
INSERT INTO reference_facts (from_node_id, reference_name, reference_kind, line, col, candidates, file_path, language)
SELECT from_node_id, reference_name, reference_kind, line, col, candidates, file_path, language
FROM unresolved_refs;
`);
},
},
];

/**
Expand Down
116 changes: 116 additions & 0 deletions src/db/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
Node,
Edge,
FileRecord,
ReferenceFact,
UnresolvedReference,
NodeKind,
EdgeKind,
Expand Down Expand Up @@ -109,6 +110,18 @@ interface UnresolvedRefRow {
language: string;
}

interface ReferenceFactRow {
id: number;
from_node_id: string;
reference_name: string;
reference_kind: string;
line: number;
col: number;
candidates: string | null;
file_path: string;
language: string;
}

/**
* Convert database row to Node object
*/
Expand Down Expand Up @@ -199,8 +212,13 @@ export class QueryBuilder {
getFileByPath?: SqliteStatement;
getAllFiles?: SqliteStatement;
insertUnresolved?: SqliteStatement;
insertReferenceFact?: SqliteStatement;
deleteUnresolvedByNode?: SqliteStatement;
deleteUnresolvedByFile?: SqliteStatement;
deleteReferenceFactsByFile?: SqliteStatement;
getUnresolvedByName?: SqliteStatement;
getReferenceFactsByFiles?: SqliteStatement;
getReferencingFilesByNames?: SqliteStatement;
getNodesByName?: SqliteStatement;
getNodesByQualifiedNameExact?: SqliteStatement;
getNodesByLowerName?: SqliteStatement;
Expand Down Expand Up @@ -1269,6 +1287,23 @@ export class QueryBuilder {
this.stmts.deleteEdgesBySource.run(sourceId);
}

deleteEdgesByProvenanceAndFiles(
provenances: ReadonlyArray<NonNullable<Edge['provenance']>>,
filePaths: readonly string[]
): void {
if (provenances.length === 0 || filePaths.length === 0) return;
const filePlaceholders = filePaths.map(() => '?').join(',');
const provPlaceholders = provenances.map(() => '?').join(',');
this.db.prepare(
`DELETE FROM edges
WHERE provenance IN (${provPlaceholders})
AND (
source IN (SELECT id FROM nodes WHERE file_path IN (${filePlaceholders}))
OR target IN (SELECT id FROM nodes WHERE file_path IN (${filePlaceholders}))
)`
).run(...provenances, ...filePaths, ...filePaths);
}

/**
* Get outgoing edges from a node
*/
Expand Down Expand Up @@ -1443,6 +1478,26 @@ export class QueryBuilder {
});
}

insertReferenceFact(ref: ReferenceFact): void {
if (!this.stmts.insertReferenceFact) {
this.stmts.insertReferenceFact = this.db.prepare(`
INSERT INTO reference_facts (from_node_id, reference_name, reference_kind, line, col, candidates, file_path, language)
VALUES (@fromNodeId, @referenceName, @referenceKind, @line, @col, @candidates, @filePath, @language)
`);
}

this.stmts.insertReferenceFact.run({
fromNodeId: ref.fromNodeId,
referenceName: ref.referenceName,
referenceKind: ref.referenceKind,
line: ref.line,
col: ref.column,
candidates: ref.candidates ? JSON.stringify(ref.candidates) : null,
filePath: ref.filePath ?? '',
language: ref.language ?? 'unknown',
});
}

/**
* Insert multiple unresolved references in a transaction
*/
Expand All @@ -1456,6 +1511,16 @@ export class QueryBuilder {
insert();
}

insertReferenceFactsBatch(refs: ReferenceFact[]): void {
if (refs.length === 0) return;
const insert = this.db.transaction(() => {
for (const ref of refs) {
this.insertReferenceFact(ref);
}
});
insert();
}

/**
* Delete unresolved references from a node
*/
Expand All @@ -1468,6 +1533,24 @@ export class QueryBuilder {
this.stmts.deleteUnresolvedByNode.run(nodeId);
}

deleteUnresolvedByFile(filePath: string): void {
if (!this.stmts.deleteUnresolvedByFile) {
this.stmts.deleteUnresolvedByFile = this.db.prepare(
'DELETE FROM unresolved_refs WHERE file_path = ?'
);
}
this.stmts.deleteUnresolvedByFile.run(filePath);
}

deleteReferenceFactsByFile(filePath: string): void {
if (!this.stmts.deleteReferenceFactsByFile) {
this.stmts.deleteReferenceFactsByFile = this.db.prepare(
'DELETE FROM reference_facts WHERE file_path = ?'
);
}
this.stmts.deleteReferenceFactsByFile.run(filePath);
}

/**
* Get unresolved references by name (for resolution)
*/
Expand Down Expand Up @@ -1589,11 +1672,43 @@ export class QueryBuilder {
}));
}

getReferenceFactsByFiles(filePaths: string[]): ReferenceFact[] {
if (filePaths.length === 0) return [];

const placeholders = filePaths.map(() => '?').join(',');
const rows = this.db
.prepare(`SELECT * FROM reference_facts WHERE file_path IN (${placeholders})`)
.all(...filePaths) as ReferenceFactRow[];

return rows.map((row) => ({
fromNodeId: row.from_node_id,
referenceName: row.reference_name,
referenceKind: row.reference_kind as EdgeKind,
line: row.line,
column: row.col,
candidates: row.candidates ? safeJsonParse(row.candidates, undefined) : undefined,
filePath: row.file_path,
language: row.language as Language,
}));
}

getReferencingFilesByNames(names: readonly string[]): string[] {
if (names.length === 0) return [];
const placeholders = names.map(() => '?').join(',');
const rows = this.db
.prepare(
`SELECT DISTINCT file_path FROM reference_facts WHERE reference_name IN (${placeholders})`
)
.all(...names) as Array<{ file_path: string }>;
return rows.map((row) => row.file_path);
}

/**
* Delete all unresolved references (after resolution)
*/
clearUnresolvedReferences(): void {
this.db.exec('DELETE FROM unresolved_refs');
this.db.exec('DELETE FROM reference_facts');
}

/**
Expand Down Expand Up @@ -1727,6 +1842,7 @@ export class QueryBuilder {
this.nodeCache.clear();
this.db.transaction(() => {
this.db.exec('DELETE FROM unresolved_refs');
this.db.exec('DELETE FROM reference_facts');
this.db.exec('DELETE FROM edges');
this.db.exec('DELETE FROM nodes');
this.db.exec('DELETE FROM files');
Expand Down
19 changes: 19 additions & 0 deletions src/db/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,21 @@ CREATE TABLE IF NOT EXISTS unresolved_refs (
FOREIGN KEY (from_node_id) REFERENCES nodes(id) ON DELETE CASCADE
);

-- Persisted reference facts: extracted references that remain queryable even
-- after successful resolution, enabling future affected-file re-resolution.
CREATE TABLE IF NOT EXISTS reference_facts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
from_node_id TEXT NOT NULL,
reference_name TEXT NOT NULL,
reference_kind TEXT NOT NULL,
line INTEGER NOT NULL,
col INTEGER NOT NULL,
candidates TEXT,
file_path TEXT NOT NULL DEFAULT '',
language TEXT NOT NULL DEFAULT 'unknown',
FOREIGN KEY (from_node_id) REFERENCES nodes(id) ON DELETE CASCADE
);

-- =============================================================================
-- Indexes for Query Performance
-- =============================================================================
Expand Down Expand Up @@ -142,6 +157,10 @@ CREATE INDEX IF NOT EXISTS idx_unresolved_name ON unresolved_refs(reference_name
CREATE INDEX IF NOT EXISTS idx_unresolved_file_path ON unresolved_refs(file_path);
CREATE INDEX IF NOT EXISTS idx_unresolved_from_name ON unresolved_refs(from_node_id, reference_name);
CREATE INDEX IF NOT EXISTS idx_edges_provenance ON edges(provenance);
CREATE INDEX IF NOT EXISTS idx_reference_facts_from_node ON reference_facts(from_node_id);
CREATE INDEX IF NOT EXISTS idx_reference_facts_name ON reference_facts(reference_name);
CREATE INDEX IF NOT EXISTS idx_reference_facts_file_path ON reference_facts(file_path);
CREATE INDEX IF NOT EXISTS idx_reference_facts_from_name ON reference_facts(from_node_id, reference_name);

-- Project metadata for version/provenance tracking
CREATE TABLE IF NOT EXISTS project_metadata (
Expand Down
Loading