Skip to content
Merged
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
10 changes: 10 additions & 0 deletions .changeset/graph-cache.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@prosdevlab/dev-agent': patch
---

Cached dependency graph for scale

- Dependency graph built at index time and saved as JSON — `dev_map` and `dev_refs` no longer fetch all docs via `getAll`
- Incremental graph updates via file watcher (O(changed files), not O(all files))
- Graceful fallback to current approach if cache is missing or corrupted
- Raises effective doc limit from 10k to 50k for graph operations
22 changes: 22 additions & 0 deletions biome.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.0/schema.json",
"files": {
"includes": ["**", "!**/dist"]
},
"assist": { "actions": { "source": { "organizeImports": "on" } } },
"linter": {
"enabled": true,
Expand All @@ -23,6 +26,25 @@
"rules": {
"suspicious": {
"noExplicitAny": "off"
},
"style": {
"noNonNullAssertion": "off"
}
}
}
},
{
"includes": [
"**/map/graph.ts",
"**/refs-adapter.ts",
"**/change-frequency.ts",
"**/pattern-analysis-service.ts",
"**/vector/index.ts"
],
"linter": {
"rules": {
"style": {
"noNonNullAssertion": "off"
}
}
}
Expand Down
1 change: 0 additions & 1 deletion packages/cli/src/commands/map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import { Command } from 'commander';
import ora from 'ora';
import { loadConfig } from '../utils/config.js';
import { logger } from '../utils/logger.js';
import { output } from '../utils/output.js';

export const mapCommand = new Command('map')
.description('Show codebase structure with component counts')
Expand Down
19 changes: 19 additions & 0 deletions packages/core/src/indexer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import type { Logger } from '@prosdevlab/kero';
import type { EventBus } from '../events/types.js';
import { buildDependencyGraph, serializeGraph } from '../map/graph';
import { scanRepository } from '../scanner';
import { getStorageFilePaths } from '../storage/path';
import type { EmbeddingDocument, LinearMergeResult, SearchOptions, SearchResult } from '../vector';
import { VectorStorage } from '../vector';
import { StatsAggregator } from './stats-aggregator';
Expand Down Expand Up @@ -183,6 +185,23 @@ export class RepositoryIndexer {
`Linear Merge complete: ${mergeResult.upserted} upserted, ${mergeResult.skipped} unchanged, ${mergeResult.deleted} removed`
);

// Build and cache dependency graph
try {
const graphDocs = embeddingDocuments.map((d) => ({
id: d.id,
score: 0,
metadata: d.metadata,
}));
const graph = buildDependencyGraph(graphDocs);
const storagePath = path.dirname(this.config.vectorStorePath);
const graphPath = getStorageFilePaths(storagePath).dependencyGraph;
await fs.writeFile(graphPath, serializeGraph(graph), 'utf-8');
logger?.info({ nodes: graph.size }, 'Dependency graph cached');
} catch (graphError) {
// Non-fatal — graph is a performance optimization, not required
logger?.warn({ error: graphError }, 'Failed to cache dependency graph');
}

// Phase 4: Complete
const endTime = new Date();
onProgress?.({
Expand Down
179 changes: 179 additions & 0 deletions packages/core/src/map/__tests__/graph.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,12 @@ import { describe, expect, it } from 'vitest';
import {
buildDependencyGraph,
connectedComponents,
deserializeGraph,
loadOrBuildGraph,
pageRank,
serializeGraph,
shortestPath,
updateGraphIncremental,
type WeightedEdge,
} from '../graph';

Expand Down Expand Up @@ -333,3 +337,178 @@ describe('shortestPath', () => {
expect(shortestPath(new Map(), 'X', 'Y')).toBeNull();
});
});

// ============================================================================
// Serialization
// ============================================================================

describe('serializeGraph / deserializeGraph', () => {
it('should round-trip correctly', () => {
const graph = new Map<string, WeightedEdge[]>();
graph.set('src/a.ts', [edge('src/b.ts', 1.5), edge('src/c.ts', 1)]);
graph.set('src/b.ts', [edge('src/c.ts', 2)]);

const json = serializeGraph(graph);
const restored = deserializeGraph(json);

expect(restored).not.toBeNull();
expect(restored!.size).toBe(2);
expect(restored!.get('src/a.ts')).toEqual([
{ target: 'src/b.ts', weight: 1.5 },
{ target: 'src/c.ts', weight: 1 },
]);
expect(restored!.get('src/b.ts')).toEqual([{ target: 'src/c.ts', weight: 2 }]);
});

it('should include metadata in serialized JSON', () => {
const graph = new Map<string, WeightedEdge[]>();
graph.set('a', [edge('b')]);

const parsed = JSON.parse(serializeGraph(graph));
expect(parsed.version).toBe(1);
expect(parsed.nodeCount).toBe(1);
expect(parsed.edgeCount).toBe(1);
expect(parsed.generatedAt).toBeTruthy();
});

it('should return null for invalid JSON', () => {
expect(deserializeGraph('not json')).toBeNull();
});

it('should return null for wrong version', () => {
const json = JSON.stringify({ version: 99, graph: {} });
expect(deserializeGraph(json)).toBeNull();
});

it('should return null for missing graph field', () => {
const json = JSON.stringify({ version: 1 });
expect(deserializeGraph(json)).toBeNull();
});

it('should handle empty graph', () => {
const graph = new Map<string, WeightedEdge[]>();
const json = serializeGraph(graph);
const restored = deserializeGraph(json);
expect(restored).not.toBeNull();
expect(restored!.size).toBe(0);
});
});

// ============================================================================
// loadOrBuildGraph
// ============================================================================

describe('loadOrBuildGraph', () => {
it('should call fallback when graphPath is undefined', async () => {
const fallbackDocs = [
{
id: '1',
score: 0,
metadata: {
path: 'src/a.ts',
callees: [{ name: 'foo', file: 'src/b.ts', line: 1 }],
},
},
];

const graph = await loadOrBuildGraph(undefined, async () => fallbackDocs);
expect(graph.get('src/a.ts')).toBeDefined();
});

it('should call fallback when graphPath file does not exist', async () => {
const fallbackDocs = [
{
id: '1',
score: 0,
metadata: {
path: 'src/x.ts',
callees: [{ name: 'bar', file: 'src/y.ts', line: 1 }],
},
},
];

const graph = await loadOrBuildGraph('/nonexistent/path.json', async () => fallbackDocs);
expect(graph.get('src/x.ts')).toBeDefined();
});
});

// ============================================================================
// updateGraphIncremental
// ============================================================================

describe('updateGraphIncremental', () => {
it('should add edges for new files', () => {
const existing = new Map<string, WeightedEdge[]>();
existing.set('src/a.ts', [edge('src/b.ts')]);

const changedDocs = [
{
id: '1',
score: 0,
metadata: {
path: 'src/c.ts',
callees: [{ name: 'foo', file: 'src/d.ts', line: 1 }],
},
},
];

const updated = updateGraphIncremental(existing, changedDocs, []);
expect(updated.get('src/a.ts')).toBeDefined(); // Kept
expect(updated.get('src/c.ts')).toBeDefined(); // Added
});

it('should remove edges for deleted files', () => {
const existing = new Map<string, WeightedEdge[]>();
existing.set('src/a.ts', [edge('src/b.ts')]);
existing.set('src/b.ts', [edge('src/c.ts')]);

const updated = updateGraphIncremental(existing, [], ['src/a.ts']);
expect(updated.has('src/a.ts')).toBe(false); // Removed
expect(updated.get('src/b.ts')).toBeDefined(); // Kept
});

it('should replace edges for changed files', () => {
const existing = new Map<string, WeightedEdge[]>();
existing.set('src/a.ts', [edge('src/old.ts')]);

const changedDocs = [
{
id: '1',
score: 0,
metadata: {
path: 'src/a.ts',
callees: [{ name: 'foo', file: 'src/new.ts', line: 1 }],
},
},
];

const updated = updateGraphIncremental(existing, changedDocs, []);
const edges = updated.get('src/a.ts')!;
expect(edges.length).toBe(1);
expect(edges[0].target).toBe('src/new.ts'); // Replaced
});

it('should not mutate the existing graph', () => {
const existing = new Map<string, WeightedEdge[]>();
existing.set('src/a.ts', [edge('src/b.ts')]);

updateGraphIncremental(existing, [], ['src/a.ts']);
expect(existing.has('src/a.ts')).toBe(true); // Original unchanged
});

it('should handle empty existing graph', () => {
const changedDocs = [
{
id: '1',
score: 0,
metadata: {
path: 'src/a.ts',
callees: [{ name: 'foo', file: 'src/b.ts', line: 1 }],
},
},
];

const updated = updateGraphIncremental(new Map(), changedDocs, []);
expect(updated.get('src/a.ts')).toBeDefined();
});
});
Loading
Loading