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
2 changes: 2 additions & 0 deletions src/domain/graph/builder/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ export class PipelineContext {
// ── Phase timing ───────────────────────────────────────────────────
timing: {
setupMs?: number;
collectMs?: number;
detectMs?: number;
parseMs?: number;
insertMs?: number;
resolveMs?: number;
Expand Down
6 changes: 5 additions & 1 deletion src/domain/graph/builder/pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,8 @@ function formatTimingResult(ctx: PipelineContext): BuildResult {
return {
phases: {
setupMs: +(t.setupMs ?? 0).toFixed(1),
collectMs: +(t.collectMs ?? 0).toFixed(1),
detectMs: +(t.detectMs ?? 0).toFixed(1),
parseMs: +(t.parseMs ?? 0).toFixed(1),
insertMs: +(t.insertMs ?? 0).toFixed(1),
resolveMs: +(t.resolveMs ?? 0).toFixed(1),
Expand Down Expand Up @@ -558,7 +560,9 @@ function formatNativeTimingResult(
): BuildResult {
return {
phases: {
setupMs: +((p.setupMs ?? 0) + (p.collectMs ?? 0) + (p.detectMs ?? 0)).toFixed(1),
setupMs: +(p.setupMs ?? 0).toFixed(1),
collectMs: +(p.collectMs ?? 0).toFixed(1),
detectMs: +(p.detectMs ?? 0).toFixed(1),
parseMs: +(p.parseMs ?? 0).toFixed(1),
insertMs: +(p.insertMs ?? 0).toFixed(1),
resolveMs: +(p.resolveMs ?? 0).toFixed(1),
Expand Down
65 changes: 42 additions & 23 deletions src/domain/graph/builder/stages/collect-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/
import fs from 'node:fs';
import path from 'node:path';
import { performance } from 'node:perf_hooks';
import { debug, info } from '../../../../infrastructure/logger.js';
import { normalizePath } from '../../../../shared/constants.js';
import { readJournal } from '../../journal.js';
Expand Down Expand Up @@ -89,42 +90,60 @@ export async function collectFiles(ctx: PipelineContext): Promise<void> {
const { rootDir, config, opts } = ctx;

if (opts.scope) {
// Scoped rebuild: rebuild only specified files
// Scoped rebuild: rebuild only specified files.
//
// Timer only wraps the filesystem-walk portion (existence checks + file
// list construction). Change-detection outputs (parseChanges, removed,
// isFullBuild) are attributed to detectMs for semantic consistency with
// the non-scoped path, even though this stage computes them.
const start = performance.now();
const scopedFiles = opts.scope.map((f: string) => normalizePath(f));
const existing: Array<{ file: string; relPath: string }> = [];
const missing: string[] = [];
for (const rel of scopedFiles) {
const abs = path.join(rootDir, rel);
if (fs.existsSync(abs)) {
existing.push({ file: abs, relPath: rel });
} else {
missing.push(rel);
try {
for (const rel of scopedFiles) {
const abs = path.join(rootDir, rel);
if (fs.existsSync(abs)) {
existing.push({ file: abs, relPath: rel });
} else {
missing.push(rel);
}
}
ctx.allFiles = existing.map((e) => e.file);
ctx.discoveredDirs = new Set(existing.map((e) => path.dirname(e.file)));
} finally {
ctx.timing.collectMs = performance.now() - start;
}
ctx.allFiles = existing.map((e) => e.file);
ctx.discoveredDirs = new Set(existing.map((e) => path.dirname(e.file)));
// Change-detection outputs — timed under detectMs for semantic parity.
const detectStart = performance.now();
ctx.parseChanges = existing;
ctx.metadataUpdates = [];
ctx.removed = missing;
ctx.isFullBuild = false;
ctx.timing.detectMs = (ctx.timing.detectMs ?? 0) + (performance.now() - detectStart);
info(`Scoped rebuild: ${existing.length} files to rebuild, ${missing.length} to purge`);
return;
}

// Incremental fast path: reconstruct file list from DB + journal deltas
// instead of full recursive filesystem scan (~8ms savings on 473 files).
if (ctx.incremental && !ctx.forceFullRebuild) {
const fast = tryFastCollect(ctx);
if (fast) {
ctx.allFiles = fast.files;
ctx.discoveredDirs = fast.directories;
info(`Found ${ctx.allFiles.length} files (cached)`);
return;
const start = performance.now();
try {
// Incremental fast path: reconstruct file list from DB + journal deltas
// instead of full recursive filesystem scan (~8ms savings on 473 files).
if (ctx.incremental && !ctx.forceFullRebuild) {
const fast = tryFastCollect(ctx);
if (fast) {
ctx.allFiles = fast.files;
ctx.discoveredDirs = fast.directories;
info(`Found ${ctx.allFiles.length} files (cached)`);
return;
}
}
}

const collected = collectFilesUtil(rootDir, [], config, new Set<string>());
ctx.allFiles = collected.files;
ctx.discoveredDirs = collected.directories;
info(`Found ${ctx.allFiles.length} files to parse`);
const collected = collectFilesUtil(rootDir, [], config, new Set<string>());
ctx.allFiles = collected.files;
ctx.discoveredDirs = collected.directories;
info(`Found ${ctx.allFiles.length} files to parse`);
} finally {
ctx.timing.collectMs = performance.now() - start;
}
}
106 changes: 57 additions & 49 deletions src/domain/graph/builder/stages/detect-changes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/
import fs from 'node:fs';
import path from 'node:path';
import { performance } from 'node:perf_hooks';
import { closeDb } from '../../../../db/index.js';
import { debug, info } from '../../../../infrastructure/logger.js';
import { normalizePath } from '../../../../shared/constants.js';
Expand Down Expand Up @@ -512,59 +513,66 @@ function handleIncrementalBuild(ctx: PipelineContext): void {
}

export async function detectChanges(ctx: PipelineContext): Promise<void> {
const { db, allFiles, rootDir, incremental, forceFullRebuild, opts } = ctx;
if ((opts as Record<string, unknown>).scope) {
handleScopedBuild(ctx);
return;
}
const increResult =
incremental && !forceFullRebuild
? getChangedFiles(db, allFiles, rootDir)
: {
changed: allFiles.map((f): ChangedFile => ({ file: f })),
removed: [] as string[],
isFullBuild: true,
};
ctx.removed = increResult.removed;
ctx.isFullBuild = increResult.isFullBuild;
ctx.parseChanges = increResult.changed
.filter((c) => !c.metadataOnly)
.map((c) => ({
file: c.file,
relPath: c.relPath,
content: c.content,
hash: c.hash,
stat: c.stat ? { mtime: Math.floor(c.stat.mtimeMs), size: c.stat.size } : undefined,
_reverseDepOnly: c._reverseDepOnly,
}));
ctx.metadataUpdates = increResult.changed
.filter(
(c): c is ChangedFile & { relPath: string; hash: string; stat: FileStat } =>
!!c.metadataOnly && !!c.relPath && !!c.hash && !!c.stat,
)
.map((c) => ({
relPath: c.relPath,
hash: c.hash,
stat: { mtime: Math.floor(c.stat.mtimeMs), size: c.stat.size },
}));
if (!ctx.isFullBuild && ctx.parseChanges.length === 0 && ctx.removed.length === 0) {
const ranAnalysis = await runPendingAnalysis(ctx);
if (ranAnalysis) {
const start = performance.now();
try {
const { db, allFiles, rootDir, incremental, forceFullRebuild, opts } = ctx;
if ((opts as Record<string, unknown>).scope) {
handleScopedBuild(ctx);
return;
}
const increResult =
incremental && !forceFullRebuild
? getChangedFiles(db, allFiles, rootDir)
: {
changed: allFiles.map((f): ChangedFile => ({ file: f })),
removed: [] as string[],
isFullBuild: true,
};
ctx.removed = increResult.removed;
ctx.isFullBuild = increResult.isFullBuild;
ctx.parseChanges = increResult.changed
.filter((c) => !c.metadataOnly)
.map((c) => ({
file: c.file,
relPath: c.relPath,
content: c.content,
hash: c.hash,
stat: c.stat ? { mtime: Math.floor(c.stat.mtimeMs), size: c.stat.size } : undefined,
_reverseDepOnly: c._reverseDepOnly,
}));
ctx.metadataUpdates = increResult.changed
.filter(
(c): c is ChangedFile & { relPath: string; hash: string; stat: FileStat } =>
!!c.metadataOnly && !!c.relPath && !!c.hash && !!c.stat,
)
.map((c) => ({
relPath: c.relPath,
hash: c.hash,
stat: { mtime: Math.floor(c.stat.mtimeMs), size: c.stat.size },
}));
if (!ctx.isFullBuild && ctx.parseChanges.length === 0 && ctx.removed.length === 0) {
const ranAnalysis = await runPendingAnalysis(ctx);
if (ranAnalysis) {
closeDb(db);
writeJournalHeader(rootDir, Date.now());
ctx.earlyExit = true;
return;
}
healMetadata(ctx);
info('No changes detected. Graph is up to date.');
closeDb(db);
writeJournalHeader(rootDir, Date.now());
ctx.earlyExit = true;
return;
}
healMetadata(ctx);
info('No changes detected. Graph is up to date.');
closeDb(db);
writeJournalHeader(rootDir, Date.now());
ctx.earlyExit = true;
return;
}
if (ctx.isFullBuild) {
handleFullBuild(ctx);
} else {
handleIncrementalBuild(ctx);
if (ctx.isFullBuild) {
handleFullBuild(ctx);
} else {
handleIncrementalBuild(ctx);
}
} finally {
// Additive to respect any partial detectMs contribution from collectFiles
// (scoped-rebuild path splits change-detection outputs across both stages).
ctx.timing.detectMs = (ctx.timing.detectMs ?? 0) + (performance.now() - start);
}
}
20 changes: 19 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1033,7 +1033,23 @@ export interface PipelineContext {
lineCountMap: Map<string, number>;

// Phase timing
timing: Record<string, number>;
timing: {
setupMs?: number;
collectMs?: number;
detectMs?: number;
parseMs?: number;
insertMs?: number;
resolveMs?: number;
edgesMs?: number;
structureMs?: number;
rolesMs?: number;
astMs?: number;
complexityMs?: number;
cfgMs?: number;
dataflowMs?: number;
finalizeMs?: number;
[key: string]: number | undefined;
};
buildStart: number;
}

Expand All @@ -1053,6 +1069,8 @@ export interface BuildGraphOpts {
export interface BuildResult {
phases: {
setupMs: number;
collectMs: number;
detectMs: number;
parseMs: number;
insertMs: number;
resolveMs: number;
Expand Down
4 changes: 4 additions & 0 deletions tests/builder/pipeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ describe('buildGraph pipeline', () => {
expect(result).toBeDefined();
expect(result.phases).toBeDefined();
expect(typeof result.phases.setupMs).toBe('number');
expect(typeof result.phases.collectMs).toBe('number');
expect(result.phases.collectMs).toBeGreaterThanOrEqual(0);
expect(typeof result.phases.detectMs).toBe('number');
expect(result.phases.detectMs).toBeGreaterThanOrEqual(0);
expect(typeof result.phases.parseMs).toBe('number');
expect(typeof result.phases.insertMs).toBe('number');
expect(typeof result.phases.resolveMs).toBe('number');
Expand Down
Loading