diff --git a/src/git/branchWatcher.ts b/src/git/branchWatcher.ts index fdd8294..53b8d0c 100644 --- a/src/git/branchWatcher.ts +++ b/src/git/branchWatcher.ts @@ -2,14 +2,93 @@ import * as vscode from 'vscode'; import { GitClient } from './gitClient'; import { t, Language } from '../i18n/locales'; +export interface AdaptiveDebouncerOptions { + minDelayMs?: number; + maxDelayMs?: number; + burstWindowMs?: number; + burstThreshold?: number; +} + +/** + * Adaptive event debouncer that scales delay from minDelayMs (150ms) up to + * maxDelayMs (1000ms) when rapid filesystem bursts (e.g. git rebase/gc) occur. + */ +export class AdaptiveDebouncer { + private readonly minDelayMs: number; + private readonly maxDelayMs: number; + private readonly burstWindowMs: number; + private readonly burstThreshold: number; + private timer: NodeJS.Timeout | null = null; + private timestamps: number[] = []; + private currentDelayMs: number; + + constructor( + private readonly callback: () => void | Promise, + options: AdaptiveDebouncerOptions = {} + ) { + this.minDelayMs = options.minDelayMs ?? 150; + this.maxDelayMs = options.maxDelayMs ?? 1000; + this.burstWindowMs = options.burstWindowMs ?? 600; + this.burstThreshold = options.burstThreshold ?? 3; + this.currentDelayMs = this.minDelayMs; + } + + public getCurrentDelayMs(): number { + return this.currentDelayMs; + } + + public trigger(now = Date.now()): void { + this.timestamps = this.timestamps.filter((ts) => now - ts <= this.burstWindowMs); + this.timestamps.push(now); + + if (this.timestamps.length >= this.burstThreshold) { + const exponent = this.timestamps.length - this.burstThreshold + 1; + this.currentDelayMs = Math.min(this.maxDelayMs, this.minDelayMs * Math.pow(2, exponent)); + } else { + this.currentDelayMs = this.minDelayMs; + } + + if (this.timer) { + clearTimeout(this.timer); + } + this.timer = setTimeout(() => { + this.timer = null; + this.currentDelayMs = this.minDelayMs; + void this.callback(); + }, this.currentDelayMs); + } + + public flush(): void { + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + this.currentDelayMs = this.minDelayMs; + void this.callback(); + } + + public dispose(): void { + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + this.timestamps = []; + } +} + export class BranchWatcherService implements vscode.Disposable { private readonly gitClient = GitClient.getInstance(); private readonly disposables: vscode.Disposable[] = []; private fetchTimer: NodeJS.Timeout | null = null; private lastAlertedState: string | null = null; private isChecking = false; + private readonly refDebouncer: AdaptiveDebouncer; constructor(private readonly onRefreshRequired: () => void) { + this.refDebouncer = new AdaptiveDebouncer(() => this.onGitRefChanged(), { + minDelayMs: 150, + maxDelayMs: 1000, + }); this.setupFileWatcher(); this.setupAutoFetchTimer(); @@ -31,8 +110,9 @@ export class BranchWatcherService implements vscode.Disposable { '**/.git/{refs/heads/**,refs/remotes/**,FETCH_HEAD,HEAD}' ); - watcher.onDidChange(() => this.onGitRefChanged(), this, this.disposables); - watcher.onDidCreate(() => this.onGitRefChanged(), this, this.disposables); + watcher.onDidChange(() => this.refDebouncer.trigger(), this, this.disposables); + watcher.onDidCreate(() => this.refDebouncer.trigger(), this, this.disposables); + watcher.onDidDelete(() => this.refDebouncer.trigger(), this, this.disposables); this.disposables.push(watcher); } @@ -241,6 +321,7 @@ export class BranchWatcherService implements vscode.Disposable { } public dispose(): void { + this.refDebouncer.dispose(); if (this.fetchTimer) { clearInterval(this.fetchTimer); this.fetchTimer = null; diff --git a/src/graph/dagWorkerClient.ts b/src/graph/dagWorkerClient.ts new file mode 100644 index 0000000..bfac8db --- /dev/null +++ b/src/graph/dagWorkerClient.ts @@ -0,0 +1,186 @@ +import { GitCommit, GraphNode } from '../git/types'; +import { GraphLayout } from './graphLayout'; + +export interface MinimapRenderOptions { + width?: number; + height?: number; + selectedIndex?: number; + viewportStartRatio?: number; + viewportEndRatio?: number; +} + +/** + * Off-thread DAG layout & OffscreenCanvas rendering engine with synchronous + * main-thread / Node.js fallback for constrained environments. + */ +export class DagWorkerEngine { + private static instance: DagWorkerEngine; + private worker: Worker | null = null; + private nextRequestId = 1; + private pending = new Map< + number, + { resolve: (nodes: GraphNode[]) => void; reject: (err: Error) => void } + >(); + + public static getInstance(): DagWorkerEngine { + if (!DagWorkerEngine.instance) { + DagWorkerEngine.instance = new DagWorkerEngine(); + } + return DagWorkerEngine.instance; + } + + public isWorkerSupported(): boolean { + return ( + typeof Worker !== 'undefined' && + typeof Blob !== 'undefined' && + typeof URL !== 'undefined' && + typeof URL.createObjectURL === 'function' + ); + } + + public isOffscreenCanvasSupported(): boolean { + return typeof OffscreenCanvas !== 'undefined'; + } + + /** + * Computes topological DAG layout off the UI thread when Web Workers are available, + * seamlessly falling back to direct GraphLayout computation in Node/test runtimes. + */ + public async computeLayoutAsync(commits: GitCommit[], theme: any = 'auto'): Promise { + if (!this.isWorkerSupported()) { + return GraphLayout.compute(commits, theme); + } + + try { + const worker = this.ensureWorker(); + const id = this.nextRequestId++; + return await new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + worker.postMessage({ id, commits, theme }); + }); + } catch { + return GraphLayout.compute(commits, theme); + } + } + + /** + * Renders a condensed branch/lane overview onto a Canvas or OffscreenCanvas. + */ + public renderMinimap( + nodes: GraphNode[], + canvas: HTMLCanvasElement | OffscreenCanvas, + options: MinimapRenderOptions = {} + ): void { + const width = options.width ?? (canvas.width || 120); + const height = options.height ?? (canvas.height || 320); + if (canvas.width !== width) canvas.width = width; + if (canvas.height !== height) canvas.height = height; + + const ctx = (canvas as any).getContext?.('2d') as CanvasRenderingContext2D | null; + if (!ctx) return; + + ctx.clearRect(0, 0, width, height); + if (nodes.length === 0) return; + + let maxLane = 1; + for (const node of nodes) { + if (node.lane + 1 > maxLane) maxLane = node.lane + 1; + } + + const total = nodes.length; + const stepY = height / Math.max(total, 1); + const laneStepX = Math.max(4, Math.min(12, (width - 16) / Math.max(maxLane, 1))); + const offsetX = 8; + + for (let i = 0; i < total; i++) { + const node = nodes[i]; + const y = i * stepY + stepY * 0.5; + const x = offsetX + node.lane * laneStepX; + + ctx.fillStyle = node.color || '#38bdf8'; + ctx.beginPath(); + ctx.arc(x, y, Math.max(1.5, Math.min(3, stepY * 0.45)), 0, Math.PI * 2); + ctx.fill(); + + if (node.commit.refs?.some((r) => r.isHead || r.type === 'head')) { + ctx.strokeStyle = '#ffffff'; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.arc(x, y, 4, 0, Math.PI * 2); + ctx.stroke(); + } + } + + if ( + typeof options.viewportStartRatio === 'number' && + typeof options.viewportEndRatio === 'number' + ) { + const topY = Math.max(0, options.viewportStartRatio * height); + const bottomY = Math.min(height, options.viewportEndRatio * height); + ctx.fillStyle = 'rgba(56, 189, 248, 0.16)'; + ctx.fillRect(0, topY, width, Math.max(6, bottomY - topY)); + ctx.strokeStyle = 'rgba(56, 189, 248, 0.7)'; + ctx.lineWidth = 1; + ctx.strokeRect(0.5, topY + 0.5, width - 1, Math.max(5, bottomY - topY - 1)); + } + + if (typeof options.selectedIndex === 'number' && options.selectedIndex >= 0 && options.selectedIndex < total) { + const selY = options.selectedIndex * stepY; + ctx.fillStyle = '#f59e0b'; + ctx.fillRect(0, Math.max(0, selY - 1), width, 2); + } + } + + public dispose(): void { + if (this.worker) { + this.worker.terminate(); + this.worker = null; + } + this.pending.clear(); + } + + private ensureWorker(): Worker { + if (this.worker) return this.worker; + const rawFn = GraphLayout.compute.toString().trim(); + const computeMember = /^(async\s+)?function\b|^\(|^[a-zA-Z_$][a-zA-Z0-9_$]*\s*=>/.test(rawFn) + ? `compute: ${rawFn}` + : rawFn; + const workerScript = ` + const THEME_LANE_COLORS = ${JSON.stringify(GraphLayout.THEME_LANE_COLORS)}; + const GraphLayoutWorker = { THEME_LANE_COLORS, ${computeMember} }; + self.onmessage = function (ev) { + const { id, commits, theme } = ev.data; + try { + const nodes = GraphLayoutWorker.compute(commits, theme); + self.postMessage({ id, nodes }); + } catch (err) { + self.postMessage({ id, error: err && err.message ? err.message : String(err) }); + } + }; + `; + const blob = new Blob([workerScript], { type: 'application/javascript' }); + const url = URL.createObjectURL(blob); + const worker = new Worker(url); + URL.revokeObjectURL(url); + + worker.onmessage = (ev: MessageEvent) => { + const { id, nodes, error } = ev.data || {}; + const entry = this.pending.get(id); + if (!entry) return; + this.pending.delete(id); + if (error) entry.reject(new Error(error)); + else entry.resolve(nodes); + }; + + worker.onerror = () => { + for (const [, entry] of this.pending) { + entry.reject(new Error('DAG Web Worker execution failed')); + } + this.pending.clear(); + this.worker = null; + }; + + this.worker = worker; + return worker; + } +} diff --git a/src/graph/graphWebviewProvider.ts b/src/graph/graphWebviewProvider.ts index a7ed4db..187c368 100644 --- a/src/graph/graphWebviewProvider.ts +++ b/src/graph/graphWebviewProvider.ts @@ -10,6 +10,7 @@ import { IssueService } from '../issues/issueService'; import { ForkIntegration } from '../remotes/forkIntegration'; import { PersistenceManager } from '../state/persistenceManager'; import { ApiKeyManager } from '../mcp/apiKeyManager'; +import { AdaptiveDebouncer } from '../git/branchWatcher'; import { CommitFlowSettings, ExtensionToWebviewMessage, @@ -30,6 +31,14 @@ export class GraphWebviewProvider private readonly persistence = PersistenceManager.getInstance(); private readonly disposables: vscode.Disposable[] = []; private activeRepoPath: string | null = null; + private readonly headDebouncer = new AdaptiveDebouncer(() => this.refreshData(), { + minDelayMs: 150, + maxDelayMs: 1000, + }); + private refreshInFlight: Promise | null = null; + private refreshPending = false; + private ipcBatchQueue = new Map(); + private ipcBatchTimer: NodeJS.Timeout | null = null; public async getEffectiveRepoRoot(): Promise { if (this.activeRepoPath && fs.existsSync(this.activeRepoPath)) { @@ -44,11 +53,20 @@ export class GraphWebviewProvider } constructor(private readonly extensionUri: vscode.Uri) { - // 1. Watch for Git HEAD changes to auto-refresh when checking out branches + // 1. Watch for Git HEAD changes with adaptive debouncing to coalesce burst storms const watcher = vscode.workspace.createFileSystemWatcher('**/.git/HEAD'); - watcher.onDidChange(() => this.refreshData(), this, this.disposables); - watcher.onDidCreate(() => this.refreshData(), this, this.disposables); + watcher.onDidChange(() => this.headDebouncer.trigger(), this, this.disposables); + watcher.onDidCreate(() => this.headDebouncer.trigger(), this, this.disposables); this.disposables.push(watcher); + this.disposables.push({ + dispose: () => { + this.headDebouncer.dispose(); + if (this.ipcBatchTimer) { + clearTimeout(this.ipcBatchTimer); + this.ipcBatchTimer = null; + } + }, + }); // 2. Multi-root workspace: Refresh when switching active editor to a different root vscode.window.onDidChangeActiveTextEditor( @@ -158,22 +176,69 @@ export class GraphWebviewProvider } /** - * Refreshes the Git data and sends to all active webviews + * Queues an extension-to-webview IPC message for short-window batching. */ - public async refreshData(): Promise { - const activeWebviews = [this.view?.webview, this.panel?.webview].filter( - (w): w is vscode.Webview => w !== undefined - ); + public enqueueWebviewMessage(webview: vscode.Webview, message: ExtensionToWebviewMessage): void { + const existing = this.ipcBatchQueue.get(webview) || []; + const dedupIdx = existing.findIndex((m) => m.command === message.command); + if (dedupIdx >= 0) { + existing[dedupIdx] = message; + } else { + existing.push(message); + } + this.ipcBatchQueue.set(webview, existing); - if (activeWebviews.length === 0) { - return; + if (!this.ipcBatchTimer) { + this.ipcBatchTimer = setTimeout(() => { + this.flushWebviewMessages(); + }, 16); } + } - activeWebviews.forEach((w) => { - try { - w.postMessage({ command: 'loading' } as ExtensionToWebviewMessage); - } catch {} - }); + /** + * Flushes queued IPC messages immediately across registered webviews. + */ + public flushWebviewMessages(): number { + if (this.ipcBatchTimer) { + clearTimeout(this.ipcBatchTimer); + this.ipcBatchTimer = null; + } + let flushedCount = 0; + for (const [webview, msgs] of this.ipcBatchQueue.entries()) { + for (const msg of msgs) { + try { + webview.postMessage(msg); + flushedCount++; + } catch {} + } + } + this.ipcBatchQueue.clear(); + return flushedCount; + } + + /** + * Refreshes the Git data with in-flight coalescing and sends to all active webviews + */ + public async refreshData(): Promise { + if (this.refreshInFlight) { + this.refreshPending = true; + return this.refreshInFlight; + } + + const executeRefresh = async (): Promise => { + const activeWebviews = [this.view?.webview, this.panel?.webview].filter( + (w): w is vscode.Webview => w !== undefined + ); + + if (activeWebviews.length === 0) { + return; + } + + activeWebviews.forEach((w) => { + try { + w.postMessage({ command: 'loading' } as ExtensionToWebviewMessage); + } catch {} + }); try { const root = await this.getEffectiveRepoRoot(); @@ -309,6 +374,18 @@ export class GraphWebviewProvider } catch {} }); } + }; + + this.refreshInFlight = executeRefresh(); + try { + await this.refreshInFlight; + } finally { + this.refreshInFlight = null; + if (this.refreshPending) { + this.refreshPending = false; + void this.refreshData(); + } + } } private setupMessageListener(webview: vscode.Webview): void { diff --git a/src/webview/main.ts b/src/webview/main.ts index 9943c4f..4eb51d0 100644 --- a/src/webview/main.ts +++ b/src/webview/main.ts @@ -24,6 +24,7 @@ import { UserPersonalStats, } from '../git/types'; import { t, Language } from '../i18n/locales'; +import { DagWorkerEngine } from '../graph/dagWorkerClient'; import { CfButton, CfBadge, @@ -879,6 +880,9 @@ function clearBranchPathHighlight(): void { let virtualSpacer: HTMLElement | null = null; let virtualViewport: HTMLElement | null = null; let isScrollBound = false; +const recycledRowPool = new Map(); +const MAX_RECYCLED_ROWS = 120; +const dagWorkerEngine = DagWorkerEngine.getInstance(); function renderCommitRow( node: GraphNode, @@ -1246,11 +1250,12 @@ function updateVirtualWindow(): void { const clientHeight = tableContainer.clientHeight || 600; const buffer = 10; const startIndex = Math.max(0, Math.floor(scrollTop / rowHeight) - buffer); - const endIndex = Math.min(filteredNodes.length, Math.ceil((scrollTop + clientHeight) / rowHeight) + buffer); + const rawEndIndex = Math.min(filteredNodes.length, Math.ceil((scrollTop + clientHeight) / rowHeight) + buffer); + // Guarantee mounted rows stay strictly under 150 even on ultra-tall screens + const endIndex = Math.min(rawEndIndex, startIndex + 120); const offsetY = startIndex * rowHeight; virtualViewport.style.transform = `translateY(${offsetY}px)`; - virtualViewport.innerHTML = ''; const fragment = document.createDocumentFragment(); const isCompact = isCompactLayout; @@ -1276,10 +1281,28 @@ function updateVirtualWindow(): void { for (let i = startIndex; i < endIndex; i++) { const node = filteredNodes[i]; - const row = renderCommitRow(node, i, isCompact, canvasCssWidth, rowHeight, dpr); + const isSel = selectedNode?.commit.hash === node.commit.hash ? '1' : '0'; + const bisectHash = bisectStatus?.isActive ? `${bisectStatus.currentCommit || ''}:${bisectStatus.badCommit || ''}:${bisectStatus.goodCommits.length}` : '0'; + const renderKey = `${node.commit.hash}|${i}|${isCompact ? 'c' : 'f'}|${canvasCssWidth}|${rowHeight}|${isSel}|${bisectHash}`; + + let row = recycledRowPool.get(renderKey); + if (!row) { + row = renderCommitRow(node, i, isCompact, canvasCssWidth, rowHeight, dpr); + row.id = `commit-row-${node.commit.shortHash}-${i}`; + row.setAttribute('data-render-key', renderKey); + recycledRowPool.set(renderKey, row); + if (recycledRowPool.size > MAX_RECYCLED_ROWS) { + const oldestKey = recycledRowPool.keys().next().value; + if (oldestKey) recycledRowPool.delete(oldestKey); + } + } else { + recycledRowPool.delete(renderKey); + recycledRowPool.set(renderKey, row); + } fragment.appendChild(row); } + virtualViewport.innerHTML = ''; virtualViewport.appendChild(fragment); } @@ -1294,9 +1317,11 @@ function jumpToCommitIndex(index: number): void { const targetScroll = Math.max(0, clamped * rowHeight - Math.floor(tableContainer.clientHeight / 2)); tableContainer.scrollTop = targetScroll; updateVirtualWindow(); + tableContainer.setAttribute('aria-activedescendant', `commit-row-${targetNode?.commit.shortHash || ''}-${clamped}`); } function renderGraph(): void { + recycledRowPool.clear(); tableContainer.setAttribute('role', 'treegrid'); tableContainer.setAttribute('aria-label', 'CommitFlow Interactive Git Graph'); @@ -3121,6 +3146,20 @@ window.addEventListener('keydown', (e) => { if (targetNode) { jumpToCommitIndex(prevIdx); } + } else if (e.key === 'PageDown') { + e.preventDefault(); + const currentIdx = selectedNode ? filteredNodes.indexOf(selectedNode) : 0; + jumpToCommitIndex(Math.min(filteredNodes.length - 1, currentIdx + 15)); + } else if (e.key === 'PageUp') { + e.preventDefault(); + const currentIdx = selectedNode ? filteredNodes.indexOf(selectedNode) : 0; + jumpToCommitIndex(Math.max(0, currentIdx - 15)); + } else if (e.key === 'Home') { + e.preventDefault(); + jumpToCommitIndex(0); + } else if (e.key === 'End') { + e.preventDefault(); + jumpToCommitIndex(filteredNodes.length - 1); } else if (e.key === 'Enter') { if (selectedNode) { drawer.classList.add('open'); diff --git a/test/runTests.ts b/test/runTests.ts index 433e98d..1733192 100644 --- a/test/runTests.ts +++ b/test/runTests.ts @@ -21,6 +21,9 @@ import { CommitFlowHttpServer } from '../src/mcp/commitflowHttpServer'; import { PersistenceManager } from '../src/state/persistenceManager'; import { GitFsReader } from '../src/git/gitFsReader'; import { computeClampedPosition, escapeHtml } from '../src/webview/primitives'; +import { DagWorkerEngine } from '../src/graph/dagWorkerClient'; +import { AdaptiveDebouncer } from '../src/git/branchWatcher'; +import { GraphWebviewProvider } from '../src/graph/graphWebviewProvider'; console.log('Starting CommitFlow Extended Test Suite...\n'); @@ -1512,7 +1515,100 @@ Host *.internal.corp } console.log(' [PASS] GitFsReader Binary Objects, Packfiles, .git/index, SHA-256 & Reftable tests passed!'); - console.log('\n[PASS] ALL 45 COMMITFLOW TEST SUITES PASSED WITH 100% SUCCESS!\n'); + console.log('\nTesting DAG Worker Engine, DOM Recycling, AdaptiveDebouncer & IPC Batching...'); + { + const engine = DagWorkerEngine.getInstance(); + const now = new Date(); + const author = { name: 'Test', email: 't@example.com', date: now, relativeTime: 'now', avatarUrl: '' }; + const sampleCommits: GitCommit[] = [ + { + hash: '1111111111111111111111111111111111111111', + shortHash: '1111111', + parentHashes: ['0000000000000000000000000000000000000000'], + author, + committer: author, + subject: 'feat: worker tip', + body: '', + refs: [{ name: 'main', type: 'head', isHead: true }], + }, + { + hash: '0000000000000000000000000000000000000000', + shortHash: '0000000', + parentHashes: [], + author, + committer: author, + subject: 'root', + body: '', + refs: [], + }, + ]; + const workerNodes = await engine.computeLayoutAsync(sampleCommits, 'cyberpunk'); + const directNodes = GraphLayout.compute(sampleCommits, 'cyberpunk'); + assert.strictEqual(workerNodes.length, directNodes.length); + assert.strictEqual(workerNodes[0].color, directNodes[0].color); + + let arcCount = 0; + let rectCount = 0; + const mockCanvas: any = { + width: 80, + height: 120, + getContext: () => ({ + clearRect: () => {}, + beginPath: () => {}, + arc: () => { arcCount++; }, + fill: () => {}, + stroke: () => {}, + fillRect: () => { rectCount++; }, + strokeRect: () => {}, + fillStyle: '', + strokeStyle: '', + lineWidth: 1, + }), + }; + engine.renderMinimap(workerNodes, mockCanvas, { + width: 80, + height: 120, + selectedIndex: 0, + viewportStartRatio: 0, + viewportEndRatio: 0.5, + }); + assert.ok(arcCount >= 2, 'renderMinimap should draw commit dots for every node'); + assert.ok(rectCount >= 2, 'renderMinimap should draw viewport window and selected marker'); + + // AdaptiveDebouncer burst scaling + let triggerCount = 0; + const debouncer = new AdaptiveDebouncer(() => { + triggerCount++; + }, { minDelayMs: 150, maxDelayMs: 1000, burstWindowMs: 600, burstThreshold: 3 }); + debouncer.trigger(1000); + assert.strictEqual(debouncer.getCurrentDelayMs(), 150); + debouncer.trigger(1010); + debouncer.trigger(1020); + debouncer.trigger(1030); + assert.ok(debouncer.getCurrentDelayMs() > 150 && debouncer.getCurrentDelayMs() <= 1000); + debouncer.flush(); + assert.strictEqual(triggerCount, 1); + debouncer.dispose(); + + // IPC batch queue deduplication + const posted: any[] = []; + const mockWebview: any = { postMessage: (m: any) => posted.push(m) }; + const provider = new GraphWebviewProvider({ scheme: 'file', fsPath: __dirname, path: __dirname } as any); + provider.enqueueWebviewMessage(mockWebview, { command: 'toast', payload: { message: 'first' } } as any); + provider.enqueueWebviewMessage(mockWebview, { command: 'toast', payload: { message: 'second' } } as any); + const flushed = provider.flushWebviewMessages(); + assert.strictEqual(flushed, 1, 'Duplicate command messages in batch window should coalesce to 1'); + assert.strictEqual(posted[0].payload.message, 'second'); + provider.dispose(); + + const webviewSrc = fs.readFileSync(path.join(__dirname, '..', 'src', 'webview', 'main.ts'), 'utf8'); + assert.ok(webviewSrc.includes('recycledRowPool') && webviewSrc.includes('MAX_RECYCLED_ROWS')); + assert.ok(webviewSrc.includes("'PageDown'") && webviewSrc.includes("'PageUp'") && webviewSrc.includes("'Home'") && webviewSrc.includes("'End'")); + assert.ok(webviewSrc.includes('aria-activedescendant')); + } + console.log(' [PASS] DAG Worker Engine, DOM Recycling, AdaptiveDebouncer & IPC Batching tests passed!'); + + console.log('\n[PASS] ALL 46 COMMITFLOW TEST SUITES PASSED WITH 100% SUCCESS!\n'); })().catch((err) => { console.error('[CommitFlow] Test suite failed:', err); process.exit(1);