From a8c933c0905dd453d5e267332a8f2245a8d0396a Mon Sep 17 00:00:00 2001 From: joesobo Date: Mon, 22 Jun 2026 14:51:51 -0700 Subject: [PATCH 001/192] docs: plan CodeGraphy performance investigation --- .../2026-06-22-codegraphy-performance.md | 285 ++++++++++++++++++ 1 file changed, 285 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-22-codegraphy-performance.md diff --git a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md new file mode 100644 index 000000000..e98f15213 --- /dev/null +++ b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md @@ -0,0 +1,285 @@ +# CodeGraphy Performance Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make CodeGraphy feel snappy on the CodeGraphy monorepo by measuring load and interaction latency, then landing small deterministic optimizations that improve those numbers. + +**Architecture:** Treat performance as a product contract across the Core Package and VS Code Extension. Measure Indexing, Graph Cache reads, Graph Projection, Graph Query, Graph Scope toggles, and Visible Graph updates separately so each optimization has a clear before/after number. + +**Tech Stack:** pnpm, Turbo, Vitest, Playwright VS Code acceptance tests, CodeGraphy Core Package, CodeGraphy VS Code Extension, Graph Cache, and optional Mac mini validation for heavy browser runs. + +--- + +## Baseline Evidence + +- Branch: `codex/speed-up-codegraphy` +- Worktree: `/Users/poleski/Desktop/Projects/CodeGraphyV4/.worktrees/speed-up-codegraphy` +- Trello card: `https://trello.com/c/TKoE7wEI` +- User settings baseline: branch starts from local `main` commit `5108cc320 settings`, which updates `.codegraphy/settings.json`. +- First full test attempt: `pnpm run test` failed because the timing wrapper launched `/usr/local/bin/pnpm` under Node `v19.5.0`; `@poleski/quality-tools` needs `path.matchesGlob`. +- Environment correction: use `PATH=/opt/homebrew/bin:/opt/homebrew/opt/node@22/bin:$PATH /opt/homebrew/bin/pnpm ...`. +- Verification of correction: `PATH=/opt/homebrew/bin:/opt/homebrew/opt/node@22/bin:$PATH /opt/homebrew/bin/pnpm --filter @codegraphy-dev/extension exec vitest run tests/playwrightVscodeConfig.test.ts --config vitest.config.ts --project node` passed 6 tests in 2.15s. +- Corrected full test baseline: `PATH=/opt/homebrew/bin:/opt/homebrew/opt/node@22/bin:$PATH /usr/bin/time -l /opt/homebrew/bin/pnpm run test` passed in 1523.98s wall time. +- Unit baseline: `1009 passed` test files, `6039 passed` tests, `158.33s` extension-package Vitest duration, `2m39.381s` Turbo unit task wall time. +- Slow unit canary: `packages/extension/tests/extension/pipeline/examplesWorkspace.test.ts` took `56006ms` and `45842ms` for the two examples-workspace tests. +- Playwright baseline: `119 passed (22.3m)`, `22m42.903s` task wall time; slow file `tests/playwright-vscode/generated/runtime.ts (21.5m)`. +- Cold monorepo CLI indexing baseline: local `node packages/core/bin/codegraphy.js --verbose index .` from no Graph Cache took `214.04s` wall time for 2365 files, 5075 nodes, and 9097 edges. +- Cold index output: `.codegraphy/graph.lbug` is 62MB, `/usr/bin/time -l` reported `2708193280` maximum resident set size and `4201907648` peak memory footprint. +- Raw logs are ignored under `reports/performance/`; commit only scripts and bounded summaries under `docs/performance/`. + +## Success Metrics + +- Cold monorepo Indexing wall time from no Graph Cache. +- Warm monorepo Graph Cache load to first Visible Graph payload. +- Graph Cache Sync time when the cache is readable but settings, plugin state, or changed files need reconciliation. +- Graph Scope toggle latency from UI action to updated Visible Graph payload. +- Display Setting toggle latency for controls that should not rebuild graph data. +- File save Live Update latency for one changed source file. +- Visible Graph payload size: node count, edge count, serialized message bytes. +- Webview apply/render latency after `GRAPH_DATA_UPDATED`. + +## Task 1: Stabilize The Baseline Harness + +**Files:** +- Create: `docs/superpowers/plans/2026-06-22-codegraphy-performance.md` +- Read: `package.json` +- Read: `packages/extension/package.json` +- Read: `packages/extension/playwright.vscode.config.ts` +- Read: `packages/extension/tests/extension/pipeline/examplesWorkspace.test.ts` + +- [x] **Step 1: Create an isolated branch and Trello card** + +Run: + +```bash +git worktree add .worktrees/speed-up-codegraphy -b codex/speed-up-codegraphy main +``` + +Expected: worktree created on `codex/speed-up-codegraphy`. + +- [x] **Step 2: Install dependencies in the worktree** + +Run: + +```bash +pnpm install +``` + +Expected: lockfile unchanged and packages installed. + +- [x] **Step 3: Run the baseline full test command** + +Run: + +```bash +PATH=/opt/homebrew/bin:/opt/homebrew/opt/node@22/bin:$PATH /usr/bin/time -l /opt/homebrew/bin/pnpm run test 2>&1 | tee reports/performance/baseline-test-node22-2026-06-22.log +``` + +Expected: test output is captured. If Playwright is too slow locally, record the partial result and move future Playwright repeats to the Mac mini. + +- [x] **Step 4: Summarize baseline timings** + +Run: + +```bash +rg -n "Test Files|Tests |Duration|Time:|real|WorkspacePipeline examples workspace|Graph built|Discovered|Analysis:" reports/performance/baseline-test-node22-2026-06-22.log +``` + +Expected: baseline notes include unit time, Playwright time, and the slow examples workspace test timings. + +- [ ] **Step 5: Commit the setup** + +Run: + +```bash +git add docs/superpowers/plans/2026-06-22-codegraphy-performance.md +git commit -m "docs: plan CodeGraphy performance investigation" +git push -u origin codex/speed-up-codegraphy +``` + +Expected: setup commit is pushed before implementation edits. + +## Task 2: Add A Deterministic Monorepo Performance Runner + +**Files:** +- Create: `scripts/performance/measure-codegraphy-monorepo.mjs` +- Create: `docs/performance/codegraphy-monorepo.md` +- Modify: `package.json` +- Test: `tests/scripts/measure-codegraphy-monorepo.test.mjs` + +- [ ] **Step 1: Write the failing script test** + +Create `tests/scripts/measure-codegraphy-monorepo.test.mjs` with checks that the runner: + +```js +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { pathToFileURL } from "node:url"; + +test("performance runner writes bounded JSON metrics", async () => { + const tempDir = await mkdtemp(path.join(tmpdir(), "codegraphy-perf-")); + const outputPath = path.join(tempDir, "metrics.json"); + + try { + const moduleUrl = pathToFileURL(path.resolve("scripts/performance/measure-codegraphy-monorepo.mjs")).href; + const { writeMetrics } = await import(moduleUrl); + + await writeMetrics({ + outputPath, + workspacePath: tempDir, + measurements: { + coldIndexMs: 100, + warmQueryMs: 20, + nodeCount: 2, + edgeCount: 1, + payloadBytes: 512 + } + }); + + const metrics = JSON.parse(await readFile(outputPath, "utf8")); + assert.equal(metrics.workspacePath, tempDir); + assert.equal(metrics.measurements.coldIndexMs, 100); + assert.equal(metrics.measurements.warmQueryMs, 20); + assert.equal(metrics.measurements.nodeCount, 2); + assert.equal(metrics.measurements.edgeCount, 1); + assert.equal(metrics.measurements.payloadBytes, 512); + assert.match(metrics.recordedAt, /^\d{4}-\d{2}-\d{2}T/); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } +}); +``` + +Run: + +```bash +node --test tests/scripts/measure-codegraphy-monorepo.test.mjs +``` + +Expected: FAIL because `scripts/performance/measure-codegraphy-monorepo.mjs` does not exist. + +- [ ] **Step 2: Implement minimal metrics writing** + +Create `scripts/performance/measure-codegraphy-monorepo.mjs` with exported `writeMetrics` and a CLI entry point that writes raw JSON to `reports/performance/monorepo-latest.json`. Durable reviewed summaries belong in `docs/performance/`. + +- [ ] **Step 3: Run the test to green** + +Run: + +```bash +node --test tests/scripts/measure-codegraphy-monorepo.test.mjs +``` + +Expected: PASS. + +- [ ] **Step 4: Wire the package script** + +Add this root script: + +```json +"perf:codegraphy-monorepo": "node scripts/performance/measure-codegraphy-monorepo.mjs" +``` + +- [ ] **Step 5: Commit the harness** + +Run: + +```bash +git add package.json scripts/performance/measure-codegraphy-monorepo.mjs tests/scripts/measure-codegraphy-monorepo.test.mjs docs/performance/codegraphy-monorepo.md +git commit -m "test: add CodeGraphy monorepo performance harness" +git push +``` + +## Task 3: Capture Current Monorepo Load And Interaction Numbers + +**Files:** +- Modify: `scripts/performance/measure-codegraphy-monorepo.mjs` +- Create: `docs/performance/codegraphy-monorepo.md` + +- [ ] **Step 1: Measure headless Core Package timings** + +Run the performance script against `/Users/poleski/Desktop/Projects/CodeGraphyV4/.worktrees/speed-up-codegraphy` using the branch settings. Record cold Indexing, warm Graph Cache query, node count, edge count, and payload bytes. + +- [ ] **Step 2: Measure VS Code user-facing timings** + +Use the Playwright VS Code lane or the Mac mini to open the same workspace and capture: + +```text +open workspace -> first graph payload +Graph Scope toggle -> updated graph payload +Display Setting toggle -> updated view state +single file save -> Live Update complete +``` + +- [ ] **Step 3: Commit the baseline metrics** + +Commit the bounded summary and keep raw logs ignored under `reports/performance/`. + +## Task 4: Optimize One Bottleneck At A Time + +**Files:** To be decided by measured bottleneck. + +- [ ] **Step 1: Rank hypotheses after baseline** + +Start with these falsifiable hypotheses: + +```text +If Graph Scope toggles rebuild graph data unnecessarily, then separating Display Setting updates from Graph Query updates will reduce toggle latency without changing node or edge counts. +If warm startup waits for full Graph Cache Sync before showing cached data, then rendering cached Visible Graph first will reduce time-to-first-graph while Graph Cache Sync continues in the background. +If large Visible Graph messages dominate interaction latency, then avoiding unchanged payload resend or using smaller incremental messages will reduce webview apply latency and message bytes. +If plugin analysis runs on files that cannot be affected by a changed setting, then narrowing reprocessing to affected providers/files will reduce Live Update and Graph Cache Sync time. +``` + +- [ ] **Step 2: Add or extend a failing test for the selected bottleneck** + +Use the closest seam: Core Package Graph Query tests for headless data work, Extension provider tests for message routing and refresh decisions, or Playwright for UI latency. + +- [ ] **Step 3: Implement the smallest behavior change** + +Keep each commit scoped to one measured path. + +- [ ] **Step 4: Re-run the targeted test and performance harness** + +Compare the metric before committing. + +- [ ] **Step 5: Commit and push** + +Commit each improvement separately with the metric delta in the commit body or PR comment. + +## Task 5: Keep The PR Reviewable + +**Files:** +- Modify: PR body and Trello card comments through GitHub/Trello. + +- [ ] **Step 1: Open a draft PR after the setup commit** + +Include the Trello link, settings baseline note, and first test evidence. + +- [ ] **Step 2: After each optimization, update the PR** + +Add a short comment: + +```text +Iteration N: +- Changed: +- Test: +- Metric before: +- Metric after: +- Next: +``` + +- [ ] **Step 3: Before final review** + +Run: + +```bash +pnpm run lint +pnpm run typecheck +pnpm run test +pnpm run perf:codegraphy-monorepo +``` + +Expected: required checks pass, and the performance report shows user-visible improvement over baseline. From c6d399b285d76eb401d940a3b2a77074b01c1603 Mon Sep 17 00:00:00 2001 From: joesobo Date: Mon, 22 Jun 2026 14:54:50 -0700 Subject: [PATCH 002/192] test: add CodeGraphy monorepo performance harness --- docs/performance/codegraphy-monorepo.md | 29 ++++ package.json | 1 + .../measure-codegraphy-monorepo.mjs | 142 ++++++++++++++++++ .../measure-codegraphy-monorepo.test.mjs | 41 +++++ 4 files changed, 213 insertions(+) create mode 100644 docs/performance/codegraphy-monorepo.md create mode 100644 scripts/performance/measure-codegraphy-monorepo.mjs create mode 100644 tests/scripts/measure-codegraphy-monorepo.test.mjs diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md new file mode 100644 index 000000000..4e7b549bc --- /dev/null +++ b/docs/performance/codegraphy-monorepo.md @@ -0,0 +1,29 @@ +# CodeGraphy Monorepo Performance + +## Baseline: 2026-06-22 + +Environment: + +- Worktree: `/Users/poleski/Desktop/Projects/CodeGraphyV4/.worktrees/speed-up-codegraphy` +- Branch: `codex/speed-up-codegraphy` +- Settings: tracked `.codegraphy/settings.json` from `main` +- Runtime: Node 22 PATH, local `packages/core/bin/codegraphy.js` + +Cold index from no Graph Cache: + +- Command: `node packages/core/bin/codegraphy.js --verbose index .` +- Wall time: `214.04s` +- Files: `2365` +- Nodes: `5075` +- Edges: `9097` +- Graph Cache: `62MB` +- Max resident set: `2708193280` bytes +- Peak memory footprint: `4201907648` bytes + +Full test baseline: + +- `pnpm run test`: `1523.98s` wall time +- Unit phase: `1009` test files and `6039` tests passed +- Playwright phase: `119` tests passed in `22.3m` + +Raw logs are intentionally ignored under `reports/performance/`. diff --git a/package.json b/package.json index 39ef569ab..4cc6c9369 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "test:release": "node --test tests/release/*.test.mjs tests/scripts/*.test.mjs", "test:playwright": "node scripts/run-playwright-turbo.mjs", "test:vscode": "pnpm -r --if-present run test:vscode", + "perf:codegraphy-monorepo": "node scripts/performance/measure-codegraphy-monorepo.mjs --workspace . --index-log reports/performance/codegraphy-index-cold-local-node22-2026-06-22.log", "check:acceptance-specs": "node scripts/guard-acceptance-spec-edits.mjs", "lint": "turbo run lint", "crap": "quality-tools crap", diff --git a/scripts/performance/measure-codegraphy-monorepo.mjs b/scripts/performance/measure-codegraphy-monorepo.mjs new file mode 100644 index 000000000..a116159a7 --- /dev/null +++ b/scripts/performance/measure-codegraphy-monorepo.mjs @@ -0,0 +1,142 @@ +import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const DEFAULT_OUTPUT_PATH = 'reports/performance/monorepo-latest.json'; + +function createMetricsRecord({ workspacePath, measurements }) { + return { + version: 1, + recordedAt: new Date().toISOString(), + workspacePath: path.resolve(workspacePath), + measurements: { ...measurements }, + }; +} + +export async function writeMetrics({ outputPath, workspacePath, measurements }) { + const metrics = createMetricsRecord({ workspacePath, measurements }); + await mkdir(path.dirname(outputPath), { recursive: true }); + await writeFile(outputPath, `${JSON.stringify(metrics, null, 2)}\n`); + return metrics; +} + +function parseJsonResult(logText) { + const jsonStart = logText.lastIndexOf('\n{'); + if (jsonStart < 0) { + return {}; + } + + const jsonEnd = logText.indexOf('\n}', jsonStart); + if (jsonEnd < 0) { + return {}; + } + + try { + return JSON.parse(logText.slice(jsonStart + 1, jsonEnd + 2)); + } catch { + return {}; + } +} + +function parseTimeSummary(logText) { + const match = logText.match(/^\s*([\d.]+)\s+real\s+([\d.]+)\s+user\s+([\d.]+)\s+sys$/m); + if (!match) { + return {}; + } + + return { + coldIndexMs: Math.round(Number(match[1]) * 1000), + coldIndexUserMs: Math.round(Number(match[2]) * 1000), + coldIndexSystemMs: Math.round(Number(match[3]) * 1000), + }; +} + +function parseMemorySummary(logText) { + const maxResidentMatch = logText.match(/^\s*(\d+)\s+maximum resident set size$/m); + const peakMemoryMatch = logText.match(/^\s*(\d+)\s+peak memory footprint$/m); + + return { + ...(maxResidentMatch ? { maxResidentSetBytes: Number(maxResidentMatch[1]) } : {}), + ...(peakMemoryMatch ? { peakMemoryFootprintBytes: Number(peakMemoryMatch[1]) } : {}), + }; +} + +async function readGraphCacheBytes(workspacePath, graphCache) { + if (typeof graphCache !== 'string' || graphCache.length === 0) { + return {}; + } + + try { + const graphCachePath = path.resolve(workspacePath, graphCache); + return { graphCacheBytes: (await stat(graphCachePath)).size }; + } catch { + return {}; + } +} + +export async function readColdIndexLogMetrics({ logPath, workspacePath }) { + const logText = await readFile(logPath, 'utf8'); + const result = parseJsonResult(logText); + const graphCache = result.graphCache; + + return { + ...parseTimeSummary(logText), + ...parseMemorySummary(logText), + ...(typeof result.files === 'number' ? { fileCount: result.files } : {}), + ...(typeof result.nodes === 'number' ? { nodeCount: result.nodes } : {}), + ...(typeof result.edges === 'number' ? { edgeCount: result.edges } : {}), + ...(typeof graphCache === 'string' ? { graphCache } : {}), + ...(typeof graphCache === 'string' + ? await readGraphCacheBytes(workspacePath, graphCache) + : {}), + }; +} + +function readOptionValue(args, name) { + const index = args.indexOf(name); + return index >= 0 ? args[index + 1] : undefined; +} + +function hasFlag(args, name) { + return args.includes(name); +} + +function printUsage() { + process.stdout.write([ + 'Usage:', + ' node scripts/performance/measure-codegraphy-monorepo.mjs --index-log [--workspace ] [--output ]', + '', + 'Writes a bounded JSON metric summary. Raw logs stay under reports/performance/.', + ].join('\n')); +} + +async function runCli(argv) { + if (hasFlag(argv, '--help')) { + printUsage(); + return; + } + + const workspacePath = readOptionValue(argv, '--workspace') ?? process.cwd(); + const outputPath = readOptionValue(argv, '--output') ?? DEFAULT_OUTPUT_PATH; + const indexLogPath = readOptionValue(argv, '--index-log'); + + if (!indexLogPath) { + throw new Error('Missing required --index-log '); + } + + const measurements = await readColdIndexLogMetrics({ + logPath: indexLogPath, + workspacePath, + }); + await writeMetrics({ outputPath, workspacePath, measurements }); +} + +const isDirectRun = process.argv[1] + && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); + +if (isDirectRun) { + runCli(process.argv.slice(2)).catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + }); +} diff --git a/tests/scripts/measure-codegraphy-monorepo.test.mjs b/tests/scripts/measure-codegraphy-monorepo.test.mjs new file mode 100644 index 000000000..e7bc53eeb --- /dev/null +++ b/tests/scripts/measure-codegraphy-monorepo.test.mjs @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { pathToFileURL } from 'node:url'; + +test('performance runner writes bounded JSON metrics', async () => { + const tempDir = await mkdtemp(path.join(tmpdir(), 'codegraphy-perf-')); + const outputPath = path.join(tempDir, 'metrics.json'); + + try { + const moduleUrl = pathToFileURL( + path.resolve('scripts/performance/measure-codegraphy-monorepo.mjs'), + ).href; + const { writeMetrics } = await import(moduleUrl); + + await writeMetrics({ + outputPath, + workspacePath: tempDir, + measurements: { + coldIndexMs: 100, + warmCacheLoadMs: 20, + nodeCount: 2, + edgeCount: 1, + payloadBytes: 512, + }, + }); + + const metrics = JSON.parse(await readFile(outputPath, 'utf8')); + assert.equal(metrics.workspacePath, tempDir); + assert.equal(metrics.measurements.coldIndexMs, 100); + assert.equal(metrics.measurements.warmCacheLoadMs, 20); + assert.equal(metrics.measurements.nodeCount, 2); + assert.equal(metrics.measurements.edgeCount, 1); + assert.equal(metrics.measurements.payloadBytes, 512); + assert.match(metrics.recordedAt, /^\d{4}-\d{2}-\d{2}T/); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } +}); From a1dd892d577552076cfdb44e849555f8c3ea21cc Mon Sep 17 00:00:00 2001 From: joesobo Date: Mon, 22 Jun 2026 14:59:04 -0700 Subject: [PATCH 003/192] perf: reuse cached analysis for scoped refreshes --- .../pipeline/service/base/internal.ts | 2 +- .../pipeline/service/refreshFacade.ts | 72 +++++++++++++++++++ .../pipeline/service/refreshFacade.test.ts | 72 +++++++++++++++++++ 3 files changed, 145 insertions(+), 1 deletion(-) diff --git a/packages/extension/src/extension/pipeline/service/base/internal.ts b/packages/extension/src/extension/pipeline/service/base/internal.ts index 3a19f427d..6def6e4b4 100644 --- a/packages/extension/src/extension/pipeline/service/base/internal.ts +++ b/packages/extension/src/extension/pipeline/service/base/internal.ts @@ -90,7 +90,7 @@ export abstract class WorkspacePipelineInternalBase extends WorkspacePipelineSta ); } - private _getActiveAnalysisPluginIds( + protected _getActiveAnalysisPluginIds( pluginIds: readonly string[] | undefined, disabledPlugins: ReadonlySet, ): string[] { diff --git a/packages/extension/src/extension/pipeline/service/refreshFacade.ts b/packages/extension/src/extension/pipeline/service/refreshFacade.ts index e1175acab..699762075 100644 --- a/packages/extension/src/extension/pipeline/service/refreshFacade.ts +++ b/packages/extension/src/extension/pipeline/service/refreshFacade.ts @@ -1,6 +1,11 @@ import * as vscode from 'vscode'; +import { + hasRequiredAnalysisCacheTiers, + type IDiscoveredFile, +} from '@codegraphy-dev/core'; import type { IGraphData } from '../../../shared/graph/contracts'; import { WorkspacePipelineDiscoveryFacade } from './discoveryFacade'; +import { createWorkspacePipelineAnalysisCacheTiers } from './cache/tiers'; import { createWorkspacePipelineDiscoveryDependencies, discoverWorkspacePipelineFilesWithWarnings, @@ -80,6 +85,62 @@ export abstract class WorkspacePipelineRefreshFacade extends WorkspacePipelineDi return source; } + private _canReuseCurrentAnalysisForScope( + discoveredFiles: readonly IDiscoveredFile[], + disabledPlugins: Set, + ): boolean { + if (discoveredFiles.length === 0) { + return false; + } + + const nodeVisibility = this._config.get>('nodeVisibility', {}) ?? {}; + const activePluginIds = this._getActiveAnalysisPluginIds(undefined, disabledPlugins); + const requiredTiers = createWorkspacePipelineAnalysisCacheTiers( + nodeVisibility, + activePluginIds, + ).required; + + return discoveredFiles.every((file) => { + const analysis = this._lastFileAnalysis.get(file.relativePath); + return Boolean(analysis && hasRequiredAnalysisCacheTiers(analysis, requiredTiers)); + }); + } + + private async _rebuildAnalysisScopeFromCurrentAnalysis(input: { + discoveredDirectories: readonly string[]; + discoveredFiles: IDiscoveredFile[]; + disabledPlugins: Set; + onProgress?: (progress: { phase: string; current: number; total: number }) => void; + showOrphans: boolean; + workspaceRoot: string; + }): Promise { + input.onProgress?.({ + phase: 'Applying Scope', + current: 0, + total: input.discoveredFiles.length, + }); + + this._lastDiscoveredDirectories = [...input.discoveredDirectories]; + this._lastDiscoveredFiles = input.discoveredFiles; + this._lastWorkspaceRoot = input.workspaceRoot; + + const graphData = this._buildGraphDataFromAnalysis( + this._lastFileAnalysis, + input.workspaceRoot, + input.showOrphans, + input.disabledPlugins, + ); + + await this._persistIndexMetadata(); + input.onProgress?.({ + phase: 'Applying Scope', + current: input.discoveredFiles.length, + total: input.discoveredFiles.length, + }); + + return graphData; + } + async refreshAnalysisScope( filterPatterns: string[] = [], disabledPlugins: Set = new Set(), @@ -108,6 +169,17 @@ export abstract class WorkspacePipelineRefreshFacade extends WorkspacePipelineDi ); this._lastGitIgnoredPaths = discoveryResult.gitIgnoredPaths ?? []; + if (this._canReuseCurrentAnalysisForScope(discoveryResult.files, disabledPlugins)) { + return this._rebuildAnalysisScopeFromCurrentAnalysis({ + disabledPlugins, + discoveredDirectories: discoveryResult.directories ?? [], + discoveredFiles: discoveryResult.files, + onProgress, + showOrphans: config.showOrphans ?? true, + workspaceRoot, + }); + } + return refreshWorkspacePipelineAnalysisScope(this._createWorkspaceIndexRefreshSource(disabledPlugins), { disabledPlugins, discoveredDirectories: discoveryResult.directories ?? [], diff --git a/packages/extension/tests/extension/pipeline/service/refreshFacade.test.ts b/packages/extension/tests/extension/pipeline/service/refreshFacade.test.ts index 8dec2b49f..a5f6933ce 100644 --- a/packages/extension/tests/extension/pipeline/service/refreshFacade.test.ts +++ b/packages/extension/tests/extension/pipeline/service/refreshFacade.test.ts @@ -53,11 +53,18 @@ class TestRefreshFacade extends WorkspacePipelineRefreshFacade { } _config = { + get: vi.fn((key: string, defaultValue: unknown) => { + if (key === 'nodeVisibility') { + return {}; + } + return defaultValue; + }), getAll: vi.fn(() => ({ showOrphans: true, respectGitignore: true })), } as never; _discovery = { kind: 'discovery' } as never; _registry = { + list: vi.fn(() => [{ plugin: { id: 'plugin.a' } }]), notifyFilesChanged: vi.fn(async () => ({ additionalFilePaths: [], requiresFullRefresh: false })), } as never; @@ -326,6 +333,71 @@ describe('pipeline/service/refreshFacade', () => { ); }); + it('rebuilds analysis scope from tier-complete cached analysis without reanalyzing files', async () => { + const facade = new TestRefreshFacade(); + const disabledPlugins = new Set(['plugin.disabled']); + const signal = new AbortController().signal; + const onProgress = vi.fn(); + const graphData = { + nodes: [{ id: 'src/a.ts#run:function' }], + edges: [], + }; + facade._config = { + get: vi.fn((key: string, defaultValue: unknown) => { + if (key === 'nodeVisibility') { + return { symbol: true, 'symbol:function': true }; + } + return defaultValue; + }), + getAll: vi.fn(() => ({ showOrphans: true, respectGitignore: true })), + } as never; + facade._lastFileAnalysis = new Map([ + ['src/a.ts', { + filePath: '/workspace/src/a.ts', + relations: [], + symbols: [{ + filePath: '/workspace/src/a.ts', + id: '/workspace/src/a.ts:function:run', + kind: 'function', + name: 'run', + }], + cache: { + tiers: ['baseline', 'symbols', 'plugin:plugin.a'], + }, + }], + ]) as never; + facade._buildGraphDataFromAnalysis = vi.fn(() => graphData) as never; + + await expect( + facade.refreshAnalysisScope(['dist/**'], disabledPlugins, signal, onProgress), + ).resolves.toBe(graphData); + + expect(refreshWorkspacePipelineAnalysisScope).not.toHaveBeenCalled(); + expect(facade._lastDiscoveredFiles).toEqual([ + { absolutePath: '/workspace/src/a.ts', relativePath: 'src/a.ts' }, + ]); + expect(facade._lastGitIgnoredPaths).toEqual(['example-python/app.py']); + expect(facade._lastWorkspaceRoot).toBe('/workspace'); + expect(facade._buildGraphDataFromAnalysis).toHaveBeenCalledWith( + facade._lastFileAnalysis, + '/workspace', + true, + disabledPlugins, + ); + expect(facade._persistCache).not.toHaveBeenCalled(); + expect(facade._persistIndexMetadata).toHaveBeenCalledOnce(); + expect(onProgress).toHaveBeenNthCalledWith(1, { + phase: 'Applying Scope', + current: 0, + total: 1, + }); + expect(onProgress).toHaveBeenNthCalledWith(2, { + phase: 'Applying Scope', + current: 1, + total: 1, + }); + }); + it('refreshes gitignore metadata by rebuilding from cached analysis without analyzing files', async () => { const facade = new TestRefreshFacade(); const disabledPlugins = new Set(['plugin.disabled']); From cb7d859f4ec54fd545c199032d810c7382ca5b5e Mon Sep 17 00:00:00 2001 From: joesobo Date: Mon, 22 Jun 2026 15:10:32 -0700 Subject: [PATCH 004/192] perf: emit phase timings for core indexing Measure cold monorepo indexing by phase: analysis 88321ms, graph build 62ms, and Graph Cache save 122757ms on the CodeGraphy monorepo fixture. --- docs/performance/codegraphy-monorepo.md | 21 +++ .../2026-06-22-codegraphy-performance.md | 28 ++- package.json | 2 +- packages/core/src/diagnostics/events.ts | 22 +++ packages/core/src/indexing/contracts.ts | 2 + packages/core/src/indexing/workspace.ts | 170 ++++++++++++++---- .../core/src/workspace/requestIndexing.ts | 5 +- .../core/tests/diagnostics/events.test.ts | 12 ++ .../core/tests/workspace/coreBacked.test.ts | 29 +++ .../measure-codegraphy-monorepo.mjs | 48 +++++ .../measure-codegraphy-monorepo.test.mjs | 56 +++++- 11 files changed, 345 insertions(+), 50 deletions(-) diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index 4e7b549bc..785591179 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -20,6 +20,27 @@ Cold index from no Graph Cache: - Max resident set: `2708193280` bytes - Peak memory footprint: `4201907648` bytes +Phase-instrumented cold index: + +- Command: `node packages/core/bin/codegraphy.js --verbose index .` +- Wall time: `213.93s` +- Files: `2367` +- Nodes: `5078` +- Edges: `9114` +- Plugin load: `542ms` +- Plugin initialization: `1ms` +- File discovery: `1900ms` +- File analysis: `88321ms` +- Graph build: `62ms` +- Graph Cache save: `122757ms` +- Metadata persistence: `4ms` +- Max resident set: `3071688704` bytes +- Peak memory footprint: `4200348736` bytes + +The first measured bottlenecks are Graph Cache persistence and file/plugin +analysis. Graph construction is not currently a cold-load bottleneck for this +workspace. + Full test baseline: - `pnpm run test`: `1523.98s` wall time diff --git a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md index e98f15213..0c5627d51 100644 --- a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md +++ b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md @@ -25,6 +25,9 @@ - Playwright baseline: `119 passed (22.3m)`, `22m42.903s` task wall time; slow file `tests/playwright-vscode/generated/runtime.ts (21.5m)`. - Cold monorepo CLI indexing baseline: local `node packages/core/bin/codegraphy.js --verbose index .` from no Graph Cache took `214.04s` wall time for 2365 files, 5075 nodes, and 9097 edges. - Cold index output: `.codegraphy/graph.lbug` is 62MB, `/usr/bin/time -l` reported `2708193280` maximum resident set size and `4201907648` peak memory footprint. +- Phase-instrumented cold monorepo CLI indexing took `213.93s` wall time for 2367 files, 5078 nodes, and 9114 edges. +- Phase split: plugin load `542ms`, plugin initialization `1ms`, file discovery `1900ms`, file analysis `88321ms`, graph build `62ms`, Graph Cache save `122757ms`, metadata persistence `4ms`. +- Measured hot spots: Graph Cache persistence and file/plugin analysis. Graph construction is not a cold-load bottleneck on this workspace. - Raw logs are ignored under `reports/performance/`; commit only scripts and bounded summaries under `docs/performance/`. ## Success Metrics @@ -87,7 +90,7 @@ rg -n "Test Files|Tests |Duration|Time:|real|WorkspacePipeline examples workspac Expected: baseline notes include unit time, Playwright time, and the slow examples workspace test timings. -- [ ] **Step 5: Commit the setup** +- [x] **Step 5: Commit the setup** Run: @@ -107,7 +110,7 @@ Expected: setup commit is pushed before implementation edits. - Modify: `package.json` - Test: `tests/scripts/measure-codegraphy-monorepo.test.mjs` -- [ ] **Step 1: Write the failing script test** +- [x] **Step 1: Write the failing script test** Create `tests/scripts/measure-codegraphy-monorepo.test.mjs` with checks that the runner: @@ -161,11 +164,11 @@ node --test tests/scripts/measure-codegraphy-monorepo.test.mjs Expected: FAIL because `scripts/performance/measure-codegraphy-monorepo.mjs` does not exist. -- [ ] **Step 2: Implement minimal metrics writing** +- [x] **Step 2: Implement minimal metrics writing** Create `scripts/performance/measure-codegraphy-monorepo.mjs` with exported `writeMetrics` and a CLI entry point that writes raw JSON to `reports/performance/monorepo-latest.json`. Durable reviewed summaries belong in `docs/performance/`. -- [ ] **Step 3: Run the test to green** +- [x] **Step 3: Run the test to green** Run: @@ -175,15 +178,15 @@ node --test tests/scripts/measure-codegraphy-monorepo.test.mjs Expected: PASS. -- [ ] **Step 4: Wire the package script** +- [x] **Step 4: Wire the package script** Add this root script: ```json -"perf:codegraphy-monorepo": "node scripts/performance/measure-codegraphy-monorepo.mjs" +"perf:codegraphy-monorepo": "node scripts/performance/measure-codegraphy-monorepo.mjs --workspace ." ``` -- [ ] **Step 5: Commit the harness** +- [x] **Step 5: Commit the harness** Run: @@ -199,10 +202,17 @@ git push - Modify: `scripts/performance/measure-codegraphy-monorepo.mjs` - Create: `docs/performance/codegraphy-monorepo.md` -- [ ] **Step 1: Measure headless Core Package timings** +- [x] **Step 1: Measure headless Core Package timings** Run the performance script against `/Users/poleski/Desktop/Projects/CodeGraphyV4/.worktrees/speed-up-codegraphy` using the branch settings. Record cold Indexing, warm Graph Cache query, node count, edge count, and payload bytes. +Current cold-index command: + +```bash +PATH=/opt/homebrew/bin:/opt/homebrew/opt/node@22/bin:$PATH /usr/bin/time -l node packages/core/bin/codegraphy.js --verbose index . 2>&1 | tee reports/performance/codegraphy-index-cold-phases-local-node22-2026-06-22.log +PATH=/opt/homebrew/bin:/opt/homebrew/opt/node@22/bin:$PATH /opt/homebrew/bin/pnpm run perf:codegraphy-monorepo -- --index-log reports/performance/codegraphy-index-cold-phases-local-node22-2026-06-22.log +``` + - [ ] **Step 2: Measure VS Code user-facing timings** Use the Playwright VS Code lane or the Mac mini to open the same workspace and capture: @@ -227,6 +237,8 @@ Commit the bounded summary and keep raw logs ignored under `reports/performance/ Start with these falsifiable hypotheses: ```text +If Graph Cache persistence spends over half of cold load writing cache records, then reducing cache payload size or batching/storage strategy will cut cold Indexing wall time without changing graph counts. +If file/plugin analysis spends most of the remaining cold load, then per-plugin phase diagnostics and cacheable analysis reuse will identify the plugin/file classes worth optimizing first. If Graph Scope toggles rebuild graph data unnecessarily, then separating Display Setting updates from Graph Query updates will reduce toggle latency without changing node or edge counts. If warm startup waits for full Graph Cache Sync before showing cached data, then rendering cached Visible Graph first will reduce time-to-first-graph while Graph Cache Sync continues in the background. If large Visible Graph messages dominate interaction latency, then avoiding unchanged payload resend or using smaller incremental messages will reduce webview apply latency and message bytes. diff --git a/package.json b/package.json index 4cc6c9369..6d48dbda6 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "test:release": "node --test tests/release/*.test.mjs tests/scripts/*.test.mjs", "test:playwright": "node scripts/run-playwright-turbo.mjs", "test:vscode": "pnpm -r --if-present run test:vscode", - "perf:codegraphy-monorepo": "node scripts/performance/measure-codegraphy-monorepo.mjs --workspace . --index-log reports/performance/codegraphy-index-cold-local-node22-2026-06-22.log", + "perf:codegraphy-monorepo": "node scripts/performance/measure-codegraphy-monorepo.mjs --workspace .", "check:acceptance-specs": "node scripts/guard-acceptance-spec-edits.mjs", "lint": "turbo run lint", "crap": "quality-tools crap", diff --git a/packages/core/src/diagnostics/events.ts b/packages/core/src/diagnostics/events.ts index 047d4b1d0..64be8f995 100644 --- a/packages/core/src/diagnostics/events.ts +++ b/packages/core/src/diagnostics/events.ts @@ -174,6 +174,24 @@ function formatIndexingComplete(context: Record return details ? `Indexing complete: ${details}` : 'Indexing complete'; } +function formatIndexingPhaseCompleted(context: Record | undefined): string { + const details = joinDetails([ + formatContextDetail(context, 'phase'), + formatContextDetail(context, 'durationMs', 'durationMs'), + formatContextDetail(context, 'files'), + formatContextDetail(context, 'directories'), + formatContextDetail(context, 'totalFound', 'totalFound'), + formatContextDetail(context, 'limitReached', 'limitReached'), + formatContextDetail(context, 'cacheHits', 'cacheHits'), + formatContextDetail(context, 'cacheMisses', 'cacheMisses'), + formatContextDetail(context, 'nodes'), + formatContextDetail(context, 'edges'), + formatContextDetail(context, 'loadedPackagePlugins', 'loadedPackagePlugins'), + formatContextDetail(context, 'registeredPlugins', 'registeredPlugins'), + ]); + return details ? `Indexing phase complete: ${details}` : 'Indexing phase complete'; +} + function formatCommandEvent( event: string, context: Record | undefined, @@ -264,6 +282,10 @@ function formatKnownEvent(event: DiagnosticEvent): string | undefined { return formatIndexingComplete(context); } + if (event.area === 'indexing' && event.event === 'phase-completed') { + return formatIndexingPhaseCompleted(context); + } + if (event.area === 'graph-query' && event.event === 'started') { return `Starting Graph Query: ${joinDetails([ formatContextDetail(context, 'report'), diff --git a/packages/core/src/indexing/contracts.ts b/packages/core/src/indexing/contracts.ts index daf625cfc..c1bdbd9f6 100644 --- a/packages/core/src/indexing/contracts.ts +++ b/packages/core/src/indexing/contracts.ts @@ -1,6 +1,7 @@ import type { IGraphData, IPlugin } from '@codegraphy-dev/plugin-api'; import type { IWorkspaceAnalysisCache } from '../analysis/cache'; import type { IDiscoveredFile } from '../discovery/contracts'; +import type { DiagnosticEventSink } from '../diagnostics/events'; import type { CodeGraphyWorkspaceSettings } from '../workspace/settings'; export interface IndexCodeGraphyWorkspacePluginEntry { @@ -25,6 +26,7 @@ export interface IndexCodeGraphyWorkspaceOptions { maxFiles?: number; respectGitignore?: boolean; signal?: AbortSignal; + diagnostics?: DiagnosticEventSink; onProgress?: (progress: { phase: string; current: number; total: number }) => void; logInfo?: (message: string) => void; warn?: (message: string) => void; diff --git a/packages/core/src/indexing/workspace.ts b/packages/core/src/indexing/workspace.ts index ee69d2f2e..76ef63e32 100644 --- a/packages/core/src/indexing/workspace.ts +++ b/packages/core/src/indexing/workspace.ts @@ -1,10 +1,12 @@ +import { performance } from 'node:perf_hooks'; import { createEmptyWorkspaceAnalysisCache } from '../analysis/cache'; +import { createDiagnosticEvent } from '../diagnostics/events'; import { FileDiscovery } from '../discovery/file/service'; import { buildWorkspacePipelineGraphFromAnalysis } from '../graph/build'; import { saveWorkspaceAnalysisDatabaseCache } from '../graphCache/database/storage'; +import { createDisabledPluginSet } from '../plugins/activityState/model'; import { getGraphCachePath, resolveWorkspaceRoot } from '../workspace/paths'; import { analyzeWorkspaceIndexFiles } from './analysis'; -import { createDisabledPluginSet } from '../plugins/activityState/model'; import type { IndexCodeGraphyWorkspaceOptions, IndexCodeGraphyWorkspaceResult } from './contracts'; import { discoverWorkspaceIndexFiles } from './discovery'; import { persistWorkspaceIndexMetadata } from './metadata'; @@ -31,6 +33,47 @@ export type { IndexCodeGraphyWorkspaceResult, } from './contracts'; +function emitIndexPhaseCompleted( + options: IndexCodeGraphyWorkspaceOptions, + phase: string, + durationMs: number, + context: Record = {}, +): void { + options.diagnostics?.emit(createDiagnosticEvent({ + area: 'indexing', + event: 'phase-completed', + context: { + phase, + durationMs: Math.round(durationMs), + ...context, + }, + })); +} + +async function timeIndexPhase( + options: IndexCodeGraphyWorkspaceOptions, + phase: string, + run: () => Promise, + createContext: (result: T) => Record = () => ({}), +): Promise { + const startedAt = performance.now(); + const result = await run(); + emitIndexPhaseCompleted(options, phase, performance.now() - startedAt, createContext(result)); + return result; +} + +function timeIndexPhaseSync( + options: IndexCodeGraphyWorkspaceOptions, + phase: string, + run: () => T, + createContext: (result: T) => Record = () => ({}), +): T { + const startedAt = performance.now(); + const result = run(); + emitIndexPhaseCompleted(options, phase, performance.now() - startedAt, createContext(result)); + return result; +} + export async function indexCodeGraphyWorkspace( options: IndexCodeGraphyWorkspaceOptions, ): Promise { @@ -39,54 +82,103 @@ export async function indexCodeGraphyWorkspace( const cache = createEmptyWorkspaceAnalysisCache(); const settings = createEffectiveIndexSettings(workspaceRoot, options); const disabledPlugins = createDisabledPluginSet(settings, options.disabledPlugins); - const { registry, loadedPackagePlugins } = await createWorkspaceIndexRegistry( + const { registry, loadedPackagePlugins } = await timeIndexPhase( options, - settings, - workspaceRoot, - disabledPlugins, + 'load-plugins', + () => createWorkspaceIndexRegistry( + options, + settings, + workspaceRoot, + disabledPlugins, + ), + result => ({ + loadedPackagePlugins: result.loadedPackagePlugins.length, + registeredPlugins: result.registry.list().length, + }), ); - await registry.initializeAll(workspaceRoot); + await timeIndexPhase( + options, + 'initialize-plugins', + () => registry.initializeAll(workspaceRoot), + () => ({ registeredPlugins: registry.list().length }), + ); - const discoveryResult = await discoverWorkspaceIndexFiles({ - disabledPlugins, - discovery, + const discoveryResult = await timeIndexPhase( options, - registry, - settings, - workspaceRoot, - }); - const analysisResult = await analyzeWorkspaceIndexFiles({ - cache, - discovery, - discoveryResult, + 'discover-files', + () => discoverWorkspaceIndexFiles({ + disabledPlugins, + discovery, + options, + registry, + settings, + workspaceRoot, + }), + result => ({ + files: result.files.length, + directories: result.directories?.length ?? 0, + totalFound: result.totalFound ?? result.files.length, + limitReached: result.limitReached, + }), + ); + const analysisResult = await timeIndexPhase( options, - registry, - disabledPlugins, - workspaceRoot, - }); + 'analyze-files', + () => analyzeWorkspaceIndexFiles({ + cache, + discovery, + discoveryResult, + options, + registry, + disabledPlugins, + workspaceRoot, + }), + result => ({ + files: discoveryResult.files.length, + cacheHits: result.cacheHits, + cacheMisses: result.cacheMisses, + }), + ); - const graph = buildWorkspacePipelineGraphFromAnalysis({ - cacheFiles: cache.files, - churnCounts: {}, - directoryPaths: discoveryResult.directories ?? [], - gitIgnoredPaths: discoveryResult.gitIgnoredPaths ?? [], - disabledPlugins, - fileAnalysis: analysisResult.fileAnalysis, - getPluginForFile: absolutePath => registry.getPluginForFile(absolutePath), - showOrphans: true, - workspaceRoot, - }); + const graph = timeIndexPhaseSync( + options, + 'build-graph', + () => buildWorkspacePipelineGraphFromAnalysis({ + cacheFiles: cache.files, + churnCounts: {}, + directoryPaths: discoveryResult.directories ?? [], + gitIgnoredPaths: discoveryResult.gitIgnoredPaths ?? [], + disabledPlugins, + fileAnalysis: analysisResult.fileAnalysis, + getPluginForFile: absolutePath => registry.getPluginForFile(absolutePath), + showOrphans: true, + workspaceRoot, + }), + result => ({ + nodes: result.nodes.length, + edges: result.edges.length, + }), + ); registry.notifyPostAnalyze(graph, disabledPlugins); registry.notifyWorkspaceReady(graph, disabledPlugins); - saveWorkspaceAnalysisDatabaseCache(workspaceRoot, cache); - persistWorkspaceIndexMetadata({ - loadedPackagePlugins, - registry, - settings, - workspaceRoot, - }); + timeIndexPhaseSync( + options, + 'save-graph-cache', + () => saveWorkspaceAnalysisDatabaseCache(workspaceRoot, cache), + () => ({ files: Object.keys(cache.files).length }), + ); + timeIndexPhaseSync( + options, + 'persist-metadata', + () => persistWorkspaceIndexMetadata({ + loadedPackagePlugins, + registry, + settings, + workspaceRoot, + }), + ); options.logInfo?.(`[CodeGraphy] Graph built: ${graph.nodes.length} nodes, ${graph.edges.length} edges`); return { diff --git a/packages/core/src/workspace/requestIndexing.ts b/packages/core/src/workspace/requestIndexing.ts index 113dc4ac5..3d557f529 100644 --- a/packages/core/src/workspace/requestIndexing.ts +++ b/packages/core/src/workspace/requestIndexing.ts @@ -33,7 +33,10 @@ export async function requestCodeGraphyIndexWorkspace( workspaceRoot, }, })); - const result = await indexCodeGraphyWorkspace({ workspaceRoot }); + const result = await indexCodeGraphyWorkspace({ + workspaceRoot, + ...(input.diagnostics ? { diagnostics: input.diagnostics } : {}), + }); const graphCache = path.relative(result.workspaceRoot, result.graphCachePath); input.diagnostics?.emit(createDiagnosticEvent({ diff --git a/packages/core/tests/diagnostics/events.test.ts b/packages/core/tests/diagnostics/events.test.ts index 57dab706f..d648e120d 100644 --- a/packages/core/tests/diagnostics/events.test.ts +++ b/packages/core/tests/diagnostics/events.test.ts @@ -55,6 +55,18 @@ describe('diagnostics/events', () => { edges: 20, }, })).toBe('[CodeGraphy] Indexing complete: 4 files, 12 nodes, 20 edges, operation=index-1'); + + expect(formatDiagnosticEventLine({ + area: 'indexing', + event: 'phase-completed', + context: { + phase: 'analyze-files', + durationMs: 2750, + files: 42, + cacheHits: 20, + cacheMisses: 22, + }, + })).toBe('[CodeGraphy] Indexing phase complete: phase=analyze-files, durationMs=2750, files=42, cacheHits=20, cacheMisses=22'); }); it('normalizes non-JSON primitive context values into readable strings', () => { diff --git a/packages/core/tests/workspace/coreBacked.test.ts b/packages/core/tests/workspace/coreBacked.test.ts index db391030e..03d36762a 100644 --- a/packages/core/tests/workspace/coreBacked.test.ts +++ b/packages/core/tests/workspace/coreBacked.test.ts @@ -77,6 +77,35 @@ describe('core-backed CodeGraphy Workspace commands', () => { }), }), ])); + expect(diagnostics.events).toEqual(expect.arrayContaining([ + expect.objectContaining({ + area: 'indexing', + event: 'phase-completed', + context: expect.objectContaining({ + phase: 'discover-files', + durationMs: expect.any(Number), + files: 2, + }), + }), + expect.objectContaining({ + area: 'indexing', + event: 'phase-completed', + context: expect.objectContaining({ + phase: 'analyze-files', + durationMs: expect.any(Number), + files: 2, + }), + }), + expect.objectContaining({ + area: 'indexing', + event: 'phase-completed', + context: expect.objectContaining({ + phase: 'build-graph', + durationMs: expect.any(Number), + nodes: 2, + }), + }), + ])); }); it('emits factual verbose diagnostics for status requests', async () => { diff --git a/scripts/performance/measure-codegraphy-monorepo.mjs b/scripts/performance/measure-codegraphy-monorepo.mjs index a116159a7..d72aa8d08 100644 --- a/scripts/performance/measure-codegraphy-monorepo.mjs +++ b/scripts/performance/measure-codegraphy-monorepo.mjs @@ -61,6 +61,53 @@ function parseMemorySummary(logText) { }; } +function parseLogScalar(value) { + if (/^-?\d+(?:\.\d+)?$/.test(value)) { + return Number(value); + } + + if (value === 'true') { + return true; + } + + if (value === 'false') { + return false; + } + + return value; +} + +function parsePhaseDetail(detail) { + const separatorIndex = detail.indexOf('='); + if (separatorIndex < 0) { + return {}; + } + + const key = detail.slice(0, separatorIndex).trim(); + const value = detail.slice(separatorIndex + 1).trim(); + if (!key) { + return {}; + } + + return { [key]: parseLogScalar(value) }; +} + +function parseIndexPhaseSummaries(logText) { + const indexPhases = {}; + const phaseLinePattern = /^\[CodeGraphy\] Indexing phase complete: (.+)$/gm; + for (const match of logText.matchAll(phaseLinePattern)) { + const phaseMetrics = match[1] + .split(',') + .map(detail => parsePhaseDetail(detail)) + .reduce((merged, detail) => ({ ...merged, ...detail }), {}); + if (typeof phaseMetrics.phase === 'string') { + indexPhases[phaseMetrics.phase] = phaseMetrics; + } + } + + return Object.keys(indexPhases).length > 0 ? { indexPhases } : {}; +} + async function readGraphCacheBytes(workspacePath, graphCache) { if (typeof graphCache !== 'string' || graphCache.length === 0) { return {}; @@ -82,6 +129,7 @@ export async function readColdIndexLogMetrics({ logPath, workspacePath }) { return { ...parseTimeSummary(logText), ...parseMemorySummary(logText), + ...parseIndexPhaseSummaries(logText), ...(typeof result.files === 'number' ? { fileCount: result.files } : {}), ...(typeof result.nodes === 'number' ? { nodeCount: result.nodes } : {}), ...(typeof result.edges === 'number' ? { edgeCount: result.edges } : {}), diff --git a/tests/scripts/measure-codegraphy-monorepo.test.mjs b/tests/scripts/measure-codegraphy-monorepo.test.mjs index e7bc53eeb..7fe896eb4 100644 --- a/tests/scripts/measure-codegraphy-monorepo.test.mjs +++ b/tests/scripts/measure-codegraphy-monorepo.test.mjs @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import test from 'node:test'; @@ -39,3 +39,57 @@ test('performance runner writes bounded JSON metrics', async () => { await rm(tempDir, { recursive: true, force: true }); } }); + +test('performance runner parses verbose indexing phase timings', async () => { + const tempDir = await mkdtemp(path.join(tmpdir(), 'codegraphy-perf-phases-')); + const logPath = path.join(tempDir, 'index.log'); + + try { + await writeFile(logPath, [ + '[CodeGraphy] Indexing phase complete: phase=discover-files, durationMs=1900, files=2367, directories=3180, totalFound=2367, limitReached=false', + '[CodeGraphy] Indexing phase complete: phase=analyze-files, durationMs=88321, files=2367, cacheHits=0, cacheMisses=2367', + '[CodeGraphy] Indexing phase complete: phase=build-graph, durationMs=62, nodes=5078, edges=9114', + ' 213.93 real 93.33 user 25.41 sys', + '{', + ' "graphCache": ".codegraphy/graph.lbug",', + ' "files": 2367,', + ' "nodes": 5078,', + ' "edges": 9114', + '}', + '', + ].join('\n')); + + const moduleUrl = pathToFileURL( + path.resolve('scripts/performance/measure-codegraphy-monorepo.mjs'), + ).href; + const { readColdIndexLogMetrics } = await import(moduleUrl); + const metrics = await readColdIndexLogMetrics({ + logPath, + workspacePath: tempDir, + }); + + assert.deepEqual(metrics.indexPhases['discover-files'], { + phase: 'discover-files', + durationMs: 1900, + files: 2367, + directories: 3180, + totalFound: 2367, + limitReached: false, + }); + assert.deepEqual(metrics.indexPhases['analyze-files'], { + phase: 'analyze-files', + durationMs: 88321, + files: 2367, + cacheHits: 0, + cacheMisses: 2367, + }); + assert.deepEqual(metrics.indexPhases['build-graph'], { + phase: 'build-graph', + durationMs: 62, + nodes: 5078, + edges: 9114, + }); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } +}); From 1de00982b9658a64639d1482abf3d4cfce41b038 Mon Sep 17 00:00:00 2001 From: joesobo Date: Mon, 22 Jun 2026 15:26:23 -0700 Subject: [PATCH 005/192] perf: persist canonical Graph Cache rows Reduce cold CodeGraphy monorepo indexing from 213.93s to 111.03s by persisting FileAnalysis as the canonical cache row and deriving snapshot symbols/relations from it. Graph Cache save dropped from 122757ms to 15139ms and cache size from 64638976 bytes to 18153472 bytes. --- docs/performance/codegraphy-monorepo.md | 21 ++++ .../2026-06-22-codegraphy-performance.md | 2 + .../src/graphCache/database/io/connection.ts | 40 +++++++ .../core/src/graphCache/database/io/save.ts | 8 +- .../src/graphCache/database/query/write.ts | 93 ++++++++------- .../core/src/graphCache/database/snapshot.ts | 35 ++++-- .../graphCache/database/query/write.test.ts | 111 +++++++++++------- .../graphCache/database/snapshot.test.ts | 36 ++++++ 8 files changed, 245 insertions(+), 101 deletions(-) diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index 785591179..85e70c8bf 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -41,6 +41,27 @@ The first measured bottlenecks are Graph Cache persistence and file/plugin analysis. Graph construction is not currently a cold-load bottleneck for this workspace. +Canonical Graph Cache write: + +- Command: `node packages/core/bin/codegraphy.js --verbose index .` +- Wall time: `111.03s` +- Files: `2367` +- Nodes: `5078` +- Edges: `9110` +- File discovery: `1924ms` +- File analysis: `92850ms` +- Graph build: `63ms` +- Graph Cache save: `15139ms` +- Graph Cache size: `18MB` +- Max resident set: `3133194240` bytes +- Peak memory footprint: `4372432256` bytes + +Result: + +- Cold index wall time improved from `213.93s` to `111.03s`. +- Graph Cache save improved from `122757ms` to `15139ms`. +- Graph Cache size improved from `64638976` bytes to `18153472` bytes. + Full test baseline: - `pnpm run test`: `1523.98s` wall time diff --git a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md index 0c5627d51..7aa0e5e3e 100644 --- a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md +++ b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md @@ -28,6 +28,8 @@ - Phase-instrumented cold monorepo CLI indexing took `213.93s` wall time for 2367 files, 5078 nodes, and 9114 edges. - Phase split: plugin load `542ms`, plugin initialization `1ms`, file discovery `1900ms`, file analysis `88321ms`, graph build `62ms`, Graph Cache save `122757ms`, metadata persistence `4ms`. - Measured hot spots: Graph Cache persistence and file/plugin analysis. Graph construction is not a cold-load bottleneck on this workspace. +- Canonical Graph Cache write iteration: cold indexing improved to `111.03s` wall time; Graph Cache save improved to `15139ms`; Graph Cache size improved from `64638976` bytes to `18153472` bytes. +- Remaining measured cold-load hot spot: file/plugin analysis at `92850ms` on the canonical-cache run. - Raw logs are ignored under `reports/performance/`; commit only scripts and bounded summaries under `docs/performance/`. ## Success Metrics diff --git a/packages/core/src/graphCache/database/io/connection.ts b/packages/core/src/graphCache/database/io/connection.ts index 3341dc59d..ed15132f7 100644 --- a/packages/core/src/graphCache/database/io/connection.ts +++ b/packages/core/src/graphCache/database/io/connection.ts @@ -30,6 +30,46 @@ export async function runStatementAsync(connection: lb.Connection, statement: st closeQueryResults(result); } +export function prepareStatementSync( + connection: lb.Connection, + statement: string, +): lb.PreparedStatement { + const preparedStatement = connection.prepareSync(statement); + if (!preparedStatement.isSuccess()) { + throw new Error(preparedStatement.getErrorMessage()); + } + return preparedStatement; +} + +export async function prepareStatementAsync( + connection: lb.Connection, + statement: string, +): Promise { + const preparedStatement = await connection.prepare(statement); + if (!preparedStatement.isSuccess()) { + throw new Error(preparedStatement.getErrorMessage()); + } + return preparedStatement; +} + +export function executeStatementSync( + connection: lb.Connection, + preparedStatement: lb.PreparedStatement, + params: Record, +): void { + const result = connection.executeSync(preparedStatement, params); + closeQueryResults(result); +} + +export async function executeStatementAsync( + connection: lb.Connection, + preparedStatement: lb.PreparedStatement, + params: Record, +): Promise { + const result = await connection.execute(preparedStatement, params); + closeQueryResults(result); +} + export function readRowsSync(connection: lb.Connection, statement: string): FileAnalysisRow[] { const result = connection.querySync(statement); diff --git a/packages/core/src/graphCache/database/io/save.ts b/packages/core/src/graphCache/database/io/save.ts index 0cbf68549..4f550ac52 100644 --- a/packages/core/src/graphCache/database/io/save.ts +++ b/packages/core/src/graphCache/database/io/save.ts @@ -5,6 +5,8 @@ import type { IWorkspaceAnalysisCache } from '../../../analysis/cache'; import { runStatementAsync, runStatementSync, withConnection, withConnectionAsync } from './connection'; import { ensureDatabaseDirectory, getWorkspaceAnalysisDatabasePath } from './paths'; import { + createWorkspaceAnalysisCacheWriter, + createWorkspaceAnalysisCacheWriterAsync, persistAnalysisEntry, persistAnalysisEntryAsync, sortedCacheEntries, @@ -51,8 +53,9 @@ export function saveWorkspaceAnalysisDatabaseCache( runStatementSync(connection, 'MATCH (entry:Symbol) DELETE entry'); runStatementSync(connection, 'MATCH (entry:Relation) DELETE entry'); + const writer = createWorkspaceAnalysisCacheWriter(connection); for (const [filePath, entry] of sortedCacheEntries(cache)) { - persistAnalysisEntry(connection, filePath, entry); + persistAnalysisEntry(writer, filePath, entry); } }); replaceDatabaseCache(tempDatabasePath, databasePath); @@ -84,6 +87,7 @@ export async function saveWorkspaceAnalysisDatabaseCacheAsync( await runStatementAsync(connection, 'MATCH (entry:FileAnalysis) DELETE entry'); await runStatementAsync(connection, 'MATCH (entry:Symbol) DELETE entry'); await runStatementAsync(connection, 'MATCH (entry:Relation) DELETE entry'); + const writer = await createWorkspaceAnalysisCacheWriterAsync(connection); let current = 0; let statementsSinceYield = 0; @@ -96,7 +100,7 @@ export async function saveWorkspaceAnalysisDatabaseCacheAsync( }; for (const [filePath, entry] of entries) { - await persistAnalysisEntryAsync(connection, filePath, entry, yieldAfterStatement); + await persistAnalysisEntryAsync(writer, filePath, entry, yieldAfterStatement); current += 1; options.onProgress?.({ current, total }); } diff --git a/packages/core/src/graphCache/database/query/write.ts b/packages/core/src/graphCache/database/query/write.ts index ac6b9d66d..29a18a457 100644 --- a/packages/core/src/graphCache/database/query/write.ts +++ b/packages/core/src/graphCache/database/query/write.ts @@ -1,26 +1,29 @@ import type * as lb from '@ladybugdb/core'; -import type { IAnalysisSymbol } from '@codegraphy-dev/plugin-api'; import type { IWorkspaceAnalysisCache } from '../../../analysis/cache'; -import { runStatementAsync, runStatementSync } from '../io/connection'; -import { createRelationStatement } from '../relation/statement'; +import { + executeStatementAsync, + executeStatementSync, + prepareStatementAsync, + prepareStatementSync, +} from '../io/connection'; -function escapeCypherString(value: string): string { - return JSON.stringify(value); -} +const CREATE_FILE_ANALYSIS_STATEMENT = 'CREATE (entry:FileAnalysis {filePath: $filePath, mtime: $mtime, size: $size, analysis: $analysis})'; -function serializeJson(value: unknown): string { - return JSON.stringify(value ?? null); +export interface WorkspaceAnalysisCacheWriter { + connection: lb.Connection; + fileAnalysisStatement: lb.PreparedStatement; } -function createFileAnalysisStatement( +function createFileAnalysisParams( filePath: string, entry: IWorkspaceAnalysisCache['files'][string], -): string { - return `CREATE (entry:FileAnalysis {filePath: ${escapeCypherString(filePath)}, mtime: ${entry.mtime}, size: ${entry.size ?? 0}, analysis: ${escapeCypherString(JSON.stringify(entry.analysis))}})`; -} - -function createSymbolStatement(symbol: IAnalysisSymbol): string { - return `CREATE (entry:Symbol {symbolId: ${escapeCypherString(symbol.id)}, filePath: ${escapeCypherString(symbol.filePath)}, name: ${escapeCypherString(symbol.name)}, kind: ${escapeCypherString(symbol.kind)}, signature: ${escapeCypherString(symbol.signature ?? '')}, rangeJson: ${escapeCypherString(serializeJson(symbol.range))}, metadataJson: ${escapeCypherString(serializeJson(symbol.metadata))}})`; +): Record { + return { + filePath, + mtime: entry.mtime ?? 0, + size: entry.size ?? 0, + analysis: JSON.stringify(entry.analysis), + }; } export function sortedCacheEntries( @@ -29,48 +32,54 @@ export function sortedCacheEntries( return Object.entries(cache.files).sort(([left], [right]) => left.localeCompare(right)); } -export function persistAnalysisEntry( +export function createWorkspaceAnalysisCacheWriter( connection: lb.Connection, +): WorkspaceAnalysisCacheWriter { + return { + connection, + fileAnalysisStatement: prepareStatementSync(connection, CREATE_FILE_ANALYSIS_STATEMENT), + }; +} + +export async function createWorkspaceAnalysisCacheWriterAsync( + connection: lb.Connection, +): Promise { + const fileAnalysisStatement = await prepareStatementAsync(connection, CREATE_FILE_ANALYSIS_STATEMENT); + return { + connection, + fileAnalysisStatement, + }; +} + +export function persistAnalysisEntry( + writer: WorkspaceAnalysisCacheWriter, filePath: string, entry: IWorkspaceAnalysisCache['files'][string], ): void { - runStatementSync(connection, createFileAnalysisStatement(filePath, entry)); - - for (const symbol of entry.analysis.symbols ?? []) { - runStatementSync(connection, createSymbolStatement(symbol)); - } - - for (const [relationIndex, relation] of (entry.analysis.relations ?? []).entries()) { - runStatementSync(connection, createRelationStatement(filePath, relation, relationIndex)); - } + executeStatementSync(writer.connection, writer.fileAnalysisStatement, createFileAnalysisParams(filePath, entry)); } -async function runStatementAndYield( - connection: lb.Connection, - statement: string, +async function executeStatementAndYield( + writer: WorkspaceAnalysisCacheWriter, + preparedStatement: lb.PreparedStatement, + params: Record, afterStatement: () => Promise, ): Promise { - await runStatementAsync(connection, statement); + await executeStatementAsync(writer.connection, preparedStatement, params); await afterStatement(); } export async function persistAnalysisEntryAsync( - connection: lb.Connection, + writer: WorkspaceAnalysisCacheWriter, filePath: string, entry: IWorkspaceAnalysisCache['files'][string], afterStatement: () => Promise, ): Promise { - await runStatementAndYield(connection, createFileAnalysisStatement(filePath, entry), afterStatement); - - for (const symbol of entry.analysis.symbols ?? []) { - await runStatementAndYield(connection, createSymbolStatement(symbol), afterStatement); - } + await executeStatementAndYield( + writer, + writer.fileAnalysisStatement, + createFileAnalysisParams(filePath, entry), + afterStatement, + ); - for (const [relationIndex, relation] of (entry.analysis.relations ?? []).entries()) { - await runStatementAndYield( - connection, - createRelationStatement(filePath, relation, relationIndex), - afterStatement, - ); - } } diff --git a/packages/core/src/graphCache/database/snapshot.ts b/packages/core/src/graphCache/database/snapshot.ts index 31026ef76..308b10ee5 100644 --- a/packages/core/src/graphCache/database/snapshot.ts +++ b/packages/core/src/graphCache/database/snapshot.ts @@ -27,6 +27,14 @@ export interface WorkspaceAnalysisDatabaseSnapshot { relations: IAnalysisRelation[]; } +function readSymbolsFromFileAnalysis(files: WorkspaceAnalysisDatabaseSnapshot['files']): IAnalysisSymbol[] { + return files.flatMap(file => file.analysis.symbols ?? []); +} + +function readRelationsFromFileAnalysis(files: WorkspaceAnalysisDatabaseSnapshot['files']): IAnalysisRelation[] { + return files.flatMap(file => file.analysis.relations ?? []); +} + export function readWorkspaceAnalysisDatabaseSnapshot( workspaceRoot: string, ): WorkspaceAnalysisDatabaseSnapshot { @@ -40,20 +48,23 @@ export function readWorkspaceAnalysisDatabaseSnapshot( const fileRows = readRowsSync(connection, FILE_ANALYSIS_ROWS_QUERY); const symbolRows = readRowsSync(connection, SYMBOL_ROWS_QUERY) as SymbolRow[]; const relationRows = readRowsSync(connection, RELATION_ROWS_QUERY) as RelationRow[]; + const files = fileRows.flatMap((row) => { + const entry = createSnapshotFileEntry(row); + return entry ? [entry] : []; + }); + const symbols = symbolRows.flatMap((row) => { + const entry = createSnapshotSymbolEntry(row); + return entry ? [entry] : []; + }); + const relations = relationRows.flatMap((row) => { + const entry = createSnapshotRelationEntry(row); + return entry ? [entry] : []; + }); return { - files: fileRows.flatMap((row) => { - const entry = createSnapshotFileEntry(row); - return entry ? [entry] : []; - }), - symbols: symbolRows.flatMap((row) => { - const entry = createSnapshotSymbolEntry(row); - return entry ? [entry] : []; - }), - relations: relationRows.flatMap((row) => { - const entry = createSnapshotRelationEntry(row); - return entry ? [entry] : []; - }), + files, + symbols: symbols.length > 0 ? symbols : readSymbolsFromFileAnalysis(files), + relations: relations.length > 0 ? relations : readRelationsFromFileAnalysis(files), }; }); } catch (error) { diff --git a/packages/core/tests/graphCache/database/query/write.test.ts b/packages/core/tests/graphCache/database/query/write.test.ts index 2a2958a3d..f92e2efec 100644 --- a/packages/core/tests/graphCache/database/query/write.test.ts +++ b/packages/core/tests/graphCache/database/query/write.test.ts @@ -1,10 +1,11 @@ import { describe, expect, it, vi } from 'vitest'; import { + createWorkspaceAnalysisCacheWriter, persistAnalysisEntry, sortedCacheEntries, + type WorkspaceAnalysisCacheWriter, } from '../../../../src/graphCache/database/query/write'; import * as cacheConnectionModule from '../../../../src/graphCache/database/io/connection'; -import * as relationStatementModule from '../../../../src/graphCache/database/relation/statement'; describe('graphCache/database/writeStatements', () => { it('sorts cache entries by file path', () => { @@ -19,63 +20,78 @@ describe('graphCache/database/writeStatements', () => { expect(entries.map(([filePath]) => filePath)).toEqual(['src/a.ts', 'src/z.ts']); }); - it('persists file, symbol, and relation statements in order', () => { - const runStatementSyncSpy = vi - .spyOn(cacheConnectionModule, 'runStatementSync') + it('prepares the canonical file analysis write statement once per cache write session', () => { + const fileStatement = {}; + const prepareStatementSyncSpy = vi + .spyOn(cacheConnectionModule, 'prepareStatementSync') + .mockReturnValueOnce(fileStatement as never); + + expect(createWorkspaceAnalysisCacheWriter({} as never)).toEqual({ + connection: {}, + fileAnalysisStatement: fileStatement, + }); + + expect(prepareStatementSyncSpy).toHaveBeenCalledTimes(1); + expect(prepareStatementSyncSpy).toHaveBeenNthCalledWith(1, {}, expect.stringContaining('filePath: $filePath')); + }); + + it('persists one canonical file analysis row through a prepared statement', () => { + const executeStatementSyncSpy = vi + .spyOn(cacheConnectionModule, 'executeStatementSync') .mockImplementation(() => []); - const createRelationStatementSpy = vi - .spyOn(relationStatementModule, 'createRelationStatement') - .mockReturnValue('RELATION'); + const writer = { + connection: {} as never, + fileAnalysisStatement: { kind: 'file' } as never, + } satisfies WorkspaceAnalysisCacheWriter; + const analysis = { + symbols: [ + { + id: 'symbol-1', + filePath: '/workspace/src/app.ts', + name: 'App', + kind: 'class', + }, + ], + relations: [ + { + filePath: '/workspace/src/app.ts', + fromFilePath: '/workspace/src/app.ts', + kind: 'import', + sourceId: 'plugin:import', + }, + ], + }; persistAnalysisEntry( - {} as never, + writer, '/workspace/src/app.ts', { mtime: 10, size: 20, - analysis: { - symbols: [ - { - id: 'symbol-1', - filePath: '/workspace/src/app.ts', - name: 'App', - kind: 'class', - }, - ], - relations: [ - { - filePath: '/workspace/src/app.ts', - fromFilePath: '/workspace/src/app.ts', - kind: 'import', - sourceId: 'plugin:import', - }, - ], - }, + analysis, } as never, ); - expect(runStatementSyncSpy).toHaveBeenNthCalledWith(1, {}, expect.stringContaining('CREATE (entry:FileAnalysis')); - expect(runStatementSyncSpy).toHaveBeenNthCalledWith(2, {}, expect.stringContaining('CREATE (entry:Symbol')); - expect(createRelationStatementSpy).toHaveBeenCalledWith( - '/workspace/src/app.ts', - { - filePath: '/workspace/src/app.ts', - fromFilePath: '/workspace/src/app.ts', - kind: 'import', - sourceId: 'plugin:import', - }, - 0, - ); - expect(runStatementSyncSpy).toHaveBeenNthCalledWith(3, {}, 'RELATION'); + expect(executeStatementSyncSpy).toHaveBeenCalledTimes(1); + expect(executeStatementSyncSpy).toHaveBeenNthCalledWith(1, {}, { kind: 'file' }, { + filePath: '/workspace/src/app.ts', + mtime: 10, + size: 20, + analysis: JSON.stringify(analysis), + }); }); - it('skips symbol and relation writes when the analysis omits them', () => { - const runStatementSyncSpy = vi - .spyOn(cacheConnectionModule, 'runStatementSync') + it('persists only the canonical row when the analysis omits symbols and relations', () => { + const executeStatementSyncSpy = vi + .spyOn(cacheConnectionModule, 'executeStatementSync') .mockImplementation(() => []); + const writer = { + connection: {} as never, + fileAnalysisStatement: { kind: 'file' } as never, + } satisfies WorkspaceAnalysisCacheWriter; persistAnalysisEntry( - {} as never, + writer, '/workspace/src/app.ts', { mtime: 10, @@ -84,7 +100,12 @@ describe('graphCache/database/writeStatements', () => { } as never, ); - expect(runStatementSyncSpy).toHaveBeenCalledTimes(1); - expect(runStatementSyncSpy).toHaveBeenCalledWith({}, expect.stringContaining('CREATE (entry:FileAnalysis')); + expect(executeStatementSyncSpy).toHaveBeenCalledTimes(1); + expect(executeStatementSyncSpy).toHaveBeenCalledWith(writer.connection, writer.fileAnalysisStatement, { + filePath: '/workspace/src/app.ts', + mtime: 10, + size: 20, + analysis: JSON.stringify({}), + }); }); }); diff --git a/packages/core/tests/graphCache/database/snapshot.test.ts b/packages/core/tests/graphCache/database/snapshot.test.ts index 49aba0c03..7aaa6ddb5 100644 --- a/packages/core/tests/graphCache/database/snapshot.test.ts +++ b/packages/core/tests/graphCache/database/snapshot.test.ts @@ -85,6 +85,42 @@ describe('pipeline/database/cache/snapshot', () => { expect(readRowsSync).toHaveBeenNthCalledWith(3, 'connection', RELATION_ROWS_QUERY); }); + it('derives structured symbols and relations from file analysis rows when dedicated rows are absent', () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(withConnection).mockImplementation((_path, callback) => callback('connection' as never)); + vi.mocked(readRowsSync) + .mockReturnValueOnce([{ id: 'file-1' }] as never) + .mockReturnValueOnce([]) + .mockReturnValueOnce([]); + vi.mocked(createSnapshotFileEntry).mockReturnValueOnce({ + filePath: 'src/file.ts', + mtime: 1, + analysis: { + filePath: 'src/file.ts', + symbols: [ + { id: 'symbol-1', filePath: 'src/file.ts', name: 'render', kind: 'function' }, + ], + relations: [ + { kind: 'import', sourceId: 'source', fromFilePath: 'src/file.ts' }, + ], + }, + } as never); + + expect(readWorkspaceAnalysisDatabaseSnapshot('/workspace')).toEqual({ + files: [{ + filePath: 'src/file.ts', + mtime: 1, + analysis: { + filePath: 'src/file.ts', + symbols: [{ id: 'symbol-1', filePath: 'src/file.ts', name: 'render', kind: 'function' }], + relations: [{ kind: 'import', sourceId: 'source', fromFilePath: 'src/file.ts' }], + }, + }], + symbols: [{ id: 'symbol-1', filePath: 'src/file.ts', name: 'render', kind: 'function' }], + relations: [{ kind: 'import', sourceId: 'source', fromFilePath: 'src/file.ts' }], + }); + }); + it('warns and falls back to an empty snapshot when reading the database fails', () => { vi.mocked(fs.existsSync).mockReturnValue(true); const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined); From 03290a6d252d6ce610a79976a73f1f1ba3e79990 Mon Sep 17 00:00:00 2001 From: joesobo Date: Mon, 22 Jun 2026 15:32:33 -0700 Subject: [PATCH 006/192] perf: reuse content during index analysis Share file content reads between pre-analysis and cold file analysis. On the CodeGraphy monorepo cold benchmark, wall time moved from 111.03s to 104.81s and analyze-files moved from 92850ms to 87297ms. --- docs/performance/codegraphy-monorepo.md | 14 ++++ .../2026-06-22-codegraphy-performance.md | 3 +- packages/core/src/indexing/analysis.ts | 23 +++++- packages/core/tests/indexing/analysis.test.ts | 78 +++++++++++++++++++ 4 files changed, 115 insertions(+), 3 deletions(-) create mode 100644 packages/core/tests/indexing/analysis.test.ts diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index 85e70c8bf..0a31f7cd7 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -62,6 +62,20 @@ Result: - Graph Cache save improved from `122757ms` to `15139ms`. - Graph Cache size improved from `64638976` bytes to `18153472` bytes. +Shared content read cache: + +- Command: `node packages/core/bin/codegraphy.js --verbose index .` +- Wall time: `104.81s` +- File analysis: `87297ms` +- Graph Cache save: `14632ms` +- Graph Cache size: `18157568` bytes + +Result: + +- Cold index wall time improved from `111.03s` to `104.81s`. +- File analysis improved from `92850ms` to `87297ms` by reusing file content + read during pre-analysis. + Full test baseline: - `pnpm run test`: `1523.98s` wall time diff --git a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md index 7aa0e5e3e..b386bb187 100644 --- a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md +++ b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md @@ -29,7 +29,8 @@ - Phase split: plugin load `542ms`, plugin initialization `1ms`, file discovery `1900ms`, file analysis `88321ms`, graph build `62ms`, Graph Cache save `122757ms`, metadata persistence `4ms`. - Measured hot spots: Graph Cache persistence and file/plugin analysis. Graph construction is not a cold-load bottleneck on this workspace. - Canonical Graph Cache write iteration: cold indexing improved to `111.03s` wall time; Graph Cache save improved to `15139ms`; Graph Cache size improved from `64638976` bytes to `18153472` bytes. -- Remaining measured cold-load hot spot: file/plugin analysis at `92850ms` on the canonical-cache run. +- Shared content read cache iteration: cold indexing improved to `104.81s`; file/plugin analysis improved to `87297ms`; Graph Cache save stayed stable at `14632ms`. +- Remaining measured cold-load hot spot: file/plugin analysis at `87297ms` on the shared-content run. - Raw logs are ignored under `reports/performance/`; commit only scripts and bounded summaries under `docs/performance/`. ## Success Metrics diff --git a/packages/core/src/indexing/analysis.ts b/packages/core/src/indexing/analysis.ts index 7c4613972..10330f5db 100644 --- a/packages/core/src/indexing/analysis.ts +++ b/packages/core/src/indexing/analysis.ts @@ -8,6 +8,23 @@ import { preAnalyzeCoreTreeSitterFiles } from '../treeSitter/core'; import type { IndexCodeGraphyWorkspaceOptions } from './contracts'; import { getFileStat } from './fileStat'; +function createCachedWorkspaceFileContentReader( + discovery: FileDiscovery, +): (file: IDiscoveryResult['files'][number]) => Promise { + const contentByRelativePath = new Map>(); + + return (file) => { + const cached = contentByRelativePath.get(file.relativePath); + if (cached) { + return cached; + } + + const content = discovery.readContent(file); + contentByRelativePath.set(file.relativePath, content); + return content; + }; +} + export async function analyzeWorkspaceIndexFiles(input: { cache: IWorkspaceAnalysisCache; discovery: FileDiscovery; @@ -17,6 +34,8 @@ export async function analyzeWorkspaceIndexFiles(input: { registry: CorePluginRegistry; workspaceRoot: string; }) { + const readContent = createCachedWorkspaceFileContentReader(input.discovery); + return analyzeWorkspacePipelineFiles({ analyzeFile: async (absolutePath, content, rootPath) => input.registry.analyzeFileResult( @@ -52,11 +71,11 @@ export async function analyzeWorkspaceIndexFiles(input: { input.disabledPlugins, ); }, - readContent: file => input.discovery.readContent(file), + readContent, }, signal, ), - readContent: file => input.discovery.readContent(file), + readContent, signal: input.options.signal, workspaceRoot: input.workspaceRoot, }); diff --git a/packages/core/tests/indexing/analysis.test.ts b/packages/core/tests/indexing/analysis.test.ts new file mode 100644 index 000000000..f8dadd4e4 --- /dev/null +++ b/packages/core/tests/indexing/analysis.test.ts @@ -0,0 +1,78 @@ +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createEmptyWorkspaceAnalysisCache } from '../../src/analysis/cache'; +import type { IDiscoveredFile } from '../../src/discovery/contracts'; +import { analyzeWorkspaceIndexFiles } from '../../src/indexing/analysis'; + +const tempRoots = new Set(); + +async function createWorkspaceRoot(): Promise { + const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-index-analysis-')); + tempRoots.add(workspaceRoot); + return workspaceRoot; +} + +function createDiscoveredFile(workspaceRoot: string, relativePath: string): IDiscoveredFile { + const extension = path.extname(relativePath); + return { + absolutePath: path.join(workspaceRoot, relativePath), + extension, + name: path.basename(relativePath), + relativePath, + }; +} + +afterEach(async () => { + await Promise.all([...tempRoots].map(workspaceRoot => + fs.rm(workspaceRoot, { recursive: true, force: true }), + )); + tempRoots.clear(); +}); + +describe('indexing/analysis', () => { + it('reuses pre-analysis file content for cold file analysis', async () => { + const workspaceRoot = await createWorkspaceRoot(); + await fs.mkdir(path.join(workspaceRoot, 'src'), { recursive: true }); + await fs.writeFile(path.join(workspaceRoot, 'src/a.txt'), 'a -> b\n', 'utf-8'); + await fs.writeFile(path.join(workspaceRoot, 'src/b.txt'), 'b\n', 'utf-8'); + const files = [ + createDiscoveredFile(workspaceRoot, 'src/a.txt'), + createDiscoveredFile(workspaceRoot, 'src/b.txt'), + ]; + const readContent = vi.fn(async (file: IDiscoveredFile) => + fs.readFile(file.absolutePath, 'utf-8'), + ); + + await analyzeWorkspaceIndexFiles({ + cache: createEmptyWorkspaceAnalysisCache(), + discovery: { readContent } as never, + discoveryResult: { + durationMs: 1, + files, + directories: [], + gitIgnoredPaths: [], + limitReached: false, + totalFound: files.length, + }, + disabledPlugins: new Set(), + options: { + workspaceRoot, + }, + registry: { + analyzeFileResult: vi.fn(async (absolutePath: string, content: string) => ({ + filePath: absolutePath, + relations: [], + symbols: content.trim().length > 0 ? [] : undefined, + })), + notifyPreAnalyze: vi.fn(async () => undefined), + } as never, + workspaceRoot, + }); + + expect(readContent).toHaveBeenCalledTimes(2); + expect(readContent).toHaveBeenCalledWith(files[0]); + expect(readContent).toHaveBeenCalledWith(files[1]); + }); +}); From 9e3294ed6b892f32169198282095f7a2b66fe36c Mon Sep 17 00:00:00 2001 From: joesobo Date: Mon, 22 Jun 2026 16:02:34 -0700 Subject: [PATCH 007/192] perf: speed up Godot class name indexing --- .changeset/godot-class-name-fast-path.md | 5 +++ docs/performance/codegraphy-monorepo.md | 28 +++++++++++++++ .../plugin-godot/src/gdscript/className.ts | 34 +++---------------- .../tests/gdscript/classNameFast.test.ts | 30 ++++++++++++++++ 4 files changed, 67 insertions(+), 30 deletions(-) create mode 100644 .changeset/godot-class-name-fast-path.md create mode 100644 packages/plugin-godot/tests/gdscript/classNameFast.test.ts diff --git a/.changeset/godot-class-name-fast-path.md b/.changeset/godot-class-name-fast-path.md new file mode 100644 index 000000000..5e8b7ce9a --- /dev/null +++ b/.changeset/godot-class-name-fast-path.md @@ -0,0 +1,5 @@ +--- +"@codegraphy-dev/plugin-godot": patch +--- + +Speed up Godot class name indexing during workspace analysis. diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index 0a31f7cd7..45501a671 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -76,6 +76,34 @@ Result: - File analysis improved from `92850ms` to `87297ms` by reusing file content read during pre-analysis. +Godot class name metadata fast path: + +- Command shape: direct Core API cold index with `userHomeDir` pointing at an + isolated plugin cache whose package roots point at this worktree's local + plugin packages. +- The isolated plugin cache matters because the user's real + `~/.codegraphy/plugins.json` can point at older worktrees or globally + installed plugin packages. +- Before command: old `extractGDScriptClassNameDeclarations` path using the + GDScript syntax parser. +- After command: line-based class name extraction for metadata pre-analysis. +- Files: `2367` +- Nodes: `5079` +- Edges: `9110` before, `9108` after. The persisted relationship diff is only + the changed CodeGraphy source imports/calls in `className.ts`; no workspace + Godot facts disappeared. +- Wall time: `104.67s` before, `37.27s` after. +- File analysis: `87918ms` before, `23352ms` after. +- Graph Cache save: `14058ms` before, `11233ms` after. +- Max resident set: `2901016576` bytes before, `465518592` bytes after. +- Peak memory footprint: `4232806784` bytes before, `332065728` bytes after. + +Result: + +- Cold index wall time improved from `104.67s` to `37.27s`. +- File analysis improved from `87918ms` to `23352ms` by avoiding Lezer recovery + during Godot `class_name` metadata pre-analysis. + Full test baseline: - `pnpm run test`: `1523.98s` wall time diff --git a/packages/plugin-godot/src/gdscript/className.ts b/packages/plugin-godot/src/gdscript/className.ts index ae8951668..2541504a0 100644 --- a/packages/plugin-godot/src/gdscript/className.ts +++ b/packages/plugin-godot/src/gdscript/className.ts @@ -1,12 +1,6 @@ import type { IGDScriptReference } from './types'; import { stripGDScriptComment } from './comments'; -import { - findGDScriptSyntaxNodes, - parseGDScriptSyntaxTree, - readFirstDescendantText, - readGDScriptLineNumber, -} from './syntaxTree'; -import { isLeadingClassNameStatement } from './classNameLine'; +import { parseGDScriptDocument } from './document'; /** * Detect class_name declarations (not imports -- used for building the class_name map). @@ -26,27 +20,7 @@ export function detectClassNameDeclaration(line: string, lineNumber: number): IG } export function extractGDScriptClassNameDeclarations(content: string): IGDScriptReference[] { - const declarations: IGDScriptReference[] = []; - const tree = parseGDScriptSyntaxTree(content); - - for (const node of findGDScriptSyntaxNodes(tree, 'ClassNameStatement')) { - if (!isLeadingClassNameStatement(content, node.from)) { - continue; - } - - const className = readFirstDescendantText(node, 'Identifier'); - if (!className) { - continue; - } - - declarations.push({ - resPath: className, - referenceType: 'class_name', - importType: 'static', - line: readGDScriptLineNumber(content, node.from), - isDeclaration: true, - }); - } - - return declarations; + return parseGDScriptDocument(content).statements + .map(statement => detectClassNameDeclaration(statement.raw, statement.line)) + .filter((reference): reference is IGDScriptReference => Boolean(reference)); } diff --git a/packages/plugin-godot/tests/gdscript/classNameFast.test.ts b/packages/plugin-godot/tests/gdscript/classNameFast.test.ts new file mode 100644 index 000000000..eb8af84ca --- /dev/null +++ b/packages/plugin-godot/tests/gdscript/classNameFast.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('../../src/gdscript/syntaxTree', () => ({ + findGDScriptSyntaxNodes: vi.fn(), + parseGDScriptSyntaxTree: vi.fn(() => { + throw new Error('syntax parser should not run for class_name extraction'); + }), + readFirstDescendantText: vi.fn(), + readGDScriptLineNumber: vi.fn(), +})); + +import { extractGDScriptClassNameDeclarations } from '../../src/gdscript/className'; + +describe('fast GDScript class_name extraction', () => { + it('extracts declarations without the syntax parser', () => { + expect(extractGDScriptClassNameDeclarations([ + '@icon("res://icon.svg")', + 'class_name Player # exported class', + 'extends Node2D', + ].join('\n'))).toEqual([ + { + resPath: 'Player', + referenceType: 'class_name', + importType: 'static', + line: 2, + isDeclaration: true, + }, + ]); + }); +}); From 595aa15117462734792a60afb6be00611c147482 Mon Sep 17 00:00:00 2001 From: joesobo Date: Mon, 22 Jun 2026 16:07:27 -0700 Subject: [PATCH 008/192] perf: skip TypeScript alias config file scans --- .changeset/typescript-alias-config-noscan.md | 5 ++ docs/performance/codegraphy-monorepo.md | 24 ++++++++ .../src/aliasImport/compilerOptions.ts | 14 ++++- .../tests/aliasImport/compilerOptions.test.ts | 58 ++++++++++++++++++- 4 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 .changeset/typescript-alias-config-noscan.md diff --git a/.changeset/typescript-alias-config-noscan.md b/.changeset/typescript-alias-config-noscan.md new file mode 100644 index 000000000..eaef61d84 --- /dev/null +++ b/.changeset/typescript-alias-config-noscan.md @@ -0,0 +1,5 @@ +--- +"@codegraphy-dev/plugin-typescript": patch +--- + +Speed up TypeScript alias import analysis by avoiding project file scans while reading alias configuration. diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index 45501a671..4b45f8dca 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -104,6 +104,30 @@ Result: - File analysis improved from `87918ms` to `23352ms` by avoiding Lezer recovery during Godot `class_name` metadata pre-analysis. +TypeScript alias config no-scan parse: + +- Command shape: same isolated plugin cache as the Godot fast path benchmark. +- Before command: TypeScript alias config parsing used + `ts.parseJsonConfigFileContent` with `ts.sys`, which enumerates project files + even though alias import analysis only needs `compilerOptions`. +- After command: TypeScript alias config parsing uses a parse host that can read + config files and extended config files but returns no project file list. +- Files: `2369`; the count is higher than the Godot fast-path run because this + iteration adds TypeScript plugin regression coverage and changeset/docs files. +- Nodes: `5081` +- Edges: `9108` +- Wall time: `37.27s` before, `17.28s` after. +- File analysis: `23352ms` before, `3697ms` after. +- Graph Cache save: `11233ms` before, `10904ms` after. +- Max resident set: `465518592` bytes before, `476708864` bytes after. +- Peak memory footprint: `332065728` bytes before, `328051904` bytes after. + +Result: + +- Cold index wall time improved from `37.27s` to `17.28s`. +- File analysis improved from `23352ms` to `3697ms` by skipping TypeScript + project file enumeration during alias config parsing. + Full test baseline: - `pnpm run test`: `1523.98s` wall time diff --git a/packages/plugin-typescript/src/aliasImport/compilerOptions.ts b/packages/plugin-typescript/src/aliasImport/compilerOptions.ts index 7266034e5..bc87b7326 100644 --- a/packages/plugin-typescript/src/aliasImport/compilerOptions.ts +++ b/packages/plugin-typescript/src/aliasImport/compilerOptions.ts @@ -51,13 +51,25 @@ function readCompilerOptions(tsconfigPath: string): ts.ParsedCommandLine | null return ts.parseJsonConfigFileContent( readResult.config, - ts.sys, + createCompilerOptionsParseHost(), path.dirname(tsconfigPath), undefined, tsconfigPath, ); } +function createCompilerOptionsParseHost(): ts.ParseConfigHost { + return { + directoryExists: directoryName => ts.sys.directoryExists?.(directoryName) ?? false, + fileExists: fileName => ts.sys.fileExists(fileName), + getCurrentDirectory: () => ts.sys.getCurrentDirectory(), + readDirectory: () => [], + readFile: fileName => ts.sys.readFile(fileName), + realpath: pathName => ts.sys.realpath?.(pathName) ?? pathName, + useCaseSensitiveFileNames: ts.sys.useCaseSensitiveFileNames, + }; +} + function createPathMappings( paths: ts.MapLike, options: ts.CompilerOptions, diff --git a/packages/plugin-typescript/tests/aliasImport/compilerOptions.test.ts b/packages/plugin-typescript/tests/aliasImport/compilerOptions.test.ts index 7ce7438af..0862941dd 100644 --- a/packages/plugin-typescript/tests/aliasImport/compilerOptions.test.ts +++ b/packages/plugin-typescript/tests/aliasImport/compilerOptions.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it } from 'vitest'; +import ts from 'typescript'; +import { describe, expect, it, vi } from 'vitest'; import { createTypeScriptPlugin } from '../../src/plugin'; import { createWorkspaceRoot, removeWorkspaceRoot, writeWorkspaceFile } from '../workspace'; @@ -233,6 +234,61 @@ describe('TypeScript Alias Import compiler options support', () => { } }); + it('reads path aliases without scanning project files', async () => { + const workspaceRoot = createWorkspaceRoot(); + const readDirectory = vi.spyOn(ts.sys, 'readDirectory') + .mockImplementation(() => { + throw new Error('project file scanning should not run for alias config'); + }); + + try { + writeWorkspaceFile( + workspaceRoot, + 'tsconfig.json', + JSON.stringify({ + compilerOptions: { + baseUrl: '.', + paths: { + '@/*': ['src/*'], + }, + }, + }), + ); + const sourcePath = writeWorkspaceFile( + workspaceRoot, + 'src/app.ts', + "import { token } from '@/token';\n", + ); + const targetPath = writeWorkspaceFile( + workspaceRoot, + 'src/token.ts', + 'export const token = Symbol();\n', + ); + + const plugin = createTypeScriptPlugin(); + const result = await plugin.analyzeFile?.( + sourcePath, + "import { token } from '@/token';\n", + workspaceRoot, + ); + + expect(result?.relations).toEqual([ + { + kind: 'codegraphy.typescript:alias-import', + sourceId: 'compiler-options-paths', + fromFilePath: sourcePath, + toFilePath: targetPath, + resolvedPath: targetPath, + specifier: '@/token', + }, + ]); + expect(readDirectory).not.toHaveBeenCalled(); + } finally { + readDirectory.mockRestore(); + removeWorkspaceRoot(workspaceRoot); + } + }); + it('emits no relationships when nearest tsconfig has no paths', async () => { const workspaceRoot = createWorkspaceRoot(); try { From 6743acb826ef4dc128fd3d79b057f12994e71c7e Mon Sep 17 00:00:00 2001 From: joesobo Date: Mon, 22 Jun 2026 16:10:11 -0700 Subject: [PATCH 009/192] docs: record warm Graph Cache query metric --- docs/performance/codegraphy-monorepo.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index 4b45f8dca..45e72b34e 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -128,6 +128,18 @@ Result: - File analysis improved from `23352ms` to `3697ms` by skipping TypeScript project file enumeration during alias config parsing. +Warm Graph Cache query proxy: + +- Command shape: `requestWorkspaceGraphQuery` with report `nodes` and + `limit: 1` against the current Graph Cache. +- Wall time: `0.74s`. +- Graph Query diagnostic duration: `601ms`. +- Query graph size: `2514` nodes, `9108` edges. +- Caveat: cache status reported `stale` with `plugin-signature-changed` because + the query status path compared against the user's real installed-plugin cache + while the benchmark loaded an isolated plugin cache. The query still loaded + the Graph Cache and built graph data. + Full test baseline: - `pnpm run test`: `1523.98s` wall time From 67c3f2f81de69b4e3664d629f652e2ca26152e58 Mon Sep 17 00:00:00 2001 From: joesobo Date: Mon, 22 Jun 2026 16:32:53 -0700 Subject: [PATCH 010/192] perf: speed visible graph filtering Current settings projection improved from 775ms median / 933ms p95 to 22ms median / 26ms p95 on the CodeGraphy monorepo visible-graph benchmark. Folders-on projection improved from 1369ms median / 1445ms p95 to 31ms median / 32ms p95. Import-edge-hidden projection improved from 153ms median to 17ms median / 18ms p95. --- .changeset/visible-graph-filter-speed.md | 6 + docs/performance/codegraphy-monorepo.md | 25 ++ .../2026-06-22-codegraphy-performance.md | 12 +- package.json | 1 + packages/core/src/globMatch.ts | 7 +- packages/core/src/visibleGraph/filter.ts | 49 +++- packages/core/tests/globMatch.test.ts | 10 +- .../core/tests/visibleGraph/collapse.test.ts | 3 + packages/extension/src/shared/globMatch.ts | 7 +- .../src/shared/visibleGraph/filter.ts | 49 +++- .../extension/tests/shared/globMatch.test.ts | 10 +- .../tests/shared/visibleGraph/derive.test.ts | 25 ++ .../measure-codegraphy-monorepo.mjs | 29 ++ .../measure-visible-graph-monorepo.mjs | 263 ++++++++++++++++++ .../measure-codegraphy-monorepo.test.mjs | 15 + 15 files changed, 482 insertions(+), 29 deletions(-) create mode 100644 .changeset/visible-graph-filter-speed.md create mode 100644 scripts/performance/measure-visible-graph-monorepo.mjs diff --git a/.changeset/visible-graph-filter-speed.md b/.changeset/visible-graph-filter-speed.md new file mode 100644 index 000000000..a9e3a7999 --- /dev/null +++ b/.changeset/visible-graph-filter-speed.md @@ -0,0 +1,6 @@ +--- +"@codegraphy-dev/core": patch +"@codegraphy-dev/extension": patch +--- + +Speed up visible graph filtering for large workspaces with many filter patterns. diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index 45e72b34e..bd429a3ef 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -140,6 +140,31 @@ Warm Graph Cache query proxy: while the benchmark loaded an isolated plugin cache. The query still loaded the Graph Cache and built graph data. +Visible Graph projection benchmark: + +- Command shape: `pnpm run perf:visible-graph-monorepo` against the existing + Graph Cache with the isolated package-plugin cache used by the cold-index + benchmark. +- Before filter optimization: + - Warm Graph Cache graph build: `409ms`. + - Current settings projection: `775ms` median, `933ms` p95. + - No-filter projection: `5ms` median. + - Folders-on Graph Scope projection: `1369ms` median, `1445ms` p95. + - Import-edge-hidden projection: `153ms` median. +- After reusable glob matchers and skipping direct edge matching for path-only + filters: + - Warm Graph Cache graph build: `378ms`. + - Current settings projection: `22ms` median, `26ms` p95. + - No-filter projection: `5ms` median. + - Folders-on Graph Scope projection: `31ms` median, `32ms` p95. + - Import-edge-hidden projection: `17ms` median, `18ms` p95. +- Result: + - Current settings projection improved from `775ms` to `22ms`. + - Folders-on Graph Scope projection improved from `1369ms` to `31ms`. + - Import-edge-hidden projection improved from `153ms` to `17ms`. + - Scenario node and edge counts stayed unchanged after the filter + optimization. + Full test baseline: - `pnpm run test`: `1523.98s` wall time diff --git a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md index b386bb187..6abbe2ec1 100644 --- a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md +++ b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md @@ -31,6 +31,12 @@ - Canonical Graph Cache write iteration: cold indexing improved to `111.03s` wall time; Graph Cache save improved to `15139ms`; Graph Cache size improved from `64638976` bytes to `18153472` bytes. - Shared content read cache iteration: cold indexing improved to `104.81s`; file/plugin analysis improved to `87297ms`; Graph Cache save stayed stable at `14632ms`. - Remaining measured cold-load hot spot: file/plugin analysis at `87297ms` on the shared-content run. +- Godot class-name metadata fast path improved cold indexing from `104.67s` to `37.27s` and file analysis from `87918ms` to `23352ms`. +- TypeScript alias-config no-scan parsing improved cold indexing from `37.27s` to `17.28s` and file analysis from `23352ms` to `3697ms`. +- Warm Graph Cache query proxy took `0.74s` wall time with a `601ms` diagnostic duration for a `2514` node / `9108` edge query graph. +- Visible Graph projection benchmark before filter optimization: current settings `775ms` median / `933ms` p95, folders-on Graph Scope `1369ms` median / `1445ms` p95, import-edge-hidden `153ms` median. +- Visible Graph projection after reusable glob matchers and skipping direct edge matching for path-only filters: current settings `22ms` median / `26ms` p95, folders-on Graph Scope `31ms` median / `32ms` p95, import-edge-hidden `17ms` median / `18ms` p95. +- Visible Graph scenario node and edge counts stayed unchanged across the filter optimization. - Raw logs are ignored under `reports/performance/`; commit only scripts and bounded summaries under `docs/performance/`. ## Success Metrics @@ -248,15 +254,15 @@ If large Visible Graph messages dominate interaction latency, then avoiding unch If plugin analysis runs on files that cannot be affected by a changed setting, then narrowing reprocessing to affected providers/files will reduce Live Update and Graph Cache Sync time. ``` -- [ ] **Step 2: Add or extend a failing test for the selected bottleneck** +- [x] **Step 2: Add or extend a failing test for the selected bottleneck** Use the closest seam: Core Package Graph Query tests for headless data work, Extension provider tests for message routing and refresh decisions, or Playwright for UI latency. -- [ ] **Step 3: Implement the smallest behavior change** +- [x] **Step 3: Implement the smallest behavior change** Keep each commit scoped to one measured path. -- [ ] **Step 4: Re-run the targeted test and performance harness** +- [x] **Step 4: Re-run the targeted test and performance harness** Compare the metric before committing. diff --git a/package.json b/package.json index 6d48dbda6..5ab8e62ea 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "test:playwright": "node scripts/run-playwright-turbo.mjs", "test:vscode": "pnpm -r --if-present run test:vscode", "perf:codegraphy-monorepo": "node scripts/performance/measure-codegraphy-monorepo.mjs --workspace .", + "perf:visible-graph-monorepo": "tsx scripts/performance/measure-visible-graph-monorepo.mjs --workspace .", "check:acceptance-specs": "node scripts/guard-acceptance-spec-edits.mjs", "lint": "turbo run lint", "crap": "quality-tools crap", diff --git a/packages/core/src/globMatch.ts b/packages/core/src/globMatch.ts index 1530b1dcc..d9a1b0a08 100644 --- a/packages/core/src/globMatch.ts +++ b/packages/core/src/globMatch.ts @@ -39,6 +39,11 @@ export function globToRegex(pattern: string): RegExp { return new RegExp(`(?:^|/)${body}$`); } +export function createGlobMatcher(pattern: string): (filePath: string) => boolean { + const regex = globToRegex(pattern); + return (filePath: string): boolean => regex.test(filePath); +} + export function globMatch(filePath: string, pattern: string): boolean { - return globToRegex(pattern).test(filePath); + return createGlobMatcher(pattern)(filePath); } diff --git a/packages/core/src/visibleGraph/filter.ts b/packages/core/src/visibleGraph/filter.ts index 2641a1d1b..0a57aaa77 100644 --- a/packages/core/src/visibleGraph/filter.ts +++ b/packages/core/src/visibleGraph/filter.ts @@ -1,22 +1,41 @@ import type { IGraphData } from '../graph/contracts'; -import { globMatch } from '../globMatch'; +import { createGlobMatcher } from '../globMatch'; import type { VisibleGraphFilterConfig } from './contracts'; import { filterEdgesToNodes } from './model'; -function nodeMatchesPattern(node: IGraphData['nodes'][number], pattern: string): boolean { - return globMatch(node.id, pattern) - || (node.symbol?.filePath ? globMatch(node.symbol.filePath, pattern) : false); +type GlobMatcher = ReturnType; +interface CompiledFilterPattern { + matches: GlobMatcher; + pattern: string; } -function edgeMatchesPattern(edge: IGraphData['edges'][number], pattern: string): boolean { +function nodeMatchesPattern(node: IGraphData['nodes'][number], matches: GlobMatcher): boolean { + return matches(node.id) + || (node.symbol?.filePath ? matches(node.symbol.filePath) : false); +} + +function edgeMatchesPattern(edge: IGraphData['edges'][number], matches: GlobMatcher): boolean { return ( - globMatch(edge.id, pattern) - || globMatch(edge.kind, pattern) - || globMatch(`${edge.from}->${edge.to}`, pattern) - || globMatch(`${edge.from}->${edge.to}#${edge.kind}`, pattern) + matches(edge.id) + || matches(edge.kind) + || matches(`${edge.from}->${edge.to}`) + || matches(`${edge.from}->${edge.to}#${edge.kind}`) ); } +function canFilterEdgeDirectly(pattern: string): boolean { + return pattern.includes('->') + || pattern.includes('#') + || (!pattern.includes('*') && !pattern.includes('/')); +} + +function compileFilterPatterns(patterns: readonly string[]): CompiledFilterPattern[] { + return patterns.map(pattern => ({ + matches: createGlobMatcher(pattern), + pattern, + })); +} + export function applyFilterPatterns( graphData: IGraphData, filter: VisibleGraphFilterConfig, @@ -25,12 +44,20 @@ export function applyFilterPatterns( return graphData; } + const compiledPatterns = compileFilterPatterns(filter.patterns); const nodes = graphData.nodes.filter( - (node) => !filter.patterns.some((pattern) => nodeMatchesPattern(node, pattern)), + (node) => !compiledPatterns.some(({ matches }) => nodeMatchesPattern(node, matches)), ); const nodeFilteredEdges = filterEdgesToNodes(graphData.edges, nodes); + const edgePatternMatchers = compiledPatterns + .filter(({ pattern }) => canFilterEdgeDirectly(pattern)) + .map(({ matches }) => matches); + if (edgePatternMatchers.length === 0) { + return { nodes, edges: nodeFilteredEdges }; + } + const edges = nodeFilteredEdges.filter( - (edge) => !filter.patterns.some((pattern) => edgeMatchesPattern(edge, pattern)), + (edge) => !edgePatternMatchers.some((matches) => edgeMatchesPattern(edge, matches)), ); return { nodes, edges }; diff --git a/packages/core/tests/globMatch.test.ts b/packages/core/tests/globMatch.test.ts index a2dc5a706..d1ec99a6b 100644 --- a/packages/core/tests/globMatch.test.ts +++ b/packages/core/tests/globMatch.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { globMatch, globToRegex } from '../src/globMatch'; +import { createGlobMatcher, globMatch, globToRegex } from '../src/globMatch'; describe('globMatch', () => { it('supports basename, single-star, and recursive glob matching', () => { @@ -22,4 +22,12 @@ describe('globMatch', () => { expect(globToRegex('src/app+(test).ts').test('src/app+(test).ts')).toBe(true); expect(globToRegex('src/app+(test).ts').test('src/appptestt.ts')).toBe(false); }); + + it('creates reusable matchers with the same glob semantics', () => { + const matcher = createGlobMatcher('src/**/*.ts'); + + expect(matcher('src/index.ts')).toBe(true); + expect(matcher('src/deep/index.ts')).toBe(true); + expect(matcher('docs/index.ts')).toBe(false); + }); }); diff --git a/packages/core/tests/visibleGraph/collapse.test.ts b/packages/core/tests/visibleGraph/collapse.test.ts index 329b743a1..18652d1b0 100644 --- a/packages/core/tests/visibleGraph/collapse.test.ts +++ b/packages/core/tests/visibleGraph/collapse.test.ts @@ -119,6 +119,9 @@ describe('visibleGraph collapse and filtering', () => { expect(applyFilterPatterns(graphData, { patterns: ['import'] }).edges).toEqual([ edge('src/b.ts', 'src/a.ts', 'reference'), ]); + expect(applyFilterPatterns(graphData, { patterns: ['src/*->src/b.ts#import'] }).edges).toEqual([ + edge('src/b.ts', 'src/a.ts', 'reference'), + ]); }); it('hides descendants of collapsed folders and projects external edges onto the visible folder', () => { diff --git a/packages/extension/src/shared/globMatch.ts b/packages/extension/src/shared/globMatch.ts index 1530b1dcc..d9a1b0a08 100644 --- a/packages/extension/src/shared/globMatch.ts +++ b/packages/extension/src/shared/globMatch.ts @@ -39,6 +39,11 @@ export function globToRegex(pattern: string): RegExp { return new RegExp(`(?:^|/)${body}$`); } +export function createGlobMatcher(pattern: string): (filePath: string) => boolean { + const regex = globToRegex(pattern); + return (filePath: string): boolean => regex.test(filePath); +} + export function globMatch(filePath: string, pattern: string): boolean { - return globToRegex(pattern).test(filePath); + return createGlobMatcher(pattern)(filePath); } diff --git a/packages/extension/src/shared/visibleGraph/filter.ts b/packages/extension/src/shared/visibleGraph/filter.ts index 2641a1d1b..0a57aaa77 100644 --- a/packages/extension/src/shared/visibleGraph/filter.ts +++ b/packages/extension/src/shared/visibleGraph/filter.ts @@ -1,22 +1,41 @@ import type { IGraphData } from '../graph/contracts'; -import { globMatch } from '../globMatch'; +import { createGlobMatcher } from '../globMatch'; import type { VisibleGraphFilterConfig } from './contracts'; import { filterEdgesToNodes } from './model'; -function nodeMatchesPattern(node: IGraphData['nodes'][number], pattern: string): boolean { - return globMatch(node.id, pattern) - || (node.symbol?.filePath ? globMatch(node.symbol.filePath, pattern) : false); +type GlobMatcher = ReturnType; +interface CompiledFilterPattern { + matches: GlobMatcher; + pattern: string; } -function edgeMatchesPattern(edge: IGraphData['edges'][number], pattern: string): boolean { +function nodeMatchesPattern(node: IGraphData['nodes'][number], matches: GlobMatcher): boolean { + return matches(node.id) + || (node.symbol?.filePath ? matches(node.symbol.filePath) : false); +} + +function edgeMatchesPattern(edge: IGraphData['edges'][number], matches: GlobMatcher): boolean { return ( - globMatch(edge.id, pattern) - || globMatch(edge.kind, pattern) - || globMatch(`${edge.from}->${edge.to}`, pattern) - || globMatch(`${edge.from}->${edge.to}#${edge.kind}`, pattern) + matches(edge.id) + || matches(edge.kind) + || matches(`${edge.from}->${edge.to}`) + || matches(`${edge.from}->${edge.to}#${edge.kind}`) ); } +function canFilterEdgeDirectly(pattern: string): boolean { + return pattern.includes('->') + || pattern.includes('#') + || (!pattern.includes('*') && !pattern.includes('/')); +} + +function compileFilterPatterns(patterns: readonly string[]): CompiledFilterPattern[] { + return patterns.map(pattern => ({ + matches: createGlobMatcher(pattern), + pattern, + })); +} + export function applyFilterPatterns( graphData: IGraphData, filter: VisibleGraphFilterConfig, @@ -25,12 +44,20 @@ export function applyFilterPatterns( return graphData; } + const compiledPatterns = compileFilterPatterns(filter.patterns); const nodes = graphData.nodes.filter( - (node) => !filter.patterns.some((pattern) => nodeMatchesPattern(node, pattern)), + (node) => !compiledPatterns.some(({ matches }) => nodeMatchesPattern(node, matches)), ); const nodeFilteredEdges = filterEdgesToNodes(graphData.edges, nodes); + const edgePatternMatchers = compiledPatterns + .filter(({ pattern }) => canFilterEdgeDirectly(pattern)) + .map(({ matches }) => matches); + if (edgePatternMatchers.length === 0) { + return { nodes, edges: nodeFilteredEdges }; + } + const edges = nodeFilteredEdges.filter( - (edge) => !filter.patterns.some((pattern) => edgeMatchesPattern(edge, pattern)), + (edge) => !edgePatternMatchers.some((matches) => edgeMatchesPattern(edge, matches)), ); return { nodes, edges }; diff --git a/packages/extension/tests/shared/globMatch.test.ts b/packages/extension/tests/shared/globMatch.test.ts index 25dcdff20..fcf9a9eec 100644 --- a/packages/extension/tests/shared/globMatch.test.ts +++ b/packages/extension/tests/shared/globMatch.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { globMatch, globToRegex } from '../../src/shared/globMatch'; +import { createGlobMatcher, globMatch, globToRegex } from '../../src/shared/globMatch'; describe('shared/globMatch', () => { it('matches basename patterns against nested paths', () => { @@ -24,4 +24,12 @@ describe('shared/globMatch', () => { expect(globMatch('src/types/apiXd.ts', '*.d.ts')).toBe(false); expect(globToRegex('*.d.ts')).toBeInstanceOf(RegExp); }); + + it('creates reusable matchers with the same glob semantics', () => { + const matcher = createGlobMatcher('src/**/*.ts'); + + expect(matcher('src/index.ts')).toBe(true); + expect(matcher('src/deep/index.ts')).toBe(true); + expect(matcher('docs/index.ts')).toBe(false); + }); }); diff --git a/packages/extension/tests/shared/visibleGraph/derive.test.ts b/packages/extension/tests/shared/visibleGraph/derive.test.ts index e4d803124..dc35ee9af 100644 --- a/packages/extension/tests/shared/visibleGraph/derive.test.ts +++ b/packages/extension/tests/shared/visibleGraph/derive.test.ts @@ -64,6 +64,31 @@ describe('shared/visibleGraph/deriveVisibleGraph', () => { }); + it('filters edges by wildcard edge id patterns', () => { + const result = deriveVisibleGraph( + { + nodes: [ + node('src/app.ts'), + node('src/generated.ts'), + node('src/other.ts'), + ], + edges: [ + edge('src/app.ts', 'src/generated.ts', 'import'), + edge('src/other.ts', 'src/app.ts', 'reference'), + ], + }, + { + filter: { patterns: ['src/*->src/generated.ts#import'] }, + }, + ); + + expect(ids(result.graphData)).toEqual({ + nodes: ['src/app.ts', 'src/generated.ts', 'src/other.ts'], + edges: ['src/other.ts->src/app.ts#reference'], + }); + }); + + it('keeps enabled child symbol rows hidden when their parent rows are disabled', () => { const result = deriveVisibleGraph( diff --git a/scripts/performance/measure-codegraphy-monorepo.mjs b/scripts/performance/measure-codegraphy-monorepo.mjs index d72aa8d08..4505d8f63 100644 --- a/scripts/performance/measure-codegraphy-monorepo.mjs +++ b/scripts/performance/measure-codegraphy-monorepo.mjs @@ -13,6 +13,35 @@ function createMetricsRecord({ workspacePath, measurements }) { }; } +function roundMs(value) { + return Math.round(value); +} + +function readPercentile(sortedSamples, percentile) { + if (sortedSamples.length === 0) { + return 0; + } + + const rank = Math.ceil((percentile / 100) * sortedSamples.length); + return sortedSamples[Math.max(0, Math.min(sortedSamples.length - 1, rank - 1))]; +} + +export function summarizeDurations(samples) { + const sortedSamples = [...samples].sort((left, right) => left - right); + const midpoint = Math.floor(sortedSamples.length / 2); + const median = sortedSamples.length % 2 === 0 + ? (sortedSamples[midpoint - 1] + sortedSamples[midpoint]) / 2 + : sortedSamples[midpoint]; + + return { + iterations: sortedSamples.length, + minMs: roundMs(sortedSamples[0] ?? 0), + medianMs: roundMs(median ?? 0), + p95Ms: roundMs(readPercentile(sortedSamples, 95)), + maxMs: roundMs(sortedSamples.at(-1) ?? 0), + }; +} + export async function writeMetrics({ outputPath, workspacePath, measurements }) { const metrics = createMetricsRecord({ workspacePath, measurements }); await mkdir(path.dirname(outputPath), { recursive: true }); diff --git a/scripts/performance/measure-visible-graph-monorepo.mjs b/scripts/performance/measure-visible-graph-monorepo.mjs new file mode 100644 index 000000000..c13fb6246 --- /dev/null +++ b/scripts/performance/measure-visible-graph-monorepo.mjs @@ -0,0 +1,263 @@ +import { Buffer } from 'node:buffer'; +import { performance } from 'node:perf_hooks'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + summarizeDurations, + writeMetrics, +} from './measure-codegraphy-monorepo.mjs'; + +function unwrapModule(module) { + return module.default ?? module; +} + +const [ + graphDataModule, + graphCacheStorageModule, + analysisFactsModule, + activityStateModule, + installedPluginCacheModule, + settingsStorageModule, + settingsDefaultsModule, + visibleGraphModule, + edgeTypesModule, + nodeTypesModule, + visibleGraphConfigModule, +] = await Promise.all([ + import('../../packages/core/src/graph/data.ts').then(unwrapModule), + import('../../packages/core/src/graphCache/database/storage.ts').then(unwrapModule), + import('../../packages/core/src/plugins/activityState/analysisFacts.ts').then(unwrapModule), + import('../../packages/core/src/plugins/activityState/model.ts').then(unwrapModule), + import('../../packages/core/src/plugins/installedPluginCache/storage.ts').then(unwrapModule), + import('../../packages/core/src/workspace/settingsStorage.ts').then(unwrapModule), + import('../../packages/core/src/workspace/settingsDefaults.ts').then(unwrapModule), + import('../../packages/extension/src/shared/visibleGraph/index.ts').then(unwrapModule), + import('../../packages/extension/src/shared/graphControls/defaults/edgeTypes.ts').then(unwrapModule), + import('../../packages/extension/src/shared/graphControls/defaults/nodeTypes.ts').then(unwrapModule), + import('../../packages/extension/src/webview/search/visibleGraphConfig.ts').then(unwrapModule), +]); + +const { buildWorkspaceGraphDataFromAnalysis } = graphDataModule; +const { loadWorkspaceAnalysisDatabaseCache } = graphCacheStorageModule; +const { filterInactivePluginFileAnalysis } = analysisFactsModule; +const { createDisabledPluginSet, createPluginActivityState } = activityStateModule; +const { readCodeGraphyInstalledPluginCache } = installedPluginCacheModule; +const { readCodeGraphyWorkspaceSettings } = settingsStorageModule; +const { CODEGRAPHY_MARKDOWN_PLUGIN_ID } = settingsDefaultsModule; +const { deriveVisibleGraph } = visibleGraphModule; +const { CORE_GRAPH_EDGE_TYPES } = edgeTypesModule; +const { CORE_GRAPH_NODE_TYPES } = nodeTypesModule; +const { buildVisibleGraphConfig } = visibleGraphConfigModule; + +const DEFAULT_OUTPUT_PATH = 'reports/performance/visible-graph-latest.json'; +const DEFAULT_ITERATIONS = 40; +const DEFAULT_WARMUP_ITERATIONS = 5; + +function readOptionValue(args, name) { + const index = args.indexOf(name); + return index >= 0 ? args[index + 1] : undefined; +} + +function hasFlag(args, name) { + return args.includes(name); +} + +function toPositiveInteger(value, defaultValue) { + if (!value) { + return defaultValue; + } + + const parsed = Number(value); + return Number.isInteger(parsed) && parsed > 0 ? parsed : defaultValue; +} + +function collectDirectoryPaths(filePaths) { + const directories = new Set(); + + for (const filePath of filePaths) { + let directory = path.posix.dirname(filePath.replace(/\\/g, '/')); + while (directory && directory !== '.') { + directories.add(directory); + directory = path.posix.dirname(directory); + } + } + + return [...directories].sort(); +} + +function createActivePluginSet(settings, userHomeDir) { + const installedPluginCache = readCodeGraphyInstalledPluginCache({ + ...(userHomeDir ? { homeDir: userHomeDir } : {}), + }); + const activityState = createPluginActivityState({ + settings, + installedPlugins: installedPluginCache.plugins, + builtInPluginIds: [CODEGRAPHY_MARKDOWN_PLUGIN_ID], + }); + return new Set(activityState.activePluginIds); +} + +function buildGraphDataFromGraphCache(workspacePath, userHomeDir) { + const workspaceRoot = path.resolve(workspacePath); + const settings = readCodeGraphyWorkspaceSettings(workspaceRoot); + const disabledPlugins = createDisabledPluginSet(settings); + const activePluginIds = createActivePluginSet(settings, userHomeDir); + const cache = loadWorkspaceAnalysisDatabaseCache(workspaceRoot); + const fileAnalysis = new Map( + Object.entries(cache.files).map(([filePath, entry]) => [filePath, entry.analysis]), + ); + + return { + graphData: buildWorkspaceGraphDataFromAnalysis({ + cacheFiles: cache.files, + churnCounts: {}, + directoryPaths: collectDirectoryPaths(Object.keys(cache.files)), + disabledPlugins, + fileAnalysis: filterInactivePluginFileAnalysis(fileAnalysis, activePluginIds), + getPluginForFile: () => undefined, + nodeVisibility: settings.nodeVisibility, + showOrphans: settings.showOrphans, + workspaceRoot, + }), + settings, + }; +} + +function createActiveFilterPatterns(settings) { + const disabledCustomPatterns = new Set(settings.disabledCustomFilterPatterns ?? []); + return (settings.filterPatterns ?? []).filter(pattern => !disabledCustomPatterns.has(pattern)); +} + +function createVisibleGraphScenarioConfig(settings, overrides = {}) { + const nodeVisibility = { + ...(settings.nodeVisibility ?? {}), + ...(overrides.nodeVisibility ?? {}), + }; + const edgeVisibility = { + ...(settings.edgeVisibility ?? {}), + ...(overrides.edgeVisibility ?? {}), + }; + + return buildVisibleGraphConfig({ + edgeTypes: CORE_GRAPH_EDGE_TYPES, + edgeVisibility, + filterPatterns: overrides.filterPatterns ?? createActiveFilterPatterns(settings), + nodeTypes: CORE_GRAPH_NODE_TYPES, + nodeVisibility, + searchOptions: overrides.searchOptions ?? { matchCase: false, wholeWord: false, regex: false }, + searchQuery: overrides.searchQuery ?? '', + showOrphans: overrides.showOrphans ?? settings.showOrphans ?? true, + }); +} + +function createVisibleGraphScenarios(settings) { + return { + current: createVisibleGraphScenarioConfig(settings), + noFilters: createVisibleGraphScenarioConfig(settings, { filterPatterns: [] }), + foldersOn: createVisibleGraphScenarioConfig(settings, { + nodeVisibility: { folder: true }, + }), + importsOff: createVisibleGraphScenarioConfig(settings, { + edgeVisibility: { import: false }, + }), + searchGraph: createVisibleGraphScenarioConfig(settings, { + searchQuery: 'graph', + }), + }; +} + +function measureVisibleGraphScenario(graphData, config, options) { + for (let index = 0; index < options.warmupIterations; index += 1) { + deriveVisibleGraph(graphData, config); + } + + const durations = []; + let visibleGraph = graphData; + let regexError = null; + + for (let index = 0; index < options.iterations; index += 1) { + const startedAt = performance.now(); + const result = deriveVisibleGraph(graphData, config); + durations.push(performance.now() - startedAt); + visibleGraph = result.graphData ?? { nodes: [], edges: [] }; + regexError = result.regexError; + } + + return { + ...summarizeDurations(durations), + nodeCount: visibleGraph.nodes.length, + edgeCount: visibleGraph.edges.length, + payloadBytes: Buffer.byteLength(JSON.stringify(visibleGraph)), + ...(regexError ? { regexError } : {}), + }; +} + +export function measureVisibleGraphScenarios(graphData, settings, options = {}) { + const iterations = options.iterations ?? DEFAULT_ITERATIONS; + const warmupIterations = options.warmupIterations ?? DEFAULT_WARMUP_ITERATIONS; + const scenarios = createVisibleGraphScenarios(settings); + + return Object.fromEntries( + Object.entries(scenarios).map(([scenarioName, config]) => [ + scenarioName, + measureVisibleGraphScenario(graphData, config, { iterations, warmupIterations }), + ]), + ); +} + +async function measureVisibleGraph({ workspacePath, userHomeDir, iterations, warmupIterations }) { + const startedAt = performance.now(); + const { graphData, settings } = buildGraphDataFromGraphCache(workspacePath, userHomeDir); + const warmCacheGraphBuildMs = Math.round(performance.now() - startedAt); + const visibleGraphScenarios = measureVisibleGraphScenarios(graphData, settings, { + iterations, + warmupIterations, + }); + + return { + warmCacheGraphBuildMs, + graphNodeCount: graphData.nodes.length, + graphEdgeCount: graphData.edges.length, + visibleGraphScenarios, + }; +} + +function printUsage() { + process.stdout.write([ + 'Usage:', + ' pnpm exec tsx scripts/performance/measure-visible-graph-monorepo.mjs [--workspace ] [--user-home ] [--iterations ] [--warmup ] [--output ]', + '', + 'Loads the existing Graph Cache and times webview visible-graph derivation scenarios.', + ].join('\n')); +} + +async function runCli(argv) { + if (hasFlag(argv, '--help')) { + printUsage(); + return; + } + + const workspacePath = readOptionValue(argv, '--workspace') ?? process.cwd(); + const outputPath = readOptionValue(argv, '--output') ?? DEFAULT_OUTPUT_PATH; + const userHomeDir = readOptionValue(argv, '--user-home'); + const iterations = toPositiveInteger(readOptionValue(argv, '--iterations'), DEFAULT_ITERATIONS); + const warmupIterations = toPositiveInteger(readOptionValue(argv, '--warmup'), DEFAULT_WARMUP_ITERATIONS); + const measurements = await measureVisibleGraph({ + workspacePath, + userHomeDir, + iterations, + warmupIterations, + }); + + await writeMetrics({ outputPath, workspacePath, measurements }); +} + +const isDirectRun = process.argv[1] + && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); + +if (isDirectRun) { + runCli(process.argv.slice(2)).catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + }); +} diff --git a/tests/scripts/measure-codegraphy-monorepo.test.mjs b/tests/scripts/measure-codegraphy-monorepo.test.mjs index 7fe896eb4..fcb926d53 100644 --- a/tests/scripts/measure-codegraphy-monorepo.test.mjs +++ b/tests/scripts/measure-codegraphy-monorepo.test.mjs @@ -93,3 +93,18 @@ test('performance runner parses verbose indexing phase timings', async () => { await rm(tempDir, { recursive: true, force: true }); } }); + +test('performance runner summarizes repeated timing samples deterministically', async () => { + const moduleUrl = pathToFileURL( + path.resolve('scripts/performance/measure-codegraphy-monorepo.mjs'), + ).href; + const { summarizeDurations } = await import(moduleUrl); + + assert.deepEqual(summarizeDurations([50.4, 10.2, 30.6, 20.1, 40.5]), { + iterations: 5, + minMs: 10, + medianMs: 31, + p95Ms: 50, + maxMs: 50, + }); +}); From 09d13950b4603bd850431a46d81f93e15e15141b Mon Sep 17 00:00:00 2001 From: joesobo Date: Mon, 22 Jun 2026 16:43:27 -0700 Subject: [PATCH 011/192] perf: measure vscode graph view latency Adds an opt-in Extension Development Host performance runner for first graph render and Graph Scope Imports toggle latency. Latest measurements: first rendered graph stats 57.2s on the first run and 9.9s on a repeat run; Imports toggle around 3.0s median, which points the next bottleneck at graph surface/runtime rendering. --- docs/performance/codegraphy-monorepo.md | 25 ++ .../2026-06-22-codegraphy-performance.md | 7 +- package.json | 1 + .../performance/measure-vscode-graph-view.mjs | 248 ++++++++++++++++++ .../measure-vscode-graph-view.test.mjs | 21 ++ 5 files changed, 300 insertions(+), 2 deletions(-) create mode 100644 scripts/performance/measure-vscode-graph-view.mjs create mode 100644 tests/scripts/measure-vscode-graph-view.test.mjs diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index bd429a3ef..c1739d2ab 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -165,6 +165,31 @@ Visible Graph projection benchmark: - Scenario node and edge counts stayed unchanged after the filter optimization. +VS Code graph view benchmark: + +- Command shape: `pnpm run perf:vscode-graph-view` against this worktree, + launching Extension Development Host with local built-in plugin packages. +- Measurement target: open CodeGraphy on the monorepo, wait for the rendered + graph stats badge, then toggle the Graph Scope `Imports` edge type through + the real webview controls. +- First run: + - VS Code launch: `1518ms`. + - Open Graph View to first rendered graph stats: `57209ms`. + - Initial rendered stats: `2249` nodes, `5333` connections. + - Imports toggle latency: `3127ms` median, `3143ms` p95 across 5 samples. +- Repeat run: + - VS Code launch: `850ms`. + - Open Graph View to first rendered graph stats: `9917ms`. + - Initial rendered stats: `2249` nodes, `5333` connections. + - Imports toggle latency: `2983ms` median, `3079ms` p95 across 2 samples. + +Interpretation: + +- Headless visible graph derivation is now in the `22ms` median range, but the + real webview still takes about `3s` to reflect a Graph Scope edge toggle. +- The next user-facing bottleneck is in the graph surface/runtime/render path, + not in filter pattern derivation. + Full test baseline: - `pnpm run test`: `1523.98s` wall time diff --git a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md index 6abbe2ec1..2bec69ef7 100644 --- a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md +++ b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md @@ -37,6 +37,9 @@ - Visible Graph projection benchmark before filter optimization: current settings `775ms` median / `933ms` p95, folders-on Graph Scope `1369ms` median / `1445ms` p95, import-edge-hidden `153ms` median. - Visible Graph projection after reusable glob matchers and skipping direct edge matching for path-only filters: current settings `22ms` median / `26ms` p95, folders-on Graph Scope `31ms` median / `32ms` p95, import-edge-hidden `17ms` median / `18ms` p95. - Visible Graph scenario node and edge counts stayed unchanged across the filter optimization. +- VS Code graph view benchmark first run: first rendered graph stats took `57209ms`; Imports Graph Scope toggle was `3127ms` median / `3143ms` p95. +- VS Code graph view benchmark repeat run: first rendered graph stats took `9917ms`; Imports Graph Scope toggle was `2983ms` median / `3079ms` p95. +- Current user-facing bottleneck moved to graph surface/runtime/render work after visible graph derivation. - Raw logs are ignored under `reports/performance/`; commit only scripts and bounded summaries under `docs/performance/`. ## Success Metrics @@ -222,7 +225,7 @@ PATH=/opt/homebrew/bin:/opt/homebrew/opt/node@22/bin:$PATH /usr/bin/time -l node PATH=/opt/homebrew/bin:/opt/homebrew/opt/node@22/bin:$PATH /opt/homebrew/bin/pnpm run perf:codegraphy-monorepo -- --index-log reports/performance/codegraphy-index-cold-phases-local-node22-2026-06-22.log ``` -- [ ] **Step 2: Measure VS Code user-facing timings** +- [x] **Step 2: Measure VS Code user-facing timings** Use the Playwright VS Code lane or the Mac mini to open the same workspace and capture: @@ -233,7 +236,7 @@ Display Setting toggle -> updated view state single file save -> Live Update complete ``` -- [ ] **Step 3: Commit the baseline metrics** +- [x] **Step 3: Commit the baseline metrics** Commit the bounded summary and keep raw logs ignored under `reports/performance/`. diff --git a/package.json b/package.json index 5ab8e62ea..72a812ba3 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "test:vscode": "pnpm -r --if-present run test:vscode", "perf:codegraphy-monorepo": "node scripts/performance/measure-codegraphy-monorepo.mjs --workspace .", "perf:visible-graph-monorepo": "tsx scripts/performance/measure-visible-graph-monorepo.mjs --workspace .", + "perf:vscode-graph-view": "tsx scripts/performance/measure-vscode-graph-view.mjs --workspace .", "check:acceptance-specs": "node scripts/guard-acceptance-spec-edits.mjs", "lint": "turbo run lint", "crap": "quality-tools crap", diff --git a/scripts/performance/measure-vscode-graph-view.mjs b/scripts/performance/measure-vscode-graph-view.mjs new file mode 100644 index 000000000..bd328ef15 --- /dev/null +++ b/scripts/performance/measure-vscode-graph-view.mjs @@ -0,0 +1,248 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import { performance } from 'node:perf_hooks'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + summarizeDurations, + writeMetrics, +} from './measure-codegraphy-monorepo.mjs'; + +const DEFAULT_OUTPUT_PATH = 'reports/performance/vscode-graph-view-latest.json'; +const DEFAULT_ITERATIONS = 5; +const DEFAULT_WARMUP_ITERATIONS = 1; +const DEFAULT_TIMEOUT_MS = 120_000; +const DEFAULT_PLUGIN_PACKAGE_RELATIVE_PATHS = [ + 'packages/plugin-godot', + 'packages/plugin-markdown', + 'packages/plugin-particles', + 'packages/plugin-svelte', + 'packages/plugin-typescript', + 'packages/plugin-unity', + 'packages/plugin-vue', +]; + +function readOptionValue(args, name) { + const index = args.indexOf(name); + return index >= 0 ? args[index + 1] : undefined; +} + +function hasFlag(args, name) { + return args.includes(name); +} + +function toPositiveInteger(value, defaultValue) { + if (!value) { + return defaultValue; + } + + const parsed = Number(value); + return Number.isInteger(parsed) && parsed > 0 ? parsed : defaultValue; +} + +function parseCount(value) { + return Number(value.replace(/,/g, '')); +} + +export function parseGraphStatsLabel(label) { + const match = /([\d,]+)\s+nodes?.*?([\d,]+)\s+connections?/i.exec(label); + if (!match) { + return null; + } + + return { + nodeCount: parseCount(match[1]), + edgeCount: parseCount(match[2]), + }; +} + +function sameGraphStats(left, right) { + return left.nodeCount === right.nodeCount && left.edgeCount === right.edgeCount; +} + +async function readGraphStats(frame) { + const text = await frame + .getByText(/[\d,]+\s+nodes?.*?[\d,]+\s+connections?/i) + .first() + .textContent({ timeout: 1_000 }) + .catch(() => null); + return text ? parseGraphStatsLabel(text) : null; +} + +async function waitForGraphStats(frame, predicate, timeoutMs = DEFAULT_TIMEOUT_MS) { + const startedAt = performance.now(); + let lastStats = null; + + while (performance.now() - startedAt < timeoutMs) { + const stats = await readGraphStats(frame); + if (stats) { + lastStats = stats; + if (predicate(stats)) { + return stats; + } + } + + await frame.waitForTimeout(100); + } + + throw new Error(`Timed out waiting for graph stats. Last stats: ${JSON.stringify(lastStats)}`); +} + +async function openGraphScopeEdgeTypes(frame) { + await frame.getByTitle('Graph Scope').click({ force: true }); + const edgeTypesButton = frame.getByRole('button', { name: 'Edge Types' }); + await edgeTypesButton.click({ timeout: DEFAULT_TIMEOUT_MS }); +} + +function graphScopeSwitch(frame, label) { + return frame.getByRole('switch', { name: `Toggle ${label}`, exact: true }); +} + +async function readSwitchEnabled(frame, label) { + const value = await graphScopeSwitch(frame, label).getAttribute('aria-checked', { + timeout: DEFAULT_TIMEOUT_MS, + }); + return value === 'true'; +} + +async function waitForSwitchEnabled(frame, label, enabled) { + const expected = String(enabled); + const startedAt = performance.now(); + + while (performance.now() - startedAt < DEFAULT_TIMEOUT_MS) { + const value = await graphScopeSwitch(frame, label).getAttribute('aria-checked').catch(() => null); + if (value === expected) { + return; + } + + await frame.waitForTimeout(50); + } + + throw new Error(`Timed out waiting for ${label} switch to become ${expected}`); +} + +async function measureSwitchTransition(frame, label, enabled) { + const beforeStats = await waitForGraphStats(frame, stats => stats.nodeCount > 0); + const startedAt = performance.now(); + await graphScopeSwitch(frame, label).click(); + await waitForSwitchEnabled(frame, label, enabled); + const afterStats = await waitForGraphStats(frame, stats => !sameGraphStats(stats, beforeStats)); + + return { + durationMs: Math.round(performance.now() - startedAt), + enabled, + beforeStats, + afterStats, + }; +} + +async function restoreWorkspaceSettings(settingsPath, originalSettings) { + if (originalSettings === null) { + return; + } + + await writeFile(settingsPath, originalSettings); +} + +async function measureVSCodeGraphView({ + iterations, + outputPath, + warmupIterations, + workspacePath, +}) { + const workspaceRoot = path.resolve(workspacePath); + const settingsPath = path.join(workspaceRoot, '.codegraphy', 'settings.json'); + const originalSettings = await readFile(settingsPath, 'utf8').catch(() => null); + const { + cleanupVSCode, + launchVSCodeWithWorkspace, + openGraphView, + waitForGraphFrame, + } = await import('../../packages/extension/tests/acceptance/graphView/vscode.ts'); + let vscode = null; + + try { + const launchStartedAt = performance.now(); + vscode = await launchVSCodeWithWorkspace(workspaceRoot, { + pluginPackageRelativePaths: DEFAULT_PLUGIN_PACKAGE_RELATIVE_PATHS, + }); + const launchMs = Math.round(performance.now() - launchStartedAt); + const openStartedAt = performance.now(); + await openGraphView(vscode.page); + const frame = await waitForGraphFrame(vscode.page); + const initialStats = await waitForGraphStats(frame, stats => stats.nodeCount > 0); + const firstGraphReadyMs = Math.round(performance.now() - openStartedAt); + + await openGraphScopeEdgeTypes(frame); + const initialImportsEnabled = await readSwitchEnabled(frame, 'Imports'); + let nextImportsEnabled = !initialImportsEnabled; + const samples = []; + + for (let index = 0; index < warmupIterations + iterations; index += 1) { + const sample = await measureSwitchTransition(frame, 'Imports', nextImportsEnabled); + if (index >= warmupIterations) { + samples.push(sample); + } + nextImportsEnabled = !nextImportsEnabled; + } + + if (await readSwitchEnabled(frame, 'Imports') !== initialImportsEnabled) { + await measureSwitchTransition(frame, 'Imports', initialImportsEnabled); + } + + const measurements = { + vscodeLaunchMs: launchMs, + firstGraphReadyMs, + initialStats, + importsToggle: { + ...summarizeDurations(samples.map(sample => sample.durationMs)), + samples, + }, + }; + + await writeMetrics({ outputPath, workspacePath: workspaceRoot, measurements }); + return measurements; + } finally { + if (vscode) { + await cleanupVSCode(vscode); + } + await restoreWorkspaceSettings(settingsPath, originalSettings); + } +} + +function printUsage() { + process.stdout.write([ + 'Usage:', + ' pnpm exec tsx scripts/performance/measure-vscode-graph-view.mjs [--workspace ] [--iterations ] [--warmup ] [--output ]', + '', + 'Launches Extension Development Host, opens CodeGraphy, and times rendered Graph Scope toggle latency.', + ].join('\n')); +} + +async function runCli(argv) { + if (hasFlag(argv, '--help')) { + printUsage(); + return; + } + + const workspacePath = readOptionValue(argv, '--workspace') ?? process.cwd(); + const outputPath = readOptionValue(argv, '--output') ?? DEFAULT_OUTPUT_PATH; + const iterations = toPositiveInteger(readOptionValue(argv, '--iterations'), DEFAULT_ITERATIONS); + const warmupIterations = toPositiveInteger(readOptionValue(argv, '--warmup'), DEFAULT_WARMUP_ITERATIONS); + + await measureVSCodeGraphView({ + iterations, + outputPath, + warmupIterations, + workspacePath, + }); +} + +const isDirectRun = process.argv[1] + && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); + +if (isDirectRun) { + runCli(process.argv.slice(2)).catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + }); +} diff --git a/tests/scripts/measure-vscode-graph-view.test.mjs b/tests/scripts/measure-vscode-graph-view.test.mjs new file mode 100644 index 000000000..e139f28d6 --- /dev/null +++ b/tests/scripts/measure-vscode-graph-view.test.mjs @@ -0,0 +1,21 @@ +import assert from 'node:assert/strict'; +import path from 'node:path'; +import test from 'node:test'; +import { pathToFileURL } from 'node:url'; + +test('VS Code graph view runner parses graph stats labels', async () => { + const moduleUrl = pathToFileURL( + path.resolve('scripts/performance/measure-vscode-graph-view.mjs'), + ).href; + const { parseGraphStatsLabel } = await import(moduleUrl); + + assert.deepEqual(parseGraphStatsLabel('2,246 nodes • 3,130 connections'), { + nodeCount: 2246, + edgeCount: 3130, + }); + assert.deepEqual(parseGraphStatsLabel('1 node • 1 connection'), { + nodeCount: 1, + edgeCount: 1, + }); + assert.equal(parseGraphStatsLabel('Loading graph...'), null); +}); From 7253e528c06ffa92509a419a28c3dbaff13b473f Mon Sep 17 00:00:00 2001 From: joesobo Date: Mon, 22 Jun 2026 17:06:01 -0700 Subject: [PATCH 012/192] perf: skip settled graph cooldown ticks When every interactive graph node already has finite coordinates, render the update without additional force-layout cooldown ticks. Fresh/unpositioned layouts and timeline mode keep their existing cooldowns. VS Code Graph Scope Imports toggle on the CodeGraphy monorepo improved from the repeat-run 2983ms median / 3079ms p95 baseline to 1925ms median / 2341ms p95 across 5 samples. --- .changeset/settled-graph-cooldown.md | 5 +++++ docs/performance/codegraphy-monorepo.md | 16 +++++++++++++--- .../plans/2026-06-22-codegraphy-performance.md | 9 ++++++++- .../graph/rendering/surface/sharedProps.ts | 18 +++++++++++++++++- .../graph/rendering/sharedProps.test.ts | 17 +++++++++++++++-- 5 files changed, 58 insertions(+), 7 deletions(-) create mode 100644 .changeset/settled-graph-cooldown.md diff --git a/.changeset/settled-graph-cooldown.md b/.changeset/settled-graph-cooldown.md new file mode 100644 index 000000000..a4a21b679 --- /dev/null +++ b/.changeset/settled-graph-cooldown.md @@ -0,0 +1,5 @@ +--- +"@codegraphy-dev/extension": patch +--- + +Make settled Graph Scope updates feel faster by skipping force-layout cooldown ticks when every visible node already has a position. diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index c1739d2ab..8a5d24f67 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -182,13 +182,23 @@ VS Code graph view benchmark: - Open Graph View to first rendered graph stats: `9917ms`. - Initial rendered stats: `2249` nodes, `5333` connections. - Imports toggle latency: `2983ms` median, `3079ms` p95 across 2 samples. +- After skipping force-graph cooldown ticks for already-positioned interactive + graphs: + - VS Code launch: `1408ms`. + - Open Graph View to first rendered graph stats: `9846ms`. + - Initial rendered stats: `2249` nodes, `5333` connections. + - Imports toggle latency: `1925ms` median, `2341ms` p95 across 5 samples. Interpretation: - Headless visible graph derivation is now in the `22ms` median range, but the - real webview still takes about `3s` to reflect a Graph Scope edge toggle. -- The next user-facing bottleneck is in the graph surface/runtime/render path, - not in filter pattern derivation. + real webview initially took about `3s` to reflect a Graph Scope edge toggle. +- Skipping settled-graph simulation ticks moves the real toggle median from the + repeat-run `2983ms` baseline to `1925ms`, a `35%` improvement, but this is + still not editor-snappy. +- The next user-facing bottleneck remains in the graph surface/runtime/render + path, likely the synchronous force-graph `graphData` update and canvas redraw + for thousands of visible objects. Full test baseline: diff --git a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md index 2bec69ef7..a2863ca2e 100644 --- a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md +++ b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md @@ -269,10 +269,17 @@ Keep each commit scoped to one measured path. Compare the metric before committing. -- [ ] **Step 5: Commit and push** +- [x] **Step 5: Commit and push** Commit each improvement separately with the metric delta in the commit body or PR comment. +Latest committed improvement: + +```text +Settled interactive graph updates skip force-graph cooldown ticks. +Imports Graph Scope toggle: 2983ms median / 3079ms p95 before, 1925ms median / 2341ms p95 after. +``` + ## Task 5: Keep The PR Reviewable **Files:** diff --git a/packages/extension/src/webview/components/graph/rendering/surface/sharedProps.ts b/packages/extension/src/webview/components/graph/rendering/surface/sharedProps.ts index e61e0e1fc..21d40eaa5 100644 --- a/packages/extension/src/webview/components/graph/rendering/surface/sharedProps.ts +++ b/packages/extension/src/webview/components/graph/rendering/surface/sharedProps.ts @@ -3,6 +3,7 @@ import type { LinkObject, NodeObject } from 'react-force-graph-2d'; import type { FGLink, FGNode } from '../../model/build'; export const INTERACTIVE_COOLDOWN_TICKS = 60; +export const POSITIONED_INTERACTIVE_COOLDOWN_TICKS = 0; export const TIMELINE_COOLDOWN_TICKS = 50; export interface GraphContainerSize { @@ -55,6 +56,21 @@ export function normalizeGraphDimension(value: number): number | undefined { return value === 0 ? undefined : value; } +function everyNodeHasFinitePosition(nodes: readonly FGNode[]): boolean { + return nodes.length > 0 + && nodes.every(node => Number.isFinite(node.x) && Number.isFinite(node.y)); +} + +function getCooldownTicks(options: Pick): number { + if (options.timelineActive) { + return TIMELINE_COOLDOWN_TICKS; + } + + return everyNodeHasFinitePosition(options.graphData.nodes) + ? POSITIONED_INTERACTIVE_COOLDOWN_TICKS + : INTERACTIVE_COOLDOWN_TICKS; +} + export function buildSharedGraphProps( options: BuildSharedGraphPropsOptions, ): GraphSurfaceSharedProps { @@ -83,7 +99,7 @@ export function buildSharedGraphProps( d3VelocityDecay: options.damping, d3AlphaDecay: 0.0228, warmupTicks: 0, - cooldownTicks: options.timelineActive ? TIMELINE_COOLDOWN_TICKS : INTERACTIVE_COOLDOWN_TICKS, + cooldownTicks: getCooldownTicks(options), nodeId: 'id', onNodeHover: (node) => options.onNodeHover(node as FGNode | null), dagMode: options.dagMode ?? undefined, diff --git a/packages/extension/tests/webview/graph/rendering/sharedProps.test.ts b/packages/extension/tests/webview/graph/rendering/sharedProps.test.ts index f1124d21b..6d34a1c99 100644 --- a/packages/extension/tests/webview/graph/rendering/sharedProps.test.ts +++ b/packages/extension/tests/webview/graph/rendering/sharedProps.test.ts @@ -4,6 +4,7 @@ import { buildSharedGraphProps, INTERACTIVE_COOLDOWN_TICKS, normalizeGraphDimension, + POSITIONED_INTERACTIVE_COOLDOWN_TICKS, TIMELINE_COOLDOWN_TICKS, type BuildSharedGraphPropsOptions, } from '../../../../src/webview/components/graph/rendering/surface/sharedProps'; @@ -78,7 +79,7 @@ describe('graph/rendering/surface/sharedProps', () => { expect(props.d3VelocityDecay).toBe(0.7); expect(props.d3AlphaDecay).toBe(0.0228); expect(props.warmupTicks).toBe(0); - expect(props.cooldownTicks).toBeGreaterThan(0); + expect(props.cooldownTicks).toBe(POSITIONED_INTERACTIVE_COOLDOWN_TICKS); expect(props.dagMode).toBe('td'); expect(props.dagLevelDistance).toBe(60); }); @@ -97,7 +98,7 @@ describe('graph/rendering/surface/sharedProps', () => { expect(props.dagLevelDistance).toBeUndefined(); }); - it('keeps positioned interactive graphs on the normal physics cooldown', () => { + it('uses the short physics cooldown once every interactive node has a position', () => { const props = buildSharedGraphProps(createOptions({ graphData: { links: [createLink()], @@ -105,6 +106,18 @@ describe('graph/rendering/surface/sharedProps', () => { }, })); + expect(POSITIONED_INTERACTIVE_COOLDOWN_TICKS).toBe(0); + expect(props.cooldownTicks).toBe(POSITIONED_INTERACTIVE_COOLDOWN_TICKS); + }); + + it('keeps unpositioned interactive graphs on the normal physics cooldown', () => { + const props = buildSharedGraphProps(createOptions({ + graphData: { + links: [createLink()], + nodes: [createNode()], + }, + })); + expect(props.cooldownTicks).toBe(INTERACTIVE_COOLDOWN_TICKS); }); From 759703b333a4ea4def54f0a8ccd08fa10e15a216 Mon Sep 17 00:00:00 2001 From: joesobo Date: Mon, 22 Jun 2026 17:18:36 -0700 Subject: [PATCH 013/192] perf: pass constant arrow settings to graph renderer Evaluate the constant 2D arrow color and arrow position once per render/settings update instead of passing callbacks that force-graph invokes for every edge. VS Code Graph Scope Imports toggle on the CodeGraphy monorepo improved from 1925ms median / 2341ms p95 after the cooldown iteration to 1595ms median / 1620ms p95 across 5 samples. --- .changeset/settled-graph-cooldown.md | 2 +- docs/performance/codegraphy-monorepo.md | 11 +++++++++-- .../plans/2026-06-22-codegraphy-performance.md | 1 + .../graph/rendering/surface/view/twoDimensional.tsx | 7 +++++-- .../graph/runtime/use/indicators/directional.ts | 7 +++++-- .../rendering/surface/view/twoDimensional.test.tsx | 11 +++++++++++ ...ticlesizetoreappliessettingswhenthegraph.test.tsx | 12 ++++++------ .../runtime/use/indicators/directional.test.tsx | 8 ++++---- 8 files changed, 42 insertions(+), 17 deletions(-) diff --git a/.changeset/settled-graph-cooldown.md b/.changeset/settled-graph-cooldown.md index a4a21b679..5c18f36e8 100644 --- a/.changeset/settled-graph-cooldown.md +++ b/.changeset/settled-graph-cooldown.md @@ -2,4 +2,4 @@ "@codegraphy-dev/extension": patch --- -Make settled Graph Scope updates feel faster by skipping force-layout cooldown ticks when every visible node already has a position. +Make settled Graph Scope updates feel faster by skipping force-layout cooldown ticks when every visible node already has a position and avoiding per-edge callbacks for constant 2D arrow settings. diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index 8a5d24f67..10c1eabb1 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -188,6 +188,11 @@ VS Code graph view benchmark: - Open Graph View to first rendered graph stats: `9846ms`. - Initial rendered stats: `2249` nodes, `5333` connections. - Imports toggle latency: `1925ms` median, `2341ms` p95 across 5 samples. +- After passing constant 2D arrow color and position values to force-graph: + - VS Code launch: `1419ms`. + - Open Graph View to first rendered graph stats: `9612ms`. + - Initial rendered stats: `2249` nodes, `5333` connections. + - Imports toggle latency: `1595ms` median, `1620ms` p95 across 5 samples. Interpretation: @@ -196,9 +201,11 @@ Interpretation: - Skipping settled-graph simulation ticks moves the real toggle median from the repeat-run `2983ms` baseline to `1925ms`, a `35%` improvement, but this is still not editor-snappy. +- Passing arrow color and arrow position as primitive values instead of per-edge + callbacks moves the median to `1595ms` and trims the p95 to `1620ms`. - The next user-facing bottleneck remains in the graph surface/runtime/render - path, likely the synchronous force-graph `graphData` update and canvas redraw - for thousands of visible objects. + path, likely the synchronous force-graph `graphData` update and full canvas + redraw for thousands of visible objects. Full test baseline: diff --git a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md index a2863ca2e..6ef102e75 100644 --- a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md +++ b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md @@ -278,6 +278,7 @@ Latest committed improvement: ```text Settled interactive graph updates skip force-graph cooldown ticks. Imports Graph Scope toggle: 2983ms median / 3079ms p95 before, 1925ms median / 2341ms p95 after. +2D arrow constants: 1925ms median / 2341ms p95 before, 1595ms median / 1620ms p95 after. ``` ## Task 5: Keep The PR Reviewable diff --git a/packages/extension/src/webview/components/graph/rendering/surface/view/twoDimensional.tsx b/packages/extension/src/webview/components/graph/rendering/surface/view/twoDimensional.tsx index 8d4d5e110..c6b313dd2 100644 --- a/packages/extension/src/webview/components/graph/rendering/surface/view/twoDimensional.tsx +++ b/packages/extension/src/webview/components/graph/rendering/surface/view/twoDimensional.tsx @@ -53,6 +53,9 @@ export function Surface2d({ particleSpeed, sharedProps, }: Surface2dProps): ReactElement { + const arrowColor = getArrowColor({} as LinkObject); + const arrowRelPos = getArrowRelPos({} as LinkObject); + return ( { expect(props.linkDirectionalArrowLength).toBe(12); }); + it('passes constant arrow position and color values', () => { + const defaultProps = createDefaultProps(); + render(); + const props = (ForceGraph2D as unknown as { getLastProps: () => Record }).getLastProps(); + + expect(props.linkDirectionalArrowRelPos).toBe(1); + expect(props.linkDirectionalArrowColor).toBe('#ffffff'); + expect(defaultProps.getArrowRelPos).toHaveBeenCalledOnce(); + expect(defaultProps.getArrowColor).toHaveBeenCalledOnce(); + }); + it('sets linkDirectionalArrowLength to 0 when direction mode is not arrows', () => { diff --git a/packages/extension/tests/webview/graph/runtime/use/indicators/directional.reappliessettingswhenparticlesizetoreappliessettingswhenthegraph.test.tsx b/packages/extension/tests/webview/graph/runtime/use/indicators/directional.reappliessettingswhenparticlesizetoreappliessettingswhenthegraph.test.tsx index 26e9a76ec..adbf7d8dd 100644 --- a/packages/extension/tests/webview/graph/runtime/use/indicators/directional.reappliessettingswhenparticlesizetoreappliessettingswhenthegraph.test.tsx +++ b/packages/extension/tests/webview/graph/runtime/use/indicators/directional.reappliessettingswhenparticlesizetoreappliessettingswhenthegraph.test.tsx @@ -24,8 +24,8 @@ function createDirectionalOptions( ): Parameters[1] { return { directionMode: 'particles', - getArrowColor: vi.fn(), - getArrowRelPos: vi.fn(), + getArrowColor: vi.fn(() => '#abcdef'), + getArrowRelPos: vi.fn(() => 1), getLinkParticles: vi.fn(), getParticleColor: vi.fn(), particleSize: 3, @@ -110,7 +110,7 @@ describe('useDirectional', () => { (props: Parameters[0]) => useDirectional(props), { initialProps: options }, ); - const getArrowColor = vi.fn(); + const getArrowColor = vi.fn(() => '#fedcba'); vi.clearAllMocks(); rerender({ @@ -118,7 +118,7 @@ describe('useDirectional', () => { getArrowColor, }); - expect(graph.linkDirectionalArrowColor).toHaveBeenCalledWith(getArrowColor); + expect(graph.linkDirectionalArrowColor).toHaveBeenCalledWith('#fedcba'); expect(graph.d3ReheatSimulation).toHaveBeenCalledOnce(); }); @@ -133,7 +133,7 @@ describe('useDirectional', () => { (props: Parameters[0]) => useDirectional(props), { initialProps: options }, ); - const getArrowRelPos = vi.fn(); + const getArrowRelPos = vi.fn(() => 0.75); vi.clearAllMocks(); rerender({ @@ -141,7 +141,7 @@ describe('useDirectional', () => { getArrowRelPos, }); - expect(graph.linkDirectionalArrowRelPos).toHaveBeenCalledWith(getArrowRelPos); + expect(graph.linkDirectionalArrowRelPos).toHaveBeenCalledWith(0.75); expect(graph.d3ReheatSimulation).toHaveBeenCalledOnce(); }); diff --git a/packages/extension/tests/webview/graph/runtime/use/indicators/directional.test.tsx b/packages/extension/tests/webview/graph/runtime/use/indicators/directional.test.tsx index eb2632d2f..24e88483b 100644 --- a/packages/extension/tests/webview/graph/runtime/use/indicators/directional.test.tsx +++ b/packages/extension/tests/webview/graph/runtime/use/indicators/directional.test.tsx @@ -24,8 +24,8 @@ function createDirectionalOptions( ): Parameters[1] { return { directionMode: 'particles', - getArrowColor: vi.fn(), - getArrowRelPos: vi.fn(), + getArrowColor: vi.fn(() => '#abcdef'), + getArrowRelPos: vi.fn(() => 1), getLinkParticles: vi.fn(), getParticleColor: vi.fn(), particleSize: 3, @@ -63,11 +63,11 @@ describe('useDirectional', () => { applyDirectionalSettings(graph, options); expect(graph.linkDirectionalArrowLength).toHaveBeenCalledWith(0); - expect(graph.linkDirectionalArrowRelPos).toHaveBeenCalledWith(options.getArrowRelPos); + expect(graph.linkDirectionalArrowRelPos).toHaveBeenCalledWith(1); expect(graph.linkDirectionalParticles).toHaveBeenCalledWith(options.getLinkParticles); expect(graph.linkDirectionalParticleWidth).toHaveBeenCalledWith(3); expect(graph.linkDirectionalParticleSpeed).toHaveBeenCalledWith(0.15); - expect(graph.linkDirectionalArrowColor).toHaveBeenCalledWith(options.getArrowColor); + expect(graph.linkDirectionalArrowColor).toHaveBeenCalledWith('#abcdef'); expect(graph.linkDirectionalParticleColor).toHaveBeenCalledWith(options.getParticleColor); expect(graph.d3ReheatSimulation).toHaveBeenCalledOnce(); expect(graph.resumeAnimation).toHaveBeenCalledOnce(); From fd3125b70caa9d72f02ac140df616eb5748f7ac2 Mon Sep 17 00:00:00 2001 From: joesobo Date: Mon, 22 Jun 2026 18:03:50 -0700 Subject: [PATCH 014/192] perf: memoize graph viewport surface Keep viewport overlay, tooltip, stats, and accessibility updates from re-rendering the force-graph surface when the surface inputs are unchanged. VS Code graph-view Imports toggle metric on the CodeGraphy monorepo: same-environment control 2891ms median / 3563ms p95; memoized surface 1628ms median / 2252ms p95. Verified with graph/webview vitest slice, typecheck, lint, and build:vscode. --- .changeset/settled-graph-cooldown.md | 2 +- docs/performance/codegraphy-monorepo.md | 17 ++++++ .../2026-06-22-codegraphy-performance.md | 3 + .../components/graph/viewport/shell.tsx | 24 +++++--- .../components/graph/viewport/view.tsx | 54 +++++++++++++++++- .../webview/graph/viewport/view.test.tsx | 56 +++++++++++++++++++ 6 files changed, 145 insertions(+), 11 deletions(-) diff --git a/.changeset/settled-graph-cooldown.md b/.changeset/settled-graph-cooldown.md index 5c18f36e8..0cee2b2ea 100644 --- a/.changeset/settled-graph-cooldown.md +++ b/.changeset/settled-graph-cooldown.md @@ -2,4 +2,4 @@ "@codegraphy-dev/extension": patch --- -Make settled Graph Scope updates feel faster by skipping force-layout cooldown ticks when every visible node already has a position and avoiding per-edge callbacks for constant 2D arrow settings. +Make settled Graph Scope updates feel faster by skipping force-layout cooldown ticks when every visible node already has a position, avoiding per-edge callbacks for constant 2D arrow settings, and preventing viewport overlay updates from re-rendering the graph surface. diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index 10c1eabb1..4d59efdd5 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -193,6 +193,20 @@ VS Code graph view benchmark: - Open Graph View to first rendered graph stats: `9612ms`. - Initial rendered stats: `2249` nodes, `5333` connections. - Imports toggle latency: `1595ms` median, `1620ms` p95 across 5 samples. +- Fresh control after reverting the hidden-edge experiment: + - VS Code launch: `1292ms`. + - Open Graph View to first rendered graph stats: `9938ms`. + - Initial rendered stats: `2249` nodes, `5333` connections. + - Imports toggle latency: `2891ms` median, `3563ms` p95 across 5 samples. +- Rejected stable-edge experiment: + - Keeping the full rendered graph stable and hiding filtered edges through + force-graph visibility callbacks measured `2918ms` to `2922ms` median + across variants, so it did not improve over the same-environment control. +- After memoizing the graph viewport surface: + - VS Code launch: `1478ms`. + - Open Graph View to first rendered graph stats: `9753ms`. + - Initial rendered stats: `2249` nodes, `5333` connections. + - Imports toggle latency: `1628ms` median, `2252ms` p95 across 5 samples. Interpretation: @@ -203,6 +217,9 @@ Interpretation: still not editor-snappy. - Passing arrow color and arrow position as primitive values instead of per-edge callbacks moves the median to `1595ms` and trims the p95 to `1620ms`. +- The same-environment control varied back to `2891ms`; memoizing the viewport + surface moved it to `1628ms` by keeping overlay, tooltip, stats, and + accessibility state churn from re-rendering the force-graph surface. - The next user-facing bottleneck remains in the graph surface/runtime/render path, likely the synchronous force-graph `graphData` update and full canvas redraw for thousands of visible objects. diff --git a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md index 6ef102e75..b1336c865 100644 --- a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md +++ b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md @@ -279,6 +279,9 @@ Latest committed improvement: Settled interactive graph updates skip force-graph cooldown ticks. Imports Graph Scope toggle: 2983ms median / 3079ms p95 before, 1925ms median / 2341ms p95 after. 2D arrow constants: 1925ms median / 2341ms p95 before, 1595ms median / 1620ms p95 after. +Fresh same-environment control before viewport memoization: 2891ms median / 3563ms p95. +Rejected stable-edge visibility callbacks: 2918ms to 2922ms median, no improvement. +Memoized viewport surface: 2891ms median / 3563ms p95 control, 1628ms median / 2252ms p95 after. ``` ## Task 5: Keep The PR Reviewable diff --git a/packages/extension/src/webview/components/graph/viewport/shell.tsx b/packages/extension/src/webview/components/graph/viewport/shell.tsx index 49328f149..f0e4acf06 100644 --- a/packages/extension/src/webview/components/graph/viewport/shell.tsx +++ b/packages/extension/src/webview/components/graph/viewport/shell.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, type ReactElement } from 'react'; +import { useCallback, useEffect, useRef, useState, type ReactElement } from 'react'; import type { CoreGraphViewContributionSet } from '@codegraphy-dev/core'; import type { ThemeKind } from '../../../theme/useTheme'; import type { GraphAppearance } from '../appearance/model'; @@ -9,7 +9,7 @@ import type { UseGraphInteractionRuntimeResult } from '../runtime/use/interactio import type { GraphRuntime } from '../runtime/use/state'; import { useGraphRenderingRuntime } from '../runtime/use/rendering'; import { useGraphEventEffects } from '../runtime/use/events/effects'; -import { Viewport } from './view'; +import { Viewport, type ViewportProps } from './view'; import { graphStore } from '../../../store/state'; import { publishGraphViewportScale as publishGraphViewportScaleChange } from './shell/scale'; import { buildRenderingRuntimeOptions } from './shell/runtimeOptions'; @@ -72,6 +72,7 @@ export function GraphViewportShell({ const lastPublishedViewportScaleRef = useRef(null); const lastAccessibilitySignatureRef = useRef(''); const accessibilityDirtyRef = useRef(true); + const renderFramePostRef = useRef(() => undefined); const [accessibilityItems, setAccessibilityItems] = useState({ nodes: [], edges: [], @@ -174,6 +175,18 @@ export function GraphViewportShell({ accessibilityDirtyRef.current = false; }; + renderFramePostRef.current = (ctx, globalScale) => { + publishGraphViewportScale(globalScale); + publishGraphViewViewportState(globalScale); + publishGraphAccessibilityItems(); + viewportRuntime.renderPluginOverlays(ctx, globalScale); + }; + + const handleRenderFramePost = useCallback( + (ctx, globalScale) => renderFramePostRef.current(ctx, globalScale), + [], + ); + useEffect(() => { return () => { pluginHost?.setGraphViewViewportState(null); @@ -187,12 +200,7 @@ export function GraphViewportShell({ const surfaceProps = createGraphViewportSurfaceProps({ callbacks, graphState, - onRenderFramePost: (ctx, globalScale) => { - publishGraphViewportScale(globalScale); - publishGraphViewViewportState(globalScale); - publishGraphAccessibilityItems(); - viewportRuntime.renderPluginOverlays(ctx, globalScale); - }, + onRenderFramePost: handleRenderFramePost, sharedProps: viewportModel.sharedProps, viewState, }); diff --git a/packages/extension/src/webview/components/graph/viewport/view.tsx b/packages/extension/src/webview/components/graph/viewport/view.tsx index 075615ca5..8449f6d35 100644 --- a/packages/extension/src/webview/components/graph/viewport/view.tsx +++ b/packages/extension/src/webview/components/graph/viewport/view.tsx @@ -1,4 +1,4 @@ -import { useRef, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactElement, type Ref } from 'react'; +import { memo, useRef, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactElement, type Ref } from 'react'; import type { DirectionMode } from '../../../../shared/settings/modes'; import type { GraphMarqueeSelectionState } from '../marqueeSelection/model'; import type { GraphTooltipState } from '../tooltip/model'; @@ -107,6 +107,56 @@ function ViewportSurface({ ); } +function areSurface2dPropsEqual( + previous: ViewportSurfaceProps['surface2dProps'], + next: ViewportSurfaceProps['surface2dProps'], +): boolean { + return previous.fg2dRef === next.fg2dRef + && previous.getArrowColor === next.getArrowColor + && previous.getArrowRelPos === next.getArrowRelPos + && previous.getLinkColor === next.getLinkColor + && previous.getLinkParticles === next.getLinkParticles + && previous.getLinkWidth === next.getLinkWidth + && previous.getParticleColor === next.getParticleColor + && previous.linkCanvasObject === next.linkCanvasObject + && previous.nodeCanvasObject === next.nodeCanvasObject + && previous.nodePointerAreaPaint === next.nodePointerAreaPaint + && previous.onRenderFramePost === next.onRenderFramePost + && previous.particleSize === next.particleSize + && previous.particleSpeed === next.particleSpeed + && previous.sharedProps === next.sharedProps; +} + +function areSurface3dPropsEqual( + previous: ViewportSurfaceProps['surface3dProps'], + next: ViewportSurfaceProps['surface3dProps'], +): boolean { + return previous.fg3dRef === next.fg3dRef + && previous.getArrowColor === next.getArrowColor + && previous.getLinkColor === next.getLinkColor + && previous.getLinkParticles === next.getLinkParticles + && previous.getLinkWidth === next.getLinkWidth + && previous.getParticleColor === next.getParticleColor + && previous.nodeThreeObject === next.nodeThreeObject + && previous.particleSize === next.particleSize + && previous.particleSpeed === next.particleSpeed + && previous.sharedProps === next.sharedProps; +} + +function areViewportSurfacePropsEqual( + previous: ViewportSurfaceProps, + next: ViewportSurfaceProps, +): boolean { + return previous.canvasBackgroundColor === next.canvasBackgroundColor + && previous.directionMode === next.directionMode + && previous.graphMode === next.graphMode + && previous.onSurface3dError === next.onSurface3dError + && areSurface2dPropsEqual(previous.surface2dProps, next.surface2dProps) + && areSurface3dPropsEqual(previous.surface3dProps, next.surface3dProps); +} + +const MemoizedViewportSurface = memo(ViewportSurface, areViewportSurfacePropsEqual); + function ViewportPluginOverlay({ pluginHost, }: Pick): ReactElement | null { @@ -289,7 +339,7 @@ export function Viewport({ tabIndex={0} > - { ); }); + it('does not rerender the 2d graph surface when only viewport overlays change', () => { + const surface2dProps = createSurface2dProps(); + const surface3dProps = createSurface3dProps(surface2dProps.sharedProps); + const { rerender } = render( + , + ); + + expect(harness.surface2d).toHaveBeenCalledTimes(1); + + rerender( + , + ); + + expect(harness.surface2d).toHaveBeenCalledTimes(1); + expect(harness.nodeTooltip).toHaveBeenCalledWith(expect.objectContaining({ + path: 'src/next.ts', + visible: true, + })); + }); + it('renders the 3d graph surface when graphMode is 3d', async () => { renderViewport({ graphMode: '3d' }); From 27ef7edbf943d1b3b6cc729072918a3cd704e0e1 Mon Sep 17 00:00:00 2001 From: joesobo Date: Mon, 22 Jun 2026 18:22:24 -0700 Subject: [PATCH 015/192] perf: compile visible graph legend matchers Add opt-in webview performance events to the VS Code graph-view benchmark, then use those timings to compile legend glob matchers once per visible graph update instead of compiling them for every node and edge check. VS Code graph-view Imports toggle metric on the CodeGraphy monorepo: instrumented pre-change run 1748ms median / 2272ms p95; compiled legend matchers rerun 835ms median / 846ms p95. applyLegendRules dropped from roughly 460-490ms per pass to roughly 79-83ms per pass. Verified with webview app/graph/graphScope/search slice 312 files / 1908 tests, pnpm run typecheck, pnpm run lint, build:vscode, and perf:vscode-graph-view. --- .changeset/settled-graph-cooldown.md | 2 +- docs/performance/codegraphy-monorepo.md | 26 +++- .../2026-06-22-codegraphy-performance.md | 2 + .../extension/src/webview/app/graph/stats.tsx | 7 +- .../webview/app/shell/graphScopeVisibility.ts | 5 + .../components/graph/runtime/use/state.ts | 32 +++-- .../webview/components/graphScope/rows.tsx | 3 + packages/extension/src/webview/globMatch.ts | 2 +- .../src/webview/performance/marks.ts | 79 ++++++++++++ .../src/webview/search/filtering/rules.ts | 10 +- .../webview/search/filtering/rules/edges.ts | 61 +++++++-- .../webview/search/filtering/rules/nodes.ts | 116 +++++++++++++++++- .../src/webview/search/useFilteredGraph.ts | 34 +++-- .../webview/app/performance/marks.test.ts | 49 ++++++++ .../tests/webview/graph/drag.test.tsx | 10 +- .../performance/measure-vscode-graph-view.mjs | 44 +++++++ 16 files changed, 427 insertions(+), 55 deletions(-) create mode 100644 packages/extension/src/webview/performance/marks.ts create mode 100644 packages/extension/tests/webview/app/performance/marks.test.ts diff --git a/.changeset/settled-graph-cooldown.md b/.changeset/settled-graph-cooldown.md index 0cee2b2ea..f840d856a 100644 --- a/.changeset/settled-graph-cooldown.md +++ b/.changeset/settled-graph-cooldown.md @@ -2,4 +2,4 @@ "@codegraphy-dev/extension": patch --- -Make settled Graph Scope updates feel faster by skipping force-layout cooldown ticks when every visible node already has a position, avoiding per-edge callbacks for constant 2D arrow settings, and preventing viewport overlay updates from re-rendering the graph surface. +Make settled Graph Scope updates feel faster by skipping force-layout cooldown ticks when every visible node already has a position, avoiding per-edge callbacks for constant 2D arrow settings, preventing viewport overlay updates from re-rendering the graph surface, and reusing compiled legend rule matchers while filtering the visible graph. diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index 4d59efdd5..6f9f1b4d3 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -207,6 +207,21 @@ VS Code graph view benchmark: - Open Graph View to first rendered graph stats: `9753ms`. - Initial rendered stats: `2249` nodes, `5333` connections. - Imports toggle latency: `1628ms` median, `2252ms` p95 across 5 samples. +- Instrumented webview-stage run before compiled legend matchers: + - VS Code launch: `1297ms`. + - Open Graph View to first rendered graph stats: `10167ms`. + - Imports toggle latency: `1748ms` median, `2272ms` p95 across 5 samples. + - Stage medians: `visibleGraph.derive` about `176ms` to `187ms`; + `visibleGraph.applyLegendRules` about `460ms` to `490ms`; + `graphRuntime.buildGraphData` about `4ms` to `7ms`. +- After compiling legend rule glob matchers once per legend snapshot: + - VS Code launch: `1411ms`. + - Open Graph View to first rendered graph stats: `7659ms`. + - Initial rendered stats: `2249` nodes, `5333` connections. + - Imports toggle latency: `835ms` median, `846ms` p95 across 5 samples. + - Stage medians: `visibleGraph.derive` `176.1ms`; + `visibleGraph.applyLegendRules` `79.8ms`; + `graphRuntime.buildGraphData` `5.4ms`. Interpretation: @@ -220,9 +235,14 @@ Interpretation: - The same-environment control varied back to `2891ms`; memoizing the viewport surface moved it to `1628ms` by keeping overlay, tooltip, stats, and accessibility state churn from re-rendering the force-graph surface. -- The next user-facing bottleneck remains in the graph surface/runtime/render - path, likely the synchronous force-graph `graphData` update and full canvas - redraw for thousands of visible objects. +- Instrumentation showed the force-graph data build is small (`4ms` to `7ms`); + the next measurable bottlenecks were legend rule application and visible + graph derivation. +- Compiling legend glob matchers reduced the measured legend stage from roughly + `460ms`-`490ms` per pass to about `79ms`-`83ms`, moving the real toggle + median under `1s`. +- The next user-facing bottleneck is visible graph derivation, which still takes + about `175ms`-`186ms` per pass and can run multiple times during one toggle. Full test baseline: diff --git a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md index b1336c865..f73d19685 100644 --- a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md +++ b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md @@ -282,6 +282,8 @@ Imports Graph Scope toggle: 2983ms median / 3079ms p95 before, 1925ms median / 2 Fresh same-environment control before viewport memoization: 2891ms median / 3563ms p95. Rejected stable-edge visibility callbacks: 2918ms to 2922ms median, no improvement. Memoized viewport surface: 2891ms median / 3563ms p95 control, 1628ms median / 2252ms p95 after. +Instrumented webview stages: 1748ms median / 2272ms p95; applyLegendRules was ~460ms-490ms per pass. +Compiled legend matchers: 835ms median / 846ms p95; applyLegendRules now ~79ms-83ms per pass. ``` ## Task 5: Keep The PR Reviewable diff --git a/packages/extension/src/webview/app/graph/stats.tsx b/packages/extension/src/webview/app/graph/stats.tsx index aa472c805..272da2edf 100644 --- a/packages/extension/src/webview/app/graph/stats.tsx +++ b/packages/extension/src/webview/app/graph/stats.tsx @@ -1,4 +1,5 @@ -import React from 'react'; +import React, { useEffect } from 'react'; +import { recordWebviewPerformanceEvent } from '../../performance/marks'; const COUNT_FORMATTER = new Intl.NumberFormat('en-US'); @@ -16,6 +17,10 @@ export function buildGraphStatsLabel( } export function GraphStatsBadge({ label }: { label: string }): React.ReactElement { + useEffect(() => { + recordWebviewPerformanceEvent('graphStats.rendered', { label }); + }, [label]); + return (
{label} diff --git a/packages/extension/src/webview/app/shell/graphScopeVisibility.ts b/packages/extension/src/webview/app/shell/graphScopeVisibility.ts index 08db4b316..c5196214b 100644 --- a/packages/extension/src/webview/app/shell/graphScopeVisibility.ts +++ b/packages/extension/src/webview/app/shell/graphScopeVisibility.ts @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react'; import type { GraphState } from '../../store/state'; +import { recordWebviewPerformanceEvent } from '../../performance/marks'; export const GRAPH_SCOPE_RENDER_DEBOUNCE_MS = 80; @@ -19,6 +20,10 @@ export function useDebouncedGraphScopeVisibility( useEffect(() => { const timer = setTimeout(() => { + recordWebviewPerformanceEvent('graphScope.visibility.renderDebounced', { + edgeVisibilityCount: Object.keys(edgeVisibility).length, + nodeVisibilityCount: Object.keys(nodeVisibility).length, + }); setRenderVisibility({ edgeVisibility, nodeVisibility, diff --git a/packages/extension/src/webview/components/graph/runtime/use/state.ts b/packages/extension/src/webview/components/graph/runtime/use/state.ts index e15cc8237..51ecd7405 100644 --- a/packages/extension/src/webview/components/graph/runtime/use/state.ts +++ b/packages/extension/src/webview/components/graph/runtime/use/state.ts @@ -31,6 +31,7 @@ import { } from '../../support/contracts/forceGraph'; import type { GraphCursorStyle } from '../../support/dom'; import type { ThemeKind } from '../../../../theme/useTheme'; +import { measureWebviewPerformance } from '../../../../performance/marks'; export interface GraphMouseState { ctrlKey: boolean; @@ -208,18 +209,25 @@ export function useGraphRuntime({ } const graphData = useMemo(() => { - const resolvedGraphMode = graphMode ?? '2d'; - const nextGraphData = buildGraphData({ - data, - appearance, - nodeSizeMode: nodeSizeModeRef.current, - theme: themeRef.current, - favorites, - graphViewContributions, - graphMode: resolvedGraphMode, - bidirectionalMode, - timelineActive, - previousNodes: graphDataRef.current.nodes, + const nextGraphData = measureWebviewPerformance('graphRuntime.buildGraphData', { + edgeCount: data.edges.length, + graphMode: graphMode ?? '2d', + nodeCount: data.nodes.length, + previousNodeCount: graphDataRef.current.nodes.length, + }, () => { + const resolvedGraphMode = graphMode ?? '2d'; + return buildGraphData({ + data, + appearance, + nodeSizeMode: nodeSizeModeRef.current, + theme: themeRef.current, + favorites, + graphViewContributions, + graphMode: resolvedGraphMode, + bidirectionalMode, + timelineActive, + previousNodes: graphDataRef.current.nodes, + }); }); graphDataRef.current = nextGraphData; diff --git a/packages/extension/src/webview/components/graphScope/rows.tsx b/packages/extension/src/webview/components/graphScope/rows.tsx index 07a4cd203..ab6ded05c 100644 --- a/packages/extension/src/webview/components/graphScope/rows.tsx +++ b/packages/extension/src/webview/components/graphScope/rows.tsx @@ -13,6 +13,7 @@ import { scheduleEdgeVisibilityMessage, scheduleNodeVisibilityMessage, } from './messages'; +import { recordWebviewPerformanceEvent } from '../../performance/marks'; const FOLDER_NODE_TYPE = 'folder'; @@ -68,6 +69,7 @@ function updateNodeVisibilityOptimistically( [nodeTypeId]: visible, }, })); + recordWebviewPerformanceEvent('graphScope.nodeVisibility.optimistic', { nodeTypeId, visible }); } function updateEdgeVisibilityOptimistically(edgeKind: string, visible: boolean): void { @@ -77,6 +79,7 @@ function updateEdgeVisibilityOptimistically(edgeKind: string, visible: boolean): [edgeKind]: visible, }, })); + recordWebviewPerformanceEvent('graphScope.edgeVisibility.optimistic', { edgeKind, visible }); } export function resolveScopeRowClassName(enabled: boolean): string { diff --git a/packages/extension/src/webview/globMatch.ts b/packages/extension/src/webview/globMatch.ts index 5daaa0acd..d9a9c7241 100644 --- a/packages/extension/src/webview/globMatch.ts +++ b/packages/extension/src/webview/globMatch.ts @@ -1 +1 @@ -export { globMatch, globToRegex } from '../shared/globMatch'; +export { createGlobMatcher, globMatch, globToRegex } from '../shared/globMatch'; diff --git a/packages/extension/src/webview/performance/marks.ts b/packages/extension/src/webview/performance/marks.ts new file mode 100644 index 000000000..f1bc2bbec --- /dev/null +++ b/packages/extension/src/webview/performance/marks.ts @@ -0,0 +1,79 @@ +export interface CodeGraphyPerformanceEvent { + name: string; + at: number; + durationMs?: number; + detail?: Record; +} + +export interface CodeGraphyPerformanceSink { + enabled?: boolean; + events?: CodeGraphyPerformanceEvent[]; + limit?: number; +} + +declare global { + interface Window { + __codegraphyPerformance?: CodeGraphyPerformanceSink; + } +} + +const DEFAULT_EVENT_LIMIT = 500; + +function getEnabledSink(): CodeGraphyPerformanceSink | null { + if (typeof window === 'undefined') { + return null; + } + + const sink = window.__codegraphyPerformance; + return sink?.enabled ? sink : null; +} + +function roundMetric(value: number): number { + return Math.round(value * 100) / 100; +} + +export function recordWebviewPerformanceEvent( + name: string, + detail?: Record, + durationMs?: number, +): void { + const sink = getEnabledSink(); + if (!sink) { + return; + } + + const events = Array.isArray(sink.events) ? sink.events : []; + sink.events = events; + events.push({ + name, + at: roundMetric(window.performance.now()), + ...(durationMs === undefined ? {} : { durationMs: roundMetric(durationMs) }), + ...(detail ? { detail } : {}), + }); + + const configuredLimit = sink.limit; + const limit = typeof configuredLimit === 'number' + && Number.isInteger(configuredLimit) + && configuredLimit > 0 + ? configuredLimit + : DEFAULT_EVENT_LIMIT; + if (events.length > limit) { + events.splice(0, events.length - limit); + } +} + +export function measureWebviewPerformance( + name: string, + detail: Record, + callback: () => T, +): T { + const sink = getEnabledSink(); + if (!sink) { + return callback(); + } + + const startedAt = window.performance.now(); + const result = callback(); + recordWebviewPerformanceEvent(name, detail, window.performance.now() - startedAt); + return result; +} diff --git a/packages/extension/src/webview/search/filtering/rules.ts b/packages/extension/src/webview/search/filtering/rules.ts index 734a130ab..96a37ae5f 100644 --- a/packages/extension/src/webview/search/filtering/rules.ts +++ b/packages/extension/src/webview/search/filtering/rules.ts @@ -1,7 +1,7 @@ import type { IGraphData } from '../../../shared/graph/contracts'; import type { IGroup } from '../../../shared/settings/groups'; -import { applyEdgeLegendRules } from './rules/edges'; -import { applyNodeLegendRules, getOrderedActiveRules } from './rules/nodes'; +import { applyCompiledEdgeLegendRules, compileEdgeLegendRules } from './rules/edges'; +import { applyCompiledNodeLegendRules, compileNodeLegendRules, getOrderedActiveRules } from './rules/nodes'; export function applyLegendRules( data: IGraphData | null, @@ -16,11 +16,13 @@ export function applyLegendRules( } const activeRules = getOrderedActiveRules(legends); + const nodeRules = compileNodeLegendRules(activeRules); + const edgeRules = compileEdgeLegendRules(activeRules); return { ...data, - nodes: data.nodes.map((node) => applyNodeLegendRules(node, activeRules)), - edges: data.edges.map((edge) => applyEdgeLegendRules(edge, activeRules)), + nodes: data.nodes.map((node) => applyCompiledNodeLegendRules(node, nodeRules)), + edges: data.edges.map((edge) => applyCompiledEdgeLegendRules(edge, edgeRules)), }; } diff --git a/packages/extension/src/webview/search/filtering/rules/edges.ts b/packages/extension/src/webview/search/filtering/rules/edges.ts index da25d48f4..2276c1e4d 100644 --- a/packages/extension/src/webview/search/filtering/rules/edges.ts +++ b/packages/extension/src/webview/search/filtering/rules/edges.ts @@ -1,4 +1,4 @@ -import { globMatch } from '../../../globMatch'; +import { createGlobMatcher } from '../../../globMatch'; import type { IGraphData } from '../../../../shared/graph/contracts'; import type { IGroup } from '../../../../shared/settings/groups'; @@ -6,31 +6,70 @@ function ruleTargetsEdges(rule: IGroup): boolean { return (rule.target ?? 'node') !== 'node'; } +export interface CompiledEdgeLegendRule { + patternMatches: (value: string) => boolean; + rule: IGroup; +} + +type EdgeLegendRuleInput = IGroup | CompiledEdgeLegendRule; + +export function compileEdgeLegendRules(activeRules: IGroup[]): CompiledEdgeLegendRule[] { + return activeRules + .filter(ruleTargetsEdges) + .map((rule) => ({ + patternMatches: createGlobMatcher(rule.pattern), + rule, + })); +} + +function isCompiledEdgeLegendRule(rule: EdgeLegendRuleInput): rule is CompiledEdgeLegendRule { + return 'patternMatches' in rule && 'rule' in rule; +} + +function normalizeEdgeLegendRules(activeRules: readonly EdgeLegendRuleInput[]): CompiledEdgeLegendRule[] { + if (activeRules.every(isCompiledEdgeLegendRule)) { + return [...activeRules]; + } + + return compileEdgeLegendRules(activeRules.filter((rule): rule is IGroup => !isCompiledEdgeLegendRule(rule))); +} + function matchesEdgeRule( edge: IGraphData['edges'][number], - rule: IGroup, + fromTo: string, + fromToKind: string, + rule: CompiledEdgeLegendRule, ): boolean { return ( - globMatch(edge.id, rule.pattern) - || globMatch(edge.kind, rule.pattern) - || globMatch(`${edge.from}->${edge.to}`, rule.pattern) - || globMatch(`${edge.from}->${edge.to}#${edge.kind}`, rule.pattern) + rule.patternMatches(edge.id) + || rule.patternMatches(edge.kind) + || rule.patternMatches(fromTo) + || rule.patternMatches(fromToKind) ); } -export function applyEdgeLegendRules( +export function applyCompiledEdgeLegendRules( edge: IGraphData['edges'][number], - activeRules: IGroup[], + activeRules: readonly CompiledEdgeLegendRule[], ): IGraphData['edges'][number] { const nextEdge = { ...edge }; + const fromTo = `${edge.from}->${edge.to}`; + const fromToKind = `${fromTo}#${edge.kind}`; - for (const rule of activeRules) { - if (!ruleTargetsEdges(rule) || !matchesEdgeRule(edge, rule)) { + for (const compiledRule of activeRules) { + if (!matchesEdgeRule(edge, fromTo, fromToKind, compiledRule)) { continue; } - nextEdge.color = rule.color; + nextEdge.color = compiledRule.rule.color; } return nextEdge; } + +export function applyEdgeLegendRules( + edge: IGraphData['edges'][number], + activeRules: readonly EdgeLegendRuleInput[], +): IGraphData['edges'][number] { + return applyCompiledEdgeLegendRules(edge, normalizeEdgeLegendRules(activeRules)); +} diff --git a/packages/extension/src/webview/search/filtering/rules/nodes.ts b/packages/extension/src/webview/search/filtering/rules/nodes.ts index 559973dd1..e45d5cb1f 100644 --- a/packages/extension/src/webview/search/filtering/rules/nodes.ts +++ b/packages/extension/src/webview/search/filtering/rules/nodes.ts @@ -1,7 +1,17 @@ import { DEFAULT_NODE_COLOR } from '../../../../shared/fileColors'; import type { IGraphData } from '../../../../shared/graph/contracts'; import type { IGroup } from '../../../../shared/settings/groups'; -import { ruleMatchesNode, ruleTargetsNodes } from './nodeMatcher'; +import { createGlobMatcher } from '../../../globMatch'; +import { ruleTargetsNodes } from './nodeMatcher'; + +export interface CompiledNodeLegendRule { + caseInsensitivePatternMatches: (value: string) => boolean; + patternMatches: (value: string) => boolean; + rule: IGroup; + symbolFilePathMatches?: (value: string) => boolean; +} + +type NodeLegendRuleInput = IGroup | CompiledNodeLegendRule; export function getOrderedActiveRules(legends: IGroup[]): IGroup[] { return legends @@ -9,20 +19,109 @@ export function getOrderedActiveRules(legends: IGroup[]): IGroup[] { .reverse(); } -export function applyNodeLegendRules( +export function compileNodeLegendRules(activeRules: IGroup[]): CompiledNodeLegendRule[] { + return activeRules + .filter(ruleTargetsNodes) + .map((rule) => ({ + caseInsensitivePatternMatches: createGlobMatcher(rule.pattern.toLowerCase()), + patternMatches: createGlobMatcher(rule.pattern), + rule, + ...(rule.matchSymbolFilePath + ? { symbolFilePathMatches: createGlobMatcher(rule.matchSymbolFilePath) } + : {}), + })); +} + +function isCompiledNodeLegendRule(rule: NodeLegendRuleInput): rule is CompiledNodeLegendRule { + return 'patternMatches' in rule && 'rule' in rule; +} + +function normalizeNodeLegendRules(activeRules: readonly NodeLegendRuleInput[]): CompiledNodeLegendRule[] { + if (activeRules.every(isCompiledNodeLegendRule)) { + return [...activeRules]; + } + + return compileNodeLegendRules(activeRules.filter((rule): rule is IGroup => !isCompiledNodeLegendRule(rule))); +} + +function getCaseInsensitiveNodeCandidates( + node: IGraphData['nodes'][number], +): string[] { + const symbol = node.symbol; + return [ + node.label, + symbol?.name, + symbol?.kind, + symbol?.pluginKind, + symbol?.filePath, + ] + .filter((candidate): candidate is string => Boolean(candidate)) + .map((candidate) => candidate.toLowerCase()); +} + +function compiledRuleConstraintsMatchNode( + node: IGraphData['nodes'][number], + compiledRule: CompiledNodeLegendRule, +): boolean { + const { rule } = compiledRule; + const symbol = node.symbol; + const exactMatches = [ + [rule.matchNodeType, node.nodeType], + [rule.matchSymbolKind, symbol?.kind], + [rule.matchSymbolPluginKind, symbol?.pluginKind], + [rule.matchSymbolSource, symbol?.source], + [rule.matchSymbolLanguage, symbol?.language], + ]; + const exactFieldsMatch = exactMatches.every(([expected, actual]) => !expected || expected === actual); + const symbolKindsMatch = !rule.matchSymbolKinds + || Boolean(symbol?.kind && rule.matchSymbolKinds.includes(symbol.kind)); + const symbolPathMatches = !compiledRule.symbolFilePathMatches + || Boolean(symbol?.filePath && compiledRule.symbolFilePathMatches(symbol.filePath)); + + return exactFieldsMatch && symbolKindsMatch && symbolPathMatches; +} + +function compiledRulePatternMatchesNode( + node: IGraphData['nodes'][number], + candidates: readonly string[], + compiledRule: CompiledNodeLegendRule, +): boolean { + if (compiledRule.patternMatches(node.id)) { + return true; + } + + if (compiledRule.rule.isPluginDefault) { + return false; + } + + return candidates.some((candidate) => compiledRule.caseInsensitivePatternMatches(candidate)); +} + +function compiledRuleMatchesNode( node: IGraphData['nodes'][number], - activeRules: IGroup[], + candidates: readonly string[], + compiledRule: CompiledNodeLegendRule, +): boolean { + return compiledRuleConstraintsMatchNode(node, compiledRule) + && compiledRulePatternMatchesNode(node, candidates, compiledRule); +} + +export function applyCompiledNodeLegendRules( + node: IGraphData['nodes'][number], + activeRules: readonly CompiledNodeLegendRule[], ): IGraphData['nodes'][number] { const nextNode = { ...node, color: node.color || DEFAULT_NODE_COLOR, }; + const candidates = getCaseInsensitiveNodeCandidates(node); - for (const rule of activeRules) { - if (!ruleTargetsNodes(rule) || !ruleMatchesNode(node, rule)) { + for (const compiledRule of activeRules) { + if (!compiledRuleMatchesNode(node, candidates, compiledRule)) { continue; } + const { rule } = compiledRule; nextNode.color = rule.color; if (rule.shape2D) { nextNode.shape2D = rule.shape2D; @@ -37,3 +136,10 @@ export function applyNodeLegendRules( return nextNode; } + +export function applyNodeLegendRules( + node: IGraphData['nodes'][number], + activeRules: readonly NodeLegendRuleInput[], +): IGraphData['nodes'][number] { + return applyCompiledNodeLegendRules(node, normalizeNodeLegendRules(activeRules)); +} diff --git a/packages/extension/src/webview/search/useFilteredGraph.ts b/packages/extension/src/webview/search/useFilteredGraph.ts index 77d22b56d..82d5a2cb8 100644 --- a/packages/extension/src/webview/search/useFilteredGraph.ts +++ b/packages/extension/src/webview/search/useFilteredGraph.ts @@ -24,6 +24,7 @@ import { buildVisibleGraphConfig, withSharedEdgeTypeAliases, } from './visibleGraphConfig'; +import { measureWebviewPerformance } from '../performance/marks'; export interface IFilteredGraph { /** Graph after node/edge search filtering (null when no graph data). */ @@ -55,7 +56,12 @@ export function useFilteredGraph( nodeTypes: IGraphNodeTypeDefinition[] = [], ): IFilteredGraph { const visibleGraph = useMemo(() => { - return deriveVisibleGraph(graphData, buildVisibleGraphConfig({ + return measureWebviewPerformance('visibleGraph.derive', { + edgeCount: graphData?.edges.length ?? 0, + filterPatternCount: filterPatterns.length, + nodeCount: graphData?.nodes.length ?? 0, + searchActive: searchQuery.trim().length > 0, + }, () => deriveVisibleGraph(graphData, buildVisibleGraphConfig({ edgeTypes, edgeVisibility, filterPatterns, @@ -64,7 +70,7 @@ export function useFilteredGraph( searchOptions, searchQuery, showOrphans, - })); + }))); }, [ edgeTypes, edgeVisibility, @@ -78,25 +84,35 @@ export function useFilteredGraph( ]); const filteredData = useMemo(() => { - if (!visibleGraph.graphData) { + const graph = visibleGraph.graphData; + if (!graph) { return null; } const edgeTypesForStyling = withSharedEdgeTypeAliases(edgeTypes); - return { - nodes: applyNodeTypeColors(withResolvedNodeTypes(visibleGraph.graphData.nodes), nodeColors), - edges: applyEdgeTypeDefaultColors(visibleGraph.graphData.edges, edgeTypesForStyling), - }; + return measureWebviewPerformance('visibleGraph.style', { + edgeCount: graph.edges.length, + nodeCount: graph.nodes.length, + }, () => ({ + nodes: applyNodeTypeColors(withResolvedNodeTypes(graph.nodes), nodeColors), + edges: applyEdgeTypeDefaultColors(graph.edges, edgeTypesForStyling), + })); }, [edgeTypes, nodeColors, visibleGraph.graphData]); const coloredData = useMemo( - () => applyLegendRules(filteredData, legends), + () => measureWebviewPerformance('visibleGraph.applyLegendRules', { + edgeCount: filteredData?.edges.length ?? 0, + legendCount: legends.length, + nodeCount: filteredData?.nodes.length ?? 0, + }, () => applyLegendRules(filteredData, legends)), [filteredData, legends], ); const controlsEdgeDecorations = useMemo( - () => filterVisibleEdgeDecorations(filteredData?.edges ?? [], edgeDecorations), + () => measureWebviewPerformance('visibleGraph.edgeDecorations', { + edgeCount: filteredData?.edges.length ?? 0, + }, () => filterVisibleEdgeDecorations(filteredData?.edges ?? [], edgeDecorations)), [edgeDecorations, filteredData], ); diff --git a/packages/extension/tests/webview/app/performance/marks.test.ts b/packages/extension/tests/webview/app/performance/marks.test.ts new file mode 100644 index 000000000..a0ff454a1 --- /dev/null +++ b/packages/extension/tests/webview/app/performance/marks.test.ts @@ -0,0 +1,49 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { + measureWebviewPerformance, + recordWebviewPerformanceEvent, +} from '../../../../src/webview/performance/marks'; + +describe('webview/performance/marks', () => { + afterEach(() => { + window.__codegraphyPerformance = undefined; + }); + + it('does not record events until the sink is enabled', () => { + window.__codegraphyPerformance = { enabled: false, events: [] }; + + recordWebviewPerformanceEvent('visibleGraph.derive'); + + expect(window.__codegraphyPerformance.events).toEqual([]); + }); + + it('records enabled events and bounds the event list', () => { + window.__codegraphyPerformance = { enabled: true, events: [], limit: 1 }; + + recordWebviewPerformanceEvent('first', { count: 1 }); + recordWebviewPerformanceEvent('second', { count: 2 }, 12.345); + + expect(window.__codegraphyPerformance.events).toEqual([ + expect.objectContaining({ + detail: { count: 2 }, + durationMs: 12.35, + name: 'second', + }), + ]); + }); + + it('measures a callback while preserving the returned value', () => { + window.__codegraphyPerformance = { enabled: true, events: [] }; + + const result = measureWebviewPerformance('graphRuntime.buildGraphData', { nodeCount: 2 }, () => 'done'); + + expect(result).toBe('done'); + expect(window.__codegraphyPerformance.events).toEqual([ + expect.objectContaining({ + detail: { nodeCount: 2 }, + name: 'graphRuntime.buildGraphData', + }), + ]); + expect(window.__codegraphyPerformance.events?.[0]?.durationMs).toEqual(expect.any(Number)); + }); +}); diff --git a/packages/extension/tests/webview/graph/drag.test.tsx b/packages/extension/tests/webview/graph/drag.test.tsx index cd999e4a0..2c6c47de3 100644 --- a/packages/extension/tests/webview/graph/drag.test.tsx +++ b/packages/extension/tests/webview/graph/drag.test.tsx @@ -77,13 +77,7 @@ describe('Graph: force-graph rendering', () => { render(); const props = ForceGraph2D.getLastProps(); expect(props.linkDirectionalArrowLength).toBeGreaterThan(0); - expect(props.linkDirectionalArrowRelPos).toEqual(expect.any(Function)); - - const relPos = props.linkDirectionalArrowRelPos({ - source: { id: 'a.ts', x: 0, y: 0, size: 10 }, - target: { id: 'b.ts', x: 100, y: 0, size: 10 }, - }); - expect(relPos).toBe(1); + expect(props.linkDirectionalArrowRelPos).toBe(1); expect(props.nodeRelSize).toBe(1); expect(props.nodeVal({ size: 10 })).toBe(100); }); @@ -101,7 +95,7 @@ describe('Graph: force-graph rendering', () => { }); expect(mockMethods.linkDirectionalArrowLength).toHaveBeenLastCalledWith(0); - expect(mockMethods.linkDirectionalArrowRelPos).toHaveBeenLastCalledWith(expect.any(Function)); + expect(mockMethods.linkDirectionalArrowRelPos).toHaveBeenLastCalledWith(1); expect(mockMethods.linkDirectionalParticles).toHaveBeenLastCalledWith(expect.any(Function)); expect(mockMethods.linkDirectionalParticleSpeed).toHaveBeenLastCalledWith(0.005); expect(mockMethods.d3ReheatSimulation).toHaveBeenCalledTimes(1); diff --git a/scripts/performance/measure-vscode-graph-view.mjs b/scripts/performance/measure-vscode-graph-view.mjs index bd328ef15..3ff6c5189 100644 --- a/scripts/performance/measure-vscode-graph-view.mjs +++ b/scripts/performance/measure-vscode-graph-view.mjs @@ -120,18 +120,61 @@ async function waitForSwitchEnabled(frame, label, enabled) { throw new Error(`Timed out waiting for ${label} switch to become ${expected}`); } +async function enableWebviewPerformanceEvents(frame) { + await frame.evaluate(() => { + window.__codegraphyPerformance = { + enabled: true, + events: [], + limit: 500, + }; + }); +} + +async function resetWebviewPerformanceEvents(frame) { + await frame.evaluate(() => { + window.__codegraphyPerformance = { + enabled: true, + events: [], + limit: 500, + }; + }); +} + +async function readWebviewPerformanceEvents(frame) { + return frame.evaluate(() => window.__codegraphyPerformance?.events ?? []); +} + +async function waitForWebviewPerformanceEvent(frame, name, timeoutMs = DEFAULT_TIMEOUT_MS) { + const startedAt = performance.now(); + + while (performance.now() - startedAt < timeoutMs) { + const matched = await frame.evaluate((eventName) => + Boolean(window.__codegraphyPerformance?.events?.some(event => event.name === eventName)), name); + if (matched) { + return; + } + + await frame.waitForTimeout(25); + } + + throw new Error(`Timed out waiting for webview performance event: ${name}`); +} + async function measureSwitchTransition(frame, label, enabled) { const beforeStats = await waitForGraphStats(frame, stats => stats.nodeCount > 0); + await resetWebviewPerformanceEvents(frame); const startedAt = performance.now(); await graphScopeSwitch(frame, label).click(); await waitForSwitchEnabled(frame, label, enabled); const afterStats = await waitForGraphStats(frame, stats => !sameGraphStats(stats, beforeStats)); + await waitForWebviewPerformanceEvent(frame, 'graphStats.rendered'); return { durationMs: Math.round(performance.now() - startedAt), enabled, beforeStats, afterStats, + webviewEvents: await readWebviewPerformanceEvents(frame), }; } @@ -169,6 +212,7 @@ async function measureVSCodeGraphView({ const openStartedAt = performance.now(); await openGraphView(vscode.page); const frame = await waitForGraphFrame(vscode.page); + await enableWebviewPerformanceEvents(frame); const initialStats = await waitForGraphStats(frame, stats => stats.nodeCount > 0); const firstGraphReadyMs = Math.round(performance.now() - openStartedAt); From 62d25bdc77cf99974aaa01578b5c79728bc201ba Mon Sep 17 00:00:00 2001 From: joesobo Date: Mon, 22 Jun 2026 18:28:14 -0700 Subject: [PATCH 016/192] perf: skip unchanged graph control echoes Imports Graph Scope toggle improved from 835ms median / 846ms p95 to 493ms median / 497ms p95 across five VS Code harness samples. The webview event log now shows one visibleGraph.derive event per toggle sample instead of duplicate derive work. --- .changeset/visible-graph-filter-speed.md | 2 +- docs/performance/codegraphy-monorepo.md | 15 ++++- .../2026-06-22-codegraphy-performance.md | 1 + .../webview/store/messageHandlers/graph.ts | 44 +++++++++++--- .../extension/src/webview/store/messages.ts | 5 +- .../store/messageHandlers/graph.test.ts | 59 +++++++++++++++++++ 6 files changed, 114 insertions(+), 12 deletions(-) diff --git a/.changeset/visible-graph-filter-speed.md b/.changeset/visible-graph-filter-speed.md index a9e3a7999..18d62d174 100644 --- a/.changeset/visible-graph-filter-speed.md +++ b/.changeset/visible-graph-filter-speed.md @@ -3,4 +3,4 @@ "@codegraphy-dev/extension": patch --- -Speed up visible graph filtering for large workspaces with many filter patterns. +Speed up visible graph filtering and Graph Scope toggles in large workspaces. diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index 6f9f1b4d3..4e3fcd312 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -222,6 +222,16 @@ VS Code graph view benchmark: - Stage medians: `visibleGraph.derive` `176.1ms`; `visibleGraph.applyLegendRules` `79.8ms`; `graphRuntime.buildGraphData` `5.4ms`. +- After skipping value-equal graph control echo updates: + - VS Code launch: `1077ms`. + - Open Graph View to first rendered graph stats: `7401ms`. + - Initial rendered stats: `2249` nodes, `5333` connections. + - Imports toggle latency: `493ms` median, `497ms` p95 across 5 samples. + - Stage medians: `visibleGraph.derive` `176.9ms`; + `visibleGraph.applyLegendRules` `80.3ms`; + `graphRuntime.buildGraphData` `5.4ms`. + - Instrumented event counts showed one `visibleGraph.derive` per toggle + sample instead of the duplicate derive work seen before this iteration. Interpretation: @@ -241,8 +251,11 @@ Interpretation: - Compiling legend glob matchers reduced the measured legend stage from roughly `460ms`-`490ms` per pass to about `79ms`-`83ms`, moving the real toggle median under `1s`. +- Skipping value-equal graph control echoes removed the extra visible graph + derivation per toggle, moving the real Imports toggle median from `835ms` to + `493ms`. - The next user-facing bottleneck is visible graph derivation, which still takes - about `175ms`-`186ms` per pass and can run multiple times during one toggle. + about `175ms`-`177ms` for the remaining pass. Full test baseline: diff --git a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md index f73d19685..2e2c435b0 100644 --- a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md +++ b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md @@ -284,6 +284,7 @@ Rejected stable-edge visibility callbacks: 2918ms to 2922ms median, no improveme Memoized viewport surface: 2891ms median / 3563ms p95 control, 1628ms median / 2252ms p95 after. Instrumented webview stages: 1748ms median / 2272ms p95; applyLegendRules was ~460ms-490ms per pass. Compiled legend matchers: 835ms median / 846ms p95; applyLegendRules now ~79ms-83ms per pass. +Skipped value-equal graph control echoes: 493ms median / 497ms p95; one visibleGraph.derive event per toggle sample. ``` ## Task 5: Keep The PR Reviewable diff --git a/packages/extension/src/webview/store/messageHandlers/graph.ts b/packages/extension/src/webview/store/messageHandlers/graph.ts index 61ed2c968..a9b9cff6a 100644 --- a/packages/extension/src/webview/store/messageHandlers/graph.ts +++ b/packages/extension/src/webview/store/messageHandlers/graph.ts @@ -70,14 +70,42 @@ export function handleGraphIndexProgress( export function handleGraphControlsUpdated( message: Extract, -): PartialState { - return { - graphNodeTypes: message.payload.nodeTypes, - graphEdgeTypes: message.payload.edgeTypes, - nodeColors: message.payload.nodeColors, - nodeVisibility: message.payload.nodeVisibility, - edgeVisibility: message.payload.edgeVisibility, - }; + ctx?: Pick, +): PartialState | void { + const state = ctx?.getState(); + if (!state) { + return { + graphNodeTypes: message.payload.nodeTypes, + graphEdgeTypes: message.payload.edgeTypes, + nodeColors: message.payload.nodeColors, + nodeVisibility: message.payload.nodeVisibility, + edgeVisibility: message.payload.edgeVisibility, + }; + } + + const next: PartialState = {}; + + if (!arePlainValuesEqual(state.graphNodeTypes, message.payload.nodeTypes)) { + next.graphNodeTypes = message.payload.nodeTypes; + } + + if (!arePlainValuesEqual(state.graphEdgeTypes, message.payload.edgeTypes)) { + next.graphEdgeTypes = message.payload.edgeTypes; + } + + if (!arePlainValuesEqual(state.nodeColors, message.payload.nodeColors)) { + next.nodeColors = message.payload.nodeColors; + } + + if (!arePlainValuesEqual(state.nodeVisibility, message.payload.nodeVisibility)) { + next.nodeVisibility = message.payload.nodeVisibility; + } + + if (!arePlainValuesEqual(state.edgeVisibility, message.payload.edgeVisibility)) { + next.edgeVisibility = message.payload.edgeVisibility; + } + + return Object.keys(next).length > 0 ? next : undefined; } export function handleFavoritesUpdated( diff --git a/packages/extension/src/webview/store/messages.ts b/packages/extension/src/webview/store/messages.ts index 5708ed726..7b6a56217 100644 --- a/packages/extension/src/webview/store/messages.ts +++ b/packages/extension/src/webview/store/messages.ts @@ -63,9 +63,10 @@ export const MESSAGE_HANDLERS: Record< handleGraphIndexProgress( msg as Extract ), - GRAPH_CONTROLS_UPDATED: (msg) => + GRAPH_CONTROLS_UPDATED: (msg, ctx) => handleGraphControlsUpdated( - msg as Extract + msg as Extract, + ctx, ), FAVORITES_UPDATED: (msg, ctx) => handleFavoritesUpdated(msg as Extract, ctx), diff --git a/packages/extension/tests/webview/store/messageHandlers/graph.test.ts b/packages/extension/tests/webview/store/messageHandlers/graph.test.ts index d312c5c35..da33888fb 100644 --- a/packages/extension/tests/webview/store/messageHandlers/graph.test.ts +++ b/packages/extension/tests/webview/store/messageHandlers/graph.test.ts @@ -213,6 +213,65 @@ describe('webview/store/messageHandlers/graph', () => { }); + it('skips graph controls updates when extension echoes the current controls', () => { + const controls: IGraphControlsSnapshot = { + nodeTypes: [{ id: 'file', label: 'File', defaultColor: '#A1A1AA', defaultVisible: true }], + edgeTypes: [{ id: 'import', label: 'Import', defaultColor: '#64748B', defaultVisible: true }], + nodeColors: { file: '#A1A1AA' }, + nodeVisibility: { file: true }, + edgeVisibility: { import: false }, + }; + const state = createState({ + graphNodeTypes: controls.nodeTypes, + graphEdgeTypes: controls.edgeTypes, + nodeColors: controls.nodeColors, + nodeVisibility: controls.nodeVisibility, + edgeVisibility: controls.edgeVisibility, + }); + const echoedControls: IGraphControlsSnapshot = { + nodeTypes: [...controls.nodeTypes], + edgeTypes: [...controls.edgeTypes], + nodeColors: { ...controls.nodeColors }, + nodeVisibility: { ...controls.nodeVisibility }, + edgeVisibility: { ...controls.edgeVisibility }, + }; + + expect(handleGraphControlsUpdated( + { type: 'GRAPH_CONTROLS_UPDATED', payload: echoedControls }, + { getState: () => state }, + )).toBeUndefined(); + }); + + it('returns only changed graph control fields when extension echoes partial changes', () => { + const controls: IGraphControlsSnapshot = { + nodeTypes: [{ id: 'file', label: 'File', defaultColor: '#A1A1AA', defaultVisible: true }], + edgeTypes: [{ id: 'import', label: 'Import', defaultColor: '#64748B', defaultVisible: true }], + nodeColors: { file: '#A1A1AA' }, + nodeVisibility: { file: true }, + edgeVisibility: { import: false }, + }; + const state = createState({ + graphNodeTypes: controls.nodeTypes, + graphEdgeTypes: controls.edgeTypes, + nodeColors: controls.nodeColors, + nodeVisibility: controls.nodeVisibility, + edgeVisibility: controls.edgeVisibility, + }); + const nextEdgeVisibility = { import: true }; + const echoedControls: IGraphControlsSnapshot = { + nodeTypes: [...controls.nodeTypes], + edgeTypes: [...controls.edgeTypes], + nodeColors: { ...controls.nodeColors }, + nodeVisibility: { ...controls.nodeVisibility }, + edgeVisibility: nextEdgeVisibility, + }; + + expect(handleGraphControlsUpdated( + { type: 'GRAPH_CONTROLS_UPDATED', payload: echoedControls }, + { getState: () => state }, + )).toEqual({ edgeVisibility: nextEdgeVisibility }); + }); + it('maps settings and filter payloads', () => { expect(handleSettingsUpdated({ type: 'SETTINGS_UPDATED', From 63176a756fcc22b915d32af4cb2e909b98a15a43 Mon Sep 17 00:00:00 2001 From: joesobo Date: Mon, 22 Jun 2026 18:39:20 -0700 Subject: [PATCH 017/192] perf: cache recent visible graph derivations Imports Graph Scope toggle improved from 493ms median / 497ms p95 to 313ms median / 345ms p95 across five VS Code harness samples. Sampled toggles had no visibleGraph.derive events after returning to recent graph-scope configs; a filter matcher cache experiment was rejected after measuring no win. --- docs/performance/codegraphy-monorepo.md | 20 +++- .../2026-06-22-codegraphy-performance.md | 2 + .../src/webview/search/useFilteredGraph.ts | 112 +++++++++++++++++- .../search/useFilteredGraph.mutations.test.ts | 22 ++++ 4 files changed, 152 insertions(+), 4 deletions(-) diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index 4e3fcd312..7624d9cd1 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -232,6 +232,19 @@ VS Code graph view benchmark: `graphRuntime.buildGraphData` `5.4ms`. - Instrumented event counts showed one `visibleGraph.derive` per toggle sample instead of the duplicate derive work seen before this iteration. +- Rejected filter matcher cache experiment: + - Imports toggle latency stayed flat at `494ms` median, `513ms` p95 across + 5 samples. + - `visibleGraph.derive` stayed flat at `176.7ms` median, so recompiling + stable filter-pattern matchers was not the browser-side bottleneck. +- After caching recent visible-graph derivations by graph data and config: + - VS Code launch: `1081ms`. + - Open Graph View to first rendered graph stats: `6963ms`. + - Initial rendered stats: `2249` nodes, `5333` connections. + - Imports toggle latency: `313ms` median, `345ms` p95 across 5 samples. + - Sampled toggles had no `visibleGraph.derive` events; the remaining stage + medians were `visibleGraph.applyLegendRules` `81.3ms`, + `visibleGraph.style` `4.3ms`, and `graphRuntime.buildGraphData` `5.7ms`. Interpretation: @@ -254,8 +267,11 @@ Interpretation: - Skipping value-equal graph control echoes removed the extra visible graph derivation per toggle, moving the real Imports toggle median from `835ms` to `493ms`. -- The next user-facing bottleneck is visible graph derivation, which still takes - about `175ms`-`177ms` for the remaining pass. +- Caching recent visible-graph derivations removed the remaining derive pass + when users return to a recent Graph Scope state, moving the sampled toggle + median from `493ms` to `313ms`. +- The next user-facing bottleneck is legend rule application at about `80ms` + per toggle, plus the remaining uninstrumented render latency. Full test baseline: diff --git a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md index 2e2c435b0..84fa053c5 100644 --- a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md +++ b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md @@ -285,6 +285,8 @@ Memoized viewport surface: 2891ms median / 3563ms p95 control, 1628ms median / 2 Instrumented webview stages: 1748ms median / 2272ms p95; applyLegendRules was ~460ms-490ms per pass. Compiled legend matchers: 835ms median / 846ms p95; applyLegendRules now ~79ms-83ms per pass. Skipped value-equal graph control echoes: 493ms median / 497ms p95; one visibleGraph.derive event per toggle sample. +Rejected stable filter matcher cache: 494ms median / 513ms p95; derive stayed ~176.7ms, so no measured win. +Cached recent visible-graph derivations: 313ms median / 345ms p95; sampled toggles had no visibleGraph.derive events. ``` ## Task 5: Keep The PR Reviewable diff --git a/packages/extension/src/webview/search/useFilteredGraph.ts b/packages/extension/src/webview/search/useFilteredGraph.ts index 82d5a2cb8..66b5ef528 100644 --- a/packages/extension/src/webview/search/useFilteredGraph.ts +++ b/packages/extension/src/webview/search/useFilteredGraph.ts @@ -4,10 +4,11 @@ * @module webview/useFilteredGraph */ -import { useMemo } from 'react'; +import { useMemo, useRef } from 'react'; import type { SearchOptions } from '../components/searchBar/field/model'; import { applyLegendRules } from './filtering/rules'; import { deriveVisibleGraph } from '../../shared/visibleGraph'; +import type { VisibleGraphResult } from '../../shared/visibleGraph'; import type { IGraphData } from '../../shared/graph/contracts'; import type { IGraphEdgeTypeDefinition, @@ -37,6 +38,76 @@ export interface IFilteredGraph { regexError: string | null; } +const VISIBLE_GRAPH_CACHE_LIMIT = 6; + +interface VisibleGraphCache { + entries: Map; + graphData: IGraphData | null | undefined; +} + +function createVisibleGraphCache(): VisibleGraphCache { + return { + entries: new Map(), + graphData: undefined, + }; +} + +function sortedRecordEntries(record: Record): [string, TValue][] { + return Object.entries(record).sort(([left], [right]) => left.localeCompare(right)); +} + +function createVisibleGraphCacheKey({ + edgeTypes, + edgeVisibility, + filterPatterns, + nodeTypes, + nodeVisibility, + searchOptions, + searchQuery, + showOrphans, +}: { + edgeTypes: IGraphEdgeTypeDefinition[]; + edgeVisibility: Record; + filterPatterns: readonly string[]; + nodeTypes: IGraphNodeTypeDefinition[]; + nodeVisibility: Record; + searchOptions: SearchOptions; + searchQuery: string; + showOrphans: boolean; +}): string { + return JSON.stringify({ + edgeTypes: edgeTypes.map(({ defaultVisible, id }) => [id, defaultVisible]), + edgeVisibility: sortedRecordEntries(edgeVisibility), + filterPatterns, + nodeTypes: nodeTypes.map(({ defaultVisible, id }) => [id, defaultVisible]), + nodeVisibility: sortedRecordEntries(nodeVisibility), + searchOptions, + searchQuery, + showOrphans, + }); +} + +function cacheVisibleGraphResult( + cache: VisibleGraphCache, + key: string, + result: VisibleGraphResult, +): void { + if (cache.entries.has(key)) { + cache.entries.delete(key); + } + + cache.entries.set(key, result); + + while (cache.entries.size > VISIBLE_GRAPH_CACHE_LIMIT) { + const oldestKey = cache.entries.keys().next().value; + if (!oldestKey) { + return; + } + + cache.entries.delete(oldestKey); + } +} + /** * Derives the filtered + colored graph data. * Both memos recompute only when their specific inputs change. @@ -55,8 +126,42 @@ export function useFilteredGraph( showOrphans = true, nodeTypes: IGraphNodeTypeDefinition[] = [], ): IFilteredGraph { + const visibleGraphCache = useRef(createVisibleGraphCache()); + const visibleGraphCacheKey = useMemo(() => createVisibleGraphCacheKey({ + edgeTypes, + edgeVisibility, + filterPatterns, + nodeTypes, + nodeVisibility, + searchOptions, + searchQuery, + showOrphans, + }), [ + edgeTypes, + edgeVisibility, + filterPatterns, + nodeTypes, + nodeVisibility, + searchOptions, + searchQuery, + showOrphans, + ]); + const visibleGraph = useMemo(() => { - return measureWebviewPerformance('visibleGraph.derive', { + const cache = visibleGraphCache.current; + if (cache.graphData !== graphData) { + cache.graphData = graphData; + cache.entries.clear(); + } + + const cached = cache.entries.get(visibleGraphCacheKey); + if (cached) { + cache.entries.delete(visibleGraphCacheKey); + cache.entries.set(visibleGraphCacheKey, cached); + return cached; + } + + const result = measureWebviewPerformance('visibleGraph.derive', { edgeCount: graphData?.edges.length ?? 0, filterPatternCount: filterPatterns.length, nodeCount: graphData?.nodes.length ?? 0, @@ -71,6 +176,8 @@ export function useFilteredGraph( searchQuery, showOrphans, }))); + cacheVisibleGraphResult(cache, visibleGraphCacheKey, result); + return result; }, [ edgeTypes, edgeVisibility, @@ -81,6 +188,7 @@ export function useFilteredGraph( searchOptions, searchQuery, showOrphans, + visibleGraphCacheKey, ]); const filteredData = useMemo(() => { diff --git a/packages/extension/tests/webview/search/useFilteredGraph.mutations.test.ts b/packages/extension/tests/webview/search/useFilteredGraph.mutations.test.ts index 7466a6780..12ac5192c 100644 --- a/packages/extension/tests/webview/search/useFilteredGraph.mutations.test.ts +++ b/packages/extension/tests/webview/search/useFilteredGraph.mutations.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { renderHook } from '@testing-library/react'; import type { IGraphData } from '../../../src/shared/graph/contracts'; +import type { IGraphEdgeTypeDefinition } from '../../../src/shared/graphControls/contracts'; import type { IGroup } from '../../../src/shared/settings/groups'; const deriveVisibleGraphMock = vi.hoisted(() => vi.fn()); @@ -132,4 +133,25 @@ describe('useFilteredGraph dependency array mutations', () => { expect.objectContaining({ showOrphans: false }), ); }); + + it('reuses derived visible graphs when graph scope returns to a cached config', () => { + const edgeTypes: IGraphEdgeTypeDefinition[] = [ + { id: 'import', label: 'Imports', defaultColor: '#60a5fa', defaultVisible: true }, + ]; + const { rerender } = renderHook( + ({ edgeVisibility }) => + useFilteredGraph(graphA, '', defaultOptions, [], {}, {}, edgeVisibility, edgeTypes), + { initialProps: { edgeVisibility: {} as Record } }, + ); + + expect(deriveVisibleGraphMock).toHaveBeenCalledTimes(1); + + rerender({ edgeVisibility: { import: false } }); + + expect(deriveVisibleGraphMock).toHaveBeenCalledTimes(2); + + rerender({ edgeVisibility: {} }); + + expect(deriveVisibleGraphMock).toHaveBeenCalledTimes(2); + }); }); From 4b7ea2c444d2fdaf5fe006bf9e7a84a96aa1c625 Mon Sep 17 00:00:00 2001 From: joesobo Date: Mon, 22 Jun 2026 18:44:17 -0700 Subject: [PATCH 018/192] perf: cache recent styled graph stages Imports Graph Scope toggle improved from 313ms median / 345ms p95 to 236ms median / 270ms p95 across five VS Code harness samples. Sampled toggles had no visibleGraph.derive, visibleGraph.style, or visibleGraph.applyLegendRules events; remaining measured stages were graphRuntime.buildGraphData and edge decorations. --- docs/performance/codegraphy-monorepo.md | 15 +- .../2026-06-22-codegraphy-performance.md | 1 + .../src/webview/search/useFilteredGraph.ts | 128 +++++++++++++++++- .../search/useFilteredGraph.mutations.test.ts | 31 +++++ 4 files changed, 166 insertions(+), 9 deletions(-) diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index 7624d9cd1..16209a024 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -245,6 +245,15 @@ VS Code graph view benchmark: - Sampled toggles had no `visibleGraph.derive` events; the remaining stage medians were `visibleGraph.applyLegendRules` `81.3ms`, `visibleGraph.style` `4.3ms`, and `graphRuntime.buildGraphData` `5.7ms`. +- After caching recent styled and legend-applied graph stages: + - VS Code launch: `1023ms`. + - Open Graph View to first rendered graph stats: `6918ms`. + - Initial rendered stats: `2249` nodes, `5333` connections. + - Imports toggle latency: `236ms` median, `270ms` p95 across 5 samples. + - Sampled toggles had no `visibleGraph.derive`, `visibleGraph.style`, or + `visibleGraph.applyLegendRules` events; remaining stage medians were + `graphRuntime.buildGraphData` `5.7ms` and + `visibleGraph.edgeDecorations` `0.3ms`. Interpretation: @@ -270,8 +279,10 @@ Interpretation: - Caching recent visible-graph derivations removed the remaining derive pass when users return to a recent Graph Scope state, moving the sampled toggle median from `493ms` to `313ms`. -- The next user-facing bottleneck is legend rule application at about `80ms` - per toggle, plus the remaining uninstrumented render latency. +- Caching recent styled and legend-applied graph stages removed the next + `80ms` legend pass, moving the sampled toggle median from `313ms` to `236ms`. +- The next user-facing bottleneck is the remaining render latency after graph + data construction, not the measured data derivation stages. Full test baseline: diff --git a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md index 84fa053c5..81ada819d 100644 --- a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md +++ b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md @@ -287,6 +287,7 @@ Compiled legend matchers: 835ms median / 846ms p95; applyLegendRules now ~79ms-8 Skipped value-equal graph control echoes: 493ms median / 497ms p95; one visibleGraph.derive event per toggle sample. Rejected stable filter matcher cache: 494ms median / 513ms p95; derive stayed ~176.7ms, so no measured win. Cached recent visible-graph derivations: 313ms median / 345ms p95; sampled toggles had no visibleGraph.derive events. +Cached recent style and legend stages: 236ms median / 270ms p95; sampled toggles had no derive/style/legend events. ``` ## Task 5: Keep The PR Reviewable diff --git a/packages/extension/src/webview/search/useFilteredGraph.ts b/packages/extension/src/webview/search/useFilteredGraph.ts index 66b5ef528..ce52b8423 100644 --- a/packages/extension/src/webview/search/useFilteredGraph.ts +++ b/packages/extension/src/webview/search/useFilteredGraph.ts @@ -45,6 +45,12 @@ interface VisibleGraphCache { graphData: IGraphData | null | undefined; } +interface ReferenceResultCache { + entries: Map; + nextReferenceId: number; + referenceIds: WeakMap; +} + function createVisibleGraphCache(): VisibleGraphCache { return { entries: new Map(), @@ -52,10 +58,35 @@ function createVisibleGraphCache(): VisibleGraphCache { }; } +function createReferenceResultCache(): ReferenceResultCache { + return { + entries: new Map(), + nextReferenceId: 1, + referenceIds: new WeakMap(), + }; +} + function sortedRecordEntries(record: Record): [string, TValue][] { return Object.entries(record).sort(([left], [right]) => left.localeCompare(right)); } +function createStyledGraphCacheKey({ + edgeTypes, + nodeColors, +}: { + edgeTypes: IGraphEdgeTypeDefinition[]; + nodeColors: Record; +}): string { + return JSON.stringify({ + edgeTypes: edgeTypes.map(({ defaultColor, id }) => [id, defaultColor]), + nodeColors: sortedRecordEntries(nodeColors), + }); +} + +function createLegendGraphCacheKey(legends: IGroup[]): string { + return JSON.stringify(legends); +} + function createVisibleGraphCacheKey({ edgeTypes, edgeVisibility, @@ -108,6 +139,60 @@ function cacheVisibleGraphResult( } } +function getReferenceId( + cache: ReferenceResultCache, + reference: object, +): number { + const existing = cache.referenceIds.get(reference); + if (existing !== undefined) { + return existing; + } + + const id = cache.nextReferenceId; + cache.nextReferenceId += 1; + cache.referenceIds.set(reference, id); + return id; +} + +function getReferenceResultCacheKey( + cache: ReferenceResultCache, + reference: object, + key: string, +): string { + return `${getReferenceId(cache, reference)}:${key}`; +} + +function getReferenceResult( + cache: ReferenceResultCache, + reference: object, + key: string, +): TValue | undefined { + return cache.entries.get(getReferenceResultCacheKey(cache, reference, key)); +} + +function cacheReferenceResult( + cache: ReferenceResultCache, + reference: object, + key: string, + result: TValue, +): void { + const cacheKey = getReferenceResultCacheKey(cache, reference, key); + if (cache.entries.has(cacheKey)) { + cache.entries.delete(cacheKey); + } + + cache.entries.set(cacheKey, result); + + while (cache.entries.size > VISIBLE_GRAPH_CACHE_LIMIT) { + const oldestKey = cache.entries.keys().next().value; + if (!oldestKey) { + return; + } + + cache.entries.delete(oldestKey); + } +} + /** * Derives the filtered + colored graph data. * Both memos recompute only when their specific inputs change. @@ -126,7 +211,17 @@ export function useFilteredGraph( showOrphans = true, nodeTypes: IGraphNodeTypeDefinition[] = [], ): IFilteredGraph { + const coloredGraphCache = useRef(createReferenceResultCache()); + const styledGraphCache = useRef(createReferenceResultCache()); const visibleGraphCache = useRef(createVisibleGraphCache()); + const legendGraphCacheKey = useMemo(() => createLegendGraphCacheKey(legends), [legends]); + const styledGraphCacheKey = useMemo(() => createStyledGraphCacheKey({ + edgeTypes, + nodeColors, + }), [ + edgeTypes, + nodeColors, + ]); const visibleGraphCacheKey = useMemo(() => createVisibleGraphCacheKey({ edgeTypes, edgeVisibility, @@ -197,25 +292,44 @@ export function useFilteredGraph( return null; } + const cached = getReferenceResult(styledGraphCache.current, graph, styledGraphCacheKey); + if (cached) { + return cached; + } + const edgeTypesForStyling = withSharedEdgeTypeAliases(edgeTypes); - return measureWebviewPerformance('visibleGraph.style', { + const result = measureWebviewPerformance('visibleGraph.style', { edgeCount: graph.edges.length, nodeCount: graph.nodes.length, }, () => ({ nodes: applyNodeTypeColors(withResolvedNodeTypes(graph.nodes), nodeColors), edges: applyEdgeTypeDefaultColors(graph.edges, edgeTypesForStyling), })); - }, [edgeTypes, nodeColors, visibleGraph.graphData]); + cacheReferenceResult(styledGraphCache.current, graph, styledGraphCacheKey, result); + return result; + }, [edgeTypes, nodeColors, styledGraphCacheKey, visibleGraph.graphData]); + + const coloredData = useMemo(() => { + if (!filteredData) { + return null; + } - const coloredData = useMemo( - () => measureWebviewPerformance('visibleGraph.applyLegendRules', { + const cached = getReferenceResult(coloredGraphCache.current, filteredData, legendGraphCacheKey); + if (cached) { + return cached; + } + + const result = measureWebviewPerformance('visibleGraph.applyLegendRules', { edgeCount: filteredData?.edges.length ?? 0, legendCount: legends.length, nodeCount: filteredData?.nodes.length ?? 0, - }, () => applyLegendRules(filteredData, legends)), - [filteredData, legends], - ); + }, () => applyLegendRules(filteredData, legends)); + if (result) { + cacheReferenceResult(coloredGraphCache.current, filteredData, legendGraphCacheKey, result); + } + return result; + }, [filteredData, legendGraphCacheKey, legends]); const controlsEdgeDecorations = useMemo( () => measureWebviewPerformance('visibleGraph.edgeDecorations', { diff --git a/packages/extension/tests/webview/search/useFilteredGraph.mutations.test.ts b/packages/extension/tests/webview/search/useFilteredGraph.mutations.test.ts index 12ac5192c..314c80192 100644 --- a/packages/extension/tests/webview/search/useFilteredGraph.mutations.test.ts +++ b/packages/extension/tests/webview/search/useFilteredGraph.mutations.test.ts @@ -154,4 +154,35 @@ describe('useFilteredGraph dependency array mutations', () => { expect(deriveVisibleGraphMock).toHaveBeenCalledTimes(2); }); + + it('reuses colored graph data when graph scope returns to a cached visible graph', () => { + const edgeTypes: IGraphEdgeTypeDefinition[] = [ + { id: 'import', label: 'Imports', defaultColor: '#60a5fa', defaultVisible: true }, + ]; + const groups: IGroup[] = [{ id: 'source', pattern: 'src/**', color: '#ff0000' }]; + deriveVisibleGraphMock.mockImplementation(( + _graphData: IGraphData | null, + config: { scope?: { edges?: { type: string; enabled: boolean }[] } }, + ) => { + const importEnabled = config.scope?.edges?.find(edge => edge.type === 'import')?.enabled !== false; + return { + graphData: importEnabled ? graphA : graphB, + regexError: null, + }; + }); + const { result, rerender } = renderHook( + ({ edgeVisibility }) => + useFilteredGraph(graphA, '', defaultOptions, groups, {}, {}, edgeVisibility, edgeTypes), + { initialProps: { edgeVisibility: {} as Record } }, + ); + const initialColoredData = result.current.coloredData; + + rerender({ edgeVisibility: { import: false } }); + + expect(result.current.coloredData).not.toBe(initialColoredData); + + rerender({ edgeVisibility: {} }); + + expect(result.current.coloredData).toBe(initialColoredData); + }); }); From 1c3d58cfa0be8e6a7fb04613b8100165e42abb50 Mon Sep 17 00:00:00 2001 From: joesobo Date: Mon, 22 Jun 2026 18:49:04 -0700 Subject: [PATCH 019/192] perf: render edge scope changes immediately Imports Graph Scope toggle improved from 236ms median / 270ms p95 to 203ms median / 226ms p95 across five VS Code harness samples. Edge-only Graph Scope changes now bypass the render debounce; node visibility changes keep the existing debounce. --- docs/performance/codegraphy-monorepo.md | 15 ++++++++++- .../2026-06-22-codegraphy-performance.md | 1 + .../webview/app/shell/graphScopeVisibility.ts | 16 +++++++++++- .../app/shell/graphScopeVisibility.test.tsx | 26 +++++++++++++++++++ 4 files changed, 56 insertions(+), 2 deletions(-) diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index 16209a024..a0e03c0d0 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -254,6 +254,15 @@ VS Code graph view benchmark: `visibleGraph.applyLegendRules` events; remaining stage medians were `graphRuntime.buildGraphData` `5.7ms` and `visibleGraph.edgeDecorations` `0.3ms`. +- After rendering edge-only Graph Scope changes immediately: + - VS Code launch: `1046ms`. + - Open Graph View to first rendered graph stats: `6895ms`. + - Initial rendered stats: `2249` nodes, `5333` connections. + - Imports toggle latency: `203ms` median, `226ms` p95 across 5 samples. + - Sampled toggles emitted `graphScope.visibility.renderImmediate` instead + of `graphScope.visibility.renderDebounced`; remaining stage medians were + `graphRuntime.buildGraphData` `5.9ms` and + `visibleGraph.edgeDecorations` `0.4ms`. Interpretation: @@ -281,8 +290,12 @@ Interpretation: median from `493ms` to `313ms`. - Caching recent styled and legend-applied graph stages removed the next `80ms` legend pass, moving the sampled toggle median from `313ms` to `236ms`. +- Rendering edge-only Graph Scope changes immediately removed the fixed + debounce wait from Imports toggles, moving the sampled toggle median from + `236ms` to `203ms`. - The next user-facing bottleneck is the remaining render latency after graph - data construction, not the measured data derivation stages. + data construction, not the measured data derivation or Graph Scope debounce + stages. Full test baseline: diff --git a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md index 81ada819d..b0a5858b1 100644 --- a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md +++ b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md @@ -288,6 +288,7 @@ Skipped value-equal graph control echoes: 493ms median / 497ms p95; one visibleG Rejected stable filter matcher cache: 494ms median / 513ms p95; derive stayed ~176.7ms, so no measured win. Cached recent visible-graph derivations: 313ms median / 345ms p95; sampled toggles had no visibleGraph.derive events. Cached recent style and legend stages: 236ms median / 270ms p95; sampled toggles had no derive/style/legend events. +Rendered edge-only Graph Scope changes immediately: 203ms median / 226ms p95; Imports toggles now emit renderImmediate instead of renderDebounced. ``` ## Task 5: Keep The PR Reviewable diff --git a/packages/extension/src/webview/app/shell/graphScopeVisibility.ts b/packages/extension/src/webview/app/shell/graphScopeVisibility.ts index c5196214b..6cbfd12e2 100644 --- a/packages/extension/src/webview/app/shell/graphScopeVisibility.ts +++ b/packages/extension/src/webview/app/shell/graphScopeVisibility.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import type { GraphState } from '../../store/state'; import { recordWebviewPerformanceEvent } from '../../performance/marks'; @@ -17,8 +17,22 @@ export function useDebouncedGraphScopeVisibility( edgeVisibility, nodeVisibility, }); + const renderVisibilityRef = useRef(renderVisibility); + renderVisibilityRef.current = renderVisibility; useEffect(() => { + if (renderVisibilityRef.current.nodeVisibility === nodeVisibility) { + recordWebviewPerformanceEvent('graphScope.visibility.renderImmediate', { + edgeVisibilityCount: Object.keys(edgeVisibility).length, + nodeVisibilityCount: Object.keys(nodeVisibility).length, + }); + setRenderVisibility({ + edgeVisibility, + nodeVisibility, + }); + return; + } + const timer = setTimeout(() => { recordWebviewPerformanceEvent('graphScope.visibility.renderDebounced', { edgeVisibilityCount: Object.keys(edgeVisibility).length, diff --git a/packages/extension/tests/webview/app/shell/graphScopeVisibility.test.tsx b/packages/extension/tests/webview/app/shell/graphScopeVisibility.test.tsx index bbcdb0d92..2609579b6 100644 --- a/packages/extension/tests/webview/app/shell/graphScopeVisibility.test.tsx +++ b/packages/extension/tests/webview/app/shell/graphScopeVisibility.test.tsx @@ -10,6 +10,32 @@ describe('useDebouncedGraphScopeVisibility', () => { vi.useRealTimers(); }); + it('renders edge-only graph scope changes immediately', () => { + vi.useFakeTimers(); + const nodeVisibility = { file: true }; + const initialEdgeVisibility = { include: true }; + const nextEdgeVisibility = { include: false }; + + const { result, rerender } = renderHook( + ({ edgeVisibility }) => useDebouncedGraphScopeVisibility( + nodeVisibility, + edgeVisibility, + ), + { + initialProps: { + edgeVisibility: initialEdgeVisibility, + }, + }, + ); + + rerender({ edgeVisibility: nextEdgeVisibility }); + + expect(result.current).toEqual({ + edgeVisibility: nextEdgeVisibility, + nodeVisibility, + }); + }); + it('keeps the current render visibility until rapid graph scope changes settle', () => { vi.useFakeTimers(); const initialNodeVisibility = { file: true }; From 50b27a9bfc75ad9f4a43a4116e16ea9f7740b11e Mon Sep 17 00:00:00 2001 From: joesobo Date: Mon, 22 Jun 2026 18:51:44 -0700 Subject: [PATCH 020/192] test: report webview graph toggle deltas The VS Code graph view performance report now includes webviewEventDelta for the browser-side optimistic-to-rendered path. Latest Imports Graph Scope run: 209ms wall-clock median / 219ms p95, with 55ms in-webview median / 58ms p95. --- docs/performance/codegraphy-monorepo.md | 12 +++++ .../2026-06-22-codegraphy-performance.md | 1 + .../performance/measure-vscode-graph-view.mjs | 36 ++++++++++++++- .../measure-vscode-graph-view.test.mjs | 44 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index a0e03c0d0..9e46351e1 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -263,6 +263,14 @@ VS Code graph view benchmark: of `graphScope.visibility.renderDebounced`; remaining stage medians were `graphRuntime.buildGraphData` `5.9ms` and `visibleGraph.edgeDecorations` `0.4ms`. +- After adding the in-webview event-delta metric to the VS Code harness: + - VS Code launch: `1068ms`. + - Open Graph View to first rendered graph stats: `6377ms`. + - Initial rendered stats: `2249` nodes, `5333` connections. + - Imports toggle wall-clock latency: `209ms` median, `219ms` p95 across + 5 samples. + - In-webview optimistic-to-rendered latency: + `55ms` median, `58ms` p95 across 5 samples. Interpretation: @@ -293,6 +301,10 @@ Interpretation: - Rendering edge-only Graph Scope changes immediately removed the fixed debounce wait from Imports toggles, moving the sampled toggle median from `236ms` to `203ms`. +- The VS Code harness still reports a Playwright-driven wall-clock duration, + but it now also reports the browser-side delta from + `graphScope.edgeVisibility.optimistic` to `graphStats.rendered`. The current + in-webview median is `55ms`, while the wall-clock median is `209ms`. - The next user-facing bottleneck is the remaining render latency after graph data construction, not the measured data derivation or Graph Scope debounce stages. diff --git a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md index b0a5858b1..470ccc346 100644 --- a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md +++ b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md @@ -289,6 +289,7 @@ Rejected stable filter matcher cache: 494ms median / 513ms p95; derive stayed ~1 Cached recent visible-graph derivations: 313ms median / 345ms p95; sampled toggles had no visibleGraph.derive events. Cached recent style and legend stages: 236ms median / 270ms p95; sampled toggles had no derive/style/legend events. Rendered edge-only Graph Scope changes immediately: 203ms median / 226ms p95; Imports toggles now emit renderImmediate instead of renderDebounced. +Added in-webview delta metric: latest wall-clock 209ms median / 219ms p95, browser-side optimistic-to-rendered 55ms median / 58ms p95. ``` ## Task 5: Keep The PR Reviewable diff --git a/scripts/performance/measure-vscode-graph-view.mjs b/scripts/performance/measure-vscode-graph-view.mjs index 3ff6c5189..ea9c0f1d7 100644 --- a/scripts/performance/measure-vscode-graph-view.mjs +++ b/scripts/performance/measure-vscode-graph-view.mjs @@ -11,6 +11,8 @@ const DEFAULT_OUTPUT_PATH = 'reports/performance/vscode-graph-view-latest.json'; const DEFAULT_ITERATIONS = 5; const DEFAULT_WARMUP_ITERATIONS = 1; const DEFAULT_TIMEOUT_MS = 120_000; +const IMPORTS_TOGGLE_START_EVENT = 'graphScope.edgeVisibility.optimistic'; +const IMPORTS_TOGGLE_RENDERED_EVENT = 'graphStats.rendered'; const DEFAULT_PLUGIN_PACKAGE_RELATIVE_PATHS = [ 'packages/plugin-godot', 'packages/plugin-markdown', @@ -59,6 +61,38 @@ function sameGraphStats(left, right) { return left.nodeCount === right.nodeCount && left.edgeCount === right.edgeCount; } +function findWebviewEventAt(sample, eventName) { + const event = sample.webviewEvents?.find(item => item.name === eventName); + return typeof event?.at === 'number' ? event.at : undefined; +} + +export function getWebviewEventDeltaMs( + sample, + startEventName = IMPORTS_TOGGLE_START_EVENT, + renderedEventName = IMPORTS_TOGGLE_RENDERED_EVENT, +) { + const startedAt = findWebviewEventAt(sample, startEventName); + const renderedAt = findWebviewEventAt(sample, renderedEventName); + if (startedAt === undefined || renderedAt === undefined) { + return undefined; + } + + return renderedAt - startedAt; +} + +export function summarizeSwitchTransitionSamples(samples) { + const webviewEventDeltas = samples + .map(sample => getWebviewEventDeltaMs(sample)) + .filter(value => value !== undefined); + + return { + ...summarizeDurations(samples.map(sample => sample.durationMs)), + ...(webviewEventDeltas.length > 0 + ? { webviewEventDelta: summarizeDurations(webviewEventDeltas) } + : {}), + }; +} + async function readGraphStats(frame) { const text = await frame .getByText(/[\d,]+\s+nodes?.*?[\d,]+\s+connections?/i) @@ -238,7 +272,7 @@ async function measureVSCodeGraphView({ firstGraphReadyMs, initialStats, importsToggle: { - ...summarizeDurations(samples.map(sample => sample.durationMs)), + ...summarizeSwitchTransitionSamples(samples), samples, }, }; diff --git a/tests/scripts/measure-vscode-graph-view.test.mjs b/tests/scripts/measure-vscode-graph-view.test.mjs index e139f28d6..67e4c0791 100644 --- a/tests/scripts/measure-vscode-graph-view.test.mjs +++ b/tests/scripts/measure-vscode-graph-view.test.mjs @@ -19,3 +19,47 @@ test('VS Code graph view runner parses graph stats labels', async () => { }); assert.equal(parseGraphStatsLabel('Loading graph...'), null); }); + +test('VS Code graph view runner summarizes in-webview toggle event deltas', async () => { + const moduleUrl = pathToFileURL( + path.resolve('scripts/performance/measure-vscode-graph-view.mjs'), + ).href; + const { summarizeSwitchTransitionSamples } = await import(moduleUrl); + + assert.deepEqual(summarizeSwitchTransitionSamples([ + { + durationMs: 220, + webviewEvents: [ + { name: 'graphScope.edgeVisibility.optimistic', at: 10 }, + { name: 'graphStats.rendered', at: 63.2 }, + ], + }, + { + durationMs: 190, + webviewEvents: [ + { name: 'graphScope.edgeVisibility.optimistic', at: 20 }, + { name: 'graphStats.rendered', at: 75.6 }, + ], + }, + { + durationMs: 205, + webviewEvents: [ + { name: 'graphScope.edgeVisibility.optimistic', at: 30 }, + { name: 'graphStats.rendered', at: 79.4 }, + ], + }, + ]), { + iterations: 3, + minMs: 190, + medianMs: 205, + p95Ms: 220, + maxMs: 220, + webviewEventDelta: { + iterations: 3, + minMs: 49, + medianMs: 53, + p95Ms: 56, + maxMs: 56, + }, + }); +}); From 64a53d3aef07060a6f01bffb79fb782b51eaf42e Mon Sep 17 00:00:00 2001 From: joesobo Date: Mon, 22 Jun 2026 18:59:25 -0700 Subject: [PATCH 021/192] docs: record rejected startup timing experiment --- docs/performance/codegraphy-monorepo.md | 8 ++++++++ .../plans/2026-06-22-codegraphy-performance.md | 1 + 2 files changed, 9 insertions(+) diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index 9e46351e1..da7a4c96a 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -271,6 +271,14 @@ VS Code graph view benchmark: 5 samples. - In-webview optimistic-to-rendered latency: `55ms` median, `58ms` p95 across 5 samples. +- Rejected startup timeline replay reorder: + - Tried moving cached timeline replay after graph bootstrap so first graph + stats could render before slow timeline work. + - VS Code launch: `1259ms`. + - Open Graph View to first rendered graph stats regressed to `7285ms`. + - Imports toggle wall-clock latency stayed flat at `204ms` median, + `219ms` p95; in-webview latency was `53ms` median, `111ms` p95. + - The change was reverted because it did not improve first graph readiness. Interpretation: diff --git a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md index 470ccc346..7eff6b05a 100644 --- a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md +++ b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md @@ -290,6 +290,7 @@ Cached recent visible-graph derivations: 313ms median / 345ms p95; sampled toggl Cached recent style and legend stages: 236ms median / 270ms p95; sampled toggles had no derive/style/legend events. Rendered edge-only Graph Scope changes immediately: 203ms median / 226ms p95; Imports toggles now emit renderImmediate instead of renderDebounced. Added in-webview delta metric: latest wall-clock 209ms median / 219ms p95, browser-side optimistic-to-rendered 55ms median / 58ms p95. +Rejected startup timeline replay reorder: first graph readiness regressed from 6377ms to 7285ms; reverted. ``` ## Task 5: Keep The PR Reviewable From 819b817457cd03ad068e6aefb4bd7486b43286e9 Mon Sep 17 00:00:00 2001 From: joesobo Date: Mon, 22 Jun 2026 19:08:42 -0700 Subject: [PATCH 022/192] test: measure graph view startup phases --- docs/performance/codegraphy-monorepo.md | 18 +++++- .../2026-06-22-codegraphy-performance.md | 1 + .../performance/measure-vscode-graph-view.mjs | 60 ++++++++++++++++--- .../measure-vscode-graph-view.test.mjs | 29 +++++++++ 4 files changed, 98 insertions(+), 10 deletions(-) diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index da7a4c96a..de720c433 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -279,6 +279,16 @@ VS Code graph view benchmark: - Imports toggle wall-clock latency stayed flat at `204ms` median, `219ms` p95; in-webview latency was `53ms` median, `111ms` p95. - The change was reverted because it did not improve first graph readiness. +- After adding startup webview stage and first-ready phase instrumentation: + - VS Code launch: `868ms`. + - Open Graph View to first rendered graph stats: `6789ms`. + - First-ready phases: command/open `1709ms`, acceptance-ready frame + `5032ms`, stats wait after frame discovery `35ms`. + - Startup webview stage medians: `visibleGraph.derive` `96ms`, + `visibleGraph.applyLegendRules` `0ms` with `89ms` p95, + `visibleGraph.style` `5ms`, `graphRuntime.buildGraphData` `7ms`. + - Imports toggle wall-clock latency: `208ms` median, `243ms` p95; in-webview + latency: `56ms` median, `61ms` p95. Interpretation: @@ -313,9 +323,11 @@ Interpretation: but it now also reports the browser-side delta from `graphScope.edgeVisibility.optimistic` to `graphStats.rendered`. The current in-webview median is `55ms`, while the wall-clock median is `209ms`. -- The next user-facing bottleneck is the remaining render latency after graph - data construction, not the measured data derivation or Graph Scope debounce - stages. +- Startup instrumentation shows first graph readiness is now dominated by + command/view opening and waiting for the acceptance-ready webview frame. Once + that frame is found, the stats label is already available within tens of + milliseconds; the startup webview data stages are not the multi-second + bottleneck. Full test baseline: diff --git a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md index 7eff6b05a..211aca88d 100644 --- a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md +++ b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md @@ -291,6 +291,7 @@ Cached recent style and legend stages: 236ms median / 270ms p95; sampled toggles Rendered edge-only Graph Scope changes immediately: 203ms median / 226ms p95; Imports toggles now emit renderImmediate instead of renderDebounced. Added in-webview delta metric: latest wall-clock 209ms median / 219ms p95, browser-side optimistic-to-rendered 55ms median / 58ms p95. Rejected startup timeline replay reorder: first graph readiness regressed from 6377ms to 7285ms; reverted. +Added startup phase metrics: latest first graph readiness 6789ms split into 1709ms command/open, 5032ms acceptance-ready frame, 35ms stats wait; startup webview data stages are sub-second. ``` ## Task 5: Keep The PR Reviewable diff --git a/scripts/performance/measure-vscode-graph-view.mjs b/scripts/performance/measure-vscode-graph-view.mjs index ea9c0f1d7..4dc7d7175 100644 --- a/scripts/performance/measure-vscode-graph-view.mjs +++ b/scripts/performance/measure-vscode-graph-view.mjs @@ -11,6 +11,7 @@ const DEFAULT_OUTPUT_PATH = 'reports/performance/vscode-graph-view-latest.json'; const DEFAULT_ITERATIONS = 5; const DEFAULT_WARMUP_ITERATIONS = 1; const DEFAULT_TIMEOUT_MS = 120_000; +const WEBVIEW_PERFORMANCE_EVENT_LIMIT = 500; const IMPORTS_TOGGLE_START_EVENT = 'graphScope.edgeVisibility.optimistic'; const IMPORTS_TOGGLE_RENDERED_EVENT = 'graphStats.rendered'; const DEFAULT_PLUGIN_PACKAGE_RELATIVE_PATHS = [ @@ -93,6 +94,26 @@ export function summarizeSwitchTransitionSamples(samples) { }; } +export function summarizeWebviewEventDurations(events) { + const durationsByEventName = new Map(); + + for (const event of events) { + if (typeof event.durationMs !== 'number') { + continue; + } + + const durations = durationsByEventName.get(event.name) ?? []; + durations.push(event.durationMs); + durationsByEventName.set(event.name, durations); + } + + return Object.fromEntries( + [...durationsByEventName.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([name, durations]) => [name, summarizeDurations(durations)]), + ); +} + async function readGraphStats(frame) { const text = await frame .getByText(/[\d,]+\s+nodes?.*?[\d,]+\s+connections?/i) @@ -121,6 +142,16 @@ async function waitForGraphStats(frame, predicate, timeoutMs = DEFAULT_TIMEOUT_M throw new Error(`Timed out waiting for graph stats. Last stats: ${JSON.stringify(lastStats)}`); } +async function installWebviewPerformanceInitScript(page) { + await page.addInitScript((limit) => { + window.__codegraphyPerformance = { + enabled: true, + events: [], + limit, + }; + }, WEBVIEW_PERFORMANCE_EVENT_LIMIT); +} + async function openGraphScopeEdgeTypes(frame) { await frame.getByTitle('Graph Scope').click({ force: true }); const edgeTypesButton = frame.getByRole('button', { name: 'Edge Types' }); @@ -155,23 +186,24 @@ async function waitForSwitchEnabled(frame, label, enabled) { } async function enableWebviewPerformanceEvents(frame) { - await frame.evaluate(() => { + await frame.evaluate((limit) => { + const existing = window.__codegraphyPerformance; window.__codegraphyPerformance = { enabled: true, - events: [], - limit: 500, + events: Array.isArray(existing?.events) ? existing.events : [], + limit, }; - }); + }, WEBVIEW_PERFORMANCE_EVENT_LIMIT); } async function resetWebviewPerformanceEvents(frame) { - await frame.evaluate(() => { + await frame.evaluate((limit) => { window.__codegraphyPerformance = { enabled: true, events: [], - limit: 500, + limit, }; - }); + }, WEBVIEW_PERFORMANCE_EVENT_LIMIT); } async function readWebviewPerformanceEvents(frame) { @@ -243,12 +275,19 @@ async function measureVSCodeGraphView({ pluginPackageRelativePaths: DEFAULT_PLUGIN_PACKAGE_RELATIVE_PATHS, }); const launchMs = Math.round(performance.now() - launchStartedAt); + await installWebviewPerformanceInitScript(vscode.page); const openStartedAt = performance.now(); await openGraphView(vscode.page); + const openGraphCommandMs = Math.round(performance.now() - openStartedAt); + const frameStartedAt = performance.now(); const frame = await waitForGraphFrame(vscode.page); + const graphFrameReadyMs = Math.round(performance.now() - frameStartedAt); await enableWebviewPerformanceEvents(frame); + const statsStartedAt = performance.now(); const initialStats = await waitForGraphStats(frame, stats => stats.nodeCount > 0); + const graphStatsReadyMs = Math.round(performance.now() - statsStartedAt); const firstGraphReadyMs = Math.round(performance.now() - openStartedAt); + const firstGraphReadyWebviewEvents = await readWebviewPerformanceEvents(frame); await openGraphScopeEdgeTypes(frame); const initialImportsEnabled = await readSwitchEnabled(frame, 'Imports'); @@ -270,6 +309,13 @@ async function measureVSCodeGraphView({ const measurements = { vscodeLaunchMs: launchMs, firstGraphReadyMs, + firstGraphReadyPhases: { + openGraphCommandMs, + graphFrameReadyMs, + graphStatsReadyMs, + }, + firstGraphReadyWebviewStages: summarizeWebviewEventDurations(firstGraphReadyWebviewEvents), + firstGraphReadyWebviewEvents, initialStats, importsToggle: { ...summarizeSwitchTransitionSamples(samples), diff --git a/tests/scripts/measure-vscode-graph-view.test.mjs b/tests/scripts/measure-vscode-graph-view.test.mjs index 67e4c0791..3cc3be828 100644 --- a/tests/scripts/measure-vscode-graph-view.test.mjs +++ b/tests/scripts/measure-vscode-graph-view.test.mjs @@ -63,3 +63,32 @@ test('VS Code graph view runner summarizes in-webview toggle event deltas', asyn }, }); }); + +test('VS Code graph view runner summarizes startup webview stage durations', async () => { + const moduleUrl = pathToFileURL( + path.resolve('scripts/performance/measure-vscode-graph-view.mjs'), + ).href; + const { summarizeWebviewEventDurations } = await import(moduleUrl); + + assert.deepEqual(summarizeWebviewEventDurations([ + { name: 'visibleGraph.derive', durationMs: 110.2 }, + { name: 'graphStats.rendered', at: 215 }, + { name: 'visibleGraph.derive', durationMs: 90.8 }, + { name: 'graphRuntime.buildGraphData', durationMs: 8.4 }, + ]), { + 'graphRuntime.buildGraphData': { + iterations: 1, + minMs: 8, + medianMs: 8, + p95Ms: 8, + maxMs: 8, + }, + 'visibleGraph.derive': { + iterations: 2, + minMs: 91, + medianMs: 101, + p95Ms: 110, + maxMs: 110, + }, + }); +}); From 00cb56a22a279a19061f7e078c1c23ad34efb008 Mon Sep 17 00:00:00 2001 From: joesobo Date: Mon, 22 Jun 2026 19:29:02 -0700 Subject: [PATCH 023/192] perf: lazy-load graph 3d runtime Move the 3D graph surface, Three.js runtime, and 3D node factory out of the default 2D webview bundle. Default dist/webview/index.js drops from 2242.28 kB minified / 632.54 kB gzip to 819.25 kB / 252.32 kB. The 3D path now loads through separate chunks. Large monorepo harness: first graph readiness was flat/noisy at 6789ms -> 6936ms; Imports toggle improved from 208ms median / 243ms p95 to 193ms median / 203ms p95. --- .changeset/lazy-3d-webview-bundle.md | 5 +++ docs/performance/codegraphy-monorepo.md | 18 ++++++++ .../2026-06-22-codegraphy-performance.md | 1 + .../src/extension/graphView/webview/html.ts | 4 +- .../surface/view/threeDimensional.tsx | 11 +++-- .../graph/rendering/useGraphCallbacks.ts | 14 ------- .../runtime/use/indicators/labelVisibility.ts | 2 +- .../runtime/use/indicators/meshHighlights.ts | 2 +- .../components/graph/runtime/use/rendering.ts | 4 +- .../components/graph/runtime/use/state.ts | 4 +- .../graph/support/contracts/forceGraph.ts | 2 +- .../graph/viewport/shell/surfaceProps.ts | 7 +++- .../components/graph/viewport/view.tsx | 41 +++++++++++++------ packages/extension/src/webview/main.tsx | 1 - .../extension/graphView/webview/html.test.ts | 3 +- .../tests/webview/Graph.wiring.test.tsx | 3 +- ...ncelsbothscheduledanimationframes.test.tsx | 11 ++++- ...wcoloraslinkdirectionalarrowcolor.test.tsx | 11 ++++- ...sto0whentopasseslinkcolorcallback.test.tsx | 13 +++++- .../surface/view/threeDimensional.test.tsx | 11 ++++- ...epscallbackidentitiesstableacross.test.tsx | 24 ----------- .../webview/graph/viewport/shell.test.tsx | 9 +++- ...tooltip.renderssurface2dfor2dmode.test.tsx | 13 +++++- .../webview/graph/viewport/tooltip.test.tsx | 13 +++++- ...tooltip.viewportstylemutationsl72.test.tsx | 13 +++++- ...viewporttooltipcountmutationsl111.test.tsx | 13 +++++- .../graph/viewport/view.mutations.test.tsx | 13 +++++- .../webview/graph/viewport/view.test.tsx | 15 +++++-- .../extension/tests/webview/main.test.tsx | 20 +++++++++ 29 files changed, 214 insertions(+), 87 deletions(-) create mode 100644 .changeset/lazy-3d-webview-bundle.md diff --git a/.changeset/lazy-3d-webview-bundle.md b/.changeset/lazy-3d-webview-bundle.md new file mode 100644 index 000000000..63a10a7ac --- /dev/null +++ b/.changeset/lazy-3d-webview-bundle.md @@ -0,0 +1,5 @@ +--- +"@codegraphy-dev/extension": patch +--- + +Speed up default Graph View startup work by lazy-loading the 3D graph runtime outside the initial 2D webview bundle. diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index de720c433..04e40b091 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -289,6 +289,20 @@ VS Code graph view benchmark: `visibleGraph.style` `5ms`, `graphRuntime.buildGraphData` `7ms`. - Imports toggle wall-clock latency: `208ms` median, `243ms` p95; in-webview latency: `56ms` median, `61ms` p95. +- After lazy-loading the 3D graph runtime outside the default 2D webview + bundle: + - Default `dist/webview/index.js` dropped from `2242.28 kB` minified + (`632.54 kB` gzip) to `819.25 kB` minified (`252.32 kB` gzip). + - 3D code now loads through separate chunks: + `threeDimensional-D-psWmzG.js` `694.00 kB`, `three.module-BKaKZvIP.js` + `726.58 kB`, and `runtime-CQzzxjLZ.js` `0.25 kB`. + - VS Code launch: `1125ms`. + - Open Graph View to first rendered graph stats: `6936ms`, effectively flat + against the `6789ms` startup-phase run. + - First-ready phases: command/open `1646ms`, acceptance-ready frame + `5189ms`, stats wait after frame discovery `40ms`. + - Imports toggle wall-clock latency: `193ms` median, `203ms` p95; in-webview + latency: `51ms` median, `81ms` p95. Interpretation: @@ -328,6 +342,10 @@ Interpretation: that frame is found, the stats label is already available within tens of milliseconds; the startup webview data stages are not the multi-second bottleneck. +- Lazy-loading the 3D runtime materially reduces the default Graph View bundle + and keeps the 2D path from paying for Three.js up front. The current VS Code + first-ready harness did not show a first-load win because its dominant bucket + remains the view/frame readiness wait, not measured webview data work. Full test baseline: diff --git a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md index 211aca88d..af39a1338 100644 --- a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md +++ b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md @@ -292,6 +292,7 @@ Rendered edge-only Graph Scope changes immediately: 203ms median / 226ms p95; Im Added in-webview delta metric: latest wall-clock 209ms median / 219ms p95, browser-side optimistic-to-rendered 55ms median / 58ms p95. Rejected startup timeline replay reorder: first graph readiness regressed from 6377ms to 7285ms; reverted. Added startup phase metrics: latest first graph readiness 6789ms split into 1709ms command/open, 5032ms acceptance-ready frame, 35ms stats wait; startup webview data stages are sub-second. +Lazy-loaded 3D runtime: default webview index.js 2242.28 kB -> 819.25 kB minified; latest Imports toggle 193ms median / 203ms p95, first graph readiness flat at 6936ms. ``` ## Task 5: Keep The PR Reviewable diff --git a/packages/extension/src/extension/graphView/webview/html.ts b/packages/extension/src/extension/graphView/webview/html.ts index 79bdcb892..a959dce2e 100644 --- a/packages/extension/src/extension/graphView/webview/html.ts +++ b/packages/extension/src/extension/graphView/webview/html.ts @@ -32,13 +32,13 @@ export function createGraphViewHtml( - + CodeGraphy
- + `; } diff --git a/packages/extension/src/webview/components/graph/rendering/surface/view/threeDimensional.tsx b/packages/extension/src/webview/components/graph/rendering/surface/view/threeDimensional.tsx index eb7dda8f6..75a4b184a 100644 --- a/packages/extension/src/webview/components/graph/rendering/surface/view/threeDimensional.tsx +++ b/packages/extension/src/webview/components/graph/rendering/surface/view/threeDimensional.tsx @@ -8,6 +8,10 @@ import type { LinkObject, NodeObject } from 'react-force-graph-2d'; import * as THREE from 'three'; import { DEFAULT_NODE_SIZE, type FGLink, type FGNode } from '../../../model/build'; import type { GraphSurfaceSharedProps } from '../sharedProps'; +import { + createNodeThreeObject, + type NodeThreeObjectDependencies, +} from '../../nodes/canvas3d'; type ForceGraph3DRef = MutableRefObject | undefined>; type Surface3dMeasurementKey = 'measured' | 'unmeasured'; @@ -21,7 +25,7 @@ export interface Surface3dProps { getLinkParticles: (this: void, link: LinkObject) => number; getLinkWidth: (this: void, link: LinkObject) => number; getParticleColor: (this: void, link: LinkObject) => string; - nodeThreeObject: (this: void, node: NodeObject) => THREE.Object3D; + nodeThreeObjectContext: NodeThreeObjectDependencies; particleSize: number; particleSpeed: number; sharedProps: GraphSurfaceSharedProps; @@ -77,7 +81,7 @@ export function Surface3d({ getLinkParticles, getLinkWidth, getParticleColor, - nodeThreeObject, + nodeThreeObjectContext, particleSize, particleSpeed, sharedProps, @@ -91,7 +95,8 @@ export function Surface3d({ nodeVal={(node: NodeObject) => (node as FGNode).size / DEFAULT_NODE_SIZE} nodeLabel="" nodeThreeObjectExtend={false} - nodeThreeObject={nodeThreeObject} + nodeThreeObject={(node: NodeObject): THREE.Object3D => + createNodeThreeObject(nodeThreeObjectContext, node as FGNode)} linkColor={getLinkColor} linkWidth={getLinkWidth} linkDirectionalArrowLength={directionMode === 'arrows' ? 6 : 0} diff --git a/packages/extension/src/webview/components/graph/rendering/useGraphCallbacks.ts b/packages/extension/src/webview/components/graph/rendering/useGraphCallbacks.ts index 3ddb31fcd..0d455432c 100644 --- a/packages/extension/src/webview/components/graph/rendering/useGraphCallbacks.ts +++ b/packages/extension/src/webview/components/graph/rendering/useGraphCallbacks.ts @@ -13,7 +13,6 @@ import { getGraphLinkWidth, } from './link/metrics'; import { renderBidirectionalLink } from './bidirectional/link'; -import { createNodeThreeObject } from './nodes/canvas3d'; import { paintNodePointerArea, renderNodeCanvas, @@ -53,7 +52,6 @@ export interface UseGraphCallbacksResult { linkCanvasObject: (this: void, link: LinkObject, ctx: CanvasRenderingContext2D, globalScale: number) => void; nodeCanvasObject: (this: void, node: NodeObject, ctx: CanvasRenderingContext2D, globalScale: number) => void; nodePointerAreaPaint: (this: void, node: NodeObject, color: string, ctx: CanvasRenderingContext2D) => void; - nodeThreeObject: (this: void, node: NodeObject) => ReturnType; } type GraphCallbackRefs = UseGraphCallbacksOptions['refs']; @@ -93,15 +91,6 @@ function getNodeCanvasContext({ }; } -function getNodeThreeObjectContext(refs: GraphCallbackRefs) { - return { - meshesRef: refs.meshesRef, - graphAppearanceRef: refs.graphAppearanceRef, - showLabelsRef: refs.showLabelsRef, - spritesRef: refs.spritesRef, - }; -} - export function useGraphCallbacks({ pluginHost, refs, @@ -159,9 +148,6 @@ export function useGraphCallbacks({ getLinkWidth(link) { return getGraphLinkWidth(getLinkRenderingContext(contextRef.current.refs), link as FGLink); }, - nodeThreeObject(node) { - return createNodeThreeObject(getNodeThreeObjectContext(contextRef.current.refs), node as FGNode); - }, }; } diff --git a/packages/extension/src/webview/components/graph/runtime/use/indicators/labelVisibility.ts b/packages/extension/src/webview/components/graph/runtime/use/indicators/labelVisibility.ts index 8f6f87f37..2d5e5bd18 100644 --- a/packages/extension/src/webview/components/graph/runtime/use/indicators/labelVisibility.ts +++ b/packages/extension/src/webview/components/graph/runtime/use/indicators/labelVisibility.ts @@ -2,7 +2,7 @@ import { useEffect, type MutableRefObject, } from 'react'; -import SpriteText from 'three-spritetext'; +import type SpriteText from 'three-spritetext'; import { setSpriteVisible } from '../../../support/contracts/forceGraph'; interface UseLabelVisibilityOptions { diff --git a/packages/extension/src/webview/components/graph/runtime/use/indicators/meshHighlights.ts b/packages/extension/src/webview/components/graph/runtime/use/indicators/meshHighlights.ts index efa7b11d9..7ab9246c7 100644 --- a/packages/extension/src/webview/components/graph/runtime/use/indicators/meshHighlights.ts +++ b/packages/extension/src/webview/components/graph/runtime/use/indicators/meshHighlights.ts @@ -2,7 +2,7 @@ import { useEffect, type MutableRefObject, } from 'react'; -import * as THREE from 'three'; +import type * as THREE from 'three'; import { DEFAULT_GRAPH_APPEARANCE, type GraphAppearance } from '../../../appearance/model'; import type { FGLink, FGNode } from '../../../model/build'; diff --git a/packages/extension/src/webview/components/graph/runtime/use/rendering.ts b/packages/extension/src/webview/components/graph/runtime/use/rendering.ts index 859f6a598..faa186890 100644 --- a/packages/extension/src/webview/components/graph/runtime/use/rendering.ts +++ b/packages/extension/src/webview/components/graph/runtime/use/rendering.ts @@ -9,8 +9,8 @@ import type { import type { ForceGraphMethods as FG3DMethods, } from 'react-force-graph-3d'; -import * as THREE from 'three'; -import SpriteText from 'three-spritetext'; +import type * as THREE from 'three'; +import type SpriteText from 'three-spritetext'; import type { IGraphData } from '../../../../../shared/graph/contracts'; import type { IPhysicsSettings } from '../../../../../shared/settings/physics'; import { ThemeKind } from '../../../../theme/useTheme'; diff --git a/packages/extension/src/webview/components/graph/runtime/use/state.ts b/packages/extension/src/webview/components/graph/runtime/use/state.ts index 51ecd7405..8bc825b8d 100644 --- a/packages/extension/src/webview/components/graph/runtime/use/state.ts +++ b/packages/extension/src/webview/components/graph/runtime/use/state.ts @@ -10,8 +10,8 @@ import { import type { CoreGraphViewContributionSet } from '@codegraphy-dev/core'; import type { ForceGraphMethods as FG2DMethods } from 'react-force-graph-2d'; import type { ForceGraphMethods as FG3DMethods } from 'react-force-graph-3d'; -import * as THREE from 'three'; -import SpriteText from 'three-spritetext'; +import type * as THREE from 'three'; +import type SpriteText from 'three-spritetext'; import type { IFileInfo } from '../../../../../shared/files/info'; import type { IGraphData } from '../../../../../shared/graph/contracts'; import type { EdgeDecorationPayload, NodeDecorationPayload } from '../../../../../shared/plugins/decorations'; diff --git a/packages/extension/src/webview/components/graph/support/contracts/forceGraph.ts b/packages/extension/src/webview/components/graph/support/contracts/forceGraph.ts index ba541b307..414460f60 100644 --- a/packages/extension/src/webview/components/graph/support/contracts/forceGraph.ts +++ b/packages/extension/src/webview/components/graph/support/contracts/forceGraph.ts @@ -1,5 +1,5 @@ import type { ForceGraphMethods as FG2DMethods, LinkObject, NodeObject } from 'react-force-graph-2d'; -import SpriteText from 'three-spritetext'; +import type SpriteText from 'three-spritetext'; export type FG2DExtMethods = FG2DMethods & { diff --git a/packages/extension/src/webview/components/graph/viewport/shell/surfaceProps.ts b/packages/extension/src/webview/components/graph/viewport/shell/surfaceProps.ts index 1dfb76dcd..d363aff4d 100644 --- a/packages/extension/src/webview/components/graph/viewport/shell/surfaceProps.ts +++ b/packages/extension/src/webview/components/graph/viewport/shell/surfaceProps.ts @@ -42,7 +42,12 @@ export function createGraphViewportSurfaceProps({ getLinkParticles: callbacks.getLinkParticles, getLinkWidth: callbacks.getLinkWidth, getParticleColor: callbacks.getParticleColor, - nodeThreeObject: callbacks.nodeThreeObject, + nodeThreeObjectContext: { + graphAppearanceRef: graphState.graphAppearanceRef, + meshesRef: graphState.renderCaches.meshesRef, + showLabelsRef: graphState.showLabelsRef, + spritesRef: graphState.renderCaches.spritesRef, + }, particleSize: viewState.particleSize, particleSpeed: viewState.particleSpeed, sharedProps, diff --git a/packages/extension/src/webview/components/graph/viewport/view.tsx b/packages/extension/src/webview/components/graph/viewport/view.tsx index 8449f6d35..6bf8c7ada 100644 --- a/packages/extension/src/webview/components/graph/viewport/view.tsx +++ b/packages/extension/src/webview/components/graph/viewport/view.tsx @@ -1,4 +1,13 @@ -import { memo, useRef, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactElement, type Ref } from 'react'; +import { + lazy, + memo, + Suspense, + useRef, + type KeyboardEvent as ReactKeyboardEvent, + type MouseEvent as ReactMouseEvent, + type ReactElement, + type Ref, +} from 'react'; import type { DirectionMode } from '../../../../shared/settings/modes'; import type { GraphMarqueeSelectionState } from '../marqueeSelection/model'; import type { GraphTooltipState } from '../tooltip/model'; @@ -19,16 +28,19 @@ import { Surface2d, type Surface2dProps, } from '../rendering/surface/view/twoDimensional'; -import { - DeferredSurface3d, - type Surface3dProps, -} from '../rendering/surface/view/threeDimensional'; +import type { Surface3dProps } from '../rendering/surface/view/threeDimensional'; import { SurfaceFallbackBoundary } from '../rendering/surface/view/fallbackBoundary'; import type { WebviewPluginHost } from '../../../pluginHost/manager'; import { SlotHost } from '../../../pluginHost/slotHost/view'; import type { GraphAccessibilityItems } from './accessibility'; import type { FGLink, FGNode } from '../model/build'; +const LazyDeferredSurface3d = lazy(async () => { + await import('../../../three/runtime'); + const module = await import('../rendering/surface/view/threeDimensional'); + return { default: module.DeferredSurface3d }; +}); + export interface ViewportProps { accessibilityItems?: GraphAccessibilityItems; canvasBackgroundColor: string; @@ -97,12 +109,14 @@ function ViewportSurface({ onError={onSurface3dError} fallback={fallback} > - + + + ); } @@ -136,8 +150,11 @@ function areSurface3dPropsEqual( && previous.getLinkColor === next.getLinkColor && previous.getLinkParticles === next.getLinkParticles && previous.getLinkWidth === next.getLinkWidth + && previous.nodeThreeObjectContext.graphAppearanceRef === next.nodeThreeObjectContext.graphAppearanceRef + && previous.nodeThreeObjectContext.meshesRef === next.nodeThreeObjectContext.meshesRef + && previous.nodeThreeObjectContext.showLabelsRef === next.nodeThreeObjectContext.showLabelsRef + && previous.nodeThreeObjectContext.spritesRef === next.nodeThreeObjectContext.spritesRef && previous.getParticleColor === next.getParticleColor - && previous.nodeThreeObject === next.nodeThreeObject && previous.particleSize === next.particleSize && previous.particleSpeed === next.particleSpeed && previous.sharedProps === next.sharedProps; diff --git a/packages/extension/src/webview/main.tsx b/packages/extension/src/webview/main.tsx index d1107b87d..27501939d 100644 --- a/packages/extension/src/webview/main.tsx +++ b/packages/extension/src/webview/main.tsx @@ -1,6 +1,5 @@ import React from 'react'; import { createRoot } from 'react-dom/client'; -import './three/runtime'; import App from './app/view'; import TimelineApp from './app/timeline/view'; import './index.css'; diff --git a/packages/extension/tests/extension/graphView/webview/html.test.ts b/packages/extension/tests/extension/graphView/webview/html.test.ts index 31ca31307..adf975cab 100644 --- a/packages/extension/tests/extension/graphView/webview/html.test.ts +++ b/packages/extension/tests/extension/graphView/webview/html.test.ts @@ -30,10 +30,11 @@ describe('graphView/webview/html', () => { 'light', ); - expect(html).toContain("script-src 'nonce-nonce-value'"); + expect(html).toContain("script-src vscode-webview://test 'nonce-nonce-value'"); expect(html).toContain("img-src vscode-webview://test data:"); expect(html).toContain('webview:/test/extension/dist/webview/index.js'); expect(html).toContain('webview:/test/extension/dist/webview/index.css'); + expect(html).toContain(''); expect(html).toContain('data-codegraphy-view="graph"'); expect(html).toContain('data-codegraphy-theme="light"'); expect(html).toContain('
'); diff --git a/packages/extension/tests/webview/Graph.wiring.test.tsx b/packages/extension/tests/webview/Graph.wiring.test.tsx index 636e8972f..a6c49e657 100644 --- a/packages/extension/tests/webview/Graph.wiring.test.tsx +++ b/packages/extension/tests/webview/Graph.wiring.test.tsx @@ -166,7 +166,6 @@ function createCallbacks() { linkCanvasObject: vi.fn(), nodeCanvasObject: vi.fn(), nodePointerAreaPaint: vi.fn(), - nodeThreeObject: vi.fn(), }; } @@ -265,7 +264,7 @@ describe('Graph wiring', () => { callbacks: expect.objectContaining({ getArrowColor: expect.any(Function), getLinkColor: expect.any(Function), - nodeThreeObject: expect.any(Function), + nodeCanvasObject: expect.any(Function), }), graphDataLayoutKey: expect.any(String), graphState: expect.objectContaining({ diff --git a/packages/extension/tests/webview/graph/rendering/surface/view/threeDimensional.passesgetparticlecoloraslinkdirectionalparticlecolortocancelsbothscheduledanimationframes.test.tsx b/packages/extension/tests/webview/graph/rendering/surface/view/threeDimensional.passesgetparticlecoloraslinkdirectionalparticlecolortocancelsbothscheduledanimationframes.test.tsx index 652c9089b..7c79d1d2d 100644 --- a/packages/extension/tests/webview/graph/rendering/surface/view/threeDimensional.passesgetparticlecoloraslinkdirectionalparticlecolortocancelsbothscheduledanimationframes.test.tsx +++ b/packages/extension/tests/webview/graph/rendering/surface/view/threeDimensional.passesgetparticlecoloraslinkdirectionalparticlecolortocancelsbothscheduledanimationframes.test.tsx @@ -31,6 +31,15 @@ function createSharedProps() { }; } +function createNodeThreeObjectContext() { + return { + graphAppearanceRef: { current: { labelForeground: '#f8fafc' } }, + meshesRef: { current: new Map() }, + showLabelsRef: { current: true }, + spritesRef: { current: new Map() }, + }; +} + function createDefaultProps() { return { backgroundColor: '#1e1e1e', @@ -41,7 +50,7 @@ function createDefaultProps() { getLinkParticles: vi.fn(() => 2), getLinkWidth: vi.fn(() => 1), getParticleColor: vi.fn(() => '#ff0000'), - nodeThreeObject: vi.fn(), + nodeThreeObjectContext: createNodeThreeObjectContext(), particleSize: 4, particleSpeed: 0.005, sharedProps: createSharedProps(), diff --git a/packages/extension/tests/webview/graph/rendering/surface/view/threeDimensional.passeslinkwidthcallbacktopassesgetarrowcoloraslinkdirectionalarrowcolor.test.tsx b/packages/extension/tests/webview/graph/rendering/surface/view/threeDimensional.passeslinkwidthcallbacktopassesgetarrowcoloraslinkdirectionalarrowcolor.test.tsx index 6d651578a..a73f7a0e9 100644 --- a/packages/extension/tests/webview/graph/rendering/surface/view/threeDimensional.passeslinkwidthcallbacktopassesgetarrowcoloraslinkdirectionalarrowcolor.test.tsx +++ b/packages/extension/tests/webview/graph/rendering/surface/view/threeDimensional.passeslinkwidthcallbacktopassesgetarrowcoloraslinkdirectionalarrowcolor.test.tsx @@ -29,6 +29,15 @@ function createSharedProps() { }; } +function createNodeThreeObjectContext() { + return { + graphAppearanceRef: { current: { labelForeground: '#f8fafc' } }, + meshesRef: { current: new Map() }, + showLabelsRef: { current: true }, + spritesRef: { current: new Map() }, + }; +} + function createDefaultProps() { return { backgroundColor: '#1e1e1e', @@ -39,7 +48,7 @@ function createDefaultProps() { getLinkParticles: vi.fn(() => 2), getLinkWidth: vi.fn(() => 1), getParticleColor: vi.fn(() => '#ff0000'), - nodeThreeObject: vi.fn(), + nodeThreeObjectContext: createNodeThreeObjectContext(), particleSize: 4, particleSpeed: 0.005, sharedProps: createSharedProps(), diff --git a/packages/extension/tests/webview/graph/rendering/surface/view/threeDimensional.setslinkdirectionalparticlesto0whentopasseslinkcolorcallback.test.tsx b/packages/extension/tests/webview/graph/rendering/surface/view/threeDimensional.setslinkdirectionalparticlesto0whentopasseslinkcolorcallback.test.tsx index ae0d3547f..5acd827fb 100644 --- a/packages/extension/tests/webview/graph/rendering/surface/view/threeDimensional.setslinkdirectionalparticlesto0whentopasseslinkcolorcallback.test.tsx +++ b/packages/extension/tests/webview/graph/rendering/surface/view/threeDimensional.setslinkdirectionalparticlesto0whentopasseslinkcolorcallback.test.tsx @@ -29,6 +29,15 @@ function createSharedProps() { }; } +function createNodeThreeObjectContext() { + return { + graphAppearanceRef: { current: { labelForeground: '#f8fafc' } }, + meshesRef: { current: new Map() }, + showLabelsRef: { current: true }, + spritesRef: { current: new Map() }, + }; +} + function createDefaultProps() { return { backgroundColor: '#1e1e1e', @@ -39,7 +48,7 @@ function createDefaultProps() { getLinkParticles: vi.fn(() => 2), getLinkWidth: vi.fn(() => 1), getParticleColor: vi.fn(() => '#ff0000'), - nodeThreeObject: vi.fn(), + nodeThreeObjectContext: createNodeThreeObjectContext(), particleSize: 4, particleSpeed: 0.005, sharedProps: createSharedProps(), @@ -98,7 +107,7 @@ describe('Surface3d', () => { const defaultProps = createDefaultProps(); render(); const props = (ForceGraph3D as unknown as { getLastProps: () => Record }).getLastProps(); - expect(props.nodeThreeObject).toBe(defaultProps.nodeThreeObject); + expect(props.nodeThreeObject).toEqual(expect.any(Function)); }); diff --git a/packages/extension/tests/webview/graph/rendering/surface/view/threeDimensional.test.tsx b/packages/extension/tests/webview/graph/rendering/surface/view/threeDimensional.test.tsx index 6385d31fe..f43ba525b 100644 --- a/packages/extension/tests/webview/graph/rendering/surface/view/threeDimensional.test.tsx +++ b/packages/extension/tests/webview/graph/rendering/surface/view/threeDimensional.test.tsx @@ -30,6 +30,15 @@ function createSharedProps() { }; } +function createNodeThreeObjectContext() { + return { + graphAppearanceRef: { current: { labelForeground: '#f8fafc' } }, + meshesRef: { current: new Map() }, + showLabelsRef: { current: true }, + spritesRef: { current: new Map() }, + }; +} + function createDefaultProps() { return { backgroundColor: '#1e1e1e', @@ -40,7 +49,7 @@ function createDefaultProps() { getLinkParticles: vi.fn(() => 2), getLinkWidth: vi.fn(() => 1), getParticleColor: vi.fn(() => '#ff0000'), - nodeThreeObject: vi.fn(), + nodeThreeObjectContext: createNodeThreeObjectContext(), particleSize: 4, particleSpeed: 0.005, sharedProps: createSharedProps(), diff --git a/packages/extension/tests/webview/graph/rendering/useGraphCallbacks.delegatesgetparticlecolortogetgraphdirectionalcolorandtokeepscallbackidentitiesstableacross.test.tsx b/packages/extension/tests/webview/graph/rendering/useGraphCallbacks.delegatesgetparticlecolortogetgraphdirectionalcolorandtokeepscallbackidentitiesstableacross.test.tsx index e7deec92f..478e15015 100644 --- a/packages/extension/tests/webview/graph/rendering/useGraphCallbacks.delegatesgetparticlecolortogetgraphdirectionalcolorandtokeepscallbackidentitiesstableacross.test.tsx +++ b/packages/extension/tests/webview/graph/rendering/useGraphCallbacks.delegatesgetparticlecolortogetgraphdirectionalcolorandtokeepscallbackidentitiesstableacross.test.tsx @@ -8,7 +8,6 @@ import { } from '../../../../src/webview/components/graph/rendering/useGraphCallbacks'; const renderingHarness = vi.hoisted(() => ({ - createNodeThreeObject: vi.fn(), getGraphArrowRelPos: vi.fn(), getGraphDirectionalColor: vi.fn(), getGraphLinkColor: vi.fn(), @@ -39,10 +38,6 @@ vi.mock('../../../../src/webview/components/graph/rendering/nodes/canvas2d', () renderNodeCanvas: renderingHarness.renderNodeCanvas, })); -vi.mock('../../../../src/webview/components/graph/rendering/nodes/canvas3d', () => ({ - createNodeThreeObject: renderingHarness.createNodeThreeObject, -})); - function createRefs(): UseGraphCallbacksOptions['refs'] { return { directionColorRef: { current: 'cycle' }, @@ -84,7 +79,6 @@ function renderUseGraphCallbacks(options: Partial = {} describe('graph/rendering/useGraphCallbacks', () => { beforeEach(() => { - renderingHarness.createNodeThreeObject.mockReset(); renderingHarness.getGraphArrowRelPos.mockReset(); renderingHarness.getGraphDirectionalColor.mockReset(); renderingHarness.getGraphLinkColor.mockReset(); @@ -134,24 +128,6 @@ describe('graph/rendering/useGraphCallbacks', () => { - it('delegates nodeThreeObject to createNodeThreeObject and returns its result', () => { - const node = { id: 'node-1' } as FGNode as NodeObject; - const threeObject = { isThreeObject: true }; - renderingHarness.createNodeThreeObject.mockReturnValue(threeObject); - const { refs, result } = renderUseGraphCallbacks(); - - const returnedObject = result.current.nodeThreeObject(node); - - expect(returnedObject).toBe(threeObject); - expect(renderingHarness.createNodeThreeObject).toHaveBeenCalledWith({ - meshesRef: refs.meshesRef, - showLabelsRef: refs.showLabelsRef, - spritesRef: refs.spritesRef, - }, node); - }); - - - it('keeps callback identities stable across rerenders while using the latest inputs', () => { const initialCallbacks = renderUseGraphCallbacks(); const node = { id: 'node-2' } as FGNode as NodeObject; diff --git a/packages/extension/tests/webview/graph/viewport/shell.test.tsx b/packages/extension/tests/webview/graph/viewport/shell.test.tsx index 52e9adb77..54915c40a 100644 --- a/packages/extension/tests/webview/graph/viewport/shell.test.tsx +++ b/packages/extension/tests/webview/graph/viewport/shell.test.tsx @@ -148,6 +148,7 @@ function createGraphState(graphData: GraphRuntime['renderer']['graphData']): Gra }, edgeDecorationsRef: { current: {} }, favoritesRef: { current: new Set() }, + graphAppearanceRef: { current: { labelForeground: '#f8fafc' } }, nodeDecorationsRef: { current: {} }, nodeSizeModeRef: { current: 'connections' }, setHighlightVersion: vi.fn(), @@ -206,7 +207,6 @@ function createCallbacks() { linkCanvasObject: vi.fn(), nodeCanvasObject: vi.fn(), nodePointerAreaPaint: vi.fn(), - nodeThreeObject: vi.fn(), }; } @@ -405,7 +405,12 @@ describe('graph/viewport/shell', () => { getArrowColor: callbacks.getArrowColor, getLinkColor: callbacks.getLinkColor, getParticleColor: callbacks.getParticleColor, - nodeThreeObject: callbacks.nodeThreeObject, + nodeThreeObjectContext: { + graphAppearanceRef: graphState.graphAppearanceRef, + meshesRef: graphState.renderCaches.meshesRef, + showLabelsRef: graphState.showLabelsRef, + spritesRef: graphState.renderCaches.spritesRef, + }, particleSize: 3, particleSpeed: 0.2, sharedProps: expect.objectContaining({ dagMode: 'td' }), diff --git a/packages/extension/tests/webview/graph/viewport/tooltip.renderssurface2dfor2dmode.test.tsx b/packages/extension/tests/webview/graph/viewport/tooltip.renderssurface2dfor2dmode.test.tsx index 5008d3d46..88aac0b91 100644 --- a/packages/extension/tests/webview/graph/viewport/tooltip.renderssurface2dfor2dmode.test.tsx +++ b/packages/extension/tests/webview/graph/viewport/tooltip.renderssurface2dfor2dmode.test.tsx @@ -33,7 +33,7 @@ vi.mock('../../../../src/webview/components/graph/rendering/surface/view/twoDime })); vi.mock('../../../../src/webview/components/graph/rendering/surface/view/threeDimensional', () => ({ - Surface3d: (props: Record) => { + DeferredSurface3d: (props: Record) => { harness.surface3d(props); return
; }, @@ -79,6 +79,15 @@ function createSharedProps() { }; } +function createNodeThreeObjectContext() { + return { + graphAppearanceRef: { current: { labelForeground: '#f8fafc' } }, + meshesRef: { current: new Map() }, + showLabelsRef: { current: true }, + spritesRef: { current: new Map() }, + }; +} + function renderViewport(overrides: Partial> = {}) { const handleContextMenu = vi.fn(); const handleMouseLeave = vi.fn(); @@ -125,7 +134,7 @@ function renderViewport(overrides: Partial getLinkParticles: vi.fn(), getLinkWidth: vi.fn(), getParticleColor: vi.fn(), - nodeThreeObject: vi.fn(), + nodeThreeObjectContext: createNodeThreeObjectContext(), particleSize: 2, particleSpeed: 0.1, sharedProps: createSharedProps(), diff --git a/packages/extension/tests/webview/graph/viewport/tooltip.test.tsx b/packages/extension/tests/webview/graph/viewport/tooltip.test.tsx index 1bfc6471e..0f98e134a 100644 --- a/packages/extension/tests/webview/graph/viewport/tooltip.test.tsx +++ b/packages/extension/tests/webview/graph/viewport/tooltip.test.tsx @@ -33,7 +33,7 @@ vi.mock('../../../../src/webview/components/graph/rendering/surface/view/twoDime })); vi.mock('../../../../src/webview/components/graph/rendering/surface/view/threeDimensional', () => ({ - Surface3d: (props: Record) => { + DeferredSurface3d: (props: Record) => { harness.surface3d(props); return
; }, @@ -79,6 +79,15 @@ function createSharedProps() { }; } +function createNodeThreeObjectContext() { + return { + graphAppearanceRef: { current: { labelForeground: '#f8fafc' } }, + meshesRef: { current: new Map() }, + showLabelsRef: { current: true }, + spritesRef: { current: new Map() }, + }; +} + function renderViewport(overrides: Partial> = {}) { const handleContextMenu = vi.fn(); const handleMouseLeave = vi.fn(); @@ -125,7 +134,7 @@ function renderViewport(overrides: Partial getLinkParticles: vi.fn(), getLinkWidth: vi.fn(), getParticleColor: vi.fn(), - nodeThreeObject: vi.fn(), + nodeThreeObjectContext: createNodeThreeObjectContext(), particleSize: 2, particleSpeed: 0.1, sharedProps: createSharedProps(), diff --git a/packages/extension/tests/webview/graph/viewport/tooltip.viewportstylemutationsl72.test.tsx b/packages/extension/tests/webview/graph/viewport/tooltip.viewportstylemutationsl72.test.tsx index 1fc8bf517..33de7ce6e 100644 --- a/packages/extension/tests/webview/graph/viewport/tooltip.viewportstylemutationsl72.test.tsx +++ b/packages/extension/tests/webview/graph/viewport/tooltip.viewportstylemutationsl72.test.tsx @@ -33,7 +33,7 @@ vi.mock('../../../../src/webview/components/graph/rendering/surface/view/twoDime })); vi.mock('../../../../src/webview/components/graph/rendering/surface/view/threeDimensional', () => ({ - Surface3d: (props: Record) => { + DeferredSurface3d: (props: Record) => { harness.surface3d(props); return
; }, @@ -79,6 +79,15 @@ function createSharedProps() { }; } +function createNodeThreeObjectContext() { + return { + graphAppearanceRef: { current: { labelForeground: '#f8fafc' } }, + meshesRef: { current: new Map() }, + showLabelsRef: { current: true }, + spritesRef: { current: new Map() }, + }; +} + function renderViewport(overrides: Partial> = {}) { const handleContextMenu = vi.fn(); const handleMouseLeave = vi.fn(); @@ -125,7 +134,7 @@ function renderViewport(overrides: Partial getLinkParticles: vi.fn(), getLinkWidth: vi.fn(), getParticleColor: vi.fn(), - nodeThreeObject: vi.fn(), + nodeThreeObjectContext: createNodeThreeObjectContext(), particleSize: 2, particleSpeed: 0.1, sharedProps: createSharedProps(), diff --git a/packages/extension/tests/webview/graph/viewport/tooltip.viewporttooltipcountmutationsl111.test.tsx b/packages/extension/tests/webview/graph/viewport/tooltip.viewporttooltipcountmutationsl111.test.tsx index e0660b2e3..080279917 100644 --- a/packages/extension/tests/webview/graph/viewport/tooltip.viewporttooltipcountmutationsl111.test.tsx +++ b/packages/extension/tests/webview/graph/viewport/tooltip.viewporttooltipcountmutationsl111.test.tsx @@ -33,7 +33,7 @@ vi.mock('../../../../src/webview/components/graph/rendering/surface/view/twoDime })); vi.mock('../../../../src/webview/components/graph/rendering/surface/view/threeDimensional', () => ({ - Surface3d: (props: Record) => { + DeferredSurface3d: (props: Record) => { harness.surface3d(props); return
; }, @@ -79,6 +79,15 @@ function createSharedProps() { }; } +function createNodeThreeObjectContext() { + return { + graphAppearanceRef: { current: { labelForeground: '#f8fafc' } }, + meshesRef: { current: new Map() }, + showLabelsRef: { current: true }, + spritesRef: { current: new Map() }, + }; +} + function renderViewport(overrides: Partial> = {}) { const handleContextMenu = vi.fn(); const handleMouseLeave = vi.fn(); @@ -125,7 +134,7 @@ function renderViewport(overrides: Partial getLinkParticles: vi.fn(), getLinkWidth: vi.fn(), getParticleColor: vi.fn(), - nodeThreeObject: vi.fn(), + nodeThreeObjectContext: createNodeThreeObjectContext(), particleSize: 2, particleSpeed: 0.1, sharedProps: createSharedProps(), diff --git a/packages/extension/tests/webview/graph/viewport/view.mutations.test.tsx b/packages/extension/tests/webview/graph/viewport/view.mutations.test.tsx index efeb0dc9a..97d199ab9 100644 --- a/packages/extension/tests/webview/graph/viewport/view.mutations.test.tsx +++ b/packages/extension/tests/webview/graph/viewport/view.mutations.test.tsx @@ -25,7 +25,7 @@ vi.mock('../../../../src/webview/components/graph/rendering/surface/view/twoDime })); vi.mock('../../../../src/webview/components/graph/rendering/surface/view/threeDimensional', () => ({ - Surface3d: (props: Record) => { + DeferredSurface3d: (props: Record) => { harness.surface3d(props); return
; }, @@ -73,6 +73,15 @@ function createSharedProps() { }; } +function createNodeThreeObjectContext() { + return { + graphAppearanceRef: { current: { labelForeground: '#f8fafc' } }, + meshesRef: { current: new Map() }, + showLabelsRef: { current: true }, + spritesRef: { current: new Map() }, + }; +} + function renderViewport(overrides: Partial> = {}) { const handleMenuAction = vi.fn(); const handleContextMenu = vi.fn(); @@ -116,7 +125,7 @@ function renderViewport(overrides: Partial getLinkParticles: vi.fn(), getLinkWidth: vi.fn(), getParticleColor: vi.fn(), - nodeThreeObject: vi.fn(), + nodeThreeObjectContext: createNodeThreeObjectContext(), particleSize: 2, particleSpeed: 0.1, sharedProps: createSharedProps(), diff --git a/packages/extension/tests/webview/graph/viewport/view.test.tsx b/packages/extension/tests/webview/graph/viewport/view.test.tsx index 48788809f..c1c0c736a 100644 --- a/packages/extension/tests/webview/graph/viewport/view.test.tsx +++ b/packages/extension/tests/webview/graph/viewport/view.test.tsx @@ -117,6 +117,15 @@ function createSharedProps(): GraphSurfaceSharedProps { }; } +function createNodeThreeObjectContext() { + return { + graphAppearanceRef: { current: { labelForeground: '#f8fafc' } }, + meshesRef: { current: new Map() }, + showLabelsRef: { current: true }, + spritesRef: { current: new Map() }, + }; +} + function createGraphNode(id: string): FGNode { return { id, @@ -173,7 +182,7 @@ function createSurface3dProps( getLinkParticles: vi.fn(), getLinkWidth: vi.fn(), getParticleColor: vi.fn(), - nodeThreeObject: vi.fn(), + nodeThreeObjectContext: createNodeThreeObjectContext(), particleSize: 2, particleSpeed: 0.1, sharedProps, @@ -232,10 +241,10 @@ describe('Viewport', () => { renderViewport({ graphMode: '3d', onSurface3dError }); await waitFor(() => { - expect(screen.getByTestId('surface-2d')).toBeInTheDocument(); + expect(onSurface3dError).toHaveBeenCalledWith(expect.any(Error)); }); + expect(screen.getByTestId('surface-2d')).toBeInTheDocument(); expect(screen.queryByTestId('surface-3d')).not.toBeInTheDocument(); - expect(onSurface3dError).toHaveBeenCalledWith(expect.any(Error)); harness.throwSurface3d = false; consoleError.mockRestore(); diff --git a/packages/extension/tests/webview/main.test.tsx b/packages/extension/tests/webview/main.test.tsx index e8b6828f8..32a68d42a 100644 --- a/packages/extension/tests/webview/main.test.tsx +++ b/packages/extension/tests/webview/main.test.tsx @@ -1,4 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; const mocks = vi.hoisted(() => { const render = vi.fn(); @@ -93,4 +95,22 @@ describe('main', () => { expect(mocks.render).not.toHaveBeenCalled(); expect((window as unknown as { vscode: unknown }).vscode).toBe(mocks.vscodeApi); }); + + it('does not load the Three.js runtime from the webview entrypoint', () => { + const source = fs.readFileSync( + path.resolve(__dirname, '../../src/webview/main.tsx'), + 'utf8', + ); + + expect(source).not.toContain("import './three/runtime'"); + }); + + it('does not load 3d node rendering from shared graph callbacks', () => { + const source = fs.readFileSync( + path.resolve(__dirname, '../../src/webview/components/graph/rendering/useGraphCallbacks.ts'), + 'utf8', + ); + + expect(source).not.toContain('nodes/canvas3d'); + }); }); From 763b11944a7c09b956cd57671e9bafa2fb9d78b9 Mon Sep 17 00:00:00 2001 From: joesobo Date: Mon, 22 Jun 2026 19:40:02 -0700 Subject: [PATCH 024/192] perf: skip duplicate graph payload replay Record webview startup ready/data/bootstrap markers and avoid replacing settled graph state when a background refresh replays an equivalent graph payload. Large monorepo harness: duplicate 5088 node / 9146 edge replay is now received at 1011.3ms and skipped by 1016.6ms. This removes the extra visibleGraph/render pass after the stale-cache refresh; first graph readiness remains flat at 6940ms -> 6987ms because frame readiness is still dominant. --- .changeset/lazy-3d-webview-bundle.md | 2 +- docs/performance/codegraphy-monorepo.md | 31 +++++++++ .../2026-06-22-codegraphy-performance.md | 1 + .../app/shell/messageListener/ready.ts | 2 + .../webview/store/messageHandlers/graph.ts | 44 +++++++++++- .../app/shell/messageListener/ready.test.ts | 11 +++ .../store/messageHandlers/graph.test.ts | 69 ++++++++++++++++++- 7 files changed, 157 insertions(+), 3 deletions(-) diff --git a/.changeset/lazy-3d-webview-bundle.md b/.changeset/lazy-3d-webview-bundle.md index 63a10a7ac..e6ef0bf68 100644 --- a/.changeset/lazy-3d-webview-bundle.md +++ b/.changeset/lazy-3d-webview-bundle.md @@ -2,4 +2,4 @@ "@codegraphy-dev/extension": patch --- -Speed up default Graph View startup work by lazy-loading the 3D graph runtime outside the initial 2D webview bundle. +Speed up default Graph View startup work by lazy-loading the 3D graph runtime outside the initial 2D webview bundle and skipping settled duplicate graph payload replays. diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index 04e40b091..889dba7a3 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -303,6 +303,31 @@ VS Code graph view benchmark: `5189ms`, stats wait after frame discovery `40ms`. - Imports toggle wall-clock latency: `193ms` median, `203ms` p95; in-webview latency: `51ms` median, `81ms` p95. +- After adding webview startup handshake markers: + - VS Code launch: `1538ms`. + - Open Graph View to first rendered graph stats: `6940ms`. + - First-ready phases: command/open `1698ms`, acceptance-ready frame + `5184ms`, stats wait after frame discovery `13ms`. + - Once the webview document was alive, it posted ready at `26.3ms`, + received `GRAPH_DATA_UPDATED` at `53.5ms`, received + `APP_BOOTSTRAP_COMPLETE` at `261.8ms`, and rendered first graph stats at + `340.5ms`. + - The same startup run received a second `GRAPH_DATA_UPDATED` with the same + `5088` node / `9146` edge payload at `985.1ms`, then another bootstrap + completion at `1291ms`. +- After skipping settled duplicate graph payload replays: + - VS Code launch: `1337ms`. + - Open Graph View to first rendered graph stats: `6987ms`, effectively flat + against the `6940ms` startup-marker run because the remaining wall-clock + bucket is still frame readiness. + - The duplicate `5088` node / `9146` edge graph payload was received at + `1011.3ms` and skipped at `1016.6ms`. + - The duplicate replay no longer triggered the later visible-graph derivation, + styling, legend, edge-decoration, or graph-runtime build pass. Startup + event counts dropped from `6` to `5` `visibleGraph.derive` events, `4` to + `3` `visibleGraph.style` events, and `5` to `4` graph-runtime build events. + - Imports toggle wall-clock latency stayed in the same band at `191ms` + median, `222ms` p95; in-webview latency was `54ms` median, `86ms` p95. Interpretation: @@ -346,6 +371,12 @@ Interpretation: and keeps the 2D path from paying for Three.js up front. The current VS Code first-ready harness did not show a first-load win because its dominant bucket remains the view/frame readiness wait, not measured webview data work. +- Startup markers show that, after the webview document exists, ready/data/ + bootstrap/render work reaches first graph stats in roughly `341ms`. The + multi-second first-ready wall clock is still outside measured webview graph + derivation/rendering. Duplicate graph replay suppression removes avoidable + post-startup work from the stale-cache refresh path, but it does not address + the current frame-readiness bucket. Full test baseline: diff --git a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md index af39a1338..1c64b28a3 100644 --- a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md +++ b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md @@ -293,6 +293,7 @@ Added in-webview delta metric: latest wall-clock 209ms median / 219ms p95, brows Rejected startup timeline replay reorder: first graph readiness regressed from 6377ms to 7285ms; reverted. Added startup phase metrics: latest first graph readiness 6789ms split into 1709ms command/open, 5032ms acceptance-ready frame, 35ms stats wait; startup webview data stages are sub-second. Lazy-loaded 3D runtime: default webview index.js 2242.28 kB -> 819.25 kB minified; latest Imports toggle 193ms median / 203ms p95, first graph readiness flat at 6936ms. +Added startup handshake markers and skipped settled duplicate graph payload replay: webview document reaches first stats at ~340ms after ready/data/bootstrap markers, duplicate 5088 node / 9146 edge replay is skipped in ~5ms, and the extra visible-graph/render pass is gone; first graph readiness remains flat at ~6987ms due to frame readiness. ``` ## Task 5: Keep The PR Reviewable diff --git a/packages/extension/src/webview/app/shell/messageListener/ready.ts b/packages/extension/src/webview/app/shell/messageListener/ready.ts index 518240317..fb784961f 100644 --- a/packages/extension/src/webview/app/shell/messageListener/ready.ts +++ b/packages/extension/src/webview/app/shell/messageListener/ready.ts @@ -1,5 +1,6 @@ import { postMessage } from '../../../vscodeApi'; import { graphStore } from '../../../store/state'; +import { recordWebviewPerformanceEvent } from '../../../performance/marks'; type WindowWithCodeGraphyReadyFlag = Window & { __codegraphyWebviewReadyPosted?: boolean; @@ -12,6 +13,7 @@ export function postWebviewReadyOnce(targetWindow: Window): void { if (!codeGraphyWindow.__codegraphyWebviewReadyPosted) { codeGraphyWindow.__codegraphyWebviewReadyPosted = true; graphStore.getState().beginInitialBootstrap(); + recordWebviewPerformanceEvent('webview.ready.posted'); postMessage({ type: 'WEBVIEW_READY', payload: null }); } } diff --git a/packages/extension/src/webview/store/messageHandlers/graph.ts b/packages/extension/src/webview/store/messageHandlers/graph.ts index a9b9cff6a..52e46ca20 100644 --- a/packages/extension/src/webview/store/messageHandlers/graph.ts +++ b/packages/extension/src/webview/store/messageHandlers/graph.ts @@ -1,16 +1,57 @@ import type { IHandlerContext, PartialState } from '../messageTypes'; import type { ExtensionToWebviewMessage } from '../../../shared/protocol/extensionToWebview'; +import type { IGraphData } from '../../../shared/graph/contracts'; import { applyPendingGroupUpdates, applyPendingUserGroupsUpdate, } from '../optimistic/groups/updates'; import { arePlainValuesEqual } from './equality/compare'; +import { recordWebviewPerformanceEvent } from '../../performance/marks'; + +function areGraphDataPayloadsEqual(left: IGraphData, right: IGraphData): boolean { + if (left.nodes.length !== right.nodes.length || left.edges.length !== right.edges.length) { + return false; + } + + try { + return JSON.stringify(left) === JSON.stringify(right); + } catch { + return false; + } +} + +function shouldSkipSettledDuplicateGraphData( + state: ReturnType>, + payload: IGraphData, +): boolean { + return Boolean( + state.graphData + && state.bootstrapComplete + && !state.awaitingInitialBootstrap + && !state.graphIsIndexing + && !state.isLoading + && areGraphDataPayloadsEqual(state.graphData, payload) + ); +} export function handleGraphDataUpdated( message: Extract, ctx?: Pick, -): PartialState { +): PartialState | void { + recordWebviewPerformanceEvent('extensionMessage.graphDataUpdated', { + edgeCount: message.payload.edges.length, + nodeCount: message.payload.nodes.length, + }); + const state = ctx?.getState(); + if (state && shouldSkipSettledDuplicateGraphData(state, message.payload)) { + recordWebviewPerformanceEvent('extensionMessage.graphDataSkipped', { + edgeCount: message.payload.edges.length, + nodeCount: message.payload.nodes.length, + }); + return undefined; + } + const waitingForInitialBootstrap = Boolean( state?.awaitingInitialBootstrap && !state.bootstrapComplete, @@ -35,6 +76,7 @@ export function handleAppBootstrapComplete( ): PartialState { const state = ctx.getState(); const graphReady = state.graphData !== null; + recordWebviewPerformanceEvent('extensionMessage.appBootstrapComplete', { graphReady }); return { bootstrapComplete: true, diff --git a/packages/extension/tests/webview/app/shell/messageListener/ready.test.ts b/packages/extension/tests/webview/app/shell/messageListener/ready.test.ts index d353fd175..95fb07db3 100644 --- a/packages/extension/tests/webview/app/shell/messageListener/ready.test.ts +++ b/packages/extension/tests/webview/app/shell/messageListener/ready.test.ts @@ -12,6 +12,7 @@ describe('app/shell/messageListener/ready', () => { beforeEach(() => { vi.restoreAllMocks(); vi.clearAllMocks(); + window.__codegraphyPerformance = undefined; delete (window as Window & { __codegraphyWebviewReadyPosted?: boolean }) .__codegraphyWebviewReadyPosted; }); @@ -26,4 +27,14 @@ describe('app/shell/messageListener/ready', () => { expect(postMessage).toHaveBeenCalledTimes(1); expect(postMessage).toHaveBeenCalledWith({ type: 'WEBVIEW_READY', payload: null }); }); + + it('records when the webview ready handshake is posted', () => { + window.__codegraphyPerformance = { enabled: true, events: [] }; + + postWebviewReadyOnce(window); + + expect(window.__codegraphyPerformance.events).toEqual([ + expect.objectContaining({ name: 'webview.ready.posted' }), + ]); + }); }); diff --git a/packages/extension/tests/webview/store/messageHandlers/graph.test.ts b/packages/extension/tests/webview/store/messageHandlers/graph.test.ts index da33888fb..937e7e06c 100644 --- a/packages/extension/tests/webview/store/messageHandlers/graph.test.ts +++ b/packages/extension/tests/webview/store/messageHandlers/graph.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { handleActiveFileUpdated, handleAppBootstrapComplete, @@ -93,6 +93,10 @@ function createState( } describe('webview/store/messageHandlers/graph', () => { + afterEach(() => { + window.__codegraphyPerformance = undefined; + }); + it('maps graph payload updates into loading and indexing state', () => { const payload = { nodes: [{ id: 'src/app.ts', label: 'App', color: '#fff' }], edges: [] }; @@ -104,6 +108,50 @@ describe('webview/store/messageHandlers/graph', () => { }); }); + it('records graph payload receipt for startup timing', () => { + window.__codegraphyPerformance = { enabled: true, events: [] }; + const payload = { + nodes: [ + { id: 'src/app.ts', label: 'App', color: '#fff' }, + { id: 'src/lib.ts', label: 'Lib', color: '#fff' }, + ], + edges: [{ id: 'src/app.ts->src/lib.ts', from: 'src/app.ts', to: 'src/lib.ts', kind: 'import' as const, sources: [] }], + }; + + handleGraphDataUpdated({ type: 'GRAPH_DATA_UPDATED', payload }); + + expect(window.__codegraphyPerformance.events).toEqual([ + expect.objectContaining({ + detail: { edgeCount: 1, nodeCount: 2 }, + name: 'extensionMessage.graphDataUpdated', + }), + ]); + }); + + it('skips duplicate graph payloads after bootstrap has settled', () => { + window.__codegraphyPerformance = { enabled: true, events: [] }; + const payload = { + nodes: [{ id: 'src/app.ts', label: 'App', color: '#fff' }], + edges: [{ id: 'src/app.ts->src/lib.ts', from: 'src/app.ts', to: 'src/lib.ts', kind: 'import' as const, sources: [] }], + }; + const state = createState({ + bootstrapComplete: true, + graphData: JSON.parse(JSON.stringify(payload)), + graphIsIndexing: false, + isLoading: false, + }); + + expect(handleGraphDataUpdated( + { type: 'GRAPH_DATA_UPDATED', payload }, + { getState: () => state }, + )).toBeUndefined(); + + expect(window.__codegraphyPerformance.events).toEqual([ + expect.objectContaining({ name: 'extensionMessage.graphDataUpdated' }), + expect.objectContaining({ name: 'extensionMessage.graphDataSkipped' }), + ]); + }); + it('settles initial bootstrap when graph data arrives after bootstrap and plugin assets are ready', () => { const payload = { nodes: [{ id: 'src/app.ts', label: 'App', color: '#fff' }], edges: [] }; const state = createState({ @@ -142,6 +190,25 @@ describe('webview/store/messageHandlers/graph', () => { }); }); + it('records app bootstrap completion for startup timing', () => { + window.__codegraphyPerformance = { enabled: true, events: [] }; + const state = createState({ + graphData: { nodes: [{ id: 'src/app.ts', label: 'App', color: '#fff' }], edges: [] }, + }); + + handleAppBootstrapComplete( + { type: 'APP_BOOTSTRAP_COMPLETE' }, + { getState: () => state }, + ); + + expect(window.__codegraphyPerformance.events).toEqual([ + expect.objectContaining({ + detail: { graphReady: true }, + name: 'extensionMessage.appBootstrapComplete', + }), + ]); + }); + it('settles initial bootstrap when graph data and app bootstrap are ready while plugin assets continue loading', () => { const state = createState({ graphData: { nodes: [{ id: 'src/app.ts', label: 'App', color: '#fff' }], edges: [] }, From a8a0fd3ddac63318c0b100b39bb104a404c2e126 Mon Sep 17 00:00:00 2001 From: joesobo Date: Mon, 22 Jun 2026 19:57:27 -0700 Subject: [PATCH 025/192] perf: combine visible graph filter matchers Add extension-host startup markers for the VS Code graph-view harness and parse them into the metrics output. The provider/webview resolve path measured at 2-3ms, ruling it out as the remaining first-load bottleneck. Use a combined glob matcher for visible-graph filter projection. On the CodeGraphy monorepo harness, the startup 74-filter derive pass dropped from 498.4ms to 244ms, and first stats after webview document start moved from 843.3ms to 586.4ms. --- docs/performance/codegraphy-monorepo.md | 37 ++++++++++ .../2026-06-22-codegraphy-performance.md | 2 + .../provider/webview/defaultDependencies.ts | 3 + .../graphView/provider/webview/resolve.ts | 15 +++- .../extension/graphView/webview/resolve.ts | 24 ++++++- .../src/extension/performance/marks.ts | 36 ++++++++++ packages/extension/src/shared/globMatch.ts | 18 +++++ .../src/shared/visibleGraph/filter.ts | 28 +++----- .../tests/acceptance/graphView/vscode.ts | 4 ++ .../webview/defaultDependencies.test.ts | 7 ++ .../provider/webview/resolve.test.ts | 51 ++++++++++++++ .../graphView/webview/resolve.test.ts | 44 ++++++++++++ .../tests/extension/performance/marks.test.ts | 68 +++++++++++++++++++ .../extension/tests/shared/globMatch.test.ts | 26 ++++++- .../performance/measure-vscode-graph-view.mjs | 55 ++++++++++++++- .../measure-vscode-graph-view.test.mjs | 27 ++++++++ 16 files changed, 420 insertions(+), 25 deletions(-) create mode 100644 packages/extension/src/extension/performance/marks.ts create mode 100644 packages/extension/tests/extension/performance/marks.test.ts diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index 889dba7a3..a811af75e 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -328,6 +328,35 @@ VS Code graph view benchmark: `3` `visibleGraph.style` events, and `5` to `4` graph-runtime build events. - Imports toggle wall-clock latency stayed in the same band at `191ms` median, `222ms` p95; in-webview latency was `54ms` median, `86ms` p95. +- After adding extension-host startup markers: + - VS Code launch: `1087ms`. + - Open Graph View to first rendered graph stats: `14305ms`; this run was + noisy in the same remaining frame-readiness bucket. + - First-ready phases: command/open `1752ms`, acceptance-ready frame + `12500ms`, stats wait after frame discovery `40ms`. + - The extension-host provider resolve path took `3ms` from + `graphWebview.providerResolve.start` to + `graphWebview.providerResolve.end`; `webview.html` was assigned at `2ms` + with a `1022` byte HTML shell and `2` local resource roots. + - Once the webview document was alive, it received a `24522` node / `20781` + edge payload at `169.4ms`, applied `74` filter patterns in a + `498.4ms` visible-graph derive pass, and rendered first graph stats at + `843.3ms`. + - Imports toggle wall-clock latency was `213ms` median, `382ms` p95; in-webview + latency was `58ms` median, `64ms` p95. +- After combining visible-graph filter glob patterns into one matcher: + - VS Code launch: `1074ms`. + - Open Graph View to first rendered graph stats: `13837ms`, still dominated + by frame readiness rather than CodeGraphy resolve/render work. + - First-ready phases: command/open `1726ms`, acceptance-ready frame + `12066ms`, stats wait after frame discovery `32ms`. + - Extension-host provider resolve stayed tiny at `2ms`. + - The startup `visibleGraph.derive` pass with `74` filters over the `24522` + node / `20781` edge payload dropped from `498.4ms` to `244ms`. + - First graph stats after webview document start moved from `843.3ms` to + `586.4ms`. + - Imports toggle wall-clock latency stayed in the same band at `228ms` + median, `337ms` p95; in-webview latency was `58ms` median, `59ms` p95. Interpretation: @@ -377,6 +406,14 @@ Interpretation: derivation/rendering. Duplicate graph replay suppression removes avoidable post-startup work from the stale-cache refresh path, but it does not address the current frame-readiness bucket. +- Extension-host startup markers show the provider/webview resolver is not the + first-load bottleneck: resolving, assigning the HTML shell, setting context, + and flushing the pending refresh take only `2ms`-`3ms`. +- Combining filter glob patterns into one matcher addresses the next measured + CodeGraphy-side startup cost for the user's filtered monorepo settings, + cutting the `74`-filter visible-graph derive pass from `498.4ms` to `244ms` + and moving first stats after webview document start from `843.3ms` to + `586.4ms`. Full test baseline: diff --git a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md index 1c64b28a3..e75fc584a 100644 --- a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md +++ b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md @@ -294,6 +294,8 @@ Rejected startup timeline replay reorder: first graph readiness regressed from 6 Added startup phase metrics: latest first graph readiness 6789ms split into 1709ms command/open, 5032ms acceptance-ready frame, 35ms stats wait; startup webview data stages are sub-second. Lazy-loaded 3D runtime: default webview index.js 2242.28 kB -> 819.25 kB minified; latest Imports toggle 193ms median / 203ms p95, first graph readiness flat at 6936ms. Added startup handshake markers and skipped settled duplicate graph payload replay: webview document reaches first stats at ~340ms after ready/data/bootstrap markers, duplicate 5088 node / 9146 edge replay is skipped in ~5ms, and the extra visible-graph/render pass is gone; first graph readiness remains flat at ~6987ms due to frame readiness. +Added extension-host startup markers: provider resolve/html/context/flush work takes only 2ms-3ms, ruling it out as the remaining first-load bottleneck; a noisy first-ready run still spent 12500ms in frame readiness, while the webview-side 74-filter derive pass took 498.4ms. +Combined visible-graph filter glob patterns into one matcher: startup 74-filter derive over the 24522 node / 20781 edge payload dropped from 498.4ms to 244ms, and first graph stats after webview document start moved from 843.3ms to 586.4ms; first-ready wall clock remains frame-readiness dominated. ``` ## Task 5: Keep The PR Reviewable diff --git a/packages/extension/src/extension/graphView/provider/webview/defaultDependencies.ts b/packages/extension/src/extension/graphView/provider/webview/defaultDependencies.ts index 1a07e3d5f..6e64f9368 100644 --- a/packages/extension/src/extension/graphView/provider/webview/defaultDependencies.ts +++ b/packages/extension/src/extension/graphView/provider/webview/defaultDependencies.ts @@ -15,6 +15,7 @@ import { onGraphViewWebviewMessage, sendGraphViewWebviewMessage, } from '../../webview/bridge'; +import { recordExtensionPerformanceEvent } from '../../../performance/marks'; export interface GraphViewProviderWebviewMethodDependencies { viewType: string; @@ -29,6 +30,7 @@ export interface GraphViewProviderWebviewMethodDependencies { onWebviewMessage: typeof onGraphViewWebviewMessage; setWebviewMessageListener: typeof setGraphViewProviderMessageListener; executeCommand(command: string, key: string, value: boolean): Thenable; + recordPerformanceEvent?(name: string, detail?: Record): void; createPanel: typeof vscode.window.createWebviewPanel; getWorkspaceTitle?(): string | undefined; } @@ -79,6 +81,7 @@ export function createDefaultGraphViewProviderWebviewMethodDependencies(): Graph onWebviewMessage: onGraphViewWebviewMessage, setWebviewMessageListener: setGraphViewProviderMessageListener, executeCommand: (command, key, value) => vscode.commands.executeCommand(command, key, value), + recordPerformanceEvent: recordExtensionPerformanceEvent, createPanel: (viewType, title, column, options) => vscode.window.createWebviewPanel(viewType, title, column, options), getWorkspaceTitle: () => vscode.workspace.workspaceFolders?.[0]?.name, diff --git a/packages/extension/src/extension/graphView/provider/webview/resolve.ts b/packages/extension/src/extension/graphView/provider/webview/resolve.ts index 528e37172..453a709d6 100644 --- a/packages/extension/src/extension/graphView/provider/webview/resolve.ts +++ b/packages/extension/src/extension/graphView/provider/webview/resolve.ts @@ -64,17 +64,23 @@ export function resolveGraphViewProviderWebviewView( source: GraphViewProviderWebviewResolveSource, dependencies: Pick< GraphViewProviderWebviewMethodDependencies, - 'createHtml' | 'executeCommand' | 'getWorkspaceTitle' | 'resolveWebviewView' | 'setWebviewMessageListener' + 'createHtml' | 'executeCommand' | 'getWorkspaceTitle' | 'recordPerformanceEvent' | 'resolveWebviewView' | 'setWebviewMessageListener' >, webviewView: vscode.WebviewView, ): void { const viewKind = getWebviewKind(webviewView); + dependencies.recordPerformanceEvent?.('graphWebview.providerResolve.start', { + viewKind, + viewType: webviewView.viewType, + visible: webviewView.visible, + }); assignResolvedWebviewView( source, webviewView, viewKind, dependencies.getWorkspaceTitle?.(), ); + dependencies.recordPerformanceEvent?.('graphWebview.providerResolve.assigned'); webviewView.onDidDispose(() => { clearResolvedWebviewView(source, webviewView, viewKind); @@ -84,6 +90,7 @@ export function resolveGraphViewProviderWebviewView( maybeFlushPendingWorkspaceRefresh(source, webviewView, viewKind); }); + dependencies.recordPerformanceEvent?.('graphWebview.providerResolve.delegate.start'); dependencies.resolveWebviewView(webviewView, { getLocalResourceRoots: () => source._getLocalResourceRoots(), setWebviewMessageListener: (nextWebview: vscode.Webview) => @@ -96,7 +103,13 @@ export function resolveGraphViewProviderWebviewView( ), executeCommand: (command: string, key: string, value: boolean) => dependencies.executeCommand(command, key, value), + recordPerformanceEvent: dependencies.recordPerformanceEvent, } as never); + dependencies.recordPerformanceEvent?.('graphWebview.providerResolve.delegate.end'); + if (viewKind === 'graph' && webviewView.visible) { + dependencies.recordPerformanceEvent?.('graphWebview.providerResolve.flushPendingRefresh'); + } maybeFlushPendingWorkspaceRefresh(source, webviewView, viewKind); + dependencies.recordPerformanceEvent?.('graphWebview.providerResolve.end'); } diff --git a/packages/extension/src/extension/graphView/webview/resolve.ts b/packages/extension/src/extension/graphView/webview/resolve.ts index 28bad2fc1..3b74e4a5b 100644 --- a/packages/extension/src/extension/graphView/webview/resolve.ts +++ b/packages/extension/src/extension/graphView/webview/resolve.ts @@ -18,6 +18,7 @@ interface ResolveGraphViewWebviewOptions { setWebviewMessageListener: (webview: GraphViewWebviewLike) => void; getHtml: (webview: GraphViewWebviewLike) => string; executeCommand: (command: string, key: string, value: boolean) => unknown; + recordPerformanceEvent?: (name: string, detail?: Record) => void; } export function resolveGraphViewWebviewView( @@ -27,18 +28,37 @@ export function resolveGraphViewWebviewView( setWebviewMessageListener, getHtml, executeCommand, + recordPerformanceEvent, }: ResolveGraphViewWebviewOptions, ): void { + recordPerformanceEvent?.('graphWebview.resolve.start', { + visible: webviewView.visible, + }); + recordPerformanceEvent?.('graphWebview.options.start'); + const localResourceRoots = getLocalResourceRoots(); webviewView.webview.options = { enableScripts: true, - localResourceRoots: getLocalResourceRoots(), + localResourceRoots, retainContextWhenHidden: true, }; + recordPerformanceEvent?.('graphWebview.options.end', { + localResourceRootCount: localResourceRoots.length, + }); + recordPerformanceEvent?.('graphWebview.listener.start'); setWebviewMessageListener(webviewView.webview); - webviewView.webview.html = getHtml(webviewView.webview); + recordPerformanceEvent?.('graphWebview.listener.end'); + + recordPerformanceEvent?.('graphWebview.html.start'); + const html = getHtml(webviewView.webview); + webviewView.webview.html = html; + recordPerformanceEvent?.('graphWebview.html.assigned', { + htmlLength: html.length, + }); void executeCommand('setContext', 'codegraphy.viewVisible', webviewView.visible); + recordPerformanceEvent?.('graphWebview.context.initial'); + recordPerformanceEvent?.('graphWebview.resolve.end'); webviewView.onDidChangeVisibility(() => { void executeCommand('setContext', 'codegraphy.viewVisible', webviewView.visible); diff --git a/packages/extension/src/extension/performance/marks.ts b/packages/extension/src/extension/performance/marks.ts new file mode 100644 index 000000000..533f3166e --- /dev/null +++ b/packages/extension/src/extension/performance/marks.ts @@ -0,0 +1,36 @@ +import { appendFileSync, mkdirSync } from 'node:fs'; +import path from 'node:path'; + +export const EXTENSION_PERFORMANCE_LOG_PATH_ENV = 'CODEGRAPHY_EXTENSION_PERFORMANCE_LOG'; + +export interface ExtensionPerformanceEventDetail { + readonly [key: string]: unknown; +} + +export function recordExtensionPerformanceEvent( + name: string, + detail?: ExtensionPerformanceEventDetail, +): void { + const logPath = process.env[EXTENSION_PERFORMANCE_LOG_PATH_ENV]?.trim(); + if (!logPath) { + return; + } + + try { + mkdirSync(path.dirname(logPath), { recursive: true }); + appendFileSync(logPath, `${JSON.stringify(createExtensionPerformanceEvent(name, detail))}\n`); + } catch { + // Performance markers are best-effort harness data and must never affect extension behavior. + } +} + +function createExtensionPerformanceEvent( + name: string, + detail?: ExtensionPerformanceEventDetail, +): { name: string; at: number; detail?: ExtensionPerformanceEventDetail } { + return { + name, + at: Date.now(), + ...(detail === undefined ? {} : { detail }), + }; +} diff --git a/packages/extension/src/shared/globMatch.ts b/packages/extension/src/shared/globMatch.ts index d9a1b0a08..9614e282b 100644 --- a/packages/extension/src/shared/globMatch.ts +++ b/packages/extension/src/shared/globMatch.ts @@ -44,6 +44,24 @@ export function createGlobMatcher(pattern: string): (filePath: string) => boolea return (filePath: string): boolean => regex.test(filePath); } +export function createCombinedGlobMatcher(patterns: readonly string[]): (filePath: string) => boolean { + if (patterns.length === 0) { + return () => false; + } + + if (patterns.length === 1) { + return createGlobMatcher(patterns[0] ?? ''); + } + + const regex = new RegExp( + patterns + .map(pattern => `(?:${globToRegex(pattern).source})`) + .join('|'), + ); + + return (filePath: string): boolean => regex.test(filePath); +} + export function globMatch(filePath: string, pattern: string): boolean { return createGlobMatcher(pattern)(filePath); } diff --git a/packages/extension/src/shared/visibleGraph/filter.ts b/packages/extension/src/shared/visibleGraph/filter.ts index 0a57aaa77..218996c17 100644 --- a/packages/extension/src/shared/visibleGraph/filter.ts +++ b/packages/extension/src/shared/visibleGraph/filter.ts @@ -1,13 +1,9 @@ import type { IGraphData } from '../graph/contracts'; -import { createGlobMatcher } from '../globMatch'; +import { createCombinedGlobMatcher } from '../globMatch'; import type { VisibleGraphFilterConfig } from './contracts'; import { filterEdgesToNodes } from './model'; -type GlobMatcher = ReturnType; -interface CompiledFilterPattern { - matches: GlobMatcher; - pattern: string; -} +type GlobMatcher = ReturnType; function nodeMatchesPattern(node: IGraphData['nodes'][number], matches: GlobMatcher): boolean { return matches(node.id) @@ -29,13 +25,6 @@ function canFilterEdgeDirectly(pattern: string): boolean { || (!pattern.includes('*') && !pattern.includes('/')); } -function compileFilterPatterns(patterns: readonly string[]): CompiledFilterPattern[] { - return patterns.map(pattern => ({ - matches: createGlobMatcher(pattern), - pattern, - })); -} - export function applyFilterPatterns( graphData: IGraphData, filter: VisibleGraphFilterConfig, @@ -44,20 +33,19 @@ export function applyFilterPatterns( return graphData; } - const compiledPatterns = compileFilterPatterns(filter.patterns); + const nodePatternMatcher = createCombinedGlobMatcher(filter.patterns); const nodes = graphData.nodes.filter( - (node) => !compiledPatterns.some(({ matches }) => nodeMatchesPattern(node, matches)), + (node) => !nodeMatchesPattern(node, nodePatternMatcher), ); const nodeFilteredEdges = filterEdgesToNodes(graphData.edges, nodes); - const edgePatternMatchers = compiledPatterns - .filter(({ pattern }) => canFilterEdgeDirectly(pattern)) - .map(({ matches }) => matches); - if (edgePatternMatchers.length === 0) { + const directEdgePatterns = filter.patterns.filter(canFilterEdgeDirectly); + if (directEdgePatterns.length === 0) { return { nodes, edges: nodeFilteredEdges }; } + const edgePatternMatcher = createCombinedGlobMatcher(directEdgePatterns); const edges = nodeFilteredEdges.filter( - (edge) => !edgePatternMatchers.some((matches) => edgeMatchesPattern(edge, matches)), + (edge) => !edgeMatchesPattern(edge, edgePatternMatcher), ); return { nodes, edges }; diff --git a/packages/extension/tests/acceptance/graphView/vscode.ts b/packages/extension/tests/acceptance/graphView/vscode.ts index 0e8d3dec7..07b4a8fc3 100644 --- a/packages/extension/tests/acceptance/graphView/vscode.ts +++ b/packages/extension/tests/acceptance/graphView/vscode.ts @@ -14,6 +14,7 @@ export const VSCODE_TEST_VERSION = process.env.CODEGRAPHY_VSCODE_TEST_VERSION ?? interface LaunchVSCodeWithWorkspaceOptions { readonly pluginPackageRelativePaths?: readonly string[]; + readonly extensionPerformanceLogPath?: string; } export async function launchVSCodeWithWorkspace( @@ -45,6 +46,9 @@ export async function launchVSCodeWithWorkspace( env: { ...process.env, CODEGRAPHY_ACCEPTANCE: '1', + ...(options.extensionPerformanceLogPath + ? { CODEGRAPHY_EXTENSION_PERFORMANCE_LOG: options.extensionPerformanceLogPath } + : {}), HOME: homePath, }, }); diff --git a/packages/extension/tests/extension/graphView/provider/webview/defaultDependencies.test.ts b/packages/extension/tests/extension/graphView/provider/webview/defaultDependencies.test.ts index 92a2467f6..4a4afa555 100644 --- a/packages/extension/tests/extension/graphView/provider/webview/defaultDependencies.test.ts +++ b/packages/extension/tests/extension/graphView/provider/webview/defaultDependencies.test.ts @@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({ resolveGraphViewWebviewView: vi.fn(), sendGraphViewWebviewMessage: vi.fn(), onGraphViewWebviewMessage: vi.fn(() => ({ dispose: vi.fn() })), + recordExtensionPerformanceEvent: vi.fn(), executeCommand: vi.fn(() => Promise.resolve('executed')), createWebviewPanel: vi.fn(() => ({ id: 'panel-1', @@ -66,6 +67,10 @@ vi.mock('../../../../../src/extension/graphView/webview/bridge', () => ({ onGraphViewWebviewMessage: mocks.onGraphViewWebviewMessage, })); +vi.mock('../../../../../src/extension/performance/marks', () => ({ + recordExtensionPerformanceEvent: mocks.recordExtensionPerformanceEvent, +})); + import { createDefaultGraphViewProviderWebviewMethodDependencies } from '../../../../../src/extension/graphView/provider/webview/defaultDependencies'; describe('graphView/provider/webview/defaultDependencies', () => { @@ -77,6 +82,7 @@ describe('graphView/provider/webview/defaultDependencies', () => { mocks.resolveGraphViewWebviewView.mockClear(); mocks.sendGraphViewWebviewMessage.mockClear(); mocks.onGraphViewWebviewMessage.mockClear(); + mocks.recordExtensionPerformanceEvent.mockClear(); mocks.executeCommand.mockClear(); mocks.createWebviewPanel.mockClear(); mocks.onGraphViewWebviewMessage.mockReturnValue({ dispose: vi.fn() }); @@ -91,6 +97,7 @@ describe('graphView/provider/webview/defaultDependencies', () => { expect(dependencies.sendWebviewMessage).toBe(mocks.sendGraphViewWebviewMessage); expect(dependencies.onWebviewMessage).toBe(mocks.onGraphViewWebviewMessage); expect(dependencies.setWebviewMessageListener).toBe(mocks.setGraphViewProviderMessageListener); + expect(dependencies.recordPerformanceEvent).toBe(mocks.recordExtensionPerformanceEvent); }); it('creates graph html with a fresh nonce', () => { diff --git a/packages/extension/tests/extension/graphView/provider/webview/resolve.test.ts b/packages/extension/tests/extension/graphView/provider/webview/resolve.test.ts index 1efb14607..e5fee99ef 100644 --- a/packages/extension/tests/extension/graphView/provider/webview/resolve.test.ts +++ b/packages/extension/tests/extension/graphView/provider/webview/resolve.test.ts @@ -111,6 +111,57 @@ describe('graphView/provider/webview/resolve', () => { expect(source._timelineView).toBe(webviewView); }); + it('records provider resolve markers and forwards the timing sink to the webview resolver', () => { + const recordPerformanceEvent = vi.fn(); + const webview = { + options: {}, + html: '', + } as unknown as vscode.Webview; + const webviewView = { + viewType: 'codegraphy.graphView', + webview, + visible: true, + onDidChangeVisibility: vi.fn(() => undefined), + onDidDispose: vi.fn(() => ({ dispose: vi.fn() })), + } as unknown as vscode.WebviewView; + const resolveWebviewView = vi.fn((_view, options) => { + options.recordPerformanceEvent('graphWebview.resolve.inner'); + }); + const source = { + _extensionUri: vscode.Uri.file('/test/extension'), + _view: undefined, + _timelineView: undefined, + _getLocalResourceRoots: vi.fn(() => []), + flushPendingWorkspaceRefresh: vi.fn(), + }; + + resolveGraphViewProviderWebviewView(source as never, { + createHtml: vi.fn(() => ''), + executeCommand: vi.fn(() => Promise.resolve(undefined)), + recordPerformanceEvent, + resolveWebviewView, + setWebviewMessageListener: vi.fn(), + }, webviewView); + + expect(recordPerformanceEvent.mock.calls.map(([name]) => name)).toEqual([ + 'graphWebview.providerResolve.start', + 'graphWebview.providerResolve.assigned', + 'graphWebview.providerResolve.delegate.start', + 'graphWebview.resolve.inner', + 'graphWebview.providerResolve.delegate.end', + 'graphWebview.providerResolve.flushPendingRefresh', + 'graphWebview.providerResolve.end', + ]); + expect(recordPerformanceEvent).toHaveBeenCalledWith( + 'graphWebview.providerResolve.start', + { + viewKind: 'graph', + viewType: 'codegraphy.graphView', + visible: true, + }, + ); + }); + it('keeps a different timeline view attached when the graph view disposes', () => { let disposeListener: (() => void) | undefined; const resourceRoots = [vscode.Uri.file('/test/root')]; diff --git a/packages/extension/tests/extension/graphView/webview/resolve.test.ts b/packages/extension/tests/extension/graphView/webview/resolve.test.ts index 9022cb235..136e80d9f 100644 --- a/packages/extension/tests/extension/graphView/webview/resolve.test.ts +++ b/packages/extension/tests/extension/graphView/webview/resolve.test.ts @@ -36,6 +36,50 @@ describe('graphView/webview/resolve', () => { expect(visibilityHandler).toBeTypeOf('function'); }); + it('records timing markers around resolver startup work', () => { + const recordPerformanceEvent = vi.fn(); + const webviewView = { + visible: true, + webview: { + options: {}, + html: '', + }, + onDidChangeVisibility: vi.fn(() => ({ dispose: () => {} })), + }; + + resolveGraphViewWebviewView(webviewView as never, { + getLocalResourceRoots: () => ['/workspace'], + setWebviewMessageListener: vi.fn(), + getHtml: () => '
', + executeCommand: vi.fn(() => Promise.resolve()), + recordPerformanceEvent, + }); + + expect(recordPerformanceEvent.mock.calls.map(([name]) => name)).toEqual([ + 'graphWebview.resolve.start', + 'graphWebview.options.start', + 'graphWebview.options.end', + 'graphWebview.listener.start', + 'graphWebview.listener.end', + 'graphWebview.html.start', + 'graphWebview.html.assigned', + 'graphWebview.context.initial', + 'graphWebview.resolve.end', + ]); + expect(recordPerformanceEvent).toHaveBeenCalledWith( + 'graphWebview.resolve.start', + { visible: true }, + ); + expect(recordPerformanceEvent).toHaveBeenCalledWith( + 'graphWebview.options.end', + { localResourceRootCount: 1 }, + ); + expect(recordPerformanceEvent).toHaveBeenCalledWith( + 'graphWebview.html.assigned', + { htmlLength: 21 }, + ); + }); + it('updates visibility context without triggering reload work when the view becomes visible again', () => { const executeCommand = vi.fn(() => Promise.resolve()); let visibilityHandler: (() => void) | undefined; diff --git a/packages/extension/tests/extension/performance/marks.test.ts b/packages/extension/tests/extension/performance/marks.test.ts new file mode 100644 index 000000000..9cf76f2c3 --- /dev/null +++ b/packages/extension/tests/extension/performance/marks.test.ts @@ -0,0 +1,68 @@ +import { mkdtemp, readFile, stat } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + EXTENSION_PERFORMANCE_LOG_PATH_ENV, + recordExtensionPerformanceEvent, +} from '../../../src/extension/performance/marks'; + +describe('extension/performance/marks', () => { + let originalLogPath: string | undefined; + + beforeEach(() => { + originalLogPath = process.env[EXTENSION_PERFORMANCE_LOG_PATH_ENV]; + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-06-22T12:00:00.123Z')); + }); + + afterEach(() => { + if (originalLogPath === undefined) { + delete process.env[EXTENSION_PERFORMANCE_LOG_PATH_ENV]; + } else { + process.env[EXTENSION_PERFORMANCE_LOG_PATH_ENV] = originalLogPath; + } + + vi.useRealTimers(); + }); + + it('does nothing when the extension performance log path is not configured', async () => { + delete process.env[EXTENSION_PERFORMANCE_LOG_PATH_ENV]; + const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'codegraphy-perf-')); + const logPath = path.join(tempRoot, 'extension-host.jsonl'); + + recordExtensionPerformanceEvent('graphWebview.resolve.start'); + + await expect(stat(logPath)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('appends one JSONL event per extension host performance marker', async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'codegraphy-perf-')); + const logPath = path.join(tempRoot, 'nested', 'extension-host.jsonl'); + process.env[EXTENSION_PERFORMANCE_LOG_PATH_ENV] = logPath; + + recordExtensionPerformanceEvent('graphWebview.resolve.start', { + visible: true, + viewKind: 'graph', + }); + vi.setSystemTime(new Date('2026-06-22T12:00:00.456Z')); + recordExtensionPerformanceEvent('graphWebview.resolve.end'); + + const lines = (await readFile(logPath, 'utf8')).trim().split('\n'); + + expect(lines.map(line => JSON.parse(line))).toEqual([ + { + name: 'graphWebview.resolve.start', + at: 1782129600123, + detail: { + visible: true, + viewKind: 'graph', + }, + }, + { + name: 'graphWebview.resolve.end', + at: 1782129600456, + }, + ]); + }); +}); diff --git a/packages/extension/tests/shared/globMatch.test.ts b/packages/extension/tests/shared/globMatch.test.ts index fcf9a9eec..0c7c3eb5e 100644 --- a/packages/extension/tests/shared/globMatch.test.ts +++ b/packages/extension/tests/shared/globMatch.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { createGlobMatcher, globMatch, globToRegex } from '../../src/shared/globMatch'; +import { + createCombinedGlobMatcher, + createGlobMatcher, + globMatch, + globToRegex, +} from '../../src/shared/globMatch'; describe('shared/globMatch', () => { it('matches basename patterns against nested paths', () => { @@ -32,4 +37,23 @@ describe('shared/globMatch', () => { expect(matcher('src/deep/index.ts')).toBe(true); expect(matcher('docs/index.ts')).toBe(false); }); + + it('creates one matcher that preserves any-pattern glob semantics', () => { + const matcher = createCombinedGlobMatcher([ + '**/tests/**', + 'reports/**', + '*.d.ts', + ]); + + expect(matcher('packages/extension/tests/unit.test.ts')).toBe(true); + expect(matcher('reports/performance/latest.json')).toBe(true); + expect(matcher('src/types/api.d.ts')).toBe(true); + expect(matcher('src/index.ts')).toBe(false); + }); + + it('creates an empty combined matcher that never matches', () => { + const matcher = createCombinedGlobMatcher([]); + + expect(matcher('src/index.ts')).toBe(false); + }); }); diff --git a/scripts/performance/measure-vscode-graph-view.mjs b/scripts/performance/measure-vscode-graph-view.mjs index 4dc7d7175..bd9b1964a 100644 --- a/scripts/performance/measure-vscode-graph-view.mjs +++ b/scripts/performance/measure-vscode-graph-view.mjs @@ -1,4 +1,4 @@ -import { readFile, writeFile } from 'node:fs/promises'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { performance } from 'node:perf_hooks'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -67,6 +67,53 @@ function findWebviewEventAt(sample, eventName) { return typeof event?.at === 'number' ? event.at : undefined; } +function isPlainObject(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +export function parseExtensionHostPerformanceLog(logText) { + const events = []; + + for (const line of logText.split('\n')) { + if (!line.trim()) { + continue; + } + + try { + const parsed = JSON.parse(line); + if (typeof parsed.name !== 'string' || typeof parsed.at !== 'number') { + continue; + } + + events.push({ + name: parsed.name, + at: parsed.at, + ...(isPlainObject(parsed.detail) ? { detail: parsed.detail } : {}), + }); + } catch { + // Ignore partial or unrelated lines so a long-running host process cannot poison the metrics file. + } + } + + const firstEventAt = events[0]?.at ?? 0; + return events.map(event => ({ + ...event, + offsetMs: Math.round(event.at - firstEventAt), + })); +} + +async function readExtensionHostPerformanceEvents(logPath) { + const logText = await readFile(logPath, 'utf8').catch(() => ''); + return parseExtensionHostPerformanceLog(logText); +} + +function createExtensionHostPerformanceLogPath(outputPath) { + const resolvedOutputPath = path.resolve(outputPath); + const extension = path.extname(resolvedOutputPath); + const basename = path.basename(resolvedOutputPath, extension); + return path.join(path.dirname(resolvedOutputPath), `${basename}-extension-host.jsonl`); +} + export function getWebviewEventDeltaMs( sample, startEventName = IMPORTS_TOGGLE_START_EVENT, @@ -260,6 +307,7 @@ async function measureVSCodeGraphView({ }) { const workspaceRoot = path.resolve(workspacePath); const settingsPath = path.join(workspaceRoot, '.codegraphy', 'settings.json'); + const extensionHostLogPath = createExtensionHostPerformanceLogPath(outputPath); const originalSettings = await readFile(settingsPath, 'utf8').catch(() => null); const { cleanupVSCode, @@ -270,8 +318,11 @@ async function measureVSCodeGraphView({ let vscode = null; try { + await mkdir(path.dirname(extensionHostLogPath), { recursive: true }); + await writeFile(extensionHostLogPath, ''); const launchStartedAt = performance.now(); vscode = await launchVSCodeWithWorkspace(workspaceRoot, { + extensionPerformanceLogPath: extensionHostLogPath, pluginPackageRelativePaths: DEFAULT_PLUGIN_PACKAGE_RELATIVE_PATHS, }); const launchMs = Math.round(performance.now() - launchStartedAt); @@ -316,6 +367,8 @@ async function measureVSCodeGraphView({ }, firstGraphReadyWebviewStages: summarizeWebviewEventDurations(firstGraphReadyWebviewEvents), firstGraphReadyWebviewEvents, + firstGraphReadyExtensionHostLogPath: extensionHostLogPath, + extensionHostEvents: await readExtensionHostPerformanceEvents(extensionHostLogPath), initialStats, importsToggle: { ...summarizeSwitchTransitionSamples(samples), diff --git a/tests/scripts/measure-vscode-graph-view.test.mjs b/tests/scripts/measure-vscode-graph-view.test.mjs index 3cc3be828..e8f6b9218 100644 --- a/tests/scripts/measure-vscode-graph-view.test.mjs +++ b/tests/scripts/measure-vscode-graph-view.test.mjs @@ -92,3 +92,30 @@ test('VS Code graph view runner summarizes startup webview stage durations', asy }, }); }); + +test('VS Code graph view runner parses extension host performance JSONL', async () => { + const moduleUrl = pathToFileURL( + path.resolve('scripts/performance/measure-vscode-graph-view.mjs'), + ).href; + const { parseExtensionHostPerformanceLog } = await import(moduleUrl); + + assert.deepEqual(parseExtensionHostPerformanceLog([ + '{"name":"graphWebview.resolve.start","at":1782129600100,"detail":{"visible":true}}', + 'not json', + '{"name":"graphWebview.html.assigned","at":1782129600125,"detail":{"htmlLength":21}}', + '{"name":12,"at":1782129600200}', + ].join('\n')), [ + { + name: 'graphWebview.resolve.start', + at: 1782129600100, + offsetMs: 0, + detail: { visible: true }, + }, + { + name: 'graphWebview.html.assigned', + at: 1782129600125, + offsetMs: 25, + detail: { htmlLength: 21 }, + }, + ]); +}); From 1f369d2e7fbba034fc12b6da24b2926e20ba5825 Mon Sep 17 00:00:00 2001 From: joesobo Date: Mon, 22 Jun 2026 20:05:02 -0700 Subject: [PATCH 026/192] test: mark graph view command startup timing Record command.open lifecycle events in the extension-host performance log and let the perf harness use its 120s timeout for frame readiness while keeping the default acceptance helper timeout at 20s. Latest large-monorepo harness: command dispatch completed at 38ms, provider resolve started at 43ms, and webview.html was assigned at 45ms. The 40497ms first-ready outlier is after HTML assignment and before the harness can observe the VS Code webview frame/document. --- docs/performance/codegraphy-monorepo.md | 23 +++++++++++++++++++ .../2026-06-22-codegraphy-performance.md | 1 + .../src/extension/commands/navigation.ts | 16 ++++++++++++- .../tests/acceptance/graphView/vscode.ts | 7 ++++-- .../extension/commands/navigation.test.ts | 23 +++++++++++++++++++ .../performance/measure-vscode-graph-view.mjs | 2 +- 6 files changed, 68 insertions(+), 4 deletions(-) diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index a811af75e..46d53d906 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -357,6 +357,22 @@ VS Code graph view benchmark: `586.4ms`. - Imports toggle wall-clock latency stayed in the same band at `228ms` median, `337ms` p95; in-webview latency was `58ms` median, `59ms` p95. +- After adding `codegraphy.open` command markers and extending only the + performance harness frame wait: + - VS Code launch: `958ms`. + - Open Graph View to first rendered graph stats: `40497ms`; the extended + harness wait captured an outlier that previously timed out at `20s`. + - First-ready phases: command/open `1595ms`, acceptance-ready frame + `38852ms`, stats wait after frame discovery `37ms`. + - Host timeline: `command.open.start` and `command.open.dispatched` at `0ms`, + `command.open.completed` at `38ms`, provider resolve start at `43ms`, and + `webview.html` assignment at `45ms`. + - Once the webview document was alive, it posted ready at `27.5ms`, received + graph data at `95.3ms`, ran the `74`-filter visible-graph derive in + `171.4ms`, completed app bootstrap at `1066.4ms`, and rendered stats at + `1145.9ms`. + - Imports toggle wall-clock latency was `185ms` median, `193ms` p95; in-webview + latency was `48ms` median, `50ms` p95. Interpretation: @@ -414,6 +430,13 @@ Interpretation: cutting the `74`-filter visible-graph derive pass from `498.4ms` to `244ms` and moving first stats after webview document start from `843.3ms` to `586.4ms`. +- Command markers rule out the command palette/open command and provider + resolver as the multi-second startup bucket. On the latest run, CodeGraphy + assigned `webview.html` `45ms` after `codegraphy.open` started; the remaining + outlier was after HTML assignment and before the harness could observe the + VS Code webview frame/document. The performance harness now uses the same + `120s` timeout as the rest of the graph-view metric collection for that + frame wait so these outliers produce data instead of failed runs. Full test baseline: diff --git a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md index e75fc584a..526369d84 100644 --- a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md +++ b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md @@ -296,6 +296,7 @@ Lazy-loaded 3D runtime: default webview index.js 2242.28 kB -> 819.25 kB minifie Added startup handshake markers and skipped settled duplicate graph payload replay: webview document reaches first stats at ~340ms after ready/data/bootstrap markers, duplicate 5088 node / 9146 edge replay is skipped in ~5ms, and the extra visible-graph/render pass is gone; first graph readiness remains flat at ~6987ms due to frame readiness. Added extension-host startup markers: provider resolve/html/context/flush work takes only 2ms-3ms, ruling it out as the remaining first-load bottleneck; a noisy first-ready run still spent 12500ms in frame readiness, while the webview-side 74-filter derive pass took 498.4ms. Combined visible-graph filter glob patterns into one matcher: startup 74-filter derive over the 24522 node / 20781 edge payload dropped from 498.4ms to 244ms, and first graph stats after webview document start moved from 843.3ms to 586.4ms; first-ready wall clock remains frame-readiness dominated. +Added command-open markers and extended only the performance harness frame wait: command dispatch completes at 38ms, provider resolve starts at 43ms, and webview.html is assigned at 45ms, so the latest 40497ms first-ready outlier is after HTML assignment and before the harness can observe the VS Code webview frame/document. ``` ## Task 5: Keep The PR Reviewable diff --git a/packages/extension/src/extension/commands/navigation.ts b/packages/extension/src/extension/commands/navigation.ts index d5442d293..06a0f1193 100644 --- a/packages/extension/src/extension/commands/navigation.ts +++ b/packages/extension/src/extension/commands/navigation.ts @@ -5,11 +5,25 @@ import * as vscode from 'vscode'; import type { GraphViewProvider } from '../graphViewProvider'; +import { recordExtensionPerformanceEvent } from '../performance/marks'; import type { CommandDefinition } from './definitions'; export function getNavCommands(provider: GraphViewProvider): CommandDefinition[] { return [ - { id: 'codegraphy.open', handler: () => { vscode.commands.executeCommand('workbench.view.extension.codegraphy'); } }, + { + id: 'codegraphy.open', + handler: () => { + recordExtensionPerformanceEvent('command.open.start'); + const openView = vscode.commands.executeCommand('workbench.view.extension.codegraphy'); + recordExtensionPerformanceEvent('command.open.dispatched'); + void Promise.resolve(openView).then( + () => recordExtensionPerformanceEvent('command.open.completed'), + (error: unknown) => recordExtensionPerformanceEvent('command.open.failed', { + message: error instanceof Error ? error.message : String(error), + }), + ); + }, + }, { id: 'codegraphy.openInEditor', handler: () => { provider.openInEditor(); } }, { id: 'codegraphy.fitView', handler: () => { provider.sendCommand('FIT_VIEW'); } }, { id: 'codegraphy.zoomIn', handler: () => { provider.sendCommand('ZOOM_IN'); } }, diff --git a/packages/extension/tests/acceptance/graphView/vscode.ts b/packages/extension/tests/acceptance/graphView/vscode.ts index 07b4a8fc3..1a912d3ba 100644 --- a/packages/extension/tests/acceptance/graphView/vscode.ts +++ b/packages/extension/tests/acceptance/graphView/vscode.ts @@ -87,7 +87,10 @@ export async function openGraphView(page: Page): Promise { throw lastError; } -export async function waitForGraphFrame(page: Page): Promise { +export async function waitForGraphFrame( + page: Page, + timeoutMs = VSCODE_PLAYWRIGHT_WAIT_TIMEOUT_MS, +): Promise { await expect.poll(async () => { for (const frame of page.frames().filter(candidate => candidate.url().includes('fake.html'))) { if (await isReadyGraphFrame(frame)) { @@ -96,7 +99,7 @@ export async function waitForGraphFrame(page: Page): Promise { } return false; - }, { timeout: VSCODE_PLAYWRIGHT_WAIT_TIMEOUT_MS }).toBe(true); + }, { timeout: timeoutMs }).toBe(true); for (const frame of page.frames().filter(candidate => candidate.url().includes('fake.html'))) { if (await isReadyGraphFrame(frame)) { diff --git a/packages/extension/tests/extension/commands/navigation.test.ts b/packages/extension/tests/extension/commands/navigation.test.ts index d522d346a..28ba21775 100644 --- a/packages/extension/tests/extension/commands/navigation.test.ts +++ b/packages/extension/tests/extension/commands/navigation.test.ts @@ -1,5 +1,12 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import * as vscode from 'vscode'; + +const recordExtensionPerformanceEvent = vi.hoisted(() => vi.fn()); + +vi.mock('../../../src/extension/performance/marks', () => ({ + recordExtensionPerformanceEvent, +})); + import { getNavCommands } from '../../../src/extension/commands/navigation'; function makeProvider() { @@ -41,6 +48,22 @@ describe('getNavCommands', () => { 'workbench.view.extension.codegraphy' ); }); + + it('records the open command lifecycle for startup timing', async () => { + vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined); + const provider = makeProvider(); + const commands = getNavCommands(provider as never); + const cmd = commands.find((cmd) => cmd.id === 'codegraphy.open')!; + + cmd.handler(); + await Promise.resolve(); + + expect(recordExtensionPerformanceEvent.mock.calls.map(([name]) => name)).toEqual([ + 'command.open.start', + 'command.open.dispatched', + 'command.open.completed', + ]); + }); }); describe('openInEditor command', () => { diff --git a/scripts/performance/measure-vscode-graph-view.mjs b/scripts/performance/measure-vscode-graph-view.mjs index bd9b1964a..7dce5996b 100644 --- a/scripts/performance/measure-vscode-graph-view.mjs +++ b/scripts/performance/measure-vscode-graph-view.mjs @@ -331,7 +331,7 @@ async function measureVSCodeGraphView({ await openGraphView(vscode.page); const openGraphCommandMs = Math.round(performance.now() - openStartedAt); const frameStartedAt = performance.now(); - const frame = await waitForGraphFrame(vscode.page); + const frame = await waitForGraphFrame(vscode.page, DEFAULT_TIMEOUT_MS); const graphFrameReadyMs = Math.round(performance.now() - frameStartedAt); await enableWebviewPerformanceEvents(frame); const statsStartedAt = performance.now(); From 63742b7c7c02b3dc39ccc42e9083c171e4d7dc14 Mon Sep 17 00:00:00 2001 From: joesobo Date: Mon, 22 Jun 2026 20:15:57 -0700 Subject: [PATCH 027/192] test: record graph frame lifecycle timing Add frame attach/navigation lifecycle events to the VS Code graph-view perf harness and write a startup-ready metrics record before Graph Scope interaction sampling. Latest large-monorepo run: early webview frames attached/navigated around 1.8s-2.0s, while the usable graph-ready fake.html frame appeared around 37.0s. This preserves evidence even when later interaction sampling flakes. --- docs/performance/codegraphy-monorepo.md | 23 ++++ .../2026-06-22-codegraphy-performance.md | 1 + .../performance/measure-vscode-graph-view.mjs | 108 ++++++++++++++++-- .../measure-vscode-graph-view.test.mjs | 88 ++++++++++++++ 4 files changed, 208 insertions(+), 12 deletions(-) diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index 46d53d906..3f4005bf5 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -373,6 +373,23 @@ VS Code graph view benchmark: `1145.9ms`. - Imports toggle wall-clock latency was `185ms` median, `193ms` p95; in-webview latency was `48ms` median, `50ms` p95. +- After adding Playwright frame lifecycle markers and writing startup-ready + metrics before interaction sampling: + - VS Code launch: `743ms`. + - Open Graph View to first rendered graph stats: `38513ms`. + - First-ready phases: command/open `1597ms`, acceptance-ready frame + `36867ms`, stats wait after frame discovery `36ms`. + - Frame lifecycle: the workbench frame existed at graph open; VS Code attached + and navigated webview frames around `1795ms`-`1983ms`; the usable + graph-ready fake.html frame attached/navigated at `37035ms`/`37040ms`; + Graph Stage was ready at `38464ms`. + - Extension-host timeline still assigned `webview.html` at `63ms` after + `codegraphy.open` started. + - Webview document work after the usable frame started remained near `1.14s`: + ready at `27.5ms`, graph data at `95ms`, first filtered derive at + `671.5ms`, bootstrap at `1058.7ms`, and graph stats at `1139.7ms`. + - Imports toggle wall-clock latency was `183ms` median, `489ms` p95; in-webview + latency was `47ms` median, `50ms` p95. Interpretation: @@ -437,6 +454,12 @@ Interpretation: VS Code webview frame/document. The performance harness now uses the same `120s` timeout as the rest of the graph-view metric collection for that frame wait so these outliers produce data instead of failed runs. +- Frame lifecycle markers show the coarse frame-readiness bucket is not just + “no iframe exists.” VS Code attaches and navigates early webview frames + around `1.8s`-`2.0s`, then the usable graph-ready fake.html frame appears + much later around `37.0s`. The harness now writes a `startup-ready` metrics + record before Graph Scope interaction sampling so a later control timeout + still preserves startup evidence. Full test baseline: diff --git a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md index 526369d84..3fc20bdea 100644 --- a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md +++ b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md @@ -297,6 +297,7 @@ Added startup handshake markers and skipped settled duplicate graph payload repl Added extension-host startup markers: provider resolve/html/context/flush work takes only 2ms-3ms, ruling it out as the remaining first-load bottleneck; a noisy first-ready run still spent 12500ms in frame readiness, while the webview-side 74-filter derive pass took 498.4ms. Combined visible-graph filter glob patterns into one matcher: startup 74-filter derive over the 24522 node / 20781 edge payload dropped from 498.4ms to 244ms, and first graph stats after webview document start moved from 843.3ms to 586.4ms; first-ready wall clock remains frame-readiness dominated. Added command-open markers and extended only the performance harness frame wait: command dispatch completes at 38ms, provider resolve starts at 43ms, and webview.html is assigned at 45ms, so the latest 40497ms first-ready outlier is after HTML assignment and before the harness can observe the VS Code webview frame/document. +Added frame lifecycle markers and startup-ready partial metrics: early webview frames attach/navigate around 1.8s-2.0s, but the usable graph-ready fake.html frame appeared around 37.0s in the latest run; the harness now preserves startup evidence before Graph Scope interaction sampling. ``` ## Task 5: Keep The PR Reviewable diff --git a/scripts/performance/measure-vscode-graph-view.mjs b/scripts/performance/measure-vscode-graph-view.mjs index 7dce5996b..10da10a83 100644 --- a/scripts/performance/measure-vscode-graph-view.mjs +++ b/scripts/performance/measure-vscode-graph-view.mjs @@ -114,6 +114,36 @@ function createExtensionHostPerformanceLogPath(outputPath) { return path.join(path.dirname(resolvedOutputPath), `${basename}-extension-host.jsonl`); } +function isWebviewFrameUrl(url) { + return url.includes('fake.html') || url.startsWith('vscode-webview://'); +} + +export function createGraphFrameLifecycleRecorder(startedAt = performance.now()) { + const events = []; + + function record(name, at = performance.now(), detail = {}) { + events.push({ + name, + offsetMs: Math.round(at - startedAt), + ...detail, + }); + } + + function recordFrame(name, frame, at = performance.now()) { + const url = frame.url(); + record(name, at, { + url, + webviewFrame: isWebviewFrameUrl(url), + }); + } + + return { + events, + record, + recordFrame, + }; +} + export function getWebviewEventDeltaMs( sample, startEventName = IMPORTS_TOGGLE_START_EVENT, @@ -161,6 +191,30 @@ export function summarizeWebviewEventDurations(events) { ); } +export function createStartupMeasurements({ + extensionHostEvents, + extensionHostLogPath, + firstGraphReadyMs, + firstGraphReadyPhases, + firstGraphReadyWebviewEvents, + frameLifecycleEvents, + initialStats, + vscodeLaunchMs, +}) { + return { + status: 'startup-ready', + vscodeLaunchMs, + firstGraphReadyMs, + firstGraphReadyPhases, + firstGraphReadyWebviewStages: summarizeWebviewEventDurations(firstGraphReadyWebviewEvents), + firstGraphReadyWebviewEvents, + firstGraphReadyFrameLifecycleEvents: frameLifecycleEvents, + firstGraphReadyExtensionHostLogPath: extensionHostLogPath, + extensionHostEvents, + initialStats, + }; +} + async function readGraphStats(frame) { const text = await frame .getByText(/[\d,]+\s+nodes?.*?[\d,]+\s+connections?/i) @@ -328,17 +382,57 @@ async function measureVSCodeGraphView({ const launchMs = Math.round(performance.now() - launchStartedAt); await installWebviewPerformanceInitScript(vscode.page); const openStartedAt = performance.now(); + const frameLifecycle = createGraphFrameLifecycleRecorder(openStartedAt); + const recordFrameAttached = frame => frameLifecycle.recordFrame('frame.attached', frame); + const recordFrameNavigated = frame => frameLifecycle.recordFrame('frame.navigated', frame); + vscode.page.on('frameattached', recordFrameAttached); + vscode.page.on('framenavigated', recordFrameNavigated); + frameLifecycle.record('graphOpen.start', openStartedAt, { + frameCount: vscode.page.frames().length, + }); + for (const frame of vscode.page.frames()) { + frameLifecycle.recordFrame('frame.existingAtOpen', frame, openStartedAt); + } await openGraphView(vscode.page); const openGraphCommandMs = Math.round(performance.now() - openStartedAt); + frameLifecycle.record('graphOpen.commandCompleted', performance.now(), { + frameCount: vscode.page.frames().length, + }); const frameStartedAt = performance.now(); const frame = await waitForGraphFrame(vscode.page, DEFAULT_TIMEOUT_MS); const graphFrameReadyMs = Math.round(performance.now() - frameStartedAt); + frameLifecycle.record('graphFrame.ready', performance.now(), { + frameCount: vscode.page.frames().length, + url: frame.url(), + }); + vscode.page.off('frameattached', recordFrameAttached); + vscode.page.off('framenavigated', recordFrameNavigated); await enableWebviewPerformanceEvents(frame); const statsStartedAt = performance.now(); const initialStats = await waitForGraphStats(frame, stats => stats.nodeCount > 0); const graphStatsReadyMs = Math.round(performance.now() - statsStartedAt); const firstGraphReadyMs = Math.round(performance.now() - openStartedAt); const firstGraphReadyWebviewEvents = await readWebviewPerformanceEvents(frame); + const extensionHostEvents = await readExtensionHostPerformanceEvents(extensionHostLogPath); + const startupMeasurements = createStartupMeasurements({ + extensionHostEvents, + extensionHostLogPath, + firstGraphReadyMs, + firstGraphReadyPhases: { + openGraphCommandMs, + graphFrameReadyMs, + graphStatsReadyMs, + }, + firstGraphReadyWebviewEvents, + frameLifecycleEvents: frameLifecycle.events, + initialStats, + vscodeLaunchMs: launchMs, + }); + await writeMetrics({ + outputPath, + workspacePath: workspaceRoot, + measurements: startupMeasurements, + }); await openGraphScopeEdgeTypes(frame); const initialImportsEnabled = await readSwitchEnabled(frame, 'Imports'); @@ -358,18 +452,8 @@ async function measureVSCodeGraphView({ } const measurements = { - vscodeLaunchMs: launchMs, - firstGraphReadyMs, - firstGraphReadyPhases: { - openGraphCommandMs, - graphFrameReadyMs, - graphStatsReadyMs, - }, - firstGraphReadyWebviewStages: summarizeWebviewEventDurations(firstGraphReadyWebviewEvents), - firstGraphReadyWebviewEvents, - firstGraphReadyExtensionHostLogPath: extensionHostLogPath, - extensionHostEvents: await readExtensionHostPerformanceEvents(extensionHostLogPath), - initialStats, + ...startupMeasurements, + status: 'complete', importsToggle: { ...summarizeSwitchTransitionSamples(samples), samples, diff --git a/tests/scripts/measure-vscode-graph-view.test.mjs b/tests/scripts/measure-vscode-graph-view.test.mjs index e8f6b9218..1254b9fb8 100644 --- a/tests/scripts/measure-vscode-graph-view.test.mjs +++ b/tests/scripts/measure-vscode-graph-view.test.mjs @@ -119,3 +119,91 @@ test('VS Code graph view runner parses extension host performance JSONL', async }, ]); }); + +test('VS Code graph view runner records frame lifecycle offsets from graph open', async () => { + const moduleUrl = pathToFileURL( + path.resolve('scripts/performance/measure-vscode-graph-view.mjs'), + ).href; + const { createGraphFrameLifecycleRecorder } = await import(moduleUrl); + const recorder = createGraphFrameLifecycleRecorder(1_000); + + recorder.record('graphOpen.start', 1_000); + recorder.recordFrame('frame.attached', { url: () => 'about:blank' }, 1_025); + recorder.recordFrame('frame.navigated', { url: () => 'vscode-webview://test/fake.html' }, 1_150); + recorder.record('graphFrame.ready', 1_425, { frameCount: 3 }); + + assert.deepEqual(recorder.events, [ + { + name: 'graphOpen.start', + offsetMs: 0, + }, + { + name: 'frame.attached', + offsetMs: 25, + url: 'about:blank', + webviewFrame: false, + }, + { + name: 'frame.navigated', + offsetMs: 150, + url: 'vscode-webview://test/fake.html', + webviewFrame: true, + }, + { + name: 'graphFrame.ready', + offsetMs: 425, + frameCount: 3, + }, + ]); +}); + +test('VS Code graph view runner builds a startup-ready measurement payload before interactions', async () => { + const moduleUrl = pathToFileURL( + path.resolve('scripts/performance/measure-vscode-graph-view.mjs'), + ).href; + const { createStartupMeasurements } = await import(moduleUrl); + + assert.deepEqual(createStartupMeasurements({ + extensionHostEvents: [{ name: 'command.open.start', offsetMs: 0 }], + extensionHostLogPath: '/tmp/extension-host.jsonl', + firstGraphReadyMs: 1200, + firstGraphReadyPhases: { + openGraphCommandMs: 100, + graphFrameReadyMs: 1000, + graphStatsReadyMs: 20, + }, + firstGraphReadyWebviewEvents: [ + { name: 'visibleGraph.derive', durationMs: 12.2 }, + { name: 'graphStats.rendered', at: 30 }, + ], + frameLifecycleEvents: [{ name: 'graphFrame.ready', offsetMs: 1100 }], + initialStats: { nodeCount: 10, edgeCount: 5 }, + vscodeLaunchMs: 900, + }), { + status: 'startup-ready', + vscodeLaunchMs: 900, + firstGraphReadyMs: 1200, + firstGraphReadyPhases: { + openGraphCommandMs: 100, + graphFrameReadyMs: 1000, + graphStatsReadyMs: 20, + }, + firstGraphReadyWebviewStages: { + 'visibleGraph.derive': { + iterations: 1, + minMs: 12, + medianMs: 12, + p95Ms: 12, + maxMs: 12, + }, + }, + firstGraphReadyWebviewEvents: [ + { name: 'visibleGraph.derive', durationMs: 12.2 }, + { name: 'graphStats.rendered', at: 30 }, + ], + firstGraphReadyFrameLifecycleEvents: [{ name: 'graphFrame.ready', offsetMs: 1100 }], + firstGraphReadyExtensionHostLogPath: '/tmp/extension-host.jsonl', + extensionHostEvents: [{ name: 'command.open.start', offsetMs: 0 }], + initialStats: { nodeCount: 10, edgeCount: 5 }, + }); +}); From d1838aa188914beba6b45d8e4795f314505bf5dc Mon Sep 17 00:00:00 2001 From: joesobo Date: Mon, 22 Jun 2026 20:22:41 -0700 Subject: [PATCH 028/192] perf: skip pre-bootstrap duplicate graph payloads Extend webview duplicate graph payload skipping to the initial bootstrap wait, preserving loading state until APP_BOOTSTRAP_COMPLETE. Latest monorepo harness had one GRAPH_DATA_UPDATED payload and no repeated 74-filter derive before bootstrap; first stats after usable document start at 597.9ms; Imports toggle 213ms median / 267ms p95. --- docs/performance/codegraphy-monorepo.md | 19 ++++++++++++++ .../2026-06-22-codegraphy-performance.md | 1 + .../webview/store/messageHandlers/graph.ts | 25 ++++++++++++------- .../store/messageHandlers/graph.test.ts | 25 +++++++++++++++++++ 4 files changed, 61 insertions(+), 9 deletions(-) diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index 3f4005bf5..1a9420a87 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -390,6 +390,21 @@ VS Code graph view benchmark: `671.5ms`, bootstrap at `1058.7ms`, and graph stats at `1139.7ms`. - Imports toggle wall-clock latency was `183ms` median, `489ms` p95; in-webview latency was `47ms` median, `50ms` p95. +- After skipping duplicate graph payloads while the app is waiting for initial + bootstrap completion: + - VS Code launch: `1172ms`. + - Open Graph View to first rendered graph stats: `13885ms`. + - First-ready phases: command/open `1727ms`, acceptance-ready frame + `12108ms`, stats wait after frame discovery `38ms`. + - Host timeline still assigned `webview.html` at `49ms` after + `codegraphy.open` started. + - The latest startup webview event stream had a single `GRAPH_DATA_UPDATED` + for the `24522` node / `20781` edge payload and no second `74`-filter + visible-graph derive before bootstrap. The first filtered derive took + `245.6ms`, bootstrap completed at `504.1ms`, and first stats rendered at + `597.9ms` after the usable document started. + - Imports toggle wall-clock latency was `213ms` median, `267ms` p95; in-webview + latency was `58ms` median, `62ms` p95. Interpretation: @@ -460,6 +475,10 @@ Interpretation: much later around `37.0s`. The harness now writes a `startup-ready` metrics record before Graph Scope interaction sampling so a later control timeout still preserves startup evidence. +- The webview now skips duplicate graph payloads even while it is still waiting + for `APP_BOOTSTRAP_COMPLETE`. This keeps loading semantics intact but avoids + re-running visible-graph derivation when the same graph arrives before + bootstrap settles. Full test baseline: diff --git a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md index 3fc20bdea..80b9bcf2d 100644 --- a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md +++ b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md @@ -298,6 +298,7 @@ Added extension-host startup markers: provider resolve/html/context/flush work t Combined visible-graph filter glob patterns into one matcher: startup 74-filter derive over the 24522 node / 20781 edge payload dropped from 498.4ms to 244ms, and first graph stats after webview document start moved from 843.3ms to 586.4ms; first-ready wall clock remains frame-readiness dominated. Added command-open markers and extended only the performance harness frame wait: command dispatch completes at 38ms, provider resolve starts at 43ms, and webview.html is assigned at 45ms, so the latest 40497ms first-ready outlier is after HTML assignment and before the harness can observe the VS Code webview frame/document. Added frame lifecycle markers and startup-ready partial metrics: early webview frames attach/navigate around 1.8s-2.0s, but the usable graph-ready fake.html frame appeared around 37.0s in the latest run; the harness now preserves startup evidence before Graph Scope interaction sampling. +Skipped duplicate graph payloads before bootstrap completion: focused test locks the loading-state behavior; latest startup event stream had one GRAPH_DATA_UPDATED payload and no repeated 74-filter derive before bootstrap, with stats rendered at 597.9ms after the usable document started and Imports toggle at 213ms median / 267ms p95. ``` ## Task 5: Keep The PR Reviewable diff --git a/packages/extension/src/webview/store/messageHandlers/graph.ts b/packages/extension/src/webview/store/messageHandlers/graph.ts index 52e46ca20..1f3502cad 100644 --- a/packages/extension/src/webview/store/messageHandlers/graph.ts +++ b/packages/extension/src/webview/store/messageHandlers/graph.ts @@ -20,17 +20,24 @@ function areGraphDataPayloadsEqual(left: IGraphData, right: IGraphData): boolean } } -function shouldSkipSettledDuplicateGraphData( +function shouldSkipDuplicateGraphData( state: ReturnType>, payload: IGraphData, ): boolean { - return Boolean( - state.graphData - && state.bootstrapComplete - && !state.awaitingInitialBootstrap - && !state.graphIsIndexing - && !state.isLoading - && areGraphDataPayloadsEqual(state.graphData, payload) + if (!state.graphData || state.graphIsIndexing || !areGraphDataPayloadsEqual(state.graphData, payload)) { + return false; + } + + return ( + ( + state.bootstrapComplete + && !state.awaitingInitialBootstrap + && !state.isLoading + ) + || ( + state.awaitingInitialBootstrap + && !state.bootstrapComplete + ) ); } @@ -44,7 +51,7 @@ export function handleGraphDataUpdated( }); const state = ctx?.getState(); - if (state && shouldSkipSettledDuplicateGraphData(state, message.payload)) { + if (state && shouldSkipDuplicateGraphData(state, message.payload)) { recordWebviewPerformanceEvent('extensionMessage.graphDataSkipped', { edgeCount: message.payload.edges.length, nodeCount: message.payload.nodes.length, diff --git a/packages/extension/tests/webview/store/messageHandlers/graph.test.ts b/packages/extension/tests/webview/store/messageHandlers/graph.test.ts index 937e7e06c..50c9147f7 100644 --- a/packages/extension/tests/webview/store/messageHandlers/graph.test.ts +++ b/packages/extension/tests/webview/store/messageHandlers/graph.test.ts @@ -152,6 +152,31 @@ describe('webview/store/messageHandlers/graph', () => { ]); }); + it('skips duplicate graph payloads while waiting for initial bootstrap completion', () => { + window.__codegraphyPerformance = { enabled: true, events: [] }; + const payload = { + nodes: [{ id: 'src/app.ts', label: 'App', color: '#fff' }], + edges: [{ id: 'src/app.ts->src/lib.ts', from: 'src/app.ts', to: 'src/lib.ts', kind: 'import' as const, sources: [] }], + }; + const state = createState({ + awaitingInitialBootstrap: true, + bootstrapComplete: false, + graphData: JSON.parse(JSON.stringify(payload)), + graphIsIndexing: false, + isLoading: true, + }); + + expect(handleGraphDataUpdated( + { type: 'GRAPH_DATA_UPDATED', payload }, + { getState: () => state }, + )).toBeUndefined(); + + expect(window.__codegraphyPerformance.events).toEqual([ + expect.objectContaining({ name: 'extensionMessage.graphDataUpdated' }), + expect.objectContaining({ name: 'extensionMessage.graphDataSkipped' }), + ]); + }); + it('settles initial bootstrap when graph data arrives after bootstrap and plugin assets are ready', () => { const payload = { nodes: [{ id: 'src/app.ts', label: 'App', color: '#fff' }], edges: [] }; const state = createState({ From 211249677a61b88e6cfdff2760e30f9335d23bec Mon Sep 17 00:00:00 2001 From: joesobo Date: Mon, 22 Jun 2026 20:44:20 -0700 Subject: [PATCH 029/192] perf: defer hidden startup graph derivation Avoid deriving and styling the real graph while the startup loading screen is still active, and skip unchanged post-load filter pattern replay while preserving late plugin filter updates. Fix the VS Code performance harness so rendered stats before a toggle start cannot produce negative in-webview latency. Latest monorepo harness: no 22304-node derive before APP_BOOTSTRAP_COMPLETE; bootstrap at 512.8ms, first real 74-filter derive at 696.6ms for 183.8ms, stats at 892.1ms after document start; Imports toggle 202ms median / 220ms p95 wall-clock and 53ms / 56ms in-webview. --- docs/performance/codegraphy-monorepo.md | 36 +++++++++++ .../2026-06-22-codegraphy-performance.md | 2 + .../graphView/webview/messages/ready.ts | 63 ++++++++++++++++--- .../extension/src/webview/app/shell/view.tsx | 7 ++- .../graphView/webview/messages/ready.test.ts | 39 ++++++++++++ .../tests/webview/app/shell/view.test.tsx | 46 ++++++++++++++ .../performance/measure-vscode-graph-view.mjs | 14 ++++- .../measure-vscode-graph-view.test.mjs | 15 +++++ 8 files changed, 208 insertions(+), 14 deletions(-) diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index 1a9420a87..0e3f25cf3 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -405,6 +405,35 @@ VS Code graph view benchmark: `597.9ms` after the usable document started. - Imports toggle wall-clock latency was `213ms` median, `267ms` p95; in-webview latency was `58ms` median, `62ms` p95. +- Rejected direct Graph View focus before the container fallback: + - The command test covered trying `codegraphy.graphView.focus` before + `workbench.view.extension.codegraphy`, but the measured run did not improve + startup and the production change was reverted. + - Open Graph View to first rendered graph stats: `39359ms`. + - First-ready phases: command/open `1577ms`, acceptance-ready frame + `37734ms`, stats wait after frame discovery `34ms`. + - Host timeline still assigned `webview.html` at `50ms` after + `codegraphy.open` started, leaving the same frame-readiness bucket. + - Imports toggle wall-clock latency was `192ms` median, `219ms` p95; in-webview + latency was `51ms` median, `55ms` p95. +- After deferring visible graph derivation while startup loading hides the graph, + skipping unchanged post-load filter pattern replay, and fixing harness + in-webview delta pairing: + - VS Code launch: `818ms`. + - Open Graph View to first rendered graph stats: `42234ms`. + - First-ready phases: command/open `1732ms`, acceptance-ready frame + `40458ms`, stats wait after frame discovery `15ms`. + - Host timeline assigned `webview.html` at `61ms` after `codegraphy.open` + started. + - The startup webview event stream received graph data at `101.2ms`, skipped + the duplicate graph payload at `512.1ms`, completed bootstrap at `512.8ms`, + first ran the real `22304` node / `74`-filter visible-graph derive at + `696.6ms` for `183.8ms`, and rendered stats at `892.1ms` after the usable + document started. + - No `22304`-node visible-graph derive ran before bootstrap while the loading + state was hiding the graph. + - Imports toggle wall-clock latency was `202ms` median, `220ms` p95; in-webview + latency was `53ms` median, `56ms` p95. Interpretation: @@ -479,6 +508,13 @@ Interpretation: for `APP_BOOTSTRAP_COMPLETE`. This keeps loading semantics intact but avoids re-running visible-graph derivation when the same graph arrives before bootstrap settles. +- Directly focusing the Graph View tree item before the container fallback did + not move the first-ready timing, so the command change was discarded instead + of adding a behavior path without a measured win. +- Startup loading no longer derives or styles the real large graph before the + app is allowed to display it. This removes hidden pre-bootstrap work, while + the latest first-load wall clock remains dominated by VS Code webview frame + readiness outside the measured CodeGraphy data path. Full test baseline: diff --git a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md index 80b9bcf2d..963a83762 100644 --- a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md +++ b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md @@ -299,6 +299,8 @@ Combined visible-graph filter glob patterns into one matcher: startup 74-filter Added command-open markers and extended only the performance harness frame wait: command dispatch completes at 38ms, provider resolve starts at 43ms, and webview.html is assigned at 45ms, so the latest 40497ms first-ready outlier is after HTML assignment and before the harness can observe the VS Code webview frame/document. Added frame lifecycle markers and startup-ready partial metrics: early webview frames attach/navigate around 1.8s-2.0s, but the usable graph-ready fake.html frame appeared around 37.0s in the latest run; the harness now preserves startup evidence before Graph Scope interaction sampling. Skipped duplicate graph payloads before bootstrap completion: focused test locks the loading-state behavior; latest startup event stream had one GRAPH_DATA_UPDATED payload and no repeated 74-filter derive before bootstrap, with stats rendered at 597.9ms after the usable document started and Imports toggle at 213ms median / 267ms p95. +Rejected direct Graph View focus before container fallback: first graph readiness stayed frame-readiness dominated at 39359ms, with webview.html assigned at 50ms and the acceptance-ready frame bucket at 37734ms, so the command-path change was reverted. +Deferred visible graph derivation while startup loading hides the graph, skipped unchanged post-load filter pattern replay, and fixed harness in-webview delta pairing: latest startup stream has no 22304-node derive before APP_BOOTSTRAP_COMPLETE, bootstrap at 512.8ms, first real 74-filter derive at 696.6ms for 183.8ms, stats at 892.1ms after document start, Imports toggle at 202ms median / 220ms p95 wall-clock and 53ms median / 56ms p95 in-webview. ``` ## Task 5: Keep The PR Reviewable diff --git a/packages/extension/src/extension/graphView/webview/messages/ready.ts b/packages/extension/src/extension/graphView/webview/messages/ready.ts index 88e9b1e1e..91acdda62 100644 --- a/packages/extension/src/extension/graphView/webview/messages/ready.ts +++ b/packages/extension/src/extension/graphView/webview/messages/ready.ts @@ -2,6 +2,7 @@ import type { DagMode, NodeSizeMode } from '../../../../shared/settings/modes'; import type { IPluginFilterPatternGroup } from '../../../../shared/protocol/extensionToWebview'; import type { IGraphData } from '../../../../shared/graph/contracts'; import { createExtensionDiagnosticLogger } from '../../../diagnostics/logger'; +import type { ExtensionToWebviewMessage } from '../../../../shared/protocol/extensionToWebview'; export interface GraphViewReadyState { maxFiles: number; @@ -45,17 +46,54 @@ export interface GraphViewReadyHandlers { notifyWebviewReady(): void; } -function sendWebviewReadyFilterPatterns(handlers: GraphViewReadyHandlers): void { +type FilterPatternsUpdatedMessage = Extract; +type FilterPatternsPayload = FilterPatternsUpdatedMessage['payload']; + +function createWebviewReadyFilterPatternsPayload(handlers: GraphViewReadyHandlers): FilterPatternsPayload { + return { + patterns: handlers.getFilterPatterns(), + pluginPatterns: handlers.getPluginFilterPatterns(), + pluginPatternGroups: handlers.getPluginFilterGroups?.() ?? [], + disabledCustomPatterns: handlers.getConfig('disabledCustomFilterPatterns', []), + disabledPluginPatterns: handlers.getConfig('disabledPluginFilterPatterns', []), + }; +} + +function areStringArraysEqual(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); +} + +function arePluginFilterPatternGroupsEqual( + left: readonly IPluginFilterPatternGroup[], + right: readonly IPluginFilterPatternGroup[], +): boolean { + return left.length === right.length && left.every((leftGroup, index) => { + const rightGroup = right[index]; + return Boolean(rightGroup) + && leftGroup.pluginId === rightGroup.pluginId + && leftGroup.pluginName === rightGroup.pluginName + && areStringArraysEqual(leftGroup.patterns, rightGroup.patterns); + }); +} + +function areWebviewReadyFilterPatternsEqual( + left: FilterPatternsPayload, + right: FilterPatternsPayload, +): boolean { + return areStringArraysEqual(left.patterns, right.patterns) + && areStringArraysEqual(left.pluginPatterns, right.pluginPatterns) + && arePluginFilterPatternGroupsEqual(left.pluginPatternGroups, right.pluginPatternGroups) + && areStringArraysEqual(left.disabledCustomPatterns, right.disabledCustomPatterns) + && areStringArraysEqual(left.disabledPluginPatterns, right.disabledPluginPatterns); +} + +function sendWebviewReadyFilterPatterns(handlers: GraphViewReadyHandlers): FilterPatternsPayload { + const payload = createWebviewReadyFilterPatternsPayload(handlers); handlers.sendMessage({ type: 'FILTER_PATTERNS_UPDATED', - payload: { - patterns: handlers.getFilterPatterns(), - pluginPatterns: handlers.getPluginFilterPatterns(), - pluginPatternGroups: handlers.getPluginFilterGroups?.() ?? [], - disabledCustomPatterns: handlers.getConfig('disabledCustomFilterPatterns', []), - disabledPluginPatterns: handlers.getConfig('disabledPluginFilterPatterns', []), - }, + payload, }); + return payload; } export function replayWebviewReadySettings( @@ -154,9 +192,16 @@ export async function applyWebviewReady( ): Promise { replayWebviewReadySettings(state, handlers); + const initialFilterPatterns = createWebviewReadyFilterPatternsPayload(handlers); await handlers.sendCachedTimeline(); await handlers.loadAndSendData(); - sendWebviewReadyFilterPatterns(handlers); + const loadedFilterPatterns = createWebviewReadyFilterPatternsPayload(handlers); + if (!areWebviewReadyFilterPatternsEqual(initialFilterPatterns, loadedFilterPatterns)) { + handlers.sendMessage({ + type: 'FILTER_PATTERNS_UPDATED', + payload: loadedFilterPatterns, + }); + } handlers.sendPluginStatuses?.(); handlers.sendMessage({ type: 'APP_BOOTSTRAP_COMPLETE' }); diff --git a/packages/extension/src/webview/app/shell/view.tsx b/packages/extension/src/webview/app/shell/view.tsx index 985ada2a0..52f2f3376 100644 --- a/packages/extension/src/webview/app/shell/view.tsx +++ b/packages/extension/src/webview/app/shell/view.tsx @@ -81,13 +81,14 @@ export default function App(): React.ReactElement { edgeVisibility: renderEdgeVisibility, nodeVisibility: renderNodeVisibility, } = useDebouncedGraphScopeVisibility(nodeVisibility, edgeVisibility); + const visibleGraphInput = isLoading ? null : graphData; const { filteredData, coloredData, edgeDecorations: graphEdgeDecorations, regexError, } = useFilteredGraph( - graphData, + visibleGraphInput, searchQuery, searchOptions, legends, @@ -104,7 +105,7 @@ export default function App(): React.ReactElement { activeFilterPatterns, edgeVisibility: renderEdgeVisibility, filteredData, - graphData, + graphData: visibleGraphInput, graphEdgeTypes, graphNodeTypes, nodeVisibility: renderNodeVisibility, @@ -129,7 +130,7 @@ export default function App(): React.ReactElement { return setupMessageListener(injectPluginAssets, pluginHost, resetPluginAssets, updatePluginData); }, [injectPluginAssets, pluginHost, resetPluginAssets, updatePluginData]); - const displayGraphData = coloredData || graphData; + const displayGraphData = coloredData || visibleGraphInput; useVisibleGraphStateResponse(displayGraphData); if (isLoading) return ; diff --git a/packages/extension/tests/extension/graphView/webview/messages/ready.test.ts b/packages/extension/tests/extension/graphView/webview/messages/ready.test.ts index 4691d92b5..2dd283578 100644 --- a/packages/extension/tests/extension/graphView/webview/messages/ready.test.ts +++ b/packages/extension/tests/extension/graphView/webview/messages/ready.test.ts @@ -117,6 +117,11 @@ describe('graph view ready message', () => { handlers.sendSettings.mockImplementation(() => callOrder.push('settings')); handlers.sendGraphControls.mockImplementation(() => callOrder.push('controls')); handlers.sendPluginWebviewInjections.mockImplementation(() => callOrder.push('plugin-injections')); + handlers.sendMessage.mockImplementation((message: { type: string }) => { + if (message.type === 'FILTER_PATTERNS_UPDATED') { + callOrder.push('filters'); + } + }); handlers.loadAndSendData.mockImplementation(() => { callOrder.push('analyze'); }); @@ -143,6 +148,40 @@ describe('graph view ready message', () => { expect(callOrder.indexOf('settings')).toBeLessThan(callOrder.indexOf('analyze')); expect(callOrder.indexOf('controls')).toBeLessThan(callOrder.indexOf('analyze')); expect(callOrder.indexOf('plugin-injections')).toBeLessThan(callOrder.indexOf('analyze')); + expect(callOrder.indexOf('filters')).toBeLessThan(callOrder.indexOf('analyze')); + }); + + it('does not replay unchanged filter patterns after graph loading', async () => { + const events: string[] = []; + const handlers = createHandlers(); + handlers.sendMessage.mockImplementation((message: { type: string }) => { + if (message.type === 'FILTER_PATTERNS_UPDATED') { + events.push('filters'); + } + }); + handlers.loadAndSendData.mockImplementation(() => { + events.push('graph'); + }); + + await applyWebviewReady( + { + maxFiles: 500, + verboseDiagnostics: false, + playbackSpeed: 1, + dagMode: null, + nodeSizeMode: 'connections', + focusedFile: undefined, + hasWorkspace: true, + firstAnalysis: true, + readyNotified: false, + }, + handlers + ); + + expect(events).toEqual(['filters', 'graph']); + expect(handlers.sendMessage.mock.calls.filter(([message]) => + (message as { type?: string }).type === 'FILTER_PATTERNS_UPDATED' + )).toHaveLength(1); }); it('replays plugin filters that become available while loading graph data', async () => { diff --git a/packages/extension/tests/webview/app/shell/view.test.tsx b/packages/extension/tests/webview/app/shell/view.test.tsx index e6c8de371..11eea6e5f 100644 --- a/packages/extension/tests/webview/app/shell/view.test.tsx +++ b/packages/extension/tests/webview/app/shell/view.test.tsx @@ -138,6 +138,52 @@ describe('App', () => { expect(screen.getByTitle('Graph Scope')).toBeInTheDocument(); }); + it('does not derive the visible graph while startup loading hides the graph', async () => { + window.__codegraphyPerformance = { enabled: true, events: [] }; + + render(); + + await act(async () => { + sendMessage({ + type: 'FILTER_PATTERNS_UPDATED', + payload: { + patterns: ['dist/**'], + pluginPatterns: [], + pluginPatternGroups: [], + disabledCustomPatterns: [], + disabledPluginPatterns: [], + }, + }); + sendMessage({ + type: 'GRAPH_DATA_UPDATED', + payload: { + nodes: [{ id: 'src/app.ts', label: 'app.ts', color: '#3B82F6' }], + edges: [], + }, + }); + }); + + expect(screen.getByText('Loading graph...')).toBeInTheDocument(); + expect(window.__codegraphyPerformance.events).not.toContainEqual( + expect.objectContaining({ + name: 'visibleGraph.derive', + detail: expect.objectContaining({ nodeCount: 1 }), + }), + ); + + await act(async () => { + sendMessage({ type: 'APP_BOOTSTRAP_COMPLETE' }); + }); + + expect(screen.getByText('1 node • 0 connections')).toBeInTheDocument(); + expect(window.__codegraphyPerformance.events).toContainEqual( + expect.objectContaining({ + name: 'visibleGraph.derive', + detail: expect.objectContaining({ filterPatternCount: 1, nodeCount: 1 }), + }), + ); + }); + it('keeps the first graph visible while startup plugin assets finish loading', async () => { let resolveInjection: (() => void) | undefined; const pendingImport = new Promise((resolve) => { diff --git a/scripts/performance/measure-vscode-graph-view.mjs b/scripts/performance/measure-vscode-graph-view.mjs index 10da10a83..5cacaf95a 100644 --- a/scripts/performance/measure-vscode-graph-view.mjs +++ b/scripts/performance/measure-vscode-graph-view.mjs @@ -67,6 +67,12 @@ function findWebviewEventAt(sample, eventName) { return typeof event?.at === 'number' ? event.at : undefined; } +function findWebviewEventAtOrAfter(sample, eventName, minimumAt) { + const event = sample.webviewEvents?.find(item => + item.name === eventName && typeof item.at === 'number' && item.at >= minimumAt); + return typeof event?.at === 'number' ? event.at : undefined; +} + function isPlainObject(value) { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); } @@ -150,8 +156,12 @@ export function getWebviewEventDeltaMs( renderedEventName = IMPORTS_TOGGLE_RENDERED_EVENT, ) { const startedAt = findWebviewEventAt(sample, startEventName); - const renderedAt = findWebviewEventAt(sample, renderedEventName); - if (startedAt === undefined || renderedAt === undefined) { + if (startedAt === undefined) { + return undefined; + } + + const renderedAt = findWebviewEventAtOrAfter(sample, renderedEventName, startedAt); + if (renderedAt === undefined) { return undefined; } diff --git a/tests/scripts/measure-vscode-graph-view.test.mjs b/tests/scripts/measure-vscode-graph-view.test.mjs index 1254b9fb8..d59077383 100644 --- a/tests/scripts/measure-vscode-graph-view.test.mjs +++ b/tests/scripts/measure-vscode-graph-view.test.mjs @@ -64,6 +64,21 @@ test('VS Code graph view runner summarizes in-webview toggle event deltas', asyn }); }); +test('VS Code graph view runner ignores rendered events before the toggle start', async () => { + const moduleUrl = pathToFileURL( + path.resolve('scripts/performance/measure-vscode-graph-view.mjs'), + ).href; + const { getWebviewEventDeltaMs } = await import(moduleUrl); + + assert.equal(getWebviewEventDeltaMs({ + webviewEvents: [ + { name: 'graphStats.rendered', at: 5 }, + { name: 'graphScope.edgeVisibility.optimistic', at: 10 }, + { name: 'graphStats.rendered', at: 62.4 }, + ], + }), 52.4); +}); + test('VS Code graph view runner summarizes startup webview stage durations', async () => { const moduleUrl = pathToFileURL( path.resolve('scripts/performance/measure-vscode-graph-view.mjs'), From 00b7465dbaf740e019b15f9d46b62e6f20bb2432 Mon Sep 17 00:00:00 2001 From: joesobo Date: Mon, 22 Jun 2026 20:58:24 -0700 Subject: [PATCH 030/192] docs: record rejected startup bootstrap experiment Documents the two early APP_BOOTSTRAP_COMPLETE attempts and why they were reverted: bootstrap still reached the browser after graph data at 1662.6ms/1682.2ms, with first stats around 2135ms/2140ms after document start. Next startup work should instrument webview message delivery and cached timeline replay instead of committing a ready-handler reorder with no measured browser-side effect. --- docs/performance/codegraphy-monorepo.md | 23 +++++++++++++++++++ .../2026-06-22-codegraphy-performance.md | 1 + 2 files changed, 24 insertions(+) diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index 0e3f25cf3..b7d03d155 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -434,6 +434,23 @@ VS Code graph view benchmark: state was hiding the graph. - Imports toggle wall-clock latency was `202ms` median, `220ms` p95; in-webview latency was `53ms` median, `56ms` p95. +- Rejected early `APP_BOOTSTRAP_COMPLETE` experiments: + - Moving bootstrap ahead of graph loading but still after cached timeline + replay did not move the browser event stream. Graph data still arrived at + `171.5ms`, bootstrap still arrived later at `1662.6ms`, and first stats + rendered at `2135.4ms` after the usable document started. Imports toggle + was `208ms` median, with one noisy Playwright p95 outlier; in-webview + latency stayed `52ms` median. + - Moving bootstrap ahead of cached timeline replay also did not move the + browser event stream. Graph data arrived at `170.0ms`, bootstrap still + arrived later at `1682.2ms`, and first stats rendered at `2140.1ms` after + the usable document started. Imports toggle was `199ms` median, `206ms` + p95; in-webview latency was `57ms` median, `60ms` p95. + - Both production variants were reverted. The result suggests that the + current first-display delay is not fixed by simply reordering + `APP_BOOTSTRAP_COMPLETE` in the ready handler; the bridge/timeline replay + path needs better delivery/phase instrumentation before changing startup + semantics. Interpretation: @@ -515,6 +532,12 @@ Interpretation: app is allowed to display it. This removes hidden pre-bootstrap work, while the latest first-load wall clock remains dominated by VS Code webview frame readiness outside the measured CodeGraphy data path. +- Sending `APP_BOOTSTRAP_COMPLETE` earlier in the ready handler is not a + sufficient startup-display fix. In real Extension Development Host runs, the + browser still observed bootstrap after the graph replay/load messages, so the + next startup iteration should instrument webview message delivery and cached + timeline replay instead of committing a source reorder that does not change + visible timing. Full test baseline: diff --git a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md index 963a83762..4b3d15606 100644 --- a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md +++ b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md @@ -301,6 +301,7 @@ Added frame lifecycle markers and startup-ready partial metrics: early webview f Skipped duplicate graph payloads before bootstrap completion: focused test locks the loading-state behavior; latest startup event stream had one GRAPH_DATA_UPDATED payload and no repeated 74-filter derive before bootstrap, with stats rendered at 597.9ms after the usable document started and Imports toggle at 213ms median / 267ms p95. Rejected direct Graph View focus before container fallback: first graph readiness stayed frame-readiness dominated at 39359ms, with webview.html assigned at 50ms and the acceptance-ready frame bucket at 37734ms, so the command-path change was reverted. Deferred visible graph derivation while startup loading hides the graph, skipped unchanged post-load filter pattern replay, and fixed harness in-webview delta pairing: latest startup stream has no 22304-node derive before APP_BOOTSTRAP_COMPLETE, bootstrap at 512.8ms, first real 74-filter derive at 696.6ms for 183.8ms, stats at 892.1ms after document start, Imports toggle at 202ms median / 220ms p95 wall-clock and 53ms median / 56ms p95 in-webview. +Rejected early APP_BOOTSTRAP_COMPLETE reordering: moving bootstrap before graph load and then before cached timeline replay did not move the real browser event stream; bootstrap still arrived after graph data at 1662.6ms/1682.2ms and first stats stayed around 2135ms/2140ms after document start, so the production variants were reverted and the next startup work should instrument message delivery and cached timeline replay. ``` ## Task 5: Keep The PR Reviewable From 5b47ae3780c3a781378b9cce2e48a9df65adbcf4 Mon Sep 17 00:00:00 2001 From: joesobo Date: Mon, 22 Jun 2026 21:03:56 -0700 Subject: [PATCH 031/192] perf: trace webview message delivery --- docs/performance/codegraphy-monorepo.md | 18 ++++++++++++++ .../2026-06-22-codegraphy-performance.md | 1 + .../src/webview/app/shell/messageListener.ts | 3 +++ .../webview/app/shell/messageListener.test.ts | 24 +++++++++++++++++++ 4 files changed, 46 insertions(+) diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index b7d03d155..f4eb82684 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -451,6 +451,20 @@ VS Code graph view benchmark: `APP_BOOTSTRAP_COMPLETE` in the ready handler; the bridge/timeline replay path needs better delivery/phase instrumentation before changing startup semantics. +- After adding generic webview message-delivery tracing: + - VS Code launch: `not captured` in the latest partial report. + - Open Graph View to first rendered graph stats: `41989ms`. + - Initial rendered stats: `2240` nodes, `5331` connections. + - First-ready phases: command/open `1723ms`, acceptance-ready frame + `40244ms`, stats wait after frame discovery `14ms`. + - The browser received the first `GRAPH_DATA_UPDATED` at `108.7ms`, replayed + cached/settings messages around `397ms`-`412ms`, received a duplicate + `GRAPH_DATA_UPDATED` at `500.7ms`, skipped it at `510.2ms`, and only then + received `APP_BOOTSTRAP_COMPLETE` at `510.9ms`. + - The first real `22304` node / `74`-filter visible-graph derive started at + `694.3ms`, took `183.3ms`, and first stats rendered after that. + - Imports toggle wall-clock latency was `201ms` median, `214ms` p95; in-webview + optimistic-to-rendered latency was `53ms` median, `59ms` p95. Interpretation: @@ -538,6 +552,10 @@ Interpretation: next startup iteration should instrument webview message delivery and cached timeline replay instead of committing a source reorder that does not change visible timing. +- Message-delivery tracing confirms the browser currently sees bootstrap only + after graph data and the duplicate graph replay. The next startup hypothesis + should follow the extension-host post sequence and cached timeline bridge, + because the webview listener is receiving messages in that late order. Full test baseline: diff --git a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md index 4b3d15606..0ea3b1af6 100644 --- a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md +++ b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md @@ -302,6 +302,7 @@ Skipped duplicate graph payloads before bootstrap completion: focused test locks Rejected direct Graph View focus before container fallback: first graph readiness stayed frame-readiness dominated at 39359ms, with webview.html assigned at 50ms and the acceptance-ready frame bucket at 37734ms, so the command-path change was reverted. Deferred visible graph derivation while startup loading hides the graph, skipped unchanged post-load filter pattern replay, and fixed harness in-webview delta pairing: latest startup stream has no 22304-node derive before APP_BOOTSTRAP_COMPLETE, bootstrap at 512.8ms, first real 74-filter derive at 696.6ms for 183.8ms, stats at 892.1ms after document start, Imports toggle at 202ms median / 220ms p95 wall-clock and 53ms median / 56ms p95 in-webview. Rejected early APP_BOOTSTRAP_COMPLETE reordering: moving bootstrap before graph load and then before cached timeline replay did not move the real browser event stream; bootstrap still arrived after graph data at 1662.6ms/1682.2ms and first stats stayed around 2135ms/2140ms after document start, so the production variants were reverted and the next startup work should instrument message delivery and cached timeline replay. +Added generic webview message-delivery tracing: browser-side order is now explicit, with GRAPH_DATA_UPDATED at 108.7ms, cached/settings messages around 397ms-412ms, duplicate GRAPH_DATA_UPDATED at 500.7ms, duplicate skip at 510.2ms, and APP_BOOTSTRAP_COMPLETE only after that at 510.9ms; latest Imports toggle is 201ms median / 214ms p95 wall-clock and 53ms median / 59ms p95 in-webview. ``` ## Task 5: Keep The PR Reviewable diff --git a/packages/extension/src/webview/app/shell/messageListener.ts b/packages/extension/src/webview/app/shell/messageListener.ts index 28f64cb48..a8963cf25 100644 --- a/packages/extension/src/webview/app/shell/messageListener.ts +++ b/packages/extension/src/webview/app/shell/messageListener.ts @@ -11,6 +11,7 @@ import { handlePluginInjectMessage } from './messageListener/pluginInjection'; import { removeDisabledPluginRegistrations } from './messageListener/pluginRegistrations'; import { postWebviewReadyOnce, resetWebviewReadyPosted } from './messageListener/ready'; import { handleCssSnippetsUpdatedMessage } from './messageListener/cssSnippets'; +import { recordWebviewPerformanceEvent } from '../../performance/marks'; export interface InjectAssetsParams { pluginId: string; @@ -63,6 +64,8 @@ export function createMessageHandler( return; } + recordWebviewPerformanceEvent('extensionMessage.received', { type: raw.type }); + if (handlePluginInjectMessage(raw, injectPluginAssets)) { return; } diff --git a/packages/extension/tests/webview/app/shell/messageListener.test.ts b/packages/extension/tests/webview/app/shell/messageListener.test.ts index 292b8742d..80e8176aa 100644 --- a/packages/extension/tests/webview/app/shell/messageListener.test.ts +++ b/packages/extension/tests/webview/app/shell/messageListener.test.ts @@ -19,6 +19,7 @@ describe('app message listener', () => { afterEach(() => { vi.restoreAllMocks(); + window.__codegraphyPerformance = undefined; }); it('ignores invalid window messages', () => { @@ -254,6 +255,29 @@ describe('app message listener', () => { expect(pluginHost.deliverMessage).not.toHaveBeenCalled(); }); + it('records inbound extension messages for performance traces', () => { + window.__codegraphyPerformance = { enabled: true, events: [] }; + const injectPluginAssets = vi.fn<(_params: InjectAssetsParams) => Promise>().mockResolvedValue(); + const pluginHost = { deliverMessage: vi.fn() } as unknown as WebviewPluginHost; + const handleExtensionMessage = vi.fn(); + vi.spyOn(graphStore, 'getState').mockReturnValue({ + handleExtensionMessage, + } as unknown as ReturnType); + + const handler = createMessageHandler(injectPluginAssets, pluginHost); + const message = { type: 'APP_BOOTSTRAP_COMPLETE', payload: null }; + + handler({ data: message } as MessageEvent); + + expect(handleExtensionMessage).toHaveBeenCalledWith(message); + expect(window.__codegraphyPerformance.events).toEqual([ + expect.objectContaining({ + name: 'extensionMessage.received', + detail: { type: 'APP_BOOTSTRAP_COMPLETE' }, + }), + ]); + }); + it('registers the window listener and posts WEBVIEW_READY', () => { const injectPluginAssets = vi.fn<(_params: InjectAssetsParams) => Promise>().mockResolvedValue(); const pluginHost = { deliverMessage: vi.fn() } as unknown as WebviewPluginHost; From 611ffd0dc7f20790d8215a5de27ab04269fb05a2 Mon Sep 17 00:00:00 2001 From: joesobo Date: Mon, 22 Jun 2026 21:17:00 -0700 Subject: [PATCH 032/192] perf: coalesce graph index progress messages --- .changeset/coalesce-graph-index-progress.md | 5 ++ docs/performance/codegraphy-monorepo.md | 24 +++++++ .../2026-06-22-codegraphy-performance.md | 1 + .../graphView/analysis/execution/progress.ts | 39 +++++++++++- .../extension/graphView/provider/refresh.ts | 7 ++- .../graphView/provider/webview/messages.ts | 18 +++++- .../analysis/execution/progress.test.ts | 63 ++++++++++++++++++- .../provider/webview/messages.test.ts | 7 +++ 8 files changed, 157 insertions(+), 7 deletions(-) create mode 100644 .changeset/coalesce-graph-index-progress.md diff --git a/.changeset/coalesce-graph-index-progress.md b/.changeset/coalesce-graph-index-progress.md new file mode 100644 index 000000000..61818fb21 --- /dev/null +++ b/.changeset/coalesce-graph-index-progress.md @@ -0,0 +1,5 @@ +--- +"@codegraphy-dev/extension": patch +--- + +Reduce graph index progress message traffic during large workspace startup. diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index f4eb82684..b10192285 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -465,6 +465,25 @@ VS Code graph view benchmark: `694.3ms`, took `183.3ms`, and first stats rendered after that. - Imports toggle wall-clock latency was `201ms` median, `214ms` p95; in-webview optimistic-to-rendered latency was `53ms` median, `59ms` p95. +- After adding extension-host outbound message tracing and coalescing dense + graph index progress: + - Before coalescing, the host sent `7844` `GRAPH_INDEX_PROGRESS` messages in + one startup run. The same run hit the webview trace limit with repeated + settings/control messages before graph data. + - After coalescing progress to deterministic phase buckets, host + `GRAPH_INDEX_PROGRESS` sends dropped to `51`. + - A 5-sample interaction run preserved startup metrics but timed out during + interaction sampling, so its startup evidence was kept as partial data: + VS Code launch `1099ms`, first graph ready `46160ms`, and first-ready + phases command/open `1673ms`, frame wait `44442ms`, stats wait `30ms`. + - A shorter 2-sample interaction run completed: VS Code launch `796ms`, + first graph ready `46329ms`, command/open `1680ms`, frame wait `44601ms`, + stats wait `32ms`. + - The completed run kept host `GRAPH_INDEX_PROGRESS` sends at `51`, with one + inbound progress marker in the webview startup trace. + - Imports toggle wall-clock latency was `357ms` median, `508ms` p95 across + 2 samples; in-webview optimistic-to-rendered latency stayed `55ms` median, + `57ms` p95. Interpretation: @@ -556,6 +575,11 @@ Interpretation: after graph data and the duplicate graph replay. The next startup hypothesis should follow the extension-host post sequence and cached timeline bridge, because the webview listener is receiving messages in that late order. +- Extension-host send tracing found a real message-volume bottleneck: + uncoalesced graph index progress produced thousands of outbound messages on + startup. Deterministic progress coalescing cuts that to dozens while keeping + first/final and phase-boundary progress visible. The remaining repeated + settings/control sends are now the next message-volume target. Full test baseline: diff --git a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md index 0ea3b1af6..00711a3b3 100644 --- a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md +++ b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md @@ -303,6 +303,7 @@ Rejected direct Graph View focus before container fallback: first graph readines Deferred visible graph derivation while startup loading hides the graph, skipped unchanged post-load filter pattern replay, and fixed harness in-webview delta pairing: latest startup stream has no 22304-node derive before APP_BOOTSTRAP_COMPLETE, bootstrap at 512.8ms, first real 74-filter derive at 696.6ms for 183.8ms, stats at 892.1ms after document start, Imports toggle at 202ms median / 220ms p95 wall-clock and 53ms median / 56ms p95 in-webview. Rejected early APP_BOOTSTRAP_COMPLETE reordering: moving bootstrap before graph load and then before cached timeline replay did not move the real browser event stream; bootstrap still arrived after graph data at 1662.6ms/1682.2ms and first stats stayed around 2135ms/2140ms after document start, so the production variants were reverted and the next startup work should instrument message delivery and cached timeline replay. Added generic webview message-delivery tracing: browser-side order is now explicit, with GRAPH_DATA_UPDATED at 108.7ms, cached/settings messages around 397ms-412ms, duplicate GRAPH_DATA_UPDATED at 500.7ms, duplicate skip at 510.2ms, and APP_BOOTSTRAP_COMPLETE only after that at 510.9ms; latest Imports toggle is 201ms median / 214ms p95 wall-clock and 53ms median / 59ms p95 in-webview. +Added extension-host outbound message tracing and coalesced dense graph index progress: host GRAPH_INDEX_PROGRESS sends dropped from 7844 to 51 in startup traces; a completed 2-sample interaction run measured Imports at 357ms median / 508ms p95 wall-clock and 55ms median / 57ms p95 in-webview, while first-ready wall clock stayed dominated by the frame-readiness bucket. ``` ## Task 5: Keep The PR Reviewable diff --git a/packages/extension/src/extension/graphView/analysis/execution/progress.ts b/packages/extension/src/extension/graphView/analysis/execution/progress.ts index d6641e6cd..d9b2f030b 100644 --- a/packages/extension/src/extension/graphView/analysis/execution/progress.ts +++ b/packages/extension/src/extension/graphView/analysis/execution/progress.ts @@ -11,6 +11,7 @@ const ANALYSIS_PHASE_BY_MODE: Record = { refresh: 'Refreshing Index', incremental: 'Applying Changes', }; +const MAX_PROGRESS_BUCKETS_PER_PHASE = 20; function supportsInitialProgress(mode: GraphViewAnalysisMode): boolean { return mode === 'index' || mode === 'refresh' || mode === 'incremental'; @@ -21,15 +22,51 @@ export function createGraphViewAnalysisProgressForwarder( handlers: GraphViewAnalysisExecutionHandlers, ): (progress: GraphViewIndexingProgress) => void { const phase = ANALYSIS_PHASE_BY_MODE[mode]; + const sendProgress = createGraphViewIndexProgressCoalescer((progress) => { + handlers.sendIndexProgress?.(progress); + }); return (progress) => { - handlers.sendIndexProgress?.({ + sendProgress({ ...progress, phase: progress.phase || phase, }); }; } +export function createGraphViewIndexProgressCoalescer( + sendProgress: (progress: TProgress) => void, +): (progress: TProgress) => void { + let lastPhase: string | undefined; + let lastTotal: number | undefined; + let lastBucket: number | undefined; + + return (progress) => { + const bucket = getGraphViewIndexProgressBucket(progress); + if ( + progress.phase === lastPhase + && progress.total === lastTotal + && bucket === lastBucket + ) { + return; + } + + lastPhase = progress.phase; + lastTotal = progress.total; + lastBucket = bucket; + sendProgress(progress); + }; +} + +function getGraphViewIndexProgressBucket(progress: GraphViewIndexingProgress): number { + if (progress.total <= MAX_PROGRESS_BUCKETS_PER_PHASE) { + return progress.current; + } + + const clampedCurrent = Math.max(0, Math.min(progress.current, progress.total)); + return Math.floor((clampedCurrent * MAX_PROGRESS_BUCKETS_PER_PHASE) / progress.total); +} + export function sendInitialGraphViewAnalysisProgress( mode: GraphViewAnalysisMode, handlers: GraphViewAnalysisExecutionHandlers, diff --git a/packages/extension/src/extension/graphView/provider/refresh.ts b/packages/extension/src/extension/graphView/provider/refresh.ts index 122be2f20..3c553e2d1 100644 --- a/packages/extension/src/extension/graphView/provider/refresh.ts +++ b/packages/extension/src/extension/graphView/provider/refresh.ts @@ -1,6 +1,7 @@ import type { IGraphData } from '../../../shared/graph/contracts'; import type { ExtensionToWebviewMessage } from '../../../shared/protocol/extensionToWebview'; import { getCodeGraphyConfiguration } from '../../repoSettings/current'; +import { createGraphViewIndexProgressCoalescer } from '../analysis/execution/progress'; import { rebuildGraphViewData, smartRebuildGraphView } from '../view/rebuild'; import { createRebuildSenders } from './refresh/rebuild'; import { runChangedFileRefresh, runIndexRefresh, runPrimaryRefresh, sendRefreshState } from './refresh/run'; @@ -135,15 +136,15 @@ async function runScopedRefreshRequest( lifecycle.setController(controller); const requestId = ++source._analysisRequestId; - const forwardProgress = (progress: GraphViewScopedRefreshProgress): void => { + const sendProgress = createGraphViewIndexProgressCoalescer((progress: GraphViewScopedRefreshProgress) => { if (isScopedRefreshStale(source, controller.signal, requestId)) { return; } source._sendMessage({ type: 'GRAPH_INDEX_PROGRESS', payload: progress }); - }; + }); try { - const graphData = await runRefresh(controller.signal, forwardProgress); + const graphData = await runRefresh(controller.signal, sendProgress); if (isScopedRefreshStale(source, controller.signal, requestId)) { return undefined; } diff --git a/packages/extension/src/extension/graphView/provider/webview/messages.ts b/packages/extension/src/extension/graphView/provider/webview/messages.ts index 016267a13..ed17cdf30 100644 --- a/packages/extension/src/extension/graphView/provider/webview/messages.ts +++ b/packages/extension/src/extension/graphView/provider/webview/messages.ts @@ -8,14 +8,28 @@ export interface GraphViewProviderWebviewMessageSource extends GraphViewProvider export function sendGraphViewProviderWebviewMessage( source: GraphViewProviderWebviewMessageSource, - dependencies: Pick, + dependencies: Pick, message: unknown, ): void { + const sidebarViews = getGraphViewProviderSidebarViews(source); + dependencies.recordPerformanceEvent?.('graphWebview.message.send', { + panelCount: source._panels.length, + sidebarViewCount: sidebarViews.length, + type: getGraphViewProviderWebviewMessageType(message), + }); dependencies.sendWebviewMessage( - getGraphViewProviderSidebarViews(source), + sidebarViews, source._panels, message, ); source._notifyExtensionMessage(message); } +function getGraphViewProviderWebviewMessageType(message: unknown): string | undefined { + if (!message || typeof message !== 'object') { + return undefined; + } + + const type = (message as { type?: unknown }).type; + return typeof type === 'string' ? type : undefined; +} diff --git a/packages/extension/tests/extension/graphView/analysis/execution/progress.test.ts b/packages/extension/tests/extension/graphView/analysis/execution/progress.test.ts index bee6d813a..98a02329b 100644 --- a/packages/extension/tests/extension/graphView/analysis/execution/progress.test.ts +++ b/packages/extension/tests/extension/graphView/analysis/execution/progress.test.ts @@ -8,8 +8,9 @@ import { createExecutionHandlers } from './fixtures'; describe('graph view analysis execution progress', () => { it('preserves analyzer progress phase labels', () => { const { handlers } = createExecutionHandlers(); + const forwardProgress = createGraphViewAnalysisProgressForwarder('refresh', handlers); - createGraphViewAnalysisProgressForwarder('refresh', handlers)({ + forwardProgress({ phase: 'Saving Graph Cache', current: 2, total: 5, @@ -22,6 +23,66 @@ describe('graph view analysis execution progress', () => { }); }); + it('coalesces dense progress updates while preserving the first and final states', () => { + const { handlers } = createExecutionHandlers(); + const forwardProgress = createGraphViewAnalysisProgressForwarder('refresh', handlers); + + for (let current = 1; current <= 100; current += 1) { + forwardProgress({ + phase: 'Refreshing Index', + current, + total: 100, + }); + } + + expect(handlers.sendIndexProgress).toHaveBeenCalledTimes(21); + expect(handlers.sendIndexProgress).toHaveBeenNthCalledWith(1, { + phase: 'Refreshing Index', + current: 1, + total: 100, + }); + expect(handlers.sendIndexProgress).not.toHaveBeenCalledWith({ + phase: 'Refreshing Index', + current: 2, + total: 100, + }); + expect(handlers.sendIndexProgress).toHaveBeenLastCalledWith({ + phase: 'Refreshing Index', + current: 100, + total: 100, + }); + }); + + it('keeps every progress update for small totals', () => { + const { handlers } = createExecutionHandlers(); + const forwardProgress = createGraphViewAnalysisProgressForwarder('refresh', handlers); + + for (let current = 1; current <= 5; current += 1) { + forwardProgress({ + phase: 'Refreshing Index', + current, + total: 5, + }); + } + + expect(handlers.sendIndexProgress).toHaveBeenCalledTimes(5); + }); + + it('keeps phase changes even when dense progress stays in the same bucket', () => { + const { handlers } = createExecutionHandlers(); + const forwardProgress = createGraphViewAnalysisProgressForwarder('refresh', handlers); + + forwardProgress({ phase: 'Refreshing Index', current: 1, total: 100 }); + forwardProgress({ phase: 'Saving Graph Cache', current: 2, total: 100 }); + + expect(handlers.sendIndexProgress).toHaveBeenCalledTimes(2); + expect(handlers.sendIndexProgress).toHaveBeenLastCalledWith({ + phase: 'Saving Graph Cache', + current: 2, + total: 100, + }); + }); + it('falls back to the mode label when a progress update does not name its phase', () => { const { handlers } = createExecutionHandlers(); diff --git a/packages/extension/tests/extension/graphView/provider/webview/messages.test.ts b/packages/extension/tests/extension/graphView/provider/webview/messages.test.ts index 3648185f7..09024e103 100644 --- a/packages/extension/tests/extension/graphView/provider/webview/messages.test.ts +++ b/packages/extension/tests/extension/graphView/provider/webview/messages.test.ts @@ -8,6 +8,7 @@ describe('graphView/provider/webview/messages', () => { const timelineView = { webview: { postMessage: vi.fn(() => true) } } as unknown as vscode.WebviewView; const panel = { webview: { postMessage: vi.fn(() => true) } } as unknown as vscode.WebviewPanel; const notifyExtensionMessage = vi.fn(); + const recordPerformanceEvent = vi.fn(); const sendWebviewMessage = vi.fn(); sendGraphViewProviderWebviewMessage( @@ -18,6 +19,7 @@ describe('graphView/provider/webview/messages', () => { _notifyExtensionMessage: notifyExtensionMessage, }, { + recordPerformanceEvent, sendWebviewMessage, }, { type: 'PING' }, @@ -26,6 +28,11 @@ describe('graphView/provider/webview/messages', () => { expect(sendWebviewMessage).toHaveBeenCalledWith([graphView, timelineView], [panel], { type: 'PING', }); + expect(recordPerformanceEvent).toHaveBeenCalledWith('graphWebview.message.send', { + panelCount: 1, + sidebarViewCount: 2, + type: 'PING', + }); expect(notifyExtensionMessage).toHaveBeenCalledWith({ type: 'PING' }); }); }); From b0da0bf694aa3f5780ce3a4942e5b77aef753e43 Mon Sep 17 00:00:00 2001 From: joesobo Date: Mon, 22 Jun 2026 21:22:31 -0700 Subject: [PATCH 033/192] perf: skip duplicate ready replay during first analysis --- .changeset/skip-duplicate-ready-replay.md | 5 +++++ docs/performance/codegraphy-monorepo.md | 20 +++++++++++++++++++ .../2026-06-22-codegraphy-performance.md | 1 + .../graphView/webview/messages/ready.ts | 3 +-- .../webview/messages/listener.test.ts | 10 +++++----- 5 files changed, 32 insertions(+), 7 deletions(-) create mode 100644 .changeset/skip-duplicate-ready-replay.md diff --git a/.changeset/skip-duplicate-ready-replay.md b/.changeset/skip-duplicate-ready-replay.md new file mode 100644 index 000000000..78adbfe1a --- /dev/null +++ b/.changeset/skip-duplicate-ready-replay.md @@ -0,0 +1,5 @@ +--- +"@codegraphy-dev/extension": patch +--- + +Avoid replaying startup settings for duplicate webview ready messages while the first workspace graph is still loading. diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index b10192285..fb57668bf 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -484,6 +484,22 @@ VS Code graph view benchmark: - Imports toggle wall-clock latency was `357ms` median, `508ms` p95 across 2 samples; in-webview optimistic-to-rendered latency stayed `55ms` median, `57ms` p95. +- After skipping duplicate `WEBVIEW_READY` replays while first analysis is + already in flight: + - The early duplicate full settings replay around `518ms` disappeared from + the host send sequence. Startup now sends the first settings batch around + `190ms`, then continues to graph/index/bootstrap work without replaying + the same first-analysis settings batch. + - VS Code launch: `1085ms`. + - Open Graph View to first rendered graph stats: `46908ms`. + - First-ready phases: command/open `1570ms`, acceptance-ready frame + `45254ms`, stats wait after frame discovery `24ms`. + - Host `GRAPH_INDEX_PROGRESS` sends stayed at `51`. + - Aggregate settings/control sends are still high because later refresh and + plugin synchronization paths repeat them; this iteration only removed the + duplicate first-analysis ready replay. + - Imports toggle wall-clock latency was `231ms` median, `266ms` p95 across + 2 samples; in-webview latency was `50ms` median, `50ms` p95. Interpretation: @@ -580,6 +596,10 @@ Interpretation: startup. Deterministic progress coalescing cuts that to dozens while keeping first/final and phase-boundary progress visible. The remaining repeated settings/control sends are now the next message-volume target. +- Duplicate `WEBVIEW_READY` handling no longer resends the first-analysis + settings bundle while the original ready handler is still loading graph data. + Later repeated settings/control sends remain visible and need separate + ownership tracing before changing behavior. Full test baseline: diff --git a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md index 00711a3b3..3a329ae77 100644 --- a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md +++ b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md @@ -304,6 +304,7 @@ Deferred visible graph derivation while startup loading hides the graph, skipped Rejected early APP_BOOTSTRAP_COMPLETE reordering: moving bootstrap before graph load and then before cached timeline replay did not move the real browser event stream; bootstrap still arrived after graph data at 1662.6ms/1682.2ms and first stats stayed around 2135ms/2140ms after document start, so the production variants were reverted and the next startup work should instrument message delivery and cached timeline replay. Added generic webview message-delivery tracing: browser-side order is now explicit, with GRAPH_DATA_UPDATED at 108.7ms, cached/settings messages around 397ms-412ms, duplicate GRAPH_DATA_UPDATED at 500.7ms, duplicate skip at 510.2ms, and APP_BOOTSTRAP_COMPLETE only after that at 510.9ms; latest Imports toggle is 201ms median / 214ms p95 wall-clock and 53ms median / 59ms p95 in-webview. Added extension-host outbound message tracing and coalesced dense graph index progress: host GRAPH_INDEX_PROGRESS sends dropped from 7844 to 51 in startup traces; a completed 2-sample interaction run measured Imports at 357ms median / 508ms p95 wall-clock and 55ms median / 57ms p95 in-webview, while first-ready wall clock stayed dominated by the frame-readiness bucket. +Skipped duplicate WEBVIEW_READY replay while first analysis is already in flight: the early duplicate settings batch around 518ms disappeared from the host send sequence; latest 2-sample run measured Imports at 231ms median / 266ms p95 wall-clock and 50ms median / 50ms p95 in-webview, with remaining aggregate settings/control sends coming from later refresh/plugin sync paths. ``` ## Task 5: Keep The PR Reviewable diff --git a/packages/extension/src/extension/graphView/webview/messages/ready.ts b/packages/extension/src/extension/graphView/webview/messages/ready.ts index 91acdda62..2f96f6d40 100644 --- a/packages/extension/src/extension/graphView/webview/messages/ready.ts +++ b/packages/extension/src/extension/graphView/webview/messages/ready.ts @@ -177,12 +177,11 @@ export async function replayDuplicateWebviewReady( state: GraphViewReadyState, handlers: GraphViewReadyHandlers, ): Promise { - replayWebviewReadySettings(state, handlers); - if (shouldWaitForFirstWorkspaceGraph(state)) { return; } + replayWebviewReadySettings(state, handlers); replayWebviewReadyGraphBootstrap(handlers); } diff --git a/packages/extension/tests/extension/graphView/webview/messages/listener.test.ts b/packages/extension/tests/extension/graphView/webview/messages/listener.test.ts index 3f946257c..c5e40421d 100644 --- a/packages/extension/tests/extension/graphView/webview/messages/listener.test.ts +++ b/packages/extension/tests/extension/graphView/webview/messages/listener.test.ts @@ -201,7 +201,7 @@ describe('graph view webview message listener', () => { expect(context.setWebviewReadyNotified).toHaveBeenCalledWith(true); }); - it('replays settings but not empty bootstrap payloads for duplicate WEBVIEW_READY during first analysis', async () => { + it('does not replay settings or empty bootstrap payloads for duplicate WEBVIEW_READY during first analysis', async () => { let messageHandler: ((message: unknown) => Promise) | undefined; const webview = { onDidReceiveMessage: vi.fn((handler: (message: unknown) => Promise) => { @@ -249,10 +249,10 @@ describe('graph view webview message listener', () => { (message as { type?: string }).type === 'GRAPH_DATA_UPDATED' ), ).toHaveLength(0); - expect(context.loadGroupsAndFilterPatterns).toHaveBeenCalledTimes(2); - expect(context.loadDisabledRulesAndPlugins).toHaveBeenCalledTimes(2); - expect(context.sendSettings).toHaveBeenCalledTimes(2); - expect(context.sendPhysicsSettings).toHaveBeenCalledTimes(2); + expect(context.loadGroupsAndFilterPatterns).toHaveBeenCalledTimes(1); + expect(context.loadDisabledRulesAndPlugins).toHaveBeenCalledTimes(1); + expect(context.sendSettings).toHaveBeenCalledTimes(1); + expect(context.sendPhysicsSettings).toHaveBeenCalledTimes(1); expect(context.notifyWebviewReady).toHaveBeenCalledTimes(1); expect(context.setWebviewReadyNotified).toHaveBeenCalledWith(true); expect(context.setWebviewReadyNotified).toHaveBeenCalledTimes(1); From 530d3e767c16419d539e77e652554c19a29e3e21 Mon Sep 17 00:00:00 2001 From: joesobo Date: Mon, 22 Jun 2026 21:31:30 -0700 Subject: [PATCH 034/192] perf: trace refresh state send reasons --- docs/performance/codegraphy-monorepo.md | 17 +++++++++++++++++ .../plans/2026-06-22-codegraphy-performance.md | 1 + .../extension/graphView/provider/refresh.ts | 15 ++++++++------- .../graphView/provider/refresh/run.ts | 18 ++++++++++++++++-- .../graphView/provider/refresh/run.test.ts | 14 +++++++++++++- 5 files changed, 55 insertions(+), 10 deletions(-) diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index fb57668bf..23dad472f 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -500,6 +500,19 @@ VS Code graph view benchmark: duplicate first-analysis ready replay. - Imports toggle wall-clock latency was `231ms` median, `266ms` p95 across 2 samples; in-webview latency was `50ms` median, `50ms` p95. +- Refresh-state reason tracing: + - Command shape: + `pnpm exec tsx scripts/performance/measure-vscode-graph-view.mjs --workspace /Users/poleski/Desktop/Projects/CodeGraphyV4 --iterations 2 --warmup 0 --output reports/performance/vscode-graph-view-refresh-state-reasons-2026-06-22.json`. + - Open Graph View to first rendered graph stats: `48255ms`. + - Host send counts still showed repeated settings/control messages: + `SETTINGS_UPDATED` `24`, `PHYSICS_SETTINGS_UPDATED` `24`, + `DIRECTION_SETTINGS_UPDATED` `24`, `SHOW_LABELS_UPDATED` `24`, + and `GRAPH_CONTROLS_UPDATED` `47`. + - All measured `graphWebview.refreshState.send` markers were + `changedFiles`, with `22` sends in the run. + - The first remaining post-bootstrap settings/control replay now has an + owner: `refreshChangedFiles` sends the full settings/control bundle after + each changed-file refresh completes. Interpretation: @@ -600,6 +613,10 @@ Interpretation: settings bundle while the original ready handler is still loading graph data. Later repeated settings/control sends remain visible and need separate ownership tracing before changing behavior. +- Refresh-state reason tracing identified `refreshChangedFiles` as the owner + of the remaining repeated settings/control bundle. The next behavior change + should preserve changed-file graph analysis while avoiding redundant full + settings replay after indexed incremental refreshes. Full test baseline: diff --git a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md index 3a329ae77..3e908c470 100644 --- a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md +++ b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md @@ -305,6 +305,7 @@ Rejected early APP_BOOTSTRAP_COMPLETE reordering: moving bootstrap before graph Added generic webview message-delivery tracing: browser-side order is now explicit, with GRAPH_DATA_UPDATED at 108.7ms, cached/settings messages around 397ms-412ms, duplicate GRAPH_DATA_UPDATED at 500.7ms, duplicate skip at 510.2ms, and APP_BOOTSTRAP_COMPLETE only after that at 510.9ms; latest Imports toggle is 201ms median / 214ms p95 wall-clock and 53ms median / 59ms p95 in-webview. Added extension-host outbound message tracing and coalesced dense graph index progress: host GRAPH_INDEX_PROGRESS sends dropped from 7844 to 51 in startup traces; a completed 2-sample interaction run measured Imports at 357ms median / 508ms p95 wall-clock and 55ms median / 57ms p95 in-webview, while first-ready wall clock stayed dominated by the frame-readiness bucket. Skipped duplicate WEBVIEW_READY replay while first analysis is already in flight: the early duplicate settings batch around 518ms disappeared from the host send sequence; latest 2-sample run measured Imports at 231ms median / 266ms p95 wall-clock and 50ms median / 50ms p95 in-webview, with remaining aggregate settings/control sends coming from later refresh/plugin sync paths. +Traced refresh-state send reasons: latest run recorded 22 full refresh-state replays, all tagged changedFiles, while aggregate settings/control sends remained high at SETTINGS_UPDATED 24 and GRAPH_CONTROLS_UPDATED 47; next change should preserve changed-file graph analysis while avoiding redundant indexed-incremental settings replay. ``` ## Task 5: Keep The PR Reviewable diff --git a/packages/extension/src/extension/graphView/provider/refresh.ts b/packages/extension/src/extension/graphView/provider/refresh.ts index 3c553e2d1..43037f19e 100644 --- a/packages/extension/src/extension/graphView/provider/refresh.ts +++ b/packages/extension/src/extension/graphView/provider/refresh.ts @@ -221,7 +221,7 @@ function createRefreshMethod( prepareRefreshInputs(source); await runPrimaryRefresh(source); - sendRefreshState(source); + sendRefreshState(source, 'refresh'); }; } @@ -239,7 +239,7 @@ function createRefreshIndexMethod( state.indexRefreshPromise = (async (): Promise => { prepareRefreshInputs(source); await runIndexRefresh(source); - sendRefreshState(source); + sendRefreshState(source, 'refreshIndex'); })(); try { @@ -283,7 +283,7 @@ function createRefreshAnalysisScopeMethod( ), scopedRefreshLifecycle, ); - publishGraphDataIfPresent(source, graphData); + publishGraphDataIfPresent(source, graphData, 'analysisScope'); }; } @@ -313,7 +313,7 @@ function createRefreshGitignoreMetadataMethod( ), scopedRefreshLifecycle, ); - publishGraphDataIfPresent(source, graphData); + publishGraphDataIfPresent(source, graphData, 'gitignoreMetadata'); }; } @@ -345,20 +345,21 @@ function createRefreshPluginFilesMethod( ), scopedRefreshLifecycle, ); - publishGraphDataIfPresent(source, graphData); + publishGraphDataIfPresent(source, graphData, 'pluginFiles'); }; } function publishGraphDataIfPresent( source: GraphViewProviderRefreshMethodsSource, graphData: IGraphData | undefined, + reason: 'analysisScope' | 'gitignoreMetadata' | 'pluginFiles', ): void { if (!graphData) { return; } publishScopedRefreshGraphData(source, graphData); - sendRefreshState(source); + sendRefreshState(source, reason); } function createRefreshChangedFilesMethod( @@ -376,7 +377,7 @@ function createRefreshChangedFilesMethod( prepareRefreshInputs(source); await runChangedFileRefresh(source, filePaths); - sendRefreshState(source); + sendRefreshState(source, 'changedFiles'); }; } diff --git a/packages/extension/src/extension/graphView/provider/refresh/run.ts b/packages/extension/src/extension/graphView/provider/refresh/run.ts index d77058ee9..f02400cbf 100644 --- a/packages/extension/src/extension/graphView/provider/refresh/run.ts +++ b/packages/extension/src/extension/graphView/provider/refresh/run.ts @@ -1,6 +1,20 @@ import type { GraphViewProviderRefreshMethodsSource } from '../refresh'; - -export function sendRefreshState(source: GraphViewProviderRefreshMethodsSource): void { +import { recordExtensionPerformanceEvent } from '../../../performance/marks'; + +export type RefreshStateReason = + | 'analysisScope' + | 'changedFiles' + | 'direct' + | 'gitignoreMetadata' + | 'pluginFiles' + | 'refresh' + | 'refreshIndex'; + +export function sendRefreshState( + source: GraphViewProviderRefreshMethodsSource, + reason: RefreshStateReason = 'direct', +): void { + recordExtensionPerformanceEvent('graphWebview.refreshState.send', { reason }); source._sendAllSettings(); source._sendGraphControls?.(); } diff --git a/packages/extension/tests/extension/graphView/provider/refresh/run.test.ts b/packages/extension/tests/extension/graphView/provider/refresh/run.test.ts index a8731a7c3..f0ada7820 100644 --- a/packages/extension/tests/extension/graphView/provider/refresh/run.test.ts +++ b/packages/extension/tests/extension/graphView/provider/refresh/run.test.ts @@ -1,4 +1,13 @@ import { describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + recordExtensionPerformanceEvent: vi.fn(), +})); + +vi.mock('../../../../../src/extension/performance/marks', () => ({ + recordExtensionPerformanceEvent: mocks.recordExtensionPerformanceEvent, +})); + import { runChangedFileRefresh, runIndexRefresh, @@ -24,9 +33,12 @@ describe('graphView/provider/refresh/run', () => { it('sends refresh state even when graph controls are unavailable', () => { const source = createSource({ _sendGraphControls: undefined }); - expect(() => sendRefreshState(source as never)).not.toThrow(); + expect(() => sendRefreshState(source as never, 'refresh')).not.toThrow(); expect(source._sendAllSettings).toHaveBeenCalledOnce(); expect(source._sendFavorites).not.toHaveBeenCalled(); + expect(mocks.recordExtensionPerformanceEvent).toHaveBeenCalledWith('graphWebview.refreshState.send', { + reason: 'refresh', + }); }); it('falls back to full analysis when no primary load helper is available', async () => { From 82ce2e0b202d1fef20861463bf25f8778116c97d Mon Sep 17 00:00:00 2001 From: joesobo Date: Mon, 22 Jun 2026 21:38:17 -0700 Subject: [PATCH 035/192] perf: skip incremental refresh settings replay --- ...kip-incremental-refresh-settings-replay.md | 5 +++++ docs/performance/codegraphy-monorepo.md | 19 +++++++++++++++++++ .../2026-06-22-codegraphy-performance.md | 1 + .../extension/graphView/provider/refresh.ts | 6 ++++-- .../graphView/provider/refresh/run.ts | 8 +++++--- .../graphView/provider/refresh.test.ts | 17 +++++++++++++++++ 6 files changed, 51 insertions(+), 5 deletions(-) create mode 100644 .changeset/skip-incremental-refresh-settings-replay.md diff --git a/.changeset/skip-incremental-refresh-settings-replay.md b/.changeset/skip-incremental-refresh-settings-replay.md new file mode 100644 index 000000000..bd0e4665a --- /dev/null +++ b/.changeset/skip-incremental-refresh-settings-replay.md @@ -0,0 +1,5 @@ +--- +"@codegraphy-dev/extension": patch +--- + +Reduce repeated Graph View settings updates after indexed file-change refreshes. diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index 23dad472f..6da286674 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -513,6 +513,21 @@ VS Code graph view benchmark: - The first remaining post-bootstrap settings/control replay now has an owner: `refreshChangedFiles` sends the full settings/control bundle after each changed-file refresh completes. +- After skipping redundant full settings replay for indexed incremental + changed-file refreshes: + - The same reason-marker run shape recorded no + `graphWebview.refreshState.send` events. + - Host `SETTINGS_UPDATED` sends dropped from `24` to `2`. + - Host `PHYSICS_SETTINGS_UPDATED` sends dropped from `24` to `2`. + - Host `DIRECTION_SETTINGS_UPDATED` sends dropped from `24` to `2`. + - Host `SHOW_LABELS_UPDATED` sends dropped from `24` to `2`. + - Host `GRAPH_CONTROLS_UPDATED` sends dropped from `47` to `5`. + - Host `GRAPH_INDEX_PROGRESS` stayed coalesced at `51`. + - First graph readiness remained dominated by the VS Code frame-readiness + bucket: `38844ms`, split into `1715ms` command/open, `37115ms` frame wait, + and `10ms` stats wait. + - A one-sample Imports Graph Scope sanity check measured `191ms` wall-clock + and `49ms` in-webview optimistic-to-rendered latency. Interpretation: @@ -617,6 +632,10 @@ Interpretation: of the remaining repeated settings/control bundle. The next behavior change should preserve changed-file graph analysis while avoiding redundant full settings replay after indexed incremental refreshes. +- Indexed incremental changed-file refreshes now keep their graph-analysis path + but avoid the trailing full settings/control replay. This removes the + measured `changedFiles` refresh-state burst and cuts repeated startup + settings/control messages back to the expected ready/bootstrap sends. Full test baseline: diff --git a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md index 3e908c470..e654c6c60 100644 --- a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md +++ b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md @@ -306,6 +306,7 @@ Added generic webview message-delivery tracing: browser-side order is now explic Added extension-host outbound message tracing and coalesced dense graph index progress: host GRAPH_INDEX_PROGRESS sends dropped from 7844 to 51 in startup traces; a completed 2-sample interaction run measured Imports at 357ms median / 508ms p95 wall-clock and 55ms median / 57ms p95 in-webview, while first-ready wall clock stayed dominated by the frame-readiness bucket. Skipped duplicate WEBVIEW_READY replay while first analysis is already in flight: the early duplicate settings batch around 518ms disappeared from the host send sequence; latest 2-sample run measured Imports at 231ms median / 266ms p95 wall-clock and 50ms median / 50ms p95 in-webview, with remaining aggregate settings/control sends coming from later refresh/plugin sync paths. Traced refresh-state send reasons: latest run recorded 22 full refresh-state replays, all tagged changedFiles, while aggregate settings/control sends remained high at SETTINGS_UPDATED 24 and GRAPH_CONTROLS_UPDATED 47; next change should preserve changed-file graph analysis while avoiding redundant indexed-incremental settings replay. +Skipped redundant full settings replay for indexed incremental changed-file refreshes: focused provider test failed red on the old `_sendAllSettings` call, then passed after the refresh runner reported `incremental` vs fallback modes; latest VS Code trace has 0 refresh-state markers, SETTINGS_UPDATED 24 -> 2, GRAPH_CONTROLS_UPDATED 47 -> 5, and a one-sample Imports sanity check at 191ms wall-clock / 49ms in-webview. ``` ## Task 5: Keep The PR Reviewable diff --git a/packages/extension/src/extension/graphView/provider/refresh.ts b/packages/extension/src/extension/graphView/provider/refresh.ts index 43037f19e..185dbba90 100644 --- a/packages/extension/src/extension/graphView/provider/refresh.ts +++ b/packages/extension/src/extension/graphView/provider/refresh.ts @@ -376,8 +376,10 @@ function createRefreshChangedFilesMethod( } prepareRefreshInputs(source); - await runChangedFileRefresh(source, filePaths); - sendRefreshState(source, 'changedFiles'); + const refreshMode = await runChangedFileRefresh(source, filePaths); + if (refreshMode !== 'incremental') { + sendRefreshState(source, 'changedFiles'); + } }; } diff --git a/packages/extension/src/extension/graphView/provider/refresh/run.ts b/packages/extension/src/extension/graphView/provider/refresh/run.ts index f02400cbf..345a8dcbe 100644 --- a/packages/extension/src/extension/graphView/provider/refresh/run.ts +++ b/packages/extension/src/extension/graphView/provider/refresh/run.ts @@ -9,6 +9,7 @@ export type RefreshStateReason = | 'pluginFiles' | 'refresh' | 'refreshIndex'; +export type ChangedFileRefreshMode = 'analysis' | 'incremental' | 'primary'; export function sendRefreshState( source: GraphViewProviderRefreshMethodsSource, @@ -40,16 +41,17 @@ export async function runIndexRefresh(source: GraphViewProviderRefreshMethodsSou export async function runChangedFileRefresh( source: GraphViewProviderRefreshMethodsSource, filePaths: readonly string[], -): Promise { +): Promise { if (!source._analyzer?.hasIndex()) { await runPrimaryRefresh(source); - return; + return 'primary'; } if (source._incrementalAnalyzeAndSendData) { await source._incrementalAnalyzeAndSendData(filePaths); - return; + return 'incremental'; } await source._analyzeAndSendData(); + return 'analysis'; } diff --git a/packages/extension/tests/extension/graphView/provider/refresh.test.ts b/packages/extension/tests/extension/graphView/provider/refresh.test.ts index dabeebbe4..de6c30a1c 100644 --- a/packages/extension/tests/extension/graphView/provider/refresh.test.ts +++ b/packages/extension/tests/extension/graphView/provider/refresh.test.ts @@ -150,4 +150,21 @@ describe('graphView/provider/refresh', () => { }); + describe('refreshChangedFiles', () => { + it('uses indexed incremental analysis without replaying full settings state', async () => { + const source = createSource(); + const methods = createGraphViewProviderRefreshMethods(source as never, { + getShowOrphans: vi.fn(() => true), + rebuildGraphData: vi.fn(), + smartRebuildGraphData: vi.fn(), + }); + + await methods.refreshChangedFiles(['src/example.ts']); + + expect(source._incrementalAnalyzeAndSendData).toHaveBeenCalledWith(['src/example.ts']); + expect(source._sendAllSettings).not.toHaveBeenCalled(); + expect(source._sendGraphControls).not.toHaveBeenCalled(); + }); + }); + }); From 454e338cb4cc67616e3b42ac7ded9e53d80bde48 Mon Sep 17 00:00:00 2001 From: joesobo Date: Mon, 22 Jun 2026 21:47:33 -0700 Subject: [PATCH 036/192] perf: trace graph analysis requests --- docs/performance/codegraphy-monorepo.md | 7 +++ .../2026-06-22-codegraphy-performance.md | 1 + .../graphView/analysis/execution/publish.ts | 10 ++++ .../extension/graphView/analysis/request.ts | 18 ++++++- .../analysis/execution/publish.test.ts | 27 +++++++++- .../graphView/analysis/request.test.ts | 51 ++++++++++++++++++- 6 files changed, 111 insertions(+), 3 deletions(-) diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index 6da286674..dac50b098 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -636,6 +636,13 @@ Interpretation: but avoid the trailing full settings/control replay. This removes the measured `changedFiles` refresh-state burst and cuts repeated startup settings/control messages back to the expected ready/bootstrap sends. +- Analysis request markers show the next startup stall is in request ownership, + not webview rendering. In the latest one-sample run, the first `load` request + started at `725ms`, an `incremental` request started at `1477ms`, the load + request completed at `1481ms` without publishing `GRAPH_DATA_UPDATED`, and + the first published graph came from a full `analyze` request at `36817ms`. + The next behavior change should keep changed-file work from preempting the + first cached webview load. Full test baseline: diff --git a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md index e654c6c60..6eb8de6ef 100644 --- a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md +++ b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md @@ -307,6 +307,7 @@ Added extension-host outbound message tracing and coalesced dense graph index pr Skipped duplicate WEBVIEW_READY replay while first analysis is already in flight: the early duplicate settings batch around 518ms disappeared from the host send sequence; latest 2-sample run measured Imports at 231ms median / 266ms p95 wall-clock and 50ms median / 50ms p95 in-webview, with remaining aggregate settings/control sends coming from later refresh/plugin sync paths. Traced refresh-state send reasons: latest run recorded 22 full refresh-state replays, all tagged changedFiles, while aggregate settings/control sends remained high at SETTINGS_UPDATED 24 and GRAPH_CONTROLS_UPDATED 47; next change should preserve changed-file graph analysis while avoiding redundant indexed-incremental settings replay. Skipped redundant full settings replay for indexed incremental changed-file refreshes: focused provider test failed red on the old `_sendAllSettings` call, then passed after the refresh runner reported `incremental` vs fallback modes; latest VS Code trace has 0 refresh-state markers, SETTINGS_UPDATED 24 -> 2, GRAPH_CONTROLS_UPDATED 47 -> 5, and a one-sample Imports sanity check at 191ms wall-clock / 49ms in-webview. +Added analysis request/publish lifecycle markers: latest startup trace shows the first cached `load` request completing without publishing after an `incremental` request starts, then the first `GRAPH_DATA_UPDATED` comes from a full `analyze` request at 36.8s; next startup fix should prevent changed-file work from preempting first cached load. ``` ## Task 5: Keep The PR Reviewable diff --git a/packages/extension/src/extension/graphView/analysis/execution/publish.ts b/packages/extension/src/extension/graphView/analysis/execution/publish.ts index b99da44d3..87489ae6c 100644 --- a/packages/extension/src/extension/graphView/analysis/execution/publish.ts +++ b/packages/extension/src/extension/graphView/analysis/execution/publish.ts @@ -4,6 +4,7 @@ import type { GraphViewAnalysisExecutionState, } from '../execution'; import type { CodeGraphyIndexFreshness } from '../../../repoSettings/freshness'; +import { recordExtensionPerformanceEvent } from '../../../performance/marks'; export const EMPTY_GRAPH_DATA: IGraphData = { nodes: [], edges: [] }; @@ -73,6 +74,15 @@ export function publishAnalyzedGraph( handlers.sendPluginWebviewInjections?.(); const graphData = handlers.getGraphData(); + recordExtensionPerformanceEvent('graphAnalysis.publish.graph', { + mode: state.mode, + rawNodeCount: rawGraphData.nodes.length, + rawEdgeCount: rawGraphData.edges.length, + nodeCount: graphData.nodes.length, + edgeCount: graphData.edges.length, + hasIndex: actualHasIndex, + freshness: status.freshness, + }); handlers.sendGraphDataUpdated(graphData); handlers.sendGraphIndexStatusUpdated(actualHasIndex, status.freshness, status.detail); state.analyzer?.registry.notifyPostAnalyze(graphData, state.disabledPlugins); diff --git a/packages/extension/src/extension/graphView/analysis/request.ts b/packages/extension/src/extension/graphView/analysis/request.ts index 983930eca..d282f0738 100644 --- a/packages/extension/src/extension/graphView/analysis/request.ts +++ b/packages/extension/src/extension/graphView/analysis/request.ts @@ -1,4 +1,5 @@ import type { DiagnosticEventInput } from '@codegraphy-dev/core'; +import { recordExtensionPerformanceEvent } from '../../performance/marks'; export interface GraphViewAnalysisRequestState { analysisController: AbortController | undefined; @@ -44,6 +45,7 @@ export async function runGraphViewAnalysisRequest( handlers.updateAnalysisRequestId(requestId); const startedAt = Date.now(); const requestContext = createRequestContext(state, requestId); + recordExtensionPerformanceEvent('graphAnalysis.request.start', requestContext); handlers.emitDiagnostic?.({ area: 'extension.analysis', event: 'request-started', @@ -52,6 +54,10 @@ export async function runGraphViewAnalysisRequest( try { await handlers.executeAnalysis(controller.signal, requestId); + recordExtensionPerformanceEvent('graphAnalysis.request.completed', { + ...requestContext, + durationMs: Date.now() - startedAt, + }); handlers.emitDiagnostic?.({ area: 'extension.analysis', event: 'request-completed', @@ -61,7 +67,17 @@ export async function runGraphViewAnalysisRequest( }, }); } catch (error) { - if (!handlers.isAbortError(error)) { + const durationMs = Date.now() - startedAt; + if (handlers.isAbortError(error)) { + recordExtensionPerformanceEvent('graphAnalysis.request.aborted', { + ...requestContext, + durationMs, + }); + } else { + recordExtensionPerformanceEvent('graphAnalysis.request.failed', { + ...requestContext, + durationMs, + }); handlers.emitDiagnostic?.({ area: 'extension.analysis', event: 'request-failed', diff --git a/packages/extension/tests/extension/graphView/analysis/execution/publish.test.ts b/packages/extension/tests/extension/graphView/analysis/execution/publish.test.ts index 313b757eb..c7f500e2e 100644 --- a/packages/extension/tests/extension/graphView/analysis/execution/publish.test.ts +++ b/packages/extension/tests/extension/graphView/analysis/execution/publish.test.ts @@ -1,5 +1,14 @@ -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { IGraphData } from '../../../../../src/shared/graph/contracts'; + +const performanceMocks = vi.hoisted(() => ({ + recordExtensionPerformanceEvent: vi.fn(), +})); + +vi.mock('../../../../../src/extension/performance/marks', () => ({ + recordExtensionPerformanceEvent: performanceMocks.recordExtensionPerformanceEvent, +})); + import { publishAnalyzedGraph, publishAnalysisFailure, @@ -12,6 +21,10 @@ import { } from './fixtures'; describe('graph view analysis execution publish', () => { + beforeEach(() => { + performanceMocks.recordExtensionPerformanceEvent.mockReset(); + }); + it('publishes an empty graph and index state', () => { const { handlers } = createExecutionHandlers(); @@ -82,6 +95,18 @@ describe('graph view analysis execution publish', () => { getGraphData(), state.disabledPlugins, ); + expect(performanceMocks.recordExtensionPerformanceEvent).toHaveBeenCalledWith( + 'graphAnalysis.publish.graph', + { + mode: 'analyze', + rawNodeCount: 1, + rawEdgeCount: 0, + nodeCount: 1, + edgeCount: 0, + hasIndex: true, + freshness: 'fresh', + }, + ); }); it('reports graph view update progress before publishing an explicit index result', () => { diff --git a/packages/extension/tests/extension/graphView/analysis/request.test.ts b/packages/extension/tests/extension/graphView/analysis/request.test.ts index ecca00060..4ce2153b2 100644 --- a/packages/extension/tests/extension/graphView/analysis/request.test.ts +++ b/packages/extension/tests/extension/graphView/analysis/request.test.ts @@ -1,4 +1,13 @@ -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const performanceMocks = vi.hoisted(() => ({ + recordExtensionPerformanceEvent: vi.fn(), +})); + +vi.mock('../../../../src/extension/performance/marks', () => ({ + recordExtensionPerformanceEvent: performanceMocks.recordExtensionPerformanceEvent, +})); + import { runGraphViewAnalysisRequest, type GraphViewAnalysisRequestState, @@ -15,6 +24,46 @@ function createState( } describe('graph view analysis request', () => { + beforeEach(() => { + performanceMocks.recordExtensionPerformanceEvent.mockReset(); + }); + + it('records request lifecycle performance markers with mode context', async () => { + const state = createState({ + mode: 'load', + filterPatterns: ['src/**'], + disabledPlugins: new Set(['plugin.test']), + } as Partial); + + await runGraphViewAnalysisRequest(state, { + executeAnalysis: vi.fn(() => Promise.resolve()), + isAbortError: vi.fn(() => false), + logError: vi.fn(), + updateAnalysisController: vi.fn(), + updateAnalysisRequestId: vi.fn(), + }); + + expect(performanceMocks.recordExtensionPerformanceEvent).toHaveBeenCalledWith( + 'graphAnalysis.request.start', + { + requestId: 1, + mode: 'load', + filterPatternCount: 1, + disabledPluginCount: 1, + }, + ); + expect(performanceMocks.recordExtensionPerformanceEvent).toHaveBeenCalledWith( + 'graphAnalysis.request.completed', + expect.objectContaining({ + requestId: 1, + mode: 'load', + filterPatternCount: 1, + disabledPluginCount: 1, + durationMs: expect.any(Number), + }), + ); + }); + it('aborts the previous controller and clears the active request on success', async () => { const previousController = new AbortController(); const abortSpy = vi.spyOn(previousController, 'abort'); From 83f7a5e7f0348d5b23fee40816669013536765c9 Mon Sep 17 00:00:00 2001 From: joesobo Date: Mon, 22 Jun 2026 21:54:12 -0700 Subject: [PATCH 037/192] perf: keep incremental refresh behind first load --- .changeset/gate-incremental-first-load.md | 5 ++ docs/performance/codegraphy-monorepo.md | 8 +++ .../2026-06-22-codegraphy-performance.md | 1 + .../graphView/provider/analysis/methods.ts | 5 ++ .../provider/analysis/methods.test.ts | 56 +++++++++++++++++++ 5 files changed, 75 insertions(+) create mode 100644 .changeset/gate-incremental-first-load.md diff --git a/.changeset/gate-incremental-first-load.md b/.changeset/gate-incremental-first-load.md new file mode 100644 index 000000000..f884fee25 --- /dev/null +++ b/.changeset/gate-incremental-first-load.md @@ -0,0 +1,5 @@ +--- +"@codegraphy-dev/extension": patch +--- + +Keep changed-file graph refreshes from interrupting the first cached Graph View load. diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index dac50b098..d4ab67f3b 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -643,6 +643,14 @@ Interpretation: the first published graph came from a full `analyze` request at `36817ms`. The next behavior change should keep changed-file work from preempting the first cached webview load. +- Incremental changed-file analysis now waits for the first workspace-ready + graph before starting. The next one-sample run published the cached `load` + graph at `9857ms` before starting incremental work at `9916ms`, so the first + `GRAPH_DATA_UPDATED` no longer waits for a full `analyze` request. First + graph readiness improved from `40649ms` to `13696ms` in this noisy VS Code + frame-readiness harness, while the host-side first publish moved from + `36817ms` to `9857ms`. The remaining startup target is the cached load path + itself, which now accounts for roughly `9.9s` before publish. Full test baseline: diff --git a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md index 6eb8de6ef..02b42c65b 100644 --- a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md +++ b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md @@ -308,6 +308,7 @@ Skipped duplicate WEBVIEW_READY replay while first analysis is already in flight Traced refresh-state send reasons: latest run recorded 22 full refresh-state replays, all tagged changedFiles, while aggregate settings/control sends remained high at SETTINGS_UPDATED 24 and GRAPH_CONTROLS_UPDATED 47; next change should preserve changed-file graph analysis while avoiding redundant indexed-incremental settings replay. Skipped redundant full settings replay for indexed incremental changed-file refreshes: focused provider test failed red on the old `_sendAllSettings` call, then passed after the refresh runner reported `incremental` vs fallback modes; latest VS Code trace has 0 refresh-state markers, SETTINGS_UPDATED 24 -> 2, GRAPH_CONTROLS_UPDATED 47 -> 5, and a one-sample Imports sanity check at 191ms wall-clock / 49ms in-webview. Added analysis request/publish lifecycle markers: latest startup trace shows the first cached `load` request completing without publishing after an `incremental` request starts, then the first `GRAPH_DATA_UPDATED` comes from a full `analyze` request at 36.8s; next startup fix should prevent changed-file work from preempting first cached load. +Gated incremental analysis behind first workspace readiness: focused provider test failed red when `incremental:start` raced an unresolved `load:start`, then passed after incremental waited for first ready; latest VS Code trace publishes cached `load` at 9.86s before incremental starts, moving first publish from 36.8s analyze to 9.86s load and first graph readiness from 40.6s to 13.7s in the one-sample harness. ``` ## Task 5: Keep The PR Reviewable diff --git a/packages/extension/src/extension/graphView/provider/analysis/methods.ts b/packages/extension/src/extension/graphView/provider/analysis/methods.ts index a04b7fb03..ce5958835 100644 --- a/packages/extension/src/extension/graphView/provider/analysis/methods.ts +++ b/packages/extension/src/extension/graphView/provider/analysis/methods.ts @@ -47,6 +47,7 @@ export interface GraphViewProviderAnalysisMethodsSource { _rawGraphData: IGraphData; _firstAnalysis: boolean; _resolveFirstWorkspaceReady?: () => void; + _firstWorkspaceReadyPromise?: Promise; _sendMessage(message: ExtensionToWebviewMessage): void; _sendDepthState(): void; _computeMergedGroups(): void; @@ -293,6 +294,10 @@ export function createGraphViewProviderAnalysisMethods( ); const _incrementalAnalyzeAndSendData = async (filePaths: readonly string[]): Promise => { await fullIndexAnalysis.waitForFullIndexAnalysis(); + if (source._firstAnalysis && source._firstWorkspaceReadyPromise) { + await source._firstWorkspaceReadyPromise; + } + source._changedFilePaths = [...filePaths]; const doIncrementalAnalyzeAndSendData = createGraphViewProviderDoAnalyzeAndSendData( source, diff --git a/packages/extension/tests/extension/graphView/provider/analysis/methods.test.ts b/packages/extension/tests/extension/graphView/provider/analysis/methods.test.ts index 025eed611..1308b1648 100644 --- a/packages/extension/tests/extension/graphView/provider/analysis/methods.test.ts +++ b/packages/extension/tests/extension/graphView/provider/analysis/methods.test.ts @@ -32,6 +32,7 @@ function createSource( _rawGraphData: IGraphData; _firstAnalysis: boolean; _resolveFirstWorkspaceReady?: ReturnType; + _firstWorkspaceReadyPromise: Promise; _sendMessage: ReturnType; _sendDepthState: ReturnType; _computeMergedGroups: ReturnType; @@ -48,6 +49,8 @@ function createSource( _isAbortError?: (error: unknown) => boolean; [key: string]: unknown; } { + const firstWorkspaceReadyPromise = Promise.resolve(); + return { _analysisController: undefined, _analysisRequestId: 7, @@ -64,6 +67,7 @@ function createSource( _rawGraphData: { nodes: [], edges: [] }, _firstAnalysis: true, _resolveFirstWorkspaceReady: vi.fn(), + _firstWorkspaceReadyPromise: firstWorkspaceReadyPromise, _sendMessage: vi.fn(), _sendDepthState: vi.fn(), _computeMergedGroups: vi.fn(), @@ -369,6 +373,58 @@ describe('graphView/provider/analysis/methods', () => { expect(events).toEqual(['load:start', 'load:end', 'analyze:start', 'analyze:end']); }); + it('waits for first workspace readiness before starting incremental analysis', async () => { + let markFirstWorkspaceReady: (() => void) | undefined; + const firstWorkspaceReadyPromise = new Promise(resolve => { + markFirstWorkspaceReady = resolve; + }); + const source = createSource({ + _firstWorkspaceReadyPromise: firstWorkspaceReadyPromise, + }); + const events: string[] = []; + let finishLoad: (() => void) | undefined; + const runAnalysisRequest = vi.fn(async state => { + events.push(`${state.mode}:start`); + if (state.mode === 'load') { + await new Promise(resolve => { + finishLoad = resolve; + }); + source._firstAnalysis = false; + markFirstWorkspaceReady?.(); + } + events.push(`${state.mode}:end`); + }); + const methods = createGraphViewProviderAnalysisMethods(source as never, { + runAnalysisRequest, + executeAnalysis: vi.fn(async () => undefined), + markWorkspaceReady: vi.fn(), + isAnalysisStale: vi.fn(() => false), + isAbortError: vi.fn(() => false), + hasWorkspace: vi.fn(() => true), + logError: vi.fn(), + }); + + const load = methods._loadAndSendData(); + await Promise.resolve(); + const incremental = methods._incrementalAnalyzeAndSendData(['src/changed.ts']); + await Promise.resolve(); + + expect(events).toEqual(['load:start']); + expect(runAnalysisRequest).toHaveBeenCalledOnce(); + + finishLoad?.(); + await load; + await incremental; + + expect(events).toEqual([ + 'load:start', + 'load:end', + 'incremental:start', + 'incremental:end', + ]); + expect(source._changedFilePaths).toEqual(['src/changed.ts']); + }); + it('falls back to the delegate wrappers when source-owned analysis methods are unavailable', async () => { const source = createSource({ _analyzer: undefined, From cef9deb6306324bffa5e83f65a335cee21955e30 Mon Sep 17 00:00:00 2001 From: joesobo Date: Mon, 22 Jun 2026 22:02:43 -0700 Subject: [PATCH 038/192] perf: skip discovery during cached graph replay --- .changeset/fast-cached-graph-replay.md | 5 ++ docs/performance/codegraphy-monorepo.md | 8 ++ .../2026-06-22-codegraphy-performance.md | 1 + .../pipeline/service/cache/cachedDiscovery.ts | 85 +++++++++++++++++++ .../pipeline/service/discoveryFacade.ts | 52 ++---------- .../service/cache/cachedDiscovery.test.ts | 78 +++++++++++++++++ .../pipeline/service/discoveryFacade.test.ts | 77 +++++++++++++---- 7 files changed, 246 insertions(+), 60 deletions(-) create mode 100644 .changeset/fast-cached-graph-replay.md create mode 100644 packages/extension/src/extension/pipeline/service/cache/cachedDiscovery.ts create mode 100644 packages/extension/tests/extension/pipeline/service/cache/cachedDiscovery.test.ts diff --git a/.changeset/fast-cached-graph-replay.md b/.changeset/fast-cached-graph-replay.md new file mode 100644 index 000000000..9aa9d62b5 --- /dev/null +++ b/.changeset/fast-cached-graph-replay.md @@ -0,0 +1,5 @@ +--- +"@codegraphy-dev/extension": patch +--- + +Speed up warm Graph View startup by replaying cached graph metadata without a full workspace discovery walk. diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index d4ab67f3b..67e967968 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -651,6 +651,14 @@ Interpretation: frame-readiness harness, while the host-side first publish moved from `36817ms` to `9857ms`. The remaining startup target is the cached load path itself, which now accounts for roughly `9.9s` before publish. +- Cached Graph Cache replay no longer performs a full workspace discovery walk. + It derives discovered files and directories from cached paths, then asks git + for ignored metadata only for those cached paths. A direct probe measured the + replacement metadata path at `322ms` versus `4083ms` for full discovery with + the user's filters. The VS Code harness then moved cached `load` publish from + `9857ms` to `2396ms`, request completion from `9917ms` to `1653ms`, and first + graph readiness from `13696ms` to `5876ms`; visible graph stats stayed stable + at `2300` nodes and `5345` edges. Full test baseline: diff --git a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md index 02b42c65b..f8f0ff849 100644 --- a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md +++ b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md @@ -309,6 +309,7 @@ Traced refresh-state send reasons: latest run recorded 22 full refresh-state rep Skipped redundant full settings replay for indexed incremental changed-file refreshes: focused provider test failed red on the old `_sendAllSettings` call, then passed after the refresh runner reported `incremental` vs fallback modes; latest VS Code trace has 0 refresh-state markers, SETTINGS_UPDATED 24 -> 2, GRAPH_CONTROLS_UPDATED 47 -> 5, and a one-sample Imports sanity check at 191ms wall-clock / 49ms in-webview. Added analysis request/publish lifecycle markers: latest startup trace shows the first cached `load` request completing without publishing after an `incremental` request starts, then the first `GRAPH_DATA_UPDATED` comes from a full `analyze` request at 36.8s; next startup fix should prevent changed-file work from preempting first cached load. Gated incremental analysis behind first workspace readiness: focused provider test failed red when `incremental:start` raced an unresolved `load:start`, then passed after incremental waited for first ready; latest VS Code trace publishes cached `load` at 9.86s before incremental starts, moving first publish from 36.8s analyze to 9.86s load and first graph readiness from 40.6s to 13.7s in the one-sample harness. +Skipped full workspace discovery during cached Graph Cache replay: focused facade test failed red on the old discovery call, then passed with cached-path metadata; direct replacement metadata probe is 322ms vs 4083ms full discovery, and latest VS Code trace publishes cached `load` at 2.40s with first graph readiness 5.88s while visible stats remain 2300 nodes / 5345 edges. ``` ## Task 5: Keep The PR Reviewable diff --git a/packages/extension/src/extension/pipeline/service/cache/cachedDiscovery.ts b/packages/extension/src/extension/pipeline/service/cache/cachedDiscovery.ts new file mode 100644 index 000000000..afeef48ca --- /dev/null +++ b/packages/extension/src/extension/pipeline/service/cache/cachedDiscovery.ts @@ -0,0 +1,85 @@ +import { spawnSync } from 'node:child_process'; +import * as path from 'node:path'; +import type { IDiscoveredFile } from '@codegraphy-dev/core'; + +export interface CachedWorkspaceDiscoveryState { + directories: string[]; + files: IDiscoveredFile[]; + gitIgnoredPaths: string[]; +} + +export function createCachedDiscoveredFiles( + workspaceRoot: string, + filePaths: readonly string[], +): IDiscoveredFile[] { + return filePaths.map(relativePath => ({ + absolutePath: path.join(workspaceRoot, relativePath), + extension: path.extname(relativePath), + name: path.basename(relativePath), + relativePath, + })); +} + +export function collectCachedDirectoryPaths(filePaths: readonly string[]): string[] { + const directories = new Set(); + + for (const filePath of filePaths) { + let directory = path.posix.dirname(filePath.replace(/\\/g, '/')); + while (directory && directory !== '.') { + directories.add(directory); + directory = path.posix.dirname(directory); + } + } + + return [...directories].sort(); +} + +function toGitPath(relativePath: string): string { + return relativePath.split(path.sep).join('/'); +} + +export function collectCachedGitIgnoredPaths( + workspaceRoot: string, + relativePaths: readonly string[], + respectGitignore: boolean, +): string[] { + if (!respectGitignore || relativePaths.length === 0) { + return []; + } + + const pathsByGitPath = new Map(); + for (const relativePath of relativePaths) { + pathsByGitPath.set(toGitPath(relativePath), relativePath); + } + + const result = spawnSync('git', ['-C', workspaceRoot, 'check-ignore', '--stdin'], { + encoding: 'utf8', + input: `${[...pathsByGitPath.keys()].join('\n')}\n`, + }); + + if (result.error || (result.status !== 0 && result.status !== 1)) { + return []; + } + + return result.stdout + .split(/\r?\n/) + .filter(Boolean) + .map(gitPath => pathsByGitPath.get(gitPath) ?? gitPath); +} + +export function createCachedWorkspaceDiscoveryState( + workspaceRoot: string, + filePaths: readonly string[], + respectGitignore: boolean, +): CachedWorkspaceDiscoveryState { + const directories = collectCachedDirectoryPaths(filePaths); + return { + directories, + files: createCachedDiscoveredFiles(workspaceRoot, filePaths), + gitIgnoredPaths: collectCachedGitIgnoredPaths( + workspaceRoot, + [...directories, ...filePaths], + respectGitignore, + ), + }; +} diff --git a/packages/extension/src/extension/pipeline/service/discoveryFacade.ts b/packages/extension/src/extension/pipeline/service/discoveryFacade.ts index 7017720da..3762b5d3b 100644 --- a/packages/extension/src/extension/pipeline/service/discoveryFacade.ts +++ b/packages/extension/src/extension/pipeline/service/discoveryFacade.ts @@ -1,7 +1,5 @@ -import * as path from 'node:path'; import * as vscode from 'vscode'; import { - type IDiscoveredFile, projectFileAnalysisConnections, readCodeGraphyWorkspaceStatus, throwIfWorkspaceAnalysisAborted, @@ -27,32 +25,7 @@ import { rebuildWorkspacePipelineGraph, } from './runtime/run'; import { createEmptyWorkspaceAnalysisCache } from '../cache'; - -function createCachedDiscoveredFiles( - workspaceRoot: string, - filePaths: readonly string[], -): IDiscoveredFile[] { - return filePaths.map(relativePath => ({ - absolutePath: path.join(workspaceRoot, relativePath), - extension: path.extname(relativePath), - name: path.basename(relativePath), - relativePath, - })); -} - -function collectCachedDirectoryPaths(filePaths: readonly string[]): string[] { - const directories = new Set(); - - for (const filePath of filePaths) { - let directory = path.posix.dirname(filePath.replace(/\\/g, '/')); - while (directory && directory !== '.') { - directories.add(directory); - directory = path.posix.dirname(directory); - } - } - - return [...directories].sort(); -} +import { createCachedWorkspaceDiscoveryState } from './cache/cachedDiscovery'; export abstract class WorkspacePipelineDiscoveryFacade extends WorkspacePipelineInternalBase { private _workspacePluginReloadQueue: Promise = Promise.resolve(); @@ -215,17 +188,6 @@ export abstract class WorkspacePipelineDiscoveryFacade extends WorkspacePipeline } const config = this._config.getAll(); - const discoveryResult = await discoverWorkspacePipelineFilesWithWarnings( - createWorkspacePipelineDiscoveryDependencies(this._discovery), - workspaceRoot, - config, - this._getEffectiveCustomFilterPatterns(_filterPatterns), - this._getEffectivePluginFilterPatterns(disabledPlugins), - signal, - message => { - vscode.window.showWarningMessage(message); - }, - ); throwIfWorkspaceAnalysisAborted(signal); const fileAnalysis = new Map( @@ -235,11 +197,15 @@ export abstract class WorkspacePipelineDiscoveryFacade extends WorkspacePipeline ]), ); const cachedFilePaths = Object.keys(this._cache.files); + const cachedDiscovery = createCachedWorkspaceDiscoveryState( + workspaceRoot, + cachedFilePaths, + config.respectGitignore, + ); - this._lastDiscoveredFiles = createCachedDiscoveredFiles(workspaceRoot, cachedFilePaths); - this._lastDiscoveredDirectories = discoveryResult.directories - ?? collectCachedDirectoryPaths(cachedFilePaths); - this._lastGitIgnoredPaths = discoveryResult.gitIgnoredPaths ?? []; + this._lastDiscoveredFiles = cachedDiscovery.files; + this._lastDiscoveredDirectories = cachedDiscovery.directories; + this._lastGitIgnoredPaths = cachedDiscovery.gitIgnoredPaths; this._lastFileAnalysis = fileAnalysis; this._lastFileConnections = projectFileAnalysisConnections(fileAnalysis, workspaceRoot); this._lastWorkspaceRoot = workspaceRoot; diff --git a/packages/extension/tests/extension/pipeline/service/cache/cachedDiscovery.test.ts b/packages/extension/tests/extension/pipeline/service/cache/cachedDiscovery.test.ts new file mode 100644 index 000000000..712aee4b9 --- /dev/null +++ b/packages/extension/tests/extension/pipeline/service/cache/cachedDiscovery.test.ts @@ -0,0 +1,78 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import { + collectCachedGitIgnoredPaths, + createCachedWorkspaceDiscoveryState, +} from '../../../../../src/extension/pipeline/service/cache/cachedDiscovery'; + +vi.mock('node:child_process', () => ({ + spawnSync: vi.fn(), +})); + +describe('pipeline/service/cache/cachedDiscovery', () => { + beforeEach(() => { + vi.mocked(spawnSync).mockReset(); + }); + + it('derives discovered file and directory metadata from cached relative paths', () => { + vi.mocked(spawnSync).mockReturnValue({ + error: undefined, + status: 1, + stdout: '', + } as never); + + expect( + createCachedWorkspaceDiscoveryState( + '/workspace', + ['src/nested/cached.ts', 'README.md'], + true, + ), + ).toEqual({ + directories: ['src', 'src/nested'], + files: [ + { + absolutePath: '/workspace/src/nested/cached.ts', + extension: '.ts', + name: 'cached.ts', + relativePath: 'src/nested/cached.ts', + }, + { + absolutePath: '/workspace/README.md', + extension: '.md', + name: 'README.md', + relativePath: 'README.md', + }, + ], + gitIgnoredPaths: [], + }); + }); + + it('collects current gitignore matches only for cached paths', () => { + vi.mocked(spawnSync).mockReturnValue({ + error: undefined, + status: 0, + stdout: 'src/generated.ts\n', + } as never); + + expect( + collectCachedGitIgnoredPaths( + '/workspace', + ['src', 'src/generated.ts', 'src/kept.ts'], + true, + ), + ).toEqual(['src/generated.ts']); + expect(spawnSync).toHaveBeenCalledWith( + 'git', + ['-C', '/workspace', 'check-ignore', '--stdin'], + { + encoding: 'utf8', + input: 'src\nsrc/generated.ts\nsrc/kept.ts\n', + }, + ); + }); + + it('skips git when gitignore handling is disabled', () => { + expect(collectCachedGitIgnoredPaths('/workspace', ['src/generated.ts'], false)).toEqual([]); + expect(spawnSync).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/extension/tests/extension/pipeline/service/discoveryFacade.test.ts b/packages/extension/tests/extension/pipeline/service/discoveryFacade.test.ts index b69f11b60..9c8de1233 100644 --- a/packages/extension/tests/extension/pipeline/service/discoveryFacade.test.ts +++ b/packages/extension/tests/extension/pipeline/service/discoveryFacade.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import * as vscode from 'vscode'; +import { spawnSync } from 'node:child_process'; import { WorkspacePipelineDiscoveryFacade } from '../../../../src/extension/pipeline/service/discoveryFacade'; import type { Configuration } from '../../../../src/extension/config/reader'; import type { FileDiscovery } from '@codegraphy-dev/core'; @@ -41,6 +42,10 @@ vi.mock('../../../../src/extension/pipeline/service/runtime/run', () => ({ rebuildWorkspacePipelineGraph: vi.fn(), })); +vi.mock('node:child_process', () => ({ + spawnSync: vi.fn(), +})); + vi.mock('vscode', () => ({ workspace: { workspaceFolders: [{ uri: { fsPath: '/workspace' } }], @@ -129,6 +134,11 @@ describe('pipeline/service/discoveryFacade', () => { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(spawnSync).mockReturnValue({ + error: undefined, + status: 1, + stdout: '', + } as never); vi.mocked(createWorkspacePipelineDiscoveryDependencies).mockReturnValue('discovery-deps' as never); vi.mocked(discoverWorkspacePipelineFilesWithWarnings).mockResolvedValue({ directories: ['src/new-folder'], @@ -458,6 +468,44 @@ describe('pipeline/service/discoveryFacade', () => { expect(discoveryState(facade)._lastDiscoveredDirectories).toEqual(['src', 'src/nested']); }); + it('loads cached graph data without walking the workspace again', async () => { + const facade = new TestDiscoveryFacade(); + const cachedAnalysis = { + filePath: '/workspace/src/nested/cached.ts', + relations: [], + }; + facade._cache = { + version: 'test', + files: { + 'src/nested/cached.ts': { + mtime: 1, + analysis: cachedAnalysis, + }, + }, + } as never; + vi.mocked(discoverWorkspacePipelineFilesWithWarnings).mockRejectedValueOnce( + new Error('full discovery should not run for cached replay'), + ); + vi.spyOn( + facade as unknown as { + _buildGraphDataFromAnalysis: (...args: unknown[]) => unknown; + }, + '_buildGraphDataFromAnalysis', + ).mockReturnValue({ + nodes: [{ id: 'src/nested/cached.ts', label: 'cached.ts', color: '#333333' }], + edges: [], + }); + + await expect(facade.loadCachedGraph()).resolves.toEqual({ + nodes: [{ id: 'src/nested/cached.ts', label: 'cached.ts', color: '#333333' }], + edges: [], + }); + + expect(discoverWorkspacePipelineFilesWithWarnings).not.toHaveBeenCalled(); + expect(discoveryState(facade)._lastDiscoveredDirectories).toEqual(['src', 'src/nested']); + expect(discoveryState(facade)._lastGitIgnoredPaths).toEqual([]); + }); + it('applies current gitignore metadata when replaying cached graph data', async () => { const facade = new TestDiscoveryFacade(); const cachedAnalysis = { @@ -473,15 +521,10 @@ describe('pipeline/service/discoveryFacade', () => { }, }, } as never; - vi.mocked(discoverWorkspacePipelineFilesWithWarnings).mockResolvedValueOnce({ - directories: ['example-python', 'example-python/src'], - files: [ - { - absolutePath: '/workspace/example-python/src/main.py', - relativePath: 'example-python/src/main.py', - }, - ], - gitIgnoredPaths: ['example-python/src/main.py'], + vi.mocked(spawnSync).mockReturnValueOnce({ + error: undefined, + status: 0, + stdout: 'example-python/src/main.py\n', } as never); vi.spyOn( facade as unknown as { @@ -495,14 +538,14 @@ describe('pipeline/service/discoveryFacade', () => { await facade.loadCachedGraph(); - expect(discoverWorkspacePipelineFilesWithWarnings).toHaveBeenCalledWith( - 'discovery-deps', - '/workspace', - { showOrphans: true, respectGitignore: true }, - [], - ['plugin-filter'], - undefined, - expect.any(Function), + expect(discoverWorkspacePipelineFilesWithWarnings).not.toHaveBeenCalled(); + expect(spawnSync).toHaveBeenCalledWith( + 'git', + ['-C', '/workspace', 'check-ignore', '--stdin'], + { + encoding: 'utf8', + input: 'example-python\nexample-python/src\nexample-python/src/main.py\n', + }, ); expect(discoveryState(facade)._lastGitIgnoredPaths).toEqual(['example-python/src/main.py']); }); From 9c4368ea7195536ff7ac89a36b5a1759dd26c6f6 Mon Sep 17 00:00:00 2001 From: joesobo Date: Mon, 22 Jun 2026 22:14:43 -0700 Subject: [PATCH 039/192] perf: speed up material icon legend matching --- .changeset/material-extension-matcher.md | 5 ++ docs/performance/codegraphy-monorepo.md | 9 +++ .../2026-06-22-codegraphy-performance.md | 1 + .../graphView/analysis/execution/publish.ts | 35 +++++++++ .../defaults/materialTheme/extensionMatch.ts | 46 ++++++++++- .../defaults/materialTheme/fileExtension.ts | 9 ++- .../groups/defaults/materialTheme/files.ts | 1 + .../groups/defaults/materialTheme/manifest.ts | 4 + .../groups/defaults/materialTheme/match.ts | 15 +++- .../groups/defaults/materialTheme/model.ts | 5 +- .../pipeline/service/discoveryFacade.ts | 34 ++++++++- .../analysis/execution/publish.test.ts | 42 ++++++++++ .../materialTheme/extensionMatch.test.ts | 20 ++++- .../pipeline/service/discoveryFacade.test.ts | 76 +++++++++++++++++++ 14 files changed, 292 insertions(+), 10 deletions(-) create mode 100644 .changeset/material-extension-matcher.md diff --git a/.changeset/material-extension-matcher.md b/.changeset/material-extension-matcher.md new file mode 100644 index 000000000..3247f83ec --- /dev/null +++ b/.changeset/material-extension-matcher.md @@ -0,0 +1,5 @@ +--- +"@codegraphy-dev/extension": patch +--- + +Speed up startup legend generation by reusing the prepared Material Icon extension matcher. diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index 67e967968..6c872be75 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -659,6 +659,15 @@ Interpretation: `9857ms` to `2396ms`, request completion from `9917ms` to `1653ms`, and first graph readiness from `13696ms` to `5876ms`; visible graph stats stayed stable at `2300` nodes and `5345` edges. +- Cached-load and publish-stage markers now split host-side startup work. After + the cached replay change, `loadCachedGraph` itself takes roughly `793ms`- + `837ms`, with hydration around `389ms`-`437ms`, cached git/path metadata + around `322ms`, and graph construction around `71ms`-`74ms`. The next + host-side bottleneck was Material Icon legend generation inside + `graphAnalysis.publish.groups`, which dropped from `748ms` to `96ms` after + reusing a prepared extension matcher from the cached material theme. The + cached `load` publish moved from `2279ms` to `1696ms`, and first graph + readiness moved from `5823ms` to `5617ms` in the one-sample harness. Full test baseline: diff --git a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md index f8f0ff849..e95772f3f 100644 --- a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md +++ b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md @@ -310,6 +310,7 @@ Skipped redundant full settings replay for indexed incremental changed-file refr Added analysis request/publish lifecycle markers: latest startup trace shows the first cached `load` request completing without publishing after an `incremental` request starts, then the first `GRAPH_DATA_UPDATED` comes from a full `analyze` request at 36.8s; next startup fix should prevent changed-file work from preempting first cached load. Gated incremental analysis behind first workspace readiness: focused provider test failed red when `incremental:start` raced an unresolved `load:start`, then passed after incremental waited for first ready; latest VS Code trace publishes cached `load` at 9.86s before incremental starts, moving first publish from 36.8s analyze to 9.86s load and first graph readiness from 40.6s to 13.7s in the one-sample harness. Skipped full workspace discovery during cached Graph Cache replay: focused facade test failed red on the old discovery call, then passed with cached-path metadata; direct replacement metadata probe is 322ms vs 4083ms full discovery, and latest VS Code trace publishes cached `load` at 2.40s with first graph readiness 5.88s while visible stats remain 2300 nodes / 5345 edges. +Added cached-load/publish stage markers and reused a prepared Material Icon extension matcher: publish `groups` dropped from 748ms to 96ms, cached `load` publish moved from 2.28s to 1.70s, first graph readiness moved from 5.82s to 5.62s, and Imports sanity check measured 209ms wall-clock / 58ms in-webview. ``` ## Task 5: Keep The PR Reviewable diff --git a/packages/extension/src/extension/graphView/analysis/execution/publish.ts b/packages/extension/src/extension/graphView/analysis/execution/publish.ts index 87489ae6c..22db46161 100644 --- a/packages/extension/src/extension/graphView/analysis/execution/publish.ts +++ b/packages/extension/src/extension/graphView/analysis/execution/publish.ts @@ -31,6 +31,17 @@ function shouldReportGraphViewUpdateProgress( return state.mode === 'index' || state.mode === 'refresh' || state.mode === 'incremental'; } +function recordPublishStage( + stage: string, + startedAt: number, + detail: Record = {}, +): void { + recordExtensionPerformanceEvent(`graphAnalysis.publish.${stage}`, { + durationMs: Date.now() - startedAt, + ...detail, + }); +} + export function publishEmptyGraph( handlers: GraphViewAnalysisExecutionHandlers, hasIndex: boolean = false, @@ -59,11 +70,24 @@ export function publishAnalyzedGraph( total: 1, }); } + let stageStartedAt = Date.now(); handlers.setRawGraphData(rawGraphData); + recordPublishStage('setRawGraphData', stageStartedAt, { + rawEdgeCount: rawGraphData.edges.length, + rawNodeCount: rawGraphData.nodes.length, + }); + + stageStartedAt = Date.now(); handlers.updateViewContext(); handlers.applyViewTransform(); + recordPublishStage('viewTransform', stageStartedAt); + + stageStartedAt = Date.now(); handlers.computeMergedGroups(); handlers.sendGroupsUpdated(); + recordPublishStage('groups', stageStartedAt); + + stageStartedAt = Date.now(); handlers.sendDepthState(); handlers.sendPluginStatuses(); handlers.sendDecorations(); @@ -72,8 +96,14 @@ export function publishAnalyzedGraph( handlers.sendPluginToolbarActions?.(); handlers.sendGraphViewContributionStatuses?.(); handlers.sendPluginWebviewInjections?.(); + recordPublishStage('broadcasts', stageStartedAt); + stageStartedAt = Date.now(); const graphData = handlers.getGraphData(); + recordPublishStage('getGraphData', stageStartedAt, { + edgeCount: graphData.edges.length, + nodeCount: graphData.nodes.length, + }); recordExtensionPerformanceEvent('graphAnalysis.publish.graph', { mode: state.mode, rawNodeCount: rawGraphData.nodes.length, @@ -83,7 +113,12 @@ export function publishAnalyzedGraph( hasIndex: actualHasIndex, freshness: status.freshness, }); + stageStartedAt = Date.now(); handlers.sendGraphDataUpdated(graphData); + recordPublishStage('sendGraphData', stageStartedAt, { + edgeCount: graphData.edges.length, + nodeCount: graphData.nodes.length, + }); handlers.sendGraphIndexStatusUpdated(actualHasIndex, status.freshness, status.detail); state.analyzer?.registry.notifyPostAnalyze(graphData, state.disabledPlugins); handlers.markWorkspaceReady(graphData, state.disabledPlugins); diff --git a/packages/extension/src/extension/graphView/groups/defaults/materialTheme/extensionMatch.ts b/packages/extension/src/extension/graphView/groups/defaults/materialTheme/extensionMatch.ts index b80a68bce..5b71f2aeb 100644 --- a/packages/extension/src/extension/graphView/groups/defaults/materialTheme/extensionMatch.ts +++ b/packages/extension/src/extension/graphView/groups/defaults/materialTheme/extensionMatch.ts @@ -1,13 +1,45 @@ import type { MaterialMatch } from './model'; +export interface MaterialExtensionMatcher { + iconNameByLowerExtension: Map; +} + +export function createMaterialExtensionMatcher( + extensions: Record, +): MaterialExtensionMatcher { + return { + iconNameByLowerExtension: new Map( + Object.entries(extensions).map(([extension, iconName]) => [ + extension.toLowerCase(), + iconName, + ]), + ), + }; +} + export function findLongestExtensionMatch( baseName: string, entries: Iterable, +): MaterialMatch | undefined { + return findLongestExtensionMatchWithMatcher( + baseName, + createMaterialExtensionMatcher(Object.fromEntries(entries)), + ); +} + +export function findLongestExtensionMatchWithMatcher( + baseName: string, + matcher: MaterialExtensionMatcher, ): MaterialMatch | undefined { const lowerBaseName = baseName.toLowerCase(); let bestMatch: MaterialMatch | undefined; - for (const [extension, iconName] of entries) { + for (const extension of getExtensionCandidates(lowerBaseName)) { + const iconName = matcher.iconNameByLowerExtension.get(extension); + if (!iconName) { + continue; + } + const match = createExtensionMatch(baseName, lowerBaseName, extension, iconName); if (!match || (bestMatch && bestMatch.key.length >= match.key.length)) { continue; @@ -19,6 +51,18 @@ export function findLongestExtensionMatch( return bestMatch; } +function getExtensionCandidates(lowerBaseName: string): string[] { + const candidates = [lowerBaseName]; + for (let index = lowerBaseName.indexOf('.'); index >= 0; index = lowerBaseName.indexOf('.', index + 1)) { + const extension = lowerBaseName.slice(index + 1); + if (extension) { + candidates.push(extension); + } + } + + return candidates; +} + function createExtensionMatch( baseName: string, lowerBaseName: string, diff --git a/packages/extension/src/extension/graphView/groups/defaults/materialTheme/fileExtension.ts b/packages/extension/src/extension/graphView/groups/defaults/materialTheme/fileExtension.ts index 6ec583ae4..d67771ba1 100644 --- a/packages/extension/src/extension/graphView/groups/defaults/materialTheme/fileExtension.ts +++ b/packages/extension/src/extension/graphView/groups/defaults/materialTheme/fileExtension.ts @@ -1,9 +1,14 @@ import type { MaterialMatch } from './model'; -import { findLongestExtensionMatch } from './extensionMatch'; +import { + createMaterialExtensionMatcher, + findLongestExtensionMatchWithMatcher, + type MaterialExtensionMatcher, +} from './extensionMatch'; export function matchMaterialFileExtension( baseName: string, fileExtensions: Record, + matcher: MaterialExtensionMatcher = createMaterialExtensionMatcher(fileExtensions), ): MaterialMatch | undefined { - return findLongestExtensionMatch(baseName, Object.entries(fileExtensions)); + return findLongestExtensionMatchWithMatcher(baseName, matcher); } diff --git a/packages/extension/src/extension/graphView/groups/defaults/materialTheme/files.ts b/packages/extension/src/extension/graphView/groups/defaults/materialTheme/files.ts index f1ed090de..3a4e13f5a 100644 --- a/packages/extension/src/extension/graphView/groups/defaults/materialTheme/files.ts +++ b/packages/extension/src/extension/graphView/groups/defaults/materialTheme/files.ts @@ -18,6 +18,7 @@ export function collectMaterialFileGroups( } const match = findMaterialMatch(node.id, theme.manifest, { + extensionMatcher: theme.extensionMatcher, pathMatchers: theme.pathMatchers, }); if (!match) { diff --git a/packages/extension/src/extension/graphView/groups/defaults/materialTheme/manifest.ts b/packages/extension/src/extension/graphView/groups/defaults/materialTheme/manifest.ts index ceba0bf23..1bef9839b 100644 --- a/packages/extension/src/extension/graphView/groups/defaults/materialTheme/manifest.ts +++ b/packages/extension/src/extension/graphView/groups/defaults/materialTheme/manifest.ts @@ -2,6 +2,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import * as vscode from 'vscode'; import type { MaterialThemeCacheEntry, MaterialIconManifest } from './model'; +import { createMaterialExtensionMatcher } from './extensionMatch'; import { createMaterialPathRuleMatcher } from './pathMatch'; const materialThemeCache = new Map(); @@ -41,6 +42,9 @@ export function loadMaterialTheme(extensionUri: vscode.Uri): MaterialThemeCacheE const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as MaterialIconManifest; const theme = { + extensionMatcher: manifest.fileExtensions + ? createMaterialExtensionMatcher(manifest.fileExtensions) + : undefined, iconDataByName: new Map(), manifest, manifestPath, diff --git a/packages/extension/src/extension/graphView/groups/defaults/materialTheme/match.ts b/packages/extension/src/extension/graphView/groups/defaults/materialTheme/match.ts index e5c1f8dcd..2d9eb8074 100644 --- a/packages/extension/src/extension/graphView/groups/defaults/materialTheme/match.ts +++ b/packages/extension/src/extension/graphView/groups/defaults/materialTheme/match.ts @@ -1,3 +1,4 @@ +import type { MaterialExtensionMatcher } from './extensionMatch'; import type { MaterialIconManifest, MaterialMatch, MaterialThemePathMatchers } from './model'; import { matchMaterialFileExtension } from './fileExtension'; import { matchMaterialFileName } from './fileName'; @@ -8,11 +9,15 @@ import { getMaterialBaseName } from './paths'; export function findMaterialMatch( nodeId: string, manifest: MaterialIconManifest, - options?: { nodeType?: 'file' | 'folder'; pathMatchers?: MaterialThemePathMatchers }, + options?: { + extensionMatcher?: MaterialExtensionMatcher; + nodeType?: 'file' | 'folder'; + pathMatchers?: MaterialThemePathMatchers; + }, ): MaterialMatch | undefined { return options?.nodeType === 'folder' ? findFolderMaterialMatch(nodeId, manifest, options.pathMatchers) - : findFileMaterialMatch(nodeId, manifest, options?.pathMatchers); + : findFileMaterialMatch(nodeId, manifest, options?.pathMatchers, options?.extensionMatcher); } function findFolderMaterialMatch( @@ -34,6 +39,7 @@ function findFileMaterialMatch( nodeId: string, manifest: MaterialIconManifest, pathMatchers: MaterialThemePathMatchers | undefined, + extensionMatcher: MaterialExtensionMatcher | undefined, ): MaterialMatch | undefined { const baseName = getMaterialBaseName(nodeId); if (!baseName) { @@ -41,7 +47,7 @@ function findFileMaterialMatch( } return findFileNameMaterialMatch(nodeId, manifest, pathMatchers) - ?? findFileExtensionMaterialMatch(baseName, manifest) + ?? findFileExtensionMaterialMatch(baseName, manifest, extensionMatcher) ?? findLanguageMaterialMatch(baseName, manifest); } @@ -58,9 +64,10 @@ function findFileNameMaterialMatch( function findFileExtensionMaterialMatch( baseName: string, manifest: MaterialIconManifest, + extensionMatcher: MaterialExtensionMatcher | undefined, ): MaterialMatch | undefined { return manifest.fileExtensions - ? matchMaterialFileExtension(baseName, manifest.fileExtensions) + ? matchMaterialFileExtension(baseName, manifest.fileExtensions, extensionMatcher) : undefined; } diff --git a/packages/extension/src/extension/graphView/groups/defaults/materialTheme/model.ts b/packages/extension/src/extension/graphView/groups/defaults/materialTheme/model.ts index afcd524e0..debb4a37d 100644 --- a/packages/extension/src/extension/graphView/groups/defaults/materialTheme/model.ts +++ b/packages/extension/src/extension/graphView/groups/defaults/materialTheme/model.ts @@ -1,3 +1,6 @@ +import type { MaterialExtensionMatcher } from './extensionMatch'; +import type { MaterialPathRuleMatcher } from './pathMatch'; + export interface MaterialIconManifest { fileExtensions?: Record; fileNames?: Record; @@ -15,6 +18,7 @@ export interface MaterialIconData { } export interface MaterialThemeCacheEntry { + extensionMatcher?: MaterialExtensionMatcher; iconDataByName: Map; manifest: MaterialIconManifest; manifestPath: string; @@ -34,4 +38,3 @@ export interface MaterialMatch { } export const DEFAULT_MATERIAL_COLOR = '#90A4AE'; -import type { MaterialPathRuleMatcher } from './pathMatch'; diff --git a/packages/extension/src/extension/pipeline/service/discoveryFacade.ts b/packages/extension/src/extension/pipeline/service/discoveryFacade.ts index 3762b5d3b..93c5a1083 100644 --- a/packages/extension/src/extension/pipeline/service/discoveryFacade.ts +++ b/packages/extension/src/extension/pipeline/service/discoveryFacade.ts @@ -26,6 +26,7 @@ import { } from './runtime/run'; import { createEmptyWorkspaceAnalysisCache } from '../cache'; import { createCachedWorkspaceDiscoveryState } from './cache/cachedDiscovery'; +import { recordExtensionPerformanceEvent } from '../../performance/marks'; export abstract class WorkspacePipelineDiscoveryFacade extends WorkspacePipelineInternalBase { private _workspacePluginReloadQueue: Promise = Promise.resolve(); @@ -178,8 +179,14 @@ export abstract class WorkspacePipelineDiscoveryFacade extends WorkspacePipeline disabledPlugins: Set = new Set(), signal?: AbortSignal, ): Promise { + const loadStartedAt = Date.now(); throwIfWorkspaceAnalysisAborted(signal); + let stageStartedAt = Date.now(); await this._hydrateCacheFromGraphCache(); + recordExtensionPerformanceEvent('workspacePipeline.loadCachedGraph.hydrate', { + durationMs: Date.now() - stageStartedAt, + fileCount: Object.keys(this._cache.files).length, + }); throwIfWorkspaceAnalysisAborted(signal); const workspaceRoot = this._getWorkspaceRoot(); @@ -188,6 +195,7 @@ export abstract class WorkspacePipelineDiscoveryFacade extends WorkspacePipeline } const config = this._config.getAll(); + stageStartedAt = Date.now(); throwIfWorkspaceAnalysisAborted(signal); const fileAnalysis = new Map( @@ -197,11 +205,22 @@ export abstract class WorkspacePipelineDiscoveryFacade extends WorkspacePipeline ]), ); const cachedFilePaths = Object.keys(this._cache.files); + recordExtensionPerformanceEvent('workspacePipeline.loadCachedGraph.cacheSnapshot', { + durationMs: Date.now() - stageStartedAt, + fileCount: cachedFilePaths.length, + }); + stageStartedAt = Date.now(); const cachedDiscovery = createCachedWorkspaceDiscoveryState( workspaceRoot, cachedFilePaths, config.respectGitignore, ); + recordExtensionPerformanceEvent('workspacePipeline.loadCachedGraph.cachedDiscovery', { + directoryCount: cachedDiscovery.directories.length, + durationMs: Date.now() - stageStartedAt, + fileCount: cachedDiscovery.files.length, + gitIgnoredPathCount: cachedDiscovery.gitIgnoredPaths.length, + }); this._lastDiscoveredFiles = cachedDiscovery.files; this._lastDiscoveredDirectories = cachedDiscovery.directories; @@ -212,12 +231,25 @@ export abstract class WorkspacePipelineDiscoveryFacade extends WorkspacePipeline throwIfWorkspaceAnalysisAborted(signal); - return this._buildGraphDataFromAnalysis( + stageStartedAt = Date.now(); + const graphData = this._buildGraphDataFromAnalysis( fileAnalysis, workspaceRoot, config.showOrphans, disabledPlugins, ); + recordExtensionPerformanceEvent('workspacePipeline.loadCachedGraph.buildGraph', { + durationMs: Date.now() - stageStartedAt, + edgeCount: graphData.edges.length, + nodeCount: graphData.nodes.length, + }); + recordExtensionPerformanceEvent('workspacePipeline.loadCachedGraph.completed', { + durationMs: Date.now() - loadStartedAt, + edgeCount: graphData.edges.length, + nodeCount: graphData.nodes.length, + }); + + return graphData; } rebuildGraph(disabledPlugins: Set, showOrphans: boolean): IGraphData { diff --git a/packages/extension/tests/extension/graphView/analysis/execution/publish.test.ts b/packages/extension/tests/extension/graphView/analysis/execution/publish.test.ts index c7f500e2e..6a091cf1d 100644 --- a/packages/extension/tests/extension/graphView/analysis/execution/publish.test.ts +++ b/packages/extension/tests/extension/graphView/analysis/execution/publish.test.ts @@ -95,6 +95,40 @@ describe('graph view analysis execution publish', () => { getGraphData(), state.disabledPlugins, ); + expect(performanceMocks.recordExtensionPerformanceEvent).toHaveBeenCalledWith( + 'graphAnalysis.publish.setRawGraphData', + expect.objectContaining({ + durationMs: expect.any(Number), + rawEdgeCount: 0, + rawNodeCount: 1, + }), + ); + expect(performanceMocks.recordExtensionPerformanceEvent).toHaveBeenCalledWith( + 'graphAnalysis.publish.viewTransform', + expect.objectContaining({ + durationMs: expect.any(Number), + }), + ); + expect(performanceMocks.recordExtensionPerformanceEvent).toHaveBeenCalledWith( + 'graphAnalysis.publish.groups', + expect.objectContaining({ + durationMs: expect.any(Number), + }), + ); + expect(performanceMocks.recordExtensionPerformanceEvent).toHaveBeenCalledWith( + 'graphAnalysis.publish.broadcasts', + expect.objectContaining({ + durationMs: expect.any(Number), + }), + ); + expect(performanceMocks.recordExtensionPerformanceEvent).toHaveBeenCalledWith( + 'graphAnalysis.publish.getGraphData', + expect.objectContaining({ + durationMs: expect.any(Number), + edgeCount: 0, + nodeCount: 1, + }), + ); expect(performanceMocks.recordExtensionPerformanceEvent).toHaveBeenCalledWith( 'graphAnalysis.publish.graph', { @@ -107,6 +141,14 @@ describe('graph view analysis execution publish', () => { freshness: 'fresh', }, ); + expect(performanceMocks.recordExtensionPerformanceEvent).toHaveBeenCalledWith( + 'graphAnalysis.publish.sendGraphData', + expect.objectContaining({ + durationMs: expect.any(Number), + edgeCount: 0, + nodeCount: 1, + }), + ); }); it('reports graph view update progress before publishing an explicit index result', () => { diff --git a/packages/extension/tests/extension/graphView/groups/defaults/materialTheme/extensionMatch.test.ts b/packages/extension/tests/extension/graphView/groups/defaults/materialTheme/extensionMatch.test.ts index 91b4826a1..e887ec871 100644 --- a/packages/extension/tests/extension/graphView/groups/defaults/materialTheme/extensionMatch.test.ts +++ b/packages/extension/tests/extension/graphView/groups/defaults/materialTheme/extensionMatch.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { findLongestExtensionMatch } from '../../../../../../src/extension/graphView/groups/defaults/materialTheme/extensionMatch'; +import { + createMaterialExtensionMatcher, + findLongestExtensionMatch, + findLongestExtensionMatchWithMatcher, +} from '../../../../../../src/extension/graphView/groups/defaults/materialTheme/extensionMatch'; describe('graphView/materialTheme/extensionMatch', () => { it('matches bare extension filenames', () => { @@ -29,4 +33,18 @@ describe('graphView/materialTheme/extensionMatch', () => { kind: 'fileExtension', }); }); + + it('reuses a prepared extension matcher while preserving longest-match behavior', () => { + const matcher = createMaterialExtensionMatcher({ + ts: 'typescript', + 'test.ts': 'test-typescript', + 'd.test.ts': 'definition-test', + }); + + expect(findLongestExtensionMatchWithMatcher('main.d.test.ts', matcher)).toEqual({ + iconName: 'definition-test', + key: 'd.test.ts', + kind: 'fileExtension', + }); + }); }); diff --git a/packages/extension/tests/extension/pipeline/service/discoveryFacade.test.ts b/packages/extension/tests/extension/pipeline/service/discoveryFacade.test.ts index 9c8de1233..1e484f37b 100644 --- a/packages/extension/tests/extension/pipeline/service/discoveryFacade.test.ts +++ b/packages/extension/tests/extension/pipeline/service/discoveryFacade.test.ts @@ -22,6 +22,10 @@ import { rebuildWorkspacePipelineGraph, } from '../../../../src/extension/pipeline/service/runtime/run'; +const performanceMocks = vi.hoisted(() => ({ + recordExtensionPerformanceEvent: vi.fn(), +})); + vi.mock('../../../../src/extension/pipeline/service/runtime/discovery', () => ({ createWorkspacePipelineDiscoveryDependencies: vi.fn(), discoverWorkspacePipelineFilesWithWarnings: vi.fn(), @@ -46,6 +50,10 @@ vi.mock('node:child_process', () => ({ spawnSync: vi.fn(), })); +vi.mock('../../../../src/extension/performance/marks', () => ({ + recordExtensionPerformanceEvent: performanceMocks.recordExtensionPerformanceEvent, +})); + vi.mock('vscode', () => ({ workspace: { workspaceFolders: [{ uri: { fsPath: '/workspace' } }], @@ -134,6 +142,7 @@ describe('pipeline/service/discoveryFacade', () => { beforeEach(() => { vi.clearAllMocks(); + performanceMocks.recordExtensionPerformanceEvent.mockReset(); vi.mocked(spawnSync).mockReturnValue({ error: undefined, status: 1, @@ -549,4 +558,71 @@ describe('pipeline/service/discoveryFacade', () => { ); expect(discoveryState(facade)._lastGitIgnoredPaths).toEqual(['example-python/src/main.py']); }); + + it('records cached graph load stage performance markers', async () => { + const facade = new TestDiscoveryFacade(); + const cachedAnalysis = { + filePath: '/workspace/src/cached.ts', + relations: [], + }; + facade._cache = { + version: 'test', + files: { + 'src/cached.ts': { + mtime: 1, + analysis: cachedAnalysis, + }, + }, + } as never; + vi.spyOn( + facade as unknown as { + _buildGraphDataFromAnalysis: (...args: unknown[]) => unknown; + }, + '_buildGraphDataFromAnalysis', + ).mockReturnValue({ + nodes: [{ id: 'src/cached.ts', label: 'cached.ts', color: '#333333' }], + edges: [], + }); + + await facade.loadCachedGraph(); + + expect(performanceMocks.recordExtensionPerformanceEvent).toHaveBeenCalledWith( + 'workspacePipeline.loadCachedGraph.hydrate', + expect.objectContaining({ + durationMs: expect.any(Number), + fileCount: 1, + }), + ); + expect(performanceMocks.recordExtensionPerformanceEvent).toHaveBeenCalledWith( + 'workspacePipeline.loadCachedGraph.cacheSnapshot', + expect.objectContaining({ + durationMs: expect.any(Number), + fileCount: 1, + }), + ); + expect(performanceMocks.recordExtensionPerformanceEvent).toHaveBeenCalledWith( + 'workspacePipeline.loadCachedGraph.cachedDiscovery', + expect.objectContaining({ + directoryCount: 1, + durationMs: expect.any(Number), + fileCount: 1, + }), + ); + expect(performanceMocks.recordExtensionPerformanceEvent).toHaveBeenCalledWith( + 'workspacePipeline.loadCachedGraph.buildGraph', + expect.objectContaining({ + durationMs: expect.any(Number), + edgeCount: 0, + nodeCount: 1, + }), + ); + expect(performanceMocks.recordExtensionPerformanceEvent).toHaveBeenCalledWith( + 'workspacePipeline.loadCachedGraph.completed', + expect.objectContaining({ + durationMs: expect.any(Number), + edgeCount: 0, + nodeCount: 1, + }), + ); + }); }); From c9a0ac47343bd7536874fbdcecf52b95e1c9dada Mon Sep 17 00:00:00 2001 From: joesobo Date: Mon, 22 Jun 2026 22:23:25 -0700 Subject: [PATCH 040/192] perf: defer gitignore probe for stale graph replay --- .changeset/material-extension-matcher.md | 2 +- docs/performance/codegraphy-monorepo.md | 11 ++++++ .../2026-06-22-codegraphy-performance.md | 1 + .../extension/graphView/analysis/execution.ts | 5 +++ .../graphView/analysis/execution/load.ts | 4 ++- .../analysis/execution/load/analyzerData.ts | 3 ++ .../pipeline/service/discoveryFacade.ts | 9 ++++- .../graphView/analysis/execution/load.test.ts | 14 ++++++-- .../pipeline/service/discoveryFacade.test.ts | 36 +++++++++++++++++++ 9 files changed, 80 insertions(+), 5 deletions(-) diff --git a/.changeset/material-extension-matcher.md b/.changeset/material-extension-matcher.md index 3247f83ec..2608909da 100644 --- a/.changeset/material-extension-matcher.md +++ b/.changeset/material-extension-matcher.md @@ -2,4 +2,4 @@ "@codegraphy-dev/extension": patch --- -Speed up startup legend generation by reusing the prepared Material Icon extension matcher. +Speed up stale cached startup by reusing the prepared Material Icon extension matcher and deferring live gitignore probing until the background index refresh. diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index 6c872be75..c20c56a50 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -668,6 +668,17 @@ Interpretation: reusing a prepared extension matcher from the cached material theme. The cached `load` publish moved from `2279ms` to `1696ms`, and first graph readiness moved from `5823ms` to `5617ms` in the one-sample harness. +- Stale cached replay now defers live gitignore metadata because load mode + immediately starts a background full analysis for stale indexes. Fresh cached + replay still includes live gitignore metadata because no background + correction is guaranteed. On the CodeGraphy monorepo benchmark this moved + `workspacePipeline.loadCachedGraph.cachedDiscovery` from `324ms` to `11ms`, + `loadCachedGraph.completed` from `836ms` to `497ms`, cached load request + completion from `1007ms` to `672ms`, cached load publish from `1696ms` to + `626ms`, and first graph readiness from `5617ms` to `5266ms`. The first + rendered stats stayed stable at `2300` nodes and `5345` edges; the stale + replay raw graph omitted ignored-only folder metadata until the background + analysis sent the follow-up graph update. Full test baseline: diff --git a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md index e95772f3f..084ee4b7f 100644 --- a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md +++ b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md @@ -311,6 +311,7 @@ Added analysis request/publish lifecycle markers: latest startup trace shows the Gated incremental analysis behind first workspace readiness: focused provider test failed red when `incremental:start` raced an unresolved `load:start`, then passed after incremental waited for first ready; latest VS Code trace publishes cached `load` at 9.86s before incremental starts, moving first publish from 36.8s analyze to 9.86s load and first graph readiness from 40.6s to 13.7s in the one-sample harness. Skipped full workspace discovery during cached Graph Cache replay: focused facade test failed red on the old discovery call, then passed with cached-path metadata; direct replacement metadata probe is 322ms vs 4083ms full discovery, and latest VS Code trace publishes cached `load` at 2.40s with first graph readiness 5.88s while visible stats remain 2300 nodes / 5345 edges. Added cached-load/publish stage markers and reused a prepared Material Icon extension matcher: publish `groups` dropped from 748ms to 96ms, cached `load` publish moved from 2.28s to 1.70s, first graph readiness moved from 5.82s to 5.62s, and Imports sanity check measured 209ms wall-clock / 58ms in-webview. +Deferred live gitignore probing only for stale cached replay: cached discovery dropped from 324ms to 11ms, cached load completion from 836ms to 497ms, cached load publish from 1.70s to 0.63s, first graph readiness from 5.62s to 5.27s, and visible stats stayed 2300 nodes / 5345 edges while background analysis handled exact ignored metadata. ``` ## Task 5: Keep The PR Reviewable diff --git a/packages/extension/src/extension/graphView/analysis/execution.ts b/packages/extension/src/extension/graphView/analysis/execution.ts index 284191629..7b3e0d3e9 100644 --- a/packages/extension/src/extension/graphView/analysis/execution.ts +++ b/packages/extension/src/extension/graphView/analysis/execution.ts @@ -8,6 +8,10 @@ import type { CodeGraphyIndexFreshness } from '../../repoSettings/freshness'; export type GraphViewAnalysisMode = 'analyze' | 'load' | 'index' | 'refresh' | 'incremental'; export type GraphViewIndexingProgress = { phase: string; current: number; total: number }; +export interface GraphViewCachedGraphLoadOptions { + includeCurrentGitignoreMetadata?: boolean; +} + interface GraphViewAnalyzerLike { initialize(): Promise; hasIndex(): boolean; @@ -24,6 +28,7 @@ interface GraphViewAnalyzerLike { filterPatterns?: string[], disabledPlugins?: Set, signal?: AbortSignal, + options?: GraphViewCachedGraphLoadOptions, ): Promise; analyze( filterPatterns?: string[], diff --git a/packages/extension/src/extension/graphView/analysis/execution/load.ts b/packages/extension/src/extension/graphView/analysis/execution/load.ts index 742311eda..81a8e7888 100644 --- a/packages/extension/src/extension/graphView/analysis/execution/load.ts +++ b/packages/extension/src/extension/graphView/analysis/execution/load.ts @@ -65,7 +65,9 @@ export async function loadGraphViewRawData( } if (decision.route === 'cached') { - const cachedGraphData = await loadCachedGraphViewRawData(signal, state, analyzer); + const cachedGraphData = await loadCachedGraphViewRawData(signal, state, analyzer, { + includeCurrentGitignoreMetadata: indexFreshness !== 'stale', + }); if (hasReplayableGraphData(cachedGraphData)) { return { rawGraphData: cachedGraphData, diff --git a/packages/extension/src/extension/graphView/analysis/execution/load/analyzerData.ts b/packages/extension/src/extension/graphView/analysis/execution/load/analyzerData.ts index 136d7203e..1c7ff55fd 100644 --- a/packages/extension/src/extension/graphView/analysis/execution/load/analyzerData.ts +++ b/packages/extension/src/extension/graphView/analysis/execution/load/analyzerData.ts @@ -1,5 +1,6 @@ import type { IGraphData } from '../../../../../shared/graph/contracts'; import type { + GraphViewCachedGraphLoadOptions, GraphViewAnalysisExecutionState, GraphViewIndexingProgress, } from '../../execution'; @@ -37,10 +38,12 @@ export async function loadCachedGraphViewRawData( signal: AbortSignal, state: GraphViewAnalysisExecutionState, analyzer: GraphViewAnalyzer, + options?: GraphViewCachedGraphLoadOptions, ): Promise { return (await analyzer.loadCachedGraph?.( state.filterPatterns, state.disabledPlugins, signal, + options, )) ?? EMPTY_GRAPH_DATA; } diff --git a/packages/extension/src/extension/pipeline/service/discoveryFacade.ts b/packages/extension/src/extension/pipeline/service/discoveryFacade.ts index 93c5a1083..558413757 100644 --- a/packages/extension/src/extension/pipeline/service/discoveryFacade.ts +++ b/packages/extension/src/extension/pipeline/service/discoveryFacade.ts @@ -28,6 +28,10 @@ import { createEmptyWorkspaceAnalysisCache } from '../cache'; import { createCachedWorkspaceDiscoveryState } from './cache/cachedDiscovery'; import { recordExtensionPerformanceEvent } from '../../performance/marks'; +export interface WorkspacePipelineCachedGraphLoadOptions { + includeCurrentGitignoreMetadata?: boolean; +} + export abstract class WorkspacePipelineDiscoveryFacade extends WorkspacePipelineInternalBase { private _workspacePluginReloadQueue: Promise = Promise.resolve(); @@ -178,6 +182,7 @@ export abstract class WorkspacePipelineDiscoveryFacade extends WorkspacePipeline _filterPatterns: string[] = [], disabledPlugins: Set = new Set(), signal?: AbortSignal, + options: WorkspacePipelineCachedGraphLoadOptions = {}, ): Promise { const loadStartedAt = Date.now(); throwIfWorkspaceAnalysisAborted(signal); @@ -210,16 +215,18 @@ export abstract class WorkspacePipelineDiscoveryFacade extends WorkspacePipeline fileCount: cachedFilePaths.length, }); stageStartedAt = Date.now(); + const includeCurrentGitignoreMetadata = options.includeCurrentGitignoreMetadata !== false; const cachedDiscovery = createCachedWorkspaceDiscoveryState( workspaceRoot, cachedFilePaths, - config.respectGitignore, + config.respectGitignore && includeCurrentGitignoreMetadata, ); recordExtensionPerformanceEvent('workspacePipeline.loadCachedGraph.cachedDiscovery', { directoryCount: cachedDiscovery.directories.length, durationMs: Date.now() - stageStartedAt, fileCount: cachedDiscovery.files.length, gitIgnoredPathCount: cachedDiscovery.gitIgnoredPaths.length, + includeCurrentGitignoreMetadata, }); this._lastDiscoveredFiles = cachedDiscovery.files; diff --git a/packages/extension/tests/extension/graphView/analysis/execution/load.test.ts b/packages/extension/tests/extension/graphView/analysis/execution/load.test.ts index 84a81ce83..b73475d83 100644 --- a/packages/extension/tests/extension/graphView/analysis/execution/load.test.ts +++ b/packages/extension/tests/extension/graphView/analysis/execution/load.test.ts @@ -95,7 +95,12 @@ describe('graph view analysis execution load', () => { expect(result.shouldDiscover).toBe(false); expect(result.rawGraphData).toEqual(cachedGraph); - expect(loadCachedGraph).toHaveBeenCalledOnce(); + expect(loadCachedGraph).toHaveBeenCalledWith( + [], + new Set(), + expect.any(AbortSignal), + { includeCurrentGitignoreMetadata: true }, + ); expect(analyze).not.toHaveBeenCalled(); expect(refreshIndex).not.toHaveBeenCalled(); expect(handlers.emitDiagnostic).toHaveBeenCalledWith({ @@ -145,7 +150,12 @@ describe('graph view analysis execution load', () => { expect(result.shouldDiscover).toBe(false); expect(result.rawGraphData).toEqual(cachedGraph); - expect(loadCachedGraph).toHaveBeenCalledOnce(); + expect(loadCachedGraph).toHaveBeenCalledWith( + [], + new Set(), + expect.any(AbortSignal), + { includeCurrentGitignoreMetadata: false }, + ); expect(refreshIndex).not.toHaveBeenCalled(); expect(analyze).not.toHaveBeenCalled(); }); diff --git a/packages/extension/tests/extension/pipeline/service/discoveryFacade.test.ts b/packages/extension/tests/extension/pipeline/service/discoveryFacade.test.ts index 1e484f37b..3d01196cd 100644 --- a/packages/extension/tests/extension/pipeline/service/discoveryFacade.test.ts +++ b/packages/extension/tests/extension/pipeline/service/discoveryFacade.test.ts @@ -559,6 +559,42 @@ describe('pipeline/service/discoveryFacade', () => { expect(discoveryState(facade)._lastGitIgnoredPaths).toEqual(['example-python/src/main.py']); }); + it('can defer current gitignore metadata while replaying stale cached graph data', async () => { + const facade = new TestDiscoveryFacade(); + const cachedAnalysis = { + filePath: '/workspace/example-python/src/main.py', + relations: [], + }; + facade._cache = { + version: 'test', + files: { + 'example-python/src/main.py': { + mtime: 1, + analysis: cachedAnalysis, + }, + }, + } as never; + vi.spyOn( + facade as unknown as { + _buildGraphDataFromAnalysis: (...args: unknown[]) => unknown; + }, + '_buildGraphDataFromAnalysis', + ).mockReturnValue({ + nodes: [{ id: 'example-python/src/main.py', label: 'main.py', color: '#333333' }], + edges: [], + }); + + await facade.loadCachedGraph( + [], + new Set(), + undefined, + { includeCurrentGitignoreMetadata: false }, + ); + + expect(spawnSync).not.toHaveBeenCalled(); + expect(discoveryState(facade)._lastGitIgnoredPaths).toEqual([]); + }); + it('records cached graph load stage performance markers', async () => { const facade = new TestDiscoveryFacade(); const cachedAnalysis = { From 3c4ba01faea8c036bfab153a82fe7075e8cef2ca Mon Sep 17 00:00:00 2001 From: joesobo Date: Mon, 22 Jun 2026 22:36:02 -0700 Subject: [PATCH 041/192] perf: warm graph cache before first replay CodeGraphy monorepo VS Code benchmark: loadCachedGraph.hydrate 406ms -> 170ms, loadCachedGraph.completed 497ms -> 259ms, cached load request 672ms -> 429ms, first graph readiness 5266ms -> 5114ms. Visible stats stayed 2300 nodes / 5345 edges. --- .changeset/material-extension-matcher.md | 2 +- docs/performance/codegraphy-monorepo.md | 10 ++++ .../2026-06-22-codegraphy-performance.md | 1 + .../graphView/provider/runtime/state/model.ts | 3 ++ .../extension/pipeline/service/base/state.ts | 4 ++ .../provider/runtime/state/model.test.ts | 4 ++ .../pipeline/service/base/state.test.ts | 47 +++++++++++++++++++ 7 files changed, 70 insertions(+), 1 deletion(-) diff --git a/.changeset/material-extension-matcher.md b/.changeset/material-extension-matcher.md index 2608909da..2db91455e 100644 --- a/.changeset/material-extension-matcher.md +++ b/.changeset/material-extension-matcher.md @@ -2,4 +2,4 @@ "@codegraphy-dev/extension": patch --- -Speed up stale cached startup by reusing the prepared Material Icon extension matcher and deferring live gitignore probing until the background index refresh. +Speed up stale cached startup by reusing the prepared Material Icon extension matcher, deferring live gitignore probing until the background index refresh, and warming the repo-local Graph Cache before the first replay needs it. diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index c20c56a50..22a10c911 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -679,6 +679,16 @@ Interpretation: rendered stats stayed stable at `2300` nodes and `5345` edges; the stale replay raw graph omitted ignored-only folder metadata until the background analysis sent the follow-up graph update. +- Graph Cache hydration now overlaps the period between analyzer construction + and the first cached load request. The first request still observes the same + cache freshness and replay semantics, but it spends less time waiting for the + repo-local cache file once the webview asks for graph data. On the CodeGraphy + monorepo benchmark this moved `workspacePipeline.loadCachedGraph.hydrate` + from `406ms` to `170ms`, `loadCachedGraph.completed` from `497ms` to + `259ms`, cached load request completion from `672ms` to `429ms`, and first + graph readiness from `5266ms` to `5114ms`. The first rendered stats stayed + stable at `2300` nodes and `5345` edges; Imports toggle latency stayed in the + same snappy band at `197ms` wall-clock and `54ms` in-webview. Full test baseline: diff --git a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md index 084ee4b7f..dcb914b4a 100644 --- a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md +++ b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md @@ -312,6 +312,7 @@ Gated incremental analysis behind first workspace readiness: focused provider te Skipped full workspace discovery during cached Graph Cache replay: focused facade test failed red on the old discovery call, then passed with cached-path metadata; direct replacement metadata probe is 322ms vs 4083ms full discovery, and latest VS Code trace publishes cached `load` at 2.40s with first graph readiness 5.88s while visible stats remain 2300 nodes / 5345 edges. Added cached-load/publish stage markers and reused a prepared Material Icon extension matcher: publish `groups` dropped from 748ms to 96ms, cached `load` publish moved from 2.28s to 1.70s, first graph readiness moved from 5.82s to 5.62s, and Imports sanity check measured 209ms wall-clock / 58ms in-webview. Deferred live gitignore probing only for stale cached replay: cached discovery dropped from 324ms to 11ms, cached load completion from 836ms to 497ms, cached load publish from 1.70s to 0.63s, first graph readiness from 5.62s to 5.27s, and visible stats stayed 2300 nodes / 5345 edges while background analysis handled exact ignored metadata. +Warmed the repo-local Graph Cache when the Graph View runtime creates its analyzer: hydration dropped from 406ms to 170ms, cached load completion from 497ms to 259ms, cached load request completion from 672ms to 429ms, first graph readiness from 5266ms to 5114ms, and visible stats stayed 2300 nodes / 5345 edges. ``` ## Task 5: Keep The PR Reviewable diff --git a/packages/extension/src/extension/graphView/provider/runtime/state/model.ts b/packages/extension/src/extension/graphView/provider/runtime/state/model.ts index a864f0af0..45b98da35 100644 --- a/packages/extension/src/extension/graphView/provider/runtime/state/model.ts +++ b/packages/extension/src/extension/graphView/provider/runtime/state/model.ts @@ -112,6 +112,9 @@ export class GraphViewProviderRuntime { Object.assign(this, createGraphViewProviderRuntimeFlagState()); this._analyzer = new WorkspacePipeline(_context); + void this._analyzer.warmGraphCache().catch(error => { + console.warn('[CodeGraphy] Failed to warm repo-local Graph Cache.', error); + }); this._viewRegistry = new ViewRegistry(); this._eventBus = new EventBus(); this._decorationManager = new DecorationManager(); diff --git a/packages/extension/src/extension/pipeline/service/base/state.ts b/packages/extension/src/extension/pipeline/service/base/state.ts index 90409bedd..ce7ff280d 100644 --- a/packages/extension/src/extension/pipeline/service/base/state.ts +++ b/packages/extension/src/extension/pipeline/service/base/state.ts @@ -107,6 +107,10 @@ export abstract class WorkspacePipelineStateBase { return this._lastFileAnalysis; } + async warmGraphCache(): Promise { + await this._hydrateCacheFromGraphCache(); + } + readStructuredAnalysisSnapshot(): WorkspaceAnalysisDatabaseSnapshot { const workspaceRoot = this._getWorkspaceRoot(); if (!workspaceRoot) { diff --git a/packages/extension/tests/extension/graphView/provider/runtime/state/model.test.ts b/packages/extension/tests/extension/graphView/provider/runtime/state/model.test.ts index 2d4c0875d..18dd050cc 100644 --- a/packages/extension/tests/extension/graphView/provider/runtime/state/model.test.ts +++ b/packages/extension/tests/extension/graphView/provider/runtime/state/model.test.ts @@ -10,6 +10,7 @@ const stateHarness = vi.hoisted(() => { analyzerInstances: [] as Array<{ context: unknown; invalidateWorkspaceFiles: ReturnType; + warmGraphCache: ReturnType; }>, viewRegistryInstances: [] as Array<{ id: string }>, decorationManagerInstances: [] as Array<{ id: string }>, @@ -115,11 +116,13 @@ vi.mock('vscode', () => ({ vi.mock('../../../../../../src/extension/pipeline/service/lifecycleFacade', () => ({ WorkspacePipeline: class WorkspacePipeline { invalidateWorkspaceFiles = vi.fn((filePaths: readonly string[]) => [...filePaths]); + warmGraphCache = vi.fn(async () => undefined); constructor(context: unknown) { stateHarness.analyzerInstances.push({ context, invalidateWorkspaceFiles: this.invalidateWorkspaceFiles, + warmGraphCache: this.warmGraphCache, }); } }, @@ -306,6 +309,7 @@ describe('graphView/provider/runtime/state/model', () => { ]; expect(stateHarness.analyzerInstances).toHaveLength(1); + expect(stateHarness.analyzerInstances[0]?.warmGraphCache).toHaveBeenCalledOnce(); expect(stateHarness.initializeRuntimeStateServices).toHaveBeenCalledOnce(); expect(stateHarness.restorePersistedRuntimeState).toHaveBeenCalledWith( context, diff --git a/packages/extension/tests/extension/pipeline/service/base/state.test.ts b/packages/extension/tests/extension/pipeline/service/base/state.test.ts index 34faed81a..d7e203441 100644 --- a/packages/extension/tests/extension/pipeline/service/base/state.test.ts +++ b/packages/extension/tests/extension/pipeline/service/base/state.test.ts @@ -6,10 +6,12 @@ import { PluginRegistry } from '../../../../../src/core/plugins/registry/manager import { WorkspacePipelineStateBase } from '../../../../../src/extension/pipeline/service/base/state'; const stateBaseHarness = vi.hoisted(() => ({ + loadWorkspaceAnalysisDatabaseCacheAsync: vi.fn(), readWorkspaceAnalysisDatabaseSnapshot: vi.fn(), })); vi.mock('../../../../../src/extension/pipeline/database/cache/storage.ts', () => ({ + loadWorkspaceAnalysisDatabaseCacheAsync: stateBaseHarness.loadWorkspaceAnalysisDatabaseCacheAsync, readWorkspaceAnalysisDatabaseSnapshot: stateBaseHarness.readWorkspaceAnalysisDatabaseSnapshot, })); @@ -38,6 +40,10 @@ function createContext(): vscode.ExtensionContext { } describe('extension/pipeline/service/stateBase', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it('initializes core collaborators and returns an empty structured snapshot without a workspace root', () => { Object.defineProperty(vscode.workspace, 'workspaceFolders', { configurable: true, @@ -97,6 +103,47 @@ describe('extension/pipeline/service/stateBase', () => { expect(state._discovery).toBeInstanceOf(FileDiscovery); }); + it('warms the repo-local Graph Cache using the shared hydration promise', async () => { + let resolveHydration!: (cache: unknown) => void; + stateBaseHarness.loadWorkspaceAnalysisDatabaseCacheAsync.mockReturnValueOnce( + new Promise(resolve => { + resolveHydration = resolve; + }), + ); + const state = new TestWorkspacePipelineState(createContext(), '/workspace') as TestWorkspacePipelineState & { + _cache: unknown; + warmGraphCache(): Promise; + }; + + const firstWarm = state.warmGraphCache(); + const secondWarm = state.warmGraphCache(); + + expect(stateBaseHarness.loadWorkspaceAnalysisDatabaseCacheAsync).toHaveBeenCalledOnce(); + expect(stateBaseHarness.loadWorkspaceAnalysisDatabaseCacheAsync).toHaveBeenCalledWith('/workspace'); + + resolveHydration({ + version: '2.1.0', + files: { + 'src/app.ts': { + mtime: 1, + analysis: { filePath: '/workspace/src/app.ts', relations: [] }, + }, + }, + }); + + await Promise.all([firstWarm, secondWarm]); + + expect(state._cache).toEqual({ + version: '2.1.0', + files: { + 'src/app.ts': { + mtime: 1, + analysis: { filePath: '/workspace/src/app.ts', relations: [] }, + }, + }, + }); + }); + it('stores retained indexing fields in the core engine state', () => { const state = new TestWorkspacePipelineState(createContext()) as TestWorkspacePipelineState & { _cache: { files: Record }; From 2da7950e36fefafb96619d05a1388119024606f4 Mon Sep 17 00:00:00 2001 From: joesobo Date: Mon, 22 Jun 2026 22:55:56 -0700 Subject: [PATCH 042/192] perf: reuse discovery for live file updates Existing-file live update probe on packages/extension/src/extension/graphViewProvider.ts: full-discovery control 3854ms wall / 3149ms request / 1900ms discovery; cached-discovery fast path 1887ms wall / 1180ms request / 0ms discovery. Added the live-update probe to the VS Code graph-view benchmark. --- .changeset/material-extension-matcher.md | 2 +- docs/performance/codegraphy-monorepo.md | 10 ++ .../2026-06-22-codegraphy-performance.md | 1 + .../pipeline/service/refreshFacade.ts | 103 +++++++++-- .../pipeline/service/refreshFacade.test.ts | 38 ++++ .../performance/measure-vscode-graph-view.mjs | 168 +++++++++++++++++- .../measure-vscode-graph-view.test.mjs | 93 ++++++++++ 7 files changed, 399 insertions(+), 16 deletions(-) diff --git a/.changeset/material-extension-matcher.md b/.changeset/material-extension-matcher.md index 2db91455e..3b373c062 100644 --- a/.changeset/material-extension-matcher.md +++ b/.changeset/material-extension-matcher.md @@ -2,4 +2,4 @@ "@codegraphy-dev/extension": patch --- -Speed up stale cached startup by reusing the prepared Material Icon extension matcher, deferring live gitignore probing until the background index refresh, and warming the repo-local Graph Cache before the first replay needs it. +Speed up stale cached startup and existing-file live updates by reusing prepared startup state, deferring live gitignore probing until the background index refresh, warming the repo-local Graph Cache before the first replay needs it, and reusing current discovery for saved-file refreshes. diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index 22a10c911..136554939 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -689,6 +689,16 @@ Interpretation: graph readiness from `5266ms` to `5114ms`. The first rendered stats stayed stable at `2300` nodes and `5345` edges; Imports toggle latency stayed in the same snappy band at `197ms` wall-clock and `54ms` in-webview. +- Existing-file live updates now reuse the current discovered-file snapshot + instead of rediscovering the full workspace before changed-file analysis. A + VS Code harness probe against `packages/extension/src/extension/graphViewProvider.ts` + used `/tmp` for the extension-host performance log so CodeGraphy did not + watch its own metrics file. With the shortcut temporarily disabled, the probe + measured `3854ms` wall-clock and `3149ms` request time, including a `1900ms` + full discovery pass. With cached discovery enabled, the same probe measured + `1887ms` wall-clock and `1180ms` request time; discovery mode was `cached` + and took `0ms`. The graph size after the refresh stayed in the same + `5101` node / `9146` edge raw graph band. Full test baseline: diff --git a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md index dcb914b4a..6cf96b525 100644 --- a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md +++ b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md @@ -313,6 +313,7 @@ Skipped full workspace discovery during cached Graph Cache replay: focused facad Added cached-load/publish stage markers and reused a prepared Material Icon extension matcher: publish `groups` dropped from 748ms to 96ms, cached `load` publish moved from 2.28s to 1.70s, first graph readiness moved from 5.82s to 5.62s, and Imports sanity check measured 209ms wall-clock / 58ms in-webview. Deferred live gitignore probing only for stale cached replay: cached discovery dropped from 324ms to 11ms, cached load completion from 836ms to 497ms, cached load publish from 1.70s to 0.63s, first graph readiness from 5.62s to 5.27s, and visible stats stayed 2300 nodes / 5345 edges while background analysis handled exact ignored metadata. Warmed the repo-local Graph Cache when the Graph View runtime creates its analyzer: hydration dropped from 406ms to 170ms, cached load completion from 497ms to 259ms, cached load request completion from 672ms to 429ms, first graph readiness from 5266ms to 5114ms, and visible stats stayed 2300 nodes / 5345 edges. +Reused current discovery for existing-file live updates and added a live-update VS Code probe: full-discovery control was 3854ms wall / 3149ms request with 1900ms discovery; cached-discovery fast path was 1887ms wall / 1180ms request with 0ms discovery. ``` ## Task 5: Keep The PR Reviewable diff --git a/packages/extension/src/extension/pipeline/service/refreshFacade.ts b/packages/extension/src/extension/pipeline/service/refreshFacade.ts index 699762075..5c451d149 100644 --- a/packages/extension/src/extension/pipeline/service/refreshFacade.ts +++ b/packages/extension/src/extension/pipeline/service/refreshFacade.ts @@ -1,3 +1,5 @@ +import fs from 'node:fs'; +import path from 'node:path'; import * as vscode from 'vscode'; import { hasRequiredAnalysisCacheTiers, @@ -5,6 +7,7 @@ import { } from '@codegraphy-dev/core'; import type { IGraphData } from '../../../shared/graph/contracts'; import { WorkspacePipelineDiscoveryFacade } from './discoveryFacade'; +import { recordExtensionPerformanceEvent } from '../../performance/marks'; import { createWorkspacePipelineAnalysisCacheTiers } from './cache/tiers'; import { createWorkspacePipelineDiscoveryDependencies, @@ -17,6 +20,11 @@ import { type WorkspacePipelineRefreshSource, } from './runtime/refresh'; +interface ChangedFileDiscoveryState { + directories: string[]; + files: IDiscoveredFile[]; +} + export abstract class WorkspacePipelineRefreshFacade extends WorkspacePipelineDiscoveryFacade { private _createWorkspaceIndexRefreshSource( disabledPlugins: Set = new Set(), @@ -141,6 +149,45 @@ export abstract class WorkspacePipelineRefreshFacade extends WorkspacePipelineDi return graphData; } + private _getReusableChangedFileDiscoveryState( + workspaceRoot: string, + filePaths: readonly string[], + ): ChangedFileDiscoveryState | undefined { + if ( + filePaths.length === 0 + || this._lastWorkspaceRoot !== workspaceRoot + || this._lastDiscoveredFiles.length === 0 + ) { + return undefined; + } + + const discoveredByRelativePath = new Map( + this._lastDiscoveredFiles.map(file => [ + file.relativePath.replace(/\\/g, '/'), + file, + ]), + ); + + for (const filePath of filePaths) { + const relativePath = this._toWorkspaceRelativePath(workspaceRoot, filePath); + if (!relativePath || !discoveredByRelativePath.has(relativePath)) { + return undefined; + } + + const absolutePath = path.isAbsolute(filePath) + ? filePath + : path.join(workspaceRoot, filePath); + if (!fs.existsSync(absolutePath)) { + return undefined; + } + } + + return { + directories: [...this._lastDiscoveredDirectories], + files: this._lastDiscoveredFiles, + }; + } + async refreshAnalysisScope( filterPatterns: string[] = [], disabledPlugins: Set = new Set(), @@ -249,6 +296,7 @@ export abstract class WorkspacePipelineRefreshFacade extends WorkspacePipelineDi signal?: AbortSignal, onProgress?: (progress: { phase: string; current: number; total: number }) => void, ): Promise { + const refreshStartedAt = Date.now(); const workspaceRoot = this._getWorkspaceRoot(); if (!workspaceRoot) { return { nodes: [], edges: [] }; @@ -257,24 +305,45 @@ export abstract class WorkspacePipelineRefreshFacade extends WorkspacePipelineDi const config = this._config.getAll(); const disabledCustomPatterns = new Set(config.disabledCustomFilterPatterns); const disabledPluginPatterns = new Set(config.disabledPluginFilterPatterns); - const discoveryResult = await discoverWorkspacePipelineFilesWithWarnings( - createWorkspacePipelineDiscoveryDependencies(this._discovery), + const reusableDiscoveryState = this._getReusableChangedFileDiscoveryState( workspaceRoot, - config, - filterPatterns.filter(pattern => !disabledCustomPatterns.has(pattern)), - this.getPluginFilterPatterns(disabledPlugins) - .filter(pattern => !disabledPluginPatterns.has(pattern)), - signal, - message => { - vscode.window.showWarningMessage(message); - }, + filePaths, ); - this._lastDiscoveredDirectories = discoveryResult.directories ?? []; - this._lastGitIgnoredPaths = discoveryResult.gitIgnoredPaths ?? []; + const discoveryStartedAt = Date.now(); + let discoveryResult: ChangedFileDiscoveryState; + if (reusableDiscoveryState) { + discoveryResult = reusableDiscoveryState; + } else { + const discovered = await discoverWorkspacePipelineFilesWithWarnings( + createWorkspacePipelineDiscoveryDependencies(this._discovery), + workspaceRoot, + config, + filterPatterns.filter(pattern => !disabledCustomPatterns.has(pattern)), + this.getPluginFilterPatterns(disabledPlugins) + .filter(pattern => !disabledPluginPatterns.has(pattern)), + signal, + message => { + vscode.window.showWarningMessage(message); + }, + ); + discoveryResult = { + directories: discovered.directories ?? [], + files: discovered.files, + }; + this._lastDiscoveredDirectories = discoveryResult.directories; + this._lastGitIgnoredPaths = discovered.gitIgnoredPaths ?? []; + } + recordExtensionPerformanceEvent('workspacePipeline.refreshChangedFiles.discovery', { + changedFileCount: filePaths.length, + directoryCount: discoveryResult.directories?.length ?? 0, + durationMs: Date.now() - discoveryStartedAt, + fileCount: discoveryResult.files.length, + mode: reusableDiscoveryState ? 'cached' : 'discover', + }); - return refreshWorkspacePipelineChangedFiles(this._createWorkspaceIndexRefreshSource(disabledPlugins), { + const graphData = await refreshWorkspacePipelineChangedFiles(this._createWorkspaceIndexRefreshSource(disabledPlugins), { disabledPlugins, - discoveredDirectories: discoveryResult.directories ?? [], + discoveredDirectories: discoveryResult.directories, discoveredFiles: discoveryResult.files, filePaths, filterPatterns, @@ -300,6 +369,12 @@ export abstract class WorkspacePipelineRefreshFacade extends WorkspacePipelineDi signal, workspaceRoot, }); + recordExtensionPerformanceEvent('workspacePipeline.refreshChangedFiles.completed', { + durationMs: Date.now() - refreshStartedAt, + edgeCount: graphData.edges.length, + nodeCount: graphData.nodes.length, + }); + return graphData; } async refreshGitignoreMetadata( diff --git a/packages/extension/tests/extension/pipeline/service/refreshFacade.test.ts b/packages/extension/tests/extension/pipeline/service/refreshFacade.test.ts index a5f6933ce..3c7031cf8 100644 --- a/packages/extension/tests/extension/pipeline/service/refreshFacade.test.ts +++ b/packages/extension/tests/extension/pipeline/service/refreshFacade.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'; +import fs from 'node:fs'; import * as vscode from 'vscode'; import { WorkspacePipelineRefreshFacade } from '../../../../src/extension/pipeline/service/refreshFacade'; import { @@ -76,6 +77,14 @@ class TestRefreshFacade extends WorkspacePipelineRefreshFacade { super._lastDiscoveredFiles = files; } + public override get _lastDiscoveredDirectories(): string[] { + return super._lastDiscoveredDirectories; + } + + public override set _lastDiscoveredDirectories(directories: string[]) { + super._lastDiscoveredDirectories = directories; + } + public override get _lastGitIgnoredPaths(): string[] { return super._lastGitIgnoredPaths; } @@ -282,6 +291,35 @@ describe('pipeline/service/refreshFacade', () => { expect(facade.invalidateWorkspaceFiles).toHaveBeenCalledWith(['/workspace/src/a.ts']); }); + it('reuses the current discovered files for existing changed files', async () => { + const facade = new TestRefreshFacade(); + facade._lastWorkspaceRoot = '/workspace'; + facade._lastDiscoveredDirectories = ['src']; + facade._lastDiscoveredFiles = [ + { + absolutePath: '/workspace/src/a.ts', + relativePath: 'src/a.ts', + extension: '.ts', + name: 'a.ts', + }, + ] as never; + vi.spyOn(fs, 'existsSync').mockImplementation(filePath => filePath === '/workspace/src/a.ts'); + + await facade.refreshChangedFiles(['/workspace/src/a.ts']); + + expect(discoverWorkspacePipelineFilesWithWarnings).not.toHaveBeenCalled(); + const [, refreshDependencies] = vi.mocked(refreshWorkspacePipelineChangedFiles).mock.calls[0]; + expect(refreshDependencies.discoveredDirectories).toEqual(['src']); + expect(refreshDependencies.discoveredFiles).toEqual([ + { + absolutePath: '/workspace/src/a.ts', + relativePath: 'src/a.ts', + extension: '.ts', + name: 'a.ts', + }, + ]); + }); + it('builds delegated discovery and refresh dependencies for analysis-scope refreshes', async () => { const facade = new TestRefreshFacade(); const disabledPlugins = new Set(['plugin.disabled']); diff --git a/scripts/performance/measure-vscode-graph-view.mjs b/scripts/performance/measure-vscode-graph-view.mjs index 5cacaf95a..13d0ec5c4 100644 --- a/scripts/performance/measure-vscode-graph-view.mjs +++ b/scripts/performance/measure-vscode-graph-view.mjs @@ -14,6 +14,7 @@ const DEFAULT_TIMEOUT_MS = 120_000; const WEBVIEW_PERFORMANCE_EVENT_LIMIT = 500; const IMPORTS_TOGGLE_START_EVENT = 'graphScope.edgeVisibility.optimistic'; const IMPORTS_TOGGLE_RENDERED_EVENT = 'graphStats.rendered'; +const LIVE_UPDATE_REQUEST_MODE = 'incremental'; const DEFAULT_PLUGIN_PACKAGE_RELATIVE_PATHS = [ 'packages/plugin-godot', 'packages/plugin-markdown', @@ -108,6 +109,53 @@ export function parseExtensionHostPerformanceLog(logText) { })); } +export function findCompletedExtensionHostRequestAfter(events, { mode, startedAt }) { + const matchingRequestIds = new Set(); + + for (const event of events) { + const requestId = event.detail?.requestId; + if (requestId === undefined || event.detail?.mode !== mode) { + continue; + } + + if (event.name === 'graphAnalysis.request.start' && event.at >= startedAt) { + matchingRequestIds.add(requestId); + continue; + } + + if ( + event.name === 'graphAnalysis.request.completed' + && matchingRequestIds.has(requestId) + ) { + return event; + } + } + + return undefined; +} + +export function findActiveExtensionHostRequestIds(events, mode) { + const activeRequestIds = new Set(); + + for (const event of events) { + const requestId = event.detail?.requestId; + if (requestId === undefined || event.detail?.mode !== mode) { + continue; + } + + if (event.name === 'graphAnalysis.request.start') { + activeRequestIds.add(requestId); + continue; + } + + if (event.name === 'graphAnalysis.request.completed') { + activeRequestIds.delete(requestId); + } + } + + return [...activeRequestIds]; +} + async function readExtensionHostPerformanceEvents(logPath) { const logText = await readFile(logPath, 'utf8').catch(() => ''); return parseExtensionHostPerformanceLog(logText); @@ -181,6 +229,19 @@ export function summarizeSwitchTransitionSamples(samples) { }; } +export function summarizeLiveUpdateSamples(samples) { + const requestDurations = samples + .map(sample => sample.requestDurationMs) + .filter(value => value !== undefined); + + return { + ...summarizeDurations(samples.map(sample => sample.durationMs)), + ...(requestDurations.length > 0 + ? { requestDuration: summarizeDurations(requestDurations) } + : {}), + }; +} + export function summarizeWebviewEventDurations(events) { const durationsByEventName = new Map(); @@ -337,6 +398,51 @@ async function waitForWebviewPerformanceEvent(frame, name, timeoutMs = DEFAULT_T throw new Error(`Timed out waiting for webview performance event: ${name}`); } +async function waitForExtensionHostPerformanceEvent(logPath, predicate, timeoutMs = DEFAULT_TIMEOUT_MS) { + const startedAt = performance.now(); + + while (performance.now() - startedAt < timeoutMs) { + const events = await readExtensionHostPerformanceEvents(logPath); + const event = predicate(events); + if (event) { + return event; + } + + await new Promise(resolve => setTimeout(resolve, 25)); + } + + throw new Error('Timed out waiting for extension host performance event'); +} + +async function waitForExtensionHostRequestIdle( + logPath, + mode, + quietMs = 500, + timeoutMs = DEFAULT_TIMEOUT_MS, +) { + const startedAt = performance.now(); + + while (performance.now() - startedAt < timeoutMs) { + const events = await readExtensionHostPerformanceEvents(logPath); + const activeRequestIds = findActiveExtensionHostRequestIds(events, mode); + const latestModeEventAt = events + .filter(event => event.name.startsWith('graphAnalysis.request.') + && event.detail?.mode === mode) + .at(-1)?.at; + + if ( + activeRequestIds.length === 0 + && (latestModeEventAt === undefined || Date.now() - latestModeEventAt >= quietMs) + ) { + return; + } + + await new Promise(resolve => setTimeout(resolve, 25)); + } + + throw new Error(`Timed out waiting for ${mode} extension host requests to become idle`); +} + async function measureSwitchTransition(frame, label, enabled) { const beforeStats = await waitForGraphStats(frame, stats => stats.nodeCount > 0); await resetWebviewPerformanceEvents(frame); @@ -355,6 +461,45 @@ async function measureSwitchTransition(frame, label, enabled) { }; } +async function measureLiveUpdateTransition({ + extensionHostLogPath, + frame, + liveUpdateFilePath, + workspaceRoot, +}) { + const absoluteFilePath = path.isAbsolute(liveUpdateFilePath) + ? liveUpdateFilePath + : path.join(workspaceRoot, liveUpdateFilePath); + const originalContent = await readFile(absoluteFilePath, 'utf8'); + const marker = `\n// CodeGraphy live update perf marker ${Date.now()}\n`; + + await waitForExtensionHostRequestIdle(extensionHostLogPath, LIVE_UPDATE_REQUEST_MODE); + await resetWebviewPerformanceEvents(frame); + const startedAtEpoch = Date.now(); + const startedAt = performance.now(); + + try { + await writeFile(absoluteFilePath, `${originalContent}${marker}`); + const requestEvent = await waitForExtensionHostPerformanceEvent( + extensionHostLogPath, + events => findCompletedExtensionHostRequestAfter(events, { + mode: LIVE_UPDATE_REQUEST_MODE, + startedAt: startedAtEpoch, + }), + ); + + return { + durationMs: Math.round(performance.now() - startedAt), + filePath: path.relative(workspaceRoot, absoluteFilePath).replace(/\\/g, '/'), + requestDurationMs: requestEvent.detail?.durationMs, + requestOffsetMs: requestEvent.offsetMs, + webviewEvents: await readWebviewPerformanceEvents(frame), + }; + } finally { + await writeFile(absoluteFilePath, originalContent); + } +} + async function restoreWorkspaceSettings(settingsPath, originalSettings) { if (originalSettings === null) { return; @@ -365,6 +510,7 @@ async function restoreWorkspaceSettings(settingsPath, originalSettings) { async function measureVSCodeGraphView({ iterations, + liveUpdateFilePath, outputPath, warmupIterations, workspacePath, @@ -461,6 +607,16 @@ async function measureVSCodeGraphView({ await measureSwitchTransition(frame, 'Imports', initialImportsEnabled); } + const liveUpdateSamples = []; + if (liveUpdateFilePath) { + liveUpdateSamples.push(await measureLiveUpdateTransition({ + extensionHostLogPath, + frame, + liveUpdateFilePath, + workspaceRoot, + })); + } + const measurements = { ...startupMeasurements, status: 'complete', @@ -468,6 +624,14 @@ async function measureVSCodeGraphView({ ...summarizeSwitchTransitionSamples(samples), samples, }, + ...(liveUpdateSamples.length > 0 + ? { + liveUpdate: { + ...summarizeLiveUpdateSamples(liveUpdateSamples), + samples: liveUpdateSamples, + }, + } + : {}), }; await writeMetrics({ outputPath, workspacePath: workspaceRoot, measurements }); @@ -483,7 +647,7 @@ async function measureVSCodeGraphView({ function printUsage() { process.stdout.write([ 'Usage:', - ' pnpm exec tsx scripts/performance/measure-vscode-graph-view.mjs [--workspace ] [--iterations ] [--warmup ] [--output ]', + ' pnpm exec tsx scripts/performance/measure-vscode-graph-view.mjs [--workspace ] [--iterations ] [--warmup ] [--live-update-file ] [--output ]', '', 'Launches Extension Development Host, opens CodeGraphy, and times rendered Graph Scope toggle latency.', ].join('\n')); @@ -499,9 +663,11 @@ async function runCli(argv) { const outputPath = readOptionValue(argv, '--output') ?? DEFAULT_OUTPUT_PATH; const iterations = toPositiveInteger(readOptionValue(argv, '--iterations'), DEFAULT_ITERATIONS); const warmupIterations = toPositiveInteger(readOptionValue(argv, '--warmup'), DEFAULT_WARMUP_ITERATIONS); + const liveUpdateFilePath = readOptionValue(argv, '--live-update-file'); await measureVSCodeGraphView({ iterations, + liveUpdateFilePath, outputPath, warmupIterations, workspacePath, diff --git a/tests/scripts/measure-vscode-graph-view.test.mjs b/tests/scripts/measure-vscode-graph-view.test.mjs index d59077383..cf173f171 100644 --- a/tests/scripts/measure-vscode-graph-view.test.mjs +++ b/tests/scripts/measure-vscode-graph-view.test.mjs @@ -108,6 +108,32 @@ test('VS Code graph view runner summarizes startup webview stage durations', asy }); }); +test('VS Code graph view runner summarizes live-update request durations', async () => { + const moduleUrl = pathToFileURL( + path.resolve('scripts/performance/measure-vscode-graph-view.mjs'), + ).href; + const { summarizeLiveUpdateSamples } = await import(moduleUrl); + + assert.deepEqual(summarizeLiveUpdateSamples([ + { durationMs: 640, requestDurationMs: 120 }, + { durationMs: 590, requestDurationMs: 90 }, + { durationMs: 615, requestDurationMs: 105 }, + ]), { + iterations: 3, + minMs: 590, + medianMs: 615, + p95Ms: 640, + maxMs: 640, + requestDuration: { + iterations: 3, + minMs: 90, + medianMs: 105, + p95Ms: 120, + maxMs: 120, + }, + }); +}); + test('VS Code graph view runner parses extension host performance JSONL', async () => { const moduleUrl = pathToFileURL( path.resolve('scripts/performance/measure-vscode-graph-view.mjs'), @@ -135,6 +161,73 @@ test('VS Code graph view runner parses extension host performance JSONL', async ]); }); +test('VS Code graph view runner ignores request completions whose start was before the marker', async () => { + const moduleUrl = pathToFileURL( + path.resolve('scripts/performance/measure-vscode-graph-view.mjs'), + ).href; + const { findCompletedExtensionHostRequestAfter } = await import(moduleUrl); + + assert.deepEqual(findCompletedExtensionHostRequestAfter([ + { + name: 'graphAnalysis.request.start', + at: 90, + detail: { requestId: 1, mode: 'incremental' }, + }, + { + name: 'graphAnalysis.request.completed', + at: 140, + detail: { requestId: 1, mode: 'incremental', durationMs: 50 }, + }, + { + name: 'graphAnalysis.request.start', + at: 150, + detail: { requestId: 2, mode: 'incremental' }, + }, + { + name: 'graphAnalysis.request.completed', + at: 190, + detail: { requestId: 2, mode: 'incremental', durationMs: 40 }, + }, + ], { + mode: 'incremental', + startedAt: 100, + }), { + name: 'graphAnalysis.request.completed', + at: 190, + detail: { requestId: 2, mode: 'incremental', durationMs: 40 }, + }); +}); + +test('VS Code graph view runner detects active extension-host requests', async () => { + const moduleUrl = pathToFileURL( + path.resolve('scripts/performance/measure-vscode-graph-view.mjs'), + ).href; + const { findActiveExtensionHostRequestIds } = await import(moduleUrl); + + assert.deepEqual(findActiveExtensionHostRequestIds([ + { + name: 'graphAnalysis.request.start', + at: 100, + detail: { requestId: 1, mode: 'incremental' }, + }, + { + name: 'graphAnalysis.request.start', + at: 120, + detail: { requestId: 2, mode: 'incremental' }, + }, + { + name: 'graphAnalysis.request.completed', + at: 150, + detail: { requestId: 1, mode: 'incremental', durationMs: 50 }, + }, + { + name: 'graphAnalysis.request.start', + at: 180, + detail: { requestId: 3, mode: 'analyze' }, + }, + ], 'incremental'), [2]); +}); + test('VS Code graph view runner records frame lifecycle offsets from graph open', async () => { const moduleUrl = pathToFileURL( path.resolve('scripts/performance/measure-vscode-graph-view.mjs'), From 88d8ae6640548b16b5df6fdddbfb4c35e66ce94f Mon Sep 17 00:00:00 2001 From: joesobo Date: Mon, 22 Jun 2026 23:21:07 -0700 Subject: [PATCH 043/192] perf: route incremental analysis work Existing-file live update moved from the cached-discovery 1887ms wall / 1180ms request run to 574ms wall / 283ms request after routing pre-analysis by supported extension, keeping saves on a 100ms debounce, and loading only the requested Tree-sitter grammar. Phase trace: notifyPreAnalyze dropped from 450ms to 0ms, delegated analyzeFiles dropped from 527ms to 78ms before the debounce change, and the final changed-file refresh completed in 190ms with graph counts stable in the 2544 node / 9146 edge raw graph band. Tests: core tree-sitter/plugin lifecycle vitest set; extension refresh/service adapter/debounce vitest set; VS Code performance script node test; extension build:vscode; extension lint; extension typecheck; core typecheck. --- .changeset/material-extension-matcher.md | 2 +- docs/performance/codegraphy-monorepo.md | 26 +++ .../2026-06-22-codegraphy-performance.md | 2 + .../src/plugins/lifecycle/notify/analysis.ts | 25 ++- .../src/treeSitter/runtime/languages/load.ts | 107 +++++++++++- .../treeSitter/runtime/languages/parser.ts | 16 +- .../tests/plugins/lifecycle/analysis.test.ts | 48 ++++++ .../treeSitter/languages/bindings.test.ts | 27 ++++ .../tests/treeSitter/languages/parser.test.ts | 62 +++---- .../pipeline/service/refreshFacade.ts | 152 +++++++++++++++++- .../src/extension/pipeline/serviceAdapters.ts | 139 ++++++++++++++-- .../workspaceFiles/refresh/operations.ts | 7 +- .../pipeline/service/refreshFacade.test.ts | 69 ++++++++ .../pipeline/serviceAdapters.test.ts | 67 +++++++- ...leWatcherSetup.registersavehandler.test.ts | 4 +- ...erSetup.workspacerefreshcoalescing.test.ts | 2 +- .../workspaceFiles/refresh/operations.test.ts | 10 +- 17 files changed, 695 insertions(+), 70 deletions(-) diff --git a/.changeset/material-extension-matcher.md b/.changeset/material-extension-matcher.md index 3b373c062..ae23443bf 100644 --- a/.changeset/material-extension-matcher.md +++ b/.changeset/material-extension-matcher.md @@ -2,4 +2,4 @@ "@codegraphy-dev/extension": patch --- -Speed up stale cached startup and existing-file live updates by reusing prepared startup state, deferring live gitignore probing until the background index refresh, warming the repo-local Graph Cache before the first replay needs it, and reusing current discovery for saved-file refreshes. +Speed up stale cached startup and existing-file live updates by reusing prepared startup state, deferring live gitignore probing until the background index refresh, warming the repo-local Graph Cache before the first replay needs it, reusing current discovery for saved-file refreshes, routing incremental pre-analysis to matching plugins, shortening save-triggered refresh debounce, and loading only the requested Tree-sitter grammar for one-file parses. diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index 136554939..84d493624 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -699,6 +699,32 @@ Interpretation: `1887ms` wall-clock and `1180ms` request time; discovery mode was `cached` and took `0ms`. The graph size after the refresh stayed in the same `5101` node / `9146` edge raw graph band. +- Changed-file refresh phase tracing showed that cached discovery exposed the + next hot spot inside per-file analysis. Before pre-analysis routing, the + one-file live-update request measured `722ms`, with `notifyPreAnalyze` + taking `450ms` and the delegated `analyzeFiles` phase taking `527ms`. After + routing plugin pre-analysis by supported file extension, the same probe + measured `955ms` wall-clock and `267ms` request time; `notifyPreAnalyze` + rounded to `0ms`, delegated `analyzeFiles` dropped to `78ms`, and the + changed-file refresh completed in `176ms`. The remaining backend phases were + one-file plugin analysis at `75ms` and two graph-build passes at `54ms` and + `37ms`. +- Existing-file content saves now use a shorter `100ms` debounce while create, + delete, and rename operations keep the wider `500ms` coalescing window. The + CodeGraphy monorepo live-update probe measured `574ms` wall-clock and + `283ms` request time after this change. The changed-file refresh itself took + `190ms`; the phase split was `91ms` analyze files, `53ms` + `buildGraphDataFromAnalysis`, and `36ms` `buildGraphData`. This trims the + human-visible wait from the cached-discovery `1887ms` run and the + pre-debounce `955ms` run, while keeping filesystem-operation coalescing + unchanged. +- Tree-sitter language loading is now targeted per requested language instead + of importing every bundled grammar before a one-file parse. A micro-probe + showed `loadTreeSitterLanguageBinding("typeScript")` taking `11ms`-`17ms` + after warm module cache effects, while the compatibility + `loadTreeSitterBindings()` path took `62ms`-`205ms` and loaded all language + bindings. This is a startup and incremental-analysis guardrail rather than a + visible graph-count change. Full test baseline: diff --git a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md index 6cf96b525..705682127 100644 --- a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md +++ b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md @@ -314,6 +314,8 @@ Added cached-load/publish stage markers and reused a prepared Material Icon exte Deferred live gitignore probing only for stale cached replay: cached discovery dropped from 324ms to 11ms, cached load completion from 836ms to 497ms, cached load publish from 1.70s to 0.63s, first graph readiness from 5.62s to 5.27s, and visible stats stayed 2300 nodes / 5345 edges while background analysis handled exact ignored metadata. Warmed the repo-local Graph Cache when the Graph View runtime creates its analyzer: hydration dropped from 406ms to 170ms, cached load completion from 497ms to 259ms, cached load request completion from 672ms to 429ms, first graph readiness from 5266ms to 5114ms, and visible stats stayed 2300 nodes / 5345 edges. Reused current discovery for existing-file live updates and added a live-update VS Code probe: full-discovery control was 3854ms wall / 3149ms request with 1900ms discovery; cached-discovery fast path was 1887ms wall / 1180ms request with 0ms discovery. +Added changed-file refresh phase markers: pre-analysis routing hotspot was notifyPreAnalyze at 450ms inside a 722ms request, then routing pre-analysis files by supported extension reduced notifyPreAnalyze to 0ms, analyzeFiles to 78ms, refresh completion to 176ms, and live update to 955ms wall / 267ms request. +Shortened existing-file save debounce and targeted Tree-sitter language loading: content saves now wait 100ms while file operations keep 500ms coalescing; latest live-update probe is 574ms wall / 283ms request, and targeted TypeScript Tree-sitter binding load probes at 11ms-17ms versus 62ms-205ms for loading all grammar bindings. ``` ## Task 5: Keep The PR Reviewable diff --git a/packages/core/src/plugins/lifecycle/notify/analysis.ts b/packages/core/src/plugins/lifecycle/notify/analysis.ts index 5b58ebea0..f12c01895 100644 --- a/packages/core/src/plugins/lifecycle/notify/analysis.ts +++ b/packages/core/src/plugins/lifecycle/notify/analysis.ts @@ -12,6 +12,24 @@ type AnalyzeFile = { content: string; }; +function pluginMatchesFile(info: ILifecyclePluginInfo, relativePath: string): boolean { + if (info.plugin.supportedExtensions.includes('*')) { + return true; + } + + const lowercasePath = relativePath.toLowerCase(); + return info.plugin.supportedExtensions.some((extension) => + lowercasePath.endsWith(extension.toLowerCase()), + ); +} + +function getPluginFiles( + info: ILifecyclePluginInfo, + files: AnalyzeFile[], +): AnalyzeFile[] { + return files.filter((file) => pluginMatchesFile(info, file.relativePath)); +} + function logLifecycleError(hook: string, pluginId: string, error: unknown): void { console.error(`[CodeGraphy] Error in ${hook} for ${pluginId}:`, error); } @@ -32,9 +50,14 @@ export async function notifyPreAnalyze( continue; } + const pluginFiles = getPluginFiles(info, files); + if (pluginFiles.length === 0) { + continue; + } + try { await info.plugin.onPreAnalyze( - files, + pluginFiles, workspaceRoot, withWorkspacePluginAnalysisOptions(analysisContext, info.options), ); diff --git a/packages/core/src/treeSitter/runtime/languages/load.ts b/packages/core/src/treeSitter/runtime/languages/load.ts index 8c1127dfa..40efd89ea 100644 --- a/packages/core/src/treeSitter/runtime/languages/load.ts +++ b/packages/core/src/treeSitter/runtime/languages/load.ts @@ -1,6 +1,8 @@ import type Parser from 'tree-sitter'; +import type { TreeSitterRuntimeBinding } from './kinds'; type TreeSitterConstructor = new () => Parser; +type TreeSitterLanguageBindingName = TreeSitterRuntimeBinding['language']; export interface ITreeSitterBindings { ParserCtor: TreeSitterConstructor; @@ -25,10 +27,106 @@ export interface ITreeSitterBindings { typeScript: Parser.Language; } +export interface ITreeSitterLanguageBinding { + ParserCtor: TreeSitterConstructor; + language: Parser.Language; +} + +function warnTreeSitterBindingsUnavailable(error: unknown): void { + console.warn( + `[CodeGraphy] Tree-sitter bindings unavailable; skipping core Tree-sitter analysis. ${String(error)}`, + ); +} + +let treeSitterParserCtorPromise: Promise | undefined; +function loadTreeSitterParserCtor(): Promise { + treeSitterParserCtorPromise ??= import('tree-sitter') + .then(parserModule => parserModule.default); + + return treeSitterParserCtorPromise; +} + +async function loadTreeSitterLanguage( + language: TreeSitterLanguageBindingName, +): Promise { + switch (language) { + case 'cLanguage': + return (await import('tree-sitter-c')).default as unknown as Parser.Language; + case 'cpp': + return (await import('tree-sitter-cpp')).default as unknown as Parser.Language; + case 'csharp': + return (await import('tree-sitter-c-sharp')).default as unknown as Parser.Language; + case 'dart': + return (await import('@driftlog/tree-sitter-dart')).default as unknown as Parser.Language; + case 'go': + return (await import('tree-sitter-go')).default as unknown as Parser.Language; + case 'haskell': + return (await import('tree-sitter-haskell')).default as unknown as Parser.Language; + case 'java': + return (await import('tree-sitter-java')).default as unknown as Parser.Language; + case 'javaScript': + return (await import('tree-sitter-javascript')).default as unknown as Parser.Language; + case 'kotlin': + return (await import('@tree-sitter-grammars/tree-sitter-kotlin')).default as unknown as Parser.Language; + case 'lua': + return (await import('@tree-sitter-grammars/tree-sitter-lua')).default as unknown as Parser.Language; + case 'objectiveC': + return (await import('tree-sitter-objc')).default as unknown as Parser.Language; + case 'php': + return ((await import('tree-sitter-php')).default as unknown as { php: Parser.Language }).php; + case 'python': + return (await import('tree-sitter-python')).default as unknown as Parser.Language; + case 'ruby': + return (await import('tree-sitter-ruby')).default as unknown as Parser.Language; + case 'rust': + return (await import('tree-sitter-rust')).default as unknown as Parser.Language; + case 'scala': + return (await import('tree-sitter-scala')).default as unknown as Parser.Language; + case 'swift': + return (await import('tree-sitter-swift')).default as unknown as Parser.Language; + case 'tsx': + return ((await import('tree-sitter-typescript')).default as unknown as { + tsx: Parser.Language; + }).tsx; + case 'typeScript': + return ((await import('tree-sitter-typescript')).default as unknown as { + typescript: Parser.Language; + }).typescript; + } +} + +const treeSitterLanguageBindingPromises = new Map< + TreeSitterLanguageBindingName, + Promise +>(); + +export function loadTreeSitterLanguageBinding( + language: TreeSitterLanguageBindingName, +): Promise { + const cached = treeSitterLanguageBindingPromises.get(language); + if (cached) { + return cached; + } + + const promise = Promise.all([ + loadTreeSitterParserCtor(), + loadTreeSitterLanguage(language), + ]) + .then(([ParserCtor, languageBinding]) => + ({ ParserCtor, language: languageBinding }), + ) + .catch((error: unknown) => { + warnTreeSitterBindingsUnavailable(error); + return null; + }); + treeSitterLanguageBindingPromises.set(language, promise); + return promise; +} + let treeSitterBindingsPromise: Promise | undefined; export async function loadTreeSitterBindings(): Promise { treeSitterBindingsPromise ??= Promise.all([ - import('tree-sitter'), + loadTreeSitterParserCtor(), import('tree-sitter-c'), import('tree-sitter-cpp'), import('tree-sitter-c-sharp'), @@ -49,7 +147,7 @@ export async function loadTreeSitterBindings(): Promise { - const ParserCtor = parserModule.default; const typeScriptLanguages = typeScriptModule.default as unknown as { tsx: Parser.Language; typescript: Parser.Language; @@ -99,9 +196,7 @@ export async function loadTreeSitterBindings(): Promise { - console.warn( - `[CodeGraphy] Tree-sitter bindings unavailable; skipping core Tree-sitter analysis. ${String(error)}`, - ); + warnTreeSitterBindingsUnavailable(error); return null; }); diff --git a/packages/core/src/treeSitter/runtime/languages/parser.ts b/packages/core/src/treeSitter/runtime/languages/parser.ts index ce4ec0541..568d0bfc7 100644 --- a/packages/core/src/treeSitter/runtime/languages/parser.ts +++ b/packages/core/src/treeSitter/runtime/languages/parser.ts @@ -5,7 +5,7 @@ import { TREE_SITTER_RUNTIME_BINDINGS, type TreeSitterLanguageKind, } from './catalog'; -import { loadTreeSitterBindings } from './load'; +import { loadTreeSitterLanguageBinding } from './load'; export interface ITreeSitterRuntime { languageKind: TreeSitterLanguageKind; @@ -17,11 +17,6 @@ async function getTreeSitterLanguageForFile(filePath: string): Promise<{ languageKind: TreeSitterLanguageKind; language: Parser.Language; } | null> { - const bindings = await loadTreeSitterBindings(); - if (!bindings) { - return null; - } - const binding = TREE_SITTER_RUNTIME_BINDINGS[ getFileExtension(filePath) as keyof typeof TREE_SITTER_RUNTIME_BINDINGS ]; @@ -29,10 +24,15 @@ async function getTreeSitterLanguageForFile(filePath: string): Promise<{ return null; } + const languageBinding = await loadTreeSitterLanguageBinding(binding.language); + if (!languageBinding) { + return null; + } + return { - ParserCtor: bindings.ParserCtor, + ParserCtor: languageBinding.ParserCtor, languageKind: binding.languageKind, - language: bindings[binding.language], + language: languageBinding.language, }; } diff --git a/packages/core/tests/plugins/lifecycle/analysis.test.ts b/packages/core/tests/plugins/lifecycle/analysis.test.ts index 405884eee..bede73c7c 100644 --- a/packages/core/tests/plugins/lifecycle/analysis.test.ts +++ b/packages/core/tests/plugins/lifecycle/analysis.test.ts @@ -48,6 +48,54 @@ describe('plugins/lifecycle analysis notifications', () => { expect(onGraphRebuild).toHaveBeenCalledWith(emptyGraph); }); + it('routes pre-analysis files to matching plugin extensions', async () => { + const onTypeScriptPreAnalyze = vi.fn(); + const onMarkdownPreAnalyze = vi.fn(); + const onWildcardPreAnalyze = vi.fn(); + const plugins = new Map([ + ['ts', pluginInfo({ + id: 'ts', + name: 'TypeScript', + version: '1.0.0', + apiVersion: '2', + supportedExtensions: ['.ts'], + onPreAnalyze: onTypeScriptPreAnalyze, + })], + ['markdown', pluginInfo({ + id: 'markdown', + name: 'Markdown', + version: '1.0.0', + apiVersion: '2', + supportedExtensions: ['.md'], + onPreAnalyze: onMarkdownPreAnalyze, + })], + ['wildcard', pluginInfo({ + id: 'wildcard', + name: 'Wildcard', + version: '1.0.0', + apiVersion: '2', + supportedExtensions: ['*'], + onPreAnalyze: onWildcardPreAnalyze, + })], + ]); + const typeScriptFile = { + absolutePath: '/workspace/src/app.ts', + relativePath: 'src/app.ts', + content: 'content', + }; + const markdownFile = { + absolutePath: '/workspace/README.md', + relativePath: 'README.md', + content: '# docs', + }; + + await notifyPreAnalyze(plugins, [typeScriptFile, markdownFile], '/workspace'); + + expect(onTypeScriptPreAnalyze).toHaveBeenCalledWith([typeScriptFile], '/workspace', expect.any(Object)); + expect(onMarkdownPreAnalyze).toHaveBeenCalledWith([markdownFile], '/workspace', expect.any(Object)); + expect(onWildcardPreAnalyze).toHaveBeenCalledWith([typeScriptFile, markdownFile], '/workspace', expect.any(Object)); + }); + it('logs lifecycle hook errors without stopping later plugins', async () => { const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); const afterRebuild = vi.fn(); diff --git a/packages/core/tests/treeSitter/languages/bindings.test.ts b/packages/core/tests/treeSitter/languages/bindings.test.ts index 9d44bbdd7..cd2690d86 100644 --- a/packages/core/tests/treeSitter/languages/bindings.test.ts +++ b/packages/core/tests/treeSitter/languages/bindings.test.ts @@ -108,6 +108,33 @@ describe('pipeline/plugins/treesitter/runtime/languages/load', () => { expect(second).toBe(first); }); + it('loads and caches only the requested language binding', async () => { + class FakeParser {} + const typeScript = { name: 'typescript' }; + + vi.doMock('tree-sitter', () => ({ default: FakeParser })); + vi.doMock('tree-sitter-javascript', () => { + throw new Error('should not load javascript grammar'); + }); + vi.doMock('tree-sitter-typescript', () => ({ + default: { + tsx: { name: 'tsx' }, + typescript: typeScript, + }, + })); + + const { loadTreeSitterLanguageBinding } = await import(MODULE_PATH); + + const first = await loadTreeSitterLanguageBinding('typeScript'); + const second = await loadTreeSitterLanguageBinding('typeScript'); + + expect(first).toEqual({ + ParserCtor: FakeParser, + language: typeScript, + }); + expect(second).toBe(first); + }); + it('logs unavailable bindings once and returns null on repeated failures', async () => { const warning = new Error('missing native module'); const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}); diff --git a/packages/core/tests/treeSitter/languages/parser.test.ts b/packages/core/tests/treeSitter/languages/parser.test.ts index 72830d8a4..60e496dc7 100644 --- a/packages/core/tests/treeSitter/languages/parser.test.ts +++ b/packages/core/tests/treeSitter/languages/parser.test.ts @@ -1,13 +1,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -const { loadTreeSitterBindings } = vi.hoisted(() => ({ - loadTreeSitterBindings: vi.fn(), +const { loadTreeSitterLanguageBinding } = vi.hoisted(() => ({ + loadTreeSitterLanguageBinding: vi.fn(), })); vi.mock( '../../../src/treeSitter/runtime/languages/load', () => ({ - loadTreeSitterBindings, + loadTreeSitterLanguageBinding, }), ); @@ -26,9 +26,9 @@ describe('pipeline/plugins/treesitter/runtime/languages/parser', () => { }); it('returns a configured parser for supported files when bindings are available', async () => { - loadTreeSitterBindings.mockResolvedValue({ + loadTreeSitterLanguageBinding.mockResolvedValue({ ParserCtor: MockParser, - typeScript: { id: 'typescript' }, + language: { id: 'typescript' }, }); const parser = await createTreeSitterParser('/workspace/src/app.ts'); @@ -39,15 +39,21 @@ describe('pipeline/plugins/treesitter/runtime/languages/parser', () => { }); it('returns configured parsers for C and C++ files', async () => { - loadTreeSitterBindings.mockResolvedValue({ - ParserCtor: MockParser, - cLanguage: { id: 'c' }, - cpp: { id: 'cpp' }, - }); + loadTreeSitterLanguageBinding + .mockResolvedValueOnce({ + ParserCtor: MockParser, + language: { id: 'c' }, + }) + .mockResolvedValueOnce({ + ParserCtor: MockParser, + language: { id: 'cpp' }, + }); const cRuntime = await createTreeSitterRuntime('/workspace/src/main.c'); const cppRuntime = await createTreeSitterRuntime('/workspace/src/main.cpp'); + expect(loadTreeSitterLanguageBinding).toHaveBeenNthCalledWith(1, 'cLanguage'); + expect(loadTreeSitterLanguageBinding).toHaveBeenNthCalledWith(2, 'cpp'); expect(cRuntime?.languageKind).toBe('c'); expect((cRuntime?.parser as unknown as MockParser).setLanguage).toHaveBeenCalledWith({ id: 'c' }); expect(cppRuntime?.languageKind).toBe('cpp'); @@ -55,9 +61,9 @@ describe('pipeline/plugins/treesitter/runtime/languages/parser', () => { }); it('returns configured parsers for Kotlin files', async () => { - loadTreeSitterBindings.mockResolvedValue({ + loadTreeSitterLanguageBinding.mockResolvedValue({ ParserCtor: MockParser, - kotlin: { id: 'kotlin' }, + language: { id: 'kotlin' }, }); const kotlinRuntime = await createTreeSitterRuntime('/workspace/src/App.kt'); @@ -74,9 +80,9 @@ describe('pipeline/plugins/treesitter/runtime/languages/parser', () => { }); it('returns configured parsers for PHP files', async () => { - loadTreeSitterBindings.mockResolvedValue({ + loadTreeSitterLanguageBinding.mockResolvedValue({ ParserCtor: MockParser, - php: { id: 'php' }, + language: { id: 'php' }, }); const phpRuntime = await createTreeSitterRuntime('/workspace/src/App.php'); @@ -88,9 +94,9 @@ describe('pipeline/plugins/treesitter/runtime/languages/parser', () => { }); it('returns configured parsers for Dart files', async () => { - loadTreeSitterBindings.mockResolvedValue({ + loadTreeSitterLanguageBinding.mockResolvedValue({ ParserCtor: MockParser, - dart: { id: 'dart' }, + language: { id: 'dart' }, }); const dartRuntime = await createTreeSitterRuntime('/workspace/lib/app/runner.dart'); @@ -102,9 +108,9 @@ describe('pipeline/plugins/treesitter/runtime/languages/parser', () => { }); it('returns configured parsers for Ruby files', async () => { - loadTreeSitterBindings.mockResolvedValue({ + loadTreeSitterLanguageBinding.mockResolvedValue({ ParserCtor: MockParser, - ruby: { id: 'ruby' }, + language: { id: 'ruby' }, }); const rubyRuntime = await createTreeSitterRuntime('/workspace/lib/app/runner.rb'); @@ -116,9 +122,9 @@ describe('pipeline/plugins/treesitter/runtime/languages/parser', () => { }); it('returns configured parsers for Haskell files', async () => { - loadTreeSitterBindings.mockResolvedValue({ + loadTreeSitterLanguageBinding.mockResolvedValue({ ParserCtor: MockParser, - haskell: { id: 'haskell' }, + language: { id: 'haskell' }, }); const haskellRuntime = await createTreeSitterRuntime('/workspace/src/App.hs'); @@ -135,9 +141,9 @@ describe('pipeline/plugins/treesitter/runtime/languages/parser', () => { }); it('returns configured parsers for Lua files', async () => { - loadTreeSitterBindings.mockResolvedValue({ + loadTreeSitterLanguageBinding.mockResolvedValue({ ParserCtor: MockParser, - lua: { id: 'lua' }, + language: { id: 'lua' }, }); const luaRuntime = await createTreeSitterRuntime('/workspace/src/app.lua'); @@ -149,9 +155,9 @@ describe('pipeline/plugins/treesitter/runtime/languages/parser', () => { }); it('returns configured parsers for Swift files', async () => { - loadTreeSitterBindings.mockResolvedValue({ + loadTreeSitterLanguageBinding.mockResolvedValue({ ParserCtor: MockParser, - swift: { id: 'swift' }, + language: { id: 'swift' }, }); const swiftRuntime = await createTreeSitterRuntime('/workspace/Sources/App/Runner.swift'); @@ -163,9 +169,9 @@ describe('pipeline/plugins/treesitter/runtime/languages/parser', () => { }); it('returns a runtime with the parser and language kind for supported files', async () => { - loadTreeSitterBindings.mockResolvedValue({ + loadTreeSitterLanguageBinding.mockResolvedValue({ ParserCtor: MockParser, - javaScript: { id: 'javascript' }, + language: { id: 'javascript' }, }); const runtime = await createTreeSitterRuntime('/workspace/src/app.js'); @@ -179,7 +185,7 @@ describe('pipeline/plugins/treesitter/runtime/languages/parser', () => { }); it('returns null for supported files when bindings are unavailable', async () => { - loadTreeSitterBindings.mockResolvedValue(null); + loadTreeSitterLanguageBinding.mockResolvedValue(null); await expect(createTreeSitterParser('/workspace/src/app.ts')).resolves.toBeNull(); await expect(createTreeSitterRuntime('/workspace/src/app.ts')).resolves.toBeNull(); @@ -189,6 +195,6 @@ describe('pipeline/plugins/treesitter/runtime/languages/parser', () => { await expect(createTreeSitterParser('/workspace/README.md')).resolves.toBeNull(); await expect(createTreeSitterRuntime('/workspace/README.md')).resolves.toBeNull(); - expect(loadTreeSitterBindings).not.toHaveBeenCalled(); + expect(loadTreeSitterLanguageBinding).not.toHaveBeenCalled(); }); }); diff --git a/packages/extension/src/extension/pipeline/service/refreshFacade.ts b/packages/extension/src/extension/pipeline/service/refreshFacade.ts index 5c451d149..5fa9ce929 100644 --- a/packages/extension/src/extension/pipeline/service/refreshFacade.ts +++ b/packages/extension/src/extension/pipeline/service/refreshFacade.ts @@ -25,6 +25,40 @@ interface ChangedFileDiscoveryState { files: IDiscoveredFile[]; } +function recordChangedFileRefreshPhase( + phase: string, + startedAt: number, + detail: Record = {}, +): void { + recordExtensionPerformanceEvent('workspacePipeline.refreshChangedFiles.phase', { + ...detail, + durationMs: Date.now() - startedAt, + phase, + }); +} + +async function timeChangedFileRefreshPhase( + phase: string, + operation: () => Promise, + describeResult: (result: T) => Record = () => ({}), +): Promise { + const startedAt = Date.now(); + const result = await operation(); + recordChangedFileRefreshPhase(phase, startedAt, describeResult(result)); + return result; +} + +function timeChangedFileRefreshPhaseSync( + phase: string, + operation: () => T, + describeResult: (result: T) => Record = () => ({}), +): T { + const startedAt = Date.now(); + const result = operation(); + recordChangedFileRefreshPhase(phase, startedAt, describeResult(result)); + return result; +} + export abstract class WorkspacePipelineRefreshFacade extends WorkspacePipelineDiscoveryFacade { private _createWorkspaceIndexRefreshSource( disabledPlugins: Set = new Set(), @@ -93,6 +127,96 @@ export abstract class WorkspacePipelineRefreshFacade extends WorkspacePipelineDi return source; } + private _createTimedWorkspaceIndexRefreshSource( + disabledPlugins: Set, + ): WorkspacePipelineRefreshSource { + const source = this._createWorkspaceIndexRefreshSource(disabledPlugins); + + const readAnalysisFiles = source._readAnalysisFiles.bind(source); + source._readAnalysisFiles = files => timeChangedFileRefreshPhase( + 'readAnalysisFiles', + () => readAnalysisFiles(files), + readFiles => ({ + fileCount: files.length, + readFileCount: readFiles.length, + }), + ); + + const analyzeFiles = source._analyzeFiles.bind(source); + source._analyzeFiles = ( + files, + root, + progress, + abortSignal, + pluginIds, + nextDisabledPlugins, + ) => timeChangedFileRefreshPhase( + 'analyzeFiles', + () => analyzeFiles( + files, + root, + progress, + abortSignal, + pluginIds, + nextDisabledPlugins, + ), + result => ({ + cacheHits: result.cacheHits, + cacheMisses: result.cacheMisses, + fileCount: files.length, + pluginIdCount: pluginIds?.length ?? 0, + }), + ); + + const buildGraphData = source._buildGraphData.bind(source); + source._buildGraphData = (fileConnections, root, selectedPlugins) => + timeChangedFileRefreshPhaseSync( + 'buildGraphData', + () => buildGraphData(fileConnections, root, selectedPlugins), + graphData => ({ + edgeCount: graphData.edges.length, + fileCount: fileConnections.size, + nodeCount: graphData.nodes.length, + }), + ); + + const buildGraphDataFromAnalysis = source._buildGraphDataFromAnalysis.bind(source); + source._buildGraphDataFromAnalysis = (fileAnalysis, root, selectedPlugins) => + timeChangedFileRefreshPhaseSync( + 'buildGraphDataFromAnalysis', + () => buildGraphDataFromAnalysis(fileAnalysis, root, selectedPlugins), + graphData => ({ + edgeCount: graphData.edges.length, + fileCount: fileAnalysis.size, + nodeCount: graphData.nodes.length, + }), + ); + + const analyze = source.analyze.bind(source); + source.analyze = (patterns, nextDisabledPlugins, signal, progress) => + timeChangedFileRefreshPhase( + 'fullAnalyze', + () => analyze(patterns, nextDisabledPlugins, signal, progress), + graphData => ({ + edgeCount: graphData.edges.length, + nodeCount: graphData.nodes.length, + patternCount: patterns?.length ?? 0, + }), + ); + + const invalidateWorkspaceFiles = source.invalidateWorkspaceFiles.bind(source); + source.invalidateWorkspaceFiles = filePaths => timeChangedFileRefreshPhaseSync( + 'invalidateWorkspaceFiles', + () => invalidateWorkspaceFiles(filePaths), + invalidatedFiles => ({ + fileCount: filePaths.length, + invalidatedFileCount: invalidatedFiles.length, + }), + ); + + return source; + } + private _canReuseCurrentAnalysisForScope( discoveredFiles: readonly IDiscoveredFile[], disabledPlugins: Set, @@ -341,7 +465,7 @@ export abstract class WorkspacePipelineRefreshFacade extends WorkspacePipelineDi mode: reusableDiscoveryState ? 'cached' : 'discover', }); - const graphData = await refreshWorkspacePipelineChangedFiles(this._createWorkspaceIndexRefreshSource(disabledPlugins), { + const graphData = await refreshWorkspacePipelineChangedFiles(this._createTimedWorkspaceIndexRefreshSource(disabledPlugins), { disabledPlugins, discoveredDirectories: discoveryResult.directories, discoveredFiles: discoveryResult.files, @@ -353,18 +477,30 @@ export abstract class WorkspacePipelineRefreshFacade extends WorkspacePipelineDi analysisContext, nextDisabledPlugins = disabledPlugins, ) => - this._registry.notifyFilesChanged( - files, - root, - analysisContext, - nextDisabledPlugins, + timeChangedFileRefreshPhase( + 'notifyFilesChanged', + () => this._registry.notifyFilesChanged( + files, + root, + analysisContext, + nextDisabledPlugins, + ), + result => ({ + additionalFilePathCount: result.additionalFilePaths.length, + fileCount: files.length, + requiresFullRefresh: result.requiresFullRefresh, + }), ), onProgress, persistCache: () => { - this._persistCache(); + timeChangedFileRefreshPhaseSync('persistCache', () => { + this._persistCache(); + }); }, persistIndexMetadata: async () => { - await this._persistIndexMetadata(); + await timeChangedFileRefreshPhase('persistIndexMetadata', () => + this._persistIndexMetadata(), + ); }, signal, workspaceRoot, diff --git a/packages/extension/src/extension/pipeline/serviceAdapters.ts b/packages/extension/src/extension/pipeline/serviceAdapters.ts index d0c60deca..dae2b8ffa 100644 --- a/packages/extension/src/extension/pipeline/serviceAdapters.ts +++ b/packages/extension/src/extension/pipeline/serviceAdapters.ts @@ -24,11 +24,44 @@ import { getWorkspacePipelineFileStat, getWorkspacePipelineRoot, } from './io'; +import { recordExtensionPerformanceEvent } from '../performance/marks'; export interface WorkspacePipelineGraphScopeOptions { nodeVisibility?: Readonly>; } +function recordWorkspacePipelineAnalyzeFilesPhase( + phase: string, + startedAt: number, + detail: Record = {}, +): void { + recordExtensionPerformanceEvent('workspacePipeline.analyzeFiles.phase', { + ...detail, + durationMs: Date.now() - startedAt, + phase, + }); +} + +async function timeWorkspacePipelineAnalyzeFilesPhase( + phase: string, + operation: () => Promise, + describeResult: (result: T) => Record = () => ({}), +): Promise { + const startedAt = Date.now(); + const result = await operation(); + recordWorkspacePipelineAnalyzeFilesPhase(phase, startedAt, describeResult(result)); + return result; +} + +function describeFileAnalysisResult( + result: IFileAnalysisResult | null, +): Record { + return { + relationCount: result?.relations?.length ?? 0, + symbolCount: result?.symbols?.length ?? 0, + }; +} + export async function preAnalyzeWorkspacePipelinePlugins( files: IDiscoveredFile[], workspaceRoot: string, @@ -65,21 +98,107 @@ export function analyzeWorkspacePipelineFiles( pluginIds?: readonly string[], disabledPlugins: Set = new Set(), ): Promise { + const timedDiscovery: WorkspacePipelineFilesSource['_discovery'] = { + readContent: file => timeWorkspacePipelineAnalyzeFilesPhase( + 'readContent', + () => discovery.readContent(file), + content => ({ + byteCount: content.length, + filePath: file.relativePath, + }), + ), + }; + const timedRegistry: WorkspacePipelineFilesSource['_registry'] = { + analyzeFileResult: ( + absolutePath, + content, + rootPath, + analysisContext, + options, + ) => timeWorkspacePipelineAnalyzeFilesPhase( + 'analyzeFileResult', + () => registry.analyzeFileResult( + absolutePath, + content, + rootPath, + analysisContext, + options, + ), + result => ({ + filePath: absolutePath, + ...describeFileAnalysisResult(result), + }), + ), + analyzeFileResultForPlugins: registry.analyzeFileResultForPlugins + ? ( + absolutePath, + content, + rootPath, + pluginIds, + analysisContext, + options, + ) => timeWorkspacePipelineAnalyzeFilesPhase( + 'analyzeFileResultForPlugins', + () => registry.analyzeFileResultForPlugins?.( + absolutePath, + content, + rootPath, + pluginIds, + analysisContext, + options, + ) ?? Promise.resolve(null), + result => ({ + filePath: absolutePath, + pluginIdCount: pluginIds.length, + ...describeFileAnalysisResult(result), + }), + ) + : undefined, + }; + const source: WorkspacePipelineFilesSource = { _cache: cache, - _discovery: discovery, + _discovery: timedDiscovery, _eventBus: eventBus, - _getFileStat: getFileStat, + _getFileStat: filePath => timeWorkspacePipelineAnalyzeFilesPhase( + 'getFileStat', + () => getFileStat(filePath), + stat => ({ + filePath, + found: Boolean(stat), + size: stat?.size, + }), + ), _preAnalyzePlugins: (preAnalyzeFiles, rootPath, abortSignal) => - preAnalyzeWorkspacePipelinePlugins( - preAnalyzeFiles, - rootPath, - registry, - discovery, - abortSignal, - disabledPlugins, + timeWorkspacePipelineAnalyzeFilesPhase( + 'preAnalyzeFiles', + () => preAnalyzeWorkspacePipelinePlugins( + preAnalyzeFiles, + rootPath, + { + notifyPreAnalyze: (analysisFiles, preAnalyzeRootPath, analysisContext, nextDisabledPlugins) => + timeWorkspacePipelineAnalyzeFilesPhase( + 'notifyPreAnalyze', + () => registry.notifyPreAnalyze( + analysisFiles, + preAnalyzeRootPath, + analysisContext, + nextDisabledPlugins, + ), + () => ({ + fileCount: analysisFiles.length, + }), + ), + } as Pick, + timedDiscovery, + abortSignal, + disabledPlugins, + ), + () => ({ + fileCount: preAnalyzeFiles.length, + }), ), - _registry: registry, + _registry: timedRegistry, }; return analyzeWorkspacePipelineSourceFiles( diff --git a/packages/extension/src/extension/workspaceFiles/refresh/operations.ts b/packages/extension/src/extension/workspaceFiles/refresh/operations.ts index 15f569dce..77b5b12ff 100644 --- a/packages/extension/src/extension/workspaceFiles/refresh/operations.ts +++ b/packages/extension/src/extension/workspaceFiles/refresh/operations.ts @@ -9,6 +9,9 @@ import { scheduleWorkspaceRefresh } from './scheduler'; type WorkspaceRenameFiles = vscode.FileRenameEvent['files']; type WorkspaceFileEventName = 'workspace:fileCreated' | 'workspace:fileDeleted'; +const WORKSPACE_CONTENT_CHANGE_REFRESH_DELAY_MS = 100; +const WORKSPACE_FILE_OPERATION_REFRESH_DELAY_MS = 500; + function isGitignorePath(filePath: string): boolean { return filePath.replace(/\\/g, '/').endsWith('/.gitignore') || filePath.replace(/\\/g, '/') === '.gitignore'; @@ -28,7 +31,7 @@ function refreshWorkspacePaths( ); if (refreshPaths.length > 0) { - scheduleWorkspaceRefresh(provider, logMessage, refreshPaths, 500, { + scheduleWorkspaceRefresh(provider, logMessage, refreshPaths, WORKSPACE_FILE_OPERATION_REFRESH_DELAY_MS, { gitignoreRefresh: includesGitignorePath(refreshPaths), }); } @@ -64,7 +67,7 @@ export function refreshWorkspaceChangedPath( provider, logMessage, [filePath], - 500, + WORKSPACE_CONTENT_CHANGE_REFRESH_DELAY_MS, { gitignoreRefresh: isGitignorePath(filePath) }, ); provider.emitEvent('workspace:fileChanged', { filePath }); diff --git a/packages/extension/tests/extension/pipeline/service/refreshFacade.test.ts b/packages/extension/tests/extension/pipeline/service/refreshFacade.test.ts index 3c7031cf8..8ae6c28f5 100644 --- a/packages/extension/tests/extension/pipeline/service/refreshFacade.test.ts +++ b/packages/extension/tests/extension/pipeline/service/refreshFacade.test.ts @@ -11,6 +11,10 @@ import { refreshWorkspacePipelineChangedFiles, } from '../../../../src/extension/pipeline/service/runtime/refresh'; +const performanceMocks = vi.hoisted(() => ({ + recordExtensionPerformanceEvent: vi.fn(), +})); + vi.mock('../../../../src/extension/pipeline/service/runtime/discovery', () => ({ createWorkspacePipelineDiscoveryDependencies: vi.fn(), discoverWorkspacePipelineFilesWithWarnings: vi.fn(), @@ -21,6 +25,10 @@ vi.mock('../../../../src/extension/pipeline/service/runtime/refresh', () => ({ refreshWorkspacePipelineChangedFiles: vi.fn(), })); +vi.mock('../../../../src/extension/performance/marks', () => ({ + recordExtensionPerformanceEvent: performanceMocks.recordExtensionPerformanceEvent, +})); + vi.mock('vscode', () => ({ workspace: { workspaceFolders: undefined, @@ -139,6 +147,7 @@ class TestRefreshFacade extends WorkspacePipelineRefreshFacade { describe('pipeline/service/refreshFacade', () => { beforeEach(() => { vi.clearAllMocks(); + performanceMocks.recordExtensionPerformanceEvent.mockReset(); vi.mocked(createWorkspacePipelineDiscoveryDependencies).mockReturnValue('discovery-deps' as never); vi.mocked(discoverWorkspacePipelineFilesWithWarnings).mockResolvedValue({ files: [{ absolutePath: '/workspace/src/a.ts', relativePath: 'src/a.ts' }], @@ -320,6 +329,66 @@ describe('pipeline/service/refreshFacade', () => { ]); }); + it('records phase timings for delegated changed-file refresh work', async () => { + const facade = new TestRefreshFacade(); + await facade.refreshChangedFiles(['/workspace/src/a.ts']); + const [refreshSource, refreshDependencies] = vi.mocked(refreshWorkspacePipelineChangedFiles).mock.calls[0]; + + await refreshDependencies.notifyFilesChanged([ + { absolutePath: '/workspace/src/a.ts', relativePath: 'src/a.ts', content: 'content:a' }, + ], '/workspace'); + refreshDependencies.persistCache(); + await refreshDependencies.persistIndexMetadata(); + await refreshSource._readAnalysisFiles([{ + absolutePath: '/workspace/src/a.ts', + relativePath: 'src/a.ts', + extension: '.ts', + name: 'a.ts', + }]); + await refreshSource._analyzeFiles([{ + absolutePath: '/workspace/src/a.ts', + relativePath: 'src/a.ts', + extension: '.ts', + name: 'a.ts', + }], '/workspace'); + refreshSource._buildGraphData(new Map(), '/workspace', new Set()); + refreshSource._buildGraphDataFromAnalysis(new Map(), '/workspace', new Set()); + await refreshSource.analyze(['*.ts'], new Set(), undefined, undefined); + refreshSource.invalidateWorkspaceFiles(['/workspace/src/a.ts']); + + const phaseCalls = performanceMocks.recordExtensionPerformanceEvent.mock.calls + .filter(([name]) => name === 'workspacePipeline.refreshChangedFiles.phase'); + expect(phaseCalls.map(([, detail]) => (detail as { phase: string }).phase)).toEqual([ + 'notifyFilesChanged', + 'persistCache', + 'persistIndexMetadata', + 'readAnalysisFiles', + 'analyzeFiles', + 'buildGraphData', + 'buildGraphDataFromAnalysis', + 'fullAnalyze', + 'invalidateWorkspaceFiles', + ]); + expect(phaseCalls).toContainEqual([ + 'workspacePipeline.refreshChangedFiles.phase', + expect.objectContaining({ + durationMs: expect.any(Number), + fileCount: 1, + phase: 'notifyFilesChanged', + }), + ]); + expect(phaseCalls).toContainEqual([ + 'workspacePipeline.refreshChangedFiles.phase', + expect.objectContaining({ + cacheHits: 0, + cacheMisses: 0, + durationMs: expect.any(Number), + fileCount: 1, + phase: 'analyzeFiles', + }), + ]); + }); + it('builds delegated discovery and refresh dependencies for analysis-scope refreshes', async () => { const facade = new TestRefreshFacade(); const disabledPlugins = new Set(['plugin.disabled']); diff --git a/packages/extension/tests/extension/pipeline/serviceAdapters.test.ts b/packages/extension/tests/extension/pipeline/serviceAdapters.test.ts index e370f4462..80e30d579 100644 --- a/packages/extension/tests/extension/pipeline/serviceAdapters.test.ts +++ b/packages/extension/tests/extension/pipeline/serviceAdapters.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { analyzeWorkspacePipelineFiles, buildWorkspacePipelineGraphData, @@ -9,7 +9,19 @@ import { } from '../../../src/extension/pipeline/serviceAdapters'; import { CACHE_VERSION } from '../../../src/extension/gitHistory/cache/stateKeys'; +const performanceMocks = vi.hoisted(() => ({ + recordExtensionPerformanceEvent: vi.fn(), +})); + +vi.mock('../../../src/extension/performance/marks', () => ({ + recordExtensionPerformanceEvent: performanceMocks.recordExtensionPerformanceEvent, +})); + describe('pipeline/serviceAdapters', () => { + beforeEach(() => { + performanceMocks.recordExtensionPerformanceEvent.mockReset(); + }); + it('pre-analyzes files with shared registry and discovery adapters', async () => { const notifyPreAnalyze = vi.fn(async () => undefined); const readContent = vi.fn(async () => 'content'); @@ -110,6 +122,59 @@ describe('pipeline/serviceAdapters', () => { await expect(readWorkspacePipelineFileStat('/workspace/src/app.ts', fileSystem as never)).resolves.toEqual(stat); }); + it('records phase timings while analyzing workspace pipeline files', async () => { + const cache = { files: {} }; + const discovery = { + readContent: vi.fn(async () => 'content'), + readAsString: vi.fn(async () => 'content'), + readAsBytes: vi.fn(async () => new Uint8Array()), + }; + const registry = { + notifyPreAnalyze: vi.fn(async () => undefined), + analyzeFileResult: vi.fn(async () => ({ + filePath: '/workspace/src/app.ts', + relations: [], + })), + }; + + await analyzeWorkspacePipelineFiles( + cache as never, + discovery as never, + undefined, + registry as never, + vi.fn(async () => ({ mtime: 5, size: 12 })), + [{ absolutePath: '/workspace/src/app.ts', relativePath: 'src/app.ts' } as never], + '/workspace', + ); + + const phaseCalls = performanceMocks.recordExtensionPerformanceEvent.mock.calls + .filter(([name]) => name === 'workspacePipeline.analyzeFiles.phase'); + expect(phaseCalls.map(([, detail]) => (detail as { phase: string }).phase)).toEqual([ + 'getFileStat', + 'readContent', + 'notifyPreAnalyze', + 'preAnalyzeFiles', + 'readContent', + 'analyzeFileResult', + ]); + expect(phaseCalls).toContainEqual([ + 'workspacePipeline.analyzeFiles.phase', + expect.objectContaining({ + durationMs: expect.any(Number), + fileCount: 1, + phase: 'preAnalyzeFiles', + }), + ]); + expect(phaseCalls).toContainEqual([ + 'workspacePipeline.analyzeFiles.phase', + expect.objectContaining({ + durationMs: expect.any(Number), + phase: 'analyzeFileResult', + relationCount: 0, + }), + ]); + }); + it('builds graph nodes with valid cached git history churn counts', () => { const cache = { files: { diff --git a/packages/extension/tests/extension/workspaceFiles/fileWatcherSetup.registersavehandler.test.ts b/packages/extension/tests/extension/workspaceFiles/fileWatcherSetup.registersavehandler.test.ts index 9abbc7d04..b4c1f975a 100644 --- a/packages/extension/tests/extension/workspaceFiles/fileWatcherSetup.registersavehandler.test.ts +++ b/packages/extension/tests/extension/workspaceFiles/fileWatcherSetup.registersavehandler.test.ts @@ -82,9 +82,9 @@ describe('registerSaveHandler', () => { const listener = mock.mock.calls[0]?.[0] as (doc: unknown) => void; listener({ uri: { fsPath: '/workspace/src/a.ts' } }); - vi.advanceTimersByTime(200); + vi.advanceTimersByTime(50); listener({ uri: { fsPath: '/workspace/src/b.ts' } }); - vi.advanceTimersByTime(500); + vi.advanceTimersByTime(100); expect(provider.refresh).toHaveBeenCalledOnce(); }); diff --git a/packages/extension/tests/extension/workspaceFiles/fileWatcherSetup.workspacerefreshcoalescing.test.ts b/packages/extension/tests/extension/workspaceFiles/fileWatcherSetup.workspacerefreshcoalescing.test.ts index a9b23544d..68ec0c969 100644 --- a/packages/extension/tests/extension/workspaceFiles/fileWatcherSetup.workspacerefreshcoalescing.test.ts +++ b/packages/extension/tests/extension/workspaceFiles/fileWatcherSetup.workspacerefreshcoalescing.test.ts @@ -61,7 +61,7 @@ describe('workspace refresh coalescing', () => { const saveListener = saveMock.mock.calls[0]?.[0] as (doc: unknown) => void; saveListener({ uri: { fsPath: '/workspace/src/app.ts' } }); - vi.advanceTimersByTime(250); + vi.advanceTimersByTime(50); createListener!({ fsPath: '/workspace/src/app.ts.tmp' }); vi.advanceTimersByTime(499); diff --git a/packages/extension/tests/extension/workspaceFiles/refresh/operations.test.ts b/packages/extension/tests/extension/workspaceFiles/refresh/operations.test.ts index dd7b3200e..840389bd2 100644 --- a/packages/extension/tests/extension/workspaceFiles/refresh/operations.test.ts +++ b/packages/extension/tests/extension/workspaceFiles/refresh/operations.test.ts @@ -37,7 +37,10 @@ describe('workspaceFiles/refresh/operations', () => { provider as never, { uri: uri('/workspace/src/app.ts') } as vscode.TextDocument, ); - vi.advanceTimersByTime(500); + vi.advanceTimersByTime(99); + expect(provider.invalidateWorkspaceFiles).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1); expect(provider.invalidateWorkspaceFiles).toHaveBeenCalledWith(['/workspace/src/app.ts']); expect(provider.emitEvent).toHaveBeenCalledWith('workspace:fileChanged', { @@ -55,7 +58,10 @@ describe('workspaceFiles/refresh/operations', () => { provider as never, { uri: uri('/workspace/.gitignore') } as vscode.TextDocument, ); - vi.advanceTimersByTime(500); + vi.advanceTimersByTime(99); + expect(provider.refreshGitignoreMetadata).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1); expect(provider.refreshGitignoreMetadata).toHaveBeenCalledOnce(); expect(provider.refreshIndex).not.toHaveBeenCalled(); From 220f6105531734eb21e994019cd30f9c860f5af0 Mon Sep 17 00:00:00 2001 From: joesobo Date: Mon, 22 Jun 2026 23:28:46 -0700 Subject: [PATCH 044/192] perf: skip covered refresh graph rebuilds Changed-file refresh now uses the analysis-built graph directly when retained connection keys are already covered by file analysis, keeping the fallback merge for discover-only gaps. Metric: rebuilt VS Code live-update probe removed the buildGraphData phase, moved refreshChangedFiles.completed from 190ms to 144ms, moved incremental request duration from 283ms to 236ms, and moved end-to-end live update from 574ms to 545ms. Tests: pnpm --filter @codegraphy-dev/core exec vitest run tests/indexing/refresh.test.ts tests/graph/data.test.ts; pnpm --filter @codegraphy-dev/extension exec vitest run --config vitest.config.ts tests/extension/pipeline/service/runtime/refresh.test.ts tests/extension/pipeline/service/refreshFacade.test.ts tests/extension/pipeline/service.refreshChangedFiles.test.ts; pnpm --filter @codegraphy-dev/core run typecheck; pnpm --filter @codegraphy-dev/core run lint; pnpm --filter @codegraphy-dev/extension run build:vscode. --- .changeset/material-extension-matcher.md | 2 +- docs/performance/codegraphy-monorepo.md | 8 +++++ .../2026-06-22-codegraphy-performance.md | 1 + packages/core/src/indexing/refresh.ts | 31 +++++++++++++++++- packages/core/tests/indexing/refresh.test.ts | 32 +++++++++++++++++++ 5 files changed, 72 insertions(+), 2 deletions(-) diff --git a/.changeset/material-extension-matcher.md b/.changeset/material-extension-matcher.md index ae23443bf..d7ee5d6c9 100644 --- a/.changeset/material-extension-matcher.md +++ b/.changeset/material-extension-matcher.md @@ -2,4 +2,4 @@ "@codegraphy-dev/extension": patch --- -Speed up stale cached startup and existing-file live updates by reusing prepared startup state, deferring live gitignore probing until the background index refresh, warming the repo-local Graph Cache before the first replay needs it, reusing current discovery for saved-file refreshes, routing incremental pre-analysis to matching plugins, shortening save-triggered refresh debounce, and loading only the requested Tree-sitter grammar for one-file parses. +Speed up stale cached startup and existing-file live updates by reusing prepared startup state, deferring live gitignore probing until the background index refresh, warming the repo-local Graph Cache before the first replay needs it, reusing current discovery for saved-file refreshes, routing incremental pre-analysis to matching plugins, shortening save-triggered refresh debounce, loading only the requested Tree-sitter grammar for one-file parses, and skipping redundant graph rebuilds when incremental analysis already covers the retained files. diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index 84d493624..97698e546 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -725,6 +725,14 @@ Interpretation: `loadTreeSitterBindings()` path took `62ms`-`205ms` and loaded all language bindings. This is a startup and incremental-analysis guardrail rather than a visible graph-count change. +- Changed-file graph refresh now skips the fallback connections-graph build + when the analysis map already covers every retained file connection key. The + fallback remains for discover-only states that need orphan preservation. On + the rebuilt VS Code live-update probe, the `buildGraphData` phase disappeared, + `refreshChangedFiles.completed` moved from `190ms` to `144ms`, and + incremental request duration moved from `283ms` to `236ms`. The end-to-end + live-update wall clock moved from `574ms` to `545ms`, with the remaining wait + now mostly outside this backend graph-build pass. Full test baseline: diff --git a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md index 705682127..36dd87479 100644 --- a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md +++ b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md @@ -316,6 +316,7 @@ Warmed the repo-local Graph Cache when the Graph View runtime creates its analyz Reused current discovery for existing-file live updates and added a live-update VS Code probe: full-discovery control was 3854ms wall / 3149ms request with 1900ms discovery; cached-discovery fast path was 1887ms wall / 1180ms request with 0ms discovery. Added changed-file refresh phase markers: pre-analysis routing hotspot was notifyPreAnalyze at 450ms inside a 722ms request, then routing pre-analysis files by supported extension reduced notifyPreAnalyze to 0ms, analyzeFiles to 78ms, refresh completion to 176ms, and live update to 955ms wall / 267ms request. Shortened existing-file save debounce and targeted Tree-sitter language loading: content saves now wait 100ms while file operations keep 500ms coalescing; latest live-update probe is 574ms wall / 283ms request, and targeted TypeScript Tree-sitter binding load probes at 11ms-17ms versus 62ms-205ms for loading all grammar bindings. +Skipped duplicate changed-file graph builds when analysis already covers retained files: focused Core refresh test failed red on the old fallback `_buildGraphData` call, then passed with the coverage guard; rebuilt VS Code probe removed the `buildGraphData` phase, refresh completion moved 190ms -> 144ms, incremental request 283ms -> 236ms, and live-update wall 574ms -> 545ms. ``` ## Task 5: Keep The PR Reviewable diff --git a/packages/core/src/indexing/refresh.ts b/packages/core/src/indexing/refresh.ts index 8dcfbd4a5..7e5a5dfcf 100644 --- a/packages/core/src/indexing/refresh.ts +++ b/packages/core/src/indexing/refresh.ts @@ -3,6 +3,7 @@ import type { IWorkspaceFileAnalysisResult } from '../analysis/fileAnalysis'; import type { IProjectedConnection } from '../analysis/projectedConnection'; import type { IDiscoveredFile } from '../discovery/contracts'; import type { IGraphData } from '../graph/contracts'; +import { toRepoRelativeGraphPath } from '../graph/symbolPaths'; import { getWorkspaceIndexPluginMatchingFiles } from '../plugins/status/extensions'; import { mapDiscoveredWorkspaceIndexFilesByRelativePath, @@ -135,6 +136,30 @@ function mergeWorkspaceIndexGraphData( }; } +function workspaceIndexAnalysisCoversConnections( + fileAnalysis: ReadonlyMap, + fileConnections: ReadonlyMap, + workspaceRoot: string, +): boolean { + if (fileConnections.size === 0) { + return true; + } + + const analysisFilePaths = new Set( + [...fileAnalysis.keys()].map(filePath => + toRepoRelativeGraphPath(filePath, workspaceRoot), + ), + ); + + for (const filePath of fileConnections.keys()) { + if (!analysisFilePaths.has(toRepoRelativeGraphPath(filePath, workspaceRoot))) { + return false; + } + } + + return true; +} + function buildWorkspaceIndexGraphFromRefreshState( source: WorkspaceIndexRefreshSource, workspaceRoot: string, @@ -145,7 +170,11 @@ function buildWorkspaceIndexGraphFromRefreshState( workspaceRoot, disabledPlugins, ); - if (source._lastFileConnections.size === 0) { + if (workspaceIndexAnalysisCoversConnections( + source._lastFileAnalysis, + source._lastFileConnections, + workspaceRoot, + )) { return analysisGraphData; } diff --git a/packages/core/tests/indexing/refresh.test.ts b/packages/core/tests/indexing/refresh.test.ts index c485f771a..2aad09e66 100644 --- a/packages/core/tests/indexing/refresh.test.ts +++ b/packages/core/tests/indexing/refresh.test.ts @@ -335,4 +335,36 @@ describe('indexing/refresh', () => { expect(persistCache).toHaveBeenCalledOnce(); expect(persistIndexMetadata).toHaveBeenCalledOnce(); }); + + it('skips the fallback connections graph build when analysis covers retained files', async () => { + const graph: IGraphData = { + nodes: [createGraphNode('src/app.ts'), createGraphNode('README.md')], + edges: [], + }; + const source = createSource({ + _buildGraphDataFromAnalysis: vi.fn(() => graph), + _lastFileAnalysis: new Map([ + ['README.md', createFileAnalysis('/workspace/README.md')], + ['src/app.ts', createFileAnalysis('/workspace/src/app.ts')], + ]), + _lastFileConnections: new Map([ + ['README.md', []], + ['src/app.ts', []], + ]), + }); + + await expect(refreshWorkspaceIndexChangedFiles(source, refreshOptions({ + discoveredFiles: [ + createDiscoveredFile('README.md'), + createDiscoveredFile('src/app.ts'), + ], + }))).resolves.toEqual(graph); + + expect(source._buildGraphDataFromAnalysis).toHaveBeenCalledWith( + source._lastFileAnalysis, + '/workspace', + new Set(), + ); + expect(source._buildGraphData).not.toHaveBeenCalled(); + }); }); From d855c0b334aa5563c9b785aa98ae1991977906c3 Mon Sep 17 00:00:00 2001 From: joesobo Date: Mon, 22 Jun 2026 23:33:27 -0700 Subject: [PATCH 045/192] perf: tighten saved-file refresh debounce Existing-file content saves now refresh after 50ms while create/delete/rename operations keep the 500ms coalescing window. Metric: after the covered graph-build skip, live update measured 545ms wall / 236ms request. With the 50ms save debounce, the rebuilt VS Code probe measured 488ms wall / 237ms request, showing the win is earlier request start rather than backend compute. Tests: pnpm --filter @codegraphy-dev/extension exec vitest run --config vitest.config.ts tests/extension/workspaceFiles/refresh/operations.test.ts tests/extension/workspaceFiles/fileWatcherSetup.registersavehandler.test.ts tests/extension/workspaceFiles/fileWatcherSetup.workspacerefreshcoalescing.test.ts tests/extension/workspaceFiles/refresh/watchers.test.ts; pnpm --filter @codegraphy-dev/extension run build:vscode; pnpm --filter @codegraphy-dev/extension run lint; pnpm --filter @codegraphy-dev/extension run typecheck. --- .changeset/material-extension-matcher.md | 2 +- docs/performance/codegraphy-monorepo.md | 7 +++++++ .../superpowers/plans/2026-06-22-codegraphy-performance.md | 1 + .../src/extension/workspaceFiles/refresh/operations.ts | 2 +- .../fileWatcherSetup.registersavehandler.test.ts | 4 ++-- .../fileWatcherSetup.workspacerefreshcoalescing.test.ts | 2 +- .../extension/workspaceFiles/refresh/operations.test.ts | 4 ++-- 7 files changed, 15 insertions(+), 7 deletions(-) diff --git a/.changeset/material-extension-matcher.md b/.changeset/material-extension-matcher.md index d7ee5d6c9..596043e17 100644 --- a/.changeset/material-extension-matcher.md +++ b/.changeset/material-extension-matcher.md @@ -2,4 +2,4 @@ "@codegraphy-dev/extension": patch --- -Speed up stale cached startup and existing-file live updates by reusing prepared startup state, deferring live gitignore probing until the background index refresh, warming the repo-local Graph Cache before the first replay needs it, reusing current discovery for saved-file refreshes, routing incremental pre-analysis to matching plugins, shortening save-triggered refresh debounce, loading only the requested Tree-sitter grammar for one-file parses, and skipping redundant graph rebuilds when incremental analysis already covers the retained files. +Speed up stale cached startup and existing-file live updates by reusing prepared startup state, deferring live gitignore probing until the background index refresh, warming the repo-local Graph Cache before the first replay needs it, reusing current discovery for saved-file refreshes, routing incremental pre-analysis to matching plugins, shortening save-triggered refresh debounce, loading only the requested Tree-sitter grammar for one-file parses, skipping redundant graph rebuilds when incremental analysis already covers the retained files, and using a tighter save debounce for existing-file changes. diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index 97698e546..6d5ce288b 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -733,6 +733,13 @@ Interpretation: incremental request duration moved from `283ms` to `236ms`. The end-to-end live-update wall clock moved from `574ms` to `545ms`, with the remaining wait now mostly outside this backend graph-build pass. +- Existing-file content saves now use a `50ms` debounce while create, delete, + and rename operations keep the `500ms` coalescing window. The next VS Code + live-update probe measured `488ms` wall-clock and `237ms` request duration. + The backend phase split stayed effectively flat (`145ms` changed-file + refresh, `82ms` one-file plugin analysis, `52ms` analysis graph build), so + this iteration specifically moved request start earlier rather than changing + graph computation. Full test baseline: diff --git a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md index 36dd87479..f399b4e74 100644 --- a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md +++ b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md @@ -317,6 +317,7 @@ Reused current discovery for existing-file live updates and added a live-update Added changed-file refresh phase markers: pre-analysis routing hotspot was notifyPreAnalyze at 450ms inside a 722ms request, then routing pre-analysis files by supported extension reduced notifyPreAnalyze to 0ms, analyzeFiles to 78ms, refresh completion to 176ms, and live update to 955ms wall / 267ms request. Shortened existing-file save debounce and targeted Tree-sitter language loading: content saves now wait 100ms while file operations keep 500ms coalescing; latest live-update probe is 574ms wall / 283ms request, and targeted TypeScript Tree-sitter binding load probes at 11ms-17ms versus 62ms-205ms for loading all grammar bindings. Skipped duplicate changed-file graph builds when analysis already covers retained files: focused Core refresh test failed red on the old fallback `_buildGraphData` call, then passed with the coverage guard; rebuilt VS Code probe removed the `buildGraphData` phase, refresh completion moved 190ms -> 144ms, incremental request 283ms -> 236ms, and live-update wall 574ms -> 545ms. +Lowered existing-file save debounce from 100ms to 50ms while keeping create/delete/rename at 500ms: focused debounce tests failed red at the 50ms boundary, then passed; VS Code live-update wall moved 545ms -> 488ms while request duration stayed flat at 237ms. ``` ## Task 5: Keep The PR Reviewable diff --git a/packages/extension/src/extension/workspaceFiles/refresh/operations.ts b/packages/extension/src/extension/workspaceFiles/refresh/operations.ts index 77b5b12ff..78c1ec78a 100644 --- a/packages/extension/src/extension/workspaceFiles/refresh/operations.ts +++ b/packages/extension/src/extension/workspaceFiles/refresh/operations.ts @@ -9,7 +9,7 @@ import { scheduleWorkspaceRefresh } from './scheduler'; type WorkspaceRenameFiles = vscode.FileRenameEvent['files']; type WorkspaceFileEventName = 'workspace:fileCreated' | 'workspace:fileDeleted'; -const WORKSPACE_CONTENT_CHANGE_REFRESH_DELAY_MS = 100; +const WORKSPACE_CONTENT_CHANGE_REFRESH_DELAY_MS = 50; const WORKSPACE_FILE_OPERATION_REFRESH_DELAY_MS = 500; function isGitignorePath(filePath: string): boolean { diff --git a/packages/extension/tests/extension/workspaceFiles/fileWatcherSetup.registersavehandler.test.ts b/packages/extension/tests/extension/workspaceFiles/fileWatcherSetup.registersavehandler.test.ts index b4c1f975a..80eed56e2 100644 --- a/packages/extension/tests/extension/workspaceFiles/fileWatcherSetup.registersavehandler.test.ts +++ b/packages/extension/tests/extension/workspaceFiles/fileWatcherSetup.registersavehandler.test.ts @@ -82,9 +82,9 @@ describe('registerSaveHandler', () => { const listener = mock.mock.calls[0]?.[0] as (doc: unknown) => void; listener({ uri: { fsPath: '/workspace/src/a.ts' } }); - vi.advanceTimersByTime(50); + vi.advanceTimersByTime(25); listener({ uri: { fsPath: '/workspace/src/b.ts' } }); - vi.advanceTimersByTime(100); + vi.advanceTimersByTime(50); expect(provider.refresh).toHaveBeenCalledOnce(); }); diff --git a/packages/extension/tests/extension/workspaceFiles/fileWatcherSetup.workspacerefreshcoalescing.test.ts b/packages/extension/tests/extension/workspaceFiles/fileWatcherSetup.workspacerefreshcoalescing.test.ts index 68ec0c969..bf695cf3e 100644 --- a/packages/extension/tests/extension/workspaceFiles/fileWatcherSetup.workspacerefreshcoalescing.test.ts +++ b/packages/extension/tests/extension/workspaceFiles/fileWatcherSetup.workspacerefreshcoalescing.test.ts @@ -61,7 +61,7 @@ describe('workspace refresh coalescing', () => { const saveListener = saveMock.mock.calls[0]?.[0] as (doc: unknown) => void; saveListener({ uri: { fsPath: '/workspace/src/app.ts' } }); - vi.advanceTimersByTime(50); + vi.advanceTimersByTime(25); createListener!({ fsPath: '/workspace/src/app.ts.tmp' }); vi.advanceTimersByTime(499); diff --git a/packages/extension/tests/extension/workspaceFiles/refresh/operations.test.ts b/packages/extension/tests/extension/workspaceFiles/refresh/operations.test.ts index 840389bd2..04360d113 100644 --- a/packages/extension/tests/extension/workspaceFiles/refresh/operations.test.ts +++ b/packages/extension/tests/extension/workspaceFiles/refresh/operations.test.ts @@ -37,7 +37,7 @@ describe('workspaceFiles/refresh/operations', () => { provider as never, { uri: uri('/workspace/src/app.ts') } as vscode.TextDocument, ); - vi.advanceTimersByTime(99); + vi.advanceTimersByTime(49); expect(provider.invalidateWorkspaceFiles).not.toHaveBeenCalled(); vi.advanceTimersByTime(1); @@ -58,7 +58,7 @@ describe('workspaceFiles/refresh/operations', () => { provider as never, { uri: uri('/workspace/.gitignore') } as vscode.TextDocument, ); - vi.advanceTimersByTime(99); + vi.advanceTimersByTime(49); expect(provider.refreshGitignoreMetadata).not.toHaveBeenCalled(); vi.advanceTimersByTime(1); From 3fee42bccff5dceb69f7eb56fca65446e9ad0102 Mon Sep 17 00:00:00 2001 From: joesobo Date: Mon, 22 Jun 2026 23:41:19 -0700 Subject: [PATCH 046/192] perf: cache TypeScript alias config The TypeScript plugin now caches parsed compiler options by tsconfig mtime and clears the cache when tsconfig-style files change, including extended config updates. Metric: VS Code live-update probe moved analyzeFileResultForPlugins from 82ms to 40ms, changed-file refresh from 145ms to 106ms, incremental request from 237ms to 200ms, and wall time from 488ms to 432ms. Tests: pnpm --filter @codegraphy-dev/plugin-typescript exec vitest run tests/aliasImport/compilerOptions.test.ts tests/lifecycle.test.ts tests/aliasImport/analysis.test.ts; pnpm --filter @codegraphy-dev/plugin-typescript run lint; pnpm --filter @codegraphy-dev/plugin-typescript run typecheck; pnpm --filter @codegraphy-dev/extension run build:vscode. --- .changeset/material-extension-matcher.md | 3 +- docs/performance/codegraphy-monorepo.md | 9 +++ .../2026-06-22-codegraphy-performance.md | 1 + .../src/aliasImport/compilerOptions.ts | 42 ++++++++-- .../src/aliasImport/model.ts | 3 +- packages/plugin-typescript/src/plugin.ts | 10 ++- .../tests/aliasImport/compilerOptions.test.ts | 57 +++++++++++++ .../plugin-typescript/tests/lifecycle.test.ts | 80 +++++++++++++++++++ 8 files changed, 192 insertions(+), 13 deletions(-) diff --git a/.changeset/material-extension-matcher.md b/.changeset/material-extension-matcher.md index 596043e17..6fa6350c9 100644 --- a/.changeset/material-extension-matcher.md +++ b/.changeset/material-extension-matcher.md @@ -1,5 +1,6 @@ --- "@codegraphy-dev/extension": patch +"@codegraphy-dev/plugin-typescript": patch --- -Speed up stale cached startup and existing-file live updates by reusing prepared startup state, deferring live gitignore probing until the background index refresh, warming the repo-local Graph Cache before the first replay needs it, reusing current discovery for saved-file refreshes, routing incremental pre-analysis to matching plugins, shortening save-triggered refresh debounce, loading only the requested Tree-sitter grammar for one-file parses, skipping redundant graph rebuilds when incremental analysis already covers the retained files, and using a tighter save debounce for existing-file changes. +Speed up stale cached startup and existing-file live updates by reusing prepared startup state, deferring live gitignore probing until the background index refresh, warming the repo-local Graph Cache before the first replay needs it, reusing current discovery for saved-file refreshes, routing incremental pre-analysis to matching plugins, shortening save-triggered refresh debounce, loading only the requested Tree-sitter grammar for one-file parses, skipping redundant graph rebuilds when incremental analysis already covers the retained files, using a tighter save debounce for existing-file changes, and caching TypeScript alias compiler options with tsconfig-change invalidation. diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index 6d5ce288b..fe4b76ea6 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -740,6 +740,15 @@ Interpretation: refresh, `82ms` one-file plugin analysis, `52ms` analysis graph build), so this iteration specifically moved request start earlier rather than changing graph computation. +- The TypeScript alias plugin now caches parsed compiler options by + `tsconfig.json` mtime and clears that cache when tsconfig-style files change. + This avoids reparsing the same path-alias config for each TypeScript file + while preserving alias updates from extended configs. On the live-update + probe, one-file plugin analysis moved from `82ms` to `40ms`, delegated + `analyzeFiles` moved from `84ms` to `43ms`, changed-file refresh completion + moved from `145ms` to `106ms`, incremental request duration moved from + `237ms` to `200ms`, and end-to-end live-update wall clock moved from `488ms` + to `432ms`. Full test baseline: diff --git a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md index f399b4e74..44de3e0f7 100644 --- a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md +++ b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md @@ -318,6 +318,7 @@ Added changed-file refresh phase markers: pre-analysis routing hotspot was notif Shortened existing-file save debounce and targeted Tree-sitter language loading: content saves now wait 100ms while file operations keep 500ms coalescing; latest live-update probe is 574ms wall / 283ms request, and targeted TypeScript Tree-sitter binding load probes at 11ms-17ms versus 62ms-205ms for loading all grammar bindings. Skipped duplicate changed-file graph builds when analysis already covers retained files: focused Core refresh test failed red on the old fallback `_buildGraphData` call, then passed with the coverage guard; rebuilt VS Code probe removed the `buildGraphData` phase, refresh completion moved 190ms -> 144ms, incremental request 283ms -> 236ms, and live-update wall 574ms -> 545ms. Lowered existing-file save debounce from 100ms to 50ms while keeping create/delete/rename at 500ms: focused debounce tests failed red at the 50ms boundary, then passed; VS Code live-update wall moved 545ms -> 488ms while request duration stayed flat at 237ms. +Cached TypeScript alias compiler options by tsconfig mtime with lifecycle invalidation for tsconfig-style changes: cache test failed red on two tsconfig reads and lifecycle test failed red on stale extended-config aliases, then passed; VS Code live-update one-file plugin analysis moved 82ms -> 40ms, refresh completion 145ms -> 106ms, request 237ms -> 200ms, and wall 488ms -> 432ms. ``` ## Task 5: Keep The PR Reviewable diff --git a/packages/plugin-typescript/src/aliasImport/compilerOptions.ts b/packages/plugin-typescript/src/aliasImport/compilerOptions.ts index bc87b7326..38303a5d0 100644 --- a/packages/plugin-typescript/src/aliasImport/compilerOptions.ts +++ b/packages/plugin-typescript/src/aliasImport/compilerOptions.ts @@ -7,6 +7,17 @@ export type TypeScriptAliasConfig = { paths: TypeScriptPathMapping[]; }; +type CompilerOptionsCacheEntry = { + mtimeMs: number; + parsed: ts.ParsedCommandLine | null; +}; + +const compilerOptionsCache = new Map(); + +export function clearTypeScriptAliasConfigCache(): void { + compilerOptionsCache.clear(); +} + export function readTypeScriptAliasConfig(filePath: string, workspaceRoot: string): TypeScriptAliasConfig | null { const tsconfigPath = findNearestTypeScriptConfig(filePath, workspaceRoot); if (!tsconfigPath) { @@ -44,18 +55,33 @@ function findNearestTypeScriptConfig(filePath: string, workspaceRoot: string): s } function readCompilerOptions(tsconfigPath: string): ts.ParsedCommandLine | null { + const mtimeMs = fs.statSync(tsconfigPath).mtimeMs; + const cached = compilerOptionsCache.get(tsconfigPath); + if (cached?.mtimeMs === mtimeMs) { + return cached.parsed; + } + const readResult = ts.readConfigFile(tsconfigPath, fileName => ts.sys.readFile(fileName)); - if (readResult.error) { + const parsed = readResult.error + ? null + : ts.parseJsonConfigFileContent( + readResult.config, + createCompilerOptionsParseHost(), + path.dirname(tsconfigPath), + undefined, + tsconfigPath, + ); + + compilerOptionsCache.set(tsconfigPath, { + mtimeMs, + parsed, + }); + + if (!parsed) { return null; } - return ts.parseJsonConfigFileContent( - readResult.config, - createCompilerOptionsParseHost(), - path.dirname(tsconfigPath), - undefined, - tsconfigPath, - ); + return parsed; } function createCompilerOptionsParseHost(): ts.ParseConfigHost { diff --git a/packages/plugin-typescript/src/aliasImport/model.ts b/packages/plugin-typescript/src/aliasImport/model.ts index 02ac25945..fcdda1494 100644 --- a/packages/plugin-typescript/src/aliasImport/model.ts +++ b/packages/plugin-typescript/src/aliasImport/model.ts @@ -1,5 +1,5 @@ import type { IFileAnalysisResult, IPluginEdgeType } from '@codegraphy-dev/plugin-api'; -import { readTypeScriptAliasConfig } from './compilerOptions'; +import { clearTypeScriptAliasConfigCache, readTypeScriptAliasConfig } from './compilerOptions'; import { collectTypeScriptFilePaths, isTypeScriptConfigFile, isTypeScriptSourceFile } from './files'; import { resolveAliasImport } from './resolve'; import { extractModuleSpecifiers } from './specifiers'; @@ -18,6 +18,7 @@ export const TYPESCRIPT_ALIAS_IMPORT_EDGE_TYPE: IPluginEdgeType = { const COMPILER_OPTIONS_PATHS_SOURCE_ID = 'compiler-options-paths'; export { + clearTypeScriptAliasConfigCache, collectTypeScriptFilePaths, isTypeScriptConfigFile, isTypeScriptSourceFile, diff --git a/packages/plugin-typescript/src/plugin.ts b/packages/plugin-typescript/src/plugin.ts index d2682c339..6e5f60d74 100644 --- a/packages/plugin-typescript/src/plugin.ts +++ b/packages/plugin-typescript/src/plugin.ts @@ -2,6 +2,7 @@ import type { IPlugin } from '@codegraphy-dev/plugin-api'; import manifest from '../codegraphy.json'; import { analyzeTypeScriptAliasImports, + clearTypeScriptAliasConfigCache, collectTypeScriptFilePaths, isTypeScriptConfigFile, TYPESCRIPT_ALIAS_IMPORT_EDGE_TYPE, @@ -33,9 +34,12 @@ export function createTypeScriptPlugin(): IPlugin { typeScriptFiles = collectTypeScriptFilePaths(files); }, async onFilesChanged(files) { - return files.some(file => isTypeScriptConfigFile(file.relativePath)) - ? typeScriptFiles - : undefined; + if (!files.some(file => isTypeScriptConfigFile(file.relativePath))) { + return undefined; + } + + clearTypeScriptAliasConfigCache(); + return typeScriptFiles; }, }; } diff --git a/packages/plugin-typescript/tests/aliasImport/compilerOptions.test.ts b/packages/plugin-typescript/tests/aliasImport/compilerOptions.test.ts index 0862941dd..6c93d7265 100644 --- a/packages/plugin-typescript/tests/aliasImport/compilerOptions.test.ts +++ b/packages/plugin-typescript/tests/aliasImport/compilerOptions.test.ts @@ -1,3 +1,4 @@ +import * as fs from 'node:fs'; import ts from 'typescript'; import { describe, expect, it, vi } from 'vitest'; import { createTypeScriptPlugin } from '../../src/plugin'; @@ -289,6 +290,62 @@ describe('TypeScript Alias Import compiler options support', () => { } }); + it('reuses parsed path aliases for files under the same tsconfig', async () => { + const workspaceRoot = createWorkspaceRoot(); + const readFile = vi.spyOn(ts.sys, 'readFile'); + + try { + const tsconfigPath = writeWorkspaceFile( + workspaceRoot, + 'tsconfig.json', + JSON.stringify({ + compilerOptions: { + baseUrl: '.', + paths: { + '@/*': ['src/*'], + }, + }, + }), + ); + const firstSourcePath = writeWorkspaceFile( + workspaceRoot, + 'src/first.ts', + "import { token } from '@/token';\n", + ); + const secondSourcePath = writeWorkspaceFile( + workspaceRoot, + 'src/second.ts', + "import { token } from '@/token';\n", + ); + writeWorkspaceFile( + workspaceRoot, + 'src/token.ts', + 'export const token = Symbol();\n', + ); + + const plugin = createTypeScriptPlugin(); + await plugin.analyzeFile?.( + firstSourcePath, + "import { token } from '@/token';\n", + workspaceRoot, + ); + await plugin.analyzeFile?.( + secondSourcePath, + "import { token } from '@/token';\n", + workspaceRoot, + ); + + const realTsconfigPath = fs.realpathSync.native(tsconfigPath); + const tsconfigReads = readFile.mock.calls.filter(([fileName]) => + fileName === realTsconfigPath, + ); + expect(tsconfigReads).toHaveLength(1); + } finally { + readFile.mockRestore(); + removeWorkspaceRoot(workspaceRoot); + } + }); + it('emits no relationships when nearest tsconfig has no paths', async () => { const workspaceRoot = createWorkspaceRoot(); try { diff --git a/packages/plugin-typescript/tests/lifecycle.test.ts b/packages/plugin-typescript/tests/lifecycle.test.ts index 4908e8b6b..69bba43b2 100644 --- a/packages/plugin-typescript/tests/lifecycle.test.ts +++ b/packages/plugin-typescript/tests/lifecycle.test.ts @@ -77,4 +77,84 @@ describe('TypeScript plugin lifecycle', () => { removeWorkspaceRoot(workspaceRoot); } }); + + it('invalidates cached alias config when an extended tsconfig changes', async () => { + const workspaceRoot = createWorkspaceRoot(); + try { + const baseConfigPath = writeWorkspaceFile( + workspaceRoot, + 'tsconfig.base.json', + JSON.stringify({ + compilerOptions: { + baseUrl: '.', + paths: { + '#/*': ['src-a/*'], + }, + }, + }), + ); + writeWorkspaceFile( + workspaceRoot, + 'tsconfig.json', + JSON.stringify({ + extends: './tsconfig.base.json', + }), + ); + const sourcePath = writeWorkspaceFile( + workspaceRoot, + 'src/app.ts', + "import { token } from '#/token';\n", + ); + const firstTargetPath = writeWorkspaceFile( + workspaceRoot, + 'src-a/token.ts', + 'export const token = 1;\n', + ); + const secondTargetPath = writeWorkspaceFile( + workspaceRoot, + 'src-b/token.ts', + 'export const token = 2;\n', + ); + + const plugin = createTypeScriptPlugin(); + await plugin.onPreAnalyze?.( + [{ absolutePath: sourcePath, relativePath: 'src/app.ts', content: 'export {};\n' }], + workspaceRoot, + ); + + const firstResult = await plugin.analyzeFile?.( + sourcePath, + "import { token } from '#/token';\n", + workspaceRoot, + ); + + writeWorkspaceFile( + workspaceRoot, + 'tsconfig.base.json', + JSON.stringify({ + compilerOptions: { + baseUrl: '.', + paths: { + '#/*': ['src-b/*'], + }, + }, + }), + ); + await plugin.onFilesChanged?.( + [{ absolutePath: baseConfigPath, relativePath: 'tsconfig.base.json', content: '' }], + workspaceRoot, + ); + + const secondResult = await plugin.analyzeFile?.( + sourcePath, + "import { token } from '#/token';\n", + workspaceRoot, + ); + + expect(firstResult?.relations?.[0]?.resolvedPath).toBe(firstTargetPath); + expect(secondResult?.relations?.[0]?.resolvedPath).toBe(secondTargetPath); + } finally { + removeWorkspaceRoot(workspaceRoot); + } + }); }); From 0f2b2ad3c6bc3fa20254b8bb4c46b806bd4b35b4 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 00:06:17 -0700 Subject: [PATCH 047/192] perf: ignore generated pending graph refreshes --- .changeset/material-extension-matcher.md | 3 +- docs/performance/codegraphy-monorepo.md | 24 +++++ packages/core/src/discovery/pathMatching.ts | 3 + .../core/tests/discovery/pathMatching.test.ts | 3 + .../extension/graphView/analysis/execution.ts | 1 + .../graphView/analysis/execution/publish.ts | 88 +++++++++++++++---- .../graphView/provider/analysis/handlers.ts | 1 + .../runtime/workspaceRefreshPersistence.ts | 53 +++++++++-- .../graphView/analysis/execution/fixtures.ts | 6 +- .../analysis/execution/publish.test.ts | 74 ++++++++++++++++ .../provider/analysis/handlers.test.ts | 2 + .../workspaceRefreshPersistence.test.ts | 37 ++++++++ .../extension/workspaceFiles/ignore.test.ts | 2 + 13 files changed, 272 insertions(+), 25 deletions(-) diff --git a/.changeset/material-extension-matcher.md b/.changeset/material-extension-matcher.md index 6fa6350c9..649b31924 100644 --- a/.changeset/material-extension-matcher.md +++ b/.changeset/material-extension-matcher.md @@ -1,6 +1,7 @@ --- "@codegraphy-dev/extension": patch +"@codegraphy-dev/core": patch "@codegraphy-dev/plugin-typescript": patch --- -Speed up stale cached startup and existing-file live updates by reusing prepared startup state, deferring live gitignore probing until the background index refresh, warming the repo-local Graph Cache before the first replay needs it, reusing current discovery for saved-file refreshes, routing incremental pre-analysis to matching plugins, shortening save-triggered refresh debounce, loading only the requested Tree-sitter grammar for one-file parses, skipping redundant graph rebuilds when incremental analysis already covers the retained files, using a tighter save debounce for existing-file changes, and caching TypeScript alias compiler options with tsconfig-change invalidation. +Speed up stale cached startup and existing-file live updates by reusing prepared startup state, deferring live gitignore probing until the background index refresh, warming the repo-local Graph Cache before the first replay needs it, reusing current discovery for saved-file refreshes, routing incremental pre-analysis to matching plugins, shortening save-triggered refresh debounce, loading only the requested Tree-sitter grammar for one-file parses, skipping redundant graph rebuilds when incremental analysis already covers the retained files, using a tighter save debounce for existing-file changes, caching TypeScript alias compiler options with tsconfig-change invalidation, skipping unchanged incremental graph publishes, and ignoring generated `.turbo` plus agent worktree paths. diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index fe4b76ea6..903ac9003 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -749,6 +749,30 @@ Interpretation: moved from `145ms` to `106ms`, incremental request duration moved from `237ms` to `200ms`, and end-to-end live-update wall clock moved from `488ms` to `432ms`. +- Incremental publish now has a no-op graph fast path for fresh changed-file + refreshes whose raw graph payload is unchanged. The fast path keeps status, + plugin, decoration, exporter, toolbar, injection, post-analyze, and workspace + ready broadcasts, but skips raw graph replacement, view transforms, merged + group recomputation, group publication, and `GRAPH_DATA_UPDATED`. A focused + publish test covers the unchanged-graph contract. The first large-repo VS + Code probe after this change did not reuse the current graph because stale + pending refresh metadata forced a full background analysis before the save + refresh; the reuse check took `18ms` and reported `reused: false`, so the + next clean run should confirm whether this fast path is net-positive on true + no-op saves. +- The VS Code probe exposed stale pending refresh metadata as the next + measurement blocker. The main CodeGraphy workspace had `7154` persisted + pending paths, mostly generated `.turbo` folders and nested agent worktree + files, which made each benchmark treat the Graph Cache as stale and run a + roughly `34s` background full analysis. Pending refresh persistence now + ignores the workspace root, generated `.turbo` paths, and `.worktrees` paths + before saving or loading metadata. Against the polluted metadata, the new + filter reduces the pending set from `7154` paths to `1` real source path + (`packages/extension/src/extension/graphViewProvider.ts`). The latest + polluted VS Code sample measured the incremental save request at `232ms`, + changed-file refresh at `97ms`, and Imports toggle at `203ms` wall-clock / + `54ms` in-webview, but the end-to-end save wall clock stayed invalid for + comparison because the stale full analysis still occupied the session. Full test baseline: diff --git a/packages/core/src/discovery/pathMatching.ts b/packages/core/src/discovery/pathMatching.ts index 9789fdb38..6556ce795 100644 --- a/packages/core/src/discovery/pathMatching.ts +++ b/packages/core/src/discovery/pathMatching.ts @@ -12,7 +12,10 @@ export const DEFAULT_EXCLUDE = [ '**/out/**', '**/.git/**', '**/.codegraphy/**', + '**/.turbo', '**/.turbo/**', + '**/.worktrees', + '**/.worktrees/**', '**/coverage/**', '**/.DS_Store', '**/*.min.js', diff --git a/packages/core/tests/discovery/pathMatching.test.ts b/packages/core/tests/discovery/pathMatching.test.ts index 1229bd0af..5f148f7ae 100644 --- a/packages/core/tests/discovery/pathMatching.test.ts +++ b/packages/core/tests/discovery/pathMatching.test.ts @@ -15,7 +15,10 @@ describe('pathMatching', () => { '**/out/**', '**/.git/**', '**/.codegraphy/**', + '**/.turbo', '**/.turbo/**', + '**/.worktrees', + '**/.worktrees/**', '**/coverage/**', '**/.DS_Store', '**/*.min.js', diff --git a/packages/extension/src/extension/graphView/analysis/execution.ts b/packages/extension/src/extension/graphView/analysis/execution.ts index 7b3e0d3e9..73a21b78c 100644 --- a/packages/extension/src/extension/graphView/analysis/execution.ts +++ b/packages/extension/src/extension/graphView/analysis/execution.ts @@ -73,6 +73,7 @@ export interface GraphViewAnalysisExecutionHandlers { hasWorkspace(): boolean; setRawGraphData(graphData: IGraphData): void; setGraphData(graphData: IGraphData): void; + getRawGraphData?(): IGraphData; getGraphData(): IGraphData; sendGraphDataUpdated(graphData: IGraphData): void; sendDepthState(): void; diff --git a/packages/extension/src/extension/graphView/analysis/execution/publish.ts b/packages/extension/src/extension/graphView/analysis/execution/publish.ts index 22db46161..cd06ca473 100644 --- a/packages/extension/src/extension/graphView/analysis/execution/publish.ts +++ b/packages/extension/src/extension/graphView/analysis/execution/publish.ts @@ -42,6 +42,39 @@ function recordPublishStage( }); } +function areGraphDataPayloadsEqual(left: IGraphData, right: IGraphData): boolean { + if (left === right) { + return true; + } + + if (left.nodes.length !== right.nodes.length || left.edges.length !== right.edges.length) { + return false; + } + + try { + return JSON.stringify(left) === JSON.stringify(right); + } catch { + return false; + } +} + +function canReuseCurrentGraphPublication( + state: GraphViewAnalysisExecutionState, + handlers: GraphViewAnalysisExecutionHandlers, + rawGraphData: IGraphData, + actualHasIndex: boolean, + freshness: CodeGraphyIndexFreshness, +): boolean { + if (state.mode !== 'incremental' || !actualHasIndex || freshness !== 'fresh') { + return false; + } + + const currentRawGraphData = handlers.getRawGraphData?.(); + return currentRawGraphData + ? areGraphDataPayloadsEqual(currentRawGraphData, rawGraphData) + : false; +} + export function publishEmptyGraph( handlers: GraphViewAnalysisExecutionHandlers, hasIndex: boolean = false, @@ -70,22 +103,45 @@ export function publishAnalyzedGraph( total: 1, }); } + let stageStartedAt = Date.now(); - handlers.setRawGraphData(rawGraphData); - recordPublishStage('setRawGraphData', stageStartedAt, { + const reuseCurrentGraphPublication = canReuseCurrentGraphPublication( + state, + handlers, + rawGraphData, + actualHasIndex, + status.freshness, + ); + recordPublishStage('reuseCheck', stageStartedAt, { + mode: state.mode, + reused: reuseCurrentGraphPublication, rawEdgeCount: rawGraphData.edges.length, rawNodeCount: rawGraphData.nodes.length, }); stageStartedAt = Date.now(); - handlers.updateViewContext(); - handlers.applyViewTransform(); - recordPublishStage('viewTransform', stageStartedAt); + if (reuseCurrentGraphPublication) { + recordPublishStage('unchangedGraph', stageStartedAt, { + edgeCount: rawGraphData.edges.length, + nodeCount: rawGraphData.nodes.length, + }); + } else { + handlers.setRawGraphData(rawGraphData); + recordPublishStage('setRawGraphData', stageStartedAt, { + rawEdgeCount: rawGraphData.edges.length, + rawNodeCount: rawGraphData.nodes.length, + }); - stageStartedAt = Date.now(); - handlers.computeMergedGroups(); - handlers.sendGroupsUpdated(); - recordPublishStage('groups', stageStartedAt); + stageStartedAt = Date.now(); + handlers.updateViewContext(); + handlers.applyViewTransform(); + recordPublishStage('viewTransform', stageStartedAt); + + stageStartedAt = Date.now(); + handlers.computeMergedGroups(); + handlers.sendGroupsUpdated(); + recordPublishStage('groups', stageStartedAt); + } stageStartedAt = Date.now(); handlers.sendDepthState(); @@ -113,12 +169,14 @@ export function publishAnalyzedGraph( hasIndex: actualHasIndex, freshness: status.freshness, }); - stageStartedAt = Date.now(); - handlers.sendGraphDataUpdated(graphData); - recordPublishStage('sendGraphData', stageStartedAt, { - edgeCount: graphData.edges.length, - nodeCount: graphData.nodes.length, - }); + if (!reuseCurrentGraphPublication) { + stageStartedAt = Date.now(); + handlers.sendGraphDataUpdated(graphData); + recordPublishStage('sendGraphData', stageStartedAt, { + edgeCount: graphData.edges.length, + nodeCount: graphData.nodes.length, + }); + } handlers.sendGraphIndexStatusUpdated(actualHasIndex, status.freshness, status.detail); state.analyzer?.registry.notifyPostAnalyze(graphData, state.disabledPlugins); handlers.markWorkspaceReady(graphData, state.disabledPlugins); diff --git a/packages/extension/src/extension/graphView/provider/analysis/handlers.ts b/packages/extension/src/extension/graphView/provider/analysis/handlers.ts index dcd5ed8f4..f9647b5a2 100644 --- a/packages/extension/src/extension/graphView/provider/analysis/handlers.ts +++ b/packages/extension/src/extension/graphView/provider/analysis/handlers.ts @@ -34,6 +34,7 @@ export function createGraphViewProviderAnalysisHandlers( setGraphData: graphData => { setGraphViewProviderGraphData(source, graphData); }, + getRawGraphData: () => source._rawGraphData, getGraphData: () => source._graphData, sendGraphDataUpdated: graphData => { sendGraphControlsUpdated( diff --git a/packages/extension/src/extension/graphView/provider/runtime/workspaceRefreshPersistence.ts b/packages/extension/src/extension/graphView/provider/runtime/workspaceRefreshPersistence.ts index 88610bcde..370340da4 100644 --- a/packages/extension/src/extension/graphView/provider/runtime/workspaceRefreshPersistence.ts +++ b/packages/extension/src/extension/graphView/provider/runtime/workspaceRefreshPersistence.ts @@ -2,6 +2,7 @@ import { readCodeGraphyRepoMeta, writeCodeGraphyRepoMeta, } from '../../../repoSettings/meta'; +import { shouldIgnoreWorkspaceFileWatcherRefresh } from '../../../workspaceFiles/ignore'; export interface PendingWorkspaceRefreshState { filePaths: Set; @@ -9,6 +10,35 @@ export interface PendingWorkspaceRefreshState { logMessage: string; } +function normalizeFilePath(filePath: string): string { + return filePath.replace(/\\/g, '/').replace(/\/+$/, ''); +} + +function filterPendingWorkspaceRefreshPaths( + workspaceRoot: string, + filePaths: readonly string[], +): string[] { + const normalizedWorkspaceRoot = normalizeFilePath(workspaceRoot); + return filePaths.filter((filePath) => { + if (normalizeFilePath(filePath) === normalizedWorkspaceRoot) { + return false; + } + + return !shouldIgnoreWorkspaceFileWatcherRefresh(filePath); + }); +} + +function persistPendingWorkspaceRefreshPaths( + workspaceRoot: string, + filePaths: readonly string[], +): void { + const meta = readCodeGraphyRepoMeta(workspaceRoot); + writeCodeGraphyRepoMeta(workspaceRoot, { + ...meta, + pendingChangedFiles: [...filePaths], + }); +} + export function persistPendingWorkspaceRefresh( workspaceRoot: string | undefined, filePaths: readonly string[], @@ -17,11 +47,10 @@ export function persistPendingWorkspaceRefresh( return; } - const meta = readCodeGraphyRepoMeta(workspaceRoot); - writeCodeGraphyRepoMeta(workspaceRoot, { - ...meta, - pendingChangedFiles: [...filePaths], - }); + persistPendingWorkspaceRefreshPaths( + workspaceRoot, + filterPendingWorkspaceRefreshPaths(workspaceRoot, filePaths), + ); } export function loadPersistedWorkspaceRefresh( @@ -32,13 +61,21 @@ export function loadPersistedWorkspaceRefresh( } const meta = readCodeGraphyRepoMeta(workspaceRoot); - if (meta.pendingChangedFiles.length === 0) { + const pendingChangedFiles = filterPendingWorkspaceRefreshPaths( + workspaceRoot, + meta.pendingChangedFiles, + ); + if (pendingChangedFiles.length !== meta.pendingChangedFiles.length) { + persistPendingWorkspaceRefreshPaths(workspaceRoot, pendingChangedFiles); + } + + if (pendingChangedFiles.length === 0) { return undefined; } return { - filePaths: new Set(meta.pendingChangedFiles), - gitignoreRefresh: meta.pendingChangedFiles.some(filePath => + filePaths: new Set(pendingChangedFiles), + gitignoreRefresh: pendingChangedFiles.some(filePath => filePath.replace(/\\/g, '/').endsWith('/.gitignore') || filePath.replace(/\\/g, '/') === '.gitignore' ), diff --git a/packages/extension/tests/extension/graphView/analysis/execution/fixtures.ts b/packages/extension/tests/extension/graphView/analysis/execution/fixtures.ts index 474ef0123..4ed7def36 100644 --- a/packages/extension/tests/extension/graphView/analysis/execution/fixtures.ts +++ b/packages/extension/tests/extension/graphView/analysis/execution/fixtures.ts @@ -39,15 +39,19 @@ export function createExecutionAnalyzer( export function createExecutionHandlers( overrides: Partial = {}, ) { + let rawGraphData: IGraphData = { nodes: [], edges: [] }; let graphData: IGraphData = { nodes: [], edges: [] }; const handlers: GraphViewAnalysisExecutionHandlers = { isAnalysisStale: vi.fn(() => false), hasWorkspace: vi.fn(() => true), - setRawGraphData: vi.fn(), + setRawGraphData: vi.fn((nextGraphData: IGraphData) => { + rawGraphData = nextGraphData; + }), setGraphData: vi.fn((nextGraphData: IGraphData) => { graphData = nextGraphData; }), + getRawGraphData: vi.fn(() => rawGraphData), getGraphData: vi.fn(() => graphData), sendGraphDataUpdated: vi.fn(), sendDepthState: vi.fn(), diff --git a/packages/extension/tests/extension/graphView/analysis/execution/publish.test.ts b/packages/extension/tests/extension/graphView/analysis/execution/publish.test.ts index 6a091cf1d..2757ca5e9 100644 --- a/packages/extension/tests/extension/graphView/analysis/execution/publish.test.ts +++ b/packages/extension/tests/extension/graphView/analysis/execution/publish.test.ts @@ -250,6 +250,80 @@ describe('graph view analysis execution publish', () => { ); }); + it('skips graph-specific publication when an incremental refresh leaves the raw graph unchanged', () => { + const rawGraphData: IGraphData = { + nodes: [{ id: 'src/index.ts', label: 'src/index.ts', color: '#ffffff' }], + edges: [ + { + id: 'src/index.ts->src/view.ts#import', + from: 'src/index.ts', + to: 'src/view.ts', + kind: 'import', + sources: [ + { + id: 'typescript:src/index.ts->src/view.ts', + pluginId: 'typescript', + sourceId: 'src/index.ts->src/view.ts', + label: 'TypeScript import', + }, + ], + }, + ], + }; + const state = createExecutionState({ + mode: 'incremental', + analyzer: createExecutionAnalyzer(), + }); + const sendPluginWebviewInjections = vi.fn(); + const { handlers, getGraphData } = createExecutionHandlers({ + sendPluginExporters: vi.fn(), + sendPluginToolbarActions: vi.fn(), + sendPluginWebviewInjections, + }); + handlers.setRawGraphData(rawGraphData); + handlers.setGraphData(rawGraphData); + vi.mocked(handlers.setRawGraphData).mockClear(); + vi.mocked(handlers.setGraphData).mockClear(); + + publishAnalyzedGraph(state, handlers, rawGraphData, true); + + expect(handlers.setRawGraphData).not.toHaveBeenCalled(); + expect(handlers.setGraphData).not.toHaveBeenCalled(); + expect(handlers.updateViewContext).not.toHaveBeenCalled(); + expect(handlers.applyViewTransform).not.toHaveBeenCalled(); + expect(handlers.computeMergedGroups).not.toHaveBeenCalled(); + expect(handlers.sendGroupsUpdated).not.toHaveBeenCalled(); + expect(handlers.sendGraphDataUpdated).not.toHaveBeenCalled(); + expect(handlers.sendDepthState).toHaveBeenCalledOnce(); + expect(handlers.sendPluginStatuses).toHaveBeenCalledOnce(); + expect(handlers.sendDecorations).toHaveBeenCalledOnce(); + expect(handlers.sendContextMenuItems).toHaveBeenCalledOnce(); + expect(handlers.sendPluginExporters).toHaveBeenCalledOnce(); + expect(handlers.sendPluginToolbarActions).toHaveBeenCalledOnce(); + expect(sendPluginWebviewInjections).toHaveBeenCalledOnce(); + expect(handlers.sendGraphIndexStatusUpdated).toHaveBeenCalledWith( + true, + 'fresh', + 'CodeGraphy index is fresh.', + ); + expect(state.analyzer?.registry.notifyPostAnalyze).toHaveBeenCalledWith( + getGraphData(), + state.disabledPlugins, + ); + expect(handlers.markWorkspaceReady).toHaveBeenCalledWith( + getGraphData(), + state.disabledPlugins, + ); + expect(performanceMocks.recordExtensionPerformanceEvent).toHaveBeenCalledWith( + 'graphAnalysis.publish.unchangedGraph', + expect.objectContaining({ + durationMs: expect.any(Number), + edgeCount: 1, + nodeCount: 1, + }), + ); + }); + it('publishes the transformed graph without post-analyze hooks when no analyzer is available', () => { const rawGraphData: IGraphData = { nodes: [{ id: 'src/index.ts', label: 'src/index.ts', color: '#ffffff' }], diff --git a/packages/extension/tests/extension/graphView/provider/analysis/handlers.test.ts b/packages/extension/tests/extension/graphView/provider/analysis/handlers.test.ts index 17e234bc9..00e05b8eb 100644 --- a/packages/extension/tests/extension/graphView/provider/analysis/handlers.test.ts +++ b/packages/extension/tests/extension/graphView/provider/analysis/handlers.test.ts @@ -110,6 +110,7 @@ describe('graphView/provider/analysis/handlers', () => { nodes: [{ id: 'raw', label: 'raw', color: '#ffffff' }], edges: [], }); + expect(handlers.getRawGraphData?.()).toEqual(source._rawGraphData); expect(source._graphData).toEqual(graphData); expect(source._sendMessage).toHaveBeenCalledWith({ type: 'GRAPH_DATA_UPDATED', @@ -166,6 +167,7 @@ describe('graphView/provider/analysis/handlers', () => { }); expect(handlers.hasWorkspace()).toBe(false); + expect(handlers.getRawGraphData?.()).toBe(source._rawGraphData); expect(handlers.getGraphData()).toBe(source._graphData); handlers.sendGraphDataUpdated(graphData); diff --git a/packages/extension/tests/extension/graphView/provider/runtime/workspaceRefreshPersistence.test.ts b/packages/extension/tests/extension/graphView/provider/runtime/workspaceRefreshPersistence.test.ts index a5dfa4a1b..19de16689 100644 --- a/packages/extension/tests/extension/graphView/provider/runtime/workspaceRefreshPersistence.test.ts +++ b/packages/extension/tests/extension/graphView/provider/runtime/workspaceRefreshPersistence.test.ts @@ -42,6 +42,22 @@ describe('graphView/provider/runtime/workspaceRefreshPersistence', () => { ]); }); + it('filters generated pending paths before persisting workspace refresh metadata', () => { + persistPendingWorkspaceRefresh('/test/workspace', [ + '/test/workspace', + '/test/workspace/packages/core/.turbo', + '/test/workspace/.worktrees/speed-up-codegraphy/src/index.ts', + '/test/workspace/src/a.ts', + ]); + + expect(metaState.writes).toEqual([ + { + workspaceRoot: '/test/workspace', + pendingChangedFiles: ['/test/workspace/src/a.ts'], + }, + ]); + }); + it('skips persistence and loading when no workspace root exists', () => { persistPendingWorkspaceRefresh(undefined, ['src/a.ts']); @@ -59,6 +75,27 @@ describe('graphView/provider/runtime/workspaceRefreshPersistence', () => { }); }); + it('cleans generated pending paths when loading persisted workspace refresh data', () => { + metaState.pendingChangedFiles = [ + '/test/workspace', + '/test/workspace/packages/core/.turbo', + '/test/workspace/.worktrees/speed-up-codegraphy/src/index.ts', + '/test/workspace/src/a.ts', + ]; + + expect(loadPersistedWorkspaceRefresh('/test/workspace')).toEqual({ + filePaths: new Set(['/test/workspace/src/a.ts']), + gitignoreRefresh: false, + logMessage: '[CodeGraphy] Applying pending workspace changes', + }); + expect(metaState.writes).toEqual([ + { + workspaceRoot: '/test/workspace', + pendingChangedFiles: ['/test/workspace/src/a.ts'], + }, + ]); + }); + it('marks persisted gitignore changes as metadata refreshes', () => { metaState.pendingChangedFiles = ['src/a.ts', '/test/workspace/.gitignore']; diff --git a/packages/extension/tests/extension/workspaceFiles/ignore.test.ts b/packages/extension/tests/extension/workspaceFiles/ignore.test.ts index 1bfbbdb18..25a4fd685 100644 --- a/packages/extension/tests/extension/workspaceFiles/ignore.test.ts +++ b/packages/extension/tests/extension/workspaceFiles/ignore.test.ts @@ -31,6 +31,8 @@ describe('extension/workspaceFiles/ignore', () => { '/workspace/out/app.js', '/workspace/.git/config', '/workspace/.turbo/cache/abc-meta.json', + '/workspace/packages/plugin-typescript/.turbo', + '/workspace/.worktrees/speed-up-codegraphy/packages/extension/src/extension.ts', '/workspace/coverage/report.json', '/workspace/assets/app.min.js', '/workspace/assets/app.bundle.js', From 36cec580abe4740d2ce07ce9874e58ab668d8615 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 00:28:51 -0700 Subject: [PATCH 048/192] perf: harden graph freshness metrics --- .changeset/material-extension-matcher.md | 2 +- docs/performance/codegraphy-monorepo.md | 21 +++++ packages/core/src/index.ts | 1 + packages/core/src/workspace/status.ts | 7 +- .../core/src/workspace/statusPendingFiles.ts | 25 ++++++ packages/core/tests/workspace/status.test.ts | 25 ++++++ .../graphView/analysis/execution/publish.ts | 1 + .../pipeline/service/base/internal.ts | 5 +- .../extension/pipeline/service/cache/index.ts | 10 ++- .../extension/repoSettings/freshness/index.ts | 15 +++- .../analysis/execution/publish.test.ts | 1 + .../pipeline/service/cache/index.test.ts | 24 ++++++ .../repoSettings/freshness/index.test.ts | 19 +++++ .../performance/measure-vscode-graph-view.mjs | 21 ++++- .../measure-vscode-graph-view.test.mjs | 85 +++++++++++++++++++ 15 files changed, 254 insertions(+), 8 deletions(-) create mode 100644 packages/core/src/workspace/statusPendingFiles.ts diff --git a/.changeset/material-extension-matcher.md b/.changeset/material-extension-matcher.md index 649b31924..c32c5a812 100644 --- a/.changeset/material-extension-matcher.md +++ b/.changeset/material-extension-matcher.md @@ -4,4 +4,4 @@ "@codegraphy-dev/plugin-typescript": patch --- -Speed up stale cached startup and existing-file live updates by reusing prepared startup state, deferring live gitignore probing until the background index refresh, warming the repo-local Graph Cache before the first replay needs it, reusing current discovery for saved-file refreshes, routing incremental pre-analysis to matching plugins, shortening save-triggered refresh debounce, loading only the requested Tree-sitter grammar for one-file parses, skipping redundant graph rebuilds when incremental analysis already covers the retained files, using a tighter save debounce for existing-file changes, caching TypeScript alias compiler options with tsconfig-change invalidation, skipping unchanged incremental graph publishes, and ignoring generated `.turbo` plus agent worktree paths. +Speed up stale cached startup and existing-file live updates by reusing prepared startup state, deferring live gitignore probing until the background index refresh, warming the repo-local Graph Cache before the first replay needs it, reusing current discovery for saved-file refreshes, routing incremental pre-analysis to matching plugins, shortening save-triggered refresh debounce, loading only the requested Tree-sitter grammar for one-file parses, skipping redundant graph rebuilds when incremental analysis already covers the retained files, using a tighter save debounce for existing-file changes, caching TypeScript alias compiler options with tsconfig-change invalidation, skipping unchanged incremental graph publishes, ignoring generated `.turbo` plus agent worktree paths, filtering generated pending paths from status checks, preserving the indexed commit in extension metadata, and making the VS Code live-update benchmark wait for restore refreshes. diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index 903ac9003..4149eaee7 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -773,6 +773,27 @@ Interpretation: changed-file refresh at `97ms`, and Imports toggle at `203ms` wall-clock / `54ms` in-webview, but the end-to-end save wall clock stayed invalid for comparison because the stale full analysis still occupied the session. +- The VS Code live-update harness now waits for the restore-triggered + incremental request before finishing, so a benchmark write/restore pair no + longer returns while the restored file is still queued. A script-level test + simulates marker and restore requests. With the harness wait in place, the + measured marker request stayed in the `218ms`-`434ms` range and the restore + request stayed in the `414ms`-`462ms` range while a background full analysis + was active. +- Generated pending paths are now filtered before Graph Cache status/freshness + checks in both core and extension code. The extension pipeline also persists + `lastIndexedCommit` after full indexing, repairing older metadata where the + commit was left `null`. A repair run wrote + `5108cc3209a9a1d92789d0ed4b1a4f027fbb741e` to `lastIndexedCommit`, and the + generated pending list filtered from `7167` paths down to one real source + path. A diagnostic startup run recorded the remaining stale reason as + `CodeGraphy Workspace Graph Cache is stale: files changed since the last + Indexing run.` The remaining blocker is therefore the benchmark source file + (`packages/extension/src/extension/graphViewProvider.ts`) being newer than + the last completed index after live-update probes, not `.turbo` or + `.worktrees` noise. The next clean measurement pass should wait for a full + background index without touching the live-update file, then take the fresh + live-update sample. Full test baseline: diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 912f66854..0562908bc 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -402,6 +402,7 @@ export type { ReadCodeGraphyWorkspaceStatusOptions, } from './workspace/status'; export { readCodeGraphyWorkspaceStatus } from './workspace/status'; +export { filterWorkspaceStatusPendingChangedFiles } from './workspace/statusPendingFiles'; export type { GraphQueryConfig, GraphQueryConnectionConfig, diff --git a/packages/core/src/workspace/status.ts b/packages/core/src/workspace/status.ts index f69029918..25c4a4bf5 100644 --- a/packages/core/src/workspace/status.ts +++ b/packages/core/src/workspace/status.ts @@ -13,6 +13,7 @@ import { createDefaultStatusPluginSignature } from './statusPlugins'; import { collectCodeGraphyWorkspaceStaleReasons, } from './statusReasons'; +import { filterWorkspaceStatusPendingChangedFiles } from './statusPendingFiles'; import { createCodeGraphyWorkspaceStatusState } from './statusState'; export type { CodeGraphyWorkspaceStatus, @@ -39,13 +40,17 @@ export function readCodeGraphyWorkspaceStatus( ?? (options.plugins ? createCodeGraphyWorkspacePluginSignature(options.plugins) : createDefaultStatusPluginSignature(settings, options.userHomeDir)); + const pendingChangedFiles = filterWorkspaceStatusPendingChangedFiles( + meta.pendingChangedFiles, + { workspaceRoot: resolvedWorkspaceRoot }, + ); const staleReasons = collectCodeGraphyWorkspaceStaleReasons({ hasGraphCache, indexedAt: meta.lastIndexedAt, metaPluginSignature: meta.pluginSignature, metaSettingsSignature: meta.settingsSignature, metaAnalysisVersion: meta.analysisVersion, - pendingChangedFiles: meta.pendingChangedFiles, + pendingChangedFiles, pluginSignature, settingsSignature, }); diff --git a/packages/core/src/workspace/statusPendingFiles.ts b/packages/core/src/workspace/statusPendingFiles.ts new file mode 100644 index 000000000..9fe0f4e39 --- /dev/null +++ b/packages/core/src/workspace/statusPendingFiles.ts @@ -0,0 +1,25 @@ +import { DEFAULT_EXCLUDE, matchesAnyPattern } from '../discovery/pathMatching'; + +function normalizePendingPath(filePath: string): string { + return filePath.replace(/\\/g, '/').replace(/\/+$/, ''); +} + +export function filterWorkspaceStatusPendingChangedFiles( + filePaths: readonly string[], + options: { workspaceRoot?: string } = {}, +): string[] { + const normalizedWorkspaceRoot = options.workspaceRoot + ? normalizePendingPath(options.workspaceRoot) + : undefined; + + return filePaths.filter((filePath) => { + if ( + normalizedWorkspaceRoot + && normalizePendingPath(filePath) === normalizedWorkspaceRoot + ) { + return false; + } + + return !matchesAnyPattern(filePath, DEFAULT_EXCLUDE); + }); +} diff --git a/packages/core/tests/workspace/status.test.ts b/packages/core/tests/workspace/status.test.ts index 75deb1e75..8d63dcb3a 100644 --- a/packages/core/tests/workspace/status.test.ts +++ b/packages/core/tests/workspace/status.test.ts @@ -9,6 +9,7 @@ import { readCodeGraphyWorkspaceMeta, readCodeGraphyWorkspaceSettings, readCodeGraphyWorkspaceStatus, + writeCodeGraphyWorkspaceMeta, writeCodeGraphyWorkspaceSettings, } from '../../src'; @@ -93,4 +94,28 @@ describe('CodeGraphy Workspace status', () => { staleReasons: ['plugin-signature-changed'], }); }); + + it('does not mark the Graph Cache stale for generated pending refresh paths', async () => { + const workspaceRoot = await createWorkspace(); + await indexCodeGraphyWorkspace({ + workspaceRoot, + includeCorePlugins: false, + plugins: [textPlugin], + }); + const meta = readCodeGraphyWorkspaceMeta(workspaceRoot); + writeCodeGraphyWorkspaceMeta(workspaceRoot, { + ...meta, + pendingChangedFiles: [ + path.join(workspaceRoot, 'packages/plugin-typescript/.turbo'), + path.join(workspaceRoot, '.worktrees/speed-up-codegraphy/packages/core/src/index.ts'), + ], + }); + + expect(readCodeGraphyWorkspaceStatus(workspaceRoot, { + plugins: [textPlugin], + })).toMatchObject({ + state: 'fresh', + staleReasons: [], + }); + }); }); diff --git a/packages/extension/src/extension/graphView/analysis/execution/publish.ts b/packages/extension/src/extension/graphView/analysis/execution/publish.ts index cd06ca473..493b1ad1f 100644 --- a/packages/extension/src/extension/graphView/analysis/execution/publish.ts +++ b/packages/extension/src/extension/graphView/analysis/execution/publish.ts @@ -168,6 +168,7 @@ export function publishAnalyzedGraph( edgeCount: graphData.edges.length, hasIndex: actualHasIndex, freshness: status.freshness, + freshnessDetail: status.detail, }); if (!reuseCurrentGraphPublication) { stageStartedAt = Date.now(); diff --git a/packages/extension/src/extension/pipeline/service/base/internal.ts b/packages/extension/src/extension/pipeline/service/base/internal.ts index 6def6e4b4..8a95e1be7 100644 --- a/packages/extension/src/extension/pipeline/service/base/internal.ts +++ b/packages/extension/src/extension/pipeline/service/base/internal.ts @@ -186,7 +186,10 @@ export abstract class WorkspacePipelineInternalBase extends WorkspacePipelineSta } protected async _persistIndexMetadata(): Promise { - await persistWorkspacePipelineIndexMetadata(this._getWorkspaceRoot(), { + const workspaceRoot = this._getWorkspaceRoot(); + await persistWorkspacePipelineIndexMetadata(workspaceRoot, { + getCurrentCommitSha: () => + workspaceRoot ? this._getCurrentCommitShaSync(workspaceRoot) : null, getPluginSignature: () => this._getPluginSignature(), getSettingsSignature: () => this._getSettingsSignature(), warn: (message: string, error: unknown) => { diff --git a/packages/extension/src/extension/pipeline/service/cache/index.ts b/packages/extension/src/extension/pipeline/service/cache/index.ts index 37fdf0c8c..b28f2341f 100644 --- a/packages/extension/src/extension/pipeline/service/cache/index.ts +++ b/packages/extension/src/extension/pipeline/service/cache/index.ts @@ -1,6 +1,6 @@ import * as fs from 'node:fs'; import { persistCodeGraphyWorkspaceIndexMetadata } from '@codegraphy-dev/core'; -import { readCodeGraphyRepoMeta } from '../../../repoSettings/meta'; +import { readCodeGraphyRepoMeta, writeCodeGraphyRepoMeta } from '../../../repoSettings/meta'; import { getWorkspaceAnalysisDatabasePath } from '../../database/cache/storage'; interface WorkspacePipelineSignatureDependencies { @@ -10,6 +10,7 @@ interface WorkspacePipelineSignatureDependencies { interface WorkspacePipelinePersistIndexDependencies extends WorkspacePipelineSignatureDependencies { + getCurrentCommitSha?: () => Promise | string | null; persistIndexMetadata?: typeof persistCodeGraphyWorkspaceIndexMetadata; warn(message: string, error: unknown): void; } @@ -38,10 +39,17 @@ export async function persistWorkspacePipelineIndexMetadata( } try { + const currentCommitSha = await dependencies.getCurrentCommitSha?.(); (dependencies.persistIndexMetadata ?? persistCodeGraphyWorkspaceIndexMetadata)(workspaceRoot, { pluginSignature: dependencies.getPluginSignature(), settingsSignature: dependencies.getSettingsSignature(), }); + if (dependencies.getCurrentCommitSha) { + writeCodeGraphyRepoMeta(workspaceRoot, { + ...readCodeGraphyRepoMeta(workspaceRoot), + lastIndexedCommit: currentCommitSha ?? null, + }); + } } catch (error) { dependencies.warn('[CodeGraphy] Failed to update repo index metadata.', error); } diff --git a/packages/extension/src/extension/repoSettings/freshness/index.ts b/packages/extension/src/extension/repoSettings/freshness/index.ts index de07f2da5..128593fa7 100644 --- a/packages/extension/src/extension/repoSettings/freshness/index.ts +++ b/packages/extension/src/extension/repoSettings/freshness/index.ts @@ -1,4 +1,5 @@ import type { ICodeGraphyRepoMeta } from '../meta'; +import { filterWorkspaceStatusPendingChangedFiles } from '@codegraphy-dev/core'; import { createFreshDetail, createMissingDetail, @@ -38,12 +39,22 @@ export function evaluateCodeGraphyIndexStatus(input: { settingsSignature: string; }): CodeGraphyIndexStatus { const { meta, currentCommit, pluginSignature, settingsSignature } = input; + const pendingChangedFiles = filterWorkspaceStatusPendingChangedFiles(meta.pendingChangedFiles); + const statusMeta = { + ...meta, + pendingChangedFiles, + }; if (meta.lastIndexedAt === null) { return createMissingStatus(); } - const staleReasons = collectStaleReasons({ meta, currentCommit, pluginSignature, settingsSignature }); + const staleReasons = collectStaleReasons({ + meta: statusMeta, + currentCommit, + pluginSignature, + settingsSignature, + }); if (staleReasons.length === 0) { return createFreshStatus(); } @@ -52,6 +63,6 @@ export function evaluateCodeGraphyIndexStatus(input: { freshness: 'stale', hasIndex: false, staleReasons, - detail: createStaleDetail(staleReasons, meta.pendingChangedFiles), + detail: createStaleDetail(staleReasons, pendingChangedFiles), }; } diff --git a/packages/extension/tests/extension/graphView/analysis/execution/publish.test.ts b/packages/extension/tests/extension/graphView/analysis/execution/publish.test.ts index 2757ca5e9..143f92efa 100644 --- a/packages/extension/tests/extension/graphView/analysis/execution/publish.test.ts +++ b/packages/extension/tests/extension/graphView/analysis/execution/publish.test.ts @@ -139,6 +139,7 @@ describe('graph view analysis execution publish', () => { edgeCount: 0, hasIndex: true, freshness: 'fresh', + freshnessDetail: 'CodeGraphy index is fresh.', }, ); expect(performanceMocks.recordExtensionPerformanceEvent).toHaveBeenCalledWith( diff --git a/packages/extension/tests/extension/pipeline/service/cache/index.test.ts b/packages/extension/tests/extension/pipeline/service/cache/index.test.ts index 6f38a9b3c..823b6b694 100644 --- a/packages/extension/tests/extension/pipeline/service/cache/index.test.ts +++ b/packages/extension/tests/extension/pipeline/service/cache/index.test.ts @@ -8,11 +8,13 @@ import { } from '../../../../../src/extension/pipeline/service/cache/index'; import { readCodeGraphyRepoMeta, + writeCodeGraphyRepoMeta, } from '../../../../../src/extension/repoSettings/meta'; import type { ICodeGraphyRepoMeta } from '../../../../../src/extension/repoSettings/meta'; vi.mock('../../../../../src/extension/repoSettings/meta', () => ({ readCodeGraphyRepoMeta: vi.fn(), + writeCodeGraphyRepoMeta: vi.fn(), })); describe('pipeline/service/cache/index', () => { @@ -130,6 +132,28 @@ describe('pipeline/service/cache/index', () => { expect(warn).not.toHaveBeenCalled(); }); + it('records the current commit when index metadata is persisted', async () => { + const persistIndexMetadata = vi.fn(); + const dependencies = { + getCurrentCommitSha: vi.fn(() => 'def456'), + getPluginSignature: vi.fn(() => 'next-plugin-signature'), + getSettingsSignature: vi.fn(() => 'next-settings-signature'), + persistIndexMetadata, + warn: vi.fn(), + }; + + await persistWorkspacePipelineIndexMetadata('/workspace', dependencies); + + expect(persistIndexMetadata).toHaveBeenCalledWith('/workspace', { + pluginSignature: 'next-plugin-signature', + settingsSignature: 'next-settings-signature', + }); + expect(writeCodeGraphyRepoMeta).toHaveBeenCalledWith('/workspace', { + ...meta(), + lastIndexedCommit: 'def456', + }); + }); + it('delegates pending changed file cleanup to core metadata persistence', async () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-04-16T08:45:00.000Z')); diff --git a/packages/extension/tests/extension/repoSettings/freshness/index.test.ts b/packages/extension/tests/extension/repoSettings/freshness/index.test.ts index 1948a069f..532ddee2d 100644 --- a/packages/extension/tests/extension/repoSettings/freshness/index.test.ts +++ b/packages/extension/tests/extension/repoSettings/freshness/index.test.ts @@ -59,6 +59,25 @@ describe('repoSettings/freshness/index', () => { }); }); + it('ignores generated pending changed files when evaluating index freshness', () => { + expect(evaluateCodeGraphyIndexStatus({ + meta: { + ...indexedMeta, + pendingChangedFiles: [ + '/workspace/packages/plugin-typescript/.turbo', + '/workspace/.worktrees/speed-up-codegraphy/packages/extension/src/extension.ts', + ], + }, + currentCommit: 'abc123', + pluginSignature: 'plugins', + settingsSignature: 'settings', + })).toMatchObject({ + freshness: 'fresh', + hasIndex: true, + staleReasons: [], + }); + }); + it('describes plural pending changed files', () => { expect(evaluateCodeGraphyIndexStatus({ meta: { diff --git a/scripts/performance/measure-vscode-graph-view.mjs b/scripts/performance/measure-vscode-graph-view.mjs index 13d0ec5c4..301c31c72 100644 --- a/scripts/performance/measure-vscode-graph-view.mjs +++ b/scripts/performance/measure-vscode-graph-view.mjs @@ -461,7 +461,7 @@ async function measureSwitchTransition(frame, label, enabled) { }; } -async function measureLiveUpdateTransition({ +export async function measureLiveUpdateTransition({ extensionHostLogPath, frame, liveUpdateFilePath, @@ -477,9 +477,12 @@ async function measureLiveUpdateTransition({ await resetWebviewPerformanceEvents(frame); const startedAtEpoch = Date.now(); const startedAt = performance.now(); + let markerWritten = false; + let updateRequestCompletedAt; try { await writeFile(absoluteFilePath, `${originalContent}${marker}`); + markerWritten = true; const requestEvent = await waitForExtensionHostPerformanceEvent( extensionHostLogPath, events => findCompletedExtensionHostRequestAfter(events, { @@ -487,6 +490,7 @@ async function measureLiveUpdateTransition({ startedAt: startedAtEpoch, }), ); + updateRequestCompletedAt = requestEvent.at; return { durationMs: Math.round(performance.now() - startedAt), @@ -496,7 +500,20 @@ async function measureLiveUpdateTransition({ webviewEvents: await readWebviewPerformanceEvents(frame), }; } finally { - await writeFile(absoluteFilePath, originalContent); + if (markerWritten) { + const restoreStartedAtEpoch = Date.now(); + await writeFile(absoluteFilePath, originalContent); + await waitForExtensionHostPerformanceEvent( + extensionHostLogPath, + events => findCompletedExtensionHostRequestAfter(events, { + mode: LIVE_UPDATE_REQUEST_MODE, + startedAt: typeof updateRequestCompletedAt === 'number' + ? Math.max(restoreStartedAtEpoch, updateRequestCompletedAt + 1) + : restoreStartedAtEpoch, + }), + ); + await waitForExtensionHostRequestIdle(extensionHostLogPath, LIVE_UPDATE_REQUEST_MODE); + } } } diff --git a/tests/scripts/measure-vscode-graph-view.test.mjs b/tests/scripts/measure-vscode-graph-view.test.mjs index cf173f171..6a3a52814 100644 --- a/tests/scripts/measure-vscode-graph-view.test.mjs +++ b/tests/scripts/measure-vscode-graph-view.test.mjs @@ -1,4 +1,6 @@ import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; import { pathToFileURL } from 'node:url'; @@ -315,3 +317,86 @@ test('VS Code graph view runner builds a startup-ready measurement payload befor initialStats: { nodeCount: 10, edgeCount: 5 }, }); }); + +test('VS Code graph view runner waits for the live-update restore request before finishing', async (t) => { + const moduleUrl = pathToFileURL( + path.resolve('scripts/performance/measure-vscode-graph-view.mjs'), + ).href; + const { measureLiveUpdateTransition } = await import(moduleUrl); + const workspaceRoot = await mkdtemp(path.join(os.tmpdir(), 'codegraphy-live-update-')); + t.after(() => rm(workspaceRoot, { recursive: true, force: true })); + + const liveUpdateFilePath = 'src/example.ts'; + const absoluteFilePath = path.join(workspaceRoot, liveUpdateFilePath); + const extensionHostLogPath = path.join(workspaceRoot, 'extension-host.jsonl'); + const originalContent = 'export const value = 1;\n'; + await mkdir(path.dirname(absoluteFilePath), { recursive: true }); + await writeFile(absoluteFilePath, originalContent); + await writeFile(extensionHostLogPath, ''); + + let stopped = false; + let markerRequestRecorded = false; + let restoreRequestCompleted = false; + const frame = { + evaluate: async (callback) => { + if (String(callback).includes('__codegraphyPerformance?.events')) { + return []; + } + return undefined; + }, + waitForTimeout: async ms => new Promise(resolve => setTimeout(resolve, ms)), + }; + + async function appendIncrementalRequest(requestId) { + const startedAt = Date.now(); + await writeFile(extensionHostLogPath, [ + JSON.stringify({ + name: 'graphAnalysis.request.start', + at: startedAt, + detail: { requestId, mode: 'incremental' }, + }), + JSON.stringify({ + name: 'graphAnalysis.request.completed', + at: startedAt + 5, + detail: { requestId, mode: 'incremental', durationMs: 5 }, + }), + '', + ].join('\n'), { flag: 'a' }); + } + + const observer = (async () => { + while (!stopped) { + const content = await readFile(absoluteFilePath, 'utf8'); + if (!markerRequestRecorded && content.includes('CodeGraphy live update perf marker')) { + markerRequestRecorded = true; + await appendIncrementalRequest(1); + } else if ( + markerRequestRecorded + && !restoreRequestCompleted + && content === originalContent + ) { + await new Promise(resolve => setTimeout(resolve, 50)); + await appendIncrementalRequest(2); + restoreRequestCompleted = true; + } + + await new Promise(resolve => setTimeout(resolve, 5)); + } + })(); + + try { + const sample = await measureLiveUpdateTransition({ + extensionHostLogPath, + frame, + liveUpdateFilePath, + workspaceRoot, + }); + + assert.equal(sample.filePath, liveUpdateFilePath); + assert.equal(restoreRequestCompleted, true); + assert.equal(await readFile(absoluteFilePath, 'utf8'), originalContent); + } finally { + stopped = true; + await observer; + } +}); From c65b46ce5a362aa3426abfd769b16f146f6b09b9 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 00:42:20 -0700 Subject: [PATCH 049/192] perf: keep fresh live-update measurements clean --- docs/performance/codegraphy-monorepo.md | 18 ++++ packages/core/src/workspace/status.ts | 5 +- .../core/src/workspace/statusPendingFiles.ts | 57 +++++++++- packages/core/tests/workspace/status.test.ts | 23 ++++ .../extension/repoSettings/freshness/index.ts | 5 +- .../repoSettings/freshness/index.test.ts | 29 +++++ .../performance/measure-vscode-graph-view.mjs | 2 + .../measure-vscode-graph-view.test.mjs | 101 ++++++++++++++++++ 8 files changed, 236 insertions(+), 4 deletions(-) diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index 4149eaee7..b7d690764 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -794,6 +794,24 @@ Interpretation: `.worktrees` noise. The next clean measurement pass should wait for a full background index without touching the live-update file, then take the fresh live-update sample. +- The VS Code live-update harness now waits for active background `analyze` + requests to go idle before writing its marker file. A script-level regression + test covers the contaminated-measurement case where a stale startup analyze + was active before the marker write. On the polluted main workspace, this + moved the reported live-update wall clock from the invalid `32164ms`- + `32444ms` band down to `671ms`, with the actual incremental request at + `404ms`; the background `analyze` still took `34531ms`, but it is no longer + counted as live-update latency. +- Pending source paths are now ignored by freshness checks when the file still + exists and its mtime is at or before `lastIndexedAt`. This handles duplicate + watcher events that persist after a successful benchmark restore/index cycle. + In the polluted main workspace, raw metadata still had `7171` pending paths + after shutdown, but the updated source filter reduced that set to `0`. The + next rebuilt VS Code run published cached `load` as `fresh` with no + background `analyze`, completed the load request in `1119ms`, reached first + graph readiness in `5112ms`, measured Imports toggle at `277ms` wall-clock / + `62ms` in-webview, and measured live-update at `943ms` wall-clock with a + `597ms` incremental request. Full test baseline: diff --git a/packages/core/src/workspace/status.ts b/packages/core/src/workspace/status.ts index 25c4a4bf5..c99881234 100644 --- a/packages/core/src/workspace/status.ts +++ b/packages/core/src/workspace/status.ts @@ -42,7 +42,10 @@ export function readCodeGraphyWorkspaceStatus( : createDefaultStatusPluginSignature(settings, options.userHomeDir)); const pendingChangedFiles = filterWorkspaceStatusPendingChangedFiles( meta.pendingChangedFiles, - { workspaceRoot: resolvedWorkspaceRoot }, + { + lastIndexedAt: meta.lastIndexedAt, + workspaceRoot: resolvedWorkspaceRoot, + }, ); const staleReasons = collectCodeGraphyWorkspaceStaleReasons({ hasGraphCache, diff --git a/packages/core/src/workspace/statusPendingFiles.ts b/packages/core/src/workspace/statusPendingFiles.ts index 9fe0f4e39..4e9e57bb8 100644 --- a/packages/core/src/workspace/statusPendingFiles.ts +++ b/packages/core/src/workspace/statusPendingFiles.ts @@ -1,16 +1,61 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; import { DEFAULT_EXCLUDE, matchesAnyPattern } from '../discovery/pathMatching'; function normalizePendingPath(filePath: string): string { return filePath.replace(/\\/g, '/').replace(/\/+$/, ''); } +function resolvePendingPath(filePath: string, workspaceRoot: string | undefined): string { + if (path.isAbsolute(filePath) || !workspaceRoot) { + return filePath; + } + + return path.join(workspaceRoot, filePath); +} + +function parseIndexedAt(indexedAt: string | null | undefined): number | undefined { + if (!indexedAt) { + return undefined; + } + + const parsed = Date.parse(indexedAt); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function wasPendingPathCoveredByIndex( + filePath: string, + options: { + indexedAtMs: number | undefined; + stat: (filePath: string) => fs.Stats; + workspaceRoot: string | undefined; + }, +): boolean { + if (options.indexedAtMs === undefined) { + return false; + } + + try { + const stat = options.stat(resolvePendingPath(filePath, options.workspaceRoot)); + return stat.mtimeMs <= options.indexedAtMs; + } catch { + return false; + } +} + export function filterWorkspaceStatusPendingChangedFiles( filePaths: readonly string[], - options: { workspaceRoot?: string } = {}, + options: { + lastIndexedAt?: string | null; + stat?: (filePath: string) => fs.Stats; + workspaceRoot?: string; + } = {}, ): string[] { const normalizedWorkspaceRoot = options.workspaceRoot ? normalizePendingPath(options.workspaceRoot) : undefined; + const indexedAtMs = parseIndexedAt(options.lastIndexedAt); + const stat = options.stat ?? fs.statSync; return filePaths.filter((filePath) => { if ( @@ -20,6 +65,14 @@ export function filterWorkspaceStatusPendingChangedFiles( return false; } - return !matchesAnyPattern(filePath, DEFAULT_EXCLUDE); + if (matchesAnyPattern(filePath, DEFAULT_EXCLUDE)) { + return false; + } + + return !wasPendingPathCoveredByIndex(filePath, { + indexedAtMs, + stat, + workspaceRoot: options.workspaceRoot, + }); }); } diff --git a/packages/core/tests/workspace/status.test.ts b/packages/core/tests/workspace/status.test.ts index 8d63dcb3a..77daad1ae 100644 --- a/packages/core/tests/workspace/status.test.ts +++ b/packages/core/tests/workspace/status.test.ts @@ -118,4 +118,27 @@ describe('CodeGraphy Workspace status', () => { staleReasons: [], }); }); + + it('does not mark the Graph Cache stale for pending files already covered by the last index', async () => { + const workspaceRoot = await createWorkspace(); + await indexCodeGraphyWorkspace({ + workspaceRoot, + includeCorePlugins: false, + plugins: [textPlugin], + }); + const meta = readCodeGraphyWorkspaceMeta(workspaceRoot); + writeCodeGraphyWorkspaceMeta(workspaceRoot, { + ...meta, + pendingChangedFiles: [ + path.join(workspaceRoot, 'source.txt'), + ], + }); + + expect(readCodeGraphyWorkspaceStatus(workspaceRoot, { + plugins: [textPlugin], + })).toMatchObject({ + state: 'fresh', + staleReasons: [], + }); + }); }); diff --git a/packages/extension/src/extension/repoSettings/freshness/index.ts b/packages/extension/src/extension/repoSettings/freshness/index.ts index 128593fa7..b66958ee0 100644 --- a/packages/extension/src/extension/repoSettings/freshness/index.ts +++ b/packages/extension/src/extension/repoSettings/freshness/index.ts @@ -39,7 +39,10 @@ export function evaluateCodeGraphyIndexStatus(input: { settingsSignature: string; }): CodeGraphyIndexStatus { const { meta, currentCommit, pluginSignature, settingsSignature } = input; - const pendingChangedFiles = filterWorkspaceStatusPendingChangedFiles(meta.pendingChangedFiles); + const pendingChangedFiles = filterWorkspaceStatusPendingChangedFiles( + meta.pendingChangedFiles, + { lastIndexedAt: meta.lastIndexedAt }, + ); const statusMeta = { ...meta, pendingChangedFiles, diff --git a/packages/extension/tests/extension/repoSettings/freshness/index.test.ts b/packages/extension/tests/extension/repoSettings/freshness/index.test.ts index 532ddee2d..400cbd08a 100644 --- a/packages/extension/tests/extension/repoSettings/freshness/index.test.ts +++ b/packages/extension/tests/extension/repoSettings/freshness/index.test.ts @@ -1,3 +1,6 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; import { describe, expect, it } from 'vitest'; import { evaluateCodeGraphyIndexStatus } from '../../../../src/extension/repoSettings/freshness'; import type { ICodeGraphyRepoMeta } from '../../../../src/extension/repoSettings/meta'; @@ -78,6 +81,32 @@ describe('repoSettings/freshness/index', () => { }); }); + it('ignores pending changed files already covered by index metadata', () => { + const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraphy-index-freshness-')); + const filePath = path.join(workspaceRoot, 'src/types.ts'); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, 'export type Example = string;\n'); + + try { + expect(evaluateCodeGraphyIndexStatus({ + meta: { + ...indexedMeta, + lastIndexedAt: new Date(Date.now() + 1_000).toISOString(), + pendingChangedFiles: [filePath], + }, + currentCommit: 'abc123', + pluginSignature: 'plugins', + settingsSignature: 'settings', + })).toMatchObject({ + freshness: 'fresh', + hasIndex: true, + staleReasons: [], + }); + } finally { + fs.rmSync(workspaceRoot, { recursive: true, force: true }); + } + }); + it('describes plural pending changed files', () => { expect(evaluateCodeGraphyIndexStatus({ meta: { diff --git a/scripts/performance/measure-vscode-graph-view.mjs b/scripts/performance/measure-vscode-graph-view.mjs index 301c31c72..a14085e36 100644 --- a/scripts/performance/measure-vscode-graph-view.mjs +++ b/scripts/performance/measure-vscode-graph-view.mjs @@ -14,6 +14,7 @@ const DEFAULT_TIMEOUT_MS = 120_000; const WEBVIEW_PERFORMANCE_EVENT_LIMIT = 500; const IMPORTS_TOGGLE_START_EVENT = 'graphScope.edgeVisibility.optimistic'; const IMPORTS_TOGGLE_RENDERED_EVENT = 'graphStats.rendered'; +const ANALYZE_REQUEST_MODE = 'analyze'; const LIVE_UPDATE_REQUEST_MODE = 'incremental'; const DEFAULT_PLUGIN_PACKAGE_RELATIVE_PATHS = [ 'packages/plugin-godot', @@ -473,6 +474,7 @@ export async function measureLiveUpdateTransition({ const originalContent = await readFile(absoluteFilePath, 'utf8'); const marker = `\n// CodeGraphy live update perf marker ${Date.now()}\n`; + await waitForExtensionHostRequestIdle(extensionHostLogPath, ANALYZE_REQUEST_MODE); await waitForExtensionHostRequestIdle(extensionHostLogPath, LIVE_UPDATE_REQUEST_MODE); await resetWebviewPerformanceEvents(frame); const startedAtEpoch = Date.now(); diff --git a/tests/scripts/measure-vscode-graph-view.test.mjs b/tests/scripts/measure-vscode-graph-view.test.mjs index 6a3a52814..611df362c 100644 --- a/tests/scripts/measure-vscode-graph-view.test.mjs +++ b/tests/scripts/measure-vscode-graph-view.test.mjs @@ -400,3 +400,104 @@ test('VS Code graph view runner waits for the live-update restore request before await observer; } }); + +test('VS Code graph view runner waits for active analyze requests before live-update markers', async (t) => { + const moduleUrl = pathToFileURL( + path.resolve('scripts/performance/measure-vscode-graph-view.mjs'), + ).href; + const { measureLiveUpdateTransition } = await import(moduleUrl); + const workspaceRoot = await mkdtemp(path.join(os.tmpdir(), 'codegraphy-live-update-analyze-')); + t.after(() => rm(workspaceRoot, { recursive: true, force: true })); + + const liveUpdateFilePath = 'src/example.ts'; + const absoluteFilePath = path.join(workspaceRoot, liveUpdateFilePath); + const extensionHostLogPath = path.join(workspaceRoot, 'extension-host.jsonl'); + const originalContent = 'export const value = 1;\n'; + await mkdir(path.dirname(absoluteFilePath), { recursive: true }); + await writeFile(absoluteFilePath, originalContent); + await writeFile(extensionHostLogPath, `${JSON.stringify({ + name: 'graphAnalysis.request.start', + at: Date.now() - 10, + detail: { requestId: 7, mode: 'analyze' }, + })}\n`); + + let stopped = false; + let markerSeenAt = 0; + let analyzeCompletedAt = 0; + let markerRequestRecorded = false; + let restoreRequestCompleted = false; + const frame = { + evaluate: async (callback) => { + if (String(callback).includes('__codegraphyPerformance?.events')) { + return []; + } + return undefined; + }, + waitForTimeout: async ms => new Promise(resolve => setTimeout(resolve, ms)), + }; + + async function appendRequestEvent(name, requestId, mode) { + const at = Date.now(); + await writeFile(extensionHostLogPath, `${JSON.stringify({ + name, + at, + detail: { requestId, mode, durationMs: 5 }, + })}\n`, { flag: 'a' }); + return at; + } + + async function appendIncrementalRequest(requestId) { + await appendRequestEvent('graphAnalysis.request.start', requestId, 'incremental'); + await appendRequestEvent('graphAnalysis.request.completed', requestId, 'incremental'); + } + + const analyzeCompletion = new Promise((resolve, reject) => { + setTimeout(() => { + appendRequestEvent('graphAnalysis.request.completed', 7, 'analyze') + .then((at) => { + analyzeCompletedAt = at; + resolve(); + }) + .catch(reject); + }, 80); + }); + + const observer = (async () => { + while (!stopped) { + const content = await readFile(absoluteFilePath, 'utf8'); + if (!markerRequestRecorded && content.includes('CodeGraphy live update perf marker')) { + markerSeenAt = Date.now(); + markerRequestRecorded = true; + await appendIncrementalRequest(8); + } else if ( + markerRequestRecorded + && !restoreRequestCompleted + && content === originalContent + ) { + await appendIncrementalRequest(9); + restoreRequestCompleted = true; + } + + await new Promise(resolve => setTimeout(resolve, 5)); + } + })(); + + try { + await measureLiveUpdateTransition({ + extensionHostLogPath, + frame, + liveUpdateFilePath, + workspaceRoot, + }); + await analyzeCompletion; + + assert.equal(restoreRequestCompleted, true); + assert.ok( + markerSeenAt >= analyzeCompletedAt, + `marker was written before analyze completed: marker=${markerSeenAt} analyze=${analyzeCompletedAt}`, + ); + } finally { + stopped = true; + await observer; + } +}); From 8a2be30e06f069599ed2ee8134c80642340ebed8 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 00:49:43 -0700 Subject: [PATCH 050/192] docs: record startup ordering perf result --- docs/performance/codegraphy-monorepo.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index b7d690764..d4b55ca03 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -812,6 +812,13 @@ Interpretation: graph readiness in `5112ms`, measured Imports toggle at `277ms` wall-clock / `62ms` in-webview, and measured live-update at `943ms` wall-clock with a `597ms` incremental request. +- Rejected startup-order experiment: moving graph loading before cached timeline + replay made the `load` request start earlier (`267ms` after open instead of + roughly `741ms`), but it ran under heavier webview startup contention and + regressed first graph readiness from `5112ms` to `5583ms`. A stats-frame + harness probe also still observed the same late visible stats timing, so the + experiment was backed out. The next startup work should target product work + inside cached load or webview render cost, not timeline-before-graph ordering. Full test baseline: From 186532f67411ad9939a5ef0696837b93889e228a Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 00:59:44 -0700 Subject: [PATCH 051/192] perf: skip metric-only group publish --- .changeset/skip-metric-group-publish.md | 5 ++ docs/performance/codegraphy-monorepo.md | 19 ++++++ .../graphView/analysis/execution/publish.ts | 67 +++++++++++++++++-- .../analysis/execution/publish.test.ts | 52 ++++++++++++++ .../performance/measure-vscode-graph-view.mjs | 48 ++++++++----- .../measure-vscode-graph-view.test.mjs | 49 ++++++++++++++ 6 files changed, 217 insertions(+), 23 deletions(-) create mode 100644 .changeset/skip-metric-group-publish.md diff --git a/.changeset/skip-metric-group-publish.md b/.changeset/skip-metric-group-publish.md new file mode 100644 index 000000000..5eeae8e09 --- /dev/null +++ b/.changeset/skip-metric-group-publish.md @@ -0,0 +1,5 @@ +--- +"@codegraphy-dev/extension": patch +--- + +Skip redundant legend group publication when a saved file only changes graph node metrics. diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index d4b55ca03..afac3f059 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -819,6 +819,25 @@ Interpretation: harness probe also still observed the same late visible stats timing, so the experiment was backed out. The next startup work should target product work inside cached load or webview render cost, not timeline-before-graph ordering. +- The VS Code graph-view harness now rereads the extension-host performance log + after interaction sampling, so completed reports include backend stages for + Graph Scope toggles, marker saves, and restore saves instead of startup-only + host events. The first complete-host-events run measured a fresh cached load + at `4822ms` first graph readiness, Imports toggle at `196ms` wall-clock / + `61ms` in-webview, and live update at `697ms` wall-clock with a `537ms` + incremental request. +- Incremental publish now skips merged group recomputation and `LEGENDS_UPDATED` + when an indexed changed-file refresh only changes node metric fields such as + `fileSize` or `churn`. It still replaces raw graph data, applies the view + transform, sends `GRAPH_DATA_UPDATED`, status, plugin, decoration, exporter, + toolbar, injection, post-analyze, and workspace-ready updates. A focused + publish test covers the metric-only contract. In the next rebuilt VS Code + probe, the marker save and restore save both emitted + `graphAnalysis.publish.groupsSkipped` with `groupInputsUnchanged`; the + post-refresh publish segment dropped from roughly `153ms`-`159ms` to + `90ms`-`93ms`. End-to-end request duration stayed in the same band + (`543ms`/`537ms`) because that run's TypeScript one-file analysis phase was + noisier (`94ms` versus `34ms`-`35ms` in the previous sample). Full test baseline: diff --git a/packages/extension/src/extension/graphView/analysis/execution/publish.ts b/packages/extension/src/extension/graphView/analysis/execution/publish.ts index 493b1ad1f..3e0a74f18 100644 --- a/packages/extension/src/extension/graphView/analysis/execution/publish.ts +++ b/packages/extension/src/extension/graphView/analysis/execution/publish.ts @@ -1,4 +1,4 @@ -import type { IGraphData } from '../../../../shared/graph/contracts'; +import type { IGraphData, IGraphNode } from '../../../../shared/graph/contracts'; import type { GraphViewAnalysisExecutionHandlers, GraphViewAnalysisExecutionState, @@ -58,9 +58,53 @@ function areGraphDataPayloadsEqual(left: IGraphData, right: IGraphData): boolean } } +function areGraphGroupSymbolInputsEqual( + left: IGraphNode['symbol'], + right: IGraphNode['symbol'], +): boolean { + if (left === right) { + return true; + } + + if (!left || !right) { + return false; + } + + return left.kind === right.kind + && left.pluginKind === right.pluginKind + && left.source === right.source + && left.language === right.language + && left.filePath === right.filePath; +} + +function areGraphGroupNodeInputsEqual(left: IGraphNode, right: IGraphNode): boolean { + return left.id === right.id + && left.nodeType === right.nodeType + && areGraphGroupSymbolInputsEqual(left.symbol, right.symbol); +} + +function doGraphViewGroupsNeedRecompute( + currentRawGraphData: IGraphData, + nextRawGraphData: IGraphData, +): boolean { + if (currentRawGraphData.nodes.length !== nextRawGraphData.nodes.length) { + return true; + } + + const nextNodesById = new Map(nextRawGraphData.nodes.map(node => [node.id, node])); + for (const currentNode of currentRawGraphData.nodes) { + const nextNode = nextNodesById.get(currentNode.id); + if (!nextNode || !areGraphGroupNodeInputsEqual(currentNode, nextNode)) { + return true; + } + } + + return false; +} + function canReuseCurrentGraphPublication( state: GraphViewAnalysisExecutionState, - handlers: GraphViewAnalysisExecutionHandlers, + currentRawGraphData: IGraphData | undefined, rawGraphData: IGraphData, actualHasIndex: boolean, freshness: CodeGraphyIndexFreshness, @@ -69,7 +113,6 @@ function canReuseCurrentGraphPublication( return false; } - const currentRawGraphData = handlers.getRawGraphData?.(); return currentRawGraphData ? areGraphDataPayloadsEqual(currentRawGraphData, rawGraphData) : false; @@ -105,9 +148,10 @@ export function publishAnalyzedGraph( } let stageStartedAt = Date.now(); + const currentRawGraphData = handlers.getRawGraphData?.(); const reuseCurrentGraphPublication = canReuseCurrentGraphPublication( state, - handlers, + currentRawGraphData, rawGraphData, actualHasIndex, status.freshness, @@ -138,9 +182,18 @@ export function publishAnalyzedGraph( recordPublishStage('viewTransform', stageStartedAt); stageStartedAt = Date.now(); - handlers.computeMergedGroups(); - handlers.sendGroupsUpdated(); - recordPublishStage('groups', stageStartedAt); + const canSkipGroupPublication = state.mode === 'incremental' + && currentRawGraphData + && !doGraphViewGroupsNeedRecompute(currentRawGraphData, rawGraphData); + if (canSkipGroupPublication) { + recordPublishStage('groupsSkipped', stageStartedAt, { + reason: 'groupInputsUnchanged', + }); + } else { + handlers.computeMergedGroups(); + handlers.sendGroupsUpdated(); + recordPublishStage('groups', stageStartedAt); + } } stageStartedAt = Date.now(); diff --git a/packages/extension/tests/extension/graphView/analysis/execution/publish.test.ts b/packages/extension/tests/extension/graphView/analysis/execution/publish.test.ts index 143f92efa..463988508 100644 --- a/packages/extension/tests/extension/graphView/analysis/execution/publish.test.ts +++ b/packages/extension/tests/extension/graphView/analysis/execution/publish.test.ts @@ -325,6 +325,58 @@ describe('graph view analysis execution publish', () => { ); }); + it('skips group publication when an incremental refresh only changes node sizing metrics', () => { + const currentGraphData: IGraphData = { + nodes: [{ + id: 'src/index.ts', + label: 'index.ts', + color: '#ffffff', + fileSize: 100, + churn: 1, + }], + edges: [], + }; + const nextGraphData: IGraphData = { + nodes: [{ + id: 'src/index.ts', + label: 'index.ts', + color: '#ffffff', + fileSize: 120, + churn: 2, + }], + edges: [], + }; + const state = createExecutionState({ + mode: 'incremental', + analyzer: createExecutionAnalyzer(), + }); + const { handlers } = createExecutionHandlers({ + applyViewTransform: vi.fn(() => { + handlers.setGraphData(nextGraphData); + }), + }); + handlers.setRawGraphData(currentGraphData); + handlers.setGraphData(currentGraphData); + vi.mocked(handlers.setRawGraphData).mockClear(); + vi.mocked(handlers.setGraphData).mockClear(); + + publishAnalyzedGraph(state, handlers, nextGraphData, true); + + expect(handlers.setRawGraphData).toHaveBeenCalledWith(nextGraphData); + expect(handlers.updateViewContext).toHaveBeenCalledOnce(); + expect(handlers.applyViewTransform).toHaveBeenCalledOnce(); + expect(handlers.computeMergedGroups).not.toHaveBeenCalled(); + expect(handlers.sendGroupsUpdated).not.toHaveBeenCalled(); + expect(handlers.sendGraphDataUpdated).toHaveBeenCalledWith(nextGraphData); + expect(performanceMocks.recordExtensionPerformanceEvent).toHaveBeenCalledWith( + 'graphAnalysis.publish.groupsSkipped', + expect.objectContaining({ + durationMs: expect.any(Number), + reason: 'groupInputsUnchanged', + }), + ); + }); + it('publishes the transformed graph without post-analyze hooks when no analyzer is available', () => { const rawGraphData: IGraphData = { nodes: [{ id: 'src/index.ts', label: 'src/index.ts', color: '#ffffff' }], diff --git a/scripts/performance/measure-vscode-graph-view.mjs b/scripts/performance/measure-vscode-graph-view.mjs index a14085e36..5e9b29b55 100644 --- a/scripts/performance/measure-vscode-graph-view.mjs +++ b/scripts/performance/measure-vscode-graph-view.mjs @@ -287,6 +287,31 @@ export function createStartupMeasurements({ }; } +export function createCompleteMeasurements({ + extensionHostEvents, + importsToggleSamples, + liveUpdateSamples, + startupMeasurements, +}) { + return { + ...startupMeasurements, + status: 'complete', + extensionHostEvents, + importsToggle: { + ...summarizeSwitchTransitionSamples(importsToggleSamples), + samples: importsToggleSamples, + }, + ...(liveUpdateSamples.length > 0 + ? { + liveUpdate: { + ...summarizeLiveUpdateSamples(liveUpdateSamples), + samples: liveUpdateSamples, + }, + } + : {}), + }; +} + async function readGraphStats(frame) { const text = await frame .getByText(/[\d,]+\s+nodes?.*?[\d,]+\s+connections?/i) @@ -636,22 +661,13 @@ async function measureVSCodeGraphView({ })); } - const measurements = { - ...startupMeasurements, - status: 'complete', - importsToggle: { - ...summarizeSwitchTransitionSamples(samples), - samples, - }, - ...(liveUpdateSamples.length > 0 - ? { - liveUpdate: { - ...summarizeLiveUpdateSamples(liveUpdateSamples), - samples: liveUpdateSamples, - }, - } - : {}), - }; + const completeExtensionHostEvents = await readExtensionHostPerformanceEvents(extensionHostLogPath); + const measurements = createCompleteMeasurements({ + extensionHostEvents: completeExtensionHostEvents, + importsToggleSamples: samples, + liveUpdateSamples, + startupMeasurements, + }); await writeMetrics({ outputPath, workspacePath: workspaceRoot, measurements }); return measurements; diff --git a/tests/scripts/measure-vscode-graph-view.test.mjs b/tests/scripts/measure-vscode-graph-view.test.mjs index 611df362c..f9bfa5c6e 100644 --- a/tests/scripts/measure-vscode-graph-view.test.mjs +++ b/tests/scripts/measure-vscode-graph-view.test.mjs @@ -318,6 +318,55 @@ test('VS Code graph view runner builds a startup-ready measurement payload befor }); }); +test('VS Code graph view runner carries post-interaction extension-host events into completed metrics', async () => { + const moduleUrl = pathToFileURL( + path.resolve('scripts/performance/measure-vscode-graph-view.mjs'), + ).href; + const { createCompleteMeasurements } = await import(moduleUrl); + + const startupMeasurements = { + status: 'startup-ready', + extensionHostEvents: [{ name: 'graphAnalysis.request.completed', offsetMs: 10 }], + }; + const extensionHostEvents = [ + { name: 'graphAnalysis.request.completed', offsetMs: 10 }, + { name: 'graphAnalysis.publish.broadcasts', offsetMs: 210 }, + ]; + + assert.deepEqual(createCompleteMeasurements({ + extensionHostEvents, + importsToggleSamples: [{ durationMs: 25 }], + liveUpdateSamples: [{ durationMs: 40, requestDurationMs: 30 }], + startupMeasurements, + }), { + status: 'complete', + extensionHostEvents, + importsToggle: { + iterations: 1, + minMs: 25, + medianMs: 25, + p95Ms: 25, + maxMs: 25, + samples: [{ durationMs: 25 }], + }, + liveUpdate: { + iterations: 1, + minMs: 40, + medianMs: 40, + p95Ms: 40, + maxMs: 40, + requestDuration: { + iterations: 1, + minMs: 30, + medianMs: 30, + p95Ms: 30, + maxMs: 30, + }, + samples: [{ durationMs: 40, requestDurationMs: 30 }], + }, + }); +}); + test('VS Code graph view runner waits for the live-update restore request before finishing', async (t) => { const moduleUrl = pathToFileURL( path.resolve('scripts/performance/measure-vscode-graph-view.mjs'), From 13f95dc4ddf6a33fa7954698b786e621607c85b9 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 01:06:17 -0700 Subject: [PATCH 052/192] perf: shortcut metric-only reuse checks --- .changeset/skip-metric-group-publish.md | 2 +- docs/performance/codegraphy-monorepo.md | 8 +++ .../graphView/analysis/execution/publish.ts | 48 ++++++++++++++- .../analysis/execution/publish.test.ts | 58 +++++++++++++++++++ 4 files changed, 114 insertions(+), 2 deletions(-) diff --git a/.changeset/skip-metric-group-publish.md b/.changeset/skip-metric-group-publish.md index 5eeae8e09..79c0a205d 100644 --- a/.changeset/skip-metric-group-publish.md +++ b/.changeset/skip-metric-group-publish.md @@ -2,4 +2,4 @@ "@codegraphy-dev/extension": patch --- -Skip redundant legend group publication when a saved file only changes graph node metrics. +Skip redundant legend group publication and deep graph reuse checks when a saved file only changes graph node metrics. diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index afac3f059..42b015c67 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -838,6 +838,14 @@ Interpretation: `90ms`-`93ms`. End-to-end request duration stayed in the same band (`543ms`/`537ms`) because that run's TypeScript one-file analysis phase was noisier (`94ms` versus `34ms`-`35ms` in the previous sample). +- The metric-only publish path now recognizes absolute changed-file paths + before running the deep unchanged-graph comparison. This lets save/restore + updates whose node `fileSize` or `churn` already differs skip serializing the + full `6485` node / `20781` edge graph during `reuseCheck`. The rebuilt VS + Code probe moved `graphAnalysis.publish.reuseCheck` from `24ms`-`27ms` to + `5ms` on marker save and `1ms` on restore. The marker request measured + `501ms`, while the cleaner restore sample measured `446ms`; both still sent + full `GRAPH_DATA_UPDATED` payloads because node metrics changed. Full test baseline: diff --git a/packages/extension/src/extension/graphView/analysis/execution/publish.ts b/packages/extension/src/extension/graphView/analysis/execution/publish.ts index 3e0a74f18..4739d9862 100644 --- a/packages/extension/src/extension/graphView/analysis/execution/publish.ts +++ b/packages/extension/src/extension/graphView/analysis/execution/publish.ts @@ -102,6 +102,51 @@ function doGraphViewGroupsNeedRecompute( return false; } +function normalizeGraphPath(filePath: string): string { + return filePath.replace(/\\/g, '/'); +} + +function isGraphNodeForChangedPath(nodeId: string, changedFilePath: string): boolean { + const normalizedNodeId = normalizeGraphPath(nodeId); + const normalizedChangedFilePath = normalizeGraphPath(changedFilePath); + return normalizedChangedFilePath === normalizedNodeId + || normalizedChangedFilePath.endsWith(`/${normalizedNodeId}`); +} + +function findGraphNodeByChangedPath( + graphData: IGraphData, + changedFilePath: string, +): IGraphNode | undefined { + return graphData.nodes.find(node => isGraphNodeForChangedPath(node.id, changedFilePath)); +} + +function hasChangedNodeMetricDifference( + currentRawGraphData: IGraphData, + nextRawGraphData: IGraphData, + changedFilePaths: readonly string[] | undefined, +): boolean { + if (!changedFilePaths?.length) { + return false; + } + + for (const changedFilePath of changedFilePaths) { + const currentNode = findGraphNodeByChangedPath(currentRawGraphData, changedFilePath); + const nextNode = findGraphNodeByChangedPath(nextRawGraphData, changedFilePath); + if (!currentNode || !nextNode) { + continue; + } + + if ( + currentNode.fileSize !== nextNode.fileSize + || currentNode.churn !== nextNode.churn + ) { + return true; + } + } + + return false; +} + function canReuseCurrentGraphPublication( state: GraphViewAnalysisExecutionState, currentRawGraphData: IGraphData | undefined, @@ -114,7 +159,8 @@ function canReuseCurrentGraphPublication( } return currentRawGraphData - ? areGraphDataPayloadsEqual(currentRawGraphData, rawGraphData) + ? !hasChangedNodeMetricDifference(currentRawGraphData, rawGraphData, state.changedFilePaths) + && areGraphDataPayloadsEqual(currentRawGraphData, rawGraphData) : false; } diff --git a/packages/extension/tests/extension/graphView/analysis/execution/publish.test.ts b/packages/extension/tests/extension/graphView/analysis/execution/publish.test.ts index 463988508..cd2eeb3ef 100644 --- a/packages/extension/tests/extension/graphView/analysis/execution/publish.test.ts +++ b/packages/extension/tests/extension/graphView/analysis/execution/publish.test.ts @@ -377,6 +377,64 @@ describe('graph view analysis execution publish', () => { ); }); + it('skips deep graph reuse comparison when a changed node metric already differs', () => { + let serializedEdgeCount = 0; + const edge = { + id: 'src/index.ts->src/view.ts#import', + from: 'src/index.ts', + to: 'src/view.ts', + kind: 'import', + sources: [], + toJSON: () => { + serializedEdgeCount += 1; + return { + id: 'src/index.ts->src/view.ts#import', + from: 'src/index.ts', + to: 'src/view.ts', + kind: 'import', + sources: [], + }; + }, + } as IGraphData['edges'][number] & { toJSON(): unknown }; + const currentGraphData: IGraphData = { + nodes: [{ + id: 'src/index.ts', + label: 'index.ts', + color: '#ffffff', + fileSize: 100, + }], + edges: [edge], + }; + const nextGraphData: IGraphData = { + nodes: [{ + id: 'src/index.ts', + label: 'index.ts', + color: '#ffffff', + fileSize: 120, + }], + edges: [edge], + }; + const state = createExecutionState({ + mode: 'incremental', + changedFilePaths: ['/workspace/src/index.ts'], + analyzer: createExecutionAnalyzer(), + }); + const { handlers } = createExecutionHandlers({ + applyViewTransform: vi.fn(() => { + handlers.setGraphData(nextGraphData); + }), + }); + handlers.setRawGraphData(currentGraphData); + handlers.setGraphData(currentGraphData); + vi.mocked(handlers.setRawGraphData).mockClear(); + vi.mocked(handlers.setGraphData).mockClear(); + + publishAnalyzedGraph(state, handlers, nextGraphData, true); + + expect(serializedEdgeCount).toBe(0); + expect(handlers.sendGraphDataUpdated).toHaveBeenCalledWith(nextGraphData); + }); + it('publishes the transformed graph without post-analyze hooks when no analyzer is available', () => { const rawGraphData: IGraphData = { nodes: [{ id: 'src/index.ts', label: 'src/index.ts', color: '#ffffff' }], From 7ed11838526d9a95335b300bce9661ffa7c4b2e6 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 01:21:15 -0700 Subject: [PATCH 053/192] perf: patch metric-only graph updates --- .changeset/skip-metric-group-publish.md | 2 +- docs/performance/codegraphy-monorepo.md | 15 +++ .../extension/graphView/analysis/execution.ts | 2 + .../graphView/analysis/execution/publish.ts | 119 +++++++++++++++- .../graphView/provider/analysis/handlers.ts | 6 + .../src/shared/protocol/extensionToWebview.ts | 9 +- .../webview/store/messageHandlers/graph.ts | 52 +++++++ .../extension/src/webview/store/messages.ts | 6 + .../analysis/execution/publish.test.ts | 127 ++++++++++++++++-- .../store/messageHandlers/graph.test.ts | 38 ++++++ .../performance/measure-vscode-graph-view.mjs | 79 ++++++++++- .../measure-vscode-graph-view.test.mjs | 116 ++++++++++++++++ 12 files changed, 552 insertions(+), 19 deletions(-) diff --git a/.changeset/skip-metric-group-publish.md b/.changeset/skip-metric-group-publish.md index 79c0a205d..9b39b0f86 100644 --- a/.changeset/skip-metric-group-publish.md +++ b/.changeset/skip-metric-group-publish.md @@ -2,4 +2,4 @@ "@codegraphy-dev/extension": patch --- -Skip redundant legend group publication and deep graph reuse checks when a saved file only changes graph node metrics. +Skip redundant legend group publication, deep graph reuse checks, and full graph payloads when a saved file only changes graph node metrics. diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index 42b015c67..ecb9b615b 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -846,6 +846,21 @@ Interpretation: `5ms` on marker save and `1ms` on restore. The marker request measured `501ms`, while the cleaner restore sample measured `446ms`; both still sent full `GRAPH_DATA_UPDATED` payloads because node metrics changed. +- Metric-only changed-file refreshes now publish a compact + `GRAPH_NODE_METRICS_UPDATED` patch instead of resending the full graph when + the affected nodes only changed `fileSize` or `churn` and the affected edge + signature stayed stable. The publish path still falls back to + `GRAPH_DATA_UPDATED` if a changed file adds/removes graph nodes or changes + affected edges. The VS Code live-update harness now waits for the exact graph + update message to be received in the webview, so this measurement includes + delivery rather than extension-host completion alone. The strict rebuilt + probe sent one-node metric patches for both marker and restore saves, + recorded `graphAnalysis.publish.sendGraphNodeMetrics` at `0ms`, and measured + the marker request at `411ms`. The end-to-end marker wall clock was `1193ms`; + the webview trace shows the remaining cost is now derived-graph work after + the tiny patch (`239.5ms` in `visibleGraph.derive` and `86ms` in + `visibleGraph.applyLegendRules`) rather than host-side full graph + publication. Full test baseline: diff --git a/packages/extension/src/extension/graphView/analysis/execution.ts b/packages/extension/src/extension/graphView/analysis/execution.ts index 73a21b78c..4f8a3be2f 100644 --- a/packages/extension/src/extension/graphView/analysis/execution.ts +++ b/packages/extension/src/extension/graphView/analysis/execution.ts @@ -1,4 +1,5 @@ import type { IGraphData } from '../../../shared/graph/contracts'; +import type { IGraphNodeMetricsUpdate } from '../../../shared/protocol/extensionToWebview'; import type { DiagnosticEventInput } from '@codegraphy-dev/core'; import { publishAnalysisFailure } from './execution/publish'; import { prepareGraphViewAnalysis } from './execution/prepare'; @@ -76,6 +77,7 @@ export interface GraphViewAnalysisExecutionHandlers { getRawGraphData?(): IGraphData; getGraphData(): IGraphData; sendGraphDataUpdated(graphData: IGraphData): void; + sendGraphNodeMetricsUpdated?(updates: IGraphNodeMetricsUpdate[]): void; sendDepthState(): void; computeMergedGroups(): void; sendGroupsUpdated(): void; diff --git a/packages/extension/src/extension/graphView/analysis/execution/publish.ts b/packages/extension/src/extension/graphView/analysis/execution/publish.ts index 4739d9862..bc4e1127e 100644 --- a/packages/extension/src/extension/graphView/analysis/execution/publish.ts +++ b/packages/extension/src/extension/graphView/analysis/execution/publish.ts @@ -3,6 +3,7 @@ import type { GraphViewAnalysisExecutionHandlers, GraphViewAnalysisExecutionState, } from '../execution'; +import type { IGraphNodeMetricsUpdate } from '../../../../shared/protocol/extensionToWebview'; import type { CodeGraphyIndexFreshness } from '../../../repoSettings/freshness'; import { recordExtensionPerformanceEvent } from '../../../performance/marks'; @@ -113,6 +114,12 @@ function isGraphNodeForChangedPath(nodeId: string, changedFilePath: string): boo || normalizedChangedFilePath.endsWith(`/${normalizedNodeId}`); } +function isGraphNodeAffectedByChangedPath(node: IGraphNode, changedFilePath: string): boolean { + const symbolFilePath = node.symbol?.filePath; + return isGraphNodeForChangedPath(node.id, changedFilePath) + || (symbolFilePath ? isGraphNodeForChangedPath(symbolFilePath, changedFilePath) : false); +} + function findGraphNodeByChangedPath( graphData: IGraphData, changedFilePath: string, @@ -147,6 +154,96 @@ function hasChangedNodeMetricDifference( return false; } +function collectChangedPathNodes( + graphData: IGraphData, + changedFilePaths: readonly string[], +): IGraphNode[] { + return graphData.nodes.filter(node => + changedFilePaths.some(changedFilePath => + isGraphNodeAffectedByChangedPath(node, changedFilePath), + ), + ); +} + +function createNodeMap(nodes: readonly IGraphNode[]): Map { + return new Map(nodes.map(node => [node.id, node])); +} + +function normalizeNodeForMetricOnlyComparison(node: IGraphNode): Omit { + const comparableNode: Partial = { ...node }; + delete comparableNode.churn; + delete comparableNode.fileSize; + return comparableNode as Omit; +} + +function areNodesEqualIgnoringMetrics(left: IGraphNode, right: IGraphNode): boolean { + return JSON.stringify(normalizeNodeForMetricOnlyComparison(left)) + === JSON.stringify(normalizeNodeForMetricOnlyComparison(right)); +} + +function collectAffectedEdgeSignature( + graphData: IGraphData, + affectedNodeIds: ReadonlySet, +): string { + return JSON.stringify( + graphData.edges + .filter(edge => affectedNodeIds.has(edge.from) || affectedNodeIds.has(edge.to)) + .sort((left, right) => left.id.localeCompare(right.id)), + ); +} + +function createMetricOnlyGraphUpdate( + currentRawGraphData: IGraphData | undefined, + nextRawGraphData: IGraphData, + changedFilePaths: readonly string[] | undefined, +): IGraphNodeMetricsUpdate[] | undefined { + if ( + !currentRawGraphData + || !changedFilePaths?.length + || currentRawGraphData.nodes.length !== nextRawGraphData.nodes.length + || currentRawGraphData.edges.length !== nextRawGraphData.edges.length + ) { + return undefined; + } + + const currentNodes = collectChangedPathNodes(currentRawGraphData, changedFilePaths); + const nextNodes = collectChangedPathNodes(nextRawGraphData, changedFilePaths); + if (currentNodes.length === 0 || currentNodes.length !== nextNodes.length) { + return undefined; + } + + const nextNodesById = createNodeMap(nextNodes); + const affectedNodeIds = new Set(); + const updates: IGraphNodeMetricsUpdate[] = []; + + for (const currentNode of currentNodes) { + const nextNode = nextNodesById.get(currentNode.id); + if (!nextNode || !areNodesEqualIgnoringMetrics(currentNode, nextNode)) { + return undefined; + } + + affectedNodeIds.add(currentNode.id); + if ( + currentNode.fileSize !== nextNode.fileSize + || currentNode.churn !== nextNode.churn + ) { + updates.push({ + id: nextNode.id, + fileSize: nextNode.fileSize, + churn: nextNode.churn, + }); + } + } + + if (updates.length === 0) { + return undefined; + } + + const currentEdgeSignature = collectAffectedEdgeSignature(currentRawGraphData, affectedNodeIds); + const nextEdgeSignature = collectAffectedEdgeSignature(nextRawGraphData, affectedNodeIds); + return currentEdgeSignature === nextEdgeSignature ? updates : undefined; +} + function canReuseCurrentGraphPublication( state: GraphViewAnalysisExecutionState, currentRawGraphData: IGraphData | undefined, @@ -195,6 +292,11 @@ export function publishAnalyzedGraph( let stageStartedAt = Date.now(); const currentRawGraphData = handlers.getRawGraphData?.(); + const metricOnlyUpdate = createMetricOnlyGraphUpdate( + currentRawGraphData, + rawGraphData, + state.changedFilePaths, + ); const reuseCurrentGraphPublication = canReuseCurrentGraphPublication( state, currentRawGraphData, @@ -271,11 +373,18 @@ export function publishAnalyzedGraph( }); if (!reuseCurrentGraphPublication) { stageStartedAt = Date.now(); - handlers.sendGraphDataUpdated(graphData); - recordPublishStage('sendGraphData', stageStartedAt, { - edgeCount: graphData.edges.length, - nodeCount: graphData.nodes.length, - }); + if (metricOnlyUpdate && handlers.sendGraphNodeMetricsUpdated) { + handlers.sendGraphNodeMetricsUpdated(metricOnlyUpdate); + recordPublishStage('sendGraphNodeMetrics', stageStartedAt, { + nodeCount: metricOnlyUpdate.length, + }); + } else { + handlers.sendGraphDataUpdated(graphData); + recordPublishStage('sendGraphData', stageStartedAt, { + edgeCount: graphData.edges.length, + nodeCount: graphData.nodes.length, + }); + } } handlers.sendGraphIndexStatusUpdated(actualHasIndex, status.freshness, status.detail); state.analyzer?.registry.notifyPostAnalyze(graphData, state.disabledPlugins); diff --git a/packages/extension/src/extension/graphView/provider/analysis/handlers.ts b/packages/extension/src/extension/graphView/provider/analysis/handlers.ts index f9647b5a2..4c07d9c3d 100644 --- a/packages/extension/src/extension/graphView/provider/analysis/handlers.ts +++ b/packages/extension/src/extension/graphView/provider/analysis/handlers.ts @@ -46,6 +46,12 @@ export function createGraphViewProviderAnalysisHandlers( ); source._sendMessage({ type: 'GRAPH_DATA_UPDATED', payload: graphData }); }, + sendGraphNodeMetricsUpdated: updates => { + source._sendMessage({ + type: 'GRAPH_NODE_METRICS_UPDATED', + payload: { nodes: updates }, + }); + }, sendDepthState: () => source._sendDepthState(), computeMergedGroups: () => source._computeMergedGroups(), sendGroupsUpdated: () => source._sendGroupsUpdated(), diff --git a/packages/extension/src/shared/protocol/extensionToWebview.ts b/packages/extension/src/shared/protocol/extensionToWebview.ts index b81db214e..99e33313e 100644 --- a/packages/extension/src/shared/protocol/extensionToWebview.ts +++ b/packages/extension/src/shared/protocol/extensionToWebview.ts @@ -1,5 +1,5 @@ import type { IFileInfo } from '../files/info'; -import type { IGraphData } from '../graph/contracts'; +import type { IGraphData, IGraphNode } from '../graph/contracts'; import type { IPluginContextMenuItem } from '../plugins/contextMenu'; import type { EdgeDecorationPayload, NodeDecorationPayload } from '../plugins/decorations'; import type { IPluginExporterItem } from '../plugins/exporters'; @@ -38,8 +38,15 @@ export interface IGraphViewContributionStatus { label: string; } +export interface IGraphNodeMetricsUpdate { + id: IGraphNode['id']; + fileSize: IGraphNode['fileSize']; + churn: IGraphNode['churn']; +} + export type ExtensionToWebviewMessage = | { type: 'GRAPH_DATA_UPDATED'; payload: IGraphData } + | { type: 'GRAPH_NODE_METRICS_UPDATED'; payload: { nodes: IGraphNodeMetricsUpdate[] } } | { type: 'APP_BOOTSTRAP_COMPLETE' } | { type: 'GRAPH_INDEX_STATUS_UPDATED'; diff --git a/packages/extension/src/webview/store/messageHandlers/graph.ts b/packages/extension/src/webview/store/messageHandlers/graph.ts index 1f3502cad..73c7fb1a1 100644 --- a/packages/extension/src/webview/store/messageHandlers/graph.ts +++ b/packages/extension/src/webview/store/messageHandlers/graph.ts @@ -77,6 +77,58 @@ export function handleGraphDataUpdated( }; } +export function handleGraphNodeMetricsUpdated( + message: Extract, + ctx?: Pick, +): PartialState | void { + recordWebviewPerformanceEvent('extensionMessage.graphNodeMetricsUpdated', { + nodeCount: message.payload.nodes.length, + }); + + const state = ctx?.getState(); + if (!state?.graphData) { + return undefined; + } + + const updatesById = new Map(message.payload.nodes.map(node => [node.id, node])); + let changed = false; + const nodes = state.graphData.nodes.map((node) => { + const update = updatesById.get(node.id); + if (!update || (node.fileSize === update.fileSize && node.churn === update.churn)) { + return node; + } + + changed = true; + return { + ...node, + fileSize: update.fileSize, + churn: update.churn, + }; + }); + + if (!changed) { + return { + graphIsIndexing: false, + graphIndexProgress: null, + }; + } + + const waitingForInitialBootstrap = Boolean( + state.awaitingInitialBootstrap + && !state.bootstrapComplete, + ); + + return { + graphData: { + ...state.graphData, + nodes, + }, + isLoading: waitingForInitialBootstrap, + graphIsIndexing: false, + graphIndexProgress: null, + }; +} + export function handleAppBootstrapComplete( _message: Extract, ctx: Pick, diff --git a/packages/extension/src/webview/store/messages.ts b/packages/extension/src/webview/store/messages.ts index 7b6a56217..18f3117f1 100644 --- a/packages/extension/src/webview/store/messages.ts +++ b/packages/extension/src/webview/store/messages.ts @@ -5,6 +5,7 @@ import { handleGraphIndexStatusUpdated, handleGraphControlsUpdated, handleFavoritesUpdated, + handleGraphNodeMetricsUpdated, handleSettingsUpdated, handleLegendsUpdated, handleFilterPatternsUpdated, @@ -50,6 +51,11 @@ export const MESSAGE_HANDLERS: Record< > = { GRAPH_DATA_UPDATED: (msg, ctx) => handleGraphDataUpdated(msg as Extract, ctx), + GRAPH_NODE_METRICS_UPDATED: (msg, ctx) => + handleGraphNodeMetricsUpdated( + msg as Extract, + ctx, + ), APP_BOOTSTRAP_COMPLETE: (msg, ctx) => handleAppBootstrapComplete( msg as Extract, diff --git a/packages/extension/tests/extension/graphView/analysis/execution/publish.test.ts b/packages/extension/tests/extension/graphView/analysis/execution/publish.test.ts index cd2eeb3ef..ceedf52e9 100644 --- a/packages/extension/tests/extension/graphView/analysis/execution/publish.test.ts +++ b/packages/extension/tests/extension/graphView/analysis/execution/publish.test.ts @@ -377,20 +377,121 @@ describe('graph view analysis execution publish', () => { ); }); - it('skips deep graph reuse comparison when a changed node metric already differs', () => { - let serializedEdgeCount = 0; - const edge = { + it('sends node metric patches instead of full graph data for metric-only incremental refreshes', () => { + const currentGraphData: IGraphData = { + nodes: [{ + id: 'src/index.ts', + label: 'index.ts', + color: '#ffffff', + fileSize: 100, + churn: 1, + }], + edges: [], + }; + const nextGraphData: IGraphData = { + nodes: [{ + id: 'src/index.ts', + label: 'index.ts', + color: '#ffffff', + fileSize: 120, + churn: 2, + }], + edges: [], + }; + const state = createExecutionState({ + mode: 'incremental', + changedFilePaths: ['/workspace/src/index.ts'], + analyzer: createExecutionAnalyzer(), + }); + const sendGraphNodeMetricsUpdated = vi.fn(); + const { handlers } = createExecutionHandlers({ + applyViewTransform: vi.fn(() => { + handlers.setGraphData(nextGraphData); + }), + sendGraphNodeMetricsUpdated, + }); + handlers.setRawGraphData(currentGraphData); + handlers.setGraphData(currentGraphData); + vi.mocked(handlers.setRawGraphData).mockClear(); + vi.mocked(handlers.setGraphData).mockClear(); + + publishAnalyzedGraph(state, handlers, nextGraphData, true); + + expect(sendGraphNodeMetricsUpdated).toHaveBeenCalledWith([ + { id: 'src/index.ts', fileSize: 120, churn: 2 }, + ]); + expect(handlers.sendGraphDataUpdated).not.toHaveBeenCalled(); + }); + + it('falls back to full graph publication when changed node metrics also change edges', () => { + const currentGraphData: IGraphData = { + nodes: [{ + id: 'src/index.ts', + label: 'index.ts', + color: '#ffffff', + fileSize: 100, + }], + edges: [], + }; + const nextGraphData: IGraphData = { + nodes: [{ + id: 'src/index.ts', + label: 'index.ts', + color: '#ffffff', + fileSize: 120, + }], + edges: [{ + id: 'src/index.ts->src/view.ts#import', + from: 'src/index.ts', + to: 'src/view.ts', + kind: 'import', + sources: [], + }], + }; + const state = createExecutionState({ + mode: 'incremental', + changedFilePaths: ['/workspace/src/index.ts'], + analyzer: createExecutionAnalyzer(), + }); + const sendGraphNodeMetricsUpdated = vi.fn(); + const { handlers } = createExecutionHandlers({ + applyViewTransform: vi.fn(() => { + handlers.setGraphData(nextGraphData); + }), + sendGraphNodeMetricsUpdated, + }); + handlers.setRawGraphData(currentGraphData); + handlers.setGraphData(currentGraphData); + vi.mocked(handlers.setRawGraphData).mockClear(); + vi.mocked(handlers.setGraphData).mockClear(); + + publishAnalyzedGraph(state, handlers, nextGraphData, true); + + expect(sendGraphNodeMetricsUpdated).not.toHaveBeenCalled(); + expect(handlers.sendGraphDataUpdated).toHaveBeenCalledWith(nextGraphData); + }); + + it('skips unrelated edge serialization when a changed node metric already differs', () => { + let serializedUnrelatedEdgeCount = 0; + const affectedEdge = { id: 'src/index.ts->src/view.ts#import', from: 'src/index.ts', to: 'src/view.ts', kind: 'import', sources: [], + } satisfies IGraphData['edges'][number]; + const unrelatedEdge = { + id: 'src/other.ts->src/leaf.ts#import', + from: 'src/other.ts', + to: 'src/leaf.ts', + kind: 'import', + sources: [], toJSON: () => { - serializedEdgeCount += 1; + serializedUnrelatedEdgeCount += 1; return { - id: 'src/index.ts->src/view.ts#import', - from: 'src/index.ts', - to: 'src/view.ts', + id: 'src/other.ts->src/leaf.ts#import', + from: 'src/other.ts', + to: 'src/leaf.ts', kind: 'import', sources: [], }; @@ -403,7 +504,7 @@ describe('graph view analysis execution publish', () => { color: '#ffffff', fileSize: 100, }], - edges: [edge], + edges: [affectedEdge, unrelatedEdge], }; const nextGraphData: IGraphData = { nodes: [{ @@ -412,17 +513,19 @@ describe('graph view analysis execution publish', () => { color: '#ffffff', fileSize: 120, }], - edges: [edge], + edges: [affectedEdge, unrelatedEdge], }; const state = createExecutionState({ mode: 'incremental', changedFilePaths: ['/workspace/src/index.ts'], analyzer: createExecutionAnalyzer(), }); + const sendGraphNodeMetricsUpdated = vi.fn(); const { handlers } = createExecutionHandlers({ applyViewTransform: vi.fn(() => { handlers.setGraphData(nextGraphData); }), + sendGraphNodeMetricsUpdated, }); handlers.setRawGraphData(currentGraphData); handlers.setGraphData(currentGraphData); @@ -431,8 +534,10 @@ describe('graph view analysis execution publish', () => { publishAnalyzedGraph(state, handlers, nextGraphData, true); - expect(serializedEdgeCount).toBe(0); - expect(handlers.sendGraphDataUpdated).toHaveBeenCalledWith(nextGraphData); + expect(serializedUnrelatedEdgeCount).toBe(0); + expect(sendGraphNodeMetricsUpdated).toHaveBeenCalledWith([ + { id: 'src/index.ts', fileSize: 120, churn: undefined }, + ]); }); it('publishes the transformed graph without post-analyze hooks when no analyzer is available', () => { diff --git a/packages/extension/tests/webview/store/messageHandlers/graph.test.ts b/packages/extension/tests/webview/store/messageHandlers/graph.test.ts index 50c9147f7..e08263eae 100644 --- a/packages/extension/tests/webview/store/messageHandlers/graph.test.ts +++ b/packages/extension/tests/webview/store/messageHandlers/graph.test.ts @@ -12,6 +12,7 @@ import { handleGraphDataUpdated, handleGraphIndexProgress, handleGraphIndexStatusUpdated, + handleGraphNodeMetricsUpdated, handleLegendsUpdated, handleMaxFilesUpdated, handlePhysicsSettingsUpdated, @@ -128,6 +129,43 @@ describe('webview/store/messageHandlers/graph', () => { ]); }); + it('applies node metric patches to the current graph data', () => { + const graphData = { + nodes: [ + { id: 'src/app.ts', label: 'App', color: '#fff', fileSize: 100, churn: 1 }, + { id: 'src/lib.ts', label: 'Lib', color: '#fff', fileSize: 50, churn: 3 }, + ], + edges: [{ id: 'src/app.ts->src/lib.ts', from: 'src/app.ts', to: 'src/lib.ts', kind: 'import' as const, sources: [] }], + }; + const state = createState({ + graphData, + graphIsIndexing: true, + graphIndexProgress: { phase: 'Updating Graph View', current: 0, total: 1 }, + isLoading: false, + }); + + expect(handleGraphNodeMetricsUpdated( + { + type: 'GRAPH_NODE_METRICS_UPDATED', + payload: { + nodes: [{ id: 'src/app.ts', fileSize: 120, churn: 2 }], + }, + }, + { getState: () => state }, + )).toEqual({ + graphData: { + nodes: [ + { id: 'src/app.ts', label: 'App', color: '#fff', fileSize: 120, churn: 2 }, + graphData.nodes[1], + ], + edges: graphData.edges, + }, + isLoading: false, + graphIsIndexing: false, + graphIndexProgress: null, + }); + }); + it('skips duplicate graph payloads after bootstrap has settled', () => { window.__codegraphyPerformance = { enabled: true, events: [] }; const payload = { diff --git a/scripts/performance/measure-vscode-graph-view.mjs b/scripts/performance/measure-vscode-graph-view.mjs index 5e9b29b55..e1b4b65b8 100644 --- a/scripts/performance/measure-vscode-graph-view.mjs +++ b/scripts/performance/measure-vscode-graph-view.mjs @@ -16,6 +16,10 @@ const IMPORTS_TOGGLE_START_EVENT = 'graphScope.edgeVisibility.optimistic'; const IMPORTS_TOGGLE_RENDERED_EVENT = 'graphStats.rendered'; const ANALYZE_REQUEST_MODE = 'analyze'; const LIVE_UPDATE_REQUEST_MODE = 'incremental'; +const GRAPH_UPDATE_MESSAGE_TYPES = new Set([ + 'GRAPH_DATA_UPDATED', + 'GRAPH_NODE_METRICS_UPDATED', +]); const DEFAULT_PLUGIN_PACKAGE_RELATIVE_PATHS = [ 'packages/plugin-godot', 'packages/plugin-markdown', @@ -424,6 +428,37 @@ async function waitForWebviewPerformanceEvent(frame, name, timeoutMs = DEFAULT_T throw new Error(`Timed out waiting for webview performance event: ${name}`); } +async function countWebviewMessagesReceived(frame, type) { + const count = await frame.evaluate((messageType) => + window.__codegraphyPerformance?.events?.filter(event => + event.name === 'extensionMessage.received' + && event.detail?.type === messageType).length ?? 0, type); + return Number.isInteger(count) ? count : 0; +} + +async function countWebviewGraphUpdateMessagesReceived(frame) { + const counts = new Map(); + for (const messageType of GRAPH_UPDATE_MESSAGE_TYPES) { + counts.set(messageType, await countWebviewMessagesReceived(frame, messageType)); + } + + return counts; +} + +async function waitForWebviewMessageReceived(frame, type, minimumCount = 0, timeoutMs = DEFAULT_TIMEOUT_MS) { + const startedAt = performance.now(); + + while (performance.now() - startedAt < timeoutMs) { + if (await countWebviewMessagesReceived(frame, type) > minimumCount) { + return; + } + + await frame.waitForTimeout(25); + } + + throw new Error(`Timed out waiting for webview message: ${type}`); +} + async function waitForExtensionHostPerformanceEvent(logPath, predicate, timeoutMs = DEFAULT_TIMEOUT_MS) { const startedAt = performance.now(); @@ -469,6 +504,40 @@ async function waitForExtensionHostRequestIdle( throw new Error(`Timed out waiting for ${mode} extension host requests to become idle`); } +function findGraphUpdateMessageSentForRequest(events, requestEvent) { + const requestId = requestEvent.detail?.requestId; + const mode = requestEvent.detail?.mode; + const startedAt = events.find(event => + event.name === 'graphAnalysis.request.start' + && event.detail?.requestId === requestId + && event.detail?.mode === mode)?.at; + + if (typeof startedAt !== 'number') { + return undefined; + } + + return events.find(event => + event.name === 'graphWebview.message.send' + && event.at >= startedAt + && event.at <= requestEvent.at + && GRAPH_UPDATE_MESSAGE_TYPES.has(event.detail?.type))?.detail?.type; +} + +async function waitForWebviewGraphUpdateMessageIfSent( + logPath, + frame, + requestEvent, + previousMessageCounts = new Map(), +) { + const events = await readExtensionHostPerformanceEvents(logPath); + const messageType = findGraphUpdateMessageSentForRequest(events, requestEvent); + if (!messageType) { + return; + } + + await waitForWebviewMessageReceived(frame, messageType, previousMessageCounts.get(messageType) ?? 0); +} + async function measureSwitchTransition(frame, label, enabled) { const beforeStats = await waitForGraphStats(frame, stats => stats.nodeCount > 0); await resetWebviewPerformanceEvents(frame); @@ -518,6 +587,7 @@ export async function measureLiveUpdateTransition({ }), ); updateRequestCompletedAt = requestEvent.at; + await waitForWebviewGraphUpdateMessageIfSent(extensionHostLogPath, frame, requestEvent); return { durationMs: Math.round(performance.now() - startedAt), @@ -529,8 +599,9 @@ export async function measureLiveUpdateTransition({ } finally { if (markerWritten) { const restoreStartedAtEpoch = Date.now(); + const previousMessageCounts = await countWebviewGraphUpdateMessagesReceived(frame); await writeFile(absoluteFilePath, originalContent); - await waitForExtensionHostPerformanceEvent( + const requestEvent = await waitForExtensionHostPerformanceEvent( extensionHostLogPath, events => findCompletedExtensionHostRequestAfter(events, { mode: LIVE_UPDATE_REQUEST_MODE, @@ -539,6 +610,12 @@ export async function measureLiveUpdateTransition({ : restoreStartedAtEpoch, }), ); + await waitForWebviewGraphUpdateMessageIfSent( + extensionHostLogPath, + frame, + requestEvent, + previousMessageCounts, + ); await waitForExtensionHostRequestIdle(extensionHostLogPath, LIVE_UPDATE_REQUEST_MODE); } } diff --git a/tests/scripts/measure-vscode-graph-view.test.mjs b/tests/scripts/measure-vscode-graph-view.test.mjs index f9bfa5c6e..0d4eafd4d 100644 --- a/tests/scripts/measure-vscode-graph-view.test.mjs +++ b/tests/scripts/measure-vscode-graph-view.test.mjs @@ -450,6 +450,122 @@ test('VS Code graph view runner waits for the live-update restore request before } }); +test('VS Code graph view runner waits for the live-update graph message in the webview', async (t) => { + const moduleUrl = pathToFileURL( + path.resolve('scripts/performance/measure-vscode-graph-view.mjs'), + ).href; + const { measureLiveUpdateTransition } = await import(moduleUrl); + const workspaceRoot = await mkdtemp(path.join(os.tmpdir(), 'codegraphy-live-update-webview-')); + t.after(() => rm(workspaceRoot, { recursive: true, force: true })); + + const liveUpdateFilePath = 'src/example.ts'; + const absoluteFilePath = path.join(workspaceRoot, liveUpdateFilePath); + const extensionHostLogPath = path.join(workspaceRoot, 'extension-host.jsonl'); + const originalContent = 'export const value = 1;\n'; + await mkdir(path.dirname(absoluteFilePath), { recursive: true }); + await writeFile(absoluteFilePath, originalContent); + await writeFile(extensionHostLogPath, ''); + + let stopped = false; + let markerRequestRecorded = false; + let restoreRequestCompleted = false; + let markerWebviewMessageReceived = false; + const webviewEvents = []; + const frame = { + evaluate: async (callback, argument) => { + const source = String(callback); + if (source.includes('window.__codegraphyPerformance =')) { + webviewEvents.length = 0; + return undefined; + } + if (source.includes('.filter(event')) { + return webviewEvents.filter(event => + event.name === 'extensionMessage.received' + && event.detail?.type === argument).length; + } + if (source.includes('__codegraphyPerformance?.events')) { + return webviewEvents; + } + return undefined; + }, + waitForTimeout: async ms => new Promise(resolve => setTimeout(resolve, ms)), + }; + + async function appendIncrementalRequest(requestId) { + const startedAt = Date.now(); + await writeFile(extensionHostLogPath, [ + JSON.stringify({ + name: 'graphAnalysis.request.start', + at: startedAt, + detail: { requestId, mode: 'incremental' }, + }), + JSON.stringify({ + name: 'graphWebview.message.send', + at: startedAt + 1, + detail: { type: 'GRAPH_NODE_METRICS_UPDATED' }, + }), + JSON.stringify({ + name: 'graphAnalysis.request.completed', + at: startedAt + 2, + detail: { requestId, mode: 'incremental', durationMs: 2 }, + }), + '', + ].join('\n'), { flag: 'a' }); + } + + function enqueueGraphMessageReceived() { + setTimeout(() => { + markerWebviewMessageReceived = true; + webviewEvents.push({ + name: 'extensionMessage.received', + at: performance.now(), + detail: { type: 'GRAPH_NODE_METRICS_UPDATED' }, + }); + }, 40); + } + + const observer = (async () => { + while (!stopped) { + const content = await readFile(absoluteFilePath, 'utf8'); + if (!markerRequestRecorded && content.includes('CodeGraphy live update perf marker')) { + markerRequestRecorded = true; + await appendIncrementalRequest(1); + enqueueGraphMessageReceived(); + } else if ( + markerRequestRecorded + && !restoreRequestCompleted + && content === originalContent + ) { + await appendIncrementalRequest(2); + enqueueGraphMessageReceived(); + restoreRequestCompleted = true; + } + + await new Promise(resolve => setTimeout(resolve, 5)); + } + })(); + + try { + const sample = await measureLiveUpdateTransition({ + extensionHostLogPath, + frame, + liveUpdateFilePath, + workspaceRoot, + }); + + assert.equal(sample.filePath, liveUpdateFilePath); + assert.equal(markerWebviewMessageReceived, true); + assert.equal( + sample.webviewEvents.some(event => event.detail?.type === 'GRAPH_NODE_METRICS_UPDATED'), + true, + ); + assert.equal(restoreRequestCompleted, true); + } finally { + stopped = true; + await observer; + } +}); + test('VS Code graph view runner waits for active analyze requests before live-update markers', async (t) => { const moduleUrl = pathToFileURL( path.resolve('scripts/performance/measure-vscode-graph-view.mjs'), From cb4122bcc41c98491b6cbc774e07cd30bf0a3930 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 01:29:19 -0700 Subject: [PATCH 054/192] perf: avoid nonvisual metric recompute --- .changeset/skip-metric-group-publish.md | 2 +- docs/performance/codegraphy-monorepo.md | 12 ++++ .../webview/store/messageHandlers/graph.ts | 65 +++++++++++++++++-- .../store/messageHandlers/graph.test.ts | 35 ++++++++++ 4 files changed, 106 insertions(+), 8 deletions(-) diff --git a/.changeset/skip-metric-group-publish.md b/.changeset/skip-metric-group-publish.md index 9b39b0f86..afa8066c8 100644 --- a/.changeset/skip-metric-group-publish.md +++ b/.changeset/skip-metric-group-publish.md @@ -2,4 +2,4 @@ "@codegraphy-dev/extension": patch --- -Skip redundant legend group publication, deep graph reuse checks, and full graph payloads when a saved file only changes graph node metrics. +Skip redundant legend group publication, deep graph reuse checks, full graph payloads, and visible graph recomputation when a saved file only changes non-visual graph node metrics. diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index ecb9b615b..817c2e864 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -861,6 +861,18 @@ Interpretation: the tiny patch (`239.5ms` in `visibleGraph.derive` and `86ms` in `visibleGraph.applyLegendRules`) rather than host-side full graph publication. +- The webview metric-patch handler now applies `fileSize`/`churn` patches in + place when the active node size mode is `connections` or `uniform`, keeping + the `graphData` reference stable so metric-only saves do not invalidate + visible graph derivation, legend coloring, or runtime graph construction. + Metric-based node size modes (`file-size` and `churn`) still replace + `graphData` so the visual sizes update correctly. The rebuilt monorepo probe + received the one-node `GRAPH_NODE_METRICS_UPDATED` message, recorded + `extensionMessage.graphNodeMetricsPatchedInPlace`, and emitted zero + `visibleGraph.derive`, zero `visibleGraph.applyLegendRules`, and zero + `graphRuntime.buildGraphData` events in the live-update window. The first + strict sample moved marker wall clock from `1193ms` to `828ms`; a follow-up + sample measured `657ms` wall clock with a `356ms` incremental request. Full test baseline: diff --git a/packages/extension/src/webview/store/messageHandlers/graph.ts b/packages/extension/src/webview/store/messageHandlers/graph.ts index 73c7fb1a1..b894f2a72 100644 --- a/packages/extension/src/webview/store/messageHandlers/graph.ts +++ b/packages/extension/src/webview/store/messageHandlers/graph.ts @@ -1,6 +1,7 @@ import type { IHandlerContext, PartialState } from '../messageTypes'; import type { ExtensionToWebviewMessage } from '../../../shared/protocol/extensionToWebview'; import type { IGraphData } from '../../../shared/graph/contracts'; +import type { NodeSizeMode } from '../../../shared/settings/modes'; import { applyPendingGroupUpdates, applyPendingUserGroupsUpdate, @@ -8,6 +9,9 @@ import { import { arePlainValuesEqual } from './equality/compare'; import { recordWebviewPerformanceEvent } from '../../performance/marks'; +type GraphNodeMetricsUpdateMessage = Extract; +type GraphNodeMetricsUpdate = GraphNodeMetricsUpdateMessage['payload']['nodes'][number]; + function areGraphDataPayloadsEqual(left: IGraphData, right: IGraphData): boolean { if (left.nodes.length !== right.nodes.length || left.edges.length !== right.edges.length) { return false; @@ -41,6 +45,37 @@ function shouldSkipDuplicateGraphData( ); } +function nodeSizeModeUsesNodeMetrics(mode: NodeSizeMode): boolean { + return mode === 'file-size' || mode === 'churn'; +} + +function nodeMetricsDiffer( + node: IGraphData['nodes'][number], + update: GraphNodeMetricsUpdate, +): boolean { + return node.fileSize !== update.fileSize || node.churn !== update.churn; +} + +function applyMetricUpdatesInPlace( + graphData: IGraphData, + updatesById: ReadonlyMap, +): boolean { + let changed = false; + + for (const node of graphData.nodes) { + const update = updatesById.get(node.id); + if (!update || !nodeMetricsDiffer(node, update)) { + continue; + } + + node.fileSize = update.fileSize; + node.churn = update.churn; + changed = true; + } + + return changed; +} + export function handleGraphDataUpdated( message: Extract, ctx?: Pick, @@ -78,7 +113,7 @@ export function handleGraphDataUpdated( } export function handleGraphNodeMetricsUpdated( - message: Extract, + message: GraphNodeMetricsUpdateMessage, ctx?: Pick, ): PartialState | void { recordWebviewPerformanceEvent('extensionMessage.graphNodeMetricsUpdated', { @@ -91,10 +126,31 @@ export function handleGraphNodeMetricsUpdated( } const updatesById = new Map(message.payload.nodes.map(node => [node.id, node])); + const waitingForInitialBootstrap = Boolean( + state.awaitingInitialBootstrap + && !state.bootstrapComplete, + ); + + if (!nodeSizeModeUsesNodeMetrics(state.nodeSizeMode)) { + // Metrics do not affect the current visual graph, so keep graphData referentially stable. + const changed = applyMetricUpdatesInPlace(state.graphData, updatesById); + if (changed) { + recordWebviewPerformanceEvent('extensionMessage.graphNodeMetricsPatchedInPlace', { + nodeCount: message.payload.nodes.length, + }); + } + + return { + isLoading: waitingForInitialBootstrap, + graphIsIndexing: false, + graphIndexProgress: null, + }; + } + let changed = false; const nodes = state.graphData.nodes.map((node) => { const update = updatesById.get(node.id); - if (!update || (node.fileSize === update.fileSize && node.churn === update.churn)) { + if (!update || !nodeMetricsDiffer(node, update)) { return node; } @@ -113,11 +169,6 @@ export function handleGraphNodeMetricsUpdated( }; } - const waitingForInitialBootstrap = Boolean( - state.awaitingInitialBootstrap - && !state.bootstrapComplete, - ); - return { graphData: { ...state.graphData, diff --git a/packages/extension/tests/webview/store/messageHandlers/graph.test.ts b/packages/extension/tests/webview/store/messageHandlers/graph.test.ts index e08263eae..7852f6cca 100644 --- a/packages/extension/tests/webview/store/messageHandlers/graph.test.ts +++ b/packages/extension/tests/webview/store/messageHandlers/graph.test.ts @@ -142,6 +142,7 @@ describe('webview/store/messageHandlers/graph', () => { graphIsIndexing: true, graphIndexProgress: { phase: 'Updating Graph View', current: 0, total: 1 }, isLoading: false, + nodeSizeMode: 'file-size', }); expect(handleGraphNodeMetricsUpdated( @@ -166,6 +167,40 @@ describe('webview/store/messageHandlers/graph', () => { }); }); + it('keeps the graph data reference stable when metric patches do not affect node sizing', () => { + const graphData = { + nodes: [ + { id: 'src/app.ts', label: 'App', color: '#fff', fileSize: 100, churn: 1 }, + { id: 'src/lib.ts', label: 'Lib', color: '#fff', fileSize: 50, churn: 3 }, + ], + edges: [{ id: 'src/app.ts->src/lib.ts', from: 'src/app.ts', to: 'src/lib.ts', kind: 'import' as const, sources: [] }], + }; + const state = createState({ + graphData, + graphIsIndexing: true, + graphIndexProgress: { phase: 'Updating Graph View', current: 0, total: 1 }, + isLoading: false, + nodeSizeMode: 'connections', + }); + + expect(handleGraphNodeMetricsUpdated( + { + type: 'GRAPH_NODE_METRICS_UPDATED', + payload: { + nodes: [{ id: 'src/app.ts', fileSize: 120, churn: 2 }], + }, + }, + { getState: () => state }, + )).toEqual({ + isLoading: false, + graphIsIndexing: false, + graphIndexProgress: null, + }); + + expect(state.graphData).toBe(graphData); + expect(graphData.nodes[0]).toMatchObject({ fileSize: 120, churn: 2 }); + }); + it('skips duplicate graph payloads after bootstrap has settled', () => { window.__codegraphyPerformance = { enabled: true, events: [] }; const payload = { From 16c5c133d9da224283effe662cc53879b64013bf Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 01:33:36 -0700 Subject: [PATCH 055/192] perf: skip incremental group preparation --- .changeset/skip-metric-group-publish.md | 2 +- docs/performance/codegraphy-monorepo.md | 9 +++++++++ .../graphView/analysis/execution/prepare.ts | 6 +++++- .../analysis/execution/prepare.test.ts | 17 +++++++++++++++++ 4 files changed, 32 insertions(+), 2 deletions(-) diff --git a/.changeset/skip-metric-group-publish.md b/.changeset/skip-metric-group-publish.md index afa8066c8..170175d19 100644 --- a/.changeset/skip-metric-group-publish.md +++ b/.changeset/skip-metric-group-publish.md @@ -2,4 +2,4 @@ "@codegraphy-dev/extension": patch --- -Skip redundant legend group publication, deep graph reuse checks, full graph payloads, and visible graph recomputation when a saved file only changes non-visual graph node metrics. +Skip redundant legend group publication, deep graph reuse checks, full graph payloads, visible graph recomputation, and pre-refresh legend broadcasts when a saved file only changes non-visual graph node metrics. diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index 817c2e864..922dc78de 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -873,6 +873,15 @@ Interpretation: `graphRuntime.buildGraphData` events in the live-update window. The first strict sample moved marker wall clock from `1193ms` to `828ms`; a follow-up sample measured `657ms` wall clock with a `356ms` incremental request. +- Incremental analysis preparation now skips the pre-refresh group recompute + and `LEGENDS_UPDATED` broadcast. Group recomputation remains in the publish + stage, where metric-only refreshes can skip it with the existing + `groupInputsUnchanged` check. In the rebuilt monorepo probe, the live-update + window no longer received `LEGENDS_UPDATED`; the request-start-to-refresh + gap moved from roughly `254ms` to `178ms` on the comparable marker sample. + Marker request duration moved from `456ms` to `383ms`, while restore measured + `328ms`. The wall-clock sample stayed noisy (`836ms`), so this iteration is + recorded as a backend request improvement rather than a measured UI-wall win. Full test baseline: diff --git a/packages/extension/src/extension/graphView/analysis/execution/prepare.ts b/packages/extension/src/extension/graphView/analysis/execution/prepare.ts index e63afcf9d..50a520ff5 100644 --- a/packages/extension/src/extension/graphView/analysis/execution/prepare.ts +++ b/packages/extension/src/extension/graphView/analysis/execution/prepare.ts @@ -22,6 +22,10 @@ function prepareAnalysisGroups( return true; } +function shouldPrepareAnalysisGroups(state: GraphViewAnalysisExecutionState): boolean { + return state.mode !== 'incremental'; +} + export async function prepareGraphViewAnalysis( signal: AbortSignal, requestId: number, @@ -45,7 +49,7 @@ export async function prepareGraphViewAnalysis( return false; } - if (!prepareAnalysisGroups(signal, requestId, handlers)) { + if (shouldPrepareAnalysisGroups(state) && !prepareAnalysisGroups(signal, requestId, handlers)) { return false; } diff --git a/packages/extension/tests/extension/graphView/analysis/execution/prepare.test.ts b/packages/extension/tests/extension/graphView/analysis/execution/prepare.test.ts index 0f47d0edd..f69bfd511 100644 --- a/packages/extension/tests/extension/graphView/analysis/execution/prepare.test.ts +++ b/packages/extension/tests/extension/graphView/analysis/execution/prepare.test.ts @@ -129,4 +129,21 @@ describe('graph view analysis execution prepare', () => { expect(handlers.computeMergedGroups).toHaveBeenCalledOnce(); expect(handlers.sendGroupsUpdated).toHaveBeenCalledOnce(); }); + + it('skips pre-refresh group publication for incremental analysis', async () => { + const state = createExecutionState({ + analyzer: createExecutionAnalyzer(), + analyzerInitialized: true, + mode: 'incremental', + }); + const { handlers } = createExecutionHandlers(); + + await expect( + prepareGraphViewAnalysis(new AbortController().signal, 1, state, handlers), + ).resolves.toBe(true); + + expect(handlers.computeMergedGroups).not.toHaveBeenCalled(); + expect(handlers.sendGroupsUpdated).not.toHaveBeenCalled(); + expect(handlers.hasWorkspace).toHaveBeenCalledOnce(); + }); }); From f7f6ccfe69ab0573e7a23e79d4a772e8bf786702 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 01:41:15 -0700 Subject: [PATCH 056/192] perf: skip incremental freshness scan --- .changeset/skip-metric-group-publish.md | 2 +- docs/performance/codegraphy-monorepo.md | 13 +++ .../graphView/analysis/execution/load.ts | 103 +++++++++++++++--- .../graphView/analysis/execution/prepare.ts | 30 +++++ .../graphView/analysis/execution/load.test.ts | 33 ++++++ 5 files changed, 163 insertions(+), 18 deletions(-) diff --git a/.changeset/skip-metric-group-publish.md b/.changeset/skip-metric-group-publish.md index 170175d19..01b1c2fcf 100644 --- a/.changeset/skip-metric-group-publish.md +++ b/.changeset/skip-metric-group-publish.md @@ -2,4 +2,4 @@ "@codegraphy-dev/extension": patch --- -Skip redundant legend group publication, deep graph reuse checks, full graph payloads, visible graph recomputation, and pre-refresh legend broadcasts when a saved file only changes non-visual graph node metrics. +Skip redundant legend group publication, deep graph reuse checks, full graph payloads, visible graph recomputation, pre-refresh legend broadcasts, and incremental freshness scans when a saved file only changes non-visual graph node metrics. diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index 922dc78de..52c115e62 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -882,6 +882,19 @@ Interpretation: Marker request duration moved from `456ms` to `383ms`, while restore measured `328ms`. The wall-clock sample stayed noisy (`836ms`), so this iteration is recorded as a backend request improvement rather than a measured UI-wall win. +- The VS Code live-update harness now captures `graphAnalysis.prepare.*` and + `graphAnalysis.load.*` phase marks. Those marks showed incremental prepare + was already `0ms`, while `graphAnalysis.load.decision` spent `177ms`-`178ms` + reading index freshness even though incremental mode always routes to + changed-file refresh. Incremental load now bypasses that freshness scan and + records `indexFreshness: "skipped"` for the route decision; publish still + reads and broadcasts the real post-refresh index status. In the rebuilt + monorepo probe, incremental `load.decision` measured `0ms`, marker request + duration moved from `383ms` to `205ms`, restore moved from `328ms` to + `135ms`, and strict live-update wall clock moved from `836ms` to `594ms`. + The remaining backend cost is now the changed-file refresh itself (`179ms` + marker, `113ms` restore), led by one-file plugin analysis and analysis graph + rebuild. Full test baseline: diff --git a/packages/extension/src/extension/graphView/analysis/execution/load.ts b/packages/extension/src/extension/graphView/analysis/execution/load.ts index 81a8e7888..ec05f0763 100644 --- a/packages/extension/src/extension/graphView/analysis/execution/load.ts +++ b/packages/extension/src/extension/graphView/analysis/execution/load.ts @@ -19,11 +19,52 @@ import { } from './load/analyzerData'; import { getGraphIndexFreshness } from './load/freshness'; import { selectGraphViewRawDataLoadDecision } from './load/routing'; +import type { GraphViewRawDataLoadDecision } from './load/routing'; +import { recordExtensionPerformanceEvent } from '../../../performance/marks'; +import type { CodeGraphyIndexFreshness } from '../../../repoSettings/freshness'; function hasReplayableGraphData(graphData: IGraphData): boolean { return graphData.nodes.length > 0 || graphData.edges.length > 0; } +function recordLoadStage( + state: GraphViewAnalysisExecutionState, + stage: string, + startedAt: number, + detail: Record = {}, +): void { + recordExtensionPerformanceEvent(`graphAnalysis.load.${stage}`, { + ...detail, + durationMs: Date.now() - startedAt, + mode: state.mode, + }); +} + +function selectGraphViewRawDataLoadDecisionForState( + state: GraphViewAnalysisExecutionState, + analyzer: NonNullable, +): { + decision: GraphViewRawDataLoadDecision; + indexFreshness: CodeGraphyIndexFreshness | undefined; +} { + if (state.mode === 'incremental') { + return { + decision: { route: 'incremental', shouldDiscover: false }, + indexFreshness: undefined, + }; + } + + const indexFreshness = getGraphIndexFreshness(analyzer); + return { + decision: selectGraphViewRawDataLoadDecision( + state.mode, + indexFreshness, + typeof analyzer.loadCachedGraph === 'function', + ), + indexFreshness, + }; +} + export async function loadGraphViewRawData( signal: AbortSignal, state: GraphViewAnalysisExecutionState, @@ -34,40 +75,56 @@ export async function loadGraphViewRawData( return { rawGraphData: EMPTY_GRAPH_DATA, shouldDiscover: false }; } - const indexFreshness = getGraphIndexFreshness(analyzer); - const decision = selectGraphViewRawDataLoadDecision( - state.mode, - indexFreshness, - typeof analyzer.loadCachedGraph === 'function', - ); + let stageStartedAt = Date.now(); + const { decision, indexFreshness } = selectGraphViewRawDataLoadDecisionForState(state, analyzer); + const diagnosticIndexFreshness = indexFreshness ?? 'skipped'; + recordLoadStage(state, 'decision', stageStartedAt, { + canReplayCache: typeof analyzer.loadCachedGraph === 'function', + indexFreshness: diagnosticIndexFreshness, + route: decision.route, + shouldDiscover: decision.shouldDiscover, + }); handlers.emitDiagnostic?.({ area: 'extension.analysis', event: 'load-decision', context: { - mode: state.mode, - route: decision.route, - shouldDiscover: decision.shouldDiscover, - indexFreshness, - canReplayCache: typeof analyzer.loadCachedGraph === 'function', - }, + mode: state.mode, + route: decision.route, + shouldDiscover: decision.shouldDiscover, + indexFreshness: diagnosticIndexFreshness, + canReplayCache: typeof analyzer.loadCachedGraph === 'function', + }, }); const forwardProgress = createGraphViewAnalysisProgressForwarder(state.mode, handlers); if (!decision.shouldDiscover) { + stageStartedAt = Date.now(); sendInitialGraphViewAnalysisProgress(state.mode, handlers); + recordLoadStage(state, 'initialProgress', stageStartedAt, { + route: decision.route, + }); } if (decision.route === 'discover') { + stageStartedAt = Date.now(); + const rawGraphData = await discoverGraphViewRawData(signal, state, analyzer); + recordLoadStage(state, 'discover', stageStartedAt); return { - rawGraphData: await discoverGraphViewRawData(signal, state, analyzer), + rawGraphData, shouldDiscover: decision.shouldDiscover, }; } if (decision.route === 'cached') { + stageStartedAt = Date.now(); const cachedGraphData = await loadCachedGraphViewRawData(signal, state, analyzer, { includeCurrentGitignoreMetadata: indexFreshness !== 'stale', }); + recordLoadStage(state, 'cached', stageStartedAt, { + edgeCount: cachedGraphData.edges.length, + hasReplayableGraphData: hasReplayableGraphData(cachedGraphData), + nodeCount: cachedGraphData.nodes.length, + }); if (hasReplayableGraphData(cachedGraphData)) { return { rawGraphData: cachedGraphData, @@ -75,28 +132,40 @@ export async function loadGraphViewRawData( }; } + stageStartedAt = Date.now(); + const rawGraphData = await refreshGraphViewRawData(signal, state, forwardProgress); + recordLoadStage(state, 'cachedFallbackRefresh', stageStartedAt); return { - rawGraphData: await refreshGraphViewRawData(signal, state, forwardProgress), + rawGraphData, shouldDiscover: decision.shouldDiscover, }; } if (decision.route === 'refresh') { + stageStartedAt = Date.now(); + const rawGraphData = await refreshGraphViewRawData(signal, state, forwardProgress); + recordLoadStage(state, 'refresh', stageStartedAt); return { - rawGraphData: await refreshGraphViewRawData(signal, state, forwardProgress), + rawGraphData, shouldDiscover: decision.shouldDiscover, }; } if (decision.route === 'incremental') { + stageStartedAt = Date.now(); + const rawGraphData = await refreshIncrementalGraphViewRawData(signal, state, forwardProgress); + recordLoadStage(state, 'incremental', stageStartedAt); return { - rawGraphData: await refreshIncrementalGraphViewRawData(signal, state, forwardProgress), + rawGraphData, shouldDiscover: decision.shouldDiscover, }; } + stageStartedAt = Date.now(); + const rawGraphData = await analyzeGraphViewRawData(signal, state, analyzer, forwardProgress); + recordLoadStage(state, 'analyze', stageStartedAt); return { - rawGraphData: await analyzeGraphViewRawData(signal, state, analyzer, forwardProgress), + rawGraphData, shouldDiscover: decision.shouldDiscover, }; } diff --git a/packages/extension/src/extension/graphView/analysis/execution/prepare.ts b/packages/extension/src/extension/graphView/analysis/execution/prepare.ts index 50a520ff5..1e27402bc 100644 --- a/packages/extension/src/extension/graphView/analysis/execution/prepare.ts +++ b/packages/extension/src/extension/graphView/analysis/execution/prepare.ts @@ -7,6 +7,20 @@ import { ensureGraphViewAnalyzerInitialized, } from './initialize'; import { publishEmptyGraph } from './publish'; +import { recordExtensionPerformanceEvent } from '../../../performance/marks'; + +function recordPrepareStage( + state: GraphViewAnalysisExecutionState, + stage: string, + startedAt: number, + detail: Record = {}, +): void { + recordExtensionPerformanceEvent(`graphAnalysis.prepare.${stage}`, { + ...detail, + durationMs: Date.now() - startedAt, + mode: state.mode, + }); +} function prepareAnalysisGroups( signal: AbortSignal, @@ -41,22 +55,38 @@ export async function prepareGraphViewAnalysis( return false; } + let stageStartedAt = Date.now(); if (!(await awaitGraphViewPluginActivation(signal, requestId, state, handlers))) { + recordPrepareStage(state, 'pluginActivation', stageStartedAt, { stale: true }); return false; } + recordPrepareStage(state, 'pluginActivation', stageStartedAt); + stageStartedAt = Date.now(); if (!(await ensureGraphViewAnalyzerInitialized(signal, requestId, state, handlers))) { + recordPrepareStage(state, 'analyzerInitialized', stageStartedAt, { stale: true }); return false; } + recordPrepareStage(state, 'analyzerInitialized', stageStartedAt, { + alreadyInitialized: state.analyzerInitialized, + }); + stageStartedAt = Date.now(); if (shouldPrepareAnalysisGroups(state) && !prepareAnalysisGroups(signal, requestId, handlers)) { + recordPrepareStage(state, 'groups', stageStartedAt, { stale: true }); return false; } + recordPrepareStage(state, 'groups', stageStartedAt, { + skipped: !shouldPrepareAnalysisGroups(state), + }); + stageStartedAt = Date.now(); if (!handlers.hasWorkspace()) { + recordPrepareStage(state, 'workspace', stageStartedAt, { hasWorkspace: false }); publishEmptyGraph(handlers); return false; } + recordPrepareStage(state, 'workspace', stageStartedAt, { hasWorkspace: true }); return true; } diff --git a/packages/extension/tests/extension/graphView/analysis/execution/load.test.ts b/packages/extension/tests/extension/graphView/analysis/execution/load.test.ts index b73475d83..f57ec2e6f 100644 --- a/packages/extension/tests/extension/graphView/analysis/execution/load.test.ts +++ b/packages/extension/tests/extension/graphView/analysis/execution/load.test.ts @@ -391,6 +391,39 @@ describe('graph view analysis execution load', () => { }); }); + it('routes incremental refreshes without reading index freshness', async () => { + const incrementalGraph = { + nodes: [{ id: 'src/changed.ts', label: 'src/changed.ts', color: '#ffffff' }], + edges: [], + }; + const getIndexStatus = vi.fn(() => { + throw new Error('incremental refresh should not read index freshness'); + }); + const refreshChangedFiles = vi.fn(async () => incrementalGraph); + const state = createExecutionState({ + mode: 'incremental', + changedFilePaths: ['src/changed.ts'], + analyzer: createExecutionAnalyzer({ + getIndexStatus, + refreshChangedFiles, + }), + analyzerInitialized: true, + }); + + const result = await loadGraphViewRawData( + new AbortController().signal, + state, + createExecutionHandlers().handlers, + ); + + expect(result).toEqual({ + rawGraphData: incrementalGraph, + shouldDiscover: false, + }); + expect(getIndexStatus).not.toHaveBeenCalled(); + expect(refreshChangedFiles).toHaveBeenCalledOnce(); + }); + it('falls back to full analysis for incremental mode when changed-file refresh is unavailable', async () => { const analyzedGraph = { nodes: [{ id: 'src/fallback.ts', label: 'src/fallback.ts', color: '#ffffff' }], From dbb1ac33f415555dd6c29aab3a498bf1c6ea00bb Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 01:49:48 -0700 Subject: [PATCH 057/192] test: align extension node mocks --- ...hetoignoresreplayreadinessforplugincallsforunknown.test.ts | 4 +++- .../registry/v2.notificationhookstoeventemission.test.ts | 2 +- ...uginactivationtokeepsqueuedworkspacechangespending.test.ts | 4 +++- .../tests/extension/graphView/provider/runtime/fixture.ts | 4 +++- .../tests/extension/graphViewProvider.bootstrap.test.ts | 4 +++- 5 files changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/extension/tests/core/plugins/registry/errorHandling.logsonloadfailureswiththetoignoresreplayreadinessforplugincallsforunknown.test.ts b/packages/extension/tests/core/plugins/registry/errorHandling.logsonloadfailureswiththetoignoresreplayreadinessforplugincallsforunknown.test.ts index eab32c303..93a34c219 100644 --- a/packages/extension/tests/core/plugins/registry/errorHandling.logsonloadfailureswiththetoignoresreplayreadinessforplugincallsforunknown.test.ts +++ b/packages/extension/tests/core/plugins/registry/errorHandling.logsonloadfailureswiththetoignoresreplayreadinessforplugincallsforunknown.test.ts @@ -78,7 +78,9 @@ describe('PluginRegistry error handling', () => { registry.register(plugin); registry.notifyWorkspaceReady({ nodes: [], edges: [] }); - await registry.notifyPreAnalyze([], '/workspace'); + await registry.notifyPreAnalyze([ + { absolutePath: '/workspace/a.test', relativePath: 'a.test', content: 'const x = 1;' }, + ], '/workspace'); registry.notifyPostAnalyze({ nodes: [], edges: [] }); registry.notifyGraphRebuild({ nodes: [], edges: [] }); registry.notifyWebviewReady(); diff --git a/packages/extension/tests/core/plugins/registry/v2.notificationhookstoeventemission.test.ts b/packages/extension/tests/core/plugins/registry/v2.notificationhookstoeventemission.test.ts index c9084cef9..23c80b741 100644 --- a/packages/extension/tests/core/plugins/registry/v2.notificationhookstoeventemission.test.ts +++ b/packages/extension/tests/core/plugins/registry/v2.notificationhookstoeventemission.test.ts @@ -71,7 +71,7 @@ describe('PluginRegistry v2', () => { const { registry } = createConfiguredRegistry(); const plugin = createV2Plugin('notify-all'); const graph: IGraphData = { nodes: [{ id: 'x', label: 'x', color: '#fff' }], edges: [] }; - const files = [{ absolutePath: '/workspace/a.ts', relativePath: 'a.ts', content: 'const x = 1;' }]; + const files = [{ absolutePath: '/workspace/a.test', relativePath: 'a.test', content: 'const x = 1;' }]; registry.register(plugin); registry.notifyWorkspaceReady(graph); diff --git a/packages/extension/tests/extension/graphView/provider/runtime.trackstheinstalledpluginactivationtokeepsqueuedworkspacechangespending.test.ts b/packages/extension/tests/extension/graphView/provider/runtime.trackstheinstalledpluginactivationtokeepsqueuedworkspacechangespending.test.ts index dfc3df885..819fba82b 100644 --- a/packages/extension/tests/extension/graphView/provider/runtime.trackstheinstalledpluginactivationtokeepsqueuedworkspacechangespending.test.ts +++ b/packages/extension/tests/extension/graphView/provider/runtime.trackstheinstalledpluginactivationtokeepsqueuedworkspacechangespending.test.ts @@ -57,7 +57,9 @@ async function loadSubject( }; vi.doMock('../../../../src/extension/pipeline/service/lifecycleFacade', () => ({ - WorkspacePipeline: class WorkspacePipeline {}, + WorkspacePipeline: class WorkspacePipeline { + warmGraphCache = vi.fn(async () => undefined); + }, })); vi.doMock('../../../../src/core/views', () => ({ ViewRegistry: class ViewRegistry { diff --git a/packages/extension/tests/extension/graphView/provider/runtime/fixture.ts b/packages/extension/tests/extension/graphView/provider/runtime/fixture.ts index eaa09c071..49a06749f 100644 --- a/packages/extension/tests/extension/graphView/provider/runtime/fixture.ts +++ b/packages/extension/tests/extension/graphView/provider/runtime/fixture.ts @@ -55,7 +55,9 @@ export async function loadSubject( }; vi.doMock('../../../../../src/extension/pipeline/service/lifecycleFacade', () => ({ - WorkspacePipeline: class WorkspacePipeline {}, + WorkspacePipeline: class WorkspacePipeline { + warmGraphCache = vi.fn(async () => undefined); + }, })); vi.doMock('../../../../../src/core/views', () => ({ ViewRegistry: class ViewRegistry { diff --git a/packages/extension/tests/extension/graphViewProvider.bootstrap.test.ts b/packages/extension/tests/extension/graphViewProvider.bootstrap.test.ts index cc21ec106..fa1f674b2 100644 --- a/packages/extension/tests/extension/graphViewProvider.bootstrap.test.ts +++ b/packages/extension/tests/extension/graphViewProvider.bootstrap.test.ts @@ -27,7 +27,9 @@ async function loadSubject( | undefined, ) { vi.doMock('../../src/extension/pipeline/service/lifecycleFacade', () => ({ - WorkspacePipeline: class WorkspacePipeline {}, + WorkspacePipeline: class WorkspacePipeline { + warmGraphCache = vi.fn(async () => undefined); + }, })); vi.doMock('../../src/core/views', () => ({ ViewRegistry: class ViewRegistry { From 37879f8f098ed6f13c1954f524d9436f39512ae2 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 02:07:15 -0700 Subject: [PATCH 058/192] perf: patch metric-only refresh graphs --- .changeset/skip-metric-group-publish.md | 2 +- docs/performance/codegraphy-monorepo.md | 12 ++ packages/core/src/indexing/refresh.ts | 108 +++++++++++++++++- packages/core/tests/indexing/refresh.test.ts | 1 + .../pipeline/service/base/internal.ts | 8 +- .../extension/pipeline/service/base/state.ts | 9 ++ .../pipeline/service/refreshFacade.ts | 70 ++++++++++++ .../pipeline/service/refreshFacade.test.ts | 95 +++++++++++++++ .../pipeline/service/runtime/refresh.test.ts | 76 ++++++++++++ 9 files changed, 377 insertions(+), 4 deletions(-) diff --git a/.changeset/skip-metric-group-publish.md b/.changeset/skip-metric-group-publish.md index 01b1c2fcf..b6d805222 100644 --- a/.changeset/skip-metric-group-publish.md +++ b/.changeset/skip-metric-group-publish.md @@ -2,4 +2,4 @@ "@codegraphy-dev/extension": patch --- -Skip redundant legend group publication, deep graph reuse checks, full graph payloads, visible graph recomputation, pre-refresh legend broadcasts, and incremental freshness scans when a saved file only changes non-visual graph node metrics. +Skip redundant legend group publication, deep graph reuse checks, full graph payloads, visible graph recomputation, pre-refresh legend broadcasts, incremental freshness scans, and host-side graph rebuilds when a saved file only changes non-visual graph node metrics. diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index 52c115e62..c5d3f3318 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -895,6 +895,18 @@ Interpretation: The remaining backend cost is now the changed-file refresh itself (`179ms` marker, `113ms` restore), led by one-file plugin analysis and analysis graph rebuild. +- Metric-only changed-file refreshes now patch node metrics onto the cached + raw graph when the changed file's analysis and connections are graph- + equivalent. This removes the host-side `buildGraphDataFromAnalysis` rebuild + from saves that only change `fileSize` or `churn`, while preserving the full + rebuild fallback for structural graph changes. In the rebuilt monorepo probe, + the marker save recorded `analyzeFiles` at `91ms`, + `patchGraphDataNodeMetrics` at `1ms`, and + `refreshChangedFiles.completed` at `119ms`; restore recorded `20ms`, `0ms`, + and `62ms` respectively. The live-update request moved from `205ms` to + `142ms`, strict wall clock moved from `594ms` to `545ms`, and the webview + still received a one-node `GRAPH_NODE_METRICS_UPDATED` patch with zero + visible-graph derivation or runtime graph rebuild in the live-update window. Full test baseline: diff --git a/packages/core/src/indexing/refresh.ts b/packages/core/src/indexing/refresh.ts index 7e5a5dfcf..a43175fc3 100644 --- a/packages/core/src/indexing/refresh.ts +++ b/packages/core/src/indexing/refresh.ts @@ -41,7 +41,12 @@ export interface WorkspaceIndexRefreshSource { _lastDiscoveredFiles: IDiscoveredFile[]; _lastFileAnalysis: Map; _lastFileConnections: Map; + _lastGraphData: IGraphData; _lastWorkspaceRoot: string; + _patchGraphDataNodeMetrics?( + graphData: IGraphData, + filePaths: readonly string[], + ): IGraphData; _preAnalyzePlugins( files: IDiscoveredFile[], workspaceRoot: string, @@ -175,13 +180,100 @@ function buildWorkspaceIndexGraphFromRefreshState( source._lastFileConnections, workspaceRoot, )) { + source._lastGraphData = analysisGraphData; return analysisGraphData; } - return mergeWorkspaceIndexGraphData( + const graphData = mergeWorkspaceIndexGraphData( analysisGraphData, source._buildGraphData(source._lastFileConnections, workspaceRoot, disabledPlugins), ); + source._lastGraphData = graphData; + return graphData; +} + +function serializeWorkspaceIndexGraphAnalysis(analysis: IFileAnalysisResult): string { + return JSON.stringify({ + edgeTypes: analysis.edgeTypes ?? [], + filePath: analysis.filePath, + nodeTypes: analysis.nodeTypes ?? [], + nodes: analysis.nodes ?? [], + relations: analysis.relations ?? [], + symbols: analysis.symbols ?? [], + }); +} + +function serializeWorkspaceIndexConnections( + connections: IProjectedConnection[] | undefined, +): string { + return JSON.stringify(connections ?? []); +} + +interface WorkspaceIndexRefreshGraphSnapshot { + fileAnalysisByPath: Map; + fileConnectionsByPath: Map; +} + +function captureWorkspaceIndexRefreshGraphSnapshot( + source: WorkspaceIndexRefreshSource, + files: readonly IDiscoveredFile[], +): WorkspaceIndexRefreshGraphSnapshot | undefined { + if ( + !source._patchGraphDataNodeMetrics + || (source._lastGraphData.nodes.length === 0 && source._lastGraphData.edges.length === 0) + ) { + return undefined; + } + + const fileAnalysisByPath = new Map(); + const fileConnectionsByPath = new Map(); + for (const file of files) { + const analysis = source._lastFileAnalysis.get(file.relativePath); + if (!analysis) { + return undefined; + } + + fileAnalysisByPath.set(file.relativePath, serializeWorkspaceIndexGraphAnalysis(analysis)); + fileConnectionsByPath.set( + file.relativePath, + serializeWorkspaceIndexConnections(source._lastFileConnections.get(file.relativePath)), + ); + } + + return { fileAnalysisByPath, fileConnectionsByPath }; +} + +function canPatchWorkspaceIndexRefreshGraphData( + snapshot: WorkspaceIndexRefreshGraphSnapshot | undefined, + analysisResult: IWorkspaceFileAnalysisResult, + files: readonly IDiscoveredFile[], +): boolean { + if (!snapshot) { + return false; + } + + for (const file of files) { + const analysis = analysisResult.fileAnalysis.get(file.relativePath); + if (!analysis) { + return false; + } + + if ( + snapshot.fileAnalysisByPath.get(file.relativePath) + !== serializeWorkspaceIndexGraphAnalysis(analysis) + ) { + return false; + } + + if ( + snapshot.fileConnectionsByPath.get(file.relativePath) + !== serializeWorkspaceIndexConnections(analysisResult.fileConnections.get(file.relativePath)) + ) { + return false; + } + } + + return true; } function applyWorkspaceIndexAnalysisResult( @@ -395,6 +487,7 @@ export async function refreshWorkspaceIndexChangedFiles( ); } + const graphSnapshot = captureWorkspaceIndexRefreshGraphSnapshot(source, filesToAnalyze); source.invalidateWorkspaceFiles(filesToAnalyze.map((file) => file.absolutePath)); dependencies.onProgress?.({ phase: 'Applying Changes', @@ -420,6 +513,19 @@ export async function refreshWorkspaceIndexChangedFiles( applyWorkspaceIndexAnalysisResult(source, analysisResult); dependencies.persistCache(); + if ( + canPatchWorkspaceIndexRefreshGraphData(graphSnapshot, analysisResult, filesToAnalyze) + && source._patchGraphDataNodeMetrics + ) { + const graphData = source._patchGraphDataNodeMetrics( + source._lastGraphData, + filesToAnalyze.map(file => file.relativePath), + ); + source._lastGraphData = graphData; + await dependencies.persistIndexMetadata(); + return graphData; + } + const graphData = buildWorkspaceIndexGraphFromRefreshState( source, dependencies.workspaceRoot, diff --git a/packages/core/tests/indexing/refresh.test.ts b/packages/core/tests/indexing/refresh.test.ts index 2aad09e66..12cdeae43 100644 --- a/packages/core/tests/indexing/refresh.test.ts +++ b/packages/core/tests/indexing/refresh.test.ts @@ -74,6 +74,7 @@ function createSource( ['src/plugin.ts', []], ['src/plain.txt', []], ]) as Map, + _lastGraphData: graph, _lastWorkspaceRoot: '/workspace', _preAnalyzePlugins: vi.fn(async () => undefined), _readAnalysisFiles: vi.fn(async (files: IDiscoveredFile[]) => files.map(file => ({ diff --git a/packages/extension/src/extension/pipeline/service/base/internal.ts b/packages/extension/src/extension/pipeline/service/base/internal.ts index 8a95e1be7..e36de658a 100644 --- a/packages/extension/src/extension/pipeline/service/base/internal.ts +++ b/packages/extension/src/extension/pipeline/service/base/internal.ts @@ -109,7 +109,7 @@ export abstract class WorkspacePipelineInternalBase extends WorkspacePipelineSta showOrphans: boolean, disabledPlugins: Set = new Set(), ): IGraphData { - return buildWorkspacePipelineGraph( + const graphData = buildWorkspacePipelineGraph( this._cache, this._context, this._registry, @@ -120,6 +120,8 @@ export abstract class WorkspacePipelineInternalBase extends WorkspacePipelineSta this._lastDiscoveredDirectories, this._lastGitIgnoredPaths, ); + this._lastGraphData = graphData; + return graphData; } protected _buildGraphDataFromAnalysis( @@ -129,7 +131,7 @@ export abstract class WorkspacePipelineInternalBase extends WorkspacePipelineSta disabledPlugins: Set = new Set(), ): IGraphData { const nodeVisibility = this._config.get>('nodeVisibility', {}) ?? {}; - return buildWorkspacePipelineGraphFromAnalysis( + const graphData = buildWorkspacePipelineGraphFromAnalysis( this._cache, this._context, this._registry, @@ -141,6 +143,8 @@ export abstract class WorkspacePipelineInternalBase extends WorkspacePipelineSta { nodeVisibility }, this._lastGitIgnoredPaths, ); + this._lastGraphData = graphData; + return graphData; } protected _getWorkspaceRoot(): string | undefined { diff --git a/packages/extension/src/extension/pipeline/service/base/state.ts b/packages/extension/src/extension/pipeline/service/base/state.ts index ce7ff280d..2675d3a3d 100644 --- a/packages/extension/src/extension/pipeline/service/base/state.ts +++ b/packages/extension/src/extension/pipeline/service/base/state.ts @@ -13,6 +13,7 @@ import { import { Configuration } from '../../../config/reader'; import { EventBus } from '../../../../core/plugins/events/bus'; import type { IWorkspaceAnalysisCache } from '../../cache'; +import type { IGraphData } from '../../../../shared/graph/contracts'; import { loadWorkspaceAnalysisDatabaseCacheAsync, readWorkspaceAnalysisDatabaseSnapshot, @@ -95,6 +96,14 @@ export abstract class WorkspacePipelineStateBase { this._engineState.workspaceRoot = workspaceRoot; } + protected get _lastGraphData(): IGraphData { + return this._engineState.graph; + } + + protected set _lastGraphData(graphData: IGraphData) { + this._engineState.graph = graphData; + } + setEventBus(eventBus: EventBus): void { this._eventBus = eventBus; } diff --git a/packages/extension/src/extension/pipeline/service/refreshFacade.ts b/packages/extension/src/extension/pipeline/service/refreshFacade.ts index 5fa9ce929..1bdd60ddf 100644 --- a/packages/extension/src/extension/pipeline/service/refreshFacade.ts +++ b/packages/extension/src/extension/pipeline/service/refreshFacade.ts @@ -9,6 +9,8 @@ import type { IGraphData } from '../../../shared/graph/contracts'; import { WorkspacePipelineDiscoveryFacade } from './discoveryFacade'; import { recordExtensionPerformanceEvent } from '../../performance/marks'; import { createWorkspacePipelineAnalysisCacheTiers } from './cache/tiers'; +import { getCachedGitHistoryChurnCounts } from '../../gitHistory/cache/state'; +import { createGitHistoryPluginSignature } from '../../gitHistory/pluginSignature'; import { createWorkspacePipelineDiscoveryDependencies, discoverWorkspacePipelineFilesWithWarnings, @@ -25,6 +27,19 @@ interface ChangedFileDiscoveryState { files: IDiscoveredFile[]; } +function normalizeGraphMetricFilePath(filePath: string): string { + return filePath.replace(/\\/g, '/'); +} + +function getGraphMetricNodeFilePath(node: IGraphData['nodes'][number]): string { + const symbolFilePath = node.symbol?.filePath; + return normalizeGraphMetricFilePath( + typeof symbolFilePath === 'string' && symbolFilePath.length > 0 + ? symbolFilePath + : node.id, + ); +} + function recordChangedFileRefreshPhase( phase: string, startedAt: number, @@ -83,6 +98,8 @@ export abstract class WorkspacePipelineRefreshFacade extends WorkspacePipelineDi this._buildGraphData(fileConnections, root, true, selectedPlugins), _buildGraphDataFromAnalysis: (fileAnalysis, root, selectedPlugins) => this._buildGraphDataFromAnalysis(fileAnalysis, root, true, selectedPlugins), + _patchGraphDataNodeMetrics: (graphData, filePaths) => + this._patchGraphDataNodeMetrics(graphData, filePaths), _preAnalyzePlugins: (files, root, abortSignal, nextDisabledPlugins = disabledPlugins) => this._preAnalyzePlugins(files, root, abortSignal, nextDisabledPlugins), _readAnalysisFiles: files => this._readAnalysisFiles(files), @@ -116,6 +133,12 @@ export abstract class WorkspacePipelineRefreshFacade extends WorkspacePipelineDi this._lastFileConnections = fileConnections; }, }, + _lastGraphData: { + get: () => this._lastGraphData, + set: (graphData: WorkspacePipelineRefreshSource['_lastGraphData']) => { + this._lastGraphData = graphData; + }, + }, _lastWorkspaceRoot: { get: () => this._lastWorkspaceRoot, set: (root: WorkspacePipelineRefreshSource['_lastWorkspaceRoot']) => { @@ -192,6 +215,20 @@ export abstract class WorkspacePipelineRefreshFacade extends WorkspacePipelineDi }), ); + const patchGraphDataNodeMetrics = source._patchGraphDataNodeMetrics?.bind(source); + if (patchGraphDataNodeMetrics) { + source._patchGraphDataNodeMetrics = (graphData, filePaths) => + timeChangedFileRefreshPhaseSync( + 'patchGraphDataNodeMetrics', + () => patchGraphDataNodeMetrics(graphData, filePaths), + patchedGraphData => ({ + changedFileCount: filePaths.length, + edgeCount: patchedGraphData.edges.length, + nodeCount: patchedGraphData.nodes.length, + }), + ); + } + const analyze = source.analyze.bind(source); source.analyze = (patterns, nextDisabledPlugins, signal, progress) => timeChangedFileRefreshPhase( @@ -217,6 +254,39 @@ export abstract class WorkspacePipelineRefreshFacade extends WorkspacePipelineDi return source; } + private _patchGraphDataNodeMetrics( + graphData: IGraphData, + filePaths: readonly string[], + ): IGraphData { + const metricFilePaths = new Set(filePaths.map(normalizeGraphMetricFilePath)); + if (metricFilePaths.size === 0) { + return graphData; + } + + const churnCounts = getCachedGitHistoryChurnCounts( + this._context.workspaceState, + createGitHistoryPluginSignature(this._registry), + ) ?? {}; + let changed = false; + const nodes = graphData.nodes.map((node) => { + const filePath = getGraphMetricNodeFilePath(node); + if (!metricFilePaths.has(filePath)) { + return node; + } + + const fileSize = this._cache.files[filePath]?.size; + const churn = churnCounts[filePath] ?? 0; + if (node.fileSize === fileSize && node.churn === churn) { + return node; + } + + changed = true; + return { ...node, fileSize, churn }; + }); + + return changed ? { ...graphData, nodes } : graphData; + } + private _canReuseCurrentAnalysisForScope( discoveredFiles: readonly IDiscoveredFile[], disabledPlugins: Set, diff --git a/packages/extension/tests/extension/pipeline/service/refreshFacade.test.ts b/packages/extension/tests/extension/pipeline/service/refreshFacade.test.ts index 8ae6c28f5..2154faeef 100644 --- a/packages/extension/tests/extension/pipeline/service/refreshFacade.test.ts +++ b/packages/extension/tests/extension/pipeline/service/refreshFacade.test.ts @@ -10,6 +10,12 @@ import { refreshWorkspacePipelineAnalysisScope, refreshWorkspacePipelineChangedFiles, } from '../../../../src/extension/pipeline/service/runtime/refresh'; +import { + CACHE_VERSION, + CACHE_VERSION_KEY, + CHURN_COUNTS_STATE_KEY, + PLUGIN_SIGNATURE_KEY, +} from '../../../../src/extension/gitHistory/cache/stateKeys'; const performanceMocks = vi.hoisted(() => ({ recordExtensionPerformanceEvent: vi.fn(), @@ -125,6 +131,22 @@ class TestRefreshFacade extends WorkspacePipelineRefreshFacade { super._lastWorkspaceRoot = workspaceRoot; } + public override get _lastGraphData(): never { + return super._lastGraphData as never; + } + + public override set _lastGraphData(graphData: never) { + super._lastGraphData = graphData; + } + + public override get _cache(): never { + return super._cache as never; + } + + public override set _cache(cache: never) { + super._cache = cache; + } + _getWorkspaceRoot = vi.fn(() => '/workspace'); getPluginFilterPatterns = vi.fn(() => ['plugin-filter']); _persistCache = vi.fn(); @@ -389,6 +411,79 @@ describe('pipeline/service/refreshFacade', () => { ]); }); + it('patches delegated graph metrics for file and symbol nodes', async () => { + const facade = new TestRefreshFacade(); + facade._cache = { + files: { + 'src/a.ts': { size: 12 }, + }, + } as never; + facade._registry = { + notifyFilesChanged: vi.fn(async () => ({ additionalFilePaths: [], requiresFullRefresh: false })), + list: vi.fn(() => [{ plugin: { id: 'plugin.a', version: '1.0.0' } }]), + } as never; + const workspaceState = ( + facade as unknown as { + _context: { workspaceState: { get: ReturnType } }; + } + )._context.workspaceState; + workspaceState.get.mockImplementation((key: string) => { + if (key === CACHE_VERSION_KEY) { + return CACHE_VERSION; + } + if (key === PLUGIN_SIGNATURE_KEY) { + return 'plugin.a@1.0.0'; + } + if (key === CHURN_COUNTS_STATE_KEY) { + return { 'src/a.ts': 7 }; + } + return undefined; + }); + + await facade.refreshChangedFiles(['/workspace/src/a.ts']); + + const [refreshSource] = vi.mocked(refreshWorkspacePipelineChangedFiles).mock.calls[0]; + expect(refreshSource._patchGraphDataNodeMetrics?.({ + nodes: [ + { color: '#fff', id: 'src/a.ts', label: 'a.ts', fileSize: 10, churn: 1 }, + { + color: '#fff', + id: 'src/a.ts#run:function', + label: 'run', + symbol: { + filePath: 'src/a.ts', + id: 'src/a.ts#run:function', + kind: 'function', + name: 'run', + }, + fileSize: 10, + churn: 1, + }, + { color: '#fff', id: 'src/b.ts', label: 'b.ts', fileSize: 4, churn: 2 }, + ], + edges: [], + }, ['src/a.ts'])).toEqual({ + nodes: [ + { color: '#fff', id: 'src/a.ts', label: 'a.ts', fileSize: 12, churn: 7 }, + { + color: '#fff', + id: 'src/a.ts#run:function', + label: 'run', + symbol: { + filePath: 'src/a.ts', + id: 'src/a.ts#run:function', + kind: 'function', + name: 'run', + }, + fileSize: 12, + churn: 7, + }, + { color: '#fff', id: 'src/b.ts', label: 'b.ts', fileSize: 4, churn: 2 }, + ], + edges: [], + }); + }); + it('builds delegated discovery and refresh dependencies for analysis-scope refreshes', async () => { const facade = new TestRefreshFacade(); const disabledPlugins = new Set(['plugin.disabled']); diff --git a/packages/extension/tests/extension/pipeline/service/runtime/refresh.test.ts b/packages/extension/tests/extension/pipeline/service/runtime/refresh.test.ts index e36169673..4dd09f923 100644 --- a/packages/extension/tests/extension/pipeline/service/runtime/refresh.test.ts +++ b/packages/extension/tests/extension/pipeline/service/runtime/refresh.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import type { IFileAnalysisResult } from '../../../../../src/core/plugins/types/contracts'; +import type { IGraphData } from '../../../../../src/shared/graph/contracts'; import { refreshWorkspacePipelineChangedFiles } from '../../../../../src/extension/pipeline/service/runtime/refresh'; function createSource() { @@ -7,11 +8,26 @@ function createSource() { _analyzeFiles: vi.fn(), _buildGraphData: vi.fn(() => ({ nodes: [], edges: [] })), _buildGraphDataFromAnalysis: vi.fn(() => ({ nodes: [{ id: 'node' }], edges: [] })), + _lastGraphData: { + nodes: [ + { id: 'src/a.ts', fileSize: 10, churn: 1 }, + { id: 'src/a.ts#run:function', symbol: { filePath: 'src/a.ts' }, fileSize: 10, churn: 1 }, + ], + edges: [{ from: 'src/a.ts', to: 'src/b.ts', kind: 'import' }], + }, _lastDiscoveredDirectories: [] as string[], _lastDiscoveredFiles: [] as Array<{ absolutePath: string; relativePath: string }>, _lastFileAnalysis: new Map(), _lastFileConnections: new Map(), _lastWorkspaceRoot: '', + _patchGraphDataNodeMetrics: vi.fn((graphData: IGraphData) => ({ + ...graphData, + nodes: graphData.nodes.map(node => ( + node.id === 'src/a.ts' || node.symbol?.filePath === 'src/a.ts' + ? { ...node, fileSize: 12, churn: 1 } + : node + )), + })), _readAnalysisFiles: vi.fn(), analyze: vi.fn(), invalidateWorkspaceFiles: vi.fn(), @@ -174,6 +190,66 @@ describe('pipeline/service/refresh', () => { expect(graph).toEqual({ nodes: [{ id: 'node' }], edges: [] }); }); + it('patches node metrics without rebuilding the graph when changed-file analysis is graph-equivalent', async () => { + const source = createSource(); + const dependencies = createDependencies(); + const existingAnalysis: IFileAnalysisResult = { + filePath: '/workspace/src/a.ts', + relations: [{ + fromFilePath: '/workspace/src/a.ts', + kind: 'import', + resolvedPath: '/workspace/src/b.ts', + sourceId: 'src/a.ts', + toFilePath: '/workspace/src/b.ts', + }], + symbols: [{ + filePath: '/workspace/src/a.ts', + id: '/workspace/src/a.ts:function:run', + kind: 'function', + name: 'run', + }], + }; + source._lastFileAnalysis.set('src/a.ts', existingAnalysis); + source._lastFileConnections.set('src/a.ts', [{ kind: 'import' }]); + source._readAnalysisFiles.mockResolvedValue([ + { + absolutePath: '/workspace/src/a.ts', + relativePath: 'src/a.ts', + content: 'content:a', + }, + ]); + dependencies.notifyFilesChanged.mockResolvedValue({ + additionalFilePaths: [], + requiresFullRefresh: false, + }); + source._analyzeFiles.mockResolvedValue({ + cacheHits: 0, + cacheMisses: 1, + fileAnalysis: new Map([['src/a.ts', { ...existingAnalysis }]]), + fileConnections: new Map([['src/a.ts', [{ kind: 'import' }]]]), + }); + const previousGraphData = source._lastGraphData; + + const graph = await refreshWorkspacePipelineChangedFiles(source as never, dependencies as never); + + expect(source._buildGraphDataFromAnalysis).not.toHaveBeenCalled(); + expect(source._buildGraphData).not.toHaveBeenCalled(); + expect(source._patchGraphDataNodeMetrics).toHaveBeenCalledWith( + previousGraphData, + ['src/a.ts'], + ); + expect(source._lastGraphData).toBe(graph); + expect(graph).toEqual({ + nodes: [ + { id: 'src/a.ts', fileSize: 12, churn: 1 }, + { id: 'src/a.ts#run:function', symbol: { filePath: 'src/a.ts' }, fileSize: 12, churn: 1 }, + ], + edges: [{ from: 'src/a.ts', to: 'src/b.ts', kind: 'import' }], + }); + expect(dependencies.persistCache).toHaveBeenCalledOnce(); + expect(dependencies.persistIndexMetadata).toHaveBeenCalledOnce(); + }); + it('supports refreshes without a progress callback', async () => { const source = createSource(); const dependencies = createDependencies(); From 0fa0d3de6f4aa4b757dedabe4587bcbc5b849ad4 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 02:23:56 -0700 Subject: [PATCH 059/192] perf: warm cached analysis path --- .changeset/skip-metric-group-publish.md | 2 +- docs/performance/codegraphy-monorepo.md | 14 ++ .../pipeline/service/discoveryFacade.ts | 150 ++++++++++++++++++ .../pipeline/service/discoveryFacade.test.ts | 150 ++++++++++++++++++ 4 files changed, 315 insertions(+), 1 deletion(-) diff --git a/.changeset/skip-metric-group-publish.md b/.changeset/skip-metric-group-publish.md index b6d805222..32327d3a5 100644 --- a/.changeset/skip-metric-group-publish.md +++ b/.changeset/skip-metric-group-publish.md @@ -2,4 +2,4 @@ "@codegraphy-dev/extension": patch --- -Skip redundant legend group publication, deep graph reuse checks, full graph payloads, visible graph recomputation, pre-refresh legend broadcasts, incremental freshness scans, and host-side graph rebuilds when a saved file only changes non-visual graph node metrics. +Skip redundant legend group publication, deep graph reuse checks, full graph payloads, visible graph recomputation, pre-refresh legend broadcasts, incremental freshness scans, and host-side graph rebuilds when a saved file only changes non-visual graph node metrics. Warm one representative cached source file after Graph Cache replay so the first edit avoids cold analyzer startup. diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index c5d3f3318..ef4b03e65 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -907,6 +907,20 @@ Interpretation: `142ms`, strict wall clock moved from `594ms` to `545ms`, and the webview still received a one-node `GRAPH_NODE_METRICS_UPDATED` patch with zero visible-graph derivation or runtime graph rebuild in the live-update window. +- Cached graph replay now warms one representative cached source file through + the routed analyzer in the background, choosing the most common supported + source extension while skipping temp/generated folders such as + `.stryker-tmp`, `.turbo`, `.worktrees`, `dist`, `coverage`, and `reports`. + This targets the cold parser/plugin cost that appeared on the first save + after opening a warm Graph Cache. In the rebuilt monorepo probe, the + background warm-up analyzed `apps/web/src/index.ts` in `727ms` without + regressing first graph readiness (`5263ms`) or Imports toggle (`272ms` + wall-clock / `59ms` webview). The first marker save's + `analyzeFileResultForPlugins` phase moved from `88ms` to `13ms`, + `analyzeFiles` moved from `91ms` to `15ms`, + `refreshChangedFiles.completed` moved from `119ms` to `53ms`, and the + incremental request moved from `142ms` to `77ms`; strict live-update wall + clock moved from `545ms` to `510ms`. Full test baseline: diff --git a/packages/extension/src/extension/pipeline/service/discoveryFacade.ts b/packages/extension/src/extension/pipeline/service/discoveryFacade.ts index 558413757..b58f5df44 100644 --- a/packages/extension/src/extension/pipeline/service/discoveryFacade.ts +++ b/packages/extension/src/extension/pipeline/service/discoveryFacade.ts @@ -1,7 +1,10 @@ import * as vscode from 'vscode'; import { + createWorkspacePluginAnalysisContext, + type IDiscoveredFile, projectFileAnalysisConnections, readCodeGraphyWorkspaceStatus, + SYMBOLS_ANALYSIS_CACHE_TIER, throwIfWorkspaceAnalysisAborted, } from '@codegraphy-dev/core'; import type { IProjectedConnection } from '../../../core/plugins/types/contracts'; @@ -27,11 +30,40 @@ import { import { createEmptyWorkspaceAnalysisCache } from '../cache'; import { createCachedWorkspaceDiscoveryState } from './cache/cachedDiscovery'; import { recordExtensionPerformanceEvent } from '../../performance/marks'; +import { createWorkspacePipelineAnalysisCacheTiers } from './cache/tiers'; export interface WorkspacePipelineCachedGraphLoadOptions { includeCurrentGitignoreMetadata?: boolean; } +function isWorkspaceAnalysisAbortError(error: unknown): boolean { + return error instanceof Error && error.name === 'AbortError'; +} + +function isMissingFileError(error: unknown): boolean { + return error instanceof Error + && 'code' in error + && (error as { code?: unknown }).code === 'ENOENT'; +} + +const CACHED_GRAPH_ANALYSIS_WARMUP_IGNORED_SEGMENTS = new Set([ + '.codegraphy', + '.git', + '.stryker-tmp', + '.turbo', + '.worktrees', + 'coverage', + 'dist', + 'node_modules', + 'out', + 'reports', +]); + +function isCachedGraphAnalysisWarmupCandidate(file: IDiscoveredFile): boolean { + const segments = file.relativePath.replace(/\\/g, '/').split('/'); + return !segments.some(segment => CACHED_GRAPH_ANALYSIS_WARMUP_IGNORED_SEGMENTS.has(segment)); +} + export abstract class WorkspacePipelineDiscoveryFacade extends WorkspacePipelineInternalBase { private _workspacePluginReloadQueue: Promise = Promise.resolve(); @@ -256,9 +288,127 @@ export abstract class WorkspacePipelineDiscoveryFacade extends WorkspacePipeline nodeCount: graphData.nodes.length, }); + this._scheduleCachedGraphAnalysisWarmup( + cachedDiscovery.files, + workspaceRoot, + disabledPlugins, + signal, + ); + return graphData; } + private _selectCachedGraphAnalysisWarmupFile( + files: readonly IDiscoveredFile[], + ): IDiscoveredFile | undefined { + if (typeof this._registry.supportsFile !== 'function') { + return files[0]; + } + + const sourceFiles = files.filter(isCachedGraphAnalysisWarmupCandidate); + const supportedFiles = sourceFiles.filter(file => + this._registry.supportsFile(file.absolutePath) + || this._registry.supportsFile(file.relativePath), + ); + if (supportedFiles.length === 0) { + return files.find(file => + this._registry.supportsFile(file.absolutePath) + || this._registry.supportsFile(file.relativePath), + ) ?? files[0]; + } + + const extensionStats = new Map(); + for (const [index, file] of supportedFiles.entries()) { + const extension = file.extension; + const stats = extensionStats.get(extension); + if (stats) { + stats.count += 1; + continue; + } + + extensionStats.set(extension, { + count: 1, + file, + firstIndex: index, + }); + } + + return [...extensionStats.values()] + .sort((left, right) => + right.count - left.count || left.firstIndex - right.firstIndex, + )[0]?.file; + } + + private _scheduleCachedGraphAnalysisWarmup( + files: readonly IDiscoveredFile[], + workspaceRoot: string, + disabledPlugins: Set, + signal?: AbortSignal, + ): void { + if (typeof this._registry.analyzeFileResultForPlugins !== 'function') { + return; + } + + const file = this._selectCachedGraphAnalysisWarmupFile(files); + if (!file) { + return; + } + + const warmupStartedAt = Date.now(); + const disabledPluginSnapshot = new Set(disabledPlugins); + const pluginIds = this._getActiveAnalysisPluginIds(undefined, disabledPluginSnapshot); + const cacheTiers = createWorkspacePipelineAnalysisCacheTiers( + this._config.get>('nodeVisibility', {}) ?? {}, + pluginIds, + ); + const analysisContext = createWorkspacePluginAnalysisContext(workspaceRoot, { + features: { + symbols: cacheTiers.active === undefined + || cacheTiers.active.includes(SYMBOLS_ANALYSIS_CACHE_TIER), + }, + }); + + void (async () => { + throwIfWorkspaceAnalysisAborted(signal); + const content = await this._discovery.readContent(file); + throwIfWorkspaceAnalysisAborted(signal); + await this._registry.analyzeFileResultForPlugins( + file.absolutePath, + content, + workspaceRoot, + pluginIds, + analysisContext, + { disabledPlugins: disabledPluginSnapshot }, + ); + recordExtensionPerformanceEvent('workspacePipeline.loadCachedGraph.warmAnalysis', { + durationMs: Date.now() - warmupStartedAt, + filePath: file.relativePath, + pluginIdCount: pluginIds.length, + status: 'completed', + }); + })().catch(error => { + const status = isWorkspaceAnalysisAbortError(error) + ? 'aborted' + : isMissingFileError(error) + ? 'skipped' + : 'failed'; + recordExtensionPerformanceEvent('workspacePipeline.loadCachedGraph.warmAnalysis', { + durationMs: Date.now() - warmupStartedAt, + filePath: file.relativePath, + pluginIdCount: pluginIds.length, + status, + }); + + if (status === 'failed') { + console.warn('[CodeGraphy] Failed to warm cached graph analysis.', error); + } + }); + } + rebuildGraph(disabledPlugins: Set, showOrphans: boolean): IGraphData { return rebuildWorkspacePipelineGraph( this as unknown as WorkspacePipelineSourceOwner, diff --git a/packages/extension/tests/extension/pipeline/service/discoveryFacade.test.ts b/packages/extension/tests/extension/pipeline/service/discoveryFacade.test.ts index 3d01196cd..ff1a03f5a 100644 --- a/packages/extension/tests/extension/pipeline/service/discoveryFacade.test.ts +++ b/packages/extension/tests/extension/pipeline/service/discoveryFacade.test.ts @@ -102,6 +102,7 @@ class TestDiscoveryFacade extends WorkspacePipelineDiscoveryFacade { } _config = { + get: vi.fn((_key: string, defaultValue: unknown) => defaultValue), getAll: vi.fn(() => ({ showOrphans: true, respectGitignore: true })), } as unknown as Configuration; @@ -515,6 +516,155 @@ describe('pipeline/service/discoveryFacade', () => { expect(discoveryState(facade)._lastGitIgnoredPaths).toEqual([]); }); + it('warms one cached source file through the routed analyzer after cached replay', async () => { + const facade = new TestDiscoveryFacade(); + const warmContent = createDeferred(); + const analyzeFileResultForPlugins = vi.fn(async () => ({ + filePath: '/workspace/src/nested/cached.ts', + relations: [], + })); + facade._discovery = { + readContent: vi.fn(() => warmContent.promise), + } as unknown as FileDiscovery; + facade._registry = { + analyzeFileResultForPlugins, + list: vi.fn(() => [{ plugin: { id: 'plugin.typescript' } }]), + supportsFile: vi.fn(() => true), + } as unknown as PluginRegistry; + facade._cache = { + version: 'test', + files: { + 'docs/readme.md': { + mtime: 1, + analysis: { + filePath: '/workspace/docs/readme.md', + relations: [], + }, + }, + '.stryker-tmp/sandbox/src/mutant.ts': { + mtime: 1, + analysis: { + filePath: '/workspace/.stryker-tmp/sandbox/src/mutant.ts', + relations: [], + }, + }, + 'src/nested/cached.ts': { + mtime: 1, + analysis: { + filePath: '/workspace/src/nested/cached.ts', + relations: [], + }, + }, + 'src/nested/next.ts': { + mtime: 1, + analysis: { + filePath: '/workspace/src/nested/next.ts', + relations: [], + }, + }, + }, + } as never; + vi.spyOn( + facade as unknown as { + _buildGraphDataFromAnalysis: (...args: unknown[]) => unknown; + }, + '_buildGraphDataFromAnalysis', + ).mockReturnValue({ + nodes: [{ id: 'src/nested/cached.ts', label: 'cached.ts', color: '#333333' }], + edges: [], + }); + + await expect(facade.loadCachedGraph()).resolves.toEqual({ + nodes: [{ id: 'src/nested/cached.ts', label: 'cached.ts', color: '#333333' }], + edges: [], + }); + + expect(facade._discovery.readContent).toHaveBeenCalledWith({ + absolutePath: '/workspace/src/nested/cached.ts', + extension: '.ts', + name: 'cached.ts', + relativePath: 'src/nested/cached.ts', + }); + expect(analyzeFileResultForPlugins).not.toHaveBeenCalled(); + + warmContent.resolve('export const cached = 1;\n'); + await vi.waitFor(() => expect(analyzeFileResultForPlugins).toHaveBeenCalledOnce()); + expect(analyzeFileResultForPlugins).toHaveBeenCalledWith( + '/workspace/src/nested/cached.ts', + 'export const cached = 1;\n', + '/workspace', + ['plugin.typescript'], + expect.objectContaining({ + features: expect.objectContaining({ symbols: false }), + mode: 'workspace', + }), + { disabledPlugins: new Set() }, + ); + expect(performanceMocks.recordExtensionPerformanceEvent).toHaveBeenCalledWith( + 'workspacePipeline.loadCachedGraph.warmAnalysis', + expect.objectContaining({ + durationMs: expect.any(Number), + filePath: 'src/nested/cached.ts', + pluginIdCount: 1, + status: 'completed', + }), + ); + }); + + it('skips cached analysis warm-up quietly when the selected file disappeared', async () => { + const facade = new TestDiscoveryFacade(); + const readError = Object.assign(new Error('missing cached file'), { code: 'ENOENT' }); + const analyzeFileResultForPlugins = vi.fn(); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + facade._discovery = { + readContent: vi.fn(async () => { + throw readError; + }), + } as unknown as FileDiscovery; + facade._registry = { + analyzeFileResultForPlugins, + list: vi.fn(() => [{ plugin: { id: 'plugin.typescript' } }]), + supportsFile: vi.fn(() => true), + } as unknown as PluginRegistry; + facade._cache = { + version: 'test', + files: { + 'src/gone.ts': { + mtime: 1, + analysis: { + filePath: '/workspace/src/gone.ts', + relations: [], + }, + }, + }, + } as never; + vi.spyOn( + facade as unknown as { + _buildGraphDataFromAnalysis: (...args: unknown[]) => unknown; + }, + '_buildGraphDataFromAnalysis', + ).mockReturnValue({ + nodes: [{ id: 'src/gone.ts', label: 'gone.ts', color: '#333333' }], + edges: [], + }); + + await facade.loadCachedGraph(); + + await vi.waitFor(() => + expect(performanceMocks.recordExtensionPerformanceEvent).toHaveBeenCalledWith( + 'workspacePipeline.loadCachedGraph.warmAnalysis', + expect.objectContaining({ + filePath: 'src/gone.ts', + status: 'skipped', + }), + ), + ); + expect(analyzeFileResultForPlugins).not.toHaveBeenCalled(); + expect(warnSpy).not.toHaveBeenCalled(); + + warnSpy.mockRestore(); + }); + it('applies current gitignore metadata when replaying cached graph data', async () => { const facade = new TestDiscoveryFacade(); const cachedAnalysis = { From 0588d87f1f29ab5a2be7287f9361ad8b44b13cd7 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 02:33:30 -0700 Subject: [PATCH 060/192] perf: tighten live update refresh latency --- .changeset/skip-metric-group-publish.md | 2 +- docs/performance/codegraphy-monorepo.md | 13 ++++ .../workspaceFiles/refresh/operations.ts | 2 +- .../workspaceFiles/refresh/operations.test.ts | 4 +- .../performance/measure-vscode-graph-view.mjs | 21 ++++++- .../measure-vscode-graph-view.test.mjs | 63 +++++++++++++++++-- 6 files changed, 95 insertions(+), 10 deletions(-) diff --git a/.changeset/skip-metric-group-publish.md b/.changeset/skip-metric-group-publish.md index 32327d3a5..f42a6d438 100644 --- a/.changeset/skip-metric-group-publish.md +++ b/.changeset/skip-metric-group-publish.md @@ -2,4 +2,4 @@ "@codegraphy-dev/extension": patch --- -Skip redundant legend group publication, deep graph reuse checks, full graph payloads, visible graph recomputation, pre-refresh legend broadcasts, incremental freshness scans, and host-side graph rebuilds when a saved file only changes non-visual graph node metrics. Warm one representative cached source file after Graph Cache replay so the first edit avoids cold analyzer startup. +Skip redundant legend group publication, deep graph reuse checks, full graph payloads, visible graph recomputation, pre-refresh legend broadcasts, incremental freshness scans, and host-side graph rebuilds when a saved file only changes non-visual graph node metrics. Warm one representative cached source file after Graph Cache replay so the first edit avoids cold analyzer startup, and shorten normal saved-file refresh debounce while preserving file-operation coalescing. diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index ef4b03e65..ee46775c0 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -921,6 +921,19 @@ Interpretation: `refreshChangedFiles.completed` moved from `119ms` to `53ms`, and the incremental request moved from `142ms` to `77ms`; strict live-update wall clock moved from `545ms` to `510ms`. +- Normal saved-file and file-change refreshes now use a `32ms` two-frame + debounce instead of `50ms`; create/delete/rename operations keep their + `500ms` coalescing window. The VS Code live-update harness also now reports + write-to-request start and write-to-request completion delay, so backend + processing can be separated from VS Code watcher/scheduler latency. In two + rebuilt monorepo probes, strict live-update wall clock measured `434ms` and + `382ms` versus the previous `510ms`, with request durations in the same band + (`87ms` and `83ms` versus `77ms`). The new request-start delay metric + measured `259ms` and `203ms`, confirming the remaining wall cost is mostly + before CodeGraphy's incremental request begins rather than graph rebuilding + or webview recomputation. First graph readiness stayed in the same band + (`5165ms` and `5026ms`), and the Imports toggle measured `201ms`/`198ms` + wall-clock with `62ms`/`57ms` in-webview. Full test baseline: diff --git a/packages/extension/src/extension/workspaceFiles/refresh/operations.ts b/packages/extension/src/extension/workspaceFiles/refresh/operations.ts index 78c1ec78a..83dcb1b6a 100644 --- a/packages/extension/src/extension/workspaceFiles/refresh/operations.ts +++ b/packages/extension/src/extension/workspaceFiles/refresh/operations.ts @@ -9,7 +9,7 @@ import { scheduleWorkspaceRefresh } from './scheduler'; type WorkspaceRenameFiles = vscode.FileRenameEvent['files']; type WorkspaceFileEventName = 'workspace:fileCreated' | 'workspace:fileDeleted'; -const WORKSPACE_CONTENT_CHANGE_REFRESH_DELAY_MS = 50; +const WORKSPACE_CONTENT_CHANGE_REFRESH_DELAY_MS = 32; const WORKSPACE_FILE_OPERATION_REFRESH_DELAY_MS = 500; function isGitignorePath(filePath: string): boolean { diff --git a/packages/extension/tests/extension/workspaceFiles/refresh/operations.test.ts b/packages/extension/tests/extension/workspaceFiles/refresh/operations.test.ts index 04360d113..06a92c2a2 100644 --- a/packages/extension/tests/extension/workspaceFiles/refresh/operations.test.ts +++ b/packages/extension/tests/extension/workspaceFiles/refresh/operations.test.ts @@ -37,7 +37,7 @@ describe('workspaceFiles/refresh/operations', () => { provider as never, { uri: uri('/workspace/src/app.ts') } as vscode.TextDocument, ); - vi.advanceTimersByTime(49); + vi.advanceTimersByTime(31); expect(provider.invalidateWorkspaceFiles).not.toHaveBeenCalled(); vi.advanceTimersByTime(1); @@ -58,7 +58,7 @@ describe('workspaceFiles/refresh/operations', () => { provider as never, { uri: uri('/workspace/.gitignore') } as vscode.TextDocument, ); - vi.advanceTimersByTime(49); + vi.advanceTimersByTime(31); expect(provider.refreshGitignoreMetadata).not.toHaveBeenCalled(); vi.advanceTimersByTime(1); diff --git a/scripts/performance/measure-vscode-graph-view.mjs b/scripts/performance/measure-vscode-graph-view.mjs index e1b4b65b8..e1d41e85f 100644 --- a/scripts/performance/measure-vscode-graph-view.mjs +++ b/scripts/performance/measure-vscode-graph-view.mjs @@ -238,12 +238,24 @@ export function summarizeLiveUpdateSamples(samples) { const requestDurations = samples .map(sample => sample.requestDurationMs) .filter(value => value !== undefined); + const requestStartDelays = samples + .map(sample => sample.requestStartDelayMs) + .filter(value => value !== undefined); + const requestCompletionDelays = samples + .map(sample => sample.requestCompletionDelayMs) + .filter(value => value !== undefined); return { ...summarizeDurations(samples.map(sample => sample.durationMs)), ...(requestDurations.length > 0 ? { requestDuration: summarizeDurations(requestDurations) } : {}), + ...(requestStartDelays.length > 0 + ? { requestStartDelay: summarizeDurations(requestStartDelays) } + : {}), + ...(requestCompletionDelays.length > 0 + ? { requestCompletionDelay: summarizeDurations(requestCompletionDelays) } + : {}), }; } @@ -588,11 +600,18 @@ export async function measureLiveUpdateTransition({ ); updateRequestCompletedAt = requestEvent.at; await waitForWebviewGraphUpdateMessageIfSent(extensionHostLogPath, frame, requestEvent); + const requestDurationMs = requestEvent.detail?.durationMs; + const requestCompletionDelayMs = Math.round(requestEvent.at - startedAtEpoch); + const requestStartDelayMs = typeof requestDurationMs === 'number' + ? Math.round(requestEvent.at - requestDurationMs - startedAtEpoch) + : undefined; return { durationMs: Math.round(performance.now() - startedAt), filePath: path.relative(workspaceRoot, absoluteFilePath).replace(/\\/g, '/'), - requestDurationMs: requestEvent.detail?.durationMs, + requestDurationMs, + requestStartDelayMs, + requestCompletionDelayMs, requestOffsetMs: requestEvent.offsetMs, webviewEvents: await readWebviewPerformanceEvents(frame), }; diff --git a/tests/scripts/measure-vscode-graph-view.test.mjs b/tests/scripts/measure-vscode-graph-view.test.mjs index 0d4eafd4d..3da7fce51 100644 --- a/tests/scripts/measure-vscode-graph-view.test.mjs +++ b/tests/scripts/measure-vscode-graph-view.test.mjs @@ -117,9 +117,24 @@ test('VS Code graph view runner summarizes live-update request durations', async const { summarizeLiveUpdateSamples } = await import(moduleUrl); assert.deepEqual(summarizeLiveUpdateSamples([ - { durationMs: 640, requestDurationMs: 120 }, - { durationMs: 590, requestDurationMs: 90 }, - { durationMs: 615, requestDurationMs: 105 }, + { + durationMs: 640, + requestDurationMs: 120, + requestStartDelayMs: 420, + requestCompletionDelayMs: 540, + }, + { + durationMs: 590, + requestDurationMs: 90, + requestStartDelayMs: 390, + requestCompletionDelayMs: 480, + }, + { + durationMs: 615, + requestDurationMs: 105, + requestStartDelayMs: 405, + requestCompletionDelayMs: 510, + }, ]), { iterations: 3, minMs: 590, @@ -133,6 +148,20 @@ test('VS Code graph view runner summarizes live-update request durations', async p95Ms: 120, maxMs: 120, }, + requestStartDelay: { + iterations: 3, + minMs: 390, + medianMs: 405, + p95Ms: 420, + maxMs: 420, + }, + requestCompletionDelay: { + iterations: 3, + minMs: 480, + medianMs: 510, + p95Ms: 540, + maxMs: 540, + }, }); }); @@ -336,7 +365,12 @@ test('VS Code graph view runner carries post-interaction extension-host events i assert.deepEqual(createCompleteMeasurements({ extensionHostEvents, importsToggleSamples: [{ durationMs: 25 }], - liveUpdateSamples: [{ durationMs: 40, requestDurationMs: 30 }], + liveUpdateSamples: [{ + durationMs: 40, + requestDurationMs: 30, + requestStartDelayMs: 10, + requestCompletionDelayMs: 40, + }], startupMeasurements, }), { status: 'complete', @@ -362,7 +396,26 @@ test('VS Code graph view runner carries post-interaction extension-host events i p95Ms: 30, maxMs: 30, }, - samples: [{ durationMs: 40, requestDurationMs: 30 }], + requestStartDelay: { + iterations: 1, + minMs: 10, + medianMs: 10, + p95Ms: 10, + maxMs: 10, + }, + requestCompletionDelay: { + iterations: 1, + minMs: 40, + medianMs: 40, + p95Ms: 40, + maxMs: 40, + }, + samples: [{ + durationMs: 40, + requestDurationMs: 30, + requestStartDelayMs: 10, + requestCompletionDelayMs: 40, + }], }, }); }); From 1bee20d0f6730af67e06167eb84e5e6a3e1cc52d Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 02:39:39 -0700 Subject: [PATCH 061/192] perf: skip static broadcasts for metric patches --- .changeset/skip-metric-group-publish.md | 2 +- docs/performance/codegraphy-monorepo.md | 12 ++++ .../graphView/analysis/execution/publish.ts | 30 +++++--- .../analysis/execution/publish.test.ts | 70 +++++++++++++++++++ 4 files changed, 102 insertions(+), 12 deletions(-) diff --git a/.changeset/skip-metric-group-publish.md b/.changeset/skip-metric-group-publish.md index f42a6d438..0f5a0c0cf 100644 --- a/.changeset/skip-metric-group-publish.md +++ b/.changeset/skip-metric-group-publish.md @@ -2,4 +2,4 @@ "@codegraphy-dev/extension": patch --- -Skip redundant legend group publication, deep graph reuse checks, full graph payloads, visible graph recomputation, pre-refresh legend broadcasts, incremental freshness scans, and host-side graph rebuilds when a saved file only changes non-visual graph node metrics. Warm one representative cached source file after Graph Cache replay so the first edit avoids cold analyzer startup, and shorten normal saved-file refresh debounce while preserving file-operation coalescing. +Skip redundant legend group publication, deep graph reuse checks, full graph payloads, visible graph recomputation, pre-refresh legend broadcasts, incremental freshness scans, host-side graph rebuilds, and static graph-state broadcasts when a saved file only changes non-visual graph node metrics. Warm one representative cached source file after Graph Cache replay so the first edit avoids cold analyzer startup, and shorten normal saved-file refresh debounce while preserving file-operation coalescing. diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index ee46775c0..a492364cd 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -934,6 +934,18 @@ Interpretation: or webview recomputation. First graph readiness stayed in the same band (`5165ms` and `5026ms`), and the Imports toggle measured `201ms`/`198ms` wall-clock with `62ms`/`57ms` in-webview. +- Metric-only incremental patches now skip the static graph-state broadcast + bundle (`DEPTH_*`, plugin statuses, decorations, context menu items, + exporters, toolbar actions, contribution statuses, and plugin webview + injections). They still send progress, the one-node + `GRAPH_NODE_METRICS_UPDATED` patch, index status, post-analyze, and + workspace-ready notifications. The rebuilt monorepo probes recorded + `graphAnalysis.publish.broadcastsSkipped` with + `reason: "metricOnlyGraphPatch"` and the webview live-update window shrank + to four progress messages, one metrics patch, the in-place patch marker, and + index status. Incremental request duration measured `66ms` and `69ms`, down + from the prior `83ms`-`87ms` samples, while strict wall clock stayed in the + same `427ms`-`429ms` range because request-start delay remained `261ms`. Full test baseline: diff --git a/packages/extension/src/extension/graphView/analysis/execution/publish.ts b/packages/extension/src/extension/graphView/analysis/execution/publish.ts index bc4e1127e..f09090dab 100644 --- a/packages/extension/src/extension/graphView/analysis/execution/publish.ts +++ b/packages/extension/src/extension/graphView/analysis/execution/publish.ts @@ -297,6 +297,8 @@ export function publishAnalyzedGraph( rawGraphData, state.changedFilePaths, ); + const shouldSendMetricPatch = metricOnlyUpdate !== undefined + && handlers.sendGraphNodeMetricsUpdated !== undefined; const reuseCurrentGraphPublication = canReuseCurrentGraphPublication( state, currentRawGraphData, @@ -345,15 +347,21 @@ export function publishAnalyzedGraph( } stageStartedAt = Date.now(); - handlers.sendDepthState(); - handlers.sendPluginStatuses(); - handlers.sendDecorations(); - handlers.sendContextMenuItems(); - handlers.sendPluginExporters?.(); - handlers.sendPluginToolbarActions?.(); - handlers.sendGraphViewContributionStatuses?.(); - handlers.sendPluginWebviewInjections?.(); - recordPublishStage('broadcasts', stageStartedAt); + if (shouldSendMetricPatch) { + recordPublishStage('broadcastsSkipped', stageStartedAt, { + reason: 'metricOnlyGraphPatch', + }); + } else { + handlers.sendDepthState(); + handlers.sendPluginStatuses(); + handlers.sendDecorations(); + handlers.sendContextMenuItems(); + handlers.sendPluginExporters?.(); + handlers.sendPluginToolbarActions?.(); + handlers.sendGraphViewContributionStatuses?.(); + handlers.sendPluginWebviewInjections?.(); + recordPublishStage('broadcasts', stageStartedAt); + } stageStartedAt = Date.now(); const graphData = handlers.getGraphData(); @@ -373,8 +381,8 @@ export function publishAnalyzedGraph( }); if (!reuseCurrentGraphPublication) { stageStartedAt = Date.now(); - if (metricOnlyUpdate && handlers.sendGraphNodeMetricsUpdated) { - handlers.sendGraphNodeMetricsUpdated(metricOnlyUpdate); + if (shouldSendMetricPatch) { + handlers.sendGraphNodeMetricsUpdated?.(metricOnlyUpdate); recordPublishStage('sendGraphNodeMetrics', stageStartedAt, { nodeCount: metricOnlyUpdate.length, }); diff --git a/packages/extension/tests/extension/graphView/analysis/execution/publish.test.ts b/packages/extension/tests/extension/graphView/analysis/execution/publish.test.ts index ceedf52e9..f23ffb5d8 100644 --- a/packages/extension/tests/extension/graphView/analysis/execution/publish.test.ts +++ b/packages/extension/tests/extension/graphView/analysis/execution/publish.test.ts @@ -423,6 +423,76 @@ describe('graph view analysis execution publish', () => { expect(handlers.sendGraphDataUpdated).not.toHaveBeenCalled(); }); + it('skips static graph-state broadcasts for metric-only incremental patches', () => { + const currentGraphData: IGraphData = { + nodes: [{ + id: 'src/index.ts', + label: 'index.ts', + color: '#ffffff', + fileSize: 100, + churn: 1, + }], + edges: [], + }; + const nextGraphData: IGraphData = { + nodes: [{ + id: 'src/index.ts', + label: 'index.ts', + color: '#ffffff', + fileSize: 120, + churn: 2, + }], + edges: [], + }; + const state = createExecutionState({ + mode: 'incremental', + changedFilePaths: ['/workspace/src/index.ts'], + analyzer: createExecutionAnalyzer(), + }); + const sendGraphNodeMetricsUpdated = vi.fn(); + const sendPluginExporters = vi.fn(); + const sendPluginToolbarActions = vi.fn(); + const sendPluginWebviewInjections = vi.fn(); + const { handlers, getGraphData } = createExecutionHandlers({ + applyViewTransform: vi.fn(() => { + handlers.setGraphData(nextGraphData); + }), + sendGraphNodeMetricsUpdated, + sendPluginExporters, + sendPluginToolbarActions, + sendPluginWebviewInjections, + }); + handlers.setRawGraphData(currentGraphData); + handlers.setGraphData(currentGraphData); + vi.mocked(handlers.setRawGraphData).mockClear(); + vi.mocked(handlers.setGraphData).mockClear(); + + publishAnalyzedGraph(state, handlers, nextGraphData, true); + + expect(sendGraphNodeMetricsUpdated).toHaveBeenCalledOnce(); + expect(handlers.sendDepthState).not.toHaveBeenCalled(); + expect(handlers.sendPluginStatuses).not.toHaveBeenCalled(); + expect(handlers.sendDecorations).not.toHaveBeenCalled(); + expect(handlers.sendContextMenuItems).not.toHaveBeenCalled(); + expect(sendPluginExporters).not.toHaveBeenCalled(); + expect(sendPluginToolbarActions).not.toHaveBeenCalled(); + expect(handlers.sendGraphViewContributionStatuses).not.toHaveBeenCalled(); + expect(sendPluginWebviewInjections).not.toHaveBeenCalled(); + expect(handlers.sendGraphIndexStatusUpdated).toHaveBeenCalledWith( + true, + 'fresh', + 'CodeGraphy index is fresh.', + ); + expect(state.analyzer?.registry.notifyPostAnalyze).toHaveBeenCalledWith( + getGraphData(), + state.disabledPlugins, + ); + expect(handlers.markWorkspaceReady).toHaveBeenCalledWith( + getGraphData(), + state.disabledPlugins, + ); + }); + it('falls back to full graph publication when changed node metrics also change edges', () => { const currentGraphData: IGraphData = { nodes: [{ From f944363667469cd2f0d69660ee24ed8ded0f96a8 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 02:51:00 -0700 Subject: [PATCH 062/192] perf: defer metric metadata persistence --- .changeset/skip-metric-group-publish.md | 2 +- docs/performance/codegraphy-monorepo.md | 10 ++ packages/core/src/indexing/refresh.ts | 18 ++- .../pipeline/service/refreshFacade.ts | 4 + .../pipeline/service/runtime/refresh.test.ts | 116 ++++++++++++++++++ 5 files changed, 148 insertions(+), 2 deletions(-) diff --git a/.changeset/skip-metric-group-publish.md b/.changeset/skip-metric-group-publish.md index 0f5a0c0cf..9ba278efb 100644 --- a/.changeset/skip-metric-group-publish.md +++ b/.changeset/skip-metric-group-publish.md @@ -2,4 +2,4 @@ "@codegraphy-dev/extension": patch --- -Skip redundant legend group publication, deep graph reuse checks, full graph payloads, visible graph recomputation, pre-refresh legend broadcasts, incremental freshness scans, host-side graph rebuilds, and static graph-state broadcasts when a saved file only changes non-visual graph node metrics. Warm one representative cached source file after Graph Cache replay so the first edit avoids cold analyzer startup, and shorten normal saved-file refresh debounce while preserving file-operation coalescing. +Skip redundant legend group publication, deep graph reuse checks, full graph payloads, visible graph recomputation, pre-refresh legend broadcasts, incremental freshness scans, host-side graph rebuilds, static graph-state broadcasts, and blocking index metadata persistence when a saved file only changes non-visual graph node metrics. Warm one representative cached source file after Graph Cache replay so the first edit avoids cold analyzer startup, and shorten normal saved-file refresh debounce while preserving file-operation coalescing. diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index a492364cd..a0e34d5dd 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -946,6 +946,16 @@ Interpretation: index status. Incremental request duration measured `66ms` and `69ms`, down from the prior `83ms`-`87ms` samples, while strict wall clock stayed in the same `427ms`-`429ms` range because request-start delay remained `261ms`. +- Metric-only changed-file refreshes now return the graph patch before index + metadata persistence settles. Persistence still runs in the background and + logs failures, while structural refreshes keep waiting for metadata because + no compact correction is guaranteed. In two rebuilt monorepo probes, + `workspacePipeline.refreshChangedFiles.completed` fired at `44ms`-`58ms`, + then `persistIndexMetadata` finished afterward in `18ms`-`26ms`. Strict + live-update wall clock measured `420ms` and `448ms`, with incremental request + duration at `67ms` and `69ms`; request-start delay still dominated at + `266ms` and `263ms`, so the next visible-latency target remains the file + watcher/scheduler path before CodeGraphy's refresh begins. Full test baseline: diff --git a/packages/core/src/indexing/refresh.ts b/packages/core/src/indexing/refresh.ts index a43175fc3..6bf97e9d5 100644 --- a/packages/core/src/indexing/refresh.ts +++ b/packages/core/src/indexing/refresh.ts @@ -66,6 +66,7 @@ export interface WorkspaceIndexRefreshSource { } export interface WorkspaceIndexRefreshDependencies { + deferMetricOnlyIndexMetadata?: boolean; disabledPlugins: Set; discoveredDirectories?: string[]; discoveredFiles: IDiscoveredFile[]; @@ -78,6 +79,7 @@ export interface WorkspaceIndexRefreshDependencies { disabledPlugins?: Set, ): Promise<{ additionalFilePaths: string[]; requiresFullRefresh: boolean }>; onProgress?: (progress: { phase: string; current: number; total: number }) => void; + onDeferredIndexMetadataError?(error: unknown): void; persistCache(): void; persistIndexMetadata(): Promise; signal?: AbortSignal; @@ -276,6 +278,20 @@ function canPatchWorkspaceIndexRefreshGraphData( return true; } +function persistMetricOnlyIndexMetadata( + dependencies: WorkspaceIndexRefreshDependencies, +): Promise | void { + const persistence = dependencies.persistIndexMetadata(); + if (dependencies.deferMetricOnlyIndexMetadata) { + void persistence.catch(error => { + dependencies.onDeferredIndexMetadataError?.(error); + }); + return; + } + + return persistence; +} + function applyWorkspaceIndexAnalysisResult( source: WorkspaceIndexRefreshSource, analysisResult: IWorkspaceFileAnalysisResult, @@ -522,7 +538,7 @@ export async function refreshWorkspaceIndexChangedFiles( filesToAnalyze.map(file => file.relativePath), ); source._lastGraphData = graphData; - await dependencies.persistIndexMetadata(); + await persistMetricOnlyIndexMetadata(dependencies); return graphData; } diff --git a/packages/extension/src/extension/pipeline/service/refreshFacade.ts b/packages/extension/src/extension/pipeline/service/refreshFacade.ts index 1bdd60ddf..2eb17b676 100644 --- a/packages/extension/src/extension/pipeline/service/refreshFacade.ts +++ b/packages/extension/src/extension/pipeline/service/refreshFacade.ts @@ -536,6 +536,7 @@ export abstract class WorkspacePipelineRefreshFacade extends WorkspacePipelineDi }); const graphData = await refreshWorkspacePipelineChangedFiles(this._createTimedWorkspaceIndexRefreshSource(disabledPlugins), { + deferMetricOnlyIndexMetadata: true, disabledPlugins, discoveredDirectories: discoveryResult.directories, discoveredFiles: discoveryResult.files, @@ -562,6 +563,9 @@ export abstract class WorkspacePipelineRefreshFacade extends WorkspacePipelineDi }), ), onProgress, + onDeferredIndexMetadataError: error => { + console.warn('[CodeGraphy] Failed to persist metric-only refresh metadata.', error); + }, persistCache: () => { timeChangedFileRefreshPhaseSync('persistCache', () => { this._persistCache(); diff --git a/packages/extension/tests/extension/pipeline/service/runtime/refresh.test.ts b/packages/extension/tests/extension/pipeline/service/runtime/refresh.test.ts index 4dd09f923..8cb177067 100644 --- a/packages/extension/tests/extension/pipeline/service/runtime/refresh.test.ts +++ b/packages/extension/tests/extension/pipeline/service/runtime/refresh.test.ts @@ -250,6 +250,122 @@ describe('pipeline/service/refresh', () => { expect(dependencies.persistIndexMetadata).toHaveBeenCalledOnce(); }); + it('can return metric-only graph patches before index metadata persistence settles', async () => { + const source = createSource(); + const dependencies = createDependencies(); + const existingAnalysis: IFileAnalysisResult = { + filePath: '/workspace/src/a.ts', + relations: [{ + fromFilePath: '/workspace/src/a.ts', + kind: 'import', + resolvedPath: '/workspace/src/b.ts', + sourceId: 'src/a.ts', + toFilePath: '/workspace/src/b.ts', + }], + symbols: [{ + filePath: '/workspace/src/a.ts', + id: '/workspace/src/a.ts:function:run', + kind: 'function', + name: 'run', + }], + }; + source._lastFileAnalysis.set('src/a.ts', existingAnalysis); + source._lastFileConnections.set('src/a.ts', [{ kind: 'import' }]); + source._readAnalysisFiles.mockResolvedValue([ + { + absolutePath: '/workspace/src/a.ts', + relativePath: 'src/a.ts', + content: 'content:a', + }, + ]); + dependencies.notifyFilesChanged.mockResolvedValue({ + additionalFilePaths: [], + requiresFullRefresh: false, + }); + source._analyzeFiles.mockResolvedValue({ + cacheHits: 0, + cacheMisses: 1, + fileAnalysis: new Map([['src/a.ts', { ...existingAnalysis }]]), + fileConnections: new Map([['src/a.ts', [{ kind: 'import' }]]]), + }); + let resolvePersistIndexMetadata: (() => void) | undefined; + dependencies.persistIndexMetadata = vi.fn(() => new Promise(resolve => { + resolvePersistIndexMetadata = resolve; + })); + (dependencies as typeof dependencies & { deferMetricOnlyIndexMetadata: boolean }) + .deferMetricOnlyIndexMetadata = true; + + const graphPromise = refreshWorkspacePipelineChangedFiles(source as never, dependencies as never); + const result = await Promise.race([ + graphPromise.then(graph => ({ status: 'resolved' as const, graph })), + new Promise<{ status: 'pending' }>(resolve => { + setTimeout(() => resolve({ status: 'pending' }), 0); + }), + ]); + + expect(result.status).toBe('resolved'); + expect(dependencies.persistIndexMetadata).toHaveBeenCalledOnce(); + resolvePersistIndexMetadata?.(); + expect(await graphPromise).toEqual((result as { graph: IGraphData }).graph); + }); + + it('reports deferred metric-only index metadata persistence failures', async () => { + const source = createSource(); + const dependencies = createDependencies(); + const existingAnalysis: IFileAnalysisResult = { + filePath: '/workspace/src/a.ts', + relations: [{ + fromFilePath: '/workspace/src/a.ts', + kind: 'import', + resolvedPath: '/workspace/src/b.ts', + sourceId: 'src/a.ts', + toFilePath: '/workspace/src/b.ts', + }], + symbols: [{ + filePath: '/workspace/src/a.ts', + id: '/workspace/src/a.ts:function:run', + kind: 'function', + name: 'run', + }], + }; + source._lastFileAnalysis.set('src/a.ts', existingAnalysis); + source._lastFileConnections.set('src/a.ts', [{ kind: 'import' }]); + source._readAnalysisFiles.mockResolvedValue([ + { + absolutePath: '/workspace/src/a.ts', + relativePath: 'src/a.ts', + content: 'content:a', + }, + ]); + dependencies.notifyFilesChanged.mockResolvedValue({ + additionalFilePaths: [], + requiresFullRefresh: false, + }); + source._analyzeFiles.mockResolvedValue({ + cacheHits: 0, + cacheMisses: 1, + fileAnalysis: new Map([['src/a.ts', { ...existingAnalysis }]]), + fileConnections: new Map([['src/a.ts', [{ kind: 'import' }]]]), + }); + const persistenceError = new Error('persist failed'); + const onDeferredIndexMetadataError = vi.fn(); + dependencies.persistIndexMetadata = vi.fn(async () => { + throw persistenceError; + }); + (dependencies as typeof dependencies & { + deferMetricOnlyIndexMetadata: boolean; + onDeferredIndexMetadataError: (error: unknown) => void; + }).deferMetricOnlyIndexMetadata = true; + (dependencies as typeof dependencies & { + onDeferredIndexMetadataError: (error: unknown) => void; + }).onDeferredIndexMetadataError = onDeferredIndexMetadataError; + + await refreshWorkspacePipelineChangedFiles(source as never, dependencies as never); + await Promise.resolve(); + + expect(onDeferredIndexMetadataError).toHaveBeenCalledWith(persistenceError); + }); + it('supports refreshes without a progress callback', async () => { const source = createSource(); const dependencies = createDependencies(); From 4c32840b8654a9aa194bb0ce896d295340925b81 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 03:10:09 -0700 Subject: [PATCH 063/192] perf: dedupe editor-save refreshes --- .changeset/skip-metric-group-publish.md | 2 +- docs/performance/codegraphy-monorepo.md | 11 ++ .../graphView/webview/dispatch/primary.ts | 24 ++++ .../workspaceFiles/refresh/operations.ts | 49 ++++++- .../workspaceFiles/refresh/watchers.ts | 6 +- .../src/shared/protocol/webviewToExtension.ts | 1 + packages/extension/tests/__mocks__/vscode.ts | 7 + .../webview/dispatch/primary/dispatch.test.ts | 47 +++++++ .../workspaceFiles/refresh/watchers.test.ts | 40 ++++++ .../performance/measure-vscode-graph-view.mjs | 83 +++++++++++- .../measure-vscode-graph-view.test.mjs | 124 ++++++++++++++++++ 11 files changed, 386 insertions(+), 8 deletions(-) diff --git a/.changeset/skip-metric-group-publish.md b/.changeset/skip-metric-group-publish.md index 9ba278efb..a707ad32d 100644 --- a/.changeset/skip-metric-group-publish.md +++ b/.changeset/skip-metric-group-publish.md @@ -2,4 +2,4 @@ "@codegraphy-dev/extension": patch --- -Skip redundant legend group publication, deep graph reuse checks, full graph payloads, visible graph recomputation, pre-refresh legend broadcasts, incremental freshness scans, host-side graph rebuilds, static graph-state broadcasts, and blocking index metadata persistence when a saved file only changes non-visual graph node metrics. Warm one representative cached source file after Graph Cache replay so the first edit avoids cold analyzer startup, and shorten normal saved-file refresh debounce while preserving file-operation coalescing. +Skip redundant legend group publication, deep graph reuse checks, full graph payloads, visible graph recomputation, pre-refresh legend broadcasts, incremental freshness scans, host-side graph rebuilds, static graph-state broadcasts, and blocking index metadata persistence when a saved file only changes non-visual graph node metrics. Warm one representative cached source file after Graph Cache replay so the first edit avoids cold analyzer startup, shorten normal saved-file refresh debounce while preserving file-operation coalescing, and suppress duplicate filesystem watcher refreshes that follow VS Code editor saves. diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index a0e34d5dd..88b5db265 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -956,6 +956,17 @@ Interpretation: duration at `67ms` and `69ms`; request-start delay still dominated at `266ms` and `263ms`, so the next visible-latency target remains the file watcher/scheduler path before CodeGraphy's refresh begins. +- The VS Code live-update harness can now trigger the benchmark write through + an actual editor save using an acceptance-only webview message, so the + measured path includes `onDidSaveTextDocument` instead of only raw filesystem + watcher events. The first editor-save run exposed duplicate changed-file + analysis: the marker save produced two incremental requests (`97ms` and + `107ms`) before the restore request (`155ms`), with `555ms` end-to-end marker + wall time and `391ms` request-start delay. File watcher change events are now + suppressed for one second after the matching saved-document refresh, with + expired saved paths pruned before reuse. The rebuilt editor-save probe + produced one marker request (`89ms`) plus the restore request (`71ms`), + moving marker wall time to `494ms` and request-start delay to `350ms`. Full test baseline: diff --git a/packages/extension/src/extension/graphView/webview/dispatch/primary.ts b/packages/extension/src/extension/graphView/webview/dispatch/primary.ts index 37cdb02fa..99237ba01 100644 --- a/packages/extension/src/extension/graphView/webview/dispatch/primary.ts +++ b/packages/extension/src/extension/graphView/webview/dispatch/primary.ts @@ -110,10 +110,34 @@ export interface GraphViewPrimaryMessageResult { filterPatterns?: string[]; } +async function saveAcceptanceLiveUpdateFile(filePath: string): Promise { + const document = await vscode.workspace.openTextDocument(vscode.Uri.file(filePath)); + const editor = await vscode.window.showTextDocument(document, { + preserveFocus: true, + preview: false, + }); + await editor.edit(editBuilder => { + editBuilder.insert( + new vscode.Position(document.lineCount, 0), + `\n// CodeGraphy live update perf marker ${Date.now()}\n`, + ); + }); + await document.save(); +} + export async function dispatchGraphViewPrimaryMessage( message: WebviewToExtensionMessage, context: GraphViewPrimaryMessageContext, ): Promise { + if (message.type === 'PERF_SAVE_LIVE_UPDATE_FILE') { + if (process.env.CODEGRAPHY_ACCEPTANCE === '1') { + await saveAcceptanceLiveUpdateFile(message.payload.path); + return { handled: true }; + } + + return { handled: false }; + } + const routedResult = await dispatchGraphViewPrimaryRouteMessage(message, context); if (routedResult.handled) { return routedResult; diff --git a/packages/extension/src/extension/workspaceFiles/refresh/operations.ts b/packages/extension/src/extension/workspaceFiles/refresh/operations.ts index 83dcb1b6a..9690b351a 100644 --- a/packages/extension/src/extension/workspaceFiles/refresh/operations.ts +++ b/packages/extension/src/extension/workspaceFiles/refresh/operations.ts @@ -11,10 +11,24 @@ type WorkspaceFileEventName = 'workspace:fileCreated' | 'workspace:fileDeleted'; const WORKSPACE_CONTENT_CHANGE_REFRESH_DELAY_MS = 32; const WORKSPACE_FILE_OPERATION_REFRESH_DELAY_MS = 500; +const RECENT_SAVE_WATCHER_SUPPRESSION_MS = 1000; +const recentSavedDocumentPaths = new Map(); + +function normalizeFileWatcherPath(filePath: string): string { + return filePath.replace(/\\/g, '/'); +} + +function pruneRecentSavedDocumentPaths(now: number): void { + for (const [filePath, expiresAt] of recentSavedDocumentPaths) { + if (expiresAt < now) { + recentSavedDocumentPaths.delete(filePath); + } + } +} function isGitignorePath(filePath: string): boolean { - return filePath.replace(/\\/g, '/').endsWith('/.gitignore') - || filePath.replace(/\\/g, '/') === '.gitignore'; + const normalizedPath = normalizeFileWatcherPath(filePath); + return normalizedPath.endsWith('/.gitignore') || normalizedPath === '.gitignore'; } function includesGitignorePath(filePaths: readonly string[]): boolean { @@ -47,6 +61,12 @@ export function refreshWorkspaceSavedDocument( return; } + const now = Date.now(); + pruneRecentSavedDocumentPaths(now); + recentSavedDocumentPaths.set( + normalizeFileWatcherPath(document.uri.fsPath), + now + RECENT_SAVE_WATCHER_SUPPRESSION_MS, + ); refreshWorkspaceChangedPath( provider, '[CodeGraphy] File saved, refreshing graph', @@ -54,6 +74,31 @@ export function refreshWorkspaceSavedDocument( ); } +function consumeRecentSavedDocumentPath(filePath: string): boolean { + const now = Date.now(); + pruneRecentSavedDocumentPaths(now); + const normalizedPath = normalizeFileWatcherPath(filePath); + const expiresAt = recentSavedDocumentPaths.get(normalizedPath); + if (expiresAt === undefined) { + return false; + } + + recentSavedDocumentPaths.delete(normalizedPath); + return now <= expiresAt; +} + +export function refreshWorkspaceChangedFileWatcherPath( + provider: GraphViewProvider, + logMessage: string, + filePath: string, +): void { + if (consumeRecentSavedDocumentPath(filePath)) { + return; + } + + refreshWorkspaceChangedPath(provider, logMessage, filePath); +} + export function refreshWorkspaceChangedPath( provider: GraphViewProvider, logMessage: string, diff --git a/packages/extension/src/extension/workspaceFiles/refresh/watchers.ts b/packages/extension/src/extension/workspaceFiles/refresh/watchers.ts index f61a7f973..fb8468368 100644 --- a/packages/extension/src/extension/workspaceFiles/refresh/watchers.ts +++ b/packages/extension/src/extension/workspaceFiles/refresh/watchers.ts @@ -1,7 +1,7 @@ import * as vscode from 'vscode'; import type { GraphViewProvider } from '../../graphViewProvider'; import { - refreshWorkspaceChangedPath, + refreshWorkspaceChangedFileWatcherPath, refreshWorkspaceFileOperation, refreshWorkspaceRenameOperation, refreshWorkspaceSavedDocument, @@ -46,7 +46,7 @@ export function registerFileWatcher( ); context.subscriptions.push( fileWatcher.onDidChange((uri) => { - refreshWorkspaceChangedPath( + refreshWorkspaceChangedFileWatcherPath( provider, '[CodeGraphy] File changed, refreshing graph', uri.fsPath, @@ -75,7 +75,7 @@ export function registerFileWatcher( ); context.subscriptions.push( gitignoreWatcher.onDidChange((uri) => { - refreshWorkspaceChangedPath( + refreshWorkspaceChangedFileWatcherPath( provider, '[CodeGraphy] .gitignore changed, refreshing graph', uri.fsPath, diff --git a/packages/extension/src/shared/protocol/webviewToExtension.ts b/packages/extension/src/shared/protocol/webviewToExtension.ts index 9f9a753e4..37b2ea529 100644 --- a/packages/extension/src/shared/protocol/webviewToExtension.ts +++ b/packages/extension/src/shared/protocol/webviewToExtension.ts @@ -93,6 +93,7 @@ export type WebviewToExtensionMessage = | { type: 'JUMP_TO_COMMIT'; payload: { sha: string } } | { type: 'RESET_TIMELINE' } | { type: 'PREVIEW_FILE_AT_COMMIT'; payload: { sha: string; filePath: string } } + | { type: 'PERF_SAVE_LIVE_UPDATE_FILE'; payload: { path: string } } | { type: 'NODE_BOUNDS_RESPONSE'; payload: { nodes: Array<{ id: string; x: number; y: number; size: number }> }; diff --git a/packages/extension/tests/__mocks__/vscode.ts b/packages/extension/tests/__mocks__/vscode.ts index c40ab6ff5..0ee7a620c 100644 --- a/packages/extension/tests/__mocks__/vscode.ts +++ b/packages/extension/tests/__mocks__/vscode.ts @@ -21,6 +21,13 @@ export const commands = { executeCommand: vi.fn(), }; +export class Position { + constructor( + public readonly line: number, + public readonly character: number, + ) {} +} + export const workspace = { getConfiguration: vi.fn(() => ({ get: vi.fn(), diff --git a/packages/extension/tests/extension/graphView/webview/dispatch/primary/dispatch.test.ts b/packages/extension/tests/extension/graphView/webview/dispatch/primary/dispatch.test.ts index c7aa378c8..03be266e3 100644 --- a/packages/extension/tests/extension/graphView/webview/dispatch/primary/dispatch.test.ts +++ b/packages/extension/tests/extension/graphView/webview/dispatch/primary/dispatch.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as vscode from 'vscode'; import type { GraphViewPrimaryMessageResult } from '../../../../../../src/extension/graphView/webview/dispatch/primary'; import { dispatchGraphViewPrimaryMessage } from '../../../../../../src/extension/graphView/webview/dispatch/primary'; import { createPrimaryMessageContext } from '../context'; @@ -21,6 +22,7 @@ describe('graph view primary message dispatch', () => { vi.clearAllMocks(); primaryDispatchMocks.route.mockReset(); primaryDispatchMocks.stateful.mockReset(); + delete process.env.CODEGRAPHY_ACCEPTANCE; }); it('returns the routed result when the routed handlers handle the message', async () => { @@ -62,4 +64,49 @@ describe('graph view primary message dispatch', () => { context, ); }); + + it('saves the requested file through VS Code when the acceptance live-update perf message is enabled', async () => { + process.env.CODEGRAPHY_ACCEPTANCE = '1'; + const context = createPrimaryMessageContext(); + const insert = vi.fn(); + const edit = vi.fn(async (callback: (builder: { insert: typeof insert }) => void) => { + callback({ insert }); + return true; + }); + const save = vi.fn(async () => true); + const document = { + lineCount: 7, + save, + }; + const editor = { edit }; + (vscode.workspace as unknown as { openTextDocument: ReturnType }) + .openTextDocument = vi.fn(async () => document); + (vscode.window as unknown as { showTextDocument: ReturnType }) + .showTextDocument = vi.fn(async () => editor); + + await expect( + dispatchGraphViewPrimaryMessage( + { + type: 'PERF_SAVE_LIVE_UPDATE_FILE', + payload: { path: '/workspace/src/app.ts' }, + }, + context, + ), + ).resolves.toEqual({ handled: true }); + + expect(vscode.workspace.openTextDocument).toHaveBeenCalledWith( + vscode.Uri.file('/workspace/src/app.ts'), + ); + expect(vscode.window.showTextDocument).toHaveBeenCalledWith(document, { + preserveFocus: true, + preview: false, + }); + expect(insert).toHaveBeenCalledWith( + new vscode.Position(7, 0), + expect.stringContaining('CodeGraphy live update perf marker'), + ); + expect(save).toHaveBeenCalledOnce(); + expect(primaryDispatchMocks.route).not.toHaveBeenCalled(); + expect(primaryDispatchMocks.stateful).not.toHaveBeenCalled(); + }); }); diff --git a/packages/extension/tests/extension/workspaceFiles/refresh/watchers.test.ts b/packages/extension/tests/extension/workspaceFiles/refresh/watchers.test.ts index 5f77f2dfe..1efbf48a2 100644 --- a/packages/extension/tests/extension/workspaceFiles/refresh/watchers.test.ts +++ b/packages/extension/tests/extension/workspaceFiles/refresh/watchers.test.ts @@ -194,6 +194,46 @@ describe('workspaceFiles/refresh/watchers', () => { }); }); + it('suppresses file-system change duplicates after saved document refreshes', () => { + vi.useFakeTimers(); + const context = makeContext(); + const provider = makeProvider(); + const triggerSave = captureSaveListener(); + + registerSaveHandler(context as unknown as vscode.ExtensionContext, provider as never); + registerFileWatcher(context as unknown as vscode.ExtensionContext, provider as never); + triggerSave({ uri: uri('/workspace/src/app.ts') } as vscode.TextDocument); + vi.advanceTimersByTime(32); + watcherListeners.change?.(uri('/workspace/src/app.ts')); + vi.advanceTimersByTime(500); + + expect(provider.refresh).toHaveBeenCalledOnce(); + expect(provider.emitEvent).toHaveBeenCalledOnce(); + expect(provider.emitEvent).toHaveBeenCalledWith('workspace:fileChanged', { + filePath: '/workspace/src/app.ts', + }); + }); + + it('allows file-system changes after saved document suppression expires', () => { + vi.useFakeTimers(); + const context = makeContext(); + const provider = makeProvider(); + const triggerSave = captureSaveListener(); + + registerSaveHandler(context as unknown as vscode.ExtensionContext, provider as never); + registerFileWatcher(context as unknown as vscode.ExtensionContext, provider as never); + triggerSave({ uri: uri('/workspace/src/app.ts') } as vscode.TextDocument); + vi.advanceTimersByTime(1001); + watcherListeners.change?.(uri('/workspace/src/app.ts')); + vi.advanceTimersByTime(500); + + expect(provider.refresh).toHaveBeenCalledTimes(2); + expect(provider.emitEvent).toHaveBeenCalledTimes(2); + expect(provider.emitEvent).toHaveBeenLastCalledWith('workspace:fileChanged', { + filePath: '/workspace/src/app.ts', + }); + }); + it('wires file-system create and delete watchers to workspace events', () => { vi.useFakeTimers(); const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); diff --git a/scripts/performance/measure-vscode-graph-view.mjs b/scripts/performance/measure-vscode-graph-view.mjs index e1d41e85f..4b816aca3 100644 --- a/scripts/performance/measure-vscode-graph-view.mjs +++ b/scripts/performance/measure-vscode-graph-view.mjs @@ -16,6 +16,8 @@ const IMPORTS_TOGGLE_START_EVENT = 'graphScope.edgeVisibility.optimistic'; const IMPORTS_TOGGLE_RENDERED_EVENT = 'graphStats.rendered'; const ANALYZE_REQUEST_MODE = 'analyze'; const LIVE_UPDATE_REQUEST_MODE = 'incremental'; +const LIVE_UPDATE_TRIGGER_FILESYSTEM = 'filesystem'; +const LIVE_UPDATE_TRIGGER_EDITOR_SAVE = 'editor-save'; const GRAPH_UPDATE_MESSAGE_TYPES = new Set([ 'GRAPH_DATA_UPDATED', 'GRAPH_NODE_METRICS_UPDATED', @@ -48,6 +50,21 @@ function toPositiveInteger(value, defaultValue) { return Number.isInteger(parsed) && parsed > 0 ? parsed : defaultValue; } +function parseLiveUpdateTrigger(value) { + if (!value) { + return LIVE_UPDATE_TRIGGER_FILESYSTEM; + } + + if ( + value === LIVE_UPDATE_TRIGGER_FILESYSTEM + || value === LIVE_UPDATE_TRIGGER_EDITOR_SAVE + ) { + return value; + } + + throw new Error(`Unsupported live update trigger: ${value}`); +} + function parseCount(value) { return Number(value.replace(/,/g, '')); } @@ -471,6 +488,50 @@ async function waitForWebviewMessageReceived(frame, type, minimumCount = 0, time throw new Error(`Timed out waiting for webview message: ${type}`); } +export async function saveLiveUpdateFileThroughEditor({ + absoluteFilePath, + frame, +}) { + if (!frame) { + throw new Error('The editor-save live update trigger requires a graph webview frame.'); + } + + await frame.evaluate((filePath) => { + const vscode = window.vscode; + if (!vscode) { + throw new Error('Graph webview did not expose the VS Code API.'); + } + + vscode.postMessage({ + type: 'PERF_SAVE_LIVE_UPDATE_FILE', + payload: { path: filePath }, + }); + }, absoluteFilePath); +} + +async function writeLiveUpdateMarker({ + absoluteFilePath, + frame, + liveUpdateTrigger, + marker, + originalContent, + page, + saveFileThroughEditor, +}) { + if (liveUpdateTrigger === LIVE_UPDATE_TRIGGER_EDITOR_SAVE) { + await saveFileThroughEditor({ + absoluteFilePath, + frame, + marker, + originalContent, + page, + }); + return; + } + + await writeFile(absoluteFilePath, `${originalContent}${marker}`); +} + async function waitForExtensionHostPerformanceEvent(logPath, predicate, timeoutMs = DEFAULT_TIMEOUT_MS) { const startedAt = performance.now(); @@ -572,8 +633,12 @@ export async function measureLiveUpdateTransition({ extensionHostLogPath, frame, liveUpdateFilePath, + liveUpdateTrigger = LIVE_UPDATE_TRIGGER_FILESYSTEM, + page, + saveFileThroughEditor = saveLiveUpdateFileThroughEditor, workspaceRoot, }) { + const normalizedLiveUpdateTrigger = parseLiveUpdateTrigger(liveUpdateTrigger); const absoluteFilePath = path.isAbsolute(liveUpdateFilePath) ? liveUpdateFilePath : path.join(workspaceRoot, liveUpdateFilePath); @@ -589,7 +654,15 @@ export async function measureLiveUpdateTransition({ let updateRequestCompletedAt; try { - await writeFile(absoluteFilePath, `${originalContent}${marker}`); + await writeLiveUpdateMarker({ + absoluteFilePath, + frame, + liveUpdateTrigger: normalizedLiveUpdateTrigger, + marker, + originalContent, + page, + saveFileThroughEditor, + }); markerWritten = true; const requestEvent = await waitForExtensionHostPerformanceEvent( extensionHostLogPath, @@ -613,6 +686,7 @@ export async function measureLiveUpdateTransition({ requestStartDelayMs, requestCompletionDelayMs, requestOffsetMs: requestEvent.offsetMs, + trigger: normalizedLiveUpdateTrigger, webviewEvents: await readWebviewPerformanceEvents(frame), }; } finally { @@ -651,6 +725,7 @@ async function restoreWorkspaceSettings(settingsPath, originalSettings) { async function measureVSCodeGraphView({ iterations, liveUpdateFilePath, + liveUpdateTrigger, outputPath, warmupIterations, workspacePath, @@ -753,6 +828,8 @@ async function measureVSCodeGraphView({ extensionHostLogPath, frame, liveUpdateFilePath, + liveUpdateTrigger, + page: vscode.page, workspaceRoot, })); } @@ -778,7 +855,7 @@ async function measureVSCodeGraphView({ function printUsage() { process.stdout.write([ 'Usage:', - ' pnpm exec tsx scripts/performance/measure-vscode-graph-view.mjs [--workspace ] [--iterations ] [--warmup ] [--live-update-file ] [--output ]', + ' pnpm exec tsx scripts/performance/measure-vscode-graph-view.mjs [--workspace ] [--iterations ] [--warmup ] [--live-update-file ] [--live-update-trigger filesystem|editor-save] [--output ]', '', 'Launches Extension Development Host, opens CodeGraphy, and times rendered Graph Scope toggle latency.', ].join('\n')); @@ -795,10 +872,12 @@ async function runCli(argv) { const iterations = toPositiveInteger(readOptionValue(argv, '--iterations'), DEFAULT_ITERATIONS); const warmupIterations = toPositiveInteger(readOptionValue(argv, '--warmup'), DEFAULT_WARMUP_ITERATIONS); const liveUpdateFilePath = readOptionValue(argv, '--live-update-file'); + const liveUpdateTrigger = parseLiveUpdateTrigger(readOptionValue(argv, '--live-update-trigger')); await measureVSCodeGraphView({ iterations, liveUpdateFilePath, + liveUpdateTrigger, outputPath, warmupIterations, workspacePath, diff --git a/tests/scripts/measure-vscode-graph-view.test.mjs b/tests/scripts/measure-vscode-graph-view.test.mjs index 3da7fce51..2fadacf87 100644 --- a/tests/scripts/measure-vscode-graph-view.test.mjs +++ b/tests/scripts/measure-vscode-graph-view.test.mjs @@ -619,6 +619,130 @@ test('VS Code graph view runner waits for the live-update graph message in the w } }); +test('VS Code graph view runner can trigger live updates through editor save', async (t) => { + const moduleUrl = pathToFileURL( + path.resolve('scripts/performance/measure-vscode-graph-view.mjs'), + ).href; + const { measureLiveUpdateTransition } = await import(moduleUrl); + const workspaceRoot = await mkdtemp(path.join(os.tmpdir(), 'codegraphy-live-update-editor-')); + t.after(() => rm(workspaceRoot, { recursive: true, force: true })); + + const liveUpdateFilePath = 'src/example.ts'; + const absoluteFilePath = path.join(workspaceRoot, liveUpdateFilePath); + const extensionHostLogPath = path.join(workspaceRoot, 'extension-host.jsonl'); + const originalContent = 'export const value = 1;\n'; + await mkdir(path.dirname(absoluteFilePath), { recursive: true }); + await writeFile(absoluteFilePath, originalContent); + await writeFile(extensionHostLogPath, ''); + + let stopped = false; + let editorSaveTriggered = false; + let markerRequestRecorded = false; + let restoreRequestCompleted = false; + const frame = { + evaluate: async (callback) => { + if (String(callback).includes('__codegraphyPerformance?.events')) { + return []; + } + return undefined; + }, + waitForTimeout: async ms => new Promise(resolve => setTimeout(resolve, ms)), + }; + + async function saveFileThroughEditor({ absoluteFilePath: targetPath, marker, originalContent: content }) { + editorSaveTriggered = true; + await writeFile(targetPath, `${content}${marker}`); + } + + async function appendIncrementalRequest(requestId) { + const startedAt = Date.now(); + await writeFile(extensionHostLogPath, [ + JSON.stringify({ + name: 'graphAnalysis.request.start', + at: startedAt, + detail: { requestId, mode: 'incremental' }, + }), + JSON.stringify({ + name: 'graphAnalysis.request.completed', + at: startedAt + 3, + detail: { requestId, mode: 'incremental', durationMs: 3 }, + }), + '', + ].join('\n'), { flag: 'a' }); + } + + const observer = (async () => { + while (!stopped) { + const content = await readFile(absoluteFilePath, 'utf8'); + if (!markerRequestRecorded && content.includes('CodeGraphy live update perf marker')) { + markerRequestRecorded = true; + await appendIncrementalRequest(1); + } else if ( + markerRequestRecorded + && !restoreRequestCompleted + && content === originalContent + ) { + await appendIncrementalRequest(2); + restoreRequestCompleted = true; + } + + await new Promise(resolve => setTimeout(resolve, 5)); + } + })(); + + try { + const sample = await measureLiveUpdateTransition({ + extensionHostLogPath, + frame, + liveUpdateFilePath, + liveUpdateTrigger: 'editor-save', + saveFileThroughEditor, + workspaceRoot, + }); + + assert.equal(sample.filePath, liveUpdateFilePath); + assert.equal(sample.trigger, 'editor-save'); + assert.equal(editorSaveTriggered, true); + assert.equal(restoreRequestCompleted, true); + } finally { + stopped = true; + await observer; + } +}); + +test('VS Code graph view runner asks the webview to trigger editor-save live updates', async () => { + const moduleUrl = pathToFileURL( + path.resolve('scripts/performance/measure-vscode-graph-view.mjs'), + ).href; + const { saveLiveUpdateFileThroughEditor } = await import(moduleUrl); + const messages = []; + const frame = { + evaluate: async (callback, filePath) => { + const previousWindow = globalThis.window; + globalThis.window = { + vscode: { + postMessage: message => messages.push(message), + }, + }; + try { + return callback(filePath); + } finally { + globalThis.window = previousWindow; + } + }, + }; + + await saveLiveUpdateFileThroughEditor({ + absoluteFilePath: '/workspace/src/app.ts', + frame, + }); + + assert.deepEqual(messages, [{ + type: 'PERF_SAVE_LIVE_UPDATE_FILE', + payload: { path: '/workspace/src/app.ts' }, + }]); +}); + test('VS Code graph view runner waits for active analyze requests before live-update markers', async (t) => { const moduleUrl = pathToFileURL( path.resolve('scripts/performance/measure-vscode-graph-view.mjs'), From 281d7da1e27b392287e2156329f403f4f5577c34 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 03:17:04 -0700 Subject: [PATCH 064/192] docs: record saved debounce experiment --- docs/performance/codegraphy-monorepo.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index 88b5db265..c00e5eab7 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -967,6 +967,17 @@ Interpretation: expired saved paths pruned before reuse. The rebuilt editor-save probe produced one marker request (`89ms`) plus the restore request (`71ms`), moving marker wall time to `494ms` and request-start delay to `350ms`. +- Rejected zero-delay saved-document debounce experiment: + - Reducing saved-document refresh scheduling from `32ms` to `0ms` measured + request-start delays of `318ms`, `322ms`, and `343ms` across three + one-sample editor-save probes, compared with the prior `350ms` editor-save + dedupe sample. Request duration stayed small in the clean samples, but + end-to-end wall time remained noisy at a `547ms` median. + - The production change was backed out because existing focused tests caught + regressions in rapid-save and save-plus-create coalescing. Preserving the + `25ms` coalescing contract is worth more than the small measured + pre-request gain. The next target remains the pre-request save/event- + delivery path rather than backend graph analysis. Full test baseline: From 008b4862bc0c672f5b5385b0790109ff41d50358 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 03:26:58 -0700 Subject: [PATCH 065/192] perf: skip indexed refresh settings reload --- .changeset/skip-metric-group-publish.md | 2 +- docs/performance/codegraphy-monorepo.md | 16 +++++ .../extension/graphView/provider/refresh.ts | 13 +++- .../graphView/webview/dispatch/primary.ts | 69 ++++++++++++++++--- .../workspaceFiles/refresh/operations.ts | 4 ++ .../workspaceFiles/refresh/scheduler.ts | 21 ++++++ .../graphView/provider/refresh.test.ts | 17 +++++ .../webview/dispatch/primary/dispatch.test.ts | 23 +++++++ .../workspaceFiles/refresh/scheduler.test.ts | 44 ++++++++++++ .../workspaceFiles/refresh/watchers.test.ts | 13 ++++ 10 files changed, 209 insertions(+), 13 deletions(-) diff --git a/.changeset/skip-metric-group-publish.md b/.changeset/skip-metric-group-publish.md index a707ad32d..62b9a0084 100644 --- a/.changeset/skip-metric-group-publish.md +++ b/.changeset/skip-metric-group-publish.md @@ -2,4 +2,4 @@ "@codegraphy-dev/extension": patch --- -Skip redundant legend group publication, deep graph reuse checks, full graph payloads, visible graph recomputation, pre-refresh legend broadcasts, incremental freshness scans, host-side graph rebuilds, static graph-state broadcasts, and blocking index metadata persistence when a saved file only changes non-visual graph node metrics. Warm one representative cached source file after Graph Cache replay so the first edit avoids cold analyzer startup, shorten normal saved-file refresh debounce while preserving file-operation coalescing, and suppress duplicate filesystem watcher refreshes that follow VS Code editor saves. +Skip redundant legend group publication, deep graph reuse checks, full graph payloads, visible graph recomputation, pre-refresh legend broadcasts, incremental freshness scans, host-side graph rebuilds, static graph-state broadcasts, blocking index metadata persistence, and repeated filter/group reloads when a saved file only changes non-visual graph node metrics. Warm one representative cached source file after Graph Cache replay so the first edit avoids cold analyzer startup, shorten normal saved-file refresh debounce while preserving file-operation coalescing, and suppress duplicate filesystem watcher refreshes that follow VS Code editor saves. diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index c00e5eab7..2e32e4eba 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -978,6 +978,22 @@ Interpretation: `25ms` coalescing contract is worth more than the small measured pre-request gain. The next target remains the pre-request save/event- delivery path rather than backend graph analysis. +- Extension-host save-path instrumentation now marks acceptance save stages, + saved-document receipt, workspace refresh scheduling/start, and provider + `refreshChangedFiles` entry. The first instrumented editor-save sample + measured `555ms` wall clock, `92ms` request duration, and `367ms` + request-start delay. The split showed `252ms` inside the acceptance save + helper, the intentional `32ms` saved-file debounce, and `78ms` between + provider `refreshChangedFiles` entry and `graphAnalysis.request.start`. +- Indexed incremental changed-file refreshes now reuse the already-loaded + filter/group state instead of reloading groups and filter patterns before + every save. Fallback changed-file paths that need a primary/full refresh + still prepare settings before running. In the rebuilt editor-save probe, the + provider-entry-to-request gap moved from `78ms` to `2ms`, incremental request + duration moved from `97ms` to `76ms`, request-start delay moved from `367ms` + to `282ms`, and wall time moved from `559ms` to `460ms`. First graph + readiness stayed in the same band (`5272ms`) and Imports toggle stayed + snappy (`217ms` wall-clock / `62ms` in-webview). Full test baseline: diff --git a/packages/extension/src/extension/graphView/provider/refresh.ts b/packages/extension/src/extension/graphView/provider/refresh.ts index 185dbba90..92a311eca 100644 --- a/packages/extension/src/extension/graphView/provider/refresh.ts +++ b/packages/extension/src/extension/graphView/provider/refresh.ts @@ -1,5 +1,6 @@ import type { IGraphData } from '../../../shared/graph/contracts'; import type { ExtensionToWebviewMessage } from '../../../shared/protocol/extensionToWebview'; +import { recordExtensionPerformanceEvent } from '../../performance/marks'; import { getCodeGraphyConfiguration } from '../../repoSettings/current'; import { createGraphViewIndexProgressCoalescer } from '../analysis/execution/progress'; import { rebuildGraphViewData, smartRebuildGraphView } from '../view/rebuild'; @@ -209,6 +210,10 @@ function prepareRefreshInputs(source: GraphViewProviderRefreshMethodsSource): vo source._loadGroupsAndFilterPatterns(); } +function canRunIndexedChangedFileRefresh(source: GraphViewProviderRefreshMethodsSource): boolean { + return source._analyzer?.hasIndex() === true && source._incrementalAnalyzeAndSendData !== undefined; +} + function createRefreshMethod( source: GraphViewProviderRefreshMethodsSource, state: RefreshCoordinatorState, @@ -367,6 +372,10 @@ function createRefreshChangedFilesMethod( state: RefreshCoordinatorState, ): (filePaths: readonly string[]) => Promise { return async (filePaths: readonly string[]): Promise => { + recordExtensionPerformanceEvent('graphView.refreshChangedFiles.received', { + fileCount: filePaths.length, + indexRefreshInFlight: state.indexRefreshPromise !== undefined, + }); if (state.indexRefreshPromise) { state.queuedChangedFilePaths = new Set([ ...state.queuedChangedFilePaths, @@ -375,7 +384,9 @@ function createRefreshChangedFilesMethod( return; } - prepareRefreshInputs(source); + if (!canRunIndexedChangedFileRefresh(source)) { + prepareRefreshInputs(source); + } const refreshMode = await runChangedFileRefresh(source, filePaths); if (refreshMode !== 'incremental') { sendRefreshState(source, 'changedFiles'); diff --git a/packages/extension/src/extension/graphView/webview/dispatch/primary.ts b/packages/extension/src/extension/graphView/webview/dispatch/primary.ts index 99237ba01..94d1615d9 100644 --- a/packages/extension/src/extension/graphView/webview/dispatch/primary.ts +++ b/packages/extension/src/extension/graphView/webview/dispatch/primary.ts @@ -8,6 +8,7 @@ import type { IPhysicsSettings } from '../../../../shared/settings/physics'; import type { IViewContext } from '../../../../core/views/contracts'; import type { IFileAnalysisResult } from '../../../../core/plugins/types/contracts'; import type { WorkspaceAnalysisDatabaseSnapshot } from '../../../pipeline/database/cache/storage'; +import { recordExtensionPerformanceEvent } from '../../../performance/marks'; import { dispatchGraphViewPrimaryRouteMessage } from './routed'; import { dispatchGraphViewPrimaryStateMessage } from './stateful'; @@ -110,19 +111,65 @@ export interface GraphViewPrimaryMessageResult { filterPatterns?: string[]; } -async function saveAcceptanceLiveUpdateFile(filePath: string): Promise { - const document = await vscode.workspace.openTextDocument(vscode.Uri.file(filePath)); - const editor = await vscode.window.showTextDocument(document, { - preserveFocus: true, - preview: false, +function recordAcceptanceLiveUpdateSaveStage( + stage: string, + detail: Record, +): void { + recordExtensionPerformanceEvent(`graphWebview.acceptanceLiveUpdateSave.${stage}`, detail); +} + +async function runAcceptanceLiveUpdateSaveStage( + stage: string, + filePath: string, + action: () => PromiseLike, +): Promise { + const startedAt = Date.now(); + const result = await action(); + recordAcceptanceLiveUpdateSaveStage(stage, { + durationMs: Date.now() - startedAt, + filePath, }); - await editor.edit(editBuilder => { - editBuilder.insert( - new vscode.Position(document.lineCount, 0), - `\n// CodeGraphy live update perf marker ${Date.now()}\n`, + return result; +} + +async function saveAcceptanceLiveUpdateFile(filePath: string): Promise { + const startedAt = Date.now(); + recordAcceptanceLiveUpdateSaveStage('start', { filePath }); + try { + const document = await runAcceptanceLiveUpdateSaveStage( + 'openDocument', + filePath, + () => vscode.workspace.openTextDocument(vscode.Uri.file(filePath)), ); - }); - await document.save(); + const editor = await runAcceptanceLiveUpdateSaveStage( + 'showDocument', + filePath, + () => vscode.window.showTextDocument(document, { + preserveFocus: true, + preview: false, + }), + ); + await runAcceptanceLiveUpdateSaveStage('edit', filePath, () => + editor.edit(editBuilder => { + editBuilder.insert( + new vscode.Position(document.lineCount, 0), + `\n// CodeGraphy live update perf marker ${Date.now()}\n`, + ); + }), + ); + await runAcceptanceLiveUpdateSaveStage('save', filePath, () => document.save()); + recordAcceptanceLiveUpdateSaveStage('completed', { + durationMs: Date.now() - startedAt, + filePath, + }); + } catch (error) { + recordAcceptanceLiveUpdateSaveStage('failed', { + durationMs: Date.now() - startedAt, + filePath, + message: error instanceof Error ? error.message : String(error), + }); + throw error; + } } export async function dispatchGraphViewPrimaryMessage( diff --git a/packages/extension/src/extension/workspaceFiles/refresh/operations.ts b/packages/extension/src/extension/workspaceFiles/refresh/operations.ts index 9690b351a..cd0748428 100644 --- a/packages/extension/src/extension/workspaceFiles/refresh/operations.ts +++ b/packages/extension/src/extension/workspaceFiles/refresh/operations.ts @@ -4,6 +4,7 @@ import { shouldIgnoreSaveForGraphRefresh, shouldIgnoreWorkspaceFileWatcherRefresh, } from '../ignore'; +import { recordExtensionPerformanceEvent } from '../../performance/marks'; import { scheduleWorkspaceRefresh } from './scheduler'; type WorkspaceRenameFiles = vscode.FileRenameEvent['files']; @@ -62,6 +63,9 @@ export function refreshWorkspaceSavedDocument( } const now = Date.now(); + recordExtensionPerformanceEvent('workspaceFiles.savedDocument.received', { + filePath: document.uri.fsPath, + }); pruneRecentSavedDocumentPaths(now); recentSavedDocumentPaths.set( normalizeFileWatcherPath(document.uri.fsPath), diff --git a/packages/extension/src/extension/workspaceFiles/refresh/scheduler.ts b/packages/extension/src/extension/workspaceFiles/refresh/scheduler.ts index 41b3a9893..34b97332b 100644 --- a/packages/extension/src/extension/workspaceFiles/refresh/scheduler.ts +++ b/packages/extension/src/extension/workspaceFiles/refresh/scheduler.ts @@ -1,4 +1,5 @@ import type { GraphViewProvider } from '../../graphViewProvider'; +import { recordExtensionPerformanceEvent } from '../../performance/marks'; interface PendingWorkspaceRefresh { filePaths: Set; @@ -28,6 +29,18 @@ function markWorkspaceRefreshPending( provider.markWorkspaceRefreshPending?.(logMessage, filePaths, options); } +function createRefreshPerformanceDetail( + pending: Pick, + delayMs: number, +): Record { + return { + delayMs, + fileCount: pending.filePaths.size, + fullRefresh: pending.fullRefresh, + gitignoreRefresh: pending.gitignoreRefresh, + }; +} + export function scheduleWorkspaceRefresh( provider: GraphViewProvider, logMessage: string, @@ -62,6 +75,10 @@ export function scheduleWorkspaceRefresh( gitignoreRefresh, logMessage, timeout: setTimeout(() => { + recordExtensionPerformanceEvent( + 'workspaceRefresh.started', + createRefreshPerformanceDetail(nextPending, delayMs), + ); pendingWorkspaceRefreshes.delete(provider); if (!isGraphOpen(provider)) { markWorkspaceRefreshPending( @@ -104,5 +121,9 @@ export function scheduleWorkspaceRefresh( }, delayMs), }; + recordExtensionPerformanceEvent( + 'workspaceRefresh.scheduled', + createRefreshPerformanceDetail(nextPending, delayMs), + ); pendingWorkspaceRefreshes.set(provider, nextPending); } diff --git a/packages/extension/tests/extension/graphView/provider/refresh.test.ts b/packages/extension/tests/extension/graphView/provider/refresh.test.ts index de6c30a1c..a88d263e7 100644 --- a/packages/extension/tests/extension/graphView/provider/refresh.test.ts +++ b/packages/extension/tests/extension/graphView/provider/refresh.test.ts @@ -2,6 +2,14 @@ import { describe, expect, it, vi } from 'vitest'; import { createGraphViewProviderRefreshMethods } from '../../../../src/extension/graphView/provider/refresh'; import { createSource } from './refresh/fixture'; +const performanceMocks = vi.hoisted(() => ({ + record: vi.fn(), +})); + +vi.mock('../../../../src/extension/performance/marks', () => ({ + recordExtensionPerformanceEvent: performanceMocks.record, +})); + describe('graphView/provider/refresh', () => { describe('refresh', () => { @@ -161,6 +169,15 @@ describe('graphView/provider/refresh', () => { await methods.refreshChangedFiles(['src/example.ts']); + expect(performanceMocks.record).toHaveBeenCalledWith( + 'graphView.refreshChangedFiles.received', + { + fileCount: 1, + indexRefreshInFlight: false, + }, + ); + expect(source._loadDisabledRulesAndPlugins).not.toHaveBeenCalled(); + expect(source._loadGroupsAndFilterPatterns).not.toHaveBeenCalled(); expect(source._incrementalAnalyzeAndSendData).toHaveBeenCalledWith(['src/example.ts']); expect(source._sendAllSettings).not.toHaveBeenCalled(); expect(source._sendGraphControls).not.toHaveBeenCalled(); diff --git a/packages/extension/tests/extension/graphView/webview/dispatch/primary/dispatch.test.ts b/packages/extension/tests/extension/graphView/webview/dispatch/primary/dispatch.test.ts index 03be266e3..f0c9c21b8 100644 --- a/packages/extension/tests/extension/graphView/webview/dispatch/primary/dispatch.test.ts +++ b/packages/extension/tests/extension/graphView/webview/dispatch/primary/dispatch.test.ts @@ -8,6 +8,9 @@ const primaryDispatchMocks = vi.hoisted(() => ({ route: vi.fn(), stateful: vi.fn(), })); +const performanceMocks = vi.hoisted(() => ({ + record: vi.fn(), +})); vi.mock('../../../../../../src/extension/graphView/webview/dispatch/routed', () => ({ dispatchGraphViewPrimaryRouteMessage: primaryDispatchMocks.route, @@ -17,11 +20,16 @@ vi.mock('../../../../../../src/extension/graphView/webview/dispatch/stateful', ( dispatchGraphViewPrimaryStateMessage: primaryDispatchMocks.stateful, })); +vi.mock('../../../../../../src/extension/performance/marks', () => ({ + recordExtensionPerformanceEvent: performanceMocks.record, +})); + describe('graph view primary message dispatch', () => { beforeEach(() => { vi.clearAllMocks(); primaryDispatchMocks.route.mockReset(); primaryDispatchMocks.stateful.mockReset(); + performanceMocks.record.mockReset(); delete process.env.CODEGRAPHY_ACCEPTANCE; }); @@ -106,6 +114,21 @@ describe('graph view primary message dispatch', () => { expect.stringContaining('CodeGraphy live update perf marker'), ); expect(save).toHaveBeenCalledOnce(); + expect(performanceMocks.record.mock.calls.map(([name]) => name)).toEqual([ + 'graphWebview.acceptanceLiveUpdateSave.start', + 'graphWebview.acceptanceLiveUpdateSave.openDocument', + 'graphWebview.acceptanceLiveUpdateSave.showDocument', + 'graphWebview.acceptanceLiveUpdateSave.edit', + 'graphWebview.acceptanceLiveUpdateSave.save', + 'graphWebview.acceptanceLiveUpdateSave.completed', + ]); + expect(performanceMocks.record).toHaveBeenCalledWith( + 'graphWebview.acceptanceLiveUpdateSave.completed', + expect.objectContaining({ + durationMs: expect.any(Number), + filePath: '/workspace/src/app.ts', + }), + ); expect(primaryDispatchMocks.route).not.toHaveBeenCalled(); expect(primaryDispatchMocks.stateful).not.toHaveBeenCalled(); }); diff --git a/packages/extension/tests/extension/workspaceFiles/refresh/scheduler.test.ts b/packages/extension/tests/extension/workspaceFiles/refresh/scheduler.test.ts index a45794a5d..1da67adb6 100644 --- a/packages/extension/tests/extension/workspaceFiles/refresh/scheduler.test.ts +++ b/packages/extension/tests/extension/workspaceFiles/refresh/scheduler.test.ts @@ -1,6 +1,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { scheduleWorkspaceRefresh } from '../../../../src/extension/workspaceFiles/refresh/scheduler'; +const performanceMocks = vi.hoisted(() => ({ + record: vi.fn(), +})); + +vi.mock('../../../../src/extension/performance/marks', () => ({ + recordExtensionPerformanceEvent: performanceMocks.record, +})); + function makeProvider() { return { refreshChangedFiles: vi.fn().mockResolvedValue(undefined), @@ -16,6 +24,7 @@ function makeProvider() { describe('workspaceFiles/refresh/scheduler', () => { beforeEach(() => { vi.clearAllMocks(); + performanceMocks.record.mockReset(); }); afterEach(() => { @@ -72,6 +81,41 @@ describe('workspaceFiles/refresh/scheduler', () => { consoleSpy.mockRestore(); }); + it('records schedule and fire timing markers', () => { + vi.useFakeTimers(); + const provider = makeProvider(); + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + scheduleWorkspaceRefresh( + provider as never, + '[CodeGraphy] File changed, refreshing graph', + ['/workspace/src/a.ts'], + 123, + ); + vi.advanceTimersByTime(123); + + expect(performanceMocks.record).toHaveBeenCalledWith( + 'workspaceRefresh.scheduled', + expect.objectContaining({ + delayMs: 123, + fileCount: 1, + fullRefresh: false, + gitignoreRefresh: false, + }), + ); + expect(performanceMocks.record).toHaveBeenCalledWith( + 'workspaceRefresh.started', + expect.objectContaining({ + delayMs: 123, + fileCount: 1, + fullRefresh: false, + gitignoreRefresh: false, + }), + ); + + consoleSpy.mockRestore(); + }); + it('queues a pending refresh instead of refreshing while the graph is closed', () => { vi.useFakeTimers(); const provider = makeProvider(); diff --git a/packages/extension/tests/extension/workspaceFiles/refresh/watchers.test.ts b/packages/extension/tests/extension/workspaceFiles/refresh/watchers.test.ts index 1efbf48a2..0fc6d169d 100644 --- a/packages/extension/tests/extension/workspaceFiles/refresh/watchers.test.ts +++ b/packages/extension/tests/extension/workspaceFiles/refresh/watchers.test.ts @@ -5,6 +5,14 @@ import { registerSaveHandler, } from '../../../../src/extension/workspaceFiles/refresh/watchers'; +const performanceMocks = vi.hoisted(() => ({ + record: vi.fn(), +})); + +vi.mock('../../../../src/extension/performance/marks', () => ({ + recordExtensionPerformanceEvent: performanceMocks.record, +})); + function makeProvider() { return { emitEvent: vi.fn(), @@ -129,6 +137,7 @@ function uri(filePath: string): vscode.Uri { describe('workspaceFiles/refresh/watchers', () => { beforeEach(() => { vi.clearAllMocks(); + performanceMocks.record.mockReset(); installFileSystemWatcher(); }); @@ -192,6 +201,10 @@ describe('workspaceFiles/refresh/watchers', () => { expect(provider.emitEvent).toHaveBeenCalledWith('workspace:fileChanged', { filePath: '/workspace/src/app.ts', }); + expect(performanceMocks.record).toHaveBeenCalledWith( + 'workspaceFiles.savedDocument.received', + { filePath: '/workspace/src/app.ts' }, + ); }); it('suppresses file-system change duplicates after saved document refreshes', () => { From b41c246a5fa9edb4fd375e101b2b2131222d08a2 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 03:32:45 -0700 Subject: [PATCH 066/192] test: report post-save live update latency --- docs/performance/codegraphy-monorepo.md | 13 +++ .../performance/measure-vscode-graph-view.mjs | 90 +++++++++++++++++++ .../measure-vscode-graph-view.test.mjs | 69 ++++++++++++++ 3 files changed, 172 insertions(+) diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index 2e32e4eba..9d32331c9 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -994,6 +994,19 @@ Interpretation: to `282ms`, and wall time moved from `559ms` to `460ms`. First graph readiness stayed in the same band (`5272ms`) and Imports toggle stayed snappy (`217ms` wall-clock / `62ms` in-webview). +- The VS Code live-update harness now reports post-save phase delays from + extension-host markers: saved-document receipt to request start/completion, + workspace-refresh start to request start, and provider entry to request + start. This keeps production save latency separate from the artificial + editor-open/edit/save work used by the benchmark trigger. In the rebuilt + editor-save probe, overall marker wall time measured `477ms`, but the real + post-save path was `39ms` from saved-document receipt to request start and + `140ms` to request completion. Workspace-refresh start to request start was + `4ms`, provider entry to request start was `3ms`, incremental request + duration was `101ms`, first graph readiness was `5346ms`, and Imports toggle + stayed in the same snappy range (`276ms` wall-clock / `58ms` in-webview). + Future live-update comparisons should prefer the post-save phase metrics + over total wall clock when the trigger is `editor-save`. Full test baseline: diff --git a/scripts/performance/measure-vscode-graph-view.mjs b/scripts/performance/measure-vscode-graph-view.mjs index 4b816aca3..f9ff556de 100644 --- a/scripts/performance/measure-vscode-graph-view.mjs +++ b/scripts/performance/measure-vscode-graph-view.mjs @@ -178,6 +178,62 @@ export function findActiveExtensionHostRequestIds(events, mode) { return [...activeRequestIds]; } +function findLatestEventBetween(events, name, startedAt, completedAt) { + return events + .filter(event => + event.name === name + && typeof event.at === 'number' + && event.at >= startedAt + && event.at <= completedAt) + .at(-1); +} + +export function computeLiveUpdatePhaseDelays(events, { requestEvent, startedAt }) { + const requestDurationMs = requestEvent.detail?.durationMs; + if (typeof requestDurationMs !== 'number') { + return {}; + } + + const requestStartedAt = requestEvent.at - requestDurationMs; + const savedDocumentEvent = findLatestEventBetween( + events, + 'workspaceFiles.savedDocument.received', + startedAt, + requestStartedAt, + ); + const workspaceRefreshStartedEvent = findLatestEventBetween( + events, + 'workspaceRefresh.started', + startedAt, + requestStartedAt, + ); + const providerReceivedEvent = findLatestEventBetween( + events, + 'graphView.refreshChangedFiles.received', + startedAt, + requestStartedAt, + ); + + return { + ...(providerReceivedEvent + ? { providerToRequestStartDelayMs: Math.round(requestStartedAt - providerReceivedEvent.at) } + : {}), + ...(savedDocumentEvent + ? { + saveEventToRequestCompletionDelayMs: Math.round(requestEvent.at - savedDocumentEvent.at), + saveEventToRequestStartDelayMs: Math.round(requestStartedAt - savedDocumentEvent.at), + } + : {}), + ...(workspaceRefreshStartedEvent + ? { + workspaceRefreshStartToRequestStartDelayMs: Math.round( + requestStartedAt - workspaceRefreshStartedEvent.at, + ), + } + : {}), + }; +} + async function readExtensionHostPerformanceEvents(logPath) { const logText = await readFile(logPath, 'utf8').catch(() => ''); return parseExtensionHostPerformanceLog(logText); @@ -261,6 +317,18 @@ export function summarizeLiveUpdateSamples(samples) { const requestCompletionDelays = samples .map(sample => sample.requestCompletionDelayMs) .filter(value => value !== undefined); + const saveEventToRequestStartDelays = samples + .map(sample => sample.saveEventToRequestStartDelayMs) + .filter(value => value !== undefined); + const saveEventToRequestCompletionDelays = samples + .map(sample => sample.saveEventToRequestCompletionDelayMs) + .filter(value => value !== undefined); + const workspaceRefreshStartToRequestStartDelays = samples + .map(sample => sample.workspaceRefreshStartToRequestStartDelayMs) + .filter(value => value !== undefined); + const providerToRequestStartDelays = samples + .map(sample => sample.providerToRequestStartDelayMs) + .filter(value => value !== undefined); return { ...summarizeDurations(samples.map(sample => sample.durationMs)), @@ -273,6 +341,22 @@ export function summarizeLiveUpdateSamples(samples) { ...(requestCompletionDelays.length > 0 ? { requestCompletionDelay: summarizeDurations(requestCompletionDelays) } : {}), + ...(saveEventToRequestStartDelays.length > 0 + ? { saveEventToRequestStartDelay: summarizeDurations(saveEventToRequestStartDelays) } + : {}), + ...(saveEventToRequestCompletionDelays.length > 0 + ? { saveEventToRequestCompletionDelay: summarizeDurations(saveEventToRequestCompletionDelays) } + : {}), + ...(workspaceRefreshStartToRequestStartDelays.length > 0 + ? { + workspaceRefreshStartToRequestStartDelay: summarizeDurations( + workspaceRefreshStartToRequestStartDelays, + ), + } + : {}), + ...(providerToRequestStartDelays.length > 0 + ? { providerToRequestStartDelay: summarizeDurations(providerToRequestStartDelays) } + : {}), }; } @@ -673,15 +757,21 @@ export async function measureLiveUpdateTransition({ ); updateRequestCompletedAt = requestEvent.at; await waitForWebviewGraphUpdateMessageIfSent(extensionHostLogPath, frame, requestEvent); + const extensionHostEvents = await readExtensionHostPerformanceEvents(extensionHostLogPath); const requestDurationMs = requestEvent.detail?.durationMs; const requestCompletionDelayMs = Math.round(requestEvent.at - startedAtEpoch); const requestStartDelayMs = typeof requestDurationMs === 'number' ? Math.round(requestEvent.at - requestDurationMs - startedAtEpoch) : undefined; + const phaseDelays = computeLiveUpdatePhaseDelays(extensionHostEvents, { + requestEvent, + startedAt: startedAtEpoch, + }); return { durationMs: Math.round(performance.now() - startedAt), filePath: path.relative(workspaceRoot, absoluteFilePath).replace(/\\/g, '/'), + ...phaseDelays, requestDurationMs, requestStartDelayMs, requestCompletionDelayMs, diff --git a/tests/scripts/measure-vscode-graph-view.test.mjs b/tests/scripts/measure-vscode-graph-view.test.mjs index 2fadacf87..2777e27d1 100644 --- a/tests/scripts/measure-vscode-graph-view.test.mjs +++ b/tests/scripts/measure-vscode-graph-view.test.mjs @@ -122,18 +122,30 @@ test('VS Code graph view runner summarizes live-update request durations', async requestDurationMs: 120, requestStartDelayMs: 420, requestCompletionDelayMs: 540, + saveEventToRequestStartDelayMs: 70, + saveEventToRequestCompletionDelayMs: 190, + workspaceRefreshStartToRequestStartDelayMs: 38, + providerToRequestStartDelayMs: 36, }, { durationMs: 590, requestDurationMs: 90, requestStartDelayMs: 390, requestCompletionDelayMs: 480, + saveEventToRequestStartDelayMs: 40, + saveEventToRequestCompletionDelayMs: 130, + workspaceRefreshStartToRequestStartDelayMs: 8, + providerToRequestStartDelayMs: 6, }, { durationMs: 615, requestDurationMs: 105, requestStartDelayMs: 405, requestCompletionDelayMs: 510, + saveEventToRequestStartDelayMs: 55, + saveEventToRequestCompletionDelayMs: 160, + workspaceRefreshStartToRequestStartDelayMs: 23, + providerToRequestStartDelayMs: 21, }, ]), { iterations: 3, @@ -162,6 +174,63 @@ test('VS Code graph view runner summarizes live-update request durations', async p95Ms: 540, maxMs: 540, }, + saveEventToRequestStartDelay: { + iterations: 3, + minMs: 40, + medianMs: 55, + p95Ms: 70, + maxMs: 70, + }, + saveEventToRequestCompletionDelay: { + iterations: 3, + minMs: 130, + medianMs: 160, + p95Ms: 190, + maxMs: 190, + }, + workspaceRefreshStartToRequestStartDelay: { + iterations: 3, + minMs: 8, + medianMs: 23, + p95Ms: 38, + maxMs: 38, + }, + providerToRequestStartDelay: { + iterations: 3, + minMs: 6, + medianMs: 21, + p95Ms: 36, + maxMs: 36, + }, + }); +}); + +test('VS Code graph view runner computes live-update phase delays from extension-host events', async () => { + const moduleUrl = pathToFileURL( + path.resolve('scripts/performance/measure-vscode-graph-view.mjs'), + ).href; + const { computeLiveUpdatePhaseDelays } = await import(moduleUrl); + + const requestEvent = { + name: 'graphAnalysis.request.completed', + at: 1_300, + detail: { requestId: 3, mode: 'incremental', durationMs: 80 }, + }; + + assert.deepEqual(computeLiveUpdatePhaseDelays([ + { name: 'workspaceFiles.savedDocument.received', at: 1_100 }, + { name: 'workspaceRefresh.started', at: 1_132 }, + { name: 'graphView.refreshChangedFiles.received', at: 1_135 }, + { name: 'graphAnalysis.request.start', at: 1_220 }, + requestEvent, + ], { + requestEvent, + startedAt: 1_000, + }), { + providerToRequestStartDelayMs: 85, + saveEventToRequestCompletionDelayMs: 200, + saveEventToRequestStartDelayMs: 120, + workspaceRefreshStartToRequestStartDelayMs: 88, }); }); From 97f5836c516ff82bf0d2a1e0b0a04ec0140d9f00 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 03:38:20 -0700 Subject: [PATCH 067/192] perf: skip duplicate ready graph replay --- docs/performance/codegraphy-monorepo.md | 10 ++++++ .../graphView/webview/messages/ready.ts | 11 ++++-- .../graphView/webview/messages/ready.test.ts | 36 ++++++++++++++++++- 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index 9d32331c9..53efe946a 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -1007,6 +1007,16 @@ Interpretation: stayed in the same snappy range (`276ms` wall-clock / `58ms` in-webview). Future live-update comparisons should prefer the post-save phase metrics over total wall clock when the trigger is `editor-save`. +- Duplicate `WEBVIEW_READY` handling after bootstrap now replays lightweight + settings and `APP_BOOTSTRAP_COMPLETE` without resending the full graph + snapshot when the webview is already marked ready. This removes the startup + duplicate `GRAPH_DATA_UPDATED` payload that the webview previously skipped + only after receiving and inspecting all `6485` raw nodes and `20781` raw + edges. In the rebuilt editor-save probe, startup sent one full graph payload + instead of two, first graph readiness moved from `5346ms` to `5191ms`, and + Imports toggle stayed snappy (`211ms` wall-clock / `60ms` in-webview). + Live update stayed in the same fast post-save band with `116ms` from + saved-document receipt to request completion. Full test baseline: diff --git a/packages/extension/src/extension/graphView/webview/messages/ready.ts b/packages/extension/src/extension/graphView/webview/messages/ready.ts index 2f96f6d40..190121d0b 100644 --- a/packages/extension/src/extension/graphView/webview/messages/ready.ts +++ b/packages/extension/src/extension/graphView/webview/messages/ready.ts @@ -162,10 +162,17 @@ export function replayWebviewReadyBootstrap( replayWebviewReadyGraphBootstrap(handlers); } +interface ReplayWebviewReadyGraphBootstrapOptions { + includeGraphData?: boolean; +} + export function replayWebviewReadyGraphBootstrap( handlers: Pick, + options: ReplayWebviewReadyGraphBootstrapOptions = {}, ): void { - handlers.sendMessage({ type: 'GRAPH_DATA_UPDATED', payload: handlers.getGraphData() }); + if (options.includeGraphData ?? true) { + handlers.sendMessage({ type: 'GRAPH_DATA_UPDATED', payload: handlers.getGraphData() }); + } handlers.sendMessage({ type: 'APP_BOOTSTRAP_COMPLETE' }); } @@ -182,7 +189,7 @@ export async function replayDuplicateWebviewReady( } replayWebviewReadySettings(state, handlers); - replayWebviewReadyGraphBootstrap(handlers); + replayWebviewReadyGraphBootstrap(handlers, { includeGraphData: !state.readyNotified }); } export async function applyWebviewReady( diff --git a/packages/extension/tests/extension/graphView/webview/messages/ready.test.ts b/packages/extension/tests/extension/graphView/webview/messages/ready.test.ts index 2dd283578..329371692 100644 --- a/packages/extension/tests/extension/graphView/webview/messages/ready.test.ts +++ b/packages/extension/tests/extension/graphView/webview/messages/ready.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it, vi } from 'vitest'; -import { applyWebviewReady } from '../../../../../src/extension/graphView/webview/messages/ready'; +import { + applyWebviewReady, + replayDuplicateWebviewReady, +} from '../../../../../src/extension/graphView/webview/messages/ready'; function createHandlers() { return { @@ -351,6 +354,37 @@ describe('graph view ready message', () => { expect(readyNotified).toBe(true); }); + it('does not resend full graph data for duplicate ready after bootstrap', async () => { + const handlers = createHandlers(); + + await replayDuplicateWebviewReady( + { + maxFiles: 500, + verboseDiagnostics: false, + playbackSpeed: 1, + dagMode: null, + nodeSizeMode: 'connections', + focusedFile: undefined, + hasWorkspace: true, + firstAnalysis: false, + readyNotified: true, + }, + handlers, + ); + + expect(handlers.getGraphData).not.toHaveBeenCalled(); + expect(handlers.sendMessage).not.toHaveBeenCalledWith({ + type: 'GRAPH_DATA_UPDATED', + payload: { + nodes: [{ id: 'cached.ts', label: 'cached.ts', color: '#ffffff' }], + edges: [], + }, + }); + expect(handlers.sendMessage).toHaveBeenCalledWith({ + type: 'APP_BOOTSTRAP_COMPLETE', + }); + }); + it('waits for cached timeline replay before notifying readiness', async () => { const events: string[] = []; const handlers = createHandlers(); From a76963ae286d61d4ecd752fdf7ecec58b6344eda Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 03:45:41 -0700 Subject: [PATCH 068/192] docs: record scoped matcher experiment --- docs/performance/codegraphy-monorepo.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index 53efe946a..a848708cf 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -1017,6 +1017,17 @@ Interpretation: Imports toggle stayed snappy (`211ms` wall-clock / `60ms` in-webview). Live update stayed in the same fast post-save band with `116ms` from saved-document receipt to request completion. +- Rejected precompiled file-path scoped symbol matcher experiment: + - The hypothesis was that `visibleGraph.derive` spent meaningful time + recompiling the two `**/*.gd` file-path symbol matchers while resolving + graph-scope node visibility. A TDD spike added a scoped-definition matcher + cache and used it in `nodeMatchesScope`. + - The rebuilt monorepo probe did not improve the target stage: + `visibleGraph.derive` stayed at `250.9ms` versus the prior `250.2ms`, and + first graph readiness stayed flat/noisy (`5215ms` versus `5191ms`). + Imports toggle was still snappy (`196ms` wall-clock / `64ms` in-webview), + but the code change was backed out because it did not move the measured + bottleneck. Full test baseline: From 87b8f05de3a8f5bd091e1c2fea345a3850733352 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 03:50:15 -0700 Subject: [PATCH 069/192] docs: record disabled symbol scope experiment --- docs/performance/codegraphy-monorepo.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index a848708cf..55a646d23 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -1028,6 +1028,19 @@ Interpretation: Imports toggle was still snappy (`196ms` wall-clock / `64ms` in-webview), but the code change was backed out because it did not move the measured bottleneck. +- Rejected disabled-base-symbol short-circuit experiment: + - The hypothesis was that graph-scope matching wasted measurable startup time + by checking scoped symbol definitions before rejecting symbols whose base + `symbol` node type was disabled. + - A TDD spike moved the disabled-node-type/ancestor check ahead of scoped + symbol matching and proved the scoped matcher no longer ran in that case, + but the rebuilt monorepo probe did not improve the hotspot: + `visibleGraph.derive` measured `251.1ms` versus the prior `250.2ms`, and + first graph readiness drifted worse/noisy (`5299ms` versus `5191ms`). + Imports toggle stayed snappy (`205ms` wall-clock / `59ms` in-webview) and + post-save live update stayed fast (`112ms` from saved-document receipt to + request completion), but the production change was backed out because it + did not move the measured bottleneck. Full test baseline: From 78a272622823594e54cca4fe6f449826fa6e9fef Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 03:59:05 -0700 Subject: [PATCH 070/192] perf: fast-path combined filter globs --- docs/performance/codegraphy-monorepo.md | 16 +++ packages/extension/src/shared/globMatch.ts | 103 ++++++++++++++++-- .../extension/tests/shared/globMatch.test.ts | 91 ++++++++++++++++ 3 files changed, 203 insertions(+), 7 deletions(-) diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index 55a646d23..679cd1adf 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -1041,6 +1041,22 @@ Interpretation: post-save live update stayed fast (`112ms` from saved-document receipt to request completion), but the production change was backed out because it did not move the measured bottleneck. +- Combined filter glob matching now uses fast-path matchers for the plugin + default-filter shapes that dominate large workspace visual filtering: + basename suffixes such as `**/*.meta`, exact path suffixes, recursive + directory subtrees such as `**/node_modules/**`, and direct-child directory + patterns. Complex globs still fall back to the existing regex semantics. + The focused regression loop over `10,000` nonmatching paths and real plugin + default filters moved from a red `301ms` sample to the focused test file + completing in `22ms`. The standalone visible-graph benchmark improved the + current scenario median from `29ms` to `19ms`, folders-on from `50ms` to + `38ms`, and imports-off from `18ms` to `7ms`. In the rebuilt VS Code + monorepo probe, browser `visibleGraph.derive` for the startup graph moved + from `250.2ms` to `46.7ms` on `6485` raw nodes / `20781` raw edges, first + graph readiness moved from `5191ms` to `5002ms`, and Imports toggle stayed + snappy (`197ms` wall-clock / `57ms` in-webview). Post-save live update + stayed in the same fast band at `120ms` from saved-document receipt to + request completion. Full test baseline: diff --git a/packages/extension/src/shared/globMatch.ts b/packages/extension/src/shared/globMatch.ts index 9614e282b..de2350d4d 100644 --- a/packages/extension/src/shared/globMatch.ts +++ b/packages/extension/src/shared/globMatch.ts @@ -44,22 +44,111 @@ export function createGlobMatcher(pattern: string): (filePath: string) => boolea return (filePath: string): boolean => regex.test(filePath); } +type GlobMatcher = (filePath: string) => boolean; + +function matchesPathSuffix(filePath: string, suffix: string): boolean { + return filePath === suffix || filePath.endsWith(`/${suffix}`); +} + +function createRecursiveDirectoryMatcher(directoryPath: string): GlobMatcher { + const rootPrefix = `${directoryPath}/`; + const nestedPrefix = `/${rootPrefix}`; + + return (filePath: string): boolean => ( + filePath.startsWith(rootPrefix) || filePath.includes(nestedPrefix) + ); +} + +function createDirectChildMatcher(directoryPath: string): GlobMatcher { + const rootPrefix = `${directoryPath}/`; + const nestedPrefix = `/${rootPrefix}`; + + return (filePath: string): boolean => { + let start = 0; + if (!filePath.startsWith(rootPrefix)) { + const nestedStart = filePath.lastIndexOf(nestedPrefix); + if (nestedStart < 0) { + return false; + } + start = nestedStart + 1; + } + + const remainder = filePath.slice(start + rootPrefix.length); + return remainder.length > 0 && !remainder.includes('/'); + }; +} + +function createFastGlobMatcher(pattern: string): GlobMatcher | undefined { + if (!pattern) { + return () => false; + } + + const recursivePattern = pattern.startsWith('**/') ? pattern.slice(3) : pattern; + + if (!recursivePattern.includes('*')) { + return (filePath: string): boolean => matchesPathSuffix(filePath, recursivePattern); + } + + if ( + recursivePattern.startsWith('*.') + && recursivePattern.indexOf('*', 1) === -1 + && !recursivePattern.includes('/') + ) { + const suffix = recursivePattern.slice(1); + return (filePath: string): boolean => filePath.endsWith(suffix); + } + + if (recursivePattern.endsWith('/**')) { + const directoryPath = recursivePattern.slice(0, -3); + if (directoryPath && !directoryPath.includes('*')) { + return createRecursiveDirectoryMatcher(directoryPath); + } + } + + if (recursivePattern.endsWith('/*')) { + const directoryPath = recursivePattern.slice(0, -2); + if (directoryPath && !directoryPath.includes('*')) { + return createDirectChildMatcher(directoryPath); + } + } + + return undefined; +} + export function createCombinedGlobMatcher(patterns: readonly string[]): (filePath: string) => boolean { if (patterns.length === 0) { return () => false; } if (patterns.length === 1) { - return createGlobMatcher(patterns[0] ?? ''); + const pattern = patterns[0] ?? ''; + return createFastGlobMatcher(pattern) ?? createGlobMatcher(pattern); } - const regex = new RegExp( - patterns - .map(pattern => `(?:${globToRegex(pattern).source})`) - .join('|'), - ); + const fastMatchers: GlobMatcher[] = []; + const regexPatterns: string[] = []; + for (const pattern of patterns) { + const fastMatcher = createFastGlobMatcher(pattern); + if (fastMatcher) { + fastMatchers.push(fastMatcher); + } else { + regexPatterns.push(pattern); + } + } - return (filePath: string): boolean => regex.test(filePath); + const regex = regexPatterns.length > 0 + ? new RegExp(regexPatterns.map(pattern => `(?:${globToRegex(pattern).source})`).join('|')) + : null; + + return (filePath: string): boolean => { + for (const matcher of fastMatchers) { + if (matcher(filePath)) { + return true; + } + } + + return regex ? regex.test(filePath) : false; + }; } export function globMatch(filePath: string, pattern: string): boolean { diff --git a/packages/extension/tests/shared/globMatch.test.ts b/packages/extension/tests/shared/globMatch.test.ts index 0c7c3eb5e..ad8d6b6db 100644 --- a/packages/extension/tests/shared/globMatch.test.ts +++ b/packages/extension/tests/shared/globMatch.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { performance } from 'node:perf_hooks'; import { createCombinedGlobMatcher, createGlobMatcher, @@ -51,9 +52,99 @@ describe('shared/globMatch', () => { expect(matcher('src/index.ts')).toBe(false); }); + it('preserves direct-child path boundaries in combined matchers', () => { + const matcher = createCombinedGlobMatcher(['src/*']); + + expect(matcher('src/index.ts')).toBe(true); + expect(matcher('packages/extension/src/index.ts')).toBe(true); + expect(matcher('packages/extension/src/deep/index.ts')).toBe(false); + expect(matcher('packages/extension/xsrc/index.ts')).toBe(false); + }); + + it('falls back to glob regexes for complex combined patterns', () => { + const matcher = createCombinedGlobMatcher([ + '**/Assets/AddressableAssetsData/**/*.bin*', + '**/[Ll]ibrary/**', + ]); + + expect(matcher('project/Assets/AddressableAssetsData/android/catalog.bin.hash')).toBe(true); + expect(matcher('project/Assets/AddressableAssetsData/catalog.json')).toBe(false); + expect(matcher('project/Library/generated.asset')).toBe(false); + expect(matcher('project/[Ll]ibrary/generated.asset')).toBe(true); + }); + it('creates an empty combined matcher that never matches', () => { const matcher = createCombinedGlobMatcher([]); expect(matcher('src/index.ts')).toBe(false); }); + + it('rejects nonmatching paths quickly with many plugin default filters', () => { + const matcher = createCombinedGlobMatcher([ + '**/node_modules/**', + '**/dist/**', + '**/build/**', + '**/out/**', + '**/.next/**', + '**/.nuxt/**', + '**/coverage/**', + '**/.turbo/**', + '**/.godot/**', + '**/.import/**', + '**/*.import', + '**/.mono/**', + '**/addons/**', + '**/*.uid', + '**/.svelte-kit/**', + '**/[Ll]ibrary/**', + '**/[Tt]emp/**', + '**/[Oo]bj/**', + '**/[Bb]uild/**', + '**/[Bb]uilds/**', + '**/[Ll]ogs/**', + '**/[Pp]roject[Ss]ettings/**', + '**/[Uu]ser[Ss]ettings/**', + '**/[Mm]emory[Cc]aptures/**', + '**/.vs/**', + '**/.gradle/**', + '**/.idea/**', + '**/Assets/Packages/**', + '**/Assets/Plugins/Editor/JetBrains/**', + '**/ExportedObj/**', + '**/.consulo/**', + '**/*.meta', + '**/*.csproj', + '**/*.unityproj', + '**/*.sln', + '**/*.slnx', + '**/*.suo', + '**/*.user', + '**/*.userprefs', + '**/*.pidb', + '**/*.booproj', + '**/*.tmp', + '**/*.pdb', + '**/*.mdb', + '**/*.pidb.meta', + '**/*.pdb.meta', + '**/*.mdb.meta', + '**/*.opendb', + '**/*.VC.db', + '**/sysinfo.txt', + '**/crashlytics-build.properties', + '**/Assets/AddressableAssetsData/**/*.bin*', + '**/Assets/StreamingAssets/aa.meta', + '**/Assets/StreamingAssets/aa/**', + ]); + const paths = Array.from({ length: 10_000 }, (_, index) => ( + `packages/package-${index % 100}/src/deep/file-${index}.ts` + )); + + const startedAt = performance.now(); + const matchedCount = paths.filter(matcher).length; + const elapsedMs = performance.now() - startedAt; + + expect(matchedCount).toBe(0); + expect(elapsedMs).toBeLessThan(120); + }); }); From 25e039392f9d410d3be3f17ab8dbc07c6171062c Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 04:03:39 -0700 Subject: [PATCH 071/192] perf: fast-path single glob matchers --- docs/performance/codegraphy-monorepo.md | 11 ++++ packages/extension/src/shared/globMatch.ts | 11 ++-- .../extension/tests/shared/globMatch.test.ts | 54 +++++++++++++++++++ 3 files changed, 73 insertions(+), 3 deletions(-) diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index 679cd1adf..a7a86c216 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -1057,6 +1057,17 @@ Interpretation: snappy (`197ms` wall-clock / `57ms` in-webview). Post-save live update stayed in the same fast band at `120ms` from saved-document receipt to request completion. +- Single-pattern glob matching now reuses the same fast-path classifier as + combined matching, so legend rules do not pay a regex test for common + suffix, exact-path, recursive-directory, or direct-child patterns. A focused + red loop over repeated simple matchers measured `32.5ms` before the change + and passed after the change with the focused glob test file completing in + `27ms`. In the rebuilt VS Code monorepo probe, the post-settings legend + application pass moved from `90.3ms` to `53.2ms` for `131` legend rules over + `2300` visible nodes and `5345` visible edges. Startup `visibleGraph.derive` + stayed fast at `43.5ms`, Imports stayed snappy (`200ms` wall-clock / `59ms` + in-webview), and post-save live update stayed in the fast band at `116ms` + from saved-document receipt to request completion. Full test baseline: diff --git a/packages/extension/src/shared/globMatch.ts b/packages/extension/src/shared/globMatch.ts index de2350d4d..a6952968f 100644 --- a/packages/extension/src/shared/globMatch.ts +++ b/packages/extension/src/shared/globMatch.ts @@ -39,13 +39,18 @@ export function globToRegex(pattern: string): RegExp { return new RegExp(`(?:^|/)${body}$`); } -export function createGlobMatcher(pattern: string): (filePath: string) => boolean { +type GlobMatcher = (filePath: string) => boolean; + +export function createGlobMatcher(pattern: string): GlobMatcher { + const fastMatcher = createFastGlobMatcher(pattern); + if (fastMatcher) { + return fastMatcher; + } + const regex = globToRegex(pattern); return (filePath: string): boolean => regex.test(filePath); } -type GlobMatcher = (filePath: string) => boolean; - function matchesPathSuffix(filePath: string, suffix: string): boolean { return filePath === suffix || filePath.endsWith(`/${suffix}`); } diff --git a/packages/extension/tests/shared/globMatch.test.ts b/packages/extension/tests/shared/globMatch.test.ts index ad8d6b6db..f8704be2b 100644 --- a/packages/extension/tests/shared/globMatch.test.ts +++ b/packages/extension/tests/shared/globMatch.test.ts @@ -39,6 +39,60 @@ describe('shared/globMatch', () => { expect(matcher('docs/index.ts')).toBe(false); }); + it('keeps repeated simple single-glob checks cheap', () => { + const matchers = [ + '*.ts', + '*.tsx', + '*.json', + '*.md', + '*.gd', + '*.cs', + '*.sln', + '*.meta', + '*.yml', + '*.yaml', + '*.js', + '*.css', + '*.vue', + '*.svelte', + '*.go', + '*.rs', + '*.rb', + '*.py', + '*.java', + '*.php', + '*.lua', + '*.swift', + '*.dart', + '*.hpp', + '*.cpp', + '*.c', + '*.h', + ].flatMap((pattern) => [ + createGlobMatcher(pattern), + createGlobMatcher(pattern), + createGlobMatcher(pattern), + createGlobMatcher(pattern), + ]); + const paths = Array.from({ length: 2_300 }, (_, index) => ( + `packages/package-${index % 100}/src/file-${index}.${index % 5 === 0 ? 'ts' : 'txt'}` + )); + + const startedAt = performance.now(); + let matchedCount = 0; + for (const filePath of paths) { + for (const matcher of matchers) { + if (matcher(filePath)) { + matchedCount += 1; + } + } + } + const elapsedMs = performance.now() - startedAt; + + expect(matchedCount).toBe(1_840); + expect(elapsedMs).toBeLessThan(20); + }); + it('creates one matcher that preserves any-pattern glob semantics', () => { const matcher = createCombinedGlobMatcher([ '**/tests/**', From 18e190a551e72b511d77ce7773c6d7667ec1fa76 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 04:56:39 -0700 Subject: [PATCH 072/192] perf: hydrate graph view settings before bootstrap --- .changeset/smooth-startup-ready-hydration.md | 5 ++ docs/performance/codegraphy-monorepo.md | 19 +++++ .../graphView/webview/messages/listener.ts | 63 ++++++++++++++--- .../graphView/webview/messages/ready.ts | 69 ++++++++++++++----- .../src/shared/protocol/webviewToExtension.ts | 7 +- .../app/shell/messageListener/ready.ts | 23 ++++++- .../src/webview/search/filtering/rules.ts | 12 +++- .../webview/messages/listener.test.ts | 64 +++++++++++++++-- .../graphView/webview/messages/ready.test.ts | 39 +++++++++++ .../webview/app/shell/messageListener.test.ts | 35 ++++++++-- .../app/shell/messageListener/ready.test.ts | 15 +++- .../webview/search/filtering/rules.test.ts | 11 +++ 12 files changed, 320 insertions(+), 42 deletions(-) create mode 100644 .changeset/smooth-startup-ready-hydration.md diff --git a/.changeset/smooth-startup-ready-hydration.md b/.changeset/smooth-startup-ready-hydration.md new file mode 100644 index 000000000..38a7515e3 --- /dev/null +++ b/.changeset/smooth-startup-ready-hydration.md @@ -0,0 +1,5 @@ +--- +"@codegraphy-dev/extension": patch +--- + +Reduce Graph View startup jank by hydrating settings before bootstrap and ignoring stale duplicate ready replays. diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index a7a86c216..826e2841d 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -528,6 +528,25 @@ VS Code graph view benchmark: and `10ms` stats wait. - A one-sample Imports Graph Scope sanity check measured `191ms` wall-clock and `49ms` in-webview optimistic-to-rendered latency. +- After fast simple-glob matching, page-tokened ready handshakes, and + pre-bootstrap hydration settings: + - Command shape: + `pnpm exec tsx scripts/performance/measure-vscode-graph-view.mjs --workspace /Users/poleski/Desktop/Projects/CodeGraphyV4 --iterations 1 --warmup 0 --output reports/performance/vscode-graph-view-ready-hydration-toggle.json`. + - Open Graph View to first rendered graph stats: `4733ms`, split into + `1577ms` command/open, `3141ms` frame wait, and `12ms` stats wait. + - The visible page received `SETTINGS_UPDATED`, `PHYSICS_SETTINGS_UPDATED`, + `LEGENDS_UPDATED`, sizing, decoration, and active-file state before + `APP_BOOTSTRAP_COMPLETE` at `332.5ms`. + - The real startup work after bootstrap now ran one `visibleGraph.derive` + (`43.7ms`), one `visibleGraph.applyLegendRules` (`48.1ms`), and one + `graphRuntime.buildGraphData` (`7.4ms`) before first stats rendered at + `523.9ms` after the usable document started. + - Earlier comparable traces with late ready/settings replay showed three + `graphRuntime.buildGraphData` startup iterations and post-bootstrap legend + work. This run had one graph-runtime build and no second + `APP_BOOTSTRAP_COMPLETE`. + - Imports toggle wall-clock latency was `190ms`; in-webview + optimistic-to-rendered latency was `55ms`. Interpretation: diff --git a/packages/extension/src/extension/graphView/webview/messages/listener.ts b/packages/extension/src/extension/graphView/webview/messages/listener.ts index 8ffc37de3..d20b28a9b 100644 --- a/packages/extension/src/extension/graphView/webview/messages/listener.ts +++ b/packages/extension/src/extension/graphView/webview/messages/listener.ts @@ -10,6 +10,7 @@ import { type GraphViewPrimaryMessageContext, } from '../dispatch/primary'; import { replayDuplicateWebviewReady } from './ready'; +import { recordExtensionPerformanceEvent } from '../../../performance/marks'; export interface GraphViewMessageListenerContext extends GraphViewPrimaryMessageContext, @@ -24,6 +25,26 @@ const webviewMessageListenerDisposables = new WeakMap>; type GraphViewPluginMessageResult = Awaited>; +type WebviewReadyMessage = Extract; + +interface WebviewReadyDelivery { + pageId?: string; + postedAt?: number; +} + +function getWebviewReadyDelivery(message: WebviewReadyMessage): WebviewReadyDelivery { + const payload = (message as { payload?: unknown }).payload; + if (!payload || typeof payload !== 'object') { + return {}; + } + + const pageId = (payload as { pageId?: unknown }).pageId; + const postedAt = (payload as { postedAt?: unknown }).postedAt; + return { + ...(typeof pageId === 'string' && pageId.length > 0 ? { pageId } : {}), + ...(typeof postedAt === 'number' && Number.isFinite(postedAt) ? { postedAt } : {}), + }; +} function applyGraphViewPrimaryMessageResult( primaryResult: GraphViewPrimaryMessageResult, @@ -74,26 +95,52 @@ function createGraphViewWebviewMessageHandler( context: GraphViewMessageListenerContext, ): (message: WebviewToExtensionMessage) => Promise { let webviewReadyHandled = false; + let webviewReadyPageId: string | undefined; + let webviewReadyCompletedAt: number | undefined; return async function handleGraphViewWebviewMessage(message: WebviewToExtensionMessage): Promise { - if (message.type === 'WEBVIEW_READY' && webviewReadyHandled) { - await replayDuplicateWebviewReady(createReadyState(context), context); - return; + const isWebviewReadyMessage = message.type === 'WEBVIEW_READY'; + if (message.type === 'WEBVIEW_READY') { + const delivery = getWebviewReadyDelivery(message); + recordExtensionPerformanceEvent('graphWebview.ready.received', { + duplicate: webviewReadyHandled, + pageId: delivery.pageId, + postedAt: delivery.postedAt, + previousPageId: webviewReadyPageId, + completedAt: webviewReadyCompletedAt, + }); + if (webviewReadyHandled) { + const isSamePage = delivery.pageId !== undefined && delivery.pageId === webviewReadyPageId; + const wasPostedBeforeCompletedBootstrap = delivery.postedAt !== undefined + && webviewReadyCompletedAt !== undefined + && delivery.postedAt <= webviewReadyCompletedAt; + if (isSamePage || wasPostedBeforeCompletedBootstrap) { + return; + } + webviewReadyPageId = delivery.pageId; + await replayDuplicateWebviewReady(createReadyState(context), context); + return; + } + webviewReadyHandled = true; + webviewReadyPageId = delivery.pageId; } - webviewReadyHandled ||= message.type === 'WEBVIEW_READY'; const primaryResult = await dispatchGraphViewPrimaryMessage(message, { ...context, asWebviewUri: uri => webview.asWebviewUri(uri), }); if (applyGraphViewPrimaryMessageResult(primaryResult, context)) { + if (isWebviewReadyMessage) { + webviewReadyCompletedAt = Date.now(); + } return; } - applyGraphViewPluginMessageResult( - await dispatchGraphViewPluginMessage(message, context), - context, - ); + const pluginResult = await dispatchGraphViewPluginMessage(message, context); + applyGraphViewPluginMessageResult(pluginResult, context); + if (isWebviewReadyMessage && pluginResult.handled) { + webviewReadyCompletedAt = Date.now(); + } }; } diff --git a/packages/extension/src/extension/graphView/webview/messages/ready.ts b/packages/extension/src/extension/graphView/webview/messages/ready.ts index 190121d0b..b6d7f8f1b 100644 --- a/packages/extension/src/extension/graphView/webview/messages/ready.ts +++ b/packages/extension/src/extension/graphView/webview/messages/ready.ts @@ -96,22 +96,16 @@ function sendWebviewReadyFilterPatterns(handlers: GraphViewReadyHandlers): Filte return payload; } -export function replayWebviewReadySettings( +interface ReplayWebviewReadySettingsMessagesOptions { + includeFilterPatterns: boolean; + includePluginBootstrap: boolean; +} + +function replayWebviewReadySettingsMessages( state: GraphViewReadyState, handlers: GraphViewReadyHandlers, + options: ReplayWebviewReadySettingsMessagesOptions, ): void { - createExtensionDiagnosticLogger({ - isEnabled: () => state.verboseDiagnostics, - }).emit({ - area: 'extension.webview', - event: 'ready-replayed', - context: { - hasWorkspace: state.hasWorkspace, - firstAnalysis: state.firstAnalysis, - readyNotified: state.readyNotified, - maxFiles: state.maxFiles, - }, - }); handlers.loadGroupsAndFilterPatterns(); handlers.loadDisabledRulesAndPlugins(); handlers.sendDepthState(); @@ -120,7 +114,9 @@ export function replayWebviewReadySettings( handlers.sendSettings(); handlers.sendPhysicsSettings(); handlers.sendGroupsUpdated(); - sendWebviewReadyFilterPatterns(handlers); + if (options.includeFilterPatterns) { + sendWebviewReadyFilterPatterns(handlers); + } handlers.sendMessage({ type: 'MAX_FILES_UPDATED', payload: { maxFiles: state.maxFiles }, @@ -149,11 +145,49 @@ export function replayWebviewReadySettings( handlers.sendContextMenuItems(); handlers.sendPluginExporters?.(); handlers.sendPluginToolbarActions?.(); - handlers.sendGraphViewContributionStatuses?.(); - handlers.sendPluginWebviewInjections(); + if (options.includePluginBootstrap) { + handlers.sendGraphViewContributionStatuses?.(); + handlers.sendPluginWebviewInjections(); + } handlers.sendActiveFile(); } +export function replayWebviewReadySettings( + state: GraphViewReadyState, + handlers: GraphViewReadyHandlers, +): void { + createExtensionDiagnosticLogger({ + isEnabled: () => state.verboseDiagnostics, + }).emit({ + area: 'extension.webview', + event: 'ready-replayed', + context: { + hasWorkspace: state.hasWorkspace, + firstAnalysis: state.firstAnalysis, + readyNotified: state.readyNotified, + maxFiles: state.maxFiles, + }, + }); + replayWebviewReadySettingsMessages(state, handlers, { + includeFilterPatterns: true, + includePluginBootstrap: true, + }); +} + +function shouldReplayHydrationSettingsAfterLoad(state: GraphViewReadyState): boolean { + return state.hasWorkspace && state.firstAnalysis; +} + +function replayWebviewReadyHydrationSettings( + state: GraphViewReadyState, + handlers: GraphViewReadyHandlers, +): void { + replayWebviewReadySettingsMessages(state, handlers, { + includeFilterPatterns: false, + includePluginBootstrap: false, + }); +} + export function replayWebviewReadyBootstrap( state: GraphViewReadyState, handlers: GraphViewReadyHandlers, @@ -209,6 +243,9 @@ export async function applyWebviewReady( }); } handlers.sendPluginStatuses?.(); + if (shouldReplayHydrationSettingsAfterLoad(state)) { + replayWebviewReadyHydrationSettings(state, handlers); + } handlers.sendMessage({ type: 'APP_BOOTSTRAP_COMPLETE' }); createExtensionDiagnosticLogger({ diff --git a/packages/extension/src/shared/protocol/webviewToExtension.ts b/packages/extension/src/shared/protocol/webviewToExtension.ts index 37b2ea529..007bbc0c4 100644 --- a/packages/extension/src/shared/protocol/webviewToExtension.ts +++ b/packages/extension/src/shared/protocol/webviewToExtension.ts @@ -16,11 +16,16 @@ export interface LegendIconImport { contentsBase64: string; } +export interface WebviewReadyPayload { + pageId: string; + postedAt: number; +} + export type WebviewToExtensionMessage = | { type: 'NODE_SELECTED'; payload: { nodeId: string } } | { type: 'NODE_DOUBLE_CLICKED'; payload: { nodeId: string } } | { type: 'CLEAR_FOCUSED_FILE' } - | { type: 'WEBVIEW_READY'; payload: null } + | { type: 'WEBVIEW_READY'; payload: WebviewReadyPayload | null } | { type: 'OPEN_FILE'; payload: { path: string } } | { type: 'OPEN_IN_EDITOR' } | { type: 'REVEAL_IN_EXPLORER'; payload: { path: string } } diff --git a/packages/extension/src/webview/app/shell/messageListener/ready.ts b/packages/extension/src/webview/app/shell/messageListener/ready.ts index fb784961f..73c4b8cdc 100644 --- a/packages/extension/src/webview/app/shell/messageListener/ready.ts +++ b/packages/extension/src/webview/app/shell/messageListener/ready.ts @@ -4,17 +4,36 @@ import { recordWebviewPerformanceEvent } from '../../../performance/marks'; type WindowWithCodeGraphyReadyFlag = Window & { __codegraphyWebviewReadyPosted?: boolean; + __codegraphyWebviewPageId?: string; }; +function createWebviewPageId(targetWindow: Window): string { + if (typeof targetWindow.crypto?.randomUUID === 'function') { + return targetWindow.crypto.randomUUID(); + } + + return `${Date.now()}-${Math.random().toString(36).slice(2)}`; +} + +function getWebviewPageId(targetWindow: Window): string { + const codeGraphyWindow = targetWindow as WindowWithCodeGraphyReadyFlag; + codeGraphyWindow.__codegraphyWebviewPageId ??= createWebviewPageId(targetWindow); + return codeGraphyWindow.__codegraphyWebviewPageId; +} + export function postWebviewReadyOnce(targetWindow: Window): void { const codeGraphyWindow = targetWindow as WindowWithCodeGraphyReadyFlag; // Keep the ready handshake single-shot for one webview page load. This avoids // duplicate ready messages during React development replays such as StrictMode. if (!codeGraphyWindow.__codegraphyWebviewReadyPosted) { + const pageId = getWebviewPageId(targetWindow); codeGraphyWindow.__codegraphyWebviewReadyPosted = true; graphStore.getState().beginInitialBootstrap(); - recordWebviewPerformanceEvent('webview.ready.posted'); - postMessage({ type: 'WEBVIEW_READY', payload: null }); + recordWebviewPerformanceEvent('webview.ready.posted', { pageId }); + postMessage({ + type: 'WEBVIEW_READY', + payload: { pageId, postedAt: Date.now() }, + }); } } diff --git a/packages/extension/src/webview/search/filtering/rules.ts b/packages/extension/src/webview/search/filtering/rules.ts index 96a37ae5f..124f180fc 100644 --- a/packages/extension/src/webview/search/filtering/rules.ts +++ b/packages/extension/src/webview/search/filtering/rules.ts @@ -16,13 +16,21 @@ export function applyLegendRules( } const activeRules = getOrderedActiveRules(legends); + if (activeRules.length === 0) { + return data; + } + const nodeRules = compileNodeLegendRules(activeRules); const edgeRules = compileEdgeLegendRules(activeRules); return { ...data, - nodes: data.nodes.map((node) => applyCompiledNodeLegendRules(node, nodeRules)), - edges: data.edges.map((edge) => applyCompiledEdgeLegendRules(edge, edgeRules)), + nodes: nodeRules.length === 0 + ? data.nodes + : data.nodes.map((node) => applyCompiledNodeLegendRules(node, nodeRules)), + edges: edgeRules.length === 0 + ? data.edges + : data.edges.map((edge) => applyCompiledEdgeLegendRules(edge, edgeRules)), }; } diff --git a/packages/extension/tests/extension/graphView/webview/messages/listener.test.ts b/packages/extension/tests/extension/graphView/webview/messages/listener.test.ts index c5e40421d..d43aff31b 100644 --- a/packages/extension/tests/extension/graphView/webview/messages/listener.test.ts +++ b/packages/extension/tests/extension/graphView/webview/messages/listener.test.ts @@ -249,15 +249,71 @@ describe('graph view webview message listener', () => { (message as { type?: string }).type === 'GRAPH_DATA_UPDATED' ), ).toHaveLength(0); - expect(context.loadGroupsAndFilterPatterns).toHaveBeenCalledTimes(1); - expect(context.loadDisabledRulesAndPlugins).toHaveBeenCalledTimes(1); - expect(context.sendSettings).toHaveBeenCalledTimes(1); - expect(context.sendPhysicsSettings).toHaveBeenCalledTimes(1); + expect(context.loadGroupsAndFilterPatterns).toHaveBeenCalledTimes(2); + expect(context.loadDisabledRulesAndPlugins).toHaveBeenCalledTimes(2); + expect(context.sendSettings).toHaveBeenCalledTimes(2); + expect(context.sendPhysicsSettings).toHaveBeenCalledTimes(2); expect(context.notifyWebviewReady).toHaveBeenCalledTimes(1); expect(context.setWebviewReadyNotified).toHaveBeenCalledWith(true); expect(context.setWebviewReadyNotified).toHaveBeenCalledTimes(1); }); + it('ignores repeated WEBVIEW_READY deliveries from the same webview page after bootstrap', async () => { + let messageHandler: ((message: unknown) => Promise) | undefined; + const webview = { + onDidReceiveMessage: vi.fn((handler: (message: unknown) => Promise) => { + messageHandler = handler; + return { dispose: () => {} }; + }), + }; + const context = createContext({ + hasWorkspace: vi.fn(() => true), + isFirstAnalysis: vi.fn(() => false), + isWebviewReadyNotified: vi.fn(() => true), + }); + const readyMessage = { type: 'WEBVIEW_READY', payload: { pageId: 'page-a' } }; + + setGraphViewWebviewMessageListener(webview as never, context); + await messageHandler?.(readyMessage); + await messageHandler?.(readyMessage); + + expect(context.loadAndSendData).toHaveBeenCalledTimes(1); + expect(context.sendSettings).toHaveBeenCalledTimes(1); + expect(context.sendPhysicsSettings).toHaveBeenCalledTimes(1); + expect( + vi.mocked(context.sendMessage).mock.calls.filter(([message]) => + (message as { type?: string }).type === 'APP_BOOTSTRAP_COMPLETE' + ), + ).toHaveLength(1); + }); + + it('ignores new-page WEBVIEW_READY deliveries posted before the previous bootstrap completed', async () => { + let messageHandler: ((message: unknown) => Promise) | undefined; + const webview = { + onDidReceiveMessage: vi.fn((handler: (message: unknown) => Promise) => { + messageHandler = handler; + return { dispose: () => {} }; + }), + }; + const context = createContext({ + hasWorkspace: vi.fn(() => true), + isFirstAnalysis: vi.fn(() => false), + isWebviewReadyNotified: vi.fn(() => true), + }); + + setGraphViewWebviewMessageListener(webview as never, context); + await messageHandler?.({ type: 'WEBVIEW_READY', payload: { pageId: 'page-a', postedAt: 1 } }); + await messageHandler?.({ type: 'WEBVIEW_READY', payload: { pageId: 'page-b', postedAt: 1 } }); + + expect(context.loadAndSendData).toHaveBeenCalledTimes(1); + expect(context.sendSettings).toHaveBeenCalledTimes(1); + expect( + vi.mocked(context.sendMessage).mock.calls.filter(([message]) => + (message as { type?: string }).type === 'APP_BOOTSTRAP_COMPLETE' + ), + ).toHaveLength(1); + }); + it('replaces the previous listener when the same webview is wired again', async () => { const activeHandlers = new Set<(message: unknown) => Promise>(); const webview = { diff --git a/packages/extension/tests/extension/graphView/webview/messages/ready.test.ts b/packages/extension/tests/extension/graphView/webview/messages/ready.test.ts index 329371692..d8a583ce3 100644 --- a/packages/extension/tests/extension/graphView/webview/messages/ready.test.ts +++ b/packages/extension/tests/extension/graphView/webview/messages/ready.test.ts @@ -280,6 +280,45 @@ describe('graph view ready message', () => { expect(events).toEqual(['graph:start', 'graph:end', 'plugins', 'bootstrap']); }); + it('hydrates settings again after initial workspace graph loading before bootstrap', async () => { + const events: string[] = []; + const handlers = createHandlers(); + handlers.sendSettings.mockImplementation(() => events.push('settings')); + handlers.sendGroupsUpdated.mockImplementation(() => events.push('legends')); + handlers.loadAndSendData.mockImplementation(() => { + events.push('graph'); + }); + handlers.sendMessage.mockImplementation((message: { type: string }) => { + if (message.type === 'APP_BOOTSTRAP_COMPLETE') { + events.push('bootstrap'); + } + }); + + await applyWebviewReady( + { + maxFiles: 500, + verboseDiagnostics: false, + playbackSpeed: 1, + dagMode: null, + nodeSizeMode: 'connections', + focusedFile: undefined, + hasWorkspace: true, + firstAnalysis: true, + readyNotified: false, + }, + handlers + ); + + expect(events).toEqual([ + 'settings', + 'legends', + 'graph', + 'settings', + 'legends', + 'bootstrap', + ]); + }); + it('does not block bootstrap on first workspace-ready plugin notifications', async () => { const events: string[] = []; const handlers = createHandlers(); diff --git a/packages/extension/tests/webview/app/shell/messageListener.test.ts b/packages/extension/tests/webview/app/shell/messageListener.test.ts index 80e8176aa..eb0d71dbe 100644 --- a/packages/extension/tests/webview/app/shell/messageListener.test.ts +++ b/packages/extension/tests/webview/app/shell/messageListener.test.ts @@ -13,8 +13,14 @@ describe('app message listener', () => { beforeEach(() => { vi.restoreAllMocks(); vi.clearAllMocks(); - delete (window as Window & { __codegraphyWebviewReadyPosted?: boolean }) - .__codegraphyWebviewReadyPosted; + delete (window as Window & { + __codegraphyWebviewReadyPosted?: boolean; + __codegraphyWebviewPageId?: string; + }).__codegraphyWebviewReadyPosted; + delete (window as Window & { + __codegraphyWebviewReadyPosted?: boolean; + __codegraphyWebviewPageId?: string; + }).__codegraphyWebviewPageId; }); afterEach(() => { @@ -289,7 +295,10 @@ describe('app message listener', () => { expect(addEventListenerSpy).toHaveBeenCalledWith('message', expect.any(Function)); expect(beginInitialBootstrap).toHaveBeenCalledOnce(); - expect(postMessage).toHaveBeenCalledWith({ type: 'WEBVIEW_READY', payload: null }); + expect(postMessage).toHaveBeenCalledWith({ + type: 'WEBVIEW_READY', + payload: { pageId: expect.any(String), postedAt: expect.any(Number) }, + }); const registeredHandler = addEventListenerSpy.mock.calls[0]?.[1]; cleanup(); @@ -305,7 +314,10 @@ describe('app message listener', () => { const secondCleanup = setupMessageListener(injectPluginAssets, pluginHost); expect(postMessage).toHaveBeenCalledTimes(1); - expect(postMessage).toHaveBeenCalledWith({ type: 'WEBVIEW_READY', payload: null }); + expect(postMessage).toHaveBeenCalledWith({ + type: 'WEBVIEW_READY', + payload: { pageId: expect.any(String), postedAt: expect.any(Number) }, + }); firstCleanup(); secondCleanup(); @@ -320,8 +332,19 @@ describe('app message listener', () => { const secondCleanup = setupMessageListener(injectPluginAssets, pluginHost); expect(postMessage).toHaveBeenCalledTimes(2); - expect(postMessage).toHaveBeenNthCalledWith(1, { type: 'WEBVIEW_READY', payload: null }); - expect(postMessage).toHaveBeenNthCalledWith(2, { type: 'WEBVIEW_READY', payload: null }); + expect(postMessage).toHaveBeenNthCalledWith(1, { + type: 'WEBVIEW_READY', + payload: { pageId: expect.any(String), postedAt: expect.any(Number) }, + }); + expect(postMessage).toHaveBeenNthCalledWith(2, { + type: 'WEBVIEW_READY', + payload: { pageId: expect.any(String), postedAt: expect.any(Number) }, + }); + const firstPayload = (vi.mocked(postMessage).mock.calls[0]?.[0] as { payload?: { pageId?: string } } | undefined) + ?.payload; + const secondPayload = (vi.mocked(postMessage).mock.calls[1]?.[0] as { payload?: { pageId?: string } } | undefined) + ?.payload; + expect(secondPayload?.pageId).toBe(firstPayload?.pageId); secondCleanup(); }); diff --git a/packages/extension/tests/webview/app/shell/messageListener/ready.test.ts b/packages/extension/tests/webview/app/shell/messageListener/ready.test.ts index 95fb07db3..b29ca3034 100644 --- a/packages/extension/tests/webview/app/shell/messageListener/ready.test.ts +++ b/packages/extension/tests/webview/app/shell/messageListener/ready.test.ts @@ -13,8 +13,14 @@ describe('app/shell/messageListener/ready', () => { vi.restoreAllMocks(); vi.clearAllMocks(); window.__codegraphyPerformance = undefined; - delete (window as Window & { __codegraphyWebviewReadyPosted?: boolean }) - .__codegraphyWebviewReadyPosted; + delete (window as Window & { + __codegraphyWebviewReadyPosted?: boolean; + __codegraphyWebviewPageId?: string; + }).__codegraphyWebviewReadyPosted; + delete (window as Window & { + __codegraphyWebviewReadyPosted?: boolean; + __codegraphyWebviewPageId?: string; + }).__codegraphyWebviewPageId; }); it('posts webview ready only once per window lifecycle', () => { @@ -25,7 +31,10 @@ describe('app/shell/messageListener/ready', () => { expect(beginInitialBootstrap).toHaveBeenCalledOnce(); expect(postMessage).toHaveBeenCalledTimes(1); - expect(postMessage).toHaveBeenCalledWith({ type: 'WEBVIEW_READY', payload: null }); + expect(postMessage).toHaveBeenCalledWith({ + type: 'WEBVIEW_READY', + payload: { pageId: expect.any(String), postedAt: expect.any(Number) }, + }); }); it('records when the webview ready handshake is posted', () => { diff --git a/packages/extension/tests/webview/search/filtering/rules.test.ts b/packages/extension/tests/webview/search/filtering/rules.test.ts index e72302b6f..ce01d3245 100644 --- a/packages/extension/tests/webview/search/filtering/rules.test.ts +++ b/packages/extension/tests/webview/search/filtering/rules.test.ts @@ -78,4 +78,15 @@ describe('search/filtering/rules', () => { expect(result?.edges[1]?.color).toBe('#ff8800'); expect(result?.nodes[0]?.color).toBe('#111111'); }); + + it('reuses edge rows when no active legend rule targets edges', () => { + const groups: IGroup[] = [ + { id: 'typescript', pattern: '*.ts', color: '#ff0000' }, + { id: 'disabled-edge', pattern: 'import', color: '#ff8800', target: 'edge', disabled: true }, + ]; + + const result = applyLegendRules(graphData, groups); + + expect(result?.edges).toBe(graphData.edges); + }); }); From 442d4ba763d7387fe768947e53ab85382a70cec1 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 05:08:07 -0700 Subject: [PATCH 073/192] perf: fast-path stale cache freshness checks --- .changeset/quick-cache-status.md | 5 ++ docs/performance/codegraphy-monorepo.md | 20 ++++++++ packages/core/src/discovery/pathMatching.ts | 32 +++++++++++++ .../core/src/workspace/statusPendingFiles.ts | 8 +++- .../core/tests/discovery/pathMatching.test.ts | 15 ++++++ .../extension/graphView/analysis/execution.ts | 1 + .../graphView/analysis/execution/load.ts | 1 + .../pipeline/service/discoveryFacade.ts | 15 +++--- .../graphView/analysis/execution/load.test.ts | 2 +- .../pipeline/service/discoveryFacade.test.ts | 48 +++++++++++++++++++ 10 files changed, 138 insertions(+), 9 deletions(-) create mode 100644 .changeset/quick-cache-status.md diff --git a/.changeset/quick-cache-status.md b/.changeset/quick-cache-status.md new file mode 100644 index 000000000..6cf5bbb6a --- /dev/null +++ b/.changeset/quick-cache-status.md @@ -0,0 +1,5 @@ +--- +"@codegraphy-dev/core": patch +--- + +Speed up Graph Cache freshness checks when generated pending paths accumulate. diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index 826e2841d..33532a5aa 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -547,6 +547,26 @@ VS Code graph view benchmark: `APP_BOOTSTRAP_COMPLETE`. - Imports toggle wall-clock latency was `190ms`; in-webview optimistic-to-rendered latency was `55ms`. +- After skipping stale-cache analysis warm-up and fast-matching generated + pending refresh paths: + - Command shape: + `pnpm exec tsx scripts/performance/measure-vscode-graph-view.mjs --workspace /Users/poleski/Desktop/Projects/CodeGraphyV4 --iterations 1 --warmup 0 --output reports/performance/vscode-graph-view-fast-pending-filter-toggle.json`. + - Open Graph View to first rendered graph stats: `4550ms`, split into + `1620ms` command/open, `2917ms` frame wait, and `10ms` stats wait. + - The stale-cache load decision fell from `214ms` in the previous comparable + trace to `11ms`; the background analyze load decision fell from `182ms` to + `9ms`. + - The cached load request completed in `473ms`, down from `838ms` in the + no-stale-warmup trace. + - The stale-cache one-file `workspacePipeline.loadCachedGraph.warmAnalysis` + event no longer appears before first interaction. + - A direct status-profile pass on the CodeGraphy monorepo metadata + (`7383` pending paths, mostly generated/cache paths) reduced + `pendingFilter` from `317ms` to `13ms`; repeated + `readCodeGraphyWorkspaceStatus` calls fell from roughly `214-288ms` to + `8-14ms`. + - Imports toggle wall-clock latency was `197ms`; in-webview + optimistic-to-rendered latency was `53ms`. Interpretation: diff --git a/packages/core/src/discovery/pathMatching.ts b/packages/core/src/discovery/pathMatching.ts index 6556ce795..834017b3e 100644 --- a/packages/core/src/discovery/pathMatching.ts +++ b/packages/core/src/discovery/pathMatching.ts @@ -23,10 +23,42 @@ export const DEFAULT_EXCLUDE = [ '**/*.map', ]; +const DEFAULT_EXCLUDE_SEGMENTS = new Set([ + 'node_modules', + 'dist', + 'build', + 'out', + '.git', + '.codegraphy', + '.turbo', + '.worktrees', + 'coverage', +]); + +const DEFAULT_EXCLUDE_BASENAMES = new Set([ + '.DS_Store', +]); + +const DEFAULT_EXCLUDE_SUFFIXES = [ + '.min.js', + '.bundle.js', + '.map', +]; + export function normalizeDiscoveryPath(relativePath: string): string { return relativePath.replace(/\\/g, '/'); } +export function isDefaultExcludedPath(relativePath: string): boolean { + const normalizedPath = normalizeDiscoveryPath(relativePath); + const segments = normalizedPath.split('/').filter(Boolean); + const basename = segments.at(-1) ?? normalizedPath; + + return segments.some(segment => DEFAULT_EXCLUDE_SEGMENTS.has(segment)) + || DEFAULT_EXCLUDE_BASENAMES.has(basename) + || DEFAULT_EXCLUDE_SUFFIXES.some(suffix => basename.endsWith(suffix)); +} + export function matchesAnyPattern(relativePath: string, patterns: readonly string[]): boolean { const normalizedPath = normalizeDiscoveryPath(relativePath); diff --git a/packages/core/src/workspace/statusPendingFiles.ts b/packages/core/src/workspace/statusPendingFiles.ts index 4e9e57bb8..9bc566dfe 100644 --- a/packages/core/src/workspace/statusPendingFiles.ts +++ b/packages/core/src/workspace/statusPendingFiles.ts @@ -1,6 +1,10 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import { DEFAULT_EXCLUDE, matchesAnyPattern } from '../discovery/pathMatching'; +import { + DEFAULT_EXCLUDE, + isDefaultExcludedPath, + matchesAnyPattern, +} from '../discovery/pathMatching'; function normalizePendingPath(filePath: string): string { return filePath.replace(/\\/g, '/').replace(/\/+$/, ''); @@ -65,7 +69,7 @@ export function filterWorkspaceStatusPendingChangedFiles( return false; } - if (matchesAnyPattern(filePath, DEFAULT_EXCLUDE)) { + if (isDefaultExcludedPath(filePath) || matchesAnyPattern(filePath, DEFAULT_EXCLUDE)) { return false; } diff --git a/packages/core/tests/discovery/pathMatching.test.ts b/packages/core/tests/discovery/pathMatching.test.ts index 5f148f7ae..dc5e80719 100644 --- a/packages/core/tests/discovery/pathMatching.test.ts +++ b/packages/core/tests/discovery/pathMatching.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { DEFAULT_EXCLUDE, + isDefaultExcludedPath, matchesAnyPattern, normalizeDiscoveryPath, shouldSkipKnownDirectory, @@ -43,6 +44,20 @@ describe('pathMatching', () => { expect(matchesAnyPattern('src\\app.ts', ['src/*.ts'])).toBe(true); }); + it('fast-matches default generated and build excludes', () => { + expect(isDefaultExcludedPath('/workspace/packages/plugin-typescript/.turbo')).toBe(true); + expect(isDefaultExcludedPath('/workspace/.worktrees/speed-up-codegraphy/src/app.ts')).toBe(true); + expect(isDefaultExcludedPath('packages/extension/dist/webview/index.js')).toBe(true); + expect(isDefaultExcludedPath('packages/core/src/index.ts')).toBe(false); + }); + + it('fast-matches default generated file suffix excludes', () => { + expect(isDefaultExcludedPath('dist/index.js.map')).toBe(true); + expect(isDefaultExcludedPath('src/vendor.bundle.js')).toBe(true); + expect(isDefaultExcludedPath('src/vendor.min.js')).toBe(true); + expect(isDefaultExcludedPath('src/vendor.js')).toBe(false); + }); + it('skips exact node_modules and git directories', () => { expect(shouldSkipKnownDirectory('node_modules')).toBe(true); expect(shouldSkipKnownDirectory('.git')).toBe(true); diff --git a/packages/extension/src/extension/graphView/analysis/execution.ts b/packages/extension/src/extension/graphView/analysis/execution.ts index 4f8a3be2f..207dc016e 100644 --- a/packages/extension/src/extension/graphView/analysis/execution.ts +++ b/packages/extension/src/extension/graphView/analysis/execution.ts @@ -11,6 +11,7 @@ export type GraphViewIndexingProgress = { phase: string; current: number; total: export interface GraphViewCachedGraphLoadOptions { includeCurrentGitignoreMetadata?: boolean; + warmAnalysis?: boolean; } interface GraphViewAnalyzerLike { diff --git a/packages/extension/src/extension/graphView/analysis/execution/load.ts b/packages/extension/src/extension/graphView/analysis/execution/load.ts index ec05f0763..d3e88bd7e 100644 --- a/packages/extension/src/extension/graphView/analysis/execution/load.ts +++ b/packages/extension/src/extension/graphView/analysis/execution/load.ts @@ -119,6 +119,7 @@ export async function loadGraphViewRawData( stageStartedAt = Date.now(); const cachedGraphData = await loadCachedGraphViewRawData(signal, state, analyzer, { includeCurrentGitignoreMetadata: indexFreshness !== 'stale', + ...(indexFreshness === 'stale' ? { warmAnalysis: false } : {}), }); recordLoadStage(state, 'cached', stageStartedAt, { edgeCount: cachedGraphData.edges.length, diff --git a/packages/extension/src/extension/pipeline/service/discoveryFacade.ts b/packages/extension/src/extension/pipeline/service/discoveryFacade.ts index b58f5df44..0e87f1665 100644 --- a/packages/extension/src/extension/pipeline/service/discoveryFacade.ts +++ b/packages/extension/src/extension/pipeline/service/discoveryFacade.ts @@ -34,6 +34,7 @@ import { createWorkspacePipelineAnalysisCacheTiers } from './cache/tiers'; export interface WorkspacePipelineCachedGraphLoadOptions { includeCurrentGitignoreMetadata?: boolean; + warmAnalysis?: boolean; } function isWorkspaceAnalysisAbortError(error: unknown): boolean { @@ -288,12 +289,14 @@ export abstract class WorkspacePipelineDiscoveryFacade extends WorkspacePipeline nodeCount: graphData.nodes.length, }); - this._scheduleCachedGraphAnalysisWarmup( - cachedDiscovery.files, - workspaceRoot, - disabledPlugins, - signal, - ); + if (options.warmAnalysis !== false) { + this._scheduleCachedGraphAnalysisWarmup( + cachedDiscovery.files, + workspaceRoot, + disabledPlugins, + signal, + ); + } return graphData; } diff --git a/packages/extension/tests/extension/graphView/analysis/execution/load.test.ts b/packages/extension/tests/extension/graphView/analysis/execution/load.test.ts index f57ec2e6f..662516757 100644 --- a/packages/extension/tests/extension/graphView/analysis/execution/load.test.ts +++ b/packages/extension/tests/extension/graphView/analysis/execution/load.test.ts @@ -154,7 +154,7 @@ describe('graph view analysis execution load', () => { [], new Set(), expect.any(AbortSignal), - { includeCurrentGitignoreMetadata: false }, + { includeCurrentGitignoreMetadata: false, warmAnalysis: false }, ); expect(refreshIndex).not.toHaveBeenCalled(); expect(analyze).not.toHaveBeenCalled(); diff --git a/packages/extension/tests/extension/pipeline/service/discoveryFacade.test.ts b/packages/extension/tests/extension/pipeline/service/discoveryFacade.test.ts index ff1a03f5a..d87ba7bd5 100644 --- a/packages/extension/tests/extension/pipeline/service/discoveryFacade.test.ts +++ b/packages/extension/tests/extension/pipeline/service/discoveryFacade.test.ts @@ -611,6 +611,54 @@ describe('pipeline/service/discoveryFacade', () => { ); }); + it('does not warm cached source analysis when cached replay disables warm-up', async () => { + const facade = new TestDiscoveryFacade(); + const analyzeFileResultForPlugins = vi.fn(); + facade._discovery = { + readContent: vi.fn(async () => 'export const cached = 1;\n'), + } as unknown as FileDiscovery; + facade._registry = { + analyzeFileResultForPlugins, + list: vi.fn(() => [{ plugin: { id: 'plugin.typescript' } }]), + supportsFile: vi.fn(() => true), + } as unknown as PluginRegistry; + facade._cache = { + version: 'test', + files: { + 'src/nested/cached.ts': { + mtime: 1, + analysis: { + filePath: '/workspace/src/nested/cached.ts', + relations: [], + }, + }, + }, + } as never; + vi.spyOn( + facade as unknown as { + _buildGraphDataFromAnalysis: (...args: unknown[]) => unknown; + }, + '_buildGraphDataFromAnalysis', + ).mockReturnValue({ + nodes: [{ id: 'src/nested/cached.ts', label: 'cached.ts', color: '#333333' }], + edges: [], + }); + + await expect(facade.loadCachedGraph([], new Set(), undefined, { + warmAnalysis: false, + })).resolves.toEqual({ + nodes: [{ id: 'src/nested/cached.ts', label: 'cached.ts', color: '#333333' }], + edges: [], + }); + + expect(facade._discovery.readContent).not.toHaveBeenCalled(); + expect(analyzeFileResultForPlugins).not.toHaveBeenCalled(); + expect(performanceMocks.recordExtensionPerformanceEvent).not.toHaveBeenCalledWith( + 'workspacePipeline.loadCachedGraph.warmAnalysis', + expect.anything(), + ); + }); + it('skips cached analysis warm-up quietly when the selected file disappeared', async () => { const facade = new TestDiscoveryFacade(); const readError = Object.assign(new Error('missing cached file'), { code: 'ENOENT' }); From 0ccb4104d9f144909ca6b6069dfe5ebd1fb7c871 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 05:23:33 -0700 Subject: [PATCH 074/192] perf: keep loaded refreshes incremental --- .changeset/loaded-incremental-refresh.md | 5 ++++ docs/performance/codegraphy-monorepo.md | 10 +++++++ .../extension/graphView/provider/refresh.ts | 10 +++++-- .../graphView/provider/refresh/run.ts | 27 +++++++++++++++---- .../graphView/provider/refresh/run.test.ts | 18 +++++++++++++ .../provider/refresh/targeted.test.ts | 23 ++++++++++++++++ 6 files changed, 86 insertions(+), 7 deletions(-) create mode 100644 .changeset/loaded-incremental-refresh.md diff --git a/.changeset/loaded-incremental-refresh.md b/.changeset/loaded-incremental-refresh.md new file mode 100644 index 000000000..bd347e713 --- /dev/null +++ b/.changeset/loaded-incremental-refresh.md @@ -0,0 +1,5 @@ +--- +"@codegraphy-dev/extension": patch +--- + +Keep loaded file-change refreshes incremental when index metadata is temporarily unavailable. diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index 33532a5aa..4262ff64a 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -1107,6 +1107,16 @@ Interpretation: stayed fast at `43.5ms`, Imports stayed snappy (`200ms` wall-clock / `59ms` in-webview), and post-save live update stayed in the fast band at `116ms` from saved-document receipt to request completion. +- Loaded changed-file refreshes now stay on the incremental path even when + persisted index metadata is temporarily unavailable during stale-cache + startup/background analysis. Truly cold no-index refreshes still fall back to + the primary load path. A rebuilt filesystem-triggered live-update probe + against `packages/core/src/index.ts` completed instead of timing out: + first graph readiness was `4534ms`, the marker edit measured `414ms` + wall-clock with a `176ms` incremental request, and the benchmark restore + also stayed incremental at `87ms` instead of starting a `load` request plus + another long background `analyze`. The protected main checkout was clean + after the probe restored the benchmark file. Full test baseline: diff --git a/packages/extension/src/extension/graphView/provider/refresh.ts b/packages/extension/src/extension/graphView/provider/refresh.ts index 92a311eca..15d1b4439 100644 --- a/packages/extension/src/extension/graphView/provider/refresh.ts +++ b/packages/extension/src/extension/graphView/provider/refresh.ts @@ -5,7 +5,13 @@ import { getCodeGraphyConfiguration } from '../../repoSettings/current'; import { createGraphViewIndexProgressCoalescer } from '../analysis/execution/progress'; import { rebuildGraphViewData, smartRebuildGraphView } from '../view/rebuild'; import { createRebuildSenders } from './refresh/rebuild'; -import { runChangedFileRefresh, runIndexRefresh, runPrimaryRefresh, sendRefreshState } from './refresh/run'; +import { + canRunIncrementalChangedFileRefresh, + runChangedFileRefresh, + runIndexRefresh, + runPrimaryRefresh, + sendRefreshState, +} from './refresh/run'; type GraphViewScopedRefreshProgress = { phase: string; current: number; total: number }; @@ -211,7 +217,7 @@ function prepareRefreshInputs(source: GraphViewProviderRefreshMethodsSource): vo } function canRunIndexedChangedFileRefresh(source: GraphViewProviderRefreshMethodsSource): boolean { - return source._analyzer?.hasIndex() === true && source._incrementalAnalyzeAndSendData !== undefined; + return canRunIncrementalChangedFileRefresh(source); } function createRefreshMethod( diff --git a/packages/extension/src/extension/graphView/provider/refresh/run.ts b/packages/extension/src/extension/graphView/provider/refresh/run.ts index 345a8dcbe..5273c0c70 100644 --- a/packages/extension/src/extension/graphView/provider/refresh/run.ts +++ b/packages/extension/src/extension/graphView/provider/refresh/run.ts @@ -1,5 +1,6 @@ import type { GraphViewProviderRefreshMethodsSource } from '../refresh'; import { recordExtensionPerformanceEvent } from '../../../performance/marks'; +import type { IGraphData } from '../../../../shared/graph/contracts'; export type RefreshStateReason = | 'analysisScope' @@ -38,20 +39,36 @@ export async function runIndexRefresh(source: GraphViewProviderRefreshMethodsSou await source._analyzeAndSendData(); } +function hasGraphData(graphData: IGraphData | undefined): boolean { + return (graphData?.nodes.length ?? 0) > 0 || (graphData?.edges.length ?? 0) > 0; +} + +export function canRunIncrementalChangedFileRefresh( + source: GraphViewProviderRefreshMethodsSource, +): boolean { + if (!source._analyzer || !source._incrementalAnalyzeAndSendData) { + return false; + } + + return source._analyzer.hasIndex() + || hasGraphData(source._rawGraphData) + || hasGraphData(source._graphData); +} + export async function runChangedFileRefresh( source: GraphViewProviderRefreshMethodsSource, filePaths: readonly string[], ): Promise { + if (canRunIncrementalChangedFileRefresh(source)) { + await source._incrementalAnalyzeAndSendData!(filePaths); + return 'incremental'; + } + if (!source._analyzer?.hasIndex()) { await runPrimaryRefresh(source); return 'primary'; } - if (source._incrementalAnalyzeAndSendData) { - await source._incrementalAnalyzeAndSendData(filePaths); - return 'incremental'; - } - await source._analyzeAndSendData(); return 'analysis'; } diff --git a/packages/extension/tests/extension/graphView/provider/refresh/run.test.ts b/packages/extension/tests/extension/graphView/provider/refresh/run.test.ts index f0ada7820..67f39489d 100644 --- a/packages/extension/tests/extension/graphView/provider/refresh/run.test.ts +++ b/packages/extension/tests/extension/graphView/provider/refresh/run.test.ts @@ -24,6 +24,8 @@ function createSource(overrides: Partial> = {}) { _refreshAndSendData: vi.fn(async () => undefined), _analyzeAndSendData: vi.fn(async () => undefined), _incrementalAnalyzeAndSendData: vi.fn(async () => undefined), + _rawGraphData: { nodes: [], edges: [] }, + _graphData: { nodes: [], edges: [] }, _analyzer: { hasIndex: vi.fn(() => true) }, ...overrides, }; @@ -100,6 +102,22 @@ describe('graphView/provider/refresh/run', () => { expect(source._loadAndSendData).not.toHaveBeenCalled(); }); + it('uses incremental refresh for a loaded graph while index metadata is unavailable', async () => { + const source = createSource({ + _analyzer: { hasIndex: vi.fn(() => false) }, + _rawGraphData: { + nodes: [{ id: 'src/app.ts' }], + edges: [], + }, + }); + + await runChangedFileRefresh(source as never, ['src/app.ts']); + + expect(source._incrementalAnalyzeAndSendData).toHaveBeenCalledWith(['src/app.ts']); + expect(source._loadAndSendData).not.toHaveBeenCalled(); + expect(source._analyzeAndSendData).not.toHaveBeenCalled(); + }); + it('falls back to full analysis when incremental refresh is unavailable', async () => { const source = createSource({ _incrementalAnalyzeAndSendData: undefined, diff --git a/packages/extension/tests/extension/graphView/provider/refresh/targeted.test.ts b/packages/extension/tests/extension/graphView/provider/refresh/targeted.test.ts index 588593b78..c058ed654 100644 --- a/packages/extension/tests/extension/graphView/provider/refresh/targeted.test.ts +++ b/packages/extension/tests/extension/graphView/provider/refresh/targeted.test.ts @@ -24,6 +24,29 @@ describe('graphView/provider/refresh targeted refreshes', () => { expect(source._sendFavorites).not.toHaveBeenCalled(); }); + it('refreshChangedFiles stays incremental for a loaded graph while index metadata is unavailable', async () => { + const source = createSource({ + _rawGraphData: { + nodes: [{ id: 'src/example.ts' }], + edges: [], + }, + }); + source._analyzer.hasIndex.mockReturnValue(false); + const methods = createGraphViewProviderRefreshMethods(source as never, { + getShowOrphans: vi.fn(() => true), + rebuildGraphData: vi.fn(), + smartRebuildGraphData: vi.fn(), + }); + + await methods.refreshChangedFiles(['src/example.ts']); + + expect(source._loadDisabledRulesAndPlugins).not.toHaveBeenCalled(); + expect(source._loadGroupsAndFilterPatterns).not.toHaveBeenCalled(); + expect(source._incrementalAnalyzeAndSendData).toHaveBeenCalledWith(['src/example.ts']); + expect(source._loadAndSendData).not.toHaveBeenCalled(); + expect(source._sendAllSettings).not.toHaveBeenCalled(); + }); + it('refreshPluginFiles publishes the targeted plugin refresh result without rebuilding it again', async () => { const source = createSource(); source._analyzer.hasIndex.mockReturnValue(false); From bd23c06e04956edaea5ef22f22d3e5f67632269a Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 05:31:41 -0700 Subject: [PATCH 075/192] perf: prioritize edits over background sync --- .changeset/background-sync-live-updates.md | 5 + docs/performance/codegraphy-monorepo.md | 11 ++ .../graphView/provider/analysis/methods.ts | 18 +++- .../provider/analysis/methods.test.ts | 57 ++++++++++ .../performance/measure-vscode-graph-view.mjs | 11 +- .../measure-vscode-graph-view.test.mjs | 102 ++++++++++++++++++ 6 files changed, 200 insertions(+), 4 deletions(-) create mode 100644 .changeset/background-sync-live-updates.md diff --git a/.changeset/background-sync-live-updates.md b/.changeset/background-sync-live-updates.md new file mode 100644 index 000000000..f02a00bf6 --- /dev/null +++ b/.changeset/background-sync-live-updates.md @@ -0,0 +1,5 @@ +--- +"@codegraphy-dev/extension": patch +--- + +Keep file-change refreshes responsive while stale Graph Cache background sync is running. diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index 4262ff64a..66bb6ab54 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -1117,6 +1117,17 @@ Interpretation: also stayed incremental at `87ms` instead of starting a `load` request plus another long background `analyze`. The protected main checkout was clean after the probe restored the benchmark file. +- Incremental changed-file refreshes now bypass stale-cache background sync + waits after the first graph is already usable. Explicit foreground index and + refresh work still blocks competing analysis, but the lower-priority + background cache sync no longer makes a user edit wait. A new + `--live-update-no-analyze-idle-wait` benchmark flag exercises that behavior + without weakening the default clean live-update metric. In a forced-stale + probe, the stale background `analyze` started after cached load, the marker + edit started an incremental request while that analyze was active, and the + edit completed in `380ms` wall-clock with a `105ms` incremental request. + The restore request also stayed incremental at `65ms`, and the protected + main checkout was clean after the temporary stale marker was restored. Full test baseline: diff --git a/packages/extension/src/extension/graphView/provider/analysis/methods.ts b/packages/extension/src/extension/graphView/provider/analysis/methods.ts index ce5958835..49934ab48 100644 --- a/packages/extension/src/extension/graphView/provider/analysis/methods.ts +++ b/packages/extension/src/extension/graphView/provider/analysis/methods.ts @@ -139,12 +139,14 @@ interface FullIndexAnalysisCoordinator { runFullIndexAnalysis(runAnalysis: () => Promise): Promise; runFullIndexAnalysisInBackground(runAnalysis: () => Promise): void; waitForFullIndexAnalysis(): Promise; + waitForForegroundFullIndexAnalysis(): Promise; } function createFullIndexAnalysisCoordinator( dependencies: Pick, ): FullIndexAnalysisCoordinator { let fullIndexAnalysisPromise: Promise | undefined; + let fullIndexAnalysisKind: 'background' | 'foreground' | undefined; const waitForFullIndexAnalysis = async (): Promise => { if (!fullIndexAnalysisPromise) { @@ -160,8 +162,17 @@ function createFullIndexAnalysisCoordinator( return true; }; + const waitForForegroundFullIndexAnalysis = async (): Promise => { + if (fullIndexAnalysisKind === 'background') { + return false; + } + + return waitForFullIndexAnalysis(); + }; + const runFullIndexAnalysis = async ( runAnalysis: () => Promise, + kind: 'background' | 'foreground' = 'foreground', ): Promise => { if (fullIndexAnalysisPromise) { await fullIndexAnalysisPromise; @@ -170,11 +181,13 @@ function createFullIndexAnalysisCoordinator( const analysisPromise = runAnalysis(); fullIndexAnalysisPromise = analysisPromise; + fullIndexAnalysisKind = kind; try { await analysisPromise; } finally { if (fullIndexAnalysisPromise === analysisPromise) { fullIndexAnalysisPromise = undefined; + fullIndexAnalysisKind = undefined; } } }; @@ -182,7 +195,7 @@ function createFullIndexAnalysisCoordinator( const runFullIndexAnalysisInBackground = ( runAnalysis: () => Promise, ): void => { - void runFullIndexAnalysis(runAnalysis).catch(error => { + void runFullIndexAnalysis(runAnalysis, 'background').catch(error => { dependencies.logError('[CodeGraphy] Background cache sync failed:', error); }); }; @@ -199,6 +212,7 @@ function createFullIndexAnalysisCoordinator( runFullIndexAnalysis, runFullIndexAnalysisInBackground, waitForFullIndexAnalysis, + waitForForegroundFullIndexAnalysis, }; } @@ -293,7 +307,7 @@ export function createGraphViewProviderAnalysisMethods( 'refresh', ); const _incrementalAnalyzeAndSendData = async (filePaths: readonly string[]): Promise => { - await fullIndexAnalysis.waitForFullIndexAnalysis(); + await fullIndexAnalysis.waitForForegroundFullIndexAnalysis(); if (source._firstAnalysis && source._firstWorkspaceReadyPromise) { await source._firstWorkspaceReadyPromise; } diff --git a/packages/extension/tests/extension/graphView/provider/analysis/methods.test.ts b/packages/extension/tests/extension/graphView/provider/analysis/methods.test.ts index 1308b1648..23f1e34aa 100644 --- a/packages/extension/tests/extension/graphView/provider/analysis/methods.test.ts +++ b/packages/extension/tests/extension/graphView/provider/analysis/methods.test.ts @@ -373,6 +373,63 @@ describe('graphView/provider/analysis/methods', () => { expect(events).toEqual(['load:start', 'load:end', 'analyze:start', 'analyze:end']); }); + it('starts incremental analysis without waiting for stale cache background sync', async () => { + const source = createSource({ + _firstAnalysis: false, + _analyzer: { + getIndexStatus: vi.fn(() => ({ + freshness: 'stale', + detail: 'CodeGraphy Workspace Graph Cache is stale: enabled plugins changed.', + })), + loadCachedGraph: vi.fn(async () => ({ nodes: [], edges: [] })), + analyze: vi.fn(async () => ({ nodes: [], edges: [] })), + refreshIndex: vi.fn(async () => ({ nodes: [], edges: [] })), + registry: { + notifyWorkspaceReady: vi.fn(), + }, + }, + }); + const events: string[] = []; + let finishCacheSync: (() => void) | undefined; + const runAnalysisRequest = vi.fn(async state => { + events.push(`${state.mode}:start`); + if (state.mode === 'analyze') { + await new Promise(resolve => { + finishCacheSync = resolve; + }); + } + events.push(`${state.mode}:end`); + }); + const methods = createGraphViewProviderAnalysisMethods(source as never, { + runAnalysisRequest, + executeAnalysis: vi.fn(async () => undefined), + markWorkspaceReady: vi.fn(), + isAnalysisStale: vi.fn(() => false), + isAbortError: vi.fn(() => false), + hasWorkspace: vi.fn(() => true), + logError: vi.fn(), + }); + + await methods._loadAndSendData(); + await Promise.resolve(); + const incremental = methods._incrementalAnalyzeAndSendData(['src/changed.ts']); + await Promise.resolve(); + + expect(events).toEqual([ + 'load:start', + 'load:end', + 'analyze:start', + 'incremental:start', + 'incremental:end', + ]); + + finishCacheSync?.(); + await incremental; + await Promise.resolve(); + + expect(source._changedFilePaths).toEqual(['src/changed.ts']); + }); + it('waits for first workspace readiness before starting incremental analysis', async () => { let markFirstWorkspaceReady: (() => void) | undefined; const firstWorkspaceReadyPromise = new Promise(resolve => { diff --git a/scripts/performance/measure-vscode-graph-view.mjs b/scripts/performance/measure-vscode-graph-view.mjs index f9ff556de..37fa88404 100644 --- a/scripts/performance/measure-vscode-graph-view.mjs +++ b/scripts/performance/measure-vscode-graph-view.mjs @@ -720,6 +720,7 @@ export async function measureLiveUpdateTransition({ liveUpdateTrigger = LIVE_UPDATE_TRIGGER_FILESYSTEM, page, saveFileThroughEditor = saveLiveUpdateFileThroughEditor, + waitForAnalyzeIdle = true, workspaceRoot, }) { const normalizedLiveUpdateTrigger = parseLiveUpdateTrigger(liveUpdateTrigger); @@ -729,7 +730,9 @@ export async function measureLiveUpdateTransition({ const originalContent = await readFile(absoluteFilePath, 'utf8'); const marker = `\n// CodeGraphy live update perf marker ${Date.now()}\n`; - await waitForExtensionHostRequestIdle(extensionHostLogPath, ANALYZE_REQUEST_MODE); + if (waitForAnalyzeIdle) { + await waitForExtensionHostRequestIdle(extensionHostLogPath, ANALYZE_REQUEST_MODE); + } await waitForExtensionHostRequestIdle(extensionHostLogPath, LIVE_UPDATE_REQUEST_MODE); await resetWebviewPerformanceEvents(frame); const startedAtEpoch = Date.now(); @@ -817,6 +820,7 @@ async function measureVSCodeGraphView({ liveUpdateFilePath, liveUpdateTrigger, outputPath, + waitForAnalyzeIdle, warmupIterations, workspacePath, }) { @@ -920,6 +924,7 @@ async function measureVSCodeGraphView({ liveUpdateFilePath, liveUpdateTrigger, page: vscode.page, + waitForAnalyzeIdle, workspaceRoot, })); } @@ -945,7 +950,7 @@ async function measureVSCodeGraphView({ function printUsage() { process.stdout.write([ 'Usage:', - ' pnpm exec tsx scripts/performance/measure-vscode-graph-view.mjs [--workspace ] [--iterations ] [--warmup ] [--live-update-file ] [--live-update-trigger filesystem|editor-save] [--output ]', + ' pnpm exec tsx scripts/performance/measure-vscode-graph-view.mjs [--workspace ] [--iterations ] [--warmup ] [--live-update-file ] [--live-update-trigger filesystem|editor-save] [--live-update-no-analyze-idle-wait] [--output ]', '', 'Launches Extension Development Host, opens CodeGraphy, and times rendered Graph Scope toggle latency.', ].join('\n')); @@ -963,12 +968,14 @@ async function runCli(argv) { const warmupIterations = toPositiveInteger(readOptionValue(argv, '--warmup'), DEFAULT_WARMUP_ITERATIONS); const liveUpdateFilePath = readOptionValue(argv, '--live-update-file'); const liveUpdateTrigger = parseLiveUpdateTrigger(readOptionValue(argv, '--live-update-trigger')); + const waitForAnalyzeIdle = !hasFlag(argv, '--live-update-no-analyze-idle-wait'); await measureVSCodeGraphView({ iterations, liveUpdateFilePath, liveUpdateTrigger, outputPath, + waitForAnalyzeIdle, warmupIterations, workspacePath, }); diff --git a/tests/scripts/measure-vscode-graph-view.test.mjs b/tests/scripts/measure-vscode-graph-view.test.mjs index 2777e27d1..08e2a805b 100644 --- a/tests/scripts/measure-vscode-graph-view.test.mjs +++ b/tests/scripts/measure-vscode-graph-view.test.mjs @@ -912,3 +912,105 @@ test('VS Code graph view runner waits for active analyze requests before live-up await observer; } }); + +test('VS Code graph view runner can skip the analyze idle wait for live-update markers', async (t) => { + const moduleUrl = pathToFileURL( + path.resolve('scripts/performance/measure-vscode-graph-view.mjs'), + ).href; + const { measureLiveUpdateTransition } = await import(moduleUrl); + const workspaceRoot = await mkdtemp(path.join(os.tmpdir(), 'codegraphy-live-update-no-wait-')); + t.after(() => rm(workspaceRoot, { recursive: true, force: true })); + + const liveUpdateFilePath = 'src/example.ts'; + const absoluteFilePath = path.join(workspaceRoot, liveUpdateFilePath); + const extensionHostLogPath = path.join(workspaceRoot, 'extension-host.jsonl'); + const originalContent = 'export const value = 1;\n'; + await mkdir(path.dirname(absoluteFilePath), { recursive: true }); + await writeFile(absoluteFilePath, originalContent); + await writeFile(extensionHostLogPath, `${JSON.stringify({ + name: 'graphAnalysis.request.start', + at: Date.now() - 10, + detail: { requestId: 7, mode: 'analyze' }, + })}\n`); + + let stopped = false; + let markerSeenAt = 0; + let analyzeCompletedAt = 0; + let markerRequestRecorded = false; + let restoreRequestCompleted = false; + const frame = { + evaluate: async (callback) => { + if (String(callback).includes('__codegraphyPerformance?.events')) { + return []; + } + return undefined; + }, + waitForTimeout: async ms => new Promise(resolve => setTimeout(resolve, ms)), + }; + + async function appendRequestEvent(name, requestId, mode) { + const at = Date.now(); + await writeFile(extensionHostLogPath, `${JSON.stringify({ + name, + at, + detail: { requestId, mode, durationMs: 5 }, + })}\n`, { flag: 'a' }); + return at; + } + + async function appendIncrementalRequest(requestId) { + await appendRequestEvent('graphAnalysis.request.start', requestId, 'incremental'); + await appendRequestEvent('graphAnalysis.request.completed', requestId, 'incremental'); + } + + const analyzeCompletion = new Promise((resolve, reject) => { + setTimeout(() => { + appendRequestEvent('graphAnalysis.request.completed', 7, 'analyze') + .then((at) => { + analyzeCompletedAt = at; + resolve(); + }) + .catch(reject); + }, 80); + }); + + const observer = (async () => { + while (!stopped) { + const content = await readFile(absoluteFilePath, 'utf8'); + if (!markerRequestRecorded && content.includes('CodeGraphy live update perf marker')) { + markerSeenAt = Date.now(); + markerRequestRecorded = true; + await appendIncrementalRequest(8); + } else if ( + markerRequestRecorded + && !restoreRequestCompleted + && content === originalContent + ) { + await appendIncrementalRequest(9); + restoreRequestCompleted = true; + } + + await new Promise(resolve => setTimeout(resolve, 5)); + } + })(); + + try { + await measureLiveUpdateTransition({ + extensionHostLogPath, + frame, + liveUpdateFilePath, + waitForAnalyzeIdle: false, + workspaceRoot, + }); + await analyzeCompletion; + + assert.equal(restoreRequestCompleted, true); + assert.ok( + markerSeenAt < analyzeCompletedAt, + `marker waited for analyze completion: marker=${markerSeenAt} analyze=${analyzeCompletedAt}`, + ); + } finally { + stopped = true; + await observer; + } +}); From 0a1eaba9fe5f72965c07b7a9f2a56cda4eafa852 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 05:45:55 -0700 Subject: [PATCH 076/192] perf: precompile scoped symbol file matchers --- .../shared/visibleGraph/scope/definitions.ts | 5 ++ .../src/shared/visibleGraph/scope/nodes.ts | 2 +- .../shared/visibleGraph/scope/symbolMatch.ts | 48 +++++++++++++++---- .../tests/shared/visibleGraph/scope.test.ts | 12 +++++ 4 files changed, 58 insertions(+), 9 deletions(-) diff --git a/packages/extension/src/shared/visibleGraph/scope/definitions.ts b/packages/extension/src/shared/visibleGraph/scope/definitions.ts index 9df0d5b4b..8d1f00096 100644 --- a/packages/extension/src/shared/visibleGraph/scope/definitions.ts +++ b/packages/extension/src/shared/visibleGraph/scope/definitions.ts @@ -1,11 +1,13 @@ import type { IGraphNodeTypeDefinition } from '../../graphControls/contracts'; import type { VisibleGraphScopeConfig } from '../contracts'; import { CORE_GRAPH_NODE_TYPES } from '../../graphControls/defaults/definitions'; +import { createGlobMatcher } from '../../globMatch'; export interface ScopedSymbolDefinition { definition: IGraphNodeTypeDefinition; enabled: boolean; specificity: number; + symbolFilePathMatches?: (value: string) => boolean; } export function getDefinitionSymbolKinds( @@ -49,6 +51,9 @@ export function getScopedSymbolDefinitions( definition, enabled: nodeVisibility.get(definition.id) ?? definition.defaultVisible, specificity: getDefinitionSpecificity(definition), + ...(definition.matchSymbolFilePath + ? { symbolFilePathMatches: createGlobMatcher(definition.matchSymbolFilePath) } + : {}), })) .sort((left, right) => right.specificity - left.specificity); } diff --git a/packages/extension/src/shared/visibleGraph/scope/nodes.ts b/packages/extension/src/shared/visibleGraph/scope/nodes.ts index 61ae35230..ccf5f8287 100644 --- a/packages/extension/src/shared/visibleGraph/scope/nodes.ts +++ b/packages/extension/src/shared/visibleGraph/scope/nodes.ts @@ -33,7 +33,7 @@ function getScopedSymbolVisibility( scopedSymbolDefinitions: readonly ScopedSymbolDefinition[], ): ScopedSymbolDefinition | undefined { const matchingDefinition = scopedSymbolDefinitions.find((item) => ( - symbolMatchesScopedDefinition(node, item.definition) + symbolMatchesScopedDefinition(node, item) )); return matchingDefinition; diff --git a/packages/extension/src/shared/visibleGraph/scope/symbolMatch.ts b/packages/extension/src/shared/visibleGraph/scope/symbolMatch.ts index 541d2705b..05b0483da 100644 --- a/packages/extension/src/shared/visibleGraph/scope/symbolMatch.ts +++ b/packages/extension/src/shared/visibleGraph/scope/symbolMatch.ts @@ -1,23 +1,55 @@ import type { IGraphData } from '../../graph/contracts'; import type { IGraphNodeTypeDefinition } from '../../graphControls/contracts'; import { globMatch } from '../../globMatch'; +import type { ScopedSymbolDefinition } from './definitions'; import { getDefinitionSymbolKinds } from './definitions'; +type ScopedSymbolMatcher = IGraphNodeTypeDefinition | ScopedSymbolDefinition; + +function isCompiledScopedSymbolDefinition( + definition: ScopedSymbolMatcher, +): definition is ScopedSymbolDefinition { + return 'definition' in definition; +} + +function getMatcherDefinition(definition: ScopedSymbolMatcher): IGraphNodeTypeDefinition { + return isCompiledScopedSymbolDefinition(definition) ? definition.definition : definition; +} + export function symbolMatchesScopedDefinition( node: IGraphData['nodes'][number], - definition: IGraphNodeTypeDefinition, + scopedDefinition: ScopedSymbolMatcher, ): boolean { const symbol = node.symbol; if (!symbol) { return false; } + const definition = getMatcherDefinition(scopedDefinition); const definitionSymbolKinds = getDefinitionSymbolKinds(definition); - return [ - !definitionSymbolKinds || definitionSymbolKinds.includes(symbol.kind), - !definition.matchSymbolPluginKind || definition.matchSymbolPluginKind === symbol.pluginKind, - !definition.matchSymbolSource || definition.matchSymbolSource === symbol.source, - !definition.matchSymbolLanguage || definition.matchSymbolLanguage === symbol.language, - !definition.matchSymbolFilePath || globMatch(symbol.filePath, definition.matchSymbolFilePath), - ].every(Boolean); + if (definitionSymbolKinds && !definitionSymbolKinds.includes(symbol.kind)) { + return false; + } + + if (definition.matchSymbolPluginKind && definition.matchSymbolPluginKind !== symbol.pluginKind) { + return false; + } + + if (definition.matchSymbolSource && definition.matchSymbolSource !== symbol.source) { + return false; + } + + if (definition.matchSymbolLanguage && definition.matchSymbolLanguage !== symbol.language) { + return false; + } + + if (!definition.matchSymbolFilePath) { + return true; + } + + if (isCompiledScopedSymbolDefinition(scopedDefinition) && scopedDefinition.symbolFilePathMatches) { + return scopedDefinition.symbolFilePathMatches(symbol.filePath); + } + + return globMatch(symbol.filePath, definition.matchSymbolFilePath); } diff --git a/packages/extension/tests/shared/visibleGraph/scope.test.ts b/packages/extension/tests/shared/visibleGraph/scope.test.ts index 2f15344b3..000fe7ec5 100644 --- a/packages/extension/tests/shared/visibleGraph/scope.test.ts +++ b/packages/extension/tests/shared/visibleGraph/scope.test.ts @@ -195,6 +195,18 @@ describe('shared/visibleGraph/scope', () => { ]); }); + it('precompiles scoped symbol file path matchers', () => { + const scopedDefinitions = getScopedSymbolDefinitions({ + nodes: [ + { type: 'plugin:codegraphy.gdscript:symbol:godot-class-name', enabled: true }, + ], + edges: [], + }); + + expect(scopedDefinitions[0]?.symbolFilePathMatches?.('scripts/player.gd')).toBe(true); + expect(scopedDefinitions[0]?.symbolFilePathMatches?.('scripts/player.ts')).toBe(false); + }); + it('keeps symbol nodes that are disconnected after edge scope is applied', () => { const result = applyGraphScope( { From 9aa7ee4ad890efec955432e56c7a1147e789a2c4 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 05:52:10 -0700 Subject: [PATCH 077/192] perf: skip unconstrained legend rule checks --- .../webview/search/filtering/rules/nodes.ts | 64 +++++++++++++++---- .../search/filtering/rules/nodes.test.ts | 23 ++++++- 2 files changed, 72 insertions(+), 15 deletions(-) diff --git a/packages/extension/src/webview/search/filtering/rules/nodes.ts b/packages/extension/src/webview/search/filtering/rules/nodes.ts index e45d5cb1f..ed9c2c9aa 100644 --- a/packages/extension/src/webview/search/filtering/rules/nodes.ts +++ b/packages/extension/src/webview/search/filtering/rules/nodes.ts @@ -6,6 +6,7 @@ import { ruleTargetsNodes } from './nodeMatcher'; export interface CompiledNodeLegendRule { caseInsensitivePatternMatches: (value: string) => boolean; + hasConstraints: boolean; patternMatches: (value: string) => boolean; rule: IGroup; symbolFilePathMatches?: (value: string) => boolean; @@ -19,11 +20,24 @@ export function getOrderedActiveRules(legends: IGroup[]): IGroup[] { .reverse(); } +function hasNodeLegendConstraints(rule: IGroup): boolean { + return Boolean( + rule.matchNodeType + || rule.matchSymbolKind + || rule.matchSymbolKinds?.length + || rule.matchSymbolPluginKind + || rule.matchSymbolSource + || rule.matchSymbolLanguage + || rule.matchSymbolFilePath, + ); +} + export function compileNodeLegendRules(activeRules: IGroup[]): CompiledNodeLegendRule[] { return activeRules .filter(ruleTargetsNodes) .map((rule) => ({ caseInsensitivePatternMatches: createGlobMatcher(rule.pattern.toLowerCase()), + hasConstraints: hasNodeLegendConstraints(rule), patternMatches: createGlobMatcher(rule.pattern), rule, ...(rule.matchSymbolFilePath @@ -63,22 +77,44 @@ function compiledRuleConstraintsMatchNode( node: IGraphData['nodes'][number], compiledRule: CompiledNodeLegendRule, ): boolean { + if (!compiledRule.hasConstraints) { + return true; + } + const { rule } = compiledRule; const symbol = node.symbol; - const exactMatches = [ - [rule.matchNodeType, node.nodeType], - [rule.matchSymbolKind, symbol?.kind], - [rule.matchSymbolPluginKind, symbol?.pluginKind], - [rule.matchSymbolSource, symbol?.source], - [rule.matchSymbolLanguage, symbol?.language], - ]; - const exactFieldsMatch = exactMatches.every(([expected, actual]) => !expected || expected === actual); - const symbolKindsMatch = !rule.matchSymbolKinds - || Boolean(symbol?.kind && rule.matchSymbolKinds.includes(symbol.kind)); - const symbolPathMatches = !compiledRule.symbolFilePathMatches - || Boolean(symbol?.filePath && compiledRule.symbolFilePathMatches(symbol.filePath)); - - return exactFieldsMatch && symbolKindsMatch && symbolPathMatches; + if (rule.matchNodeType && rule.matchNodeType !== node.nodeType) { + return false; + } + + if (rule.matchSymbolKind && rule.matchSymbolKind !== symbol?.kind) { + return false; + } + + if (rule.matchSymbolPluginKind && rule.matchSymbolPluginKind !== symbol?.pluginKind) { + return false; + } + + if (rule.matchSymbolSource && rule.matchSymbolSource !== symbol?.source) { + return false; + } + + if (rule.matchSymbolLanguage && rule.matchSymbolLanguage !== symbol?.language) { + return false; + } + + if (rule.matchSymbolKinds && (!symbol?.kind || !rule.matchSymbolKinds.includes(symbol.kind))) { + return false; + } + + if ( + compiledRule.symbolFilePathMatches + && (!symbol?.filePath || !compiledRule.symbolFilePathMatches(symbol.filePath)) + ) { + return false; + } + + return true; } function compiledRulePatternMatchesNode( diff --git a/packages/extension/tests/webview/search/filtering/rules/nodes.test.ts b/packages/extension/tests/webview/search/filtering/rules/nodes.test.ts index 6ca69e2aa..9a4bffe93 100644 --- a/packages/extension/tests/webview/search/filtering/rules/nodes.test.ts +++ b/packages/extension/tests/webview/search/filtering/rules/nodes.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest'; import { DEFAULT_NODE_COLOR } from '../../../../../src/shared/fileColors'; -import { applyNodeLegendRules, getOrderedActiveRules } from '../../../../../src/webview/search/filtering/rules/nodes'; +import { + applyNodeLegendRules, + compileNodeLegendRules, + getOrderedActiveRules, +} from '../../../../../src/webview/search/filtering/rules/nodes'; import { buildGraphViewMergedGroups } from '../../../../../src/extension/graphView/groups/merged'; describe('search/filtering/rules/nodes', () => { @@ -15,6 +19,23 @@ describe('search/filtering/rules/nodes', () => { expect(legends.map((rule) => rule.id)).toEqual(['first', 'disabled', 'last']); }); + it('precomputes whether compiled rules have scoped constraints', () => { + const compiledRules = compileNodeLegendRules([ + { id: 'plain', pattern: 'src/**', color: '#111111' }, + { + id: 'scoped', + pattern: '**', + color: '#222222', + matchNodeType: 'symbol', + }, + ]); + + expect(compiledRules.map((rule) => [rule.rule.id, rule.hasConstraints])).toEqual([ + ['plain', false], + ['scoped', true], + ]); + }); + it('drops disabled rules and applies active rules from bottom to top', () => { const activeRules = getOrderedActiveRules([ { id: 'specific', pattern: 'src/App.ts', color: '#00ff00', imageUrl: 'icon.png' }, From 2302037e2c07696d7a36c3c52a22829f64246ce7 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 06:14:47 -0700 Subject: [PATCH 078/192] perf: defer stale cache background sync --- .../graphView/provider/analysis/methods.ts | 43 ++++++++++++++++--- .../provider/analysis/methods.test.ts | 22 +++++++--- 2 files changed, 55 insertions(+), 10 deletions(-) diff --git a/packages/extension/src/extension/graphView/provider/analysis/methods.ts b/packages/extension/src/extension/graphView/provider/analysis/methods.ts index 49934ab48..8e9ae0994 100644 --- a/packages/extension/src/extension/graphView/provider/analysis/methods.ts +++ b/packages/extension/src/extension/graphView/provider/analysis/methods.ts @@ -137,7 +137,10 @@ export function createDefaultGraphViewProviderAnalysisMethodDependencies(): Grap interface FullIndexAnalysisCoordinator { runAfterFullIndexAnalysis(runAnalysis: () => Promise): Promise; runFullIndexAnalysis(runAnalysis: () => Promise): Promise; - runFullIndexAnalysisInBackground(runAnalysis: () => Promise): void; + runFullIndexAnalysisInBackground( + runAnalysis: () => Promise, + shouldStart?: () => boolean, + ): void; waitForFullIndexAnalysis(): Promise; waitForForegroundFullIndexAnalysis(): Promise; } @@ -147,6 +150,16 @@ function createFullIndexAnalysisCoordinator( ): FullIndexAnalysisCoordinator { let fullIndexAnalysisPromise: Promise | undefined; let fullIndexAnalysisKind: 'background' | 'foreground' | undefined; + let scheduledBackgroundAnalysis: ReturnType | undefined; + + const clearScheduledBackgroundAnalysis = (): void => { + if (scheduledBackgroundAnalysis === undefined) { + return; + } + + clearTimeout(scheduledBackgroundAnalysis); + scheduledBackgroundAnalysis = undefined; + }; const waitForFullIndexAnalysis = async (): Promise => { if (!fullIndexAnalysisPromise) { @@ -174,6 +187,10 @@ function createFullIndexAnalysisCoordinator( runAnalysis: () => Promise, kind: 'background' | 'foreground' = 'foreground', ): Promise => { + if (kind === 'foreground') { + clearScheduledBackgroundAnalysis(); + } + if (fullIndexAnalysisPromise) { await fullIndexAnalysisPromise; return; @@ -194,15 +211,28 @@ function createFullIndexAnalysisCoordinator( const runFullIndexAnalysisInBackground = ( runAnalysis: () => Promise, + shouldStart: () => boolean = () => true, ): void => { - void runFullIndexAnalysis(runAnalysis, 'background').catch(error => { - dependencies.logError('[CodeGraphy] Background cache sync failed:', error); - }); + if (scheduledBackgroundAnalysis !== undefined || fullIndexAnalysisPromise) { + return; + } + + scheduledBackgroundAnalysis = setTimeout(() => { + scheduledBackgroundAnalysis = undefined; + if (!shouldStart()) { + return; + } + + void runFullIndexAnalysis(runAnalysis, 'background').catch(error => { + dependencies.logError('[CodeGraphy] Background cache sync failed:', error); + }); + }, 0); }; const runAfterFullIndexAnalysis = async ( runAnalysis: () => Promise, ): Promise => { + clearScheduledBackgroundAnalysis(); await waitForFullIndexAnalysis(); await runAnalysis(); }; @@ -338,7 +368,10 @@ export function createGraphViewProviderAnalysisMethods( await _loadAndSendData(); if (canReplayStaleCache(source)) { - fullIndexAnalysis.runFullIndexAnalysisInBackground(_analyzeAndSendData); + fullIndexAnalysis.runFullIndexAnalysisInBackground( + _analyzeAndSendData, + () => source._analysisController === undefined, + ); } }, _indexAndSendData: () => fullIndexAnalysis.runFullIndexAnalysis(_indexAndSendData), diff --git a/packages/extension/tests/extension/graphView/provider/analysis/methods.test.ts b/packages/extension/tests/extension/graphView/provider/analysis/methods.test.ts index 23f1e34aa..7877cfc43 100644 --- a/packages/extension/tests/extension/graphView/provider/analysis/methods.test.ts +++ b/packages/extension/tests/extension/graphView/provider/analysis/methods.test.ts @@ -326,7 +326,7 @@ describe('graphView/provider/analysis/methods', () => { expect(runAnalysisRequest).toHaveBeenCalledOnce(); }); - it('starts stale cache sync in the background after cached load returns', async () => { + it('defers stale cache sync until after cached load returns to the caller', async () => { const source = createSource({ _analyzer: { getIndexStatus: vi.fn(() => ({ @@ -365,6 +365,10 @@ describe('graphView/provider/analysis/methods', () => { await methods._loadAndSendData(); await Promise.resolve(); + expect(events).toEqual(['load:start', 'load:end']); + + await new Promise(resolve => setTimeout(resolve, 0)); + expect(events).toEqual(['load:start', 'load:end', 'analyze:start']); finishCacheSync?.(); @@ -373,7 +377,7 @@ describe('graphView/provider/analysis/methods', () => { expect(events).toEqual(['load:start', 'load:end', 'analyze:start', 'analyze:end']); }); - it('starts incremental analysis without waiting for stale cache background sync', async () => { + it('starts incremental analysis before deferred stale cache background sync', async () => { const source = createSource({ _firstAnalysis: false, _analyzer: { @@ -413,18 +417,26 @@ describe('graphView/provider/analysis/methods', () => { await methods._loadAndSendData(); await Promise.resolve(); const incremental = methods._incrementalAnalyzeAndSendData(['src/changed.ts']); - await Promise.resolve(); + await incremental; expect(events).toEqual([ 'load:start', 'load:end', - 'analyze:start', 'incremental:start', 'incremental:end', ]); + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(events).toEqual([ + 'load:start', + 'load:end', + 'incremental:start', + 'incremental:end', + 'analyze:start', + ]); + finishCacheSync?.(); - await incremental; await Promise.resolve(); expect(source._changedFilePaths).toEqual(['src/changed.ts']); From 8986caf72c039b9b751aefd5ca687e81bab20c4b Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 06:22:33 -0700 Subject: [PATCH 079/192] perf: cache built-in default groups --- .changeset/smooth-large-graphs.md | 5 ++ .../graphView/groups/defaults/builtIn.ts | 28 ++++++++- .../graphView/groups/defaults/builtIn.test.ts | 60 ++++++++++++++++++- 3 files changed, 90 insertions(+), 3 deletions(-) create mode 100644 .changeset/smooth-large-graphs.md diff --git a/.changeset/smooth-large-graphs.md b/.changeset/smooth-large-graphs.md new file mode 100644 index 000000000..6e16a6dab --- /dev/null +++ b/.changeset/smooth-large-graphs.md @@ -0,0 +1,5 @@ +--- +"@codegraphy-dev/extension": patch +--- + +Improve large graph responsiveness by reducing repeated graph scope and legend work. diff --git a/packages/extension/src/extension/graphView/groups/defaults/builtIn.ts b/packages/extension/src/extension/graphView/groups/defaults/builtIn.ts index 3432d1801..90fe9a1fd 100644 --- a/packages/extension/src/extension/graphView/groups/defaults/builtIn.ts +++ b/packages/extension/src/extension/graphView/groups/defaults/builtIn.ts @@ -6,6 +6,19 @@ import { getCodeGraphyConfiguration } from '../../../repoSettings/current'; import { getMaterialThemeDefaultGroups } from './materialTheme/view'; import { getSymbolDefaultGroups } from './symbols'; +const builtInDefaultGroupsCache = new WeakMap>(); + +function getExtensionUriCacheKey(extensionUri: vscode.Uri): string { + return extensionUri.fsPath || extensionUri.path || extensionUri.toString(); +} + +function getBuiltInDefaultGroupsCacheKey( + extensionUri: vscode.Uri, + includeFolderMatches: boolean, +): string { + return `${getExtensionUriCacheKey(extensionUri)}|folder:${includeFolderMatches ? '1' : '0'}`; +} + export function getBuiltInGraphViewDefaultGroups( graphData: IGraphData, extensionUri: vscode.Uri, @@ -13,11 +26,22 @@ export function getBuiltInGraphViewDefaultGroups( const config = getCodeGraphyConfiguration(); const defaultNodeVisibility = createDefaultNodeVisibility(); const configuredNodeVisibility = config.get>('nodeVisibility', {}) ?? {}; + const includeFolderMatches = configuredNodeVisibility.folder ?? defaultNodeVisibility.folder; + const cacheKey = getBuiltInDefaultGroupsCacheKey(extensionUri, includeFolderMatches); + const cachedGroups = builtInDefaultGroupsCache.get(graphData)?.get(cacheKey); + if (cachedGroups) { + return cachedGroups; + } - return [ + const groups = [ ...getMaterialThemeDefaultGroups(graphData, extensionUri, { - includeFolderMatches: configuredNodeVisibility.folder ?? defaultNodeVisibility.folder, + includeFolderMatches, }), ...getSymbolDefaultGroups(graphData), ]; + + const cachedGroupsByInput = builtInDefaultGroupsCache.get(graphData) ?? new Map(); + cachedGroupsByInput.set(cacheKey, groups); + builtInDefaultGroupsCache.set(graphData, cachedGroupsByInput); + return groups; } diff --git a/packages/extension/tests/extension/graphView/groups/defaults/builtIn.test.ts b/packages/extension/tests/extension/graphView/groups/defaults/builtIn.test.ts index b682d56b2..7caf541b8 100644 --- a/packages/extension/tests/extension/graphView/groups/defaults/builtIn.test.ts +++ b/packages/extension/tests/extension/graphView/groups/defaults/builtIn.test.ts @@ -1,9 +1,16 @@ import * as vscode from 'vscode'; import * as path from 'node:path'; -import { describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { getBuiltInGraphViewDefaultGroups } from '../../../../../src/extension/graphView/groups/defaults/builtIn'; +import type { IGraphData } from '../../../../../src/shared/graph/contracts'; describe('graphView/builtInDefaultGroups', () => { + beforeEach(() => { + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ + get: (_key: string, defaultValue: T): T => defaultValue, + } as never); + }); + it('materializes matching Material theme defaults for the current graph and keeps Material Icon Theme metadata', () => { const groups = getBuiltInGraphViewDefaultGroups( { @@ -210,4 +217,55 @@ describe('graphView/builtInDefaultGroups', () => { 'default:symbol-kind:plugin', ])); }); + + it('reuses computed defaults for repeated same graph inputs', () => { + const graphData: IGraphData = { + nodes: [ + { id: 'package.json', label: 'package.json', color: '#000000', nodeType: 'file' }, + { + id: 'src/app.ts#format:function', + label: 'format', + color: '#000000', + nodeType: 'symbol', + symbol: { + id: 'src/app.ts#format:function', + name: 'format', + kind: 'function', + filePath: 'src/app.ts', + }, + }, + ], + edges: [], + }; + const extensionUri = vscode.Uri.file(path.resolve(process.cwd(), '../..')); + + const first = getBuiltInGraphViewDefaultGroups(graphData, extensionUri); + const second = getBuiltInGraphViewDefaultGroups(graphData, extensionUri); + + expect(second).toBe(first); + }); + + it('recomputes defaults when folder visibility changes for the same graph', () => { + let nodeVisibility: Record = {}; + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ + get: (key: string, defaultValue: T): T => ( + key === 'nodeVisibility' ? nodeVisibility as T : defaultValue + ), + } as never); + + const graphData: IGraphData = { + nodes: [ + { id: 'src', label: 'src', color: '#000000', nodeType: 'folder' }, + { id: 'src/app.ts', label: 'app.ts', color: '#000000', nodeType: 'file' }, + ], + edges: [], + }; + const extensionUri = vscode.Uri.file(path.resolve(process.cwd(), '../..')); + const hiddenFolders = getBuiltInGraphViewDefaultGroups(graphData, extensionUri); + + nodeVisibility = { folder: true }; + const visibleFolders = getBuiltInGraphViewDefaultGroups(graphData, extensionUri); + + expect(visibleFolders).not.toBe(hiddenFolders); + }); }); From e7ccd67821a4099b260bc09a532d45200f9f59d6 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 06:37:33 -0700 Subject: [PATCH 080/192] perf: index material path rules by basename --- .../groups/defaults/materialTheme/pathMatch.ts | 13 +++++++++++-- .../defaults/materialTheme/pathMatch.test.ts | 18 ++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/packages/extension/src/extension/graphView/groups/defaults/materialTheme/pathMatch.ts b/packages/extension/src/extension/graphView/groups/defaults/materialTheme/pathMatch.ts index aaefb1123..06bf96fc4 100644 --- a/packages/extension/src/extension/graphView/groups/defaults/materialTheme/pathMatch.ts +++ b/packages/extension/src/extension/graphView/groups/defaults/materialTheme/pathMatch.ts @@ -19,6 +19,7 @@ interface MaterialPathRuleEntry { export interface MaterialPathRuleMatcher { baseNameRules: Map; pathRules: MaterialPathRuleEntry[]; + pathRulesByLowerBaseName: Map; } export function createMaterialPathRuleMatcher( @@ -26,6 +27,7 @@ export function createMaterialPathRuleMatcher( ): MaterialPathRuleMatcher { const baseNameRules = new Map(); const pathRules: MaterialPathRuleEntry[] = []; + const pathRulesByLowerBaseName = new Map(); for (const [ruleKey, iconName] of Object.entries(rules)) { const normalizedRule = normalizePathSeparators(ruleKey); @@ -34,6 +36,10 @@ export function createMaterialPathRuleMatcher( if (normalizedRule.includes('/')) { pathRules.push(entry); + const lowerBaseName = getMaterialBaseName(normalizedRule).toLowerCase(); + const rulesForBaseName = pathRulesByLowerBaseName.get(lowerBaseName) ?? []; + rulesForBaseName.push(entry); + pathRulesByLowerBaseName.set(lowerBaseName, rulesForBaseName); continue; } @@ -41,8 +47,11 @@ export function createMaterialPathRuleMatcher( } pathRules.sort((left, right) => right.normalizedRule.length - left.normalizedRule.length); + for (const rulesForBaseName of pathRulesByLowerBaseName.values()) { + rulesForBaseName.sort((left, right) => right.normalizedRule.length - left.normalizedRule.length); + } - return { baseNameRules, pathRules }; + return { baseNameRules, pathRules, pathRulesByLowerBaseName }; } export function findLongestPathMatch( @@ -63,7 +72,7 @@ export function findLongestPathMatchWithMatcher( kind: PathMatchKind, ): MaterialMatch | undefined { const context = getPathMatchContext(subjectPath); - for (const rule of matcher.pathRules) { + for (const rule of matcher.pathRulesByLowerBaseName.get(context.lowerBaseName) ?? []) { if (!matchesPathRule(context, rule.normalizedRule, rule.lowerRule)) { continue; } diff --git a/packages/extension/tests/extension/graphView/groups/defaults/materialTheme/pathMatch.test.ts b/packages/extension/tests/extension/graphView/groups/defaults/materialTheme/pathMatch.test.ts index 50a2a4cb4..161dd4a6c 100644 --- a/packages/extension/tests/extension/graphView/groups/defaults/materialTheme/pathMatch.test.ts +++ b/packages/extension/tests/extension/graphView/groups/defaults/materialTheme/pathMatch.test.ts @@ -37,6 +37,24 @@ describe('graphView/materialTheme/pathMatch', () => { }); }); + it('indexes scoped path rules by basename for candidate lookup', () => { + const matcher = createMaterialPathRuleMatcher({ + 'apps/web/vite.config.ts': 'web-vite', + 'packages/api/vite.config.ts': 'api-vite', + 'apps/web/package.json': 'package', + }); + + expect( + matcher.pathRulesByLowerBaseName.get('vite.config.ts')?.map(rule => rule.normalizedRule), + ).toEqual([ + 'packages/api/vite.config.ts', + 'apps/web/vite.config.ts', + ]); + expect( + matcher.pathRulesByLowerBaseName.get('package.json')?.map(rule => rule.normalizedRule), + ).toEqual(['apps/web/package.json']); + }); + it('returns undefined for non-matches', () => { expect(findLongestPathMatch('src/main.ts', { 'package.json': 'package' }, 'fileName')).toBeUndefined(); }); From f8e7e9f27263e1ebca1f8085a9c76e03be1f3bf8 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 06:54:29 -0700 Subject: [PATCH 081/192] perf: warm graph cache with sync loader --- .../extension/pipeline/service/base/state.ts | 5 +-- .../pipeline/service/base/state.test.ts | 33 +++++++++---------- .../installed/activation.test.ts | 3 +- .../installed/statuses.test.ts | 3 +- .../pluginIntegration/typescript.test.ts | 3 +- 5 files changed, 24 insertions(+), 23 deletions(-) diff --git a/packages/extension/src/extension/pipeline/service/base/state.ts b/packages/extension/src/extension/pipeline/service/base/state.ts index 2675d3a3d..17dee518a 100644 --- a/packages/extension/src/extension/pipeline/service/base/state.ts +++ b/packages/extension/src/extension/pipeline/service/base/state.ts @@ -15,7 +15,7 @@ import { EventBus } from '../../../../core/plugins/events/bus'; import type { IWorkspaceAnalysisCache } from '../../cache'; import type { IGraphData } from '../../../../shared/graph/contracts'; import { - loadWorkspaceAnalysisDatabaseCacheAsync, + loadWorkspaceAnalysisDatabaseCache, readWorkspaceAnalysisDatabaseSnapshot, type WorkspaceAnalysisDatabaseSnapshot, } from '../../database/cache/storage'; @@ -135,7 +135,8 @@ export abstract class WorkspacePipelineStateBase { return; } - this._cacheHydrationPromise ??= loadWorkspaceAnalysisDatabaseCacheAsync(workspaceRoot) + this._cacheHydrationPromise ??= Promise.resolve() + .then(() => loadWorkspaceAnalysisDatabaseCache(workspaceRoot)) .then((cache) => { if (Object.keys(this._cache.files).length === 0) { this._cache = cache; diff --git a/packages/extension/tests/extension/pipeline/service/base/state.test.ts b/packages/extension/tests/extension/pipeline/service/base/state.test.ts index d7e203441..4ae532480 100644 --- a/packages/extension/tests/extension/pipeline/service/base/state.test.ts +++ b/packages/extension/tests/extension/pipeline/service/base/state.test.ts @@ -6,11 +6,13 @@ import { PluginRegistry } from '../../../../../src/core/plugins/registry/manager import { WorkspacePipelineStateBase } from '../../../../../src/extension/pipeline/service/base/state'; const stateBaseHarness = vi.hoisted(() => ({ + loadWorkspaceAnalysisDatabaseCache: vi.fn(), loadWorkspaceAnalysisDatabaseCacheAsync: vi.fn(), readWorkspaceAnalysisDatabaseSnapshot: vi.fn(), })); vi.mock('../../../../../src/extension/pipeline/database/cache/storage.ts', () => ({ + loadWorkspaceAnalysisDatabaseCache: stateBaseHarness.loadWorkspaceAnalysisDatabaseCache, loadWorkspaceAnalysisDatabaseCacheAsync: stateBaseHarness.loadWorkspaceAnalysisDatabaseCacheAsync, readWorkspaceAnalysisDatabaseSnapshot: stateBaseHarness.readWorkspaceAnalysisDatabaseSnapshot, })); @@ -104,24 +106,7 @@ describe('extension/pipeline/service/stateBase', () => { }); it('warms the repo-local Graph Cache using the shared hydration promise', async () => { - let resolveHydration!: (cache: unknown) => void; - stateBaseHarness.loadWorkspaceAnalysisDatabaseCacheAsync.mockReturnValueOnce( - new Promise(resolve => { - resolveHydration = resolve; - }), - ); - const state = new TestWorkspacePipelineState(createContext(), '/workspace') as TestWorkspacePipelineState & { - _cache: unknown; - warmGraphCache(): Promise; - }; - - const firstWarm = state.warmGraphCache(); - const secondWarm = state.warmGraphCache(); - - expect(stateBaseHarness.loadWorkspaceAnalysisDatabaseCacheAsync).toHaveBeenCalledOnce(); - expect(stateBaseHarness.loadWorkspaceAnalysisDatabaseCacheAsync).toHaveBeenCalledWith('/workspace'); - - resolveHydration({ + stateBaseHarness.loadWorkspaceAnalysisDatabaseCache.mockReturnValueOnce({ version: '2.1.0', files: { 'src/app.ts': { @@ -130,9 +115,21 @@ describe('extension/pipeline/service/stateBase', () => { }, }, }); + const state = new TestWorkspacePipelineState(createContext(), '/workspace') as TestWorkspacePipelineState & { + _cache: unknown; + warmGraphCache(): Promise; + }; + + const firstWarm = state.warmGraphCache(); + const secondWarm = state.warmGraphCache(); + + expect(stateBaseHarness.loadWorkspaceAnalysisDatabaseCache).not.toHaveBeenCalled(); await Promise.all([firstWarm, secondWarm]); + expect(stateBaseHarness.loadWorkspaceAnalysisDatabaseCache).toHaveBeenCalledOnce(); + expect(stateBaseHarness.loadWorkspaceAnalysisDatabaseCache).toHaveBeenCalledWith('/workspace'); + expect(stateBaseHarness.loadWorkspaceAnalysisDatabaseCacheAsync).not.toHaveBeenCalled(); expect(state._cache).toEqual({ version: '2.1.0', files: { diff --git a/packages/extension/tests/extension/pluginIntegration/installed/activation.test.ts b/packages/extension/tests/extension/pluginIntegration/installed/activation.test.ts index 1f4c40562..2fb1d660b 100644 --- a/packages/extension/tests/extension/pluginIntegration/installed/activation.test.ts +++ b/packages/extension/tests/extension/pluginIntegration/installed/activation.test.ts @@ -158,7 +158,8 @@ describe('extension/pluginIntegration/installedPluginActivation', () => { expect(pluginIds).toEqual( expect.arrayContaining(['codegraphy.markdown', installedPackage!.pluginId]), ); - expect(mockState.databaseCache.loadWorkspaceAnalysisDatabaseCache).not.toHaveBeenCalled(); + expect(mockState.databaseCache.loadWorkspaceAnalysisDatabaseCache).toHaveBeenCalled(); + expect(mockState.databaseCache.loadWorkspaceAnalysisDatabaseCacheAsync).not.toHaveBeenCalled(); expect(mockState.databaseCache.saveWorkspaceAnalysisDatabaseCacheAsync).toHaveBeenCalled(); }, 15000); }); diff --git a/packages/extension/tests/extension/pluginIntegration/installed/statuses.test.ts b/packages/extension/tests/extension/pluginIntegration/installed/statuses.test.ts index 45374b188..7b1b37962 100644 --- a/packages/extension/tests/extension/pluginIntegration/installed/statuses.test.ts +++ b/packages/extension/tests/extension/pluginIntegration/installed/statuses.test.ts @@ -270,7 +270,8 @@ describe('extension/pluginIntegration/installedPluginStatuses', () => { ]), ); await internals._analysisMethods._analyzeAndSendData(); - expect(mockState.databaseCache.loadWorkspaceAnalysisDatabaseCache).not.toHaveBeenCalled(); + expect(mockState.databaseCache.loadWorkspaceAnalysisDatabaseCache).toHaveBeenCalled(); + expect(mockState.databaseCache.loadWorkspaceAnalysisDatabaseCacheAsync).not.toHaveBeenCalled(); expect(mockState.databaseCache.saveWorkspaceAnalysisDatabaseCacheAsync).toHaveBeenCalled(); }, 15000); diff --git a/packages/extension/tests/extension/pluginIntegration/typescript.test.ts b/packages/extension/tests/extension/pluginIntegration/typescript.test.ts index 1092e079a..05f6a79b6 100644 --- a/packages/extension/tests/extension/pluginIntegration/typescript.test.ts +++ b/packages/extension/tests/extension/pluginIntegration/typescript.test.ts @@ -164,7 +164,8 @@ describe('extension/pluginIntegration/typescript', () => { ).map((pluginInfo) => pluginInfo.plugin.id); expect(pluginIds).toContain('codegraphy.typescript'); - expect(mockState.databaseCache.loadWorkspaceAnalysisDatabaseCache).not.toHaveBeenCalled(); + expect(mockState.databaseCache.loadWorkspaceAnalysisDatabaseCache).toHaveBeenCalled(); + expect(mockState.databaseCache.loadWorkspaceAnalysisDatabaseCacheAsync).not.toHaveBeenCalled(); expect(mockState.databaseCache.clearWorkspaceAnalysisDatabaseCache).not.toHaveBeenCalled(); expect(mockState.databaseCache.saveWorkspaceAnalysisDatabaseCache).not.toHaveBeenCalled(); expect(mockState.databaseCache.saveWorkspaceAnalysisDatabaseCacheAsync).toHaveBeenCalled(); From e730cd7f99b73be398ce188d2e49f97d31b43832 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 07:01:58 -0700 Subject: [PATCH 082/192] perf: cache graph edge target resolution --- packages/core/src/graph/edges.ts | 62 ++++++++++++++++++++++--- packages/core/tests/graph/edges.test.ts | 17 +++++++ 2 files changed, 72 insertions(+), 7 deletions(-) diff --git a/packages/core/src/graph/edges.ts b/packages/core/src/graph/edges.ts index 39f3552c6..1540c7a9a 100644 --- a/packages/core/src/graph/edges.ts +++ b/packages/core/src/graph/edges.ts @@ -11,9 +11,12 @@ import { createGraphEdgeId } from './edgeIdentity'; import { createEdgeSource } from './edgeSources'; import { getConnectionTargetId } from './edgeTargets'; +type ConnectionTargetResolver = typeof getConnectionTargetId; + export interface IWorkspaceGraphEdgesOptions { disabledPlugins: ReadonlySet; fileConnections: ReadonlyMap; + getConnectionTargetId?: ConnectionTargetResolver; getPluginForFile: (absolutePath: string) => IPlugin | undefined; workspaceRoot: string; } @@ -43,10 +46,9 @@ function appendConnectionEdge( disabledPlugins: ReadonlySet; edgeMap: Map; edges: IGraphEdge[]; - fileConnections: ReadonlyMap; nodeIds: Set; plugin: IPlugin | undefined; - workspaceRoot: string; + resolveConnectionTargetId: (plugin: IPlugin | undefined, connection: IProjectedConnection) => string | null; }, ): void { const sourcePluginId = connection.pluginId; @@ -54,11 +56,9 @@ function appendConnectionEdge( return; } - const targetId = getConnectionTargetId( + const targetId = options.resolveConnectionTargetId( options.plugin, connection, - options.fileConnections, - options.workspaceRoot, ); if (!targetId) { return; @@ -94,12 +94,56 @@ function appendConnectionEdge( options.edgeMap.set(edgeId, edge); } +function createTargetCacheKey( + plugin: IPlugin | undefined, + connection: IProjectedConnection, +): string | undefined { + if (connection.resolvedPath) { + return `${plugin?.id ?? ''}\0resolved\0${connection.resolvedPath}`; + } + + if (connection.specifier) { + return `${plugin?.id ?? ''}\0specifier\0${connection.specifier}`; + } + + return undefined; +} + +function createCachedConnectionTargetResolver( + resolveConnectionTargetId: ConnectionTargetResolver, + fileConnections: ReadonlyMap, + workspaceRoot: string, +): (plugin: IPlugin | undefined, connection: IProjectedConnection) => string | null { + const targetIdByKey = new Map(); + + return (plugin, connection) => { + const cacheKey = createTargetCacheKey(plugin, connection); + if (cacheKey && targetIdByKey.has(cacheKey)) { + return targetIdByKey.get(cacheKey) ?? null; + } + + const targetId = resolveConnectionTargetId( + plugin, + connection, + fileConnections, + workspaceRoot, + ); + + if (cacheKey) { + targetIdByKey.set(cacheKey, targetId); + } + + return targetId; + }; +} + export function buildWorkspaceGraphEdges( options: IWorkspaceGraphEdgesOptions, ): IWorkspaceGraphEdgeBuildResult { const { disabledPlugins, fileConnections, + getConnectionTargetId: resolveConnectionTargetId = getConnectionTargetId, getPluginForFile, workspaceRoot, } = options; @@ -108,6 +152,11 @@ export function buildWorkspaceGraphEdges( const edgeMap = new Map(); const edges: IGraphEdge[] = []; const nodeIds = new Set(); + const resolveTarget = createCachedConnectionTargetResolver( + resolveConnectionTargetId, + fileConnections, + workspaceRoot, + ); for (const [filePath, connections] of fileConnections) { nodeIds.add(filePath); @@ -120,10 +169,9 @@ export function buildWorkspaceGraphEdges( disabledPlugins, edgeMap, edges, - fileConnections, nodeIds, plugin, - workspaceRoot, + resolveConnectionTargetId: resolveTarget, }); } } diff --git a/packages/core/tests/graph/edges.test.ts b/packages/core/tests/graph/edges.test.ts index b18cd3ecf..6ae9ffa6f 100644 --- a/packages/core/tests/graph/edges.test.ts +++ b/packages/core/tests/graph/edges.test.ts @@ -114,6 +114,23 @@ describe('core/graph/edges', () => { ]); }); + it('reuses resolved target ids for repeated resolved paths', () => { + const resolveTarget = vi.fn(() => 'src/utils.ts'); + const result = buildWorkspaceGraphEdges(createOptions({ + fileConnections: new Map([ + ['src/index.ts', [ + { specifier: './utils', resolvedPath: '/workspace/src/utils.ts', kind: 'import', sourceId: 'import' }, + { specifier: './utils', resolvedPath: '/workspace/src/utils.ts', kind: 'reference', sourceId: 'reference' }, + ]], + ['src/utils.ts', []], + ]), + getConnectionTargetId: resolveTarget, + })); + + expect(resolveTarget).toHaveBeenCalledOnce(); + expect(result.edges.map(edge => edge.to)).toEqual(['src/utils.ts', 'src/utils.ts']); + }); + it('filters only the disabled plugin provenance when multiple plugins contribute to one file', () => { const result = buildWorkspaceGraphEdges(createOptions({ disabledPlugins: new Set(['plugin.markdown']), From b045a459aa6d90b4c72b29248ff1ef4c05d8bc59 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 07:13:13 -0700 Subject: [PATCH 083/192] perf: streamline visible graph edge scoping --- .../src/shared/visibleGraph/scope.ts | 44 +++++++++++++------ 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/packages/extension/src/shared/visibleGraph/scope.ts b/packages/extension/src/shared/visibleGraph/scope.ts index 813835f1a..0a5077ced 100644 --- a/packages/extension/src/shared/visibleGraph/scope.ts +++ b/packages/extension/src/shared/visibleGraph/scope.ts @@ -16,15 +16,23 @@ function getEdgeContainingFileKey( return `${edge.kind}\0${fromFile}\0${toFile}`; } +interface ScopedEdgeCandidate { + edge: IGraphData['edges'][number]; + endpointPreference?: number; + key?: string; +} + function keepMostSpecificUniqueEdges( nodes: IGraphData['nodes'], edges: IGraphData['edges'], ): IGraphData['edges'] { const nodeById = new Map(nodes.map((node) => [node.id, node])); const bestEndpointPreferenceByKey = new Map(); + const candidates: ScopedEdgeCandidate[] = []; for (const edge of edges) { if (edge.kind === 'contains') { + candidates.push({ edge }); continue; } const key = getEdgeContainingFileKey(edge, nodeById); @@ -36,24 +44,27 @@ function keepMostSpecificUniqueEdges( ? endpointPreference : Math.max(currentEndpointPreference, endpointPreference), ); + candidates.push({ edge, endpointPreference, key }); } const seenEdgeIds = new Set(); - return edges.filter((edge) => { - const key = getEdgeContainingFileKey(edge, nodeById); - const endpointPreference = getEndpointPreference(edge, nodeById); - if (edge.kind !== 'contains' - && endpointPreference !== (bestEndpointPreferenceByKey.get(key) ?? endpointPreference)) { - return false; + const uniqueEdges: IGraphData['edges'] = []; + + for (const candidate of candidates) { + if (candidate.key + && candidate.endpointPreference !== (bestEndpointPreferenceByKey.get(candidate.key) ?? candidate.endpointPreference)) { + continue; } - if (seenEdgeIds.has(edge.id)) { - return false; + if (seenEdgeIds.has(candidate.edge.id)) { + continue; } - seenEdgeIds.add(edge.id); - return true; - }); + seenEdgeIds.add(candidate.edge.id); + uniqueEdges.push(candidate.edge); + } + + return uniqueEdges; } function getEndpointPreference( @@ -130,11 +141,16 @@ function projectEdgesToVisibleNodes( ): IGraphData['edges'] { const allNodeById = new Map(graphNodes.map((node) => [node.id, node])); const visibleNodeIds = new Set(visibleNodes.map((node) => node.id)); + const projectedEdges: IGraphData['edges'] = []; - return edges.flatMap((edge) => { + for (const edge of edges) { const projectedEdge = projectEdgeToVisibleNodes(edge, allNodeById, visibleNodeIds); - return projectedEdge ? [projectedEdge] : []; - }); + if (projectedEdge) { + projectedEdges.push(projectedEdge); + } + } + + return projectedEdges; } export function applyGraphScope( From 8459cae46940c793496250727e159d421275d067 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 07:16:26 -0700 Subject: [PATCH 084/192] perf: include plugin filters in visible graph metric --- .../measure-visible-graph-monorepo.mjs | 73 ++++++++++++------- 1 file changed, 47 insertions(+), 26 deletions(-) diff --git a/scripts/performance/measure-visible-graph-monorepo.mjs b/scripts/performance/measure-visible-graph-monorepo.mjs index c13fb6246..60ca79bc1 100644 --- a/scripts/performance/measure-visible-graph-monorepo.mjs +++ b/scripts/performance/measure-visible-graph-monorepo.mjs @@ -19,6 +19,7 @@ const [ installedPluginCacheModule, settingsStorageModule, settingsDefaultsModule, + indexingRegistryModule, visibleGraphModule, edgeTypesModule, nodeTypesModule, @@ -31,6 +32,7 @@ const [ import('../../packages/core/src/plugins/installedPluginCache/storage.ts').then(unwrapModule), import('../../packages/core/src/workspace/settingsStorage.ts').then(unwrapModule), import('../../packages/core/src/workspace/settingsDefaults.ts').then(unwrapModule), + import('../../packages/core/src/indexing/registry.ts').then(unwrapModule), import('../../packages/extension/src/shared/visibleGraph/index.ts').then(unwrapModule), import('../../packages/extension/src/shared/graphControls/defaults/edgeTypes.ts').then(unwrapModule), import('../../packages/extension/src/shared/graphControls/defaults/nodeTypes.ts').then(unwrapModule), @@ -44,6 +46,7 @@ const { createDisabledPluginSet, createPluginActivityState } = activityStateModu const { readCodeGraphyInstalledPluginCache } = installedPluginCacheModule; const { readCodeGraphyWorkspaceSettings } = settingsStorageModule; const { CODEGRAPHY_MARKDOWN_PLUGIN_ID } = settingsDefaultsModule; +const { createWorkspaceIndexRegistry } = indexingRegistryModule; const { deriveVisibleGraph } = visibleGraphModule; const { CORE_GRAPH_EDGE_TYPES } = edgeTypesModule; const { CORE_GRAPH_NODE_TYPES } = nodeTypesModule; @@ -97,38 +100,53 @@ function createActivePluginSet(settings, userHomeDir) { return new Set(activityState.activePluginIds); } -function buildGraphDataFromGraphCache(workspacePath, userHomeDir) { +async function buildGraphDataFromGraphCache(workspacePath, userHomeDir) { const workspaceRoot = path.resolve(workspacePath); const settings = readCodeGraphyWorkspaceSettings(workspaceRoot); const disabledPlugins = createDisabledPluginSet(settings); const activePluginIds = createActivePluginSet(settings, userHomeDir); + const { registry } = await createWorkspaceIndexRegistry( + { userHomeDir }, + settings, + workspaceRoot, + disabledPlugins, + ); + const pluginFilterPatterns = registry.getPluginFilterPatterns(disabledPlugins); const cache = loadWorkspaceAnalysisDatabaseCache(workspaceRoot); const fileAnalysis = new Map( Object.entries(cache.files).map(([filePath, entry]) => [filePath, entry.analysis]), ); + const graphBuildStartedAt = performance.now(); + const graphData = buildWorkspaceGraphDataFromAnalysis({ + cacheFiles: cache.files, + churnCounts: {}, + directoryPaths: collectDirectoryPaths(Object.keys(cache.files)), + disabledPlugins, + fileAnalysis: filterInactivePluginFileAnalysis(fileAnalysis, activePluginIds), + getPluginForFile: () => undefined, + nodeVisibility: settings.nodeVisibility, + showOrphans: settings.showOrphans, + workspaceRoot, + }); return { - graphData: buildWorkspaceGraphDataFromAnalysis({ - cacheFiles: cache.files, - churnCounts: {}, - directoryPaths: collectDirectoryPaths(Object.keys(cache.files)), - disabledPlugins, - fileAnalysis: filterInactivePluginFileAnalysis(fileAnalysis, activePluginIds), - getPluginForFile: () => undefined, - nodeVisibility: settings.nodeVisibility, - showOrphans: settings.showOrphans, - workspaceRoot, - }), + graphData, + warmCacheGraphBuildMs: Math.round(performance.now() - graphBuildStartedAt), settings, + pluginFilterPatterns, }; } -function createActiveFilterPatterns(settings) { +function createActiveFilterPatterns(settings, pluginFilterPatterns = []) { const disabledCustomPatterns = new Set(settings.disabledCustomFilterPatterns ?? []); - return (settings.filterPatterns ?? []).filter(pattern => !disabledCustomPatterns.has(pattern)); + const disabledPluginPatterns = new Set(settings.disabledPluginFilterPatterns ?? []); + return [ + ...pluginFilterPatterns.filter(pattern => !disabledPluginPatterns.has(pattern)), + ...(settings.filterPatterns ?? []).filter(pattern => !disabledCustomPatterns.has(pattern)), + ]; } -function createVisibleGraphScenarioConfig(settings, overrides = {}) { +function createVisibleGraphScenarioConfig(settings, pluginFilterPatterns, overrides = {}) { const nodeVisibility = { ...(settings.nodeVisibility ?? {}), ...(overrides.nodeVisibility ?? {}), @@ -141,7 +159,7 @@ function createVisibleGraphScenarioConfig(settings, overrides = {}) { return buildVisibleGraphConfig({ edgeTypes: CORE_GRAPH_EDGE_TYPES, edgeVisibility, - filterPatterns: overrides.filterPatterns ?? createActiveFilterPatterns(settings), + filterPatterns: overrides.filterPatterns ?? createActiveFilterPatterns(settings, pluginFilterPatterns), nodeTypes: CORE_GRAPH_NODE_TYPES, nodeVisibility, searchOptions: overrides.searchOptions ?? { matchCase: false, wholeWord: false, regex: false }, @@ -150,17 +168,17 @@ function createVisibleGraphScenarioConfig(settings, overrides = {}) { }); } -function createVisibleGraphScenarios(settings) { +function createVisibleGraphScenarios(settings, pluginFilterPatterns) { return { - current: createVisibleGraphScenarioConfig(settings), - noFilters: createVisibleGraphScenarioConfig(settings, { filterPatterns: [] }), - foldersOn: createVisibleGraphScenarioConfig(settings, { + current: createVisibleGraphScenarioConfig(settings, pluginFilterPatterns), + noFilters: createVisibleGraphScenarioConfig(settings, pluginFilterPatterns, { filterPatterns: [] }), + foldersOn: createVisibleGraphScenarioConfig(settings, pluginFilterPatterns, { nodeVisibility: { folder: true }, }), - importsOff: createVisibleGraphScenarioConfig(settings, { + importsOff: createVisibleGraphScenarioConfig(settings, pluginFilterPatterns, { edgeVisibility: { import: false }, }), - searchGraph: createVisibleGraphScenarioConfig(settings, { + searchGraph: createVisibleGraphScenarioConfig(settings, pluginFilterPatterns, { searchQuery: 'graph', }), }; @@ -195,7 +213,8 @@ function measureVisibleGraphScenario(graphData, config, options) { export function measureVisibleGraphScenarios(graphData, settings, options = {}) { const iterations = options.iterations ?? DEFAULT_ITERATIONS; const warmupIterations = options.warmupIterations ?? DEFAULT_WARMUP_ITERATIONS; - const scenarios = createVisibleGraphScenarios(settings); + const pluginFilterPatterns = options.pluginFilterPatterns ?? []; + const scenarios = createVisibleGraphScenarios(settings, pluginFilterPatterns); return Object.fromEntries( Object.entries(scenarios).map(([scenarioName, config]) => [ @@ -206,16 +225,18 @@ export function measureVisibleGraphScenarios(graphData, settings, options = {}) } async function measureVisibleGraph({ workspacePath, userHomeDir, iterations, warmupIterations }) { - const startedAt = performance.now(); - const { graphData, settings } = buildGraphDataFromGraphCache(workspacePath, userHomeDir); - const warmCacheGraphBuildMs = Math.round(performance.now() - startedAt); + const { graphData, settings, pluginFilterPatterns, warmCacheGraphBuildMs } = + await buildGraphDataFromGraphCache(workspacePath, userHomeDir); const visibleGraphScenarios = measureVisibleGraphScenarios(graphData, settings, { iterations, + pluginFilterPatterns, warmupIterations, }); return { warmCacheGraphBuildMs, + activeFilterPatternCount: createActiveFilterPatterns(settings, pluginFilterPatterns).length, + pluginFilterPatternCount: pluginFilterPatterns.length, graphNodeCount: graphData.nodes.length, graphEdgeCount: graphData.edges.length, visibleGraphScenarios, From e2856392e2795cc3fd47f1de9e375165c15f424e Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 07:20:45 -0700 Subject: [PATCH 085/192] perf: group combined glob matcher checks --- packages/extension/src/shared/globMatch.ts | 117 +++++++++++++++++++-- 1 file changed, 111 insertions(+), 6 deletions(-) diff --git a/packages/extension/src/shared/globMatch.ts b/packages/extension/src/shared/globMatch.ts index a6952968f..3d1677fff 100644 --- a/packages/extension/src/shared/globMatch.ts +++ b/packages/extension/src/shared/globMatch.ts @@ -41,6 +41,13 @@ export function globToRegex(pattern: string): RegExp { type GlobMatcher = (filePath: string) => boolean; +interface CombinedFastGlobMatchers { + directMatchers: GlobMatcher[]; + literalSuffixes: string[]; + recursiveDirectoryNames: Set; + suffixes: string[]; +} + export function createGlobMatcher(pattern: string): GlobMatcher { const fastMatcher = createFastGlobMatcher(pattern); if (fastMatcher) { @@ -83,6 +90,94 @@ function createDirectChildMatcher(directoryPath: string): GlobMatcher { }; } +function collectFastMatcher( + fastMatchers: CombinedFastGlobMatchers, + pattern: string, +): boolean { + const recursivePattern = pattern.startsWith('**/') ? pattern.slice(3) : pattern; + + if (!recursivePattern.includes('*')) { + fastMatchers.literalSuffixes.push(recursivePattern); + return true; + } + + if ( + recursivePattern.startsWith('*.') + && recursivePattern.indexOf('*', 1) === -1 + && !recursivePattern.includes('/') + ) { + fastMatchers.suffixes.push(recursivePattern.slice(1)); + return true; + } + + if (recursivePattern.endsWith('/**')) { + const directoryPath = recursivePattern.slice(0, -3); + if (directoryPath && !directoryPath.includes('*')) { + if (!directoryPath.includes('/')) { + fastMatchers.recursiveDirectoryNames.add(directoryPath); + } else { + fastMatchers.directMatchers.push(createRecursiveDirectoryMatcher(directoryPath)); + } + return true; + } + } + + if (recursivePattern.endsWith('/*')) { + const directoryPath = recursivePattern.slice(0, -2); + if (directoryPath && !directoryPath.includes('*')) { + fastMatchers.directMatchers.push(createDirectChildMatcher(directoryPath)); + return true; + } + } + + return false; +} + +function matchesAnyPathSuffix(filePath: string, suffixes: readonly string[]): boolean { + for (const suffix of suffixes) { + if (matchesPathSuffix(filePath, suffix)) { + return true; + } + } + + return false; +} + +function hasAnySuffix(filePath: string, suffixes: readonly string[]): boolean { + for (const suffix of suffixes) { + if (filePath.endsWith(suffix)) { + return true; + } + } + + return false; +} + +function containsRecursiveDirectoryName( + filePath: string, + directoryNames: ReadonlySet, +): boolean { + if (directoryNames.size === 0) { + return false; + } + + let segmentStart = 0; + while (segmentStart < filePath.length) { + const slashIndex = filePath.indexOf('/', segmentStart); + if (slashIndex < 0) { + return false; + } + + if (directoryNames.has(filePath.slice(segmentStart, slashIndex))) { + return true; + } + + segmentStart = slashIndex + 1; + } + + return false; +} + function createFastGlobMatcher(pattern: string): GlobMatcher | undefined { if (!pattern) { return () => false; @@ -130,13 +225,15 @@ export function createCombinedGlobMatcher(patterns: readonly string[]): (filePat return createFastGlobMatcher(pattern) ?? createGlobMatcher(pattern); } - const fastMatchers: GlobMatcher[] = []; + const fastMatchers: CombinedFastGlobMatchers = { + directMatchers: [], + literalSuffixes: [], + recursiveDirectoryNames: new Set(), + suffixes: [], + }; const regexPatterns: string[] = []; for (const pattern of patterns) { - const fastMatcher = createFastGlobMatcher(pattern); - if (fastMatcher) { - fastMatchers.push(fastMatcher); - } else { + if (!collectFastMatcher(fastMatchers, pattern)) { regexPatterns.push(pattern); } } @@ -146,7 +243,15 @@ export function createCombinedGlobMatcher(patterns: readonly string[]): (filePat : null; return (filePath: string): boolean => { - for (const matcher of fastMatchers) { + if ( + containsRecursiveDirectoryName(filePath, fastMatchers.recursiveDirectoryNames) + || hasAnySuffix(filePath, fastMatchers.suffixes) + || matchesAnyPathSuffix(filePath, fastMatchers.literalSuffixes) + ) { + return true; + } + + for (const matcher of fastMatchers.directMatchers) { if (matcher(filePath)) { return true; } From d5158c51742edd23a080148de4ae22660e688650 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 07:36:46 -0700 Subject: [PATCH 086/192] perf: measure legend rule application --- .../measure-visible-graph-monorepo.mjs | 277 +++++++++++++++++- 1 file changed, 265 insertions(+), 12 deletions(-) diff --git a/scripts/performance/measure-visible-graph-monorepo.mjs b/scripts/performance/measure-visible-graph-monorepo.mjs index 60ca79bc1..cd2c3ee77 100644 --- a/scripts/performance/measure-visible-graph-monorepo.mjs +++ b/scripts/performance/measure-visible-graph-monorepo.mjs @@ -1,4 +1,5 @@ import { Buffer } from 'node:buffer'; +import { existsSync, readFileSync } from 'node:fs'; import { performance } from 'node:perf_hooks'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -24,6 +25,18 @@ const [ edgeTypesModule, nodeTypesModule, visibleGraphConfigModule, + nodeVisibilityDefaultsModule, + materialExtensionMatchModule, + materialPathMatchModule, + materialFileGroupsModule, + materialFolderGroupsModule, + materialGroupsModule, + symbolGroupsModule, + mergedGroupsModule, + legendRulesModule, + graphControlsRegistryModule, + graphControlsSnapshotModule, + visibleGraphModelModule, ] = await Promise.all([ import('../../packages/core/src/graph/data.ts').then(unwrapModule), import('../../packages/core/src/graphCache/database/storage.ts').then(unwrapModule), @@ -37,6 +50,18 @@ const [ import('../../packages/extension/src/shared/graphControls/defaults/edgeTypes.ts').then(unwrapModule), import('../../packages/extension/src/shared/graphControls/defaults/nodeTypes.ts').then(unwrapModule), import('../../packages/extension/src/webview/search/visibleGraphConfig.ts').then(unwrapModule), + import('../../packages/extension/src/shared/graphControls/defaults/maps.ts').then(unwrapModule), + import('../../packages/extension/src/extension/graphView/groups/defaults/materialTheme/extensionMatch.ts').then(unwrapModule), + import('../../packages/extension/src/extension/graphView/groups/defaults/materialTheme/pathMatch.ts').then(unwrapModule), + import('../../packages/extension/src/extension/graphView/groups/defaults/materialTheme/files.ts').then(unwrapModule), + import('../../packages/extension/src/extension/graphView/groups/defaults/materialTheme/folders.ts').then(unwrapModule), + import('../../packages/extension/src/extension/graphView/groups/defaults/materialTheme/groups.ts').then(unwrapModule), + import('../../packages/extension/src/extension/graphView/groups/defaults/symbols.ts').then(unwrapModule), + import('../../packages/extension/src/extension/graphView/groups/merged.ts').then(unwrapModule), + import('../../packages/extension/src/webview/search/filtering/rules.ts').then(unwrapModule), + import('../../packages/extension/src/extension/graphView/controls/send/definitions/registry.ts').then(unwrapModule), + import('../../packages/extension/src/extension/graphView/controls/send/definitions/snapshot.ts').then(unwrapModule), + import('../../packages/extension/src/shared/visibleGraph/model.ts').then(unwrapModule), ]); const { buildWorkspaceGraphDataFromAnalysis } = graphDataModule; @@ -51,10 +76,51 @@ const { deriveVisibleGraph } = visibleGraphModule; const { CORE_GRAPH_EDGE_TYPES } = edgeTypesModule; const { CORE_GRAPH_NODE_TYPES } = nodeTypesModule; const { buildVisibleGraphConfig } = visibleGraphConfigModule; +const { createDefaultNodeVisibility } = nodeVisibilityDefaultsModule; +const { createMaterialExtensionMatcher } = materialExtensionMatchModule; +const { createMaterialPathRuleMatcher } = materialPathMatchModule; +const { collectMaterialFileGroups } = materialFileGroupsModule; +const { collectMaterialFolderGroups } = materialFolderGroupsModule; +const { getManualGroups, sortMaterialGroups } = materialGroupsModule; +const { getSymbolDefaultGroups } = symbolGroupsModule; +const { buildGraphViewMergedGroups } = mergedGroupsModule; +const { applyLegendRules } = legendRulesModule; +const { readEdgeTypes, readGraphScopeCapabilities, readNodeTypes } = graphControlsRegistryModule; +const { captureGraphControlsSnapshot } = graphControlsSnapshotModule; +const { isFileNode } = visibleGraphModelModule; const DEFAULT_OUTPUT_PATH = 'reports/performance/visible-graph-latest.json'; const DEFAULT_ITERATIONS = 40; const DEFAULT_WARMUP_ITERATIONS = 5; +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); +const MATERIAL_THEME_MANIFEST_PATH = path.join( + REPO_ROOT, + 'packages', + 'extension', + 'node_modules', + 'material-icon-theme', + 'dist', + 'material-icons.json', +); + +function readRawWorkspaceSettings(workspaceRoot) { + try { + const parsed = JSON.parse(readFileSync( + path.join(workspaceRoot, '.codegraphy', 'settings.json'), + 'utf8', + )); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}; + } catch { + return {}; + } +} + +function readBenchmarkWorkspaceSettings(workspaceRoot) { + return { + ...readCodeGraphyWorkspaceSettings(workspaceRoot), + ...readRawWorkspaceSettings(workspaceRoot), + }; +} function readOptionValue(args, name) { const index = args.indexOf(name); @@ -100,9 +166,146 @@ function createActivePluginSet(settings, userHomeDir) { return new Set(activityState.activePluginIds); } +function createPluginDefaultGroup(pluginInfo, pattern, value) { + const group = { + id: `plugin:${pluginInfo.plugin.id}:${pattern}`, + pattern, + color: typeof value === 'string' ? value : value.color, + isPluginDefault: true, + pluginId: pluginInfo.plugin.id, + pluginName: pluginInfo.plugin.name, + }; + + if (typeof value === 'object') { + if (value.shape2D) group.shape2D = value.shape2D; + if (value.shape3D) group.shape3D = value.shape3D; + if (value.imagePath) { + group.imagePath = value.imagePath; + group.imageUrl = value.imagePath; + } + } + + return group; +} + +function getPluginDefaultGroups(registry, disabledPlugins) { + const result = []; + const addedIds = new Set(); + + for (const pluginInfo of registry.list()) { + if (disabledPlugins.has(pluginInfo.plugin.id)) continue; + const fileColors = pluginInfo.plugin.fileColors; + if (!fileColors) continue; + + for (const [pattern, value] of Object.entries(fileColors)) { + const id = `plugin:${pluginInfo.plugin.id}:${pattern}`; + if (addedIds.has(id)) continue; + + result.push(createPluginDefaultGroup(pluginInfo, pattern, value)); + addedIds.add(id); + } + } + + return result; +} + +function loadMaterialTheme() { + if (!existsSync(MATERIAL_THEME_MANIFEST_PATH)) { + return null; + } + + const manifest = JSON.parse(readFileSync(MATERIAL_THEME_MANIFEST_PATH, 'utf8')); + return { + extensionMatcher: manifest.fileExtensions + ? createMaterialExtensionMatcher(manifest.fileExtensions) + : undefined, + iconDataByName: new Map(), + manifest, + manifestPath: MATERIAL_THEME_MANIFEST_PATH, + pathMatchers: { + fileNames: manifest.fileNames + ? createMaterialPathRuleMatcher(manifest.fileNames) + : undefined, + folderNames: manifest.folderNames + ? createMaterialPathRuleMatcher(manifest.folderNames) + : undefined, + folderNamesExpanded: manifest.folderNamesExpanded + ? createMaterialPathRuleMatcher(manifest.folderNamesExpanded) + : undefined, + }, + }; +} + +function getMaterialThemeDefaultGroups(graphData, settings) { + const theme = loadMaterialTheme(); + if (!theme) { + return []; + } + + const defaultNodeVisibility = createDefaultNodeVisibility(); + const includeFolderMatches = + settings.nodeVisibility?.folder ?? defaultNodeVisibility.folder; + const groupsById = new Map(); + + for (const group of collectMaterialFileGroups(graphData, theme)) { + groupsById.set(group.id, group); + } + + if (includeFolderMatches) { + for (const group of collectMaterialFolderGroups(graphData, theme)) { + groupsById.set(group.id, group); + } + } + + for (const group of getManualGroups()) { + groupsById.set(group.id, group); + } + + return sortMaterialGroups([...groupsById.values()]); +} + +function getBuiltInDefaultGroups(graphData, settings) { + return [ + ...getMaterialThemeDefaultGroups(graphData, settings), + ...getSymbolDefaultGroups(graphData), + ]; +} + +function getResolvedLegendGroups({ graphData, registry, disabledPlugins, settings }) { + return buildGraphViewMergedGroups( + settings.legend ?? [], + getBuiltInDefaultGroups(graphData, settings), + getPluginDefaultGroups(registry, disabledPlugins), + settings.legendVisibility ?? {}, + settings.legendOrder ?? [], + ); +} + +function createSettingsConfiguration(settings) { + return { + get(key, defaultValue) { + return settings[key] ?? defaultValue; + }, + }; +} + +function captureGraphControls({ graphData, registry, disabledPlugins, settings }) { + const filePaths = graphData.nodes + .filter(isFileNode) + .map((node) => node.id); + + return captureGraphControlsSnapshot( + createSettingsConfiguration(settings), + graphData, + readNodeTypes(registry, disabledPlugins), + readEdgeTypes(registry, disabledPlugins), + readGraphScopeCapabilities(registry, filePaths, disabledPlugins), + ); +} + async function buildGraphDataFromGraphCache(workspacePath, userHomeDir) { const workspaceRoot = path.resolve(workspacePath); - const settings = readCodeGraphyWorkspaceSettings(workspaceRoot); + const settings = readBenchmarkWorkspaceSettings(workspaceRoot); const disabledPlugins = createDisabledPluginSet(settings); const activePluginIds = createActivePluginSet(settings, userHomeDir); const { registry } = await createWorkspaceIndexRegistry( @@ -131,6 +334,18 @@ async function buildGraphDataFromGraphCache(workspacePath, userHomeDir) { return { graphData, + graphControls: captureGraphControls({ + graphData, + registry, + disabledPlugins, + settings, + }), + legends: getResolvedLegendGroups({ + graphData, + registry, + disabledPlugins, + settings, + }), warmCacheGraphBuildMs: Math.round(performance.now() - graphBuildStartedAt), settings, pluginFilterPatterns, @@ -146,7 +361,7 @@ function createActiveFilterPatterns(settings, pluginFilterPatterns = []) { ]; } -function createVisibleGraphScenarioConfig(settings, pluginFilterPatterns, overrides = {}) { +function createVisibleGraphScenarioConfig(settings, pluginFilterPatterns, graphControls, overrides = {}) { const nodeVisibility = { ...(settings.nodeVisibility ?? {}), ...(overrides.nodeVisibility ?? {}), @@ -157,10 +372,10 @@ function createVisibleGraphScenarioConfig(settings, pluginFilterPatterns, overri }; return buildVisibleGraphConfig({ - edgeTypes: CORE_GRAPH_EDGE_TYPES, + edgeTypes: graphControls?.edgeTypes ?? CORE_GRAPH_EDGE_TYPES, edgeVisibility, filterPatterns: overrides.filterPatterns ?? createActiveFilterPatterns(settings, pluginFilterPatterns), - nodeTypes: CORE_GRAPH_NODE_TYPES, + nodeTypes: graphControls?.nodeTypes ?? CORE_GRAPH_NODE_TYPES, nodeVisibility, searchOptions: overrides.searchOptions ?? { matchCase: false, wholeWord: false, regex: false }, searchQuery: overrides.searchQuery ?? '', @@ -168,17 +383,17 @@ function createVisibleGraphScenarioConfig(settings, pluginFilterPatterns, overri }); } -function createVisibleGraphScenarios(settings, pluginFilterPatterns) { +function createVisibleGraphScenarios(settings, pluginFilterPatterns, graphControls) { return { - current: createVisibleGraphScenarioConfig(settings, pluginFilterPatterns), - noFilters: createVisibleGraphScenarioConfig(settings, pluginFilterPatterns, { filterPatterns: [] }), - foldersOn: createVisibleGraphScenarioConfig(settings, pluginFilterPatterns, { + current: createVisibleGraphScenarioConfig(settings, pluginFilterPatterns, graphControls), + noFilters: createVisibleGraphScenarioConfig(settings, pluginFilterPatterns, graphControls, { filterPatterns: [] }), + foldersOn: createVisibleGraphScenarioConfig(settings, pluginFilterPatterns, graphControls, { nodeVisibility: { folder: true }, }), - importsOff: createVisibleGraphScenarioConfig(settings, pluginFilterPatterns, { + importsOff: createVisibleGraphScenarioConfig(settings, pluginFilterPatterns, graphControls, { edgeVisibility: { import: false }, }), - searchGraph: createVisibleGraphScenarioConfig(settings, pluginFilterPatterns, { + searchGraph: createVisibleGraphScenarioConfig(settings, pluginFilterPatterns, graphControls, { searchQuery: 'graph', }), }; @@ -210,11 +425,39 @@ function measureVisibleGraphScenario(graphData, config, options) { }; } +function measureLegendRulesScenario(graphData, settings, pluginFilterPatterns, graphControls, legends, options) { + const config = createVisibleGraphScenarioConfig(settings, pluginFilterPatterns, graphControls); + const visibleGraph = deriveVisibleGraph(graphData, config).graphData ?? { nodes: [], edges: [] }; + + for (let index = 0; index < options.warmupIterations; index += 1) { + applyLegendRules(visibleGraph, legends); + } + + const durations = []; + let coloredGraph = visibleGraph; + + for (let index = 0; index < options.iterations; index += 1) { + const startedAt = performance.now(); + coloredGraph = applyLegendRules(visibleGraph, legends) ?? { nodes: [], edges: [] }; + durations.push(performance.now() - startedAt); + } + + return { + ...summarizeDurations(durations), + activeLegendRuleCount: legends.filter(legend => !legend.disabled).length, + edgeCount: visibleGraph.edges.length, + legendCount: legends.length, + nodeCount: visibleGraph.nodes.length, + payloadBytes: Buffer.byteLength(JSON.stringify(coloredGraph)), + }; +} + export function measureVisibleGraphScenarios(graphData, settings, options = {}) { const iterations = options.iterations ?? DEFAULT_ITERATIONS; const warmupIterations = options.warmupIterations ?? DEFAULT_WARMUP_ITERATIONS; const pluginFilterPatterns = options.pluginFilterPatterns ?? []; - const scenarios = createVisibleGraphScenarios(settings, pluginFilterPatterns); + const graphControls = options.graphControls; + const scenarios = createVisibleGraphScenarios(settings, pluginFilterPatterns, graphControls); return Object.fromEntries( Object.entries(scenarios).map(([scenarioName, config]) => [ @@ -225,13 +468,20 @@ export function measureVisibleGraphScenarios(graphData, settings, options = {}) } async function measureVisibleGraph({ workspacePath, userHomeDir, iterations, warmupIterations }) { - const { graphData, settings, pluginFilterPatterns, warmCacheGraphBuildMs } = + const { graphControls, graphData, legends, settings, pluginFilterPatterns, warmCacheGraphBuildMs } = await buildGraphDataFromGraphCache(workspacePath, userHomeDir); const visibleGraphScenarios = measureVisibleGraphScenarios(graphData, settings, { + graphControls, iterations, pluginFilterPatterns, warmupIterations, }); + const legendRules = { + current: measureLegendRulesScenario(graphData, settings, pluginFilterPatterns, graphControls, legends, { + iterations, + warmupIterations, + }), + }; return { warmCacheGraphBuildMs, @@ -239,7 +489,10 @@ async function measureVisibleGraph({ workspacePath, userHomeDir, iterations, war pluginFilterPatternCount: pluginFilterPatterns.length, graphNodeCount: graphData.nodes.length, graphEdgeCount: graphData.edges.length, + graphNodeTypeCount: graphControls.nodeTypes.length, + graphEdgeTypeCount: graphControls.edgeTypes.length, visibleGraphScenarios, + legendRules, }; } From 9ed2fd2884b97d1d78814ec9dfefc5ddf8f774b2 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 07:41:30 -0700 Subject: [PATCH 087/192] perf: skip irrelevant path legend candidates --- .../webview/search/filtering/rules/nodes.ts | 38 ++++++++++++++++--- .../search/filtering/rules/nodes.test.ts | 26 +++++++++++++ 2 files changed, 58 insertions(+), 6 deletions(-) diff --git a/packages/extension/src/webview/search/filtering/rules/nodes.ts b/packages/extension/src/webview/search/filtering/rules/nodes.ts index ed9c2c9aa..1b04f8ad2 100644 --- a/packages/extension/src/webview/search/filtering/rules/nodes.ts +++ b/packages/extension/src/webview/search/filtering/rules/nodes.ts @@ -8,6 +8,7 @@ export interface CompiledNodeLegendRule { caseInsensitivePatternMatches: (value: string) => boolean; hasConstraints: boolean; patternMatches: (value: string) => boolean; + patternHasPathSeparator: boolean; rule: IGroup; symbolFilePathMatches?: (value: string) => boolean; } @@ -39,6 +40,7 @@ export function compileNodeLegendRules(activeRules: IGroup[]): CompiledNodeLegen caseInsensitivePatternMatches: createGlobMatcher(rule.pattern.toLowerCase()), hasConstraints: hasNodeLegendConstraints(rule), patternMatches: createGlobMatcher(rule.pattern), + patternHasPathSeparator: rule.pattern.includes('/'), rule, ...(rule.matchSymbolFilePath ? { symbolFilePathMatches: createGlobMatcher(rule.matchSymbolFilePath) } @@ -117,9 +119,20 @@ function compiledRuleConstraintsMatchNode( return true; } +function pathCandidateMatchesNodeRule( + value: string | undefined, + compiledRule: CompiledNodeLegendRule, +): boolean { + return Boolean( + value + && value.includes('/') + && compiledRule.caseInsensitivePatternMatches(value.toLowerCase()), + ); +} + function compiledRulePatternMatchesNode( node: IGraphData['nodes'][number], - candidates: readonly string[], + getCandidates: () => readonly string[], compiledRule: CompiledNodeLegendRule, ): boolean { if (compiledRule.patternMatches(node.id)) { @@ -130,16 +143,25 @@ function compiledRulePatternMatchesNode( return false; } - return candidates.some((candidate) => compiledRule.caseInsensitivePatternMatches(candidate)); + if (compiledRule.patternHasPathSeparator) { + const symbol = node.symbol; + return pathCandidateMatchesNodeRule(node.label, compiledRule) + || pathCandidateMatchesNodeRule(symbol?.name, compiledRule) + || pathCandidateMatchesNodeRule(symbol?.kind, compiledRule) + || pathCandidateMatchesNodeRule(symbol?.pluginKind, compiledRule) + || pathCandidateMatchesNodeRule(symbol?.filePath, compiledRule); + } + + return getCandidates().some((candidate) => compiledRule.caseInsensitivePatternMatches(candidate)); } function compiledRuleMatchesNode( node: IGraphData['nodes'][number], - candidates: readonly string[], + getCandidates: () => readonly string[], compiledRule: CompiledNodeLegendRule, ): boolean { return compiledRuleConstraintsMatchNode(node, compiledRule) - && compiledRulePatternMatchesNode(node, candidates, compiledRule); + && compiledRulePatternMatchesNode(node, getCandidates, compiledRule); } export function applyCompiledNodeLegendRules( @@ -150,10 +172,14 @@ export function applyCompiledNodeLegendRules( ...node, color: node.color || DEFAULT_NODE_COLOR, }; - const candidates = getCaseInsensitiveNodeCandidates(node); + let candidates: readonly string[] | undefined; + const getCandidates = (): readonly string[] => { + candidates ??= getCaseInsensitiveNodeCandidates(node); + return candidates; + }; for (const compiledRule of activeRules) { - if (!compiledRuleMatchesNode(node, candidates, compiledRule)) { + if (!compiledRuleMatchesNode(node, getCandidates, compiledRule)) { continue; } diff --git a/packages/extension/tests/webview/search/filtering/rules/nodes.test.ts b/packages/extension/tests/webview/search/filtering/rules/nodes.test.ts index 9a4bffe93..19bc1eb5a 100644 --- a/packages/extension/tests/webview/search/filtering/rules/nodes.test.ts +++ b/packages/extension/tests/webview/search/filtering/rules/nodes.test.ts @@ -180,6 +180,32 @@ describe('search/filtering/rules/nodes', () => { }); }); + it('matches path-based custom rules against symbol containing file paths', () => { + const activeRules = getOrderedActiveRules([ + { id: 'core', pattern: 'packages/core/**', color: '#00ff00' }, + ]); + + expect( + applyNodeLegendRules( + { + id: 'symbol:function:parseGraph', + label: 'parseGraph', + color: '#111111', + nodeType: 'symbol', + symbol: { + id: 'symbol:function:parseGraph', + name: 'parseGraph', + kind: 'function', + filePath: 'packages/core/src/graph/parser.ts', + }, + }, + activeRules, + ), + ).toMatchObject({ + color: '#00ff00', + }); + }); + it('applies scoped symbol rules by kind, plugin kind, source, language, and containing file', () => { const activeRules = getOrderedActiveRules([ { From 11175a01644ed95f37b015d1548b436d9da2e7b1 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 07:58:03 -0700 Subject: [PATCH 088/192] perf: render initial graph scope immediately --- .changeset/initial-graph-scope-ready.md | 5 +++ .../webview/app/shell/graphScopeVisibility.ts | 26 ++++++++++++++-- .../src/webview/search/useFilteredGraph.ts | 5 +++ .../app/shell/graphScopeVisibility.test.tsx | 31 +++++++++++++++++++ 4 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 .changeset/initial-graph-scope-ready.md diff --git a/.changeset/initial-graph-scope-ready.md b/.changeset/initial-graph-scope-ready.md new file mode 100644 index 000000000..68b461ad7 --- /dev/null +++ b/.changeset/initial-graph-scope-ready.md @@ -0,0 +1,5 @@ +--- +"@codegraphy-dev/extension": patch +--- + +Render the first populated Graph Scope visibility state immediately so large graphs avoid an extra stale startup render. diff --git a/packages/extension/src/webview/app/shell/graphScopeVisibility.ts b/packages/extension/src/webview/app/shell/graphScopeVisibility.ts index 6cbfd12e2..84a384fa7 100644 --- a/packages/extension/src/webview/app/shell/graphScopeVisibility.ts +++ b/packages/extension/src/webview/app/shell/graphScopeVisibility.ts @@ -9,6 +9,20 @@ interface GraphScopeVisibility { nodeVisibility: GraphState['nodeVisibility']; } +function hasVisibilityEntries(visibility: Record): boolean { + return Object.keys(visibility).length > 0; +} + +function isEmptyGraphScopeVisibility(visibility: GraphScopeVisibility): boolean { + return !hasVisibilityEntries(visibility.nodeVisibility) + && !hasVisibilityEntries(visibility.edgeVisibility); +} + +function hasGraphScopeVisibilityEntries(visibility: GraphScopeVisibility): boolean { + return hasVisibilityEntries(visibility.nodeVisibility) + || hasVisibilityEntries(visibility.edgeVisibility); +} + export function useDebouncedGraphScopeVisibility( nodeVisibility: GraphState['nodeVisibility'], edgeVisibility: GraphState['edgeVisibility'], @@ -17,8 +31,16 @@ export function useDebouncedGraphScopeVisibility( edgeVisibility, nodeVisibility, }); + const incomingVisibility = { + edgeVisibility, + nodeVisibility, + }; + const effectiveRenderVisibility = isEmptyGraphScopeVisibility(renderVisibility) + && hasGraphScopeVisibilityEntries(incomingVisibility) + ? incomingVisibility + : renderVisibility; const renderVisibilityRef = useRef(renderVisibility); - renderVisibilityRef.current = renderVisibility; + renderVisibilityRef.current = effectiveRenderVisibility; useEffect(() => { if (renderVisibilityRef.current.nodeVisibility === nodeVisibility) { @@ -47,5 +69,5 @@ export function useDebouncedGraphScopeVisibility( return () => clearTimeout(timer); }, [edgeVisibility, nodeVisibility]); - return renderVisibility; + return effectiveRenderVisibility; } diff --git a/packages/extension/src/webview/search/useFilteredGraph.ts b/packages/extension/src/webview/search/useFilteredGraph.ts index ce52b8423..a8621e39c 100644 --- a/packages/extension/src/webview/search/useFilteredGraph.ts +++ b/packages/extension/src/webview/search/useFilteredGraph.ts @@ -258,9 +258,14 @@ export function useFilteredGraph( const result = measureWebviewPerformance('visibleGraph.derive', { edgeCount: graphData?.edges.length ?? 0, + edgeTypeCount: edgeTypes.length, + edgeVisibilityCount: Object.keys(edgeVisibility).length, filterPatternCount: filterPatterns.length, + nodeTypeCount: nodeTypes.length, + nodeVisibilityCount: Object.keys(nodeVisibility).length, nodeCount: graphData?.nodes.length ?? 0, searchActive: searchQuery.trim().length > 0, + showOrphans, }, () => deriveVisibleGraph(graphData, buildVisibleGraphConfig({ edgeTypes, edgeVisibility, diff --git a/packages/extension/tests/webview/app/shell/graphScopeVisibility.test.tsx b/packages/extension/tests/webview/app/shell/graphScopeVisibility.test.tsx index 2609579b6..32e79fd30 100644 --- a/packages/extension/tests/webview/app/shell/graphScopeVisibility.test.tsx +++ b/packages/extension/tests/webview/app/shell/graphScopeVisibility.test.tsx @@ -10,6 +10,37 @@ describe('useDebouncedGraphScopeVisibility', () => { vi.useRealTimers(); }); + it('renders the first populated graph scope immediately', () => { + vi.useFakeTimers(); + const initialNodeVisibility = {}; + const initialEdgeVisibility = {}; + const nextNodeVisibility = { file: true }; + const nextEdgeVisibility = { include: true }; + + const { result, rerender } = renderHook( + ({ nodeVisibility, edgeVisibility }) => useDebouncedGraphScopeVisibility( + nodeVisibility, + edgeVisibility, + ), + { + initialProps: { + edgeVisibility: initialEdgeVisibility, + nodeVisibility: initialNodeVisibility, + }, + }, + ); + + rerender({ + edgeVisibility: nextEdgeVisibility, + nodeVisibility: nextNodeVisibility, + }); + + expect(result.current).toEqual({ + edgeVisibility: nextEdgeVisibility, + nodeVisibility: nextNodeVisibility, + }); + }); + it('renders edge-only graph scope changes immediately', () => { vi.useFakeTimers(); const nodeVisibility = { file: true }; From cfdcc09c0465c1f6b076348fb75506c08e7f1465 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 08:18:20 -0700 Subject: [PATCH 089/192] perf: cache material file group matches --- .changeset/material-file-group-cache.md | 5 +++ .../graphView/analysis/execution/publish.ts | 8 +++- .../groups/defaults/materialTheme/files.ts | 42 ++++++++++++++++--- .../defaults/materialTheme/files.test.ts | 39 ++++++++++++++++- 4 files changed, 86 insertions(+), 8 deletions(-) create mode 100644 .changeset/material-file-group-cache.md diff --git a/.changeset/material-file-group-cache.md b/.changeset/material-file-group-cache.md new file mode 100644 index 000000000..7c2dd9c94 --- /dev/null +++ b/.changeset/material-file-group-cache.md @@ -0,0 +1,5 @@ +--- +"@codegraphy-dev/extension": patch +--- + +Cache repeated Material Icon Theme file matches while building default Graph View groups for large workspaces. diff --git a/packages/extension/src/extension/graphView/analysis/execution/publish.ts b/packages/extension/src/extension/graphView/analysis/execution/publish.ts index f09090dab..b7c6ffe1f 100644 --- a/packages/extension/src/extension/graphView/analysis/execution/publish.ts +++ b/packages/extension/src/extension/graphView/analysis/execution/publish.ts @@ -340,9 +340,15 @@ export function publishAnalyzedGraph( reason: 'groupInputsUnchanged', }); } else { + const groupsStartedAt = stageStartedAt; + stageStartedAt = Date.now(); handlers.computeMergedGroups(); + recordPublishStage('computeGroups', stageStartedAt); + + stageStartedAt = Date.now(); handlers.sendGroupsUpdated(); - recordPublishStage('groups', stageStartedAt); + recordPublishStage('sendGroups', stageStartedAt); + recordPublishStage('groups', groupsStartedAt); } } diff --git a/packages/extension/src/extension/graphView/groups/defaults/materialTheme/files.ts b/packages/extension/src/extension/graphView/groups/defaults/materialTheme/files.ts index 3a4e13f5a..05f327e5d 100644 --- a/packages/extension/src/extension/graphView/groups/defaults/materialTheme/files.ts +++ b/packages/extension/src/extension/graphView/groups/defaults/materialTheme/files.ts @@ -1,26 +1,58 @@ import type { IGroup } from '../../../../../shared/settings/groups'; import type { IGraphData } from '../../../../../shared/graph/contracts'; import { isExternalPackageNodeId } from '../../../../pipeline/graph/packageSpecifiers/nodeId'; -import type { MaterialThemeCacheEntry } from './model'; +import type { MaterialMatch, MaterialThemeCacheEntry } from './model'; import { createMaterialGroup } from './groups'; import { resolveIconData } from './icons'; import { findMaterialMatch } from './match'; +import { getMaterialBaseName } from './paths'; + +function hasPathSpecificFileNameRules( + baseName: string, + theme: MaterialThemeCacheEntry, +): boolean { + return Boolean(theme.pathMatchers.fileNames?.pathRulesByLowerBaseName.has(baseName.toLowerCase())); +} + +function findCachedMaterialFileMatch( + nodeId: string, + theme: MaterialThemeCacheEntry, + matchCacheByBaseName: Map, +): MaterialMatch | undefined { + const baseName = getMaterialBaseName(nodeId); + if (!baseName || hasPathSpecificFileNameRules(baseName, theme)) { + return findMaterialMatch(nodeId, theme.manifest, { + extensionMatcher: theme.extensionMatcher, + pathMatchers: theme.pathMatchers, + }); + } + + const cached = matchCacheByBaseName.get(baseName); + if (cached !== undefined) { + return cached ?? undefined; + } + + const match = findMaterialMatch(baseName, theme.manifest, { + extensionMatcher: theme.extensionMatcher, + pathMatchers: theme.pathMatchers, + }); + matchCacheByBaseName.set(baseName, match ?? null); + return match; +} export function collectMaterialFileGroups( graphData: IGraphData, theme: MaterialThemeCacheEntry, ): IGroup[] { const groupsById = new Map(); + const matchCacheByBaseName = new Map(); for (const node of graphData.nodes) { if (node.nodeType === 'package' || node.nodeType === 'folder' || isExternalPackageNodeId(node.id)) { continue; } - const match = findMaterialMatch(node.id, theme.manifest, { - extensionMatcher: theme.extensionMatcher, - pathMatchers: theme.pathMatchers, - }); + const match = findCachedMaterialFileMatch(node.id, theme, matchCacheByBaseName); if (!match) { continue; } diff --git a/packages/extension/tests/extension/graphView/groups/defaults/materialTheme/files.test.ts b/packages/extension/tests/extension/graphView/groups/defaults/materialTheme/files.test.ts index 9444827f9..3da07d46f 100644 --- a/packages/extension/tests/extension/graphView/groups/defaults/materialTheme/files.test.ts +++ b/packages/extension/tests/extension/graphView/groups/defaults/materialTheme/files.test.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import type { IGraphData } from '../../../../../../src/shared/graph/contracts'; import { collectMaterialFileGroups } from '../../../../../../src/extension/graphView/groups/defaults/materialTheme/files'; import type { MaterialThemeCacheEntry } from '../../../../../../src/extension/graphView/groups/defaults/materialTheme/model'; +import { createMaterialPathRuleMatcher } from '../../../../../../src/extension/graphView/groups/defaults/materialTheme/pathMatch'; const tempDirs: string[] = []; @@ -18,17 +19,31 @@ function createTheme(): MaterialThemeCacheEntry { fs.writeFileSync(manifestPath, '{}'); fs.writeFileSync(path.join(iconRoot, 'typescript.svg'), ''); fs.writeFileSync(path.join(iconRoot, 'readme.svg'), ''); + fs.writeFileSync(path.join(iconRoot, 'vite.svg'), ''); + fs.writeFileSync(path.join(iconRoot, 'web-vite.svg'), ''); + fs.writeFileSync(path.join(iconRoot, 'api-vite.svg'), ''); + const fileNames = { + 'readme.md': 'readme', + 'vite.config.ts': 'vite', + 'apps/web/vite.config.ts': 'web-vite', + 'packages/api/vite.config.ts': 'api-vite', + }; return { iconDataByName: new Map(), manifestPath, - pathMatchers: {}, + pathMatchers: { + fileNames: createMaterialPathRuleMatcher(fileNames), + }, manifest: { fileExtensions: { ts: 'typescript' }, - fileNames: { 'readme.md': 'readme' }, + fileNames, iconDefinitions: { typescript: { iconPath: '../icons/typescript.svg' }, readme: { iconPath: '../icons/readme.svg' }, + vite: { iconPath: '../icons/vite.svg' }, + 'web-vite': { iconPath: '../icons/web-vite.svg' }, + 'api-vite': { iconPath: '../icons/api-vite.svg' }, }, }, }; @@ -58,4 +73,24 @@ describe('graphView/materialTheme/files', () => { 'default:fileName:README.md', ]); }); + + it('keeps path-specific filename groups distinct while caching basename-only matches', () => { + const groups = collectMaterialFileGroups({ + nodes: [ + { id: 'apps/web/vite.config.ts', label: 'vite.config.ts', color: '#000000' }, + { id: 'packages/api/vite.config.ts', label: 'vite.config.ts', color: '#000000' }, + { id: 'examples/basic/vite.config.ts', label: 'vite.config.ts', color: '#000000' }, + { id: 'src/main.ts', label: 'main.ts', color: '#000000' }, + { id: 'tests/main.ts', label: 'main.ts', color: '#000000' }, + ], + edges: [], + } satisfies IGraphData, createTheme()); + + expect(groups.map((group) => group.id)).toEqual([ + 'default:fileName:apps/web/vite.config.ts', + 'default:fileName:packages/api/vite.config.ts', + 'default:fileName:vite.config.ts', + 'default:fileExtension:ts', + ]); + }); }); From 569ceba5248cb2de4f702e8ccbfd05b4eb725bb3 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 08:45:21 -0700 Subject: [PATCH 090/192] test: summarize graph startup timing split --- docs/performance/codegraphy-monorepo.md | 9 ++ .../performance/measure-vscode-graph-view.mjs | 102 ++++++++++++++++++ .../measure-vscode-graph-view.test.mjs | 51 +++++++++ 3 files changed, 162 insertions(+) diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md index 66bb6ab54..e7e5ff978 100644 --- a/docs/performance/codegraphy-monorepo.md +++ b/docs/performance/codegraphy-monorepo.md @@ -1128,6 +1128,15 @@ Interpretation: edit completed in `380ms` wall-clock with a `105ms` incremental request. The restore request also stayed incremental at `65ms`, and the protected main checkout was clean after the temporary stale marker was restored. +- The VS Code graph-view harness now reports a first-graph-ready breakdown so + remaining startup work is not hidden inside one wall-clock number. Applied to + the latest clean large-monorepo trace, CodeGraphy assigned the webview HTML + at `73ms` after the extension command started, sent the first graph payload + at `1041ms`, and completed the cached `load` request in `237ms`. Once the + browser document posted ready, it reached rendered graph stats in `243ms`. + The measured `4614ms` first-ready total is therefore dominated by the + benchmark's `1647ms` command/view-open bucket and `2954ms` VS Code webview + frame-readiness bucket, not graph derivation or renderer work. Full test baseline: diff --git a/scripts/performance/measure-vscode-graph-view.mjs b/scripts/performance/measure-vscode-graph-view.mjs index 37fa88404..93a5b3dcc 100644 --- a/scripts/performance/measure-vscode-graph-view.mjs +++ b/scripts/performance/measure-vscode-graph-view.mjs @@ -380,6 +380,103 @@ export function summarizeWebviewEventDurations(events) { ); } +function findFirstWebviewEvent(events, predicate) { + return events.find(event => typeof event.at === 'number' && predicate(event)); +} + +function findFirstExtensionHostEvent(events, predicate) { + return events.find(event => typeof event.offsetMs === 'number' && predicate(event)); +} + +function addRoundedDelta(target, key, endAt, startAt) { + if (typeof endAt !== 'number' || typeof startAt !== 'number') { + return; + } + + target[key] = Math.round(endAt - startAt); +} + +export function computeFirstGraphReadyBreakdown({ + extensionHostEvents, + firstGraphReadyPhases, + firstGraphReadyWebviewEvents, +}) { + const readyPosted = findFirstWebviewEvent( + firstGraphReadyWebviewEvents, + event => event.name === 'webview.ready.posted', + ); + const graphDataReceived = findFirstWebviewEvent( + firstGraphReadyWebviewEvents, + event => event.name === 'extensionMessage.received' + && event.detail?.type === 'GRAPH_DATA_UPDATED', + ); + const bootstrapComplete = findFirstWebviewEvent( + firstGraphReadyWebviewEvents, + event => event.name === 'extensionMessage.appBootstrapComplete' + || ( + event.name === 'extensionMessage.received' + && event.detail?.type === 'APP_BOOTSTRAP_COMPLETE' + ), + ); + const statsRendered = findFirstWebviewEvent( + firstGraphReadyWebviewEvents, + event => event.name === 'graphStats.rendered', + ); + const htmlAssigned = findFirstExtensionHostEvent( + extensionHostEvents, + event => event.name === 'graphWebview.html.assigned', + ); + const graphDataSent = findFirstExtensionHostEvent( + extensionHostEvents, + event => event.name === 'graphWebview.message.send' + && event.detail?.type === 'GRAPH_DATA_UPDATED', + ); + const loadRequestCompleted = findFirstExtensionHostEvent( + extensionHostEvents, + event => event.name === 'graphAnalysis.request.completed' + && event.detail?.mode === 'load', + ); + + const breakdown = { + commandAndViewOpenMs: firstGraphReadyPhases.openGraphCommandMs, + frameReadyMs: firstGraphReadyPhases.graphFrameReadyMs, + statsAfterFrameMs: firstGraphReadyPhases.graphStatsReadyMs, + ...(typeof htmlAssigned?.offsetMs === 'number' + ? { extensionHostHtmlAssignedOffsetMs: htmlAssigned.offsetMs } + : {}), + ...(typeof graphDataSent?.offsetMs === 'number' + ? { extensionHostFirstGraphDataSendOffsetMs: graphDataSent.offsetMs } + : {}), + ...(typeof loadRequestCompleted?.offsetMs === 'number' + ? { extensionHostFirstLoadRequestCompletedOffsetMs: loadRequestCompleted.offsetMs } + : {}), + ...(typeof loadRequestCompleted?.detail?.durationMs === 'number' + ? { extensionHostFirstLoadRequestDurationMs: loadRequestCompleted.detail.durationMs } + : {}), + }; + + addRoundedDelta( + breakdown, + 'webviewDocumentReadyToStatsMs', + statsRendered?.at, + readyPosted?.at, + ); + addRoundedDelta( + breakdown, + 'webviewGraphDataToStatsMs', + statsRendered?.at, + graphDataReceived?.at, + ); + addRoundedDelta( + breakdown, + 'webviewBootstrapToStatsMs', + statsRendered?.at, + bootstrapComplete?.at, + ); + + return breakdown; +} + export function createStartupMeasurements({ extensionHostEvents, extensionHostLogPath, @@ -395,6 +492,11 @@ export function createStartupMeasurements({ vscodeLaunchMs, firstGraphReadyMs, firstGraphReadyPhases, + firstGraphReadyBreakdown: computeFirstGraphReadyBreakdown({ + extensionHostEvents, + firstGraphReadyPhases, + firstGraphReadyWebviewEvents, + }), firstGraphReadyWebviewStages: summarizeWebviewEventDurations(firstGraphReadyWebviewEvents), firstGraphReadyWebviewEvents, firstGraphReadyFrameLifecycleEvents: frameLifecycleEvents, diff --git a/tests/scripts/measure-vscode-graph-view.test.mjs b/tests/scripts/measure-vscode-graph-view.test.mjs index 08e2a805b..6c89b0a0f 100644 --- a/tests/scripts/measure-vscode-graph-view.test.mjs +++ b/tests/scripts/measure-vscode-graph-view.test.mjs @@ -110,6 +110,52 @@ test('VS Code graph view runner summarizes startup webview stage durations', asy }); }); +test('VS Code graph view runner computes first graph ready timing breakdown', async () => { + const moduleUrl = pathToFileURL( + path.resolve('scripts/performance/measure-vscode-graph-view.mjs'), + ).href; + const { computeFirstGraphReadyBreakdown } = await import(moduleUrl); + + assert.deepEqual(computeFirstGraphReadyBreakdown({ + extensionHostEvents: [ + { name: 'command.open.start', offsetMs: 0 }, + { name: 'graphWebview.html.assigned', offsetMs: 42 }, + { + name: 'graphWebview.message.send', + offsetMs: 310, + detail: { type: 'GRAPH_DATA_UPDATED' }, + }, + { + name: 'graphAnalysis.request.completed', + offsetMs: 350, + detail: { mode: 'load', durationMs: 120 }, + }, + ], + firstGraphReadyPhases: { + openGraphCommandMs: 100, + graphFrameReadyMs: 1000, + graphStatsReadyMs: 20, + }, + firstGraphReadyWebviewEvents: [ + { name: 'webview.ready.posted', at: 10.2 }, + { name: 'extensionMessage.received', at: 40.3, detail: { type: 'GRAPH_DATA_UPDATED' } }, + { name: 'extensionMessage.appBootstrapComplete', at: 70.4 }, + { name: 'graphStats.rendered', at: 130.8 }, + ], + }), { + commandAndViewOpenMs: 100, + frameReadyMs: 1000, + statsAfterFrameMs: 20, + extensionHostHtmlAssignedOffsetMs: 42, + extensionHostFirstGraphDataSendOffsetMs: 310, + extensionHostFirstLoadRequestCompletedOffsetMs: 350, + extensionHostFirstLoadRequestDurationMs: 120, + webviewDocumentReadyToStatsMs: 121, + webviewGraphDataToStatsMs: 91, + webviewBootstrapToStatsMs: 60, + }); +}); + test('VS Code graph view runner summarizes live-update request durations', async () => { const moduleUrl = pathToFileURL( path.resolve('scripts/performance/measure-vscode-graph-view.mjs'), @@ -405,6 +451,11 @@ test('VS Code graph view runner builds a startup-ready measurement payload befor maxMs: 12, }, }, + firstGraphReadyBreakdown: { + commandAndViewOpenMs: 100, + frameReadyMs: 1000, + statsAfterFrameMs: 20, + }, firstGraphReadyWebviewEvents: [ { name: 'visibleGraph.derive', durationMs: 12.2 }, { name: 'graphStats.rendered', at: 30 }, From 8e6cab6a20add4a303e9d3de1e89000813cdada8 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 08:50:50 -0700 Subject: [PATCH 091/192] test: stabilize glob matcher performance guard --- .../extension/tests/shared/globMatch.test.ts | 42 ++++++++++++++----- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/packages/extension/tests/shared/globMatch.test.ts b/packages/extension/tests/shared/globMatch.test.ts index f8704be2b..1cdee2dff 100644 --- a/packages/extension/tests/shared/globMatch.test.ts +++ b/packages/extension/tests/shared/globMatch.test.ts @@ -40,7 +40,7 @@ describe('shared/globMatch', () => { }); it('keeps repeated simple single-glob checks cheap', () => { - const matchers = [ + const patterns = [ '*.ts', '*.tsx', '*.json', @@ -68,29 +68,51 @@ describe('shared/globMatch', () => { '*.cpp', '*.c', '*.h', - ].flatMap((pattern) => [ + ]; + const matchers = patterns.flatMap((pattern) => [ createGlobMatcher(pattern), createGlobMatcher(pattern), createGlobMatcher(pattern), createGlobMatcher(pattern), ]); + const regexMatchers = patterns.flatMap((pattern) => { + const regex = globToRegex(pattern); + return [ + (filePath: string) => regex.test(filePath), + (filePath: string) => regex.test(filePath), + (filePath: string) => regex.test(filePath), + (filePath: string) => regex.test(filePath), + ]; + }); const paths = Array.from({ length: 2_300 }, (_, index) => ( `packages/package-${index % 100}/src/file-${index}.${index % 5 === 0 ? 'ts' : 'txt'}` )); - const startedAt = performance.now(); - let matchedCount = 0; - for (const filePath of paths) { - for (const matcher of matchers) { - if (matcher(filePath)) { - matchedCount += 1; + const countMatches = (nextMatchers: Array<(filePath: string) => boolean>) => { + let matchedCount = 0; + for (const filePath of paths) { + for (const matcher of nextMatchers) { + if (matcher(filePath)) { + matchedCount += 1; + } } } - } + return matchedCount; + }; + countMatches(matchers); + countMatches(regexMatchers); + + const startedAt = performance.now(); + const matchedCount = countMatches(matchers); const elapsedMs = performance.now() - startedAt; + const regexStartedAt = performance.now(); + const regexMatchedCount = countMatches(regexMatchers); + const regexElapsedMs = performance.now() - regexStartedAt; expect(matchedCount).toBe(1_840); - expect(elapsedMs).toBeLessThan(20); + expect(regexMatchedCount).toBe(matchedCount); + expect(elapsedMs).toBeLessThan(50); + expect(elapsedMs).toBeLessThan(regexElapsedMs * 0.75); }); it('creates one matcher that preserves any-pattern glob semantics', () => { From e95061239ab63fc3c5e64ec8b653db7466271979 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 09:21:15 -0700 Subject: [PATCH 092/192] chore: remove performance measurement harness --- .changeset/background-sync-live-updates.md | 5 - .changeset/coalesce-graph-index-progress.md | 5 - .changeset/fast-cached-graph-replay.md | 5 - .changeset/faster-godot-class-names.md | 5 + .../faster-graph-cache-and-filtering.md | 8 + .changeset/faster-graph-view-interactions.md | 9 + .changeset/faster-material-groups.md | 5 + .changeset/faster-typescript-aliases.md | 5 + .changeset/gate-incremental-first-load.md | 5 - .changeset/godot-class-name-fast-path.md | 5 - .changeset/initial-graph-scope-ready.md | 5 - .changeset/lazy-3d-webview-bundle.md | 5 - .changeset/loaded-incremental-refresh.md | 5 - .changeset/material-extension-matcher.md | 7 - .changeset/material-file-group-cache.md | 5 - .changeset/quick-cache-status.md | 5 - .changeset/settled-graph-cooldown.md | 5 - .changeset/skip-duplicate-ready-replay.md | 5 - ...kip-incremental-refresh-settings-replay.md | 5 - .changeset/skip-metric-group-publish.md | 5 - .changeset/smooth-large-graphs.md | 5 - .changeset/smooth-startup-ready-hydration.md | 5 - .changeset/typescript-alias-config-noscan.md | 5 - .changeset/visible-graph-filter-speed.md | 6 - docs/performance/codegraphy-monorepo.md | 1147 ----------------- .../2026-06-22-codegraphy-performance.md | 357 ----- package.json | 3 - .../src/extension/commands/navigation.ts | 11 +- .../graphView/analysis/execution/load.ts | 41 - .../graphView/analysis/execution/prepare.ts | 30 - .../graphView/analysis/execution/publish.ts | 79 +- .../extension/graphView/analysis/request.ts | 16 +- .../extension/graphView/provider/refresh.ts | 5 - .../graphView/provider/refresh/run.ts | 4 +- .../provider/webview/defaultDependencies.ts | 3 - .../graphView/provider/webview/messages.ts | 16 +- .../graphView/provider/webview/resolve.ts | 16 +- .../graphView/webview/dispatch/primary.ts | 73 +- .../graphView/webview/messages/listener.ts | 8 - .../extension/graphView/webview/resolve.ts | 17 - .../src/extension/performance/marks.ts | 36 - .../pipeline/service/discoveryFacade.ts | 44 - .../pipeline/service/refreshFacade.ts | 182 +-- .../src/extension/pipeline/serviceAdapters.ts | 139 +- .../workspaceFiles/refresh/operations.ts | 4 - .../workspaceFiles/refresh/scheduler.ts | 21 - .../src/shared/protocol/webviewToExtension.ts | 1 - .../extension/src/webview/app/graph/stats.tsx | 7 +- .../webview/app/shell/graphScopeVisibility.ts | 9 - .../src/webview/app/shell/messageListener.ts | 4 - .../app/shell/messageListener/ready.ts | 2 - .../components/graph/runtime/use/state.ts | 32 +- .../webview/components/graphScope/rows.tsx | 3 - .../src/webview/performance/marks.ts | 79 -- .../src/webview/search/useFilteredGraph.ts | 32 +- .../webview/store/messageHandlers/graph.ts | 22 +- .../tests/acceptance/graphView/vscode.ts | 4 - .../extension/commands/navigation.test.ts | 22 - .../analysis/execution/publish.test.ts | 84 +- .../graphView/analysis/request.test.ts | 32 +- .../graphView/provider/refresh.test.ts | 15 - .../graphView/provider/refresh/run.test.ts | 11 - .../webview/defaultDependencies.test.ts | 7 - .../provider/webview/messages.test.ts | 7 - .../provider/webview/resolve.test.ts | 51 - .../webview/dispatch/primary/dispatch.test.ts | 70 - .../graphView/webview/resolve.test.ts | 44 - .../tests/extension/performance/marks.test.ts | 68 - .../pipeline/service/discoveryFacade.test.ts | 99 +- .../pipeline/service/refreshFacade.test.ts | 69 - .../pipeline/serviceAdapters.test.ts | 63 +- .../workspaceFiles/refresh/scheduler.test.ts | 44 - .../workspaceFiles/refresh/watchers.test.ts | 13 - .../webview/app/performance/marks.test.ts | 49 - .../webview/app/shell/messageListener.test.ts | 30 +- .../app/shell/messageListener/ready.test.ts | 10 - .../tests/webview/app/shell/view.test.tsx | 16 +- .../store/messageHandlers/graph.test.ts | 57 +- .../measure-codegraphy-monorepo.mjs | 219 ---- .../measure-visible-graph-monorepo.mjs | 537 -------- .../performance/measure-vscode-graph-view.mjs | 1094 ---------------- .../measure-codegraphy-monorepo.test.mjs | 110 -- .../measure-vscode-graph-view.test.mjs | 1067 --------------- 83 files changed, 100 insertions(+), 6365 deletions(-) delete mode 100644 .changeset/background-sync-live-updates.md delete mode 100644 .changeset/coalesce-graph-index-progress.md delete mode 100644 .changeset/fast-cached-graph-replay.md create mode 100644 .changeset/faster-godot-class-names.md create mode 100644 .changeset/faster-graph-cache-and-filtering.md create mode 100644 .changeset/faster-graph-view-interactions.md create mode 100644 .changeset/faster-material-groups.md create mode 100644 .changeset/faster-typescript-aliases.md delete mode 100644 .changeset/gate-incremental-first-load.md delete mode 100644 .changeset/godot-class-name-fast-path.md delete mode 100644 .changeset/initial-graph-scope-ready.md delete mode 100644 .changeset/lazy-3d-webview-bundle.md delete mode 100644 .changeset/loaded-incremental-refresh.md delete mode 100644 .changeset/material-extension-matcher.md delete mode 100644 .changeset/material-file-group-cache.md delete mode 100644 .changeset/quick-cache-status.md delete mode 100644 .changeset/settled-graph-cooldown.md delete mode 100644 .changeset/skip-duplicate-ready-replay.md delete mode 100644 .changeset/skip-incremental-refresh-settings-replay.md delete mode 100644 .changeset/skip-metric-group-publish.md delete mode 100644 .changeset/smooth-large-graphs.md delete mode 100644 .changeset/smooth-startup-ready-hydration.md delete mode 100644 .changeset/typescript-alias-config-noscan.md delete mode 100644 .changeset/visible-graph-filter-speed.md delete mode 100644 docs/performance/codegraphy-monorepo.md delete mode 100644 docs/superpowers/plans/2026-06-22-codegraphy-performance.md delete mode 100644 packages/extension/src/extension/performance/marks.ts delete mode 100644 packages/extension/src/webview/performance/marks.ts delete mode 100644 packages/extension/tests/extension/performance/marks.test.ts delete mode 100644 packages/extension/tests/webview/app/performance/marks.test.ts delete mode 100644 scripts/performance/measure-codegraphy-monorepo.mjs delete mode 100644 scripts/performance/measure-visible-graph-monorepo.mjs delete mode 100644 scripts/performance/measure-vscode-graph-view.mjs delete mode 100644 tests/scripts/measure-codegraphy-monorepo.test.mjs delete mode 100644 tests/scripts/measure-vscode-graph-view.test.mjs diff --git a/.changeset/background-sync-live-updates.md b/.changeset/background-sync-live-updates.md deleted file mode 100644 index f02a00bf6..000000000 --- a/.changeset/background-sync-live-updates.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@codegraphy-dev/extension": patch ---- - -Keep file-change refreshes responsive while stale Graph Cache background sync is running. diff --git a/.changeset/coalesce-graph-index-progress.md b/.changeset/coalesce-graph-index-progress.md deleted file mode 100644 index 61818fb21..000000000 --- a/.changeset/coalesce-graph-index-progress.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@codegraphy-dev/extension": patch ---- - -Reduce graph index progress message traffic during large workspace startup. diff --git a/.changeset/fast-cached-graph-replay.md b/.changeset/fast-cached-graph-replay.md deleted file mode 100644 index 9aa9d62b5..000000000 --- a/.changeset/fast-cached-graph-replay.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@codegraphy-dev/extension": patch ---- - -Speed up warm Graph View startup by replaying cached graph metadata without a full workspace discovery walk. diff --git a/.changeset/faster-godot-class-names.md b/.changeset/faster-godot-class-names.md new file mode 100644 index 000000000..cd89e5dde --- /dev/null +++ b/.changeset/faster-godot-class-names.md @@ -0,0 +1,5 @@ +--- +"@codegraphy-dev/plugin-godot": patch +--- + +Godot class-name indexing no longer runs the full GDScript parser for metadata-only `class_name` discovery. On the CodeGraphy monorepo benchmark, the Godot metadata slice helped move cold indexing from 104.67s to 37.27s and file analysis from 87,918ms to 23,352ms. diff --git a/.changeset/faster-graph-cache-and-filtering.md b/.changeset/faster-graph-cache-and-filtering.md new file mode 100644 index 000000000..fdd16e77c --- /dev/null +++ b/.changeset/faster-graph-cache-and-filtering.md @@ -0,0 +1,8 @@ +--- +"@codegraphy-dev/core": patch +"@codegraphy-dev/extension": patch +--- + +Large CodeGraphy workspaces now index, save, and filter graph data much faster. On the CodeGraphy monorepo benchmark, cold indexing improved from 214.04s to 17.28s, Graph Cache saves improved from 122,757ms to 10,904ms, and the Graph Cache shrank from 64,638,976 bytes to 18,153,472 bytes. + +The same benchmark now projects the current Visible Graph in 12ms instead of 775ms. Folder-node projection improved from 1,369ms to 32ms, import-edge-off projection improved from 153ms to 7ms, and search projection improved from 781ms to 12ms. diff --git a/.changeset/faster-graph-view-interactions.md b/.changeset/faster-graph-view-interactions.md new file mode 100644 index 000000000..93dd79831 --- /dev/null +++ b/.changeset/faster-graph-view-interactions.md @@ -0,0 +1,9 @@ +--- +"@codegraphy-dev/extension": patch +--- + +Graph View interactions now stay responsive on large workspaces. In the VS Code benchmark, toggling the Imports Graph Scope row improved from a 2,983ms median to 188ms wall clock, with the browser-visible update path measuring 54ms. + +Warm Graph View startup improved from 9,917ms to 4,614ms. The latest startup split shows CodeGraphy sends the first graph payload at 1,041ms, then spends most remaining first-ready time in VS Code view and webview frame readiness rather than graph work. + +Saved-file updates now stay incremental after the graph has loaded. In the editor-save benchmark, the post-save path measured 39ms from saved-document receipt to request start and 140ms to request completion. diff --git a/.changeset/faster-material-groups.md b/.changeset/faster-material-groups.md new file mode 100644 index 000000000..66dda4826 --- /dev/null +++ b/.changeset/faster-material-groups.md @@ -0,0 +1,5 @@ +--- +"@codegraphy-dev/extension": patch +--- + +Default Graph View groups from Material Icon Theme rules now resolve faster in large workspaces. The measured group computation improved from 66ms to 38ms, and total group publish time improved from 71ms to 39ms. diff --git a/.changeset/faster-typescript-aliases.md b/.changeset/faster-typescript-aliases.md new file mode 100644 index 000000000..22592fb52 --- /dev/null +++ b/.changeset/faster-typescript-aliases.md @@ -0,0 +1,5 @@ +--- +"@codegraphy-dev/plugin-typescript": patch +--- + +TypeScript alias import analysis now reads `tsconfig` compiler options without enumerating every project file, and it reuses parsed alias configuration until the config changes. On the CodeGraphy monorepo benchmark, this moved cold indexing from 37.27s to 17.28s and file analysis from 23,352ms to 3,697ms. diff --git a/.changeset/gate-incremental-first-load.md b/.changeset/gate-incremental-first-load.md deleted file mode 100644 index f884fee25..000000000 --- a/.changeset/gate-incremental-first-load.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@codegraphy-dev/extension": patch ---- - -Keep changed-file graph refreshes from interrupting the first cached Graph View load. diff --git a/.changeset/godot-class-name-fast-path.md b/.changeset/godot-class-name-fast-path.md deleted file mode 100644 index 5e8b7ce9a..000000000 --- a/.changeset/godot-class-name-fast-path.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@codegraphy-dev/plugin-godot": patch ---- - -Speed up Godot class name indexing during workspace analysis. diff --git a/.changeset/initial-graph-scope-ready.md b/.changeset/initial-graph-scope-ready.md deleted file mode 100644 index 68b461ad7..000000000 --- a/.changeset/initial-graph-scope-ready.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@codegraphy-dev/extension": patch ---- - -Render the first populated Graph Scope visibility state immediately so large graphs avoid an extra stale startup render. diff --git a/.changeset/lazy-3d-webview-bundle.md b/.changeset/lazy-3d-webview-bundle.md deleted file mode 100644 index e6ef0bf68..000000000 --- a/.changeset/lazy-3d-webview-bundle.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@codegraphy-dev/extension": patch ---- - -Speed up default Graph View startup work by lazy-loading the 3D graph runtime outside the initial 2D webview bundle and skipping settled duplicate graph payload replays. diff --git a/.changeset/loaded-incremental-refresh.md b/.changeset/loaded-incremental-refresh.md deleted file mode 100644 index bd347e713..000000000 --- a/.changeset/loaded-incremental-refresh.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@codegraphy-dev/extension": patch ---- - -Keep loaded file-change refreshes incremental when index metadata is temporarily unavailable. diff --git a/.changeset/material-extension-matcher.md b/.changeset/material-extension-matcher.md deleted file mode 100644 index c32c5a812..000000000 --- a/.changeset/material-extension-matcher.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@codegraphy-dev/extension": patch -"@codegraphy-dev/core": patch -"@codegraphy-dev/plugin-typescript": patch ---- - -Speed up stale cached startup and existing-file live updates by reusing prepared startup state, deferring live gitignore probing until the background index refresh, warming the repo-local Graph Cache before the first replay needs it, reusing current discovery for saved-file refreshes, routing incremental pre-analysis to matching plugins, shortening save-triggered refresh debounce, loading only the requested Tree-sitter grammar for one-file parses, skipping redundant graph rebuilds when incremental analysis already covers the retained files, using a tighter save debounce for existing-file changes, caching TypeScript alias compiler options with tsconfig-change invalidation, skipping unchanged incremental graph publishes, ignoring generated `.turbo` plus agent worktree paths, filtering generated pending paths from status checks, preserving the indexed commit in extension metadata, and making the VS Code live-update benchmark wait for restore refreshes. diff --git a/.changeset/material-file-group-cache.md b/.changeset/material-file-group-cache.md deleted file mode 100644 index 7c2dd9c94..000000000 --- a/.changeset/material-file-group-cache.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@codegraphy-dev/extension": patch ---- - -Cache repeated Material Icon Theme file matches while building default Graph View groups for large workspaces. diff --git a/.changeset/quick-cache-status.md b/.changeset/quick-cache-status.md deleted file mode 100644 index 6cf5bbb6a..000000000 --- a/.changeset/quick-cache-status.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@codegraphy-dev/core": patch ---- - -Speed up Graph Cache freshness checks when generated pending paths accumulate. diff --git a/.changeset/settled-graph-cooldown.md b/.changeset/settled-graph-cooldown.md deleted file mode 100644 index f840d856a..000000000 --- a/.changeset/settled-graph-cooldown.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@codegraphy-dev/extension": patch ---- - -Make settled Graph Scope updates feel faster by skipping force-layout cooldown ticks when every visible node already has a position, avoiding per-edge callbacks for constant 2D arrow settings, preventing viewport overlay updates from re-rendering the graph surface, and reusing compiled legend rule matchers while filtering the visible graph. diff --git a/.changeset/skip-duplicate-ready-replay.md b/.changeset/skip-duplicate-ready-replay.md deleted file mode 100644 index 78adbfe1a..000000000 --- a/.changeset/skip-duplicate-ready-replay.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@codegraphy-dev/extension": patch ---- - -Avoid replaying startup settings for duplicate webview ready messages while the first workspace graph is still loading. diff --git a/.changeset/skip-incremental-refresh-settings-replay.md b/.changeset/skip-incremental-refresh-settings-replay.md deleted file mode 100644 index bd0e4665a..000000000 --- a/.changeset/skip-incremental-refresh-settings-replay.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@codegraphy-dev/extension": patch ---- - -Reduce repeated Graph View settings updates after indexed file-change refreshes. diff --git a/.changeset/skip-metric-group-publish.md b/.changeset/skip-metric-group-publish.md deleted file mode 100644 index 62b9a0084..000000000 --- a/.changeset/skip-metric-group-publish.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@codegraphy-dev/extension": patch ---- - -Skip redundant legend group publication, deep graph reuse checks, full graph payloads, visible graph recomputation, pre-refresh legend broadcasts, incremental freshness scans, host-side graph rebuilds, static graph-state broadcasts, blocking index metadata persistence, and repeated filter/group reloads when a saved file only changes non-visual graph node metrics. Warm one representative cached source file after Graph Cache replay so the first edit avoids cold analyzer startup, shorten normal saved-file refresh debounce while preserving file-operation coalescing, and suppress duplicate filesystem watcher refreshes that follow VS Code editor saves. diff --git a/.changeset/smooth-large-graphs.md b/.changeset/smooth-large-graphs.md deleted file mode 100644 index 6e16a6dab..000000000 --- a/.changeset/smooth-large-graphs.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@codegraphy-dev/extension": patch ---- - -Improve large graph responsiveness by reducing repeated graph scope and legend work. diff --git a/.changeset/smooth-startup-ready-hydration.md b/.changeset/smooth-startup-ready-hydration.md deleted file mode 100644 index 38a7515e3..000000000 --- a/.changeset/smooth-startup-ready-hydration.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@codegraphy-dev/extension": patch ---- - -Reduce Graph View startup jank by hydrating settings before bootstrap and ignoring stale duplicate ready replays. diff --git a/.changeset/typescript-alias-config-noscan.md b/.changeset/typescript-alias-config-noscan.md deleted file mode 100644 index eaef61d84..000000000 --- a/.changeset/typescript-alias-config-noscan.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@codegraphy-dev/plugin-typescript": patch ---- - -Speed up TypeScript alias import analysis by avoiding project file scans while reading alias configuration. diff --git a/.changeset/visible-graph-filter-speed.md b/.changeset/visible-graph-filter-speed.md deleted file mode 100644 index 18d62d174..000000000 --- a/.changeset/visible-graph-filter-speed.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@codegraphy-dev/core": patch -"@codegraphy-dev/extension": patch ---- - -Speed up visible graph filtering and Graph Scope toggles in large workspaces. diff --git a/docs/performance/codegraphy-monorepo.md b/docs/performance/codegraphy-monorepo.md deleted file mode 100644 index e7e5ff978..000000000 --- a/docs/performance/codegraphy-monorepo.md +++ /dev/null @@ -1,1147 +0,0 @@ -# CodeGraphy Monorepo Performance - -## Baseline: 2026-06-22 - -Environment: - -- Worktree: `/Users/poleski/Desktop/Projects/CodeGraphyV4/.worktrees/speed-up-codegraphy` -- Branch: `codex/speed-up-codegraphy` -- Settings: tracked `.codegraphy/settings.json` from `main` -- Runtime: Node 22 PATH, local `packages/core/bin/codegraphy.js` - -Cold index from no Graph Cache: - -- Command: `node packages/core/bin/codegraphy.js --verbose index .` -- Wall time: `214.04s` -- Files: `2365` -- Nodes: `5075` -- Edges: `9097` -- Graph Cache: `62MB` -- Max resident set: `2708193280` bytes -- Peak memory footprint: `4201907648` bytes - -Phase-instrumented cold index: - -- Command: `node packages/core/bin/codegraphy.js --verbose index .` -- Wall time: `213.93s` -- Files: `2367` -- Nodes: `5078` -- Edges: `9114` -- Plugin load: `542ms` -- Plugin initialization: `1ms` -- File discovery: `1900ms` -- File analysis: `88321ms` -- Graph build: `62ms` -- Graph Cache save: `122757ms` -- Metadata persistence: `4ms` -- Max resident set: `3071688704` bytes -- Peak memory footprint: `4200348736` bytes - -The first measured bottlenecks are Graph Cache persistence and file/plugin -analysis. Graph construction is not currently a cold-load bottleneck for this -workspace. - -Canonical Graph Cache write: - -- Command: `node packages/core/bin/codegraphy.js --verbose index .` -- Wall time: `111.03s` -- Files: `2367` -- Nodes: `5078` -- Edges: `9110` -- File discovery: `1924ms` -- File analysis: `92850ms` -- Graph build: `63ms` -- Graph Cache save: `15139ms` -- Graph Cache size: `18MB` -- Max resident set: `3133194240` bytes -- Peak memory footprint: `4372432256` bytes - -Result: - -- Cold index wall time improved from `213.93s` to `111.03s`. -- Graph Cache save improved from `122757ms` to `15139ms`. -- Graph Cache size improved from `64638976` bytes to `18153472` bytes. - -Shared content read cache: - -- Command: `node packages/core/bin/codegraphy.js --verbose index .` -- Wall time: `104.81s` -- File analysis: `87297ms` -- Graph Cache save: `14632ms` -- Graph Cache size: `18157568` bytes - -Result: - -- Cold index wall time improved from `111.03s` to `104.81s`. -- File analysis improved from `92850ms` to `87297ms` by reusing file content - read during pre-analysis. - -Godot class name metadata fast path: - -- Command shape: direct Core API cold index with `userHomeDir` pointing at an - isolated plugin cache whose package roots point at this worktree's local - plugin packages. -- The isolated plugin cache matters because the user's real - `~/.codegraphy/plugins.json` can point at older worktrees or globally - installed plugin packages. -- Before command: old `extractGDScriptClassNameDeclarations` path using the - GDScript syntax parser. -- After command: line-based class name extraction for metadata pre-analysis. -- Files: `2367` -- Nodes: `5079` -- Edges: `9110` before, `9108` after. The persisted relationship diff is only - the changed CodeGraphy source imports/calls in `className.ts`; no workspace - Godot facts disappeared. -- Wall time: `104.67s` before, `37.27s` after. -- File analysis: `87918ms` before, `23352ms` after. -- Graph Cache save: `14058ms` before, `11233ms` after. -- Max resident set: `2901016576` bytes before, `465518592` bytes after. -- Peak memory footprint: `4232806784` bytes before, `332065728` bytes after. - -Result: - -- Cold index wall time improved from `104.67s` to `37.27s`. -- File analysis improved from `87918ms` to `23352ms` by avoiding Lezer recovery - during Godot `class_name` metadata pre-analysis. - -TypeScript alias config no-scan parse: - -- Command shape: same isolated plugin cache as the Godot fast path benchmark. -- Before command: TypeScript alias config parsing used - `ts.parseJsonConfigFileContent` with `ts.sys`, which enumerates project files - even though alias import analysis only needs `compilerOptions`. -- After command: TypeScript alias config parsing uses a parse host that can read - config files and extended config files but returns no project file list. -- Files: `2369`; the count is higher than the Godot fast-path run because this - iteration adds TypeScript plugin regression coverage and changeset/docs files. -- Nodes: `5081` -- Edges: `9108` -- Wall time: `37.27s` before, `17.28s` after. -- File analysis: `23352ms` before, `3697ms` after. -- Graph Cache save: `11233ms` before, `10904ms` after. -- Max resident set: `465518592` bytes before, `476708864` bytes after. -- Peak memory footprint: `332065728` bytes before, `328051904` bytes after. - -Result: - -- Cold index wall time improved from `37.27s` to `17.28s`. -- File analysis improved from `23352ms` to `3697ms` by skipping TypeScript - project file enumeration during alias config parsing. - -Warm Graph Cache query proxy: - -- Command shape: `requestWorkspaceGraphQuery` with report `nodes` and - `limit: 1` against the current Graph Cache. -- Wall time: `0.74s`. -- Graph Query diagnostic duration: `601ms`. -- Query graph size: `2514` nodes, `9108` edges. -- Caveat: cache status reported `stale` with `plugin-signature-changed` because - the query status path compared against the user's real installed-plugin cache - while the benchmark loaded an isolated plugin cache. The query still loaded - the Graph Cache and built graph data. - -Visible Graph projection benchmark: - -- Command shape: `pnpm run perf:visible-graph-monorepo` against the existing - Graph Cache with the isolated package-plugin cache used by the cold-index - benchmark. -- Before filter optimization: - - Warm Graph Cache graph build: `409ms`. - - Current settings projection: `775ms` median, `933ms` p95. - - No-filter projection: `5ms` median. - - Folders-on Graph Scope projection: `1369ms` median, `1445ms` p95. - - Import-edge-hidden projection: `153ms` median. -- After reusable glob matchers and skipping direct edge matching for path-only - filters: - - Warm Graph Cache graph build: `378ms`. - - Current settings projection: `22ms` median, `26ms` p95. - - No-filter projection: `5ms` median. - - Folders-on Graph Scope projection: `31ms` median, `32ms` p95. - - Import-edge-hidden projection: `17ms` median, `18ms` p95. -- Result: - - Current settings projection improved from `775ms` to `22ms`. - - Folders-on Graph Scope projection improved from `1369ms` to `31ms`. - - Import-edge-hidden projection improved from `153ms` to `17ms`. - - Scenario node and edge counts stayed unchanged after the filter - optimization. - -VS Code graph view benchmark: - -- Command shape: `pnpm run perf:vscode-graph-view` against this worktree, - launching Extension Development Host with local built-in plugin packages. -- Measurement target: open CodeGraphy on the monorepo, wait for the rendered - graph stats badge, then toggle the Graph Scope `Imports` edge type through - the real webview controls. -- First run: - - VS Code launch: `1518ms`. - - Open Graph View to first rendered graph stats: `57209ms`. - - Initial rendered stats: `2249` nodes, `5333` connections. - - Imports toggle latency: `3127ms` median, `3143ms` p95 across 5 samples. -- Repeat run: - - VS Code launch: `850ms`. - - Open Graph View to first rendered graph stats: `9917ms`. - - Initial rendered stats: `2249` nodes, `5333` connections. - - Imports toggle latency: `2983ms` median, `3079ms` p95 across 2 samples. -- After skipping force-graph cooldown ticks for already-positioned interactive - graphs: - - VS Code launch: `1408ms`. - - Open Graph View to first rendered graph stats: `9846ms`. - - Initial rendered stats: `2249` nodes, `5333` connections. - - Imports toggle latency: `1925ms` median, `2341ms` p95 across 5 samples. -- After passing constant 2D arrow color and position values to force-graph: - - VS Code launch: `1419ms`. - - Open Graph View to first rendered graph stats: `9612ms`. - - Initial rendered stats: `2249` nodes, `5333` connections. - - Imports toggle latency: `1595ms` median, `1620ms` p95 across 5 samples. -- Fresh control after reverting the hidden-edge experiment: - - VS Code launch: `1292ms`. - - Open Graph View to first rendered graph stats: `9938ms`. - - Initial rendered stats: `2249` nodes, `5333` connections. - - Imports toggle latency: `2891ms` median, `3563ms` p95 across 5 samples. -- Rejected stable-edge experiment: - - Keeping the full rendered graph stable and hiding filtered edges through - force-graph visibility callbacks measured `2918ms` to `2922ms` median - across variants, so it did not improve over the same-environment control. -- After memoizing the graph viewport surface: - - VS Code launch: `1478ms`. - - Open Graph View to first rendered graph stats: `9753ms`. - - Initial rendered stats: `2249` nodes, `5333` connections. - - Imports toggle latency: `1628ms` median, `2252ms` p95 across 5 samples. -- Instrumented webview-stage run before compiled legend matchers: - - VS Code launch: `1297ms`. - - Open Graph View to first rendered graph stats: `10167ms`. - - Imports toggle latency: `1748ms` median, `2272ms` p95 across 5 samples. - - Stage medians: `visibleGraph.derive` about `176ms` to `187ms`; - `visibleGraph.applyLegendRules` about `460ms` to `490ms`; - `graphRuntime.buildGraphData` about `4ms` to `7ms`. -- After compiling legend rule glob matchers once per legend snapshot: - - VS Code launch: `1411ms`. - - Open Graph View to first rendered graph stats: `7659ms`. - - Initial rendered stats: `2249` nodes, `5333` connections. - - Imports toggle latency: `835ms` median, `846ms` p95 across 5 samples. - - Stage medians: `visibleGraph.derive` `176.1ms`; - `visibleGraph.applyLegendRules` `79.8ms`; - `graphRuntime.buildGraphData` `5.4ms`. -- After skipping value-equal graph control echo updates: - - VS Code launch: `1077ms`. - - Open Graph View to first rendered graph stats: `7401ms`. - - Initial rendered stats: `2249` nodes, `5333` connections. - - Imports toggle latency: `493ms` median, `497ms` p95 across 5 samples. - - Stage medians: `visibleGraph.derive` `176.9ms`; - `visibleGraph.applyLegendRules` `80.3ms`; - `graphRuntime.buildGraphData` `5.4ms`. - - Instrumented event counts showed one `visibleGraph.derive` per toggle - sample instead of the duplicate derive work seen before this iteration. -- Rejected filter matcher cache experiment: - - Imports toggle latency stayed flat at `494ms` median, `513ms` p95 across - 5 samples. - - `visibleGraph.derive` stayed flat at `176.7ms` median, so recompiling - stable filter-pattern matchers was not the browser-side bottleneck. -- After caching recent visible-graph derivations by graph data and config: - - VS Code launch: `1081ms`. - - Open Graph View to first rendered graph stats: `6963ms`. - - Initial rendered stats: `2249` nodes, `5333` connections. - - Imports toggle latency: `313ms` median, `345ms` p95 across 5 samples. - - Sampled toggles had no `visibleGraph.derive` events; the remaining stage - medians were `visibleGraph.applyLegendRules` `81.3ms`, - `visibleGraph.style` `4.3ms`, and `graphRuntime.buildGraphData` `5.7ms`. -- After caching recent styled and legend-applied graph stages: - - VS Code launch: `1023ms`. - - Open Graph View to first rendered graph stats: `6918ms`. - - Initial rendered stats: `2249` nodes, `5333` connections. - - Imports toggle latency: `236ms` median, `270ms` p95 across 5 samples. - - Sampled toggles had no `visibleGraph.derive`, `visibleGraph.style`, or - `visibleGraph.applyLegendRules` events; remaining stage medians were - `graphRuntime.buildGraphData` `5.7ms` and - `visibleGraph.edgeDecorations` `0.3ms`. -- After rendering edge-only Graph Scope changes immediately: - - VS Code launch: `1046ms`. - - Open Graph View to first rendered graph stats: `6895ms`. - - Initial rendered stats: `2249` nodes, `5333` connections. - - Imports toggle latency: `203ms` median, `226ms` p95 across 5 samples. - - Sampled toggles emitted `graphScope.visibility.renderImmediate` instead - of `graphScope.visibility.renderDebounced`; remaining stage medians were - `graphRuntime.buildGraphData` `5.9ms` and - `visibleGraph.edgeDecorations` `0.4ms`. -- After adding the in-webview event-delta metric to the VS Code harness: - - VS Code launch: `1068ms`. - - Open Graph View to first rendered graph stats: `6377ms`. - - Initial rendered stats: `2249` nodes, `5333` connections. - - Imports toggle wall-clock latency: `209ms` median, `219ms` p95 across - 5 samples. - - In-webview optimistic-to-rendered latency: - `55ms` median, `58ms` p95 across 5 samples. -- Rejected startup timeline replay reorder: - - Tried moving cached timeline replay after graph bootstrap so first graph - stats could render before slow timeline work. - - VS Code launch: `1259ms`. - - Open Graph View to first rendered graph stats regressed to `7285ms`. - - Imports toggle wall-clock latency stayed flat at `204ms` median, - `219ms` p95; in-webview latency was `53ms` median, `111ms` p95. - - The change was reverted because it did not improve first graph readiness. -- After adding startup webview stage and first-ready phase instrumentation: - - VS Code launch: `868ms`. - - Open Graph View to first rendered graph stats: `6789ms`. - - First-ready phases: command/open `1709ms`, acceptance-ready frame - `5032ms`, stats wait after frame discovery `35ms`. - - Startup webview stage medians: `visibleGraph.derive` `96ms`, - `visibleGraph.applyLegendRules` `0ms` with `89ms` p95, - `visibleGraph.style` `5ms`, `graphRuntime.buildGraphData` `7ms`. - - Imports toggle wall-clock latency: `208ms` median, `243ms` p95; in-webview - latency: `56ms` median, `61ms` p95. -- After lazy-loading the 3D graph runtime outside the default 2D webview - bundle: - - Default `dist/webview/index.js` dropped from `2242.28 kB` minified - (`632.54 kB` gzip) to `819.25 kB` minified (`252.32 kB` gzip). - - 3D code now loads through separate chunks: - `threeDimensional-D-psWmzG.js` `694.00 kB`, `three.module-BKaKZvIP.js` - `726.58 kB`, and `runtime-CQzzxjLZ.js` `0.25 kB`. - - VS Code launch: `1125ms`. - - Open Graph View to first rendered graph stats: `6936ms`, effectively flat - against the `6789ms` startup-phase run. - - First-ready phases: command/open `1646ms`, acceptance-ready frame - `5189ms`, stats wait after frame discovery `40ms`. - - Imports toggle wall-clock latency: `193ms` median, `203ms` p95; in-webview - latency: `51ms` median, `81ms` p95. -- After adding webview startup handshake markers: - - VS Code launch: `1538ms`. - - Open Graph View to first rendered graph stats: `6940ms`. - - First-ready phases: command/open `1698ms`, acceptance-ready frame - `5184ms`, stats wait after frame discovery `13ms`. - - Once the webview document was alive, it posted ready at `26.3ms`, - received `GRAPH_DATA_UPDATED` at `53.5ms`, received - `APP_BOOTSTRAP_COMPLETE` at `261.8ms`, and rendered first graph stats at - `340.5ms`. - - The same startup run received a second `GRAPH_DATA_UPDATED` with the same - `5088` node / `9146` edge payload at `985.1ms`, then another bootstrap - completion at `1291ms`. -- After skipping settled duplicate graph payload replays: - - VS Code launch: `1337ms`. - - Open Graph View to first rendered graph stats: `6987ms`, effectively flat - against the `6940ms` startup-marker run because the remaining wall-clock - bucket is still frame readiness. - - The duplicate `5088` node / `9146` edge graph payload was received at - `1011.3ms` and skipped at `1016.6ms`. - - The duplicate replay no longer triggered the later visible-graph derivation, - styling, legend, edge-decoration, or graph-runtime build pass. Startup - event counts dropped from `6` to `5` `visibleGraph.derive` events, `4` to - `3` `visibleGraph.style` events, and `5` to `4` graph-runtime build events. - - Imports toggle wall-clock latency stayed in the same band at `191ms` - median, `222ms` p95; in-webview latency was `54ms` median, `86ms` p95. -- After adding extension-host startup markers: - - VS Code launch: `1087ms`. - - Open Graph View to first rendered graph stats: `14305ms`; this run was - noisy in the same remaining frame-readiness bucket. - - First-ready phases: command/open `1752ms`, acceptance-ready frame - `12500ms`, stats wait after frame discovery `40ms`. - - The extension-host provider resolve path took `3ms` from - `graphWebview.providerResolve.start` to - `graphWebview.providerResolve.end`; `webview.html` was assigned at `2ms` - with a `1022` byte HTML shell and `2` local resource roots. - - Once the webview document was alive, it received a `24522` node / `20781` - edge payload at `169.4ms`, applied `74` filter patterns in a - `498.4ms` visible-graph derive pass, and rendered first graph stats at - `843.3ms`. - - Imports toggle wall-clock latency was `213ms` median, `382ms` p95; in-webview - latency was `58ms` median, `64ms` p95. -- After combining visible-graph filter glob patterns into one matcher: - - VS Code launch: `1074ms`. - - Open Graph View to first rendered graph stats: `13837ms`, still dominated - by frame readiness rather than CodeGraphy resolve/render work. - - First-ready phases: command/open `1726ms`, acceptance-ready frame - `12066ms`, stats wait after frame discovery `32ms`. - - Extension-host provider resolve stayed tiny at `2ms`. - - The startup `visibleGraph.derive` pass with `74` filters over the `24522` - node / `20781` edge payload dropped from `498.4ms` to `244ms`. - - First graph stats after webview document start moved from `843.3ms` to - `586.4ms`. - - Imports toggle wall-clock latency stayed in the same band at `228ms` - median, `337ms` p95; in-webview latency was `58ms` median, `59ms` p95. -- After adding `codegraphy.open` command markers and extending only the - performance harness frame wait: - - VS Code launch: `958ms`. - - Open Graph View to first rendered graph stats: `40497ms`; the extended - harness wait captured an outlier that previously timed out at `20s`. - - First-ready phases: command/open `1595ms`, acceptance-ready frame - `38852ms`, stats wait after frame discovery `37ms`. - - Host timeline: `command.open.start` and `command.open.dispatched` at `0ms`, - `command.open.completed` at `38ms`, provider resolve start at `43ms`, and - `webview.html` assignment at `45ms`. - - Once the webview document was alive, it posted ready at `27.5ms`, received - graph data at `95.3ms`, ran the `74`-filter visible-graph derive in - `171.4ms`, completed app bootstrap at `1066.4ms`, and rendered stats at - `1145.9ms`. - - Imports toggle wall-clock latency was `185ms` median, `193ms` p95; in-webview - latency was `48ms` median, `50ms` p95. -- After adding Playwright frame lifecycle markers and writing startup-ready - metrics before interaction sampling: - - VS Code launch: `743ms`. - - Open Graph View to first rendered graph stats: `38513ms`. - - First-ready phases: command/open `1597ms`, acceptance-ready frame - `36867ms`, stats wait after frame discovery `36ms`. - - Frame lifecycle: the workbench frame existed at graph open; VS Code attached - and navigated webview frames around `1795ms`-`1983ms`; the usable - graph-ready fake.html frame attached/navigated at `37035ms`/`37040ms`; - Graph Stage was ready at `38464ms`. - - Extension-host timeline still assigned `webview.html` at `63ms` after - `codegraphy.open` started. - - Webview document work after the usable frame started remained near `1.14s`: - ready at `27.5ms`, graph data at `95ms`, first filtered derive at - `671.5ms`, bootstrap at `1058.7ms`, and graph stats at `1139.7ms`. - - Imports toggle wall-clock latency was `183ms` median, `489ms` p95; in-webview - latency was `47ms` median, `50ms` p95. -- After skipping duplicate graph payloads while the app is waiting for initial - bootstrap completion: - - VS Code launch: `1172ms`. - - Open Graph View to first rendered graph stats: `13885ms`. - - First-ready phases: command/open `1727ms`, acceptance-ready frame - `12108ms`, stats wait after frame discovery `38ms`. - - Host timeline still assigned `webview.html` at `49ms` after - `codegraphy.open` started. - - The latest startup webview event stream had a single `GRAPH_DATA_UPDATED` - for the `24522` node / `20781` edge payload and no second `74`-filter - visible-graph derive before bootstrap. The first filtered derive took - `245.6ms`, bootstrap completed at `504.1ms`, and first stats rendered at - `597.9ms` after the usable document started. - - Imports toggle wall-clock latency was `213ms` median, `267ms` p95; in-webview - latency was `58ms` median, `62ms` p95. -- Rejected direct Graph View focus before the container fallback: - - The command test covered trying `codegraphy.graphView.focus` before - `workbench.view.extension.codegraphy`, but the measured run did not improve - startup and the production change was reverted. - - Open Graph View to first rendered graph stats: `39359ms`. - - First-ready phases: command/open `1577ms`, acceptance-ready frame - `37734ms`, stats wait after frame discovery `34ms`. - - Host timeline still assigned `webview.html` at `50ms` after - `codegraphy.open` started, leaving the same frame-readiness bucket. - - Imports toggle wall-clock latency was `192ms` median, `219ms` p95; in-webview - latency was `51ms` median, `55ms` p95. -- After deferring visible graph derivation while startup loading hides the graph, - skipping unchanged post-load filter pattern replay, and fixing harness - in-webview delta pairing: - - VS Code launch: `818ms`. - - Open Graph View to first rendered graph stats: `42234ms`. - - First-ready phases: command/open `1732ms`, acceptance-ready frame - `40458ms`, stats wait after frame discovery `15ms`. - - Host timeline assigned `webview.html` at `61ms` after `codegraphy.open` - started. - - The startup webview event stream received graph data at `101.2ms`, skipped - the duplicate graph payload at `512.1ms`, completed bootstrap at `512.8ms`, - first ran the real `22304` node / `74`-filter visible-graph derive at - `696.6ms` for `183.8ms`, and rendered stats at `892.1ms` after the usable - document started. - - No `22304`-node visible-graph derive ran before bootstrap while the loading - state was hiding the graph. - - Imports toggle wall-clock latency was `202ms` median, `220ms` p95; in-webview - latency was `53ms` median, `56ms` p95. -- Rejected early `APP_BOOTSTRAP_COMPLETE` experiments: - - Moving bootstrap ahead of graph loading but still after cached timeline - replay did not move the browser event stream. Graph data still arrived at - `171.5ms`, bootstrap still arrived later at `1662.6ms`, and first stats - rendered at `2135.4ms` after the usable document started. Imports toggle - was `208ms` median, with one noisy Playwright p95 outlier; in-webview - latency stayed `52ms` median. - - Moving bootstrap ahead of cached timeline replay also did not move the - browser event stream. Graph data arrived at `170.0ms`, bootstrap still - arrived later at `1682.2ms`, and first stats rendered at `2140.1ms` after - the usable document started. Imports toggle was `199ms` median, `206ms` - p95; in-webview latency was `57ms` median, `60ms` p95. - - Both production variants were reverted. The result suggests that the - current first-display delay is not fixed by simply reordering - `APP_BOOTSTRAP_COMPLETE` in the ready handler; the bridge/timeline replay - path needs better delivery/phase instrumentation before changing startup - semantics. -- After adding generic webview message-delivery tracing: - - VS Code launch: `not captured` in the latest partial report. - - Open Graph View to first rendered graph stats: `41989ms`. - - Initial rendered stats: `2240` nodes, `5331` connections. - - First-ready phases: command/open `1723ms`, acceptance-ready frame - `40244ms`, stats wait after frame discovery `14ms`. - - The browser received the first `GRAPH_DATA_UPDATED` at `108.7ms`, replayed - cached/settings messages around `397ms`-`412ms`, received a duplicate - `GRAPH_DATA_UPDATED` at `500.7ms`, skipped it at `510.2ms`, and only then - received `APP_BOOTSTRAP_COMPLETE` at `510.9ms`. - - The first real `22304` node / `74`-filter visible-graph derive started at - `694.3ms`, took `183.3ms`, and first stats rendered after that. - - Imports toggle wall-clock latency was `201ms` median, `214ms` p95; in-webview - optimistic-to-rendered latency was `53ms` median, `59ms` p95. -- After adding extension-host outbound message tracing and coalescing dense - graph index progress: - - Before coalescing, the host sent `7844` `GRAPH_INDEX_PROGRESS` messages in - one startup run. The same run hit the webview trace limit with repeated - settings/control messages before graph data. - - After coalescing progress to deterministic phase buckets, host - `GRAPH_INDEX_PROGRESS` sends dropped to `51`. - - A 5-sample interaction run preserved startup metrics but timed out during - interaction sampling, so its startup evidence was kept as partial data: - VS Code launch `1099ms`, first graph ready `46160ms`, and first-ready - phases command/open `1673ms`, frame wait `44442ms`, stats wait `30ms`. - - A shorter 2-sample interaction run completed: VS Code launch `796ms`, - first graph ready `46329ms`, command/open `1680ms`, frame wait `44601ms`, - stats wait `32ms`. - - The completed run kept host `GRAPH_INDEX_PROGRESS` sends at `51`, with one - inbound progress marker in the webview startup trace. - - Imports toggle wall-clock latency was `357ms` median, `508ms` p95 across - 2 samples; in-webview optimistic-to-rendered latency stayed `55ms` median, - `57ms` p95. -- After skipping duplicate `WEBVIEW_READY` replays while first analysis is - already in flight: - - The early duplicate full settings replay around `518ms` disappeared from - the host send sequence. Startup now sends the first settings batch around - `190ms`, then continues to graph/index/bootstrap work without replaying - the same first-analysis settings batch. - - VS Code launch: `1085ms`. - - Open Graph View to first rendered graph stats: `46908ms`. - - First-ready phases: command/open `1570ms`, acceptance-ready frame - `45254ms`, stats wait after frame discovery `24ms`. - - Host `GRAPH_INDEX_PROGRESS` sends stayed at `51`. - - Aggregate settings/control sends are still high because later refresh and - plugin synchronization paths repeat them; this iteration only removed the - duplicate first-analysis ready replay. - - Imports toggle wall-clock latency was `231ms` median, `266ms` p95 across - 2 samples; in-webview latency was `50ms` median, `50ms` p95. -- Refresh-state reason tracing: - - Command shape: - `pnpm exec tsx scripts/performance/measure-vscode-graph-view.mjs --workspace /Users/poleski/Desktop/Projects/CodeGraphyV4 --iterations 2 --warmup 0 --output reports/performance/vscode-graph-view-refresh-state-reasons-2026-06-22.json`. - - Open Graph View to first rendered graph stats: `48255ms`. - - Host send counts still showed repeated settings/control messages: - `SETTINGS_UPDATED` `24`, `PHYSICS_SETTINGS_UPDATED` `24`, - `DIRECTION_SETTINGS_UPDATED` `24`, `SHOW_LABELS_UPDATED` `24`, - and `GRAPH_CONTROLS_UPDATED` `47`. - - All measured `graphWebview.refreshState.send` markers were - `changedFiles`, with `22` sends in the run. - - The first remaining post-bootstrap settings/control replay now has an - owner: `refreshChangedFiles` sends the full settings/control bundle after - each changed-file refresh completes. -- After skipping redundant full settings replay for indexed incremental - changed-file refreshes: - - The same reason-marker run shape recorded no - `graphWebview.refreshState.send` events. - - Host `SETTINGS_UPDATED` sends dropped from `24` to `2`. - - Host `PHYSICS_SETTINGS_UPDATED` sends dropped from `24` to `2`. - - Host `DIRECTION_SETTINGS_UPDATED` sends dropped from `24` to `2`. - - Host `SHOW_LABELS_UPDATED` sends dropped from `24` to `2`. - - Host `GRAPH_CONTROLS_UPDATED` sends dropped from `47` to `5`. - - Host `GRAPH_INDEX_PROGRESS` stayed coalesced at `51`. - - First graph readiness remained dominated by the VS Code frame-readiness - bucket: `38844ms`, split into `1715ms` command/open, `37115ms` frame wait, - and `10ms` stats wait. - - A one-sample Imports Graph Scope sanity check measured `191ms` wall-clock - and `49ms` in-webview optimistic-to-rendered latency. -- After fast simple-glob matching, page-tokened ready handshakes, and - pre-bootstrap hydration settings: - - Command shape: - `pnpm exec tsx scripts/performance/measure-vscode-graph-view.mjs --workspace /Users/poleski/Desktop/Projects/CodeGraphyV4 --iterations 1 --warmup 0 --output reports/performance/vscode-graph-view-ready-hydration-toggle.json`. - - Open Graph View to first rendered graph stats: `4733ms`, split into - `1577ms` command/open, `3141ms` frame wait, and `12ms` stats wait. - - The visible page received `SETTINGS_UPDATED`, `PHYSICS_SETTINGS_UPDATED`, - `LEGENDS_UPDATED`, sizing, decoration, and active-file state before - `APP_BOOTSTRAP_COMPLETE` at `332.5ms`. - - The real startup work after bootstrap now ran one `visibleGraph.derive` - (`43.7ms`), one `visibleGraph.applyLegendRules` (`48.1ms`), and one - `graphRuntime.buildGraphData` (`7.4ms`) before first stats rendered at - `523.9ms` after the usable document started. - - Earlier comparable traces with late ready/settings replay showed three - `graphRuntime.buildGraphData` startup iterations and post-bootstrap legend - work. This run had one graph-runtime build and no second - `APP_BOOTSTRAP_COMPLETE`. - - Imports toggle wall-clock latency was `190ms`; in-webview - optimistic-to-rendered latency was `55ms`. -- After skipping stale-cache analysis warm-up and fast-matching generated - pending refresh paths: - - Command shape: - `pnpm exec tsx scripts/performance/measure-vscode-graph-view.mjs --workspace /Users/poleski/Desktop/Projects/CodeGraphyV4 --iterations 1 --warmup 0 --output reports/performance/vscode-graph-view-fast-pending-filter-toggle.json`. - - Open Graph View to first rendered graph stats: `4550ms`, split into - `1620ms` command/open, `2917ms` frame wait, and `10ms` stats wait. - - The stale-cache load decision fell from `214ms` in the previous comparable - trace to `11ms`; the background analyze load decision fell from `182ms` to - `9ms`. - - The cached load request completed in `473ms`, down from `838ms` in the - no-stale-warmup trace. - - The stale-cache one-file `workspacePipeline.loadCachedGraph.warmAnalysis` - event no longer appears before first interaction. - - A direct status-profile pass on the CodeGraphy monorepo metadata - (`7383` pending paths, mostly generated/cache paths) reduced - `pendingFilter` from `317ms` to `13ms`; repeated - `readCodeGraphyWorkspaceStatus` calls fell from roughly `214-288ms` to - `8-14ms`. - - Imports toggle wall-clock latency was `197ms`; in-webview - optimistic-to-rendered latency was `53ms`. - -Interpretation: - -- Headless visible graph derivation is now in the `22ms` median range, but the - real webview initially took about `3s` to reflect a Graph Scope edge toggle. -- Skipping settled-graph simulation ticks moves the real toggle median from the - repeat-run `2983ms` baseline to `1925ms`, a `35%` improvement, but this is - still not editor-snappy. -- Passing arrow color and arrow position as primitive values instead of per-edge - callbacks moves the median to `1595ms` and trims the p95 to `1620ms`. -- The same-environment control varied back to `2891ms`; memoizing the viewport - surface moved it to `1628ms` by keeping overlay, tooltip, stats, and - accessibility state churn from re-rendering the force-graph surface. -- Instrumentation showed the force-graph data build is small (`4ms` to `7ms`); - the next measurable bottlenecks were legend rule application and visible - graph derivation. -- Compiling legend glob matchers reduced the measured legend stage from roughly - `460ms`-`490ms` per pass to about `79ms`-`83ms`, moving the real toggle - median under `1s`. -- Skipping value-equal graph control echoes removed the extra visible graph - derivation per toggle, moving the real Imports toggle median from `835ms` to - `493ms`. -- Caching recent visible-graph derivations removed the remaining derive pass - when users return to a recent Graph Scope state, moving the sampled toggle - median from `493ms` to `313ms`. -- Caching recent styled and legend-applied graph stages removed the next - `80ms` legend pass, moving the sampled toggle median from `313ms` to `236ms`. -- Rendering edge-only Graph Scope changes immediately removed the fixed - debounce wait from Imports toggles, moving the sampled toggle median from - `236ms` to `203ms`. -- The VS Code harness still reports a Playwright-driven wall-clock duration, - but it now also reports the browser-side delta from - `graphScope.edgeVisibility.optimistic` to `graphStats.rendered`. The current - in-webview median is `55ms`, while the wall-clock median is `209ms`. -- Startup instrumentation shows first graph readiness is now dominated by - command/view opening and waiting for the acceptance-ready webview frame. Once - that frame is found, the stats label is already available within tens of - milliseconds; the startup webview data stages are not the multi-second - bottleneck. -- Lazy-loading the 3D runtime materially reduces the default Graph View bundle - and keeps the 2D path from paying for Three.js up front. The current VS Code - first-ready harness did not show a first-load win because its dominant bucket - remains the view/frame readiness wait, not measured webview data work. -- Startup markers show that, after the webview document exists, ready/data/ - bootstrap/render work reaches first graph stats in roughly `341ms`. The - multi-second first-ready wall clock is still outside measured webview graph - derivation/rendering. Duplicate graph replay suppression removes avoidable - post-startup work from the stale-cache refresh path, but it does not address - the current frame-readiness bucket. -- Extension-host startup markers show the provider/webview resolver is not the - first-load bottleneck: resolving, assigning the HTML shell, setting context, - and flushing the pending refresh take only `2ms`-`3ms`. -- Combining filter glob patterns into one matcher addresses the next measured - CodeGraphy-side startup cost for the user's filtered monorepo settings, - cutting the `74`-filter visible-graph derive pass from `498.4ms` to `244ms` - and moving first stats after webview document start from `843.3ms` to - `586.4ms`. -- Command markers rule out the command palette/open command and provider - resolver as the multi-second startup bucket. On the latest run, CodeGraphy - assigned `webview.html` `45ms` after `codegraphy.open` started; the remaining - outlier was after HTML assignment and before the harness could observe the - VS Code webview frame/document. The performance harness now uses the same - `120s` timeout as the rest of the graph-view metric collection for that - frame wait so these outliers produce data instead of failed runs. -- Frame lifecycle markers show the coarse frame-readiness bucket is not just - “no iframe exists.” VS Code attaches and navigates early webview frames - around `1.8s`-`2.0s`, then the usable graph-ready fake.html frame appears - much later around `37.0s`. The harness now writes a `startup-ready` metrics - record before Graph Scope interaction sampling so a later control timeout - still preserves startup evidence. -- The webview now skips duplicate graph payloads even while it is still waiting - for `APP_BOOTSTRAP_COMPLETE`. This keeps loading semantics intact but avoids - re-running visible-graph derivation when the same graph arrives before - bootstrap settles. -- Directly focusing the Graph View tree item before the container fallback did - not move the first-ready timing, so the command change was discarded instead - of adding a behavior path without a measured win. -- Startup loading no longer derives or styles the real large graph before the - app is allowed to display it. This removes hidden pre-bootstrap work, while - the latest first-load wall clock remains dominated by VS Code webview frame - readiness outside the measured CodeGraphy data path. -- Sending `APP_BOOTSTRAP_COMPLETE` earlier in the ready handler is not a - sufficient startup-display fix. In real Extension Development Host runs, the - browser still observed bootstrap after the graph replay/load messages, so the - next startup iteration should instrument webview message delivery and cached - timeline replay instead of committing a source reorder that does not change - visible timing. -- Message-delivery tracing confirms the browser currently sees bootstrap only - after graph data and the duplicate graph replay. The next startup hypothesis - should follow the extension-host post sequence and cached timeline bridge, - because the webview listener is receiving messages in that late order. -- Extension-host send tracing found a real message-volume bottleneck: - uncoalesced graph index progress produced thousands of outbound messages on - startup. Deterministic progress coalescing cuts that to dozens while keeping - first/final and phase-boundary progress visible. The remaining repeated - settings/control sends are now the next message-volume target. -- Duplicate `WEBVIEW_READY` handling no longer resends the first-analysis - settings bundle while the original ready handler is still loading graph data. - Later repeated settings/control sends remain visible and need separate - ownership tracing before changing behavior. -- Refresh-state reason tracing identified `refreshChangedFiles` as the owner - of the remaining repeated settings/control bundle. The next behavior change - should preserve changed-file graph analysis while avoiding redundant full - settings replay after indexed incremental refreshes. -- Indexed incremental changed-file refreshes now keep their graph-analysis path - but avoid the trailing full settings/control replay. This removes the - measured `changedFiles` refresh-state burst and cuts repeated startup - settings/control messages back to the expected ready/bootstrap sends. -- Analysis request markers show the next startup stall is in request ownership, - not webview rendering. In the latest one-sample run, the first `load` request - started at `725ms`, an `incremental` request started at `1477ms`, the load - request completed at `1481ms` without publishing `GRAPH_DATA_UPDATED`, and - the first published graph came from a full `analyze` request at `36817ms`. - The next behavior change should keep changed-file work from preempting the - first cached webview load. -- Incremental changed-file analysis now waits for the first workspace-ready - graph before starting. The next one-sample run published the cached `load` - graph at `9857ms` before starting incremental work at `9916ms`, so the first - `GRAPH_DATA_UPDATED` no longer waits for a full `analyze` request. First - graph readiness improved from `40649ms` to `13696ms` in this noisy VS Code - frame-readiness harness, while the host-side first publish moved from - `36817ms` to `9857ms`. The remaining startup target is the cached load path - itself, which now accounts for roughly `9.9s` before publish. -- Cached Graph Cache replay no longer performs a full workspace discovery walk. - It derives discovered files and directories from cached paths, then asks git - for ignored metadata only for those cached paths. A direct probe measured the - replacement metadata path at `322ms` versus `4083ms` for full discovery with - the user's filters. The VS Code harness then moved cached `load` publish from - `9857ms` to `2396ms`, request completion from `9917ms` to `1653ms`, and first - graph readiness from `13696ms` to `5876ms`; visible graph stats stayed stable - at `2300` nodes and `5345` edges. -- Cached-load and publish-stage markers now split host-side startup work. After - the cached replay change, `loadCachedGraph` itself takes roughly `793ms`- - `837ms`, with hydration around `389ms`-`437ms`, cached git/path metadata - around `322ms`, and graph construction around `71ms`-`74ms`. The next - host-side bottleneck was Material Icon legend generation inside - `graphAnalysis.publish.groups`, which dropped from `748ms` to `96ms` after - reusing a prepared extension matcher from the cached material theme. The - cached `load` publish moved from `2279ms` to `1696ms`, and first graph - readiness moved from `5823ms` to `5617ms` in the one-sample harness. -- Stale cached replay now defers live gitignore metadata because load mode - immediately starts a background full analysis for stale indexes. Fresh cached - replay still includes live gitignore metadata because no background - correction is guaranteed. On the CodeGraphy monorepo benchmark this moved - `workspacePipeline.loadCachedGraph.cachedDiscovery` from `324ms` to `11ms`, - `loadCachedGraph.completed` from `836ms` to `497ms`, cached load request - completion from `1007ms` to `672ms`, cached load publish from `1696ms` to - `626ms`, and first graph readiness from `5617ms` to `5266ms`. The first - rendered stats stayed stable at `2300` nodes and `5345` edges; the stale - replay raw graph omitted ignored-only folder metadata until the background - analysis sent the follow-up graph update. -- Graph Cache hydration now overlaps the period between analyzer construction - and the first cached load request. The first request still observes the same - cache freshness and replay semantics, but it spends less time waiting for the - repo-local cache file once the webview asks for graph data. On the CodeGraphy - monorepo benchmark this moved `workspacePipeline.loadCachedGraph.hydrate` - from `406ms` to `170ms`, `loadCachedGraph.completed` from `497ms` to - `259ms`, cached load request completion from `672ms` to `429ms`, and first - graph readiness from `5266ms` to `5114ms`. The first rendered stats stayed - stable at `2300` nodes and `5345` edges; Imports toggle latency stayed in the - same snappy band at `197ms` wall-clock and `54ms` in-webview. -- Existing-file live updates now reuse the current discovered-file snapshot - instead of rediscovering the full workspace before changed-file analysis. A - VS Code harness probe against `packages/extension/src/extension/graphViewProvider.ts` - used `/tmp` for the extension-host performance log so CodeGraphy did not - watch its own metrics file. With the shortcut temporarily disabled, the probe - measured `3854ms` wall-clock and `3149ms` request time, including a `1900ms` - full discovery pass. With cached discovery enabled, the same probe measured - `1887ms` wall-clock and `1180ms` request time; discovery mode was `cached` - and took `0ms`. The graph size after the refresh stayed in the same - `5101` node / `9146` edge raw graph band. -- Changed-file refresh phase tracing showed that cached discovery exposed the - next hot spot inside per-file analysis. Before pre-analysis routing, the - one-file live-update request measured `722ms`, with `notifyPreAnalyze` - taking `450ms` and the delegated `analyzeFiles` phase taking `527ms`. After - routing plugin pre-analysis by supported file extension, the same probe - measured `955ms` wall-clock and `267ms` request time; `notifyPreAnalyze` - rounded to `0ms`, delegated `analyzeFiles` dropped to `78ms`, and the - changed-file refresh completed in `176ms`. The remaining backend phases were - one-file plugin analysis at `75ms` and two graph-build passes at `54ms` and - `37ms`. -- Existing-file content saves now use a shorter `100ms` debounce while create, - delete, and rename operations keep the wider `500ms` coalescing window. The - CodeGraphy monorepo live-update probe measured `574ms` wall-clock and - `283ms` request time after this change. The changed-file refresh itself took - `190ms`; the phase split was `91ms` analyze files, `53ms` - `buildGraphDataFromAnalysis`, and `36ms` `buildGraphData`. This trims the - human-visible wait from the cached-discovery `1887ms` run and the - pre-debounce `955ms` run, while keeping filesystem-operation coalescing - unchanged. -- Tree-sitter language loading is now targeted per requested language instead - of importing every bundled grammar before a one-file parse. A micro-probe - showed `loadTreeSitterLanguageBinding("typeScript")` taking `11ms`-`17ms` - after warm module cache effects, while the compatibility - `loadTreeSitterBindings()` path took `62ms`-`205ms` and loaded all language - bindings. This is a startup and incremental-analysis guardrail rather than a - visible graph-count change. -- Changed-file graph refresh now skips the fallback connections-graph build - when the analysis map already covers every retained file connection key. The - fallback remains for discover-only states that need orphan preservation. On - the rebuilt VS Code live-update probe, the `buildGraphData` phase disappeared, - `refreshChangedFiles.completed` moved from `190ms` to `144ms`, and - incremental request duration moved from `283ms` to `236ms`. The end-to-end - live-update wall clock moved from `574ms` to `545ms`, with the remaining wait - now mostly outside this backend graph-build pass. -- Existing-file content saves now use a `50ms` debounce while create, delete, - and rename operations keep the `500ms` coalescing window. The next VS Code - live-update probe measured `488ms` wall-clock and `237ms` request duration. - The backend phase split stayed effectively flat (`145ms` changed-file - refresh, `82ms` one-file plugin analysis, `52ms` analysis graph build), so - this iteration specifically moved request start earlier rather than changing - graph computation. -- The TypeScript alias plugin now caches parsed compiler options by - `tsconfig.json` mtime and clears that cache when tsconfig-style files change. - This avoids reparsing the same path-alias config for each TypeScript file - while preserving alias updates from extended configs. On the live-update - probe, one-file plugin analysis moved from `82ms` to `40ms`, delegated - `analyzeFiles` moved from `84ms` to `43ms`, changed-file refresh completion - moved from `145ms` to `106ms`, incremental request duration moved from - `237ms` to `200ms`, and end-to-end live-update wall clock moved from `488ms` - to `432ms`. -- Incremental publish now has a no-op graph fast path for fresh changed-file - refreshes whose raw graph payload is unchanged. The fast path keeps status, - plugin, decoration, exporter, toolbar, injection, post-analyze, and workspace - ready broadcasts, but skips raw graph replacement, view transforms, merged - group recomputation, group publication, and `GRAPH_DATA_UPDATED`. A focused - publish test covers the unchanged-graph contract. The first large-repo VS - Code probe after this change did not reuse the current graph because stale - pending refresh metadata forced a full background analysis before the save - refresh; the reuse check took `18ms` and reported `reused: false`, so the - next clean run should confirm whether this fast path is net-positive on true - no-op saves. -- The VS Code probe exposed stale pending refresh metadata as the next - measurement blocker. The main CodeGraphy workspace had `7154` persisted - pending paths, mostly generated `.turbo` folders and nested agent worktree - files, which made each benchmark treat the Graph Cache as stale and run a - roughly `34s` background full analysis. Pending refresh persistence now - ignores the workspace root, generated `.turbo` paths, and `.worktrees` paths - before saving or loading metadata. Against the polluted metadata, the new - filter reduces the pending set from `7154` paths to `1` real source path - (`packages/extension/src/extension/graphViewProvider.ts`). The latest - polluted VS Code sample measured the incremental save request at `232ms`, - changed-file refresh at `97ms`, and Imports toggle at `203ms` wall-clock / - `54ms` in-webview, but the end-to-end save wall clock stayed invalid for - comparison because the stale full analysis still occupied the session. -- The VS Code live-update harness now waits for the restore-triggered - incremental request before finishing, so a benchmark write/restore pair no - longer returns while the restored file is still queued. A script-level test - simulates marker and restore requests. With the harness wait in place, the - measured marker request stayed in the `218ms`-`434ms` range and the restore - request stayed in the `414ms`-`462ms` range while a background full analysis - was active. -- Generated pending paths are now filtered before Graph Cache status/freshness - checks in both core and extension code. The extension pipeline also persists - `lastIndexedCommit` after full indexing, repairing older metadata where the - commit was left `null`. A repair run wrote - `5108cc3209a9a1d92789d0ed4b1a4f027fbb741e` to `lastIndexedCommit`, and the - generated pending list filtered from `7167` paths down to one real source - path. A diagnostic startup run recorded the remaining stale reason as - `CodeGraphy Workspace Graph Cache is stale: files changed since the last - Indexing run.` The remaining blocker is therefore the benchmark source file - (`packages/extension/src/extension/graphViewProvider.ts`) being newer than - the last completed index after live-update probes, not `.turbo` or - `.worktrees` noise. The next clean measurement pass should wait for a full - background index without touching the live-update file, then take the fresh - live-update sample. -- The VS Code live-update harness now waits for active background `analyze` - requests to go idle before writing its marker file. A script-level regression - test covers the contaminated-measurement case where a stale startup analyze - was active before the marker write. On the polluted main workspace, this - moved the reported live-update wall clock from the invalid `32164ms`- - `32444ms` band down to `671ms`, with the actual incremental request at - `404ms`; the background `analyze` still took `34531ms`, but it is no longer - counted as live-update latency. -- Pending source paths are now ignored by freshness checks when the file still - exists and its mtime is at or before `lastIndexedAt`. This handles duplicate - watcher events that persist after a successful benchmark restore/index cycle. - In the polluted main workspace, raw metadata still had `7171` pending paths - after shutdown, but the updated source filter reduced that set to `0`. The - next rebuilt VS Code run published cached `load` as `fresh` with no - background `analyze`, completed the load request in `1119ms`, reached first - graph readiness in `5112ms`, measured Imports toggle at `277ms` wall-clock / - `62ms` in-webview, and measured live-update at `943ms` wall-clock with a - `597ms` incremental request. -- Rejected startup-order experiment: moving graph loading before cached timeline - replay made the `load` request start earlier (`267ms` after open instead of - roughly `741ms`), but it ran under heavier webview startup contention and - regressed first graph readiness from `5112ms` to `5583ms`. A stats-frame - harness probe also still observed the same late visible stats timing, so the - experiment was backed out. The next startup work should target product work - inside cached load or webview render cost, not timeline-before-graph ordering. -- The VS Code graph-view harness now rereads the extension-host performance log - after interaction sampling, so completed reports include backend stages for - Graph Scope toggles, marker saves, and restore saves instead of startup-only - host events. The first complete-host-events run measured a fresh cached load - at `4822ms` first graph readiness, Imports toggle at `196ms` wall-clock / - `61ms` in-webview, and live update at `697ms` wall-clock with a `537ms` - incremental request. -- Incremental publish now skips merged group recomputation and `LEGENDS_UPDATED` - when an indexed changed-file refresh only changes node metric fields such as - `fileSize` or `churn`. It still replaces raw graph data, applies the view - transform, sends `GRAPH_DATA_UPDATED`, status, plugin, decoration, exporter, - toolbar, injection, post-analyze, and workspace-ready updates. A focused - publish test covers the metric-only contract. In the next rebuilt VS Code - probe, the marker save and restore save both emitted - `graphAnalysis.publish.groupsSkipped` with `groupInputsUnchanged`; the - post-refresh publish segment dropped from roughly `153ms`-`159ms` to - `90ms`-`93ms`. End-to-end request duration stayed in the same band - (`543ms`/`537ms`) because that run's TypeScript one-file analysis phase was - noisier (`94ms` versus `34ms`-`35ms` in the previous sample). -- The metric-only publish path now recognizes absolute changed-file paths - before running the deep unchanged-graph comparison. This lets save/restore - updates whose node `fileSize` or `churn` already differs skip serializing the - full `6485` node / `20781` edge graph during `reuseCheck`. The rebuilt VS - Code probe moved `graphAnalysis.publish.reuseCheck` from `24ms`-`27ms` to - `5ms` on marker save and `1ms` on restore. The marker request measured - `501ms`, while the cleaner restore sample measured `446ms`; both still sent - full `GRAPH_DATA_UPDATED` payloads because node metrics changed. -- Metric-only changed-file refreshes now publish a compact - `GRAPH_NODE_METRICS_UPDATED` patch instead of resending the full graph when - the affected nodes only changed `fileSize` or `churn` and the affected edge - signature stayed stable. The publish path still falls back to - `GRAPH_DATA_UPDATED` if a changed file adds/removes graph nodes or changes - affected edges. The VS Code live-update harness now waits for the exact graph - update message to be received in the webview, so this measurement includes - delivery rather than extension-host completion alone. The strict rebuilt - probe sent one-node metric patches for both marker and restore saves, - recorded `graphAnalysis.publish.sendGraphNodeMetrics` at `0ms`, and measured - the marker request at `411ms`. The end-to-end marker wall clock was `1193ms`; - the webview trace shows the remaining cost is now derived-graph work after - the tiny patch (`239.5ms` in `visibleGraph.derive` and `86ms` in - `visibleGraph.applyLegendRules`) rather than host-side full graph - publication. -- The webview metric-patch handler now applies `fileSize`/`churn` patches in - place when the active node size mode is `connections` or `uniform`, keeping - the `graphData` reference stable so metric-only saves do not invalidate - visible graph derivation, legend coloring, or runtime graph construction. - Metric-based node size modes (`file-size` and `churn`) still replace - `graphData` so the visual sizes update correctly. The rebuilt monorepo probe - received the one-node `GRAPH_NODE_METRICS_UPDATED` message, recorded - `extensionMessage.graphNodeMetricsPatchedInPlace`, and emitted zero - `visibleGraph.derive`, zero `visibleGraph.applyLegendRules`, and zero - `graphRuntime.buildGraphData` events in the live-update window. The first - strict sample moved marker wall clock from `1193ms` to `828ms`; a follow-up - sample measured `657ms` wall clock with a `356ms` incremental request. -- Incremental analysis preparation now skips the pre-refresh group recompute - and `LEGENDS_UPDATED` broadcast. Group recomputation remains in the publish - stage, where metric-only refreshes can skip it with the existing - `groupInputsUnchanged` check. In the rebuilt monorepo probe, the live-update - window no longer received `LEGENDS_UPDATED`; the request-start-to-refresh - gap moved from roughly `254ms` to `178ms` on the comparable marker sample. - Marker request duration moved from `456ms` to `383ms`, while restore measured - `328ms`. The wall-clock sample stayed noisy (`836ms`), so this iteration is - recorded as a backend request improvement rather than a measured UI-wall win. -- The VS Code live-update harness now captures `graphAnalysis.prepare.*` and - `graphAnalysis.load.*` phase marks. Those marks showed incremental prepare - was already `0ms`, while `graphAnalysis.load.decision` spent `177ms`-`178ms` - reading index freshness even though incremental mode always routes to - changed-file refresh. Incremental load now bypasses that freshness scan and - records `indexFreshness: "skipped"` for the route decision; publish still - reads and broadcasts the real post-refresh index status. In the rebuilt - monorepo probe, incremental `load.decision` measured `0ms`, marker request - duration moved from `383ms` to `205ms`, restore moved from `328ms` to - `135ms`, and strict live-update wall clock moved from `836ms` to `594ms`. - The remaining backend cost is now the changed-file refresh itself (`179ms` - marker, `113ms` restore), led by one-file plugin analysis and analysis graph - rebuild. -- Metric-only changed-file refreshes now patch node metrics onto the cached - raw graph when the changed file's analysis and connections are graph- - equivalent. This removes the host-side `buildGraphDataFromAnalysis` rebuild - from saves that only change `fileSize` or `churn`, while preserving the full - rebuild fallback for structural graph changes. In the rebuilt monorepo probe, - the marker save recorded `analyzeFiles` at `91ms`, - `patchGraphDataNodeMetrics` at `1ms`, and - `refreshChangedFiles.completed` at `119ms`; restore recorded `20ms`, `0ms`, - and `62ms` respectively. The live-update request moved from `205ms` to - `142ms`, strict wall clock moved from `594ms` to `545ms`, and the webview - still received a one-node `GRAPH_NODE_METRICS_UPDATED` patch with zero - visible-graph derivation or runtime graph rebuild in the live-update window. -- Cached graph replay now warms one representative cached source file through - the routed analyzer in the background, choosing the most common supported - source extension while skipping temp/generated folders such as - `.stryker-tmp`, `.turbo`, `.worktrees`, `dist`, `coverage`, and `reports`. - This targets the cold parser/plugin cost that appeared on the first save - after opening a warm Graph Cache. In the rebuilt monorepo probe, the - background warm-up analyzed `apps/web/src/index.ts` in `727ms` without - regressing first graph readiness (`5263ms`) or Imports toggle (`272ms` - wall-clock / `59ms` webview). The first marker save's - `analyzeFileResultForPlugins` phase moved from `88ms` to `13ms`, - `analyzeFiles` moved from `91ms` to `15ms`, - `refreshChangedFiles.completed` moved from `119ms` to `53ms`, and the - incremental request moved from `142ms` to `77ms`; strict live-update wall - clock moved from `545ms` to `510ms`. -- Normal saved-file and file-change refreshes now use a `32ms` two-frame - debounce instead of `50ms`; create/delete/rename operations keep their - `500ms` coalescing window. The VS Code live-update harness also now reports - write-to-request start and write-to-request completion delay, so backend - processing can be separated from VS Code watcher/scheduler latency. In two - rebuilt monorepo probes, strict live-update wall clock measured `434ms` and - `382ms` versus the previous `510ms`, with request durations in the same band - (`87ms` and `83ms` versus `77ms`). The new request-start delay metric - measured `259ms` and `203ms`, confirming the remaining wall cost is mostly - before CodeGraphy's incremental request begins rather than graph rebuilding - or webview recomputation. First graph readiness stayed in the same band - (`5165ms` and `5026ms`), and the Imports toggle measured `201ms`/`198ms` - wall-clock with `62ms`/`57ms` in-webview. -- Metric-only incremental patches now skip the static graph-state broadcast - bundle (`DEPTH_*`, plugin statuses, decorations, context menu items, - exporters, toolbar actions, contribution statuses, and plugin webview - injections). They still send progress, the one-node - `GRAPH_NODE_METRICS_UPDATED` patch, index status, post-analyze, and - workspace-ready notifications. The rebuilt monorepo probes recorded - `graphAnalysis.publish.broadcastsSkipped` with - `reason: "metricOnlyGraphPatch"` and the webview live-update window shrank - to four progress messages, one metrics patch, the in-place patch marker, and - index status. Incremental request duration measured `66ms` and `69ms`, down - from the prior `83ms`-`87ms` samples, while strict wall clock stayed in the - same `427ms`-`429ms` range because request-start delay remained `261ms`. -- Metric-only changed-file refreshes now return the graph patch before index - metadata persistence settles. Persistence still runs in the background and - logs failures, while structural refreshes keep waiting for metadata because - no compact correction is guaranteed. In two rebuilt monorepo probes, - `workspacePipeline.refreshChangedFiles.completed` fired at `44ms`-`58ms`, - then `persistIndexMetadata` finished afterward in `18ms`-`26ms`. Strict - live-update wall clock measured `420ms` and `448ms`, with incremental request - duration at `67ms` and `69ms`; request-start delay still dominated at - `266ms` and `263ms`, so the next visible-latency target remains the file - watcher/scheduler path before CodeGraphy's refresh begins. -- The VS Code live-update harness can now trigger the benchmark write through - an actual editor save using an acceptance-only webview message, so the - measured path includes `onDidSaveTextDocument` instead of only raw filesystem - watcher events. The first editor-save run exposed duplicate changed-file - analysis: the marker save produced two incremental requests (`97ms` and - `107ms`) before the restore request (`155ms`), with `555ms` end-to-end marker - wall time and `391ms` request-start delay. File watcher change events are now - suppressed for one second after the matching saved-document refresh, with - expired saved paths pruned before reuse. The rebuilt editor-save probe - produced one marker request (`89ms`) plus the restore request (`71ms`), - moving marker wall time to `494ms` and request-start delay to `350ms`. -- Rejected zero-delay saved-document debounce experiment: - - Reducing saved-document refresh scheduling from `32ms` to `0ms` measured - request-start delays of `318ms`, `322ms`, and `343ms` across three - one-sample editor-save probes, compared with the prior `350ms` editor-save - dedupe sample. Request duration stayed small in the clean samples, but - end-to-end wall time remained noisy at a `547ms` median. - - The production change was backed out because existing focused tests caught - regressions in rapid-save and save-plus-create coalescing. Preserving the - `25ms` coalescing contract is worth more than the small measured - pre-request gain. The next target remains the pre-request save/event- - delivery path rather than backend graph analysis. -- Extension-host save-path instrumentation now marks acceptance save stages, - saved-document receipt, workspace refresh scheduling/start, and provider - `refreshChangedFiles` entry. The first instrumented editor-save sample - measured `555ms` wall clock, `92ms` request duration, and `367ms` - request-start delay. The split showed `252ms` inside the acceptance save - helper, the intentional `32ms` saved-file debounce, and `78ms` between - provider `refreshChangedFiles` entry and `graphAnalysis.request.start`. -- Indexed incremental changed-file refreshes now reuse the already-loaded - filter/group state instead of reloading groups and filter patterns before - every save. Fallback changed-file paths that need a primary/full refresh - still prepare settings before running. In the rebuilt editor-save probe, the - provider-entry-to-request gap moved from `78ms` to `2ms`, incremental request - duration moved from `97ms` to `76ms`, request-start delay moved from `367ms` - to `282ms`, and wall time moved from `559ms` to `460ms`. First graph - readiness stayed in the same band (`5272ms`) and Imports toggle stayed - snappy (`217ms` wall-clock / `62ms` in-webview). -- The VS Code live-update harness now reports post-save phase delays from - extension-host markers: saved-document receipt to request start/completion, - workspace-refresh start to request start, and provider entry to request - start. This keeps production save latency separate from the artificial - editor-open/edit/save work used by the benchmark trigger. In the rebuilt - editor-save probe, overall marker wall time measured `477ms`, but the real - post-save path was `39ms` from saved-document receipt to request start and - `140ms` to request completion. Workspace-refresh start to request start was - `4ms`, provider entry to request start was `3ms`, incremental request - duration was `101ms`, first graph readiness was `5346ms`, and Imports toggle - stayed in the same snappy range (`276ms` wall-clock / `58ms` in-webview). - Future live-update comparisons should prefer the post-save phase metrics - over total wall clock when the trigger is `editor-save`. -- Duplicate `WEBVIEW_READY` handling after bootstrap now replays lightweight - settings and `APP_BOOTSTRAP_COMPLETE` without resending the full graph - snapshot when the webview is already marked ready. This removes the startup - duplicate `GRAPH_DATA_UPDATED` payload that the webview previously skipped - only after receiving and inspecting all `6485` raw nodes and `20781` raw - edges. In the rebuilt editor-save probe, startup sent one full graph payload - instead of two, first graph readiness moved from `5346ms` to `5191ms`, and - Imports toggle stayed snappy (`211ms` wall-clock / `60ms` in-webview). - Live update stayed in the same fast post-save band with `116ms` from - saved-document receipt to request completion. -- Rejected precompiled file-path scoped symbol matcher experiment: - - The hypothesis was that `visibleGraph.derive` spent meaningful time - recompiling the two `**/*.gd` file-path symbol matchers while resolving - graph-scope node visibility. A TDD spike added a scoped-definition matcher - cache and used it in `nodeMatchesScope`. - - The rebuilt monorepo probe did not improve the target stage: - `visibleGraph.derive` stayed at `250.9ms` versus the prior `250.2ms`, and - first graph readiness stayed flat/noisy (`5215ms` versus `5191ms`). - Imports toggle was still snappy (`196ms` wall-clock / `64ms` in-webview), - but the code change was backed out because it did not move the measured - bottleneck. -- Rejected disabled-base-symbol short-circuit experiment: - - The hypothesis was that graph-scope matching wasted measurable startup time - by checking scoped symbol definitions before rejecting symbols whose base - `symbol` node type was disabled. - - A TDD spike moved the disabled-node-type/ancestor check ahead of scoped - symbol matching and proved the scoped matcher no longer ran in that case, - but the rebuilt monorepo probe did not improve the hotspot: - `visibleGraph.derive` measured `251.1ms` versus the prior `250.2ms`, and - first graph readiness drifted worse/noisy (`5299ms` versus `5191ms`). - Imports toggle stayed snappy (`205ms` wall-clock / `59ms` in-webview) and - post-save live update stayed fast (`112ms` from saved-document receipt to - request completion), but the production change was backed out because it - did not move the measured bottleneck. -- Combined filter glob matching now uses fast-path matchers for the plugin - default-filter shapes that dominate large workspace visual filtering: - basename suffixes such as `**/*.meta`, exact path suffixes, recursive - directory subtrees such as `**/node_modules/**`, and direct-child directory - patterns. Complex globs still fall back to the existing regex semantics. - The focused regression loop over `10,000` nonmatching paths and real plugin - default filters moved from a red `301ms` sample to the focused test file - completing in `22ms`. The standalone visible-graph benchmark improved the - current scenario median from `29ms` to `19ms`, folders-on from `50ms` to - `38ms`, and imports-off from `18ms` to `7ms`. In the rebuilt VS Code - monorepo probe, browser `visibleGraph.derive` for the startup graph moved - from `250.2ms` to `46.7ms` on `6485` raw nodes / `20781` raw edges, first - graph readiness moved from `5191ms` to `5002ms`, and Imports toggle stayed - snappy (`197ms` wall-clock / `57ms` in-webview). Post-save live update - stayed in the same fast band at `120ms` from saved-document receipt to - request completion. -- Single-pattern glob matching now reuses the same fast-path classifier as - combined matching, so legend rules do not pay a regex test for common - suffix, exact-path, recursive-directory, or direct-child patterns. A focused - red loop over repeated simple matchers measured `32.5ms` before the change - and passed after the change with the focused glob test file completing in - `27ms`. In the rebuilt VS Code monorepo probe, the post-settings legend - application pass moved from `90.3ms` to `53.2ms` for `131` legend rules over - `2300` visible nodes and `5345` visible edges. Startup `visibleGraph.derive` - stayed fast at `43.5ms`, Imports stayed snappy (`200ms` wall-clock / `59ms` - in-webview), and post-save live update stayed in the fast band at `116ms` - from saved-document receipt to request completion. -- Loaded changed-file refreshes now stay on the incremental path even when - persisted index metadata is temporarily unavailable during stale-cache - startup/background analysis. Truly cold no-index refreshes still fall back to - the primary load path. A rebuilt filesystem-triggered live-update probe - against `packages/core/src/index.ts` completed instead of timing out: - first graph readiness was `4534ms`, the marker edit measured `414ms` - wall-clock with a `176ms` incremental request, and the benchmark restore - also stayed incremental at `87ms` instead of starting a `load` request plus - another long background `analyze`. The protected main checkout was clean - after the probe restored the benchmark file. -- Incremental changed-file refreshes now bypass stale-cache background sync - waits after the first graph is already usable. Explicit foreground index and - refresh work still blocks competing analysis, but the lower-priority - background cache sync no longer makes a user edit wait. A new - `--live-update-no-analyze-idle-wait` benchmark flag exercises that behavior - without weakening the default clean live-update metric. In a forced-stale - probe, the stale background `analyze` started after cached load, the marker - edit started an incremental request while that analyze was active, and the - edit completed in `380ms` wall-clock with a `105ms` incremental request. - The restore request also stayed incremental at `65ms`, and the protected - main checkout was clean after the temporary stale marker was restored. -- The VS Code graph-view harness now reports a first-graph-ready breakdown so - remaining startup work is not hidden inside one wall-clock number. Applied to - the latest clean large-monorepo trace, CodeGraphy assigned the webview HTML - at `73ms` after the extension command started, sent the first graph payload - at `1041ms`, and completed the cached `load` request in `237ms`. Once the - browser document posted ready, it reached rendered graph stats in `243ms`. - The measured `4614ms` first-ready total is therefore dominated by the - benchmark's `1647ms` command/view-open bucket and `2954ms` VS Code webview - frame-readiness bucket, not graph derivation or renderer work. - -Full test baseline: - -- `pnpm run test`: `1523.98s` wall time -- Unit phase: `1009` test files and `6039` tests passed -- Playwright phase: `119` tests passed in `22.3m` - -Raw logs are intentionally ignored under `reports/performance/`. diff --git a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md b/docs/superpowers/plans/2026-06-22-codegraphy-performance.md deleted file mode 100644 index 44de3e0f7..000000000 --- a/docs/superpowers/plans/2026-06-22-codegraphy-performance.md +++ /dev/null @@ -1,357 +0,0 @@ -# CodeGraphy Performance Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Make CodeGraphy feel snappy on the CodeGraphy monorepo by measuring load and interaction latency, then landing small deterministic optimizations that improve those numbers. - -**Architecture:** Treat performance as a product contract across the Core Package and VS Code Extension. Measure Indexing, Graph Cache reads, Graph Projection, Graph Query, Graph Scope toggles, and Visible Graph updates separately so each optimization has a clear before/after number. - -**Tech Stack:** pnpm, Turbo, Vitest, Playwright VS Code acceptance tests, CodeGraphy Core Package, CodeGraphy VS Code Extension, Graph Cache, and optional Mac mini validation for heavy browser runs. - ---- - -## Baseline Evidence - -- Branch: `codex/speed-up-codegraphy` -- Worktree: `/Users/poleski/Desktop/Projects/CodeGraphyV4/.worktrees/speed-up-codegraphy` -- Trello card: `https://trello.com/c/TKoE7wEI` -- User settings baseline: branch starts from local `main` commit `5108cc320 settings`, which updates `.codegraphy/settings.json`. -- First full test attempt: `pnpm run test` failed because the timing wrapper launched `/usr/local/bin/pnpm` under Node `v19.5.0`; `@poleski/quality-tools` needs `path.matchesGlob`. -- Environment correction: use `PATH=/opt/homebrew/bin:/opt/homebrew/opt/node@22/bin:$PATH /opt/homebrew/bin/pnpm ...`. -- Verification of correction: `PATH=/opt/homebrew/bin:/opt/homebrew/opt/node@22/bin:$PATH /opt/homebrew/bin/pnpm --filter @codegraphy-dev/extension exec vitest run tests/playwrightVscodeConfig.test.ts --config vitest.config.ts --project node` passed 6 tests in 2.15s. -- Corrected full test baseline: `PATH=/opt/homebrew/bin:/opt/homebrew/opt/node@22/bin:$PATH /usr/bin/time -l /opt/homebrew/bin/pnpm run test` passed in 1523.98s wall time. -- Unit baseline: `1009 passed` test files, `6039 passed` tests, `158.33s` extension-package Vitest duration, `2m39.381s` Turbo unit task wall time. -- Slow unit canary: `packages/extension/tests/extension/pipeline/examplesWorkspace.test.ts` took `56006ms` and `45842ms` for the two examples-workspace tests. -- Playwright baseline: `119 passed (22.3m)`, `22m42.903s` task wall time; slow file `tests/playwright-vscode/generated/runtime.ts (21.5m)`. -- Cold monorepo CLI indexing baseline: local `node packages/core/bin/codegraphy.js --verbose index .` from no Graph Cache took `214.04s` wall time for 2365 files, 5075 nodes, and 9097 edges. -- Cold index output: `.codegraphy/graph.lbug` is 62MB, `/usr/bin/time -l` reported `2708193280` maximum resident set size and `4201907648` peak memory footprint. -- Phase-instrumented cold monorepo CLI indexing took `213.93s` wall time for 2367 files, 5078 nodes, and 9114 edges. -- Phase split: plugin load `542ms`, plugin initialization `1ms`, file discovery `1900ms`, file analysis `88321ms`, graph build `62ms`, Graph Cache save `122757ms`, metadata persistence `4ms`. -- Measured hot spots: Graph Cache persistence and file/plugin analysis. Graph construction is not a cold-load bottleneck on this workspace. -- Canonical Graph Cache write iteration: cold indexing improved to `111.03s` wall time; Graph Cache save improved to `15139ms`; Graph Cache size improved from `64638976` bytes to `18153472` bytes. -- Shared content read cache iteration: cold indexing improved to `104.81s`; file/plugin analysis improved to `87297ms`; Graph Cache save stayed stable at `14632ms`. -- Remaining measured cold-load hot spot: file/plugin analysis at `87297ms` on the shared-content run. -- Godot class-name metadata fast path improved cold indexing from `104.67s` to `37.27s` and file analysis from `87918ms` to `23352ms`. -- TypeScript alias-config no-scan parsing improved cold indexing from `37.27s` to `17.28s` and file analysis from `23352ms` to `3697ms`. -- Warm Graph Cache query proxy took `0.74s` wall time with a `601ms` diagnostic duration for a `2514` node / `9108` edge query graph. -- Visible Graph projection benchmark before filter optimization: current settings `775ms` median / `933ms` p95, folders-on Graph Scope `1369ms` median / `1445ms` p95, import-edge-hidden `153ms` median. -- Visible Graph projection after reusable glob matchers and skipping direct edge matching for path-only filters: current settings `22ms` median / `26ms` p95, folders-on Graph Scope `31ms` median / `32ms` p95, import-edge-hidden `17ms` median / `18ms` p95. -- Visible Graph scenario node and edge counts stayed unchanged across the filter optimization. -- VS Code graph view benchmark first run: first rendered graph stats took `57209ms`; Imports Graph Scope toggle was `3127ms` median / `3143ms` p95. -- VS Code graph view benchmark repeat run: first rendered graph stats took `9917ms`; Imports Graph Scope toggle was `2983ms` median / `3079ms` p95. -- Current user-facing bottleneck moved to graph surface/runtime/render work after visible graph derivation. -- Raw logs are ignored under `reports/performance/`; commit only scripts and bounded summaries under `docs/performance/`. - -## Success Metrics - -- Cold monorepo Indexing wall time from no Graph Cache. -- Warm monorepo Graph Cache load to first Visible Graph payload. -- Graph Cache Sync time when the cache is readable but settings, plugin state, or changed files need reconciliation. -- Graph Scope toggle latency from UI action to updated Visible Graph payload. -- Display Setting toggle latency for controls that should not rebuild graph data. -- File save Live Update latency for one changed source file. -- Visible Graph payload size: node count, edge count, serialized message bytes. -- Webview apply/render latency after `GRAPH_DATA_UPDATED`. - -## Task 1: Stabilize The Baseline Harness - -**Files:** -- Create: `docs/superpowers/plans/2026-06-22-codegraphy-performance.md` -- Read: `package.json` -- Read: `packages/extension/package.json` -- Read: `packages/extension/playwright.vscode.config.ts` -- Read: `packages/extension/tests/extension/pipeline/examplesWorkspace.test.ts` - -- [x] **Step 1: Create an isolated branch and Trello card** - -Run: - -```bash -git worktree add .worktrees/speed-up-codegraphy -b codex/speed-up-codegraphy main -``` - -Expected: worktree created on `codex/speed-up-codegraphy`. - -- [x] **Step 2: Install dependencies in the worktree** - -Run: - -```bash -pnpm install -``` - -Expected: lockfile unchanged and packages installed. - -- [x] **Step 3: Run the baseline full test command** - -Run: - -```bash -PATH=/opt/homebrew/bin:/opt/homebrew/opt/node@22/bin:$PATH /usr/bin/time -l /opt/homebrew/bin/pnpm run test 2>&1 | tee reports/performance/baseline-test-node22-2026-06-22.log -``` - -Expected: test output is captured. If Playwright is too slow locally, record the partial result and move future Playwright repeats to the Mac mini. - -- [x] **Step 4: Summarize baseline timings** - -Run: - -```bash -rg -n "Test Files|Tests |Duration|Time:|real|WorkspacePipeline examples workspace|Graph built|Discovered|Analysis:" reports/performance/baseline-test-node22-2026-06-22.log -``` - -Expected: baseline notes include unit time, Playwright time, and the slow examples workspace test timings. - -- [x] **Step 5: Commit the setup** - -Run: - -```bash -git add docs/superpowers/plans/2026-06-22-codegraphy-performance.md -git commit -m "docs: plan CodeGraphy performance investigation" -git push -u origin codex/speed-up-codegraphy -``` - -Expected: setup commit is pushed before implementation edits. - -## Task 2: Add A Deterministic Monorepo Performance Runner - -**Files:** -- Create: `scripts/performance/measure-codegraphy-monorepo.mjs` -- Create: `docs/performance/codegraphy-monorepo.md` -- Modify: `package.json` -- Test: `tests/scripts/measure-codegraphy-monorepo.test.mjs` - -- [x] **Step 1: Write the failing script test** - -Create `tests/scripts/measure-codegraphy-monorepo.test.mjs` with checks that the runner: - -```js -import assert from "node:assert/strict"; -import { mkdtemp, readFile, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import test from "node:test"; -import { pathToFileURL } from "node:url"; - -test("performance runner writes bounded JSON metrics", async () => { - const tempDir = await mkdtemp(path.join(tmpdir(), "codegraphy-perf-")); - const outputPath = path.join(tempDir, "metrics.json"); - - try { - const moduleUrl = pathToFileURL(path.resolve("scripts/performance/measure-codegraphy-monorepo.mjs")).href; - const { writeMetrics } = await import(moduleUrl); - - await writeMetrics({ - outputPath, - workspacePath: tempDir, - measurements: { - coldIndexMs: 100, - warmQueryMs: 20, - nodeCount: 2, - edgeCount: 1, - payloadBytes: 512 - } - }); - - const metrics = JSON.parse(await readFile(outputPath, "utf8")); - assert.equal(metrics.workspacePath, tempDir); - assert.equal(metrics.measurements.coldIndexMs, 100); - assert.equal(metrics.measurements.warmQueryMs, 20); - assert.equal(metrics.measurements.nodeCount, 2); - assert.equal(metrics.measurements.edgeCount, 1); - assert.equal(metrics.measurements.payloadBytes, 512); - assert.match(metrics.recordedAt, /^\d{4}-\d{2}-\d{2}T/); - } finally { - await rm(tempDir, { recursive: true, force: true }); - } -}); -``` - -Run: - -```bash -node --test tests/scripts/measure-codegraphy-monorepo.test.mjs -``` - -Expected: FAIL because `scripts/performance/measure-codegraphy-monorepo.mjs` does not exist. - -- [x] **Step 2: Implement minimal metrics writing** - -Create `scripts/performance/measure-codegraphy-monorepo.mjs` with exported `writeMetrics` and a CLI entry point that writes raw JSON to `reports/performance/monorepo-latest.json`. Durable reviewed summaries belong in `docs/performance/`. - -- [x] **Step 3: Run the test to green** - -Run: - -```bash -node --test tests/scripts/measure-codegraphy-monorepo.test.mjs -``` - -Expected: PASS. - -- [x] **Step 4: Wire the package script** - -Add this root script: - -```json -"perf:codegraphy-monorepo": "node scripts/performance/measure-codegraphy-monorepo.mjs --workspace ." -``` - -- [x] **Step 5: Commit the harness** - -Run: - -```bash -git add package.json scripts/performance/measure-codegraphy-monorepo.mjs tests/scripts/measure-codegraphy-monorepo.test.mjs docs/performance/codegraphy-monorepo.md -git commit -m "test: add CodeGraphy monorepo performance harness" -git push -``` - -## Task 3: Capture Current Monorepo Load And Interaction Numbers - -**Files:** -- Modify: `scripts/performance/measure-codegraphy-monorepo.mjs` -- Create: `docs/performance/codegraphy-monorepo.md` - -- [x] **Step 1: Measure headless Core Package timings** - -Run the performance script against `/Users/poleski/Desktop/Projects/CodeGraphyV4/.worktrees/speed-up-codegraphy` using the branch settings. Record cold Indexing, warm Graph Cache query, node count, edge count, and payload bytes. - -Current cold-index command: - -```bash -PATH=/opt/homebrew/bin:/opt/homebrew/opt/node@22/bin:$PATH /usr/bin/time -l node packages/core/bin/codegraphy.js --verbose index . 2>&1 | tee reports/performance/codegraphy-index-cold-phases-local-node22-2026-06-22.log -PATH=/opt/homebrew/bin:/opt/homebrew/opt/node@22/bin:$PATH /opt/homebrew/bin/pnpm run perf:codegraphy-monorepo -- --index-log reports/performance/codegraphy-index-cold-phases-local-node22-2026-06-22.log -``` - -- [x] **Step 2: Measure VS Code user-facing timings** - -Use the Playwright VS Code lane or the Mac mini to open the same workspace and capture: - -```text -open workspace -> first graph payload -Graph Scope toggle -> updated graph payload -Display Setting toggle -> updated view state -single file save -> Live Update complete -``` - -- [x] **Step 3: Commit the baseline metrics** - -Commit the bounded summary and keep raw logs ignored under `reports/performance/`. - -## Task 4: Optimize One Bottleneck At A Time - -**Files:** To be decided by measured bottleneck. - -- [ ] **Step 1: Rank hypotheses after baseline** - -Start with these falsifiable hypotheses: - -```text -If Graph Cache persistence spends over half of cold load writing cache records, then reducing cache payload size or batching/storage strategy will cut cold Indexing wall time without changing graph counts. -If file/plugin analysis spends most of the remaining cold load, then per-plugin phase diagnostics and cacheable analysis reuse will identify the plugin/file classes worth optimizing first. -If Graph Scope toggles rebuild graph data unnecessarily, then separating Display Setting updates from Graph Query updates will reduce toggle latency without changing node or edge counts. -If warm startup waits for full Graph Cache Sync before showing cached data, then rendering cached Visible Graph first will reduce time-to-first-graph while Graph Cache Sync continues in the background. -If large Visible Graph messages dominate interaction latency, then avoiding unchanged payload resend or using smaller incremental messages will reduce webview apply latency and message bytes. -If plugin analysis runs on files that cannot be affected by a changed setting, then narrowing reprocessing to affected providers/files will reduce Live Update and Graph Cache Sync time. -``` - -- [x] **Step 2: Add or extend a failing test for the selected bottleneck** - -Use the closest seam: Core Package Graph Query tests for headless data work, Extension provider tests for message routing and refresh decisions, or Playwright for UI latency. - -- [x] **Step 3: Implement the smallest behavior change** - -Keep each commit scoped to one measured path. - -- [x] **Step 4: Re-run the targeted test and performance harness** - -Compare the metric before committing. - -- [x] **Step 5: Commit and push** - -Commit each improvement separately with the metric delta in the commit body or PR comment. - -Latest committed improvement: - -```text -Settled interactive graph updates skip force-graph cooldown ticks. -Imports Graph Scope toggle: 2983ms median / 3079ms p95 before, 1925ms median / 2341ms p95 after. -2D arrow constants: 1925ms median / 2341ms p95 before, 1595ms median / 1620ms p95 after. -Fresh same-environment control before viewport memoization: 2891ms median / 3563ms p95. -Rejected stable-edge visibility callbacks: 2918ms to 2922ms median, no improvement. -Memoized viewport surface: 2891ms median / 3563ms p95 control, 1628ms median / 2252ms p95 after. -Instrumented webview stages: 1748ms median / 2272ms p95; applyLegendRules was ~460ms-490ms per pass. -Compiled legend matchers: 835ms median / 846ms p95; applyLegendRules now ~79ms-83ms per pass. -Skipped value-equal graph control echoes: 493ms median / 497ms p95; one visibleGraph.derive event per toggle sample. -Rejected stable filter matcher cache: 494ms median / 513ms p95; derive stayed ~176.7ms, so no measured win. -Cached recent visible-graph derivations: 313ms median / 345ms p95; sampled toggles had no visibleGraph.derive events. -Cached recent style and legend stages: 236ms median / 270ms p95; sampled toggles had no derive/style/legend events. -Rendered edge-only Graph Scope changes immediately: 203ms median / 226ms p95; Imports toggles now emit renderImmediate instead of renderDebounced. -Added in-webview delta metric: latest wall-clock 209ms median / 219ms p95, browser-side optimistic-to-rendered 55ms median / 58ms p95. -Rejected startup timeline replay reorder: first graph readiness regressed from 6377ms to 7285ms; reverted. -Added startup phase metrics: latest first graph readiness 6789ms split into 1709ms command/open, 5032ms acceptance-ready frame, 35ms stats wait; startup webview data stages are sub-second. -Lazy-loaded 3D runtime: default webview index.js 2242.28 kB -> 819.25 kB minified; latest Imports toggle 193ms median / 203ms p95, first graph readiness flat at 6936ms. -Added startup handshake markers and skipped settled duplicate graph payload replay: webview document reaches first stats at ~340ms after ready/data/bootstrap markers, duplicate 5088 node / 9146 edge replay is skipped in ~5ms, and the extra visible-graph/render pass is gone; first graph readiness remains flat at ~6987ms due to frame readiness. -Added extension-host startup markers: provider resolve/html/context/flush work takes only 2ms-3ms, ruling it out as the remaining first-load bottleneck; a noisy first-ready run still spent 12500ms in frame readiness, while the webview-side 74-filter derive pass took 498.4ms. -Combined visible-graph filter glob patterns into one matcher: startup 74-filter derive over the 24522 node / 20781 edge payload dropped from 498.4ms to 244ms, and first graph stats after webview document start moved from 843.3ms to 586.4ms; first-ready wall clock remains frame-readiness dominated. -Added command-open markers and extended only the performance harness frame wait: command dispatch completes at 38ms, provider resolve starts at 43ms, and webview.html is assigned at 45ms, so the latest 40497ms first-ready outlier is after HTML assignment and before the harness can observe the VS Code webview frame/document. -Added frame lifecycle markers and startup-ready partial metrics: early webview frames attach/navigate around 1.8s-2.0s, but the usable graph-ready fake.html frame appeared around 37.0s in the latest run; the harness now preserves startup evidence before Graph Scope interaction sampling. -Skipped duplicate graph payloads before bootstrap completion: focused test locks the loading-state behavior; latest startup event stream had one GRAPH_DATA_UPDATED payload and no repeated 74-filter derive before bootstrap, with stats rendered at 597.9ms after the usable document started and Imports toggle at 213ms median / 267ms p95. -Rejected direct Graph View focus before container fallback: first graph readiness stayed frame-readiness dominated at 39359ms, with webview.html assigned at 50ms and the acceptance-ready frame bucket at 37734ms, so the command-path change was reverted. -Deferred visible graph derivation while startup loading hides the graph, skipped unchanged post-load filter pattern replay, and fixed harness in-webview delta pairing: latest startup stream has no 22304-node derive before APP_BOOTSTRAP_COMPLETE, bootstrap at 512.8ms, first real 74-filter derive at 696.6ms for 183.8ms, stats at 892.1ms after document start, Imports toggle at 202ms median / 220ms p95 wall-clock and 53ms median / 56ms p95 in-webview. -Rejected early APP_BOOTSTRAP_COMPLETE reordering: moving bootstrap before graph load and then before cached timeline replay did not move the real browser event stream; bootstrap still arrived after graph data at 1662.6ms/1682.2ms and first stats stayed around 2135ms/2140ms after document start, so the production variants were reverted and the next startup work should instrument message delivery and cached timeline replay. -Added generic webview message-delivery tracing: browser-side order is now explicit, with GRAPH_DATA_UPDATED at 108.7ms, cached/settings messages around 397ms-412ms, duplicate GRAPH_DATA_UPDATED at 500.7ms, duplicate skip at 510.2ms, and APP_BOOTSTRAP_COMPLETE only after that at 510.9ms; latest Imports toggle is 201ms median / 214ms p95 wall-clock and 53ms median / 59ms p95 in-webview. -Added extension-host outbound message tracing and coalesced dense graph index progress: host GRAPH_INDEX_PROGRESS sends dropped from 7844 to 51 in startup traces; a completed 2-sample interaction run measured Imports at 357ms median / 508ms p95 wall-clock and 55ms median / 57ms p95 in-webview, while first-ready wall clock stayed dominated by the frame-readiness bucket. -Skipped duplicate WEBVIEW_READY replay while first analysis is already in flight: the early duplicate settings batch around 518ms disappeared from the host send sequence; latest 2-sample run measured Imports at 231ms median / 266ms p95 wall-clock and 50ms median / 50ms p95 in-webview, with remaining aggregate settings/control sends coming from later refresh/plugin sync paths. -Traced refresh-state send reasons: latest run recorded 22 full refresh-state replays, all tagged changedFiles, while aggregate settings/control sends remained high at SETTINGS_UPDATED 24 and GRAPH_CONTROLS_UPDATED 47; next change should preserve changed-file graph analysis while avoiding redundant indexed-incremental settings replay. -Skipped redundant full settings replay for indexed incremental changed-file refreshes: focused provider test failed red on the old `_sendAllSettings` call, then passed after the refresh runner reported `incremental` vs fallback modes; latest VS Code trace has 0 refresh-state markers, SETTINGS_UPDATED 24 -> 2, GRAPH_CONTROLS_UPDATED 47 -> 5, and a one-sample Imports sanity check at 191ms wall-clock / 49ms in-webview. -Added analysis request/publish lifecycle markers: latest startup trace shows the first cached `load` request completing without publishing after an `incremental` request starts, then the first `GRAPH_DATA_UPDATED` comes from a full `analyze` request at 36.8s; next startup fix should prevent changed-file work from preempting first cached load. -Gated incremental analysis behind first workspace readiness: focused provider test failed red when `incremental:start` raced an unresolved `load:start`, then passed after incremental waited for first ready; latest VS Code trace publishes cached `load` at 9.86s before incremental starts, moving first publish from 36.8s analyze to 9.86s load and first graph readiness from 40.6s to 13.7s in the one-sample harness. -Skipped full workspace discovery during cached Graph Cache replay: focused facade test failed red on the old discovery call, then passed with cached-path metadata; direct replacement metadata probe is 322ms vs 4083ms full discovery, and latest VS Code trace publishes cached `load` at 2.40s with first graph readiness 5.88s while visible stats remain 2300 nodes / 5345 edges. -Added cached-load/publish stage markers and reused a prepared Material Icon extension matcher: publish `groups` dropped from 748ms to 96ms, cached `load` publish moved from 2.28s to 1.70s, first graph readiness moved from 5.82s to 5.62s, and Imports sanity check measured 209ms wall-clock / 58ms in-webview. -Deferred live gitignore probing only for stale cached replay: cached discovery dropped from 324ms to 11ms, cached load completion from 836ms to 497ms, cached load publish from 1.70s to 0.63s, first graph readiness from 5.62s to 5.27s, and visible stats stayed 2300 nodes / 5345 edges while background analysis handled exact ignored metadata. -Warmed the repo-local Graph Cache when the Graph View runtime creates its analyzer: hydration dropped from 406ms to 170ms, cached load completion from 497ms to 259ms, cached load request completion from 672ms to 429ms, first graph readiness from 5266ms to 5114ms, and visible stats stayed 2300 nodes / 5345 edges. -Reused current discovery for existing-file live updates and added a live-update VS Code probe: full-discovery control was 3854ms wall / 3149ms request with 1900ms discovery; cached-discovery fast path was 1887ms wall / 1180ms request with 0ms discovery. -Added changed-file refresh phase markers: pre-analysis routing hotspot was notifyPreAnalyze at 450ms inside a 722ms request, then routing pre-analysis files by supported extension reduced notifyPreAnalyze to 0ms, analyzeFiles to 78ms, refresh completion to 176ms, and live update to 955ms wall / 267ms request. -Shortened existing-file save debounce and targeted Tree-sitter language loading: content saves now wait 100ms while file operations keep 500ms coalescing; latest live-update probe is 574ms wall / 283ms request, and targeted TypeScript Tree-sitter binding load probes at 11ms-17ms versus 62ms-205ms for loading all grammar bindings. -Skipped duplicate changed-file graph builds when analysis already covers retained files: focused Core refresh test failed red on the old fallback `_buildGraphData` call, then passed with the coverage guard; rebuilt VS Code probe removed the `buildGraphData` phase, refresh completion moved 190ms -> 144ms, incremental request 283ms -> 236ms, and live-update wall 574ms -> 545ms. -Lowered existing-file save debounce from 100ms to 50ms while keeping create/delete/rename at 500ms: focused debounce tests failed red at the 50ms boundary, then passed; VS Code live-update wall moved 545ms -> 488ms while request duration stayed flat at 237ms. -Cached TypeScript alias compiler options by tsconfig mtime with lifecycle invalidation for tsconfig-style changes: cache test failed red on two tsconfig reads and lifecycle test failed red on stale extended-config aliases, then passed; VS Code live-update one-file plugin analysis moved 82ms -> 40ms, refresh completion 145ms -> 106ms, request 237ms -> 200ms, and wall 488ms -> 432ms. -``` - -## Task 5: Keep The PR Reviewable - -**Files:** -- Modify: PR body and Trello card comments through GitHub/Trello. - -- [ ] **Step 1: Open a draft PR after the setup commit** - -Include the Trello link, settings baseline note, and first test evidence. - -- [ ] **Step 2: After each optimization, update the PR** - -Add a short comment: - -```text -Iteration N: -- Changed: -- Test: -- Metric before: -- Metric after: -- Next: -``` - -- [ ] **Step 3: Before final review** - -Run: - -```bash -pnpm run lint -pnpm run typecheck -pnpm run test -pnpm run perf:codegraphy-monorepo -``` - -Expected: required checks pass, and the performance report shows user-visible improvement over baseline. diff --git a/package.json b/package.json index 72a812ba3..39ef569ab 100644 --- a/package.json +++ b/package.json @@ -16,9 +16,6 @@ "test:release": "node --test tests/release/*.test.mjs tests/scripts/*.test.mjs", "test:playwright": "node scripts/run-playwright-turbo.mjs", "test:vscode": "pnpm -r --if-present run test:vscode", - "perf:codegraphy-monorepo": "node scripts/performance/measure-codegraphy-monorepo.mjs --workspace .", - "perf:visible-graph-monorepo": "tsx scripts/performance/measure-visible-graph-monorepo.mjs --workspace .", - "perf:vscode-graph-view": "tsx scripts/performance/measure-vscode-graph-view.mjs --workspace .", "check:acceptance-specs": "node scripts/guard-acceptance-spec-edits.mjs", "lint": "turbo run lint", "crap": "quality-tools crap", diff --git a/packages/extension/src/extension/commands/navigation.ts b/packages/extension/src/extension/commands/navigation.ts index 06a0f1193..ccfc8a69a 100644 --- a/packages/extension/src/extension/commands/navigation.ts +++ b/packages/extension/src/extension/commands/navigation.ts @@ -5,7 +5,6 @@ import * as vscode from 'vscode'; import type { GraphViewProvider } from '../graphViewProvider'; -import { recordExtensionPerformanceEvent } from '../performance/marks'; import type { CommandDefinition } from './definitions'; export function getNavCommands(provider: GraphViewProvider): CommandDefinition[] { @@ -13,15 +12,7 @@ export function getNavCommands(provider: GraphViewProvider): CommandDefinition[] { id: 'codegraphy.open', handler: () => { - recordExtensionPerformanceEvent('command.open.start'); - const openView = vscode.commands.executeCommand('workbench.view.extension.codegraphy'); - recordExtensionPerformanceEvent('command.open.dispatched'); - void Promise.resolve(openView).then( - () => recordExtensionPerformanceEvent('command.open.completed'), - (error: unknown) => recordExtensionPerformanceEvent('command.open.failed', { - message: error instanceof Error ? error.message : String(error), - }), - ); + void vscode.commands.executeCommand('workbench.view.extension.codegraphy'); }, }, { id: 'codegraphy.openInEditor', handler: () => { provider.openInEditor(); } }, diff --git a/packages/extension/src/extension/graphView/analysis/execution/load.ts b/packages/extension/src/extension/graphView/analysis/execution/load.ts index d3e88bd7e..ab08be259 100644 --- a/packages/extension/src/extension/graphView/analysis/execution/load.ts +++ b/packages/extension/src/extension/graphView/analysis/execution/load.ts @@ -20,26 +20,12 @@ import { import { getGraphIndexFreshness } from './load/freshness'; import { selectGraphViewRawDataLoadDecision } from './load/routing'; import type { GraphViewRawDataLoadDecision } from './load/routing'; -import { recordExtensionPerformanceEvent } from '../../../performance/marks'; import type { CodeGraphyIndexFreshness } from '../../../repoSettings/freshness'; function hasReplayableGraphData(graphData: IGraphData): boolean { return graphData.nodes.length > 0 || graphData.edges.length > 0; } -function recordLoadStage( - state: GraphViewAnalysisExecutionState, - stage: string, - startedAt: number, - detail: Record = {}, -): void { - recordExtensionPerformanceEvent(`graphAnalysis.load.${stage}`, { - ...detail, - durationMs: Date.now() - startedAt, - mode: state.mode, - }); -} - function selectGraphViewRawDataLoadDecisionForState( state: GraphViewAnalysisExecutionState, analyzer: NonNullable, @@ -75,15 +61,8 @@ export async function loadGraphViewRawData( return { rawGraphData: EMPTY_GRAPH_DATA, shouldDiscover: false }; } - let stageStartedAt = Date.now(); const { decision, indexFreshness } = selectGraphViewRawDataLoadDecisionForState(state, analyzer); const diagnosticIndexFreshness = indexFreshness ?? 'skipped'; - recordLoadStage(state, 'decision', stageStartedAt, { - canReplayCache: typeof analyzer.loadCachedGraph === 'function', - indexFreshness: diagnosticIndexFreshness, - route: decision.route, - shouldDiscover: decision.shouldDiscover, - }); handlers.emitDiagnostic?.({ area: 'extension.analysis', event: 'load-decision', @@ -98,17 +77,11 @@ export async function loadGraphViewRawData( const forwardProgress = createGraphViewAnalysisProgressForwarder(state.mode, handlers); if (!decision.shouldDiscover) { - stageStartedAt = Date.now(); sendInitialGraphViewAnalysisProgress(state.mode, handlers); - recordLoadStage(state, 'initialProgress', stageStartedAt, { - route: decision.route, - }); } if (decision.route === 'discover') { - stageStartedAt = Date.now(); const rawGraphData = await discoverGraphViewRawData(signal, state, analyzer); - recordLoadStage(state, 'discover', stageStartedAt); return { rawGraphData, shouldDiscover: decision.shouldDiscover, @@ -116,16 +89,10 @@ export async function loadGraphViewRawData( } if (decision.route === 'cached') { - stageStartedAt = Date.now(); const cachedGraphData = await loadCachedGraphViewRawData(signal, state, analyzer, { includeCurrentGitignoreMetadata: indexFreshness !== 'stale', ...(indexFreshness === 'stale' ? { warmAnalysis: false } : {}), }); - recordLoadStage(state, 'cached', stageStartedAt, { - edgeCount: cachedGraphData.edges.length, - hasReplayableGraphData: hasReplayableGraphData(cachedGraphData), - nodeCount: cachedGraphData.nodes.length, - }); if (hasReplayableGraphData(cachedGraphData)) { return { rawGraphData: cachedGraphData, @@ -133,9 +100,7 @@ export async function loadGraphViewRawData( }; } - stageStartedAt = Date.now(); const rawGraphData = await refreshGraphViewRawData(signal, state, forwardProgress); - recordLoadStage(state, 'cachedFallbackRefresh', stageStartedAt); return { rawGraphData, shouldDiscover: decision.shouldDiscover, @@ -143,9 +108,7 @@ export async function loadGraphViewRawData( } if (decision.route === 'refresh') { - stageStartedAt = Date.now(); const rawGraphData = await refreshGraphViewRawData(signal, state, forwardProgress); - recordLoadStage(state, 'refresh', stageStartedAt); return { rawGraphData, shouldDiscover: decision.shouldDiscover, @@ -153,18 +116,14 @@ export async function loadGraphViewRawData( } if (decision.route === 'incremental') { - stageStartedAt = Date.now(); const rawGraphData = await refreshIncrementalGraphViewRawData(signal, state, forwardProgress); - recordLoadStage(state, 'incremental', stageStartedAt); return { rawGraphData, shouldDiscover: decision.shouldDiscover, }; } - stageStartedAt = Date.now(); const rawGraphData = await analyzeGraphViewRawData(signal, state, analyzer, forwardProgress); - recordLoadStage(state, 'analyze', stageStartedAt); return { rawGraphData, shouldDiscover: decision.shouldDiscover, diff --git a/packages/extension/src/extension/graphView/analysis/execution/prepare.ts b/packages/extension/src/extension/graphView/analysis/execution/prepare.ts index 1e27402bc..50a520ff5 100644 --- a/packages/extension/src/extension/graphView/analysis/execution/prepare.ts +++ b/packages/extension/src/extension/graphView/analysis/execution/prepare.ts @@ -7,20 +7,6 @@ import { ensureGraphViewAnalyzerInitialized, } from './initialize'; import { publishEmptyGraph } from './publish'; -import { recordExtensionPerformanceEvent } from '../../../performance/marks'; - -function recordPrepareStage( - state: GraphViewAnalysisExecutionState, - stage: string, - startedAt: number, - detail: Record = {}, -): void { - recordExtensionPerformanceEvent(`graphAnalysis.prepare.${stage}`, { - ...detail, - durationMs: Date.now() - startedAt, - mode: state.mode, - }); -} function prepareAnalysisGroups( signal: AbortSignal, @@ -55,38 +41,22 @@ export async function prepareGraphViewAnalysis( return false; } - let stageStartedAt = Date.now(); if (!(await awaitGraphViewPluginActivation(signal, requestId, state, handlers))) { - recordPrepareStage(state, 'pluginActivation', stageStartedAt, { stale: true }); return false; } - recordPrepareStage(state, 'pluginActivation', stageStartedAt); - stageStartedAt = Date.now(); if (!(await ensureGraphViewAnalyzerInitialized(signal, requestId, state, handlers))) { - recordPrepareStage(state, 'analyzerInitialized', stageStartedAt, { stale: true }); return false; } - recordPrepareStage(state, 'analyzerInitialized', stageStartedAt, { - alreadyInitialized: state.analyzerInitialized, - }); - stageStartedAt = Date.now(); if (shouldPrepareAnalysisGroups(state) && !prepareAnalysisGroups(signal, requestId, handlers)) { - recordPrepareStage(state, 'groups', stageStartedAt, { stale: true }); return false; } - recordPrepareStage(state, 'groups', stageStartedAt, { - skipped: !shouldPrepareAnalysisGroups(state), - }); - stageStartedAt = Date.now(); if (!handlers.hasWorkspace()) { - recordPrepareStage(state, 'workspace', stageStartedAt, { hasWorkspace: false }); publishEmptyGraph(handlers); return false; } - recordPrepareStage(state, 'workspace', stageStartedAt, { hasWorkspace: true }); return true; } diff --git a/packages/extension/src/extension/graphView/analysis/execution/publish.ts b/packages/extension/src/extension/graphView/analysis/execution/publish.ts index b7c6ffe1f..c96ec3fb5 100644 --- a/packages/extension/src/extension/graphView/analysis/execution/publish.ts +++ b/packages/extension/src/extension/graphView/analysis/execution/publish.ts @@ -5,7 +5,6 @@ import type { } from '../execution'; import type { IGraphNodeMetricsUpdate } from '../../../../shared/protocol/extensionToWebview'; import type { CodeGraphyIndexFreshness } from '../../../repoSettings/freshness'; -import { recordExtensionPerformanceEvent } from '../../../performance/marks'; export const EMPTY_GRAPH_DATA: IGraphData = { nodes: [], edges: [] }; @@ -32,17 +31,6 @@ function shouldReportGraphViewUpdateProgress( return state.mode === 'index' || state.mode === 'refresh' || state.mode === 'incremental'; } -function recordPublishStage( - stage: string, - startedAt: number, - detail: Record = {}, -): void { - recordExtensionPerformanceEvent(`graphAnalysis.publish.${stage}`, { - durationMs: Date.now() - startedAt, - ...detail, - }); -} - function areGraphDataPayloadsEqual(left: IGraphData, right: IGraphData): boolean { if (left === right) { return true; @@ -290,7 +278,6 @@ export function publishAnalyzedGraph( }); } - let stageStartedAt = Date.now(); const currentRawGraphData = handlers.getRawGraphData?.(); const metricOnlyUpdate = createMetricOnlyGraphUpdate( currentRawGraphData, @@ -306,58 +293,24 @@ export function publishAnalyzedGraph( actualHasIndex, status.freshness, ); - recordPublishStage('reuseCheck', stageStartedAt, { - mode: state.mode, - reused: reuseCurrentGraphPublication, - rawEdgeCount: rawGraphData.edges.length, - rawNodeCount: rawGraphData.nodes.length, - }); - - stageStartedAt = Date.now(); - if (reuseCurrentGraphPublication) { - recordPublishStage('unchangedGraph', stageStartedAt, { - edgeCount: rawGraphData.edges.length, - nodeCount: rawGraphData.nodes.length, - }); - } else { + + if (!reuseCurrentGraphPublication) { handlers.setRawGraphData(rawGraphData); - recordPublishStage('setRawGraphData', stageStartedAt, { - rawEdgeCount: rawGraphData.edges.length, - rawNodeCount: rawGraphData.nodes.length, - }); - stageStartedAt = Date.now(); handlers.updateViewContext(); handlers.applyViewTransform(); - recordPublishStage('viewTransform', stageStartedAt); - stageStartedAt = Date.now(); const canSkipGroupPublication = state.mode === 'incremental' && currentRawGraphData && !doGraphViewGroupsNeedRecompute(currentRawGraphData, rawGraphData); - if (canSkipGroupPublication) { - recordPublishStage('groupsSkipped', stageStartedAt, { - reason: 'groupInputsUnchanged', - }); - } else { - const groupsStartedAt = stageStartedAt; - stageStartedAt = Date.now(); + if (!canSkipGroupPublication) { handlers.computeMergedGroups(); - recordPublishStage('computeGroups', stageStartedAt); - stageStartedAt = Date.now(); handlers.sendGroupsUpdated(); - recordPublishStage('sendGroups', stageStartedAt); - recordPublishStage('groups', groupsStartedAt); } } - stageStartedAt = Date.now(); - if (shouldSendMetricPatch) { - recordPublishStage('broadcastsSkipped', stageStartedAt, { - reason: 'metricOnlyGraphPatch', - }); - } else { + if (!shouldSendMetricPatch) { handlers.sendDepthState(); handlers.sendPluginStatuses(); handlers.sendDecorations(); @@ -366,38 +319,14 @@ export function publishAnalyzedGraph( handlers.sendPluginToolbarActions?.(); handlers.sendGraphViewContributionStatuses?.(); handlers.sendPluginWebviewInjections?.(); - recordPublishStage('broadcasts', stageStartedAt); } - stageStartedAt = Date.now(); const graphData = handlers.getGraphData(); - recordPublishStage('getGraphData', stageStartedAt, { - edgeCount: graphData.edges.length, - nodeCount: graphData.nodes.length, - }); - recordExtensionPerformanceEvent('graphAnalysis.publish.graph', { - mode: state.mode, - rawNodeCount: rawGraphData.nodes.length, - rawEdgeCount: rawGraphData.edges.length, - nodeCount: graphData.nodes.length, - edgeCount: graphData.edges.length, - hasIndex: actualHasIndex, - freshness: status.freshness, - freshnessDetail: status.detail, - }); if (!reuseCurrentGraphPublication) { - stageStartedAt = Date.now(); if (shouldSendMetricPatch) { handlers.sendGraphNodeMetricsUpdated?.(metricOnlyUpdate); - recordPublishStage('sendGraphNodeMetrics', stageStartedAt, { - nodeCount: metricOnlyUpdate.length, - }); } else { handlers.sendGraphDataUpdated(graphData); - recordPublishStage('sendGraphData', stageStartedAt, { - edgeCount: graphData.edges.length, - nodeCount: graphData.nodes.length, - }); } } handlers.sendGraphIndexStatusUpdated(actualHasIndex, status.freshness, status.detail); diff --git a/packages/extension/src/extension/graphView/analysis/request.ts b/packages/extension/src/extension/graphView/analysis/request.ts index d282f0738..c0a54dc48 100644 --- a/packages/extension/src/extension/graphView/analysis/request.ts +++ b/packages/extension/src/extension/graphView/analysis/request.ts @@ -1,5 +1,4 @@ import type { DiagnosticEventInput } from '@codegraphy-dev/core'; -import { recordExtensionPerformanceEvent } from '../../performance/marks'; export interface GraphViewAnalysisRequestState { analysisController: AbortController | undefined; @@ -45,7 +44,6 @@ export async function runGraphViewAnalysisRequest( handlers.updateAnalysisRequestId(requestId); const startedAt = Date.now(); const requestContext = createRequestContext(state, requestId); - recordExtensionPerformanceEvent('graphAnalysis.request.start', requestContext); handlers.emitDiagnostic?.({ area: 'extension.analysis', event: 'request-started', @@ -54,10 +52,6 @@ export async function runGraphViewAnalysisRequest( try { await handlers.executeAnalysis(controller.signal, requestId); - recordExtensionPerformanceEvent('graphAnalysis.request.completed', { - ...requestContext, - durationMs: Date.now() - startedAt, - }); handlers.emitDiagnostic?.({ area: 'extension.analysis', event: 'request-completed', @@ -67,17 +61,9 @@ export async function runGraphViewAnalysisRequest( }, }); } catch (error) { - const durationMs = Date.now() - startedAt; if (handlers.isAbortError(error)) { - recordExtensionPerformanceEvent('graphAnalysis.request.aborted', { - ...requestContext, - durationMs, - }); + return; } else { - recordExtensionPerformanceEvent('graphAnalysis.request.failed', { - ...requestContext, - durationMs, - }); handlers.emitDiagnostic?.({ area: 'extension.analysis', event: 'request-failed', diff --git a/packages/extension/src/extension/graphView/provider/refresh.ts b/packages/extension/src/extension/graphView/provider/refresh.ts index 15d1b4439..035171ea7 100644 --- a/packages/extension/src/extension/graphView/provider/refresh.ts +++ b/packages/extension/src/extension/graphView/provider/refresh.ts @@ -1,6 +1,5 @@ import type { IGraphData } from '../../../shared/graph/contracts'; import type { ExtensionToWebviewMessage } from '../../../shared/protocol/extensionToWebview'; -import { recordExtensionPerformanceEvent } from '../../performance/marks'; import { getCodeGraphyConfiguration } from '../../repoSettings/current'; import { createGraphViewIndexProgressCoalescer } from '../analysis/execution/progress'; import { rebuildGraphViewData, smartRebuildGraphView } from '../view/rebuild'; @@ -378,10 +377,6 @@ function createRefreshChangedFilesMethod( state: RefreshCoordinatorState, ): (filePaths: readonly string[]) => Promise { return async (filePaths: readonly string[]): Promise => { - recordExtensionPerformanceEvent('graphView.refreshChangedFiles.received', { - fileCount: filePaths.length, - indexRefreshInFlight: state.indexRefreshPromise !== undefined, - }); if (state.indexRefreshPromise) { state.queuedChangedFilePaths = new Set([ ...state.queuedChangedFilePaths, diff --git a/packages/extension/src/extension/graphView/provider/refresh/run.ts b/packages/extension/src/extension/graphView/provider/refresh/run.ts index 5273c0c70..8cc66feaa 100644 --- a/packages/extension/src/extension/graphView/provider/refresh/run.ts +++ b/packages/extension/src/extension/graphView/provider/refresh/run.ts @@ -1,5 +1,4 @@ import type { GraphViewProviderRefreshMethodsSource } from '../refresh'; -import { recordExtensionPerformanceEvent } from '../../../performance/marks'; import type { IGraphData } from '../../../../shared/graph/contracts'; export type RefreshStateReason = @@ -14,9 +13,8 @@ export type ChangedFileRefreshMode = 'analysis' | 'incremental' | 'primary'; export function sendRefreshState( source: GraphViewProviderRefreshMethodsSource, - reason: RefreshStateReason = 'direct', + _reason: RefreshStateReason = 'direct', ): void { - recordExtensionPerformanceEvent('graphWebview.refreshState.send', { reason }); source._sendAllSettings(); source._sendGraphControls?.(); } diff --git a/packages/extension/src/extension/graphView/provider/webview/defaultDependencies.ts b/packages/extension/src/extension/graphView/provider/webview/defaultDependencies.ts index 6e64f9368..1a07e3d5f 100644 --- a/packages/extension/src/extension/graphView/provider/webview/defaultDependencies.ts +++ b/packages/extension/src/extension/graphView/provider/webview/defaultDependencies.ts @@ -15,7 +15,6 @@ import { onGraphViewWebviewMessage, sendGraphViewWebviewMessage, } from '../../webview/bridge'; -import { recordExtensionPerformanceEvent } from '../../../performance/marks'; export interface GraphViewProviderWebviewMethodDependencies { viewType: string; @@ -30,7 +29,6 @@ export interface GraphViewProviderWebviewMethodDependencies { onWebviewMessage: typeof onGraphViewWebviewMessage; setWebviewMessageListener: typeof setGraphViewProviderMessageListener; executeCommand(command: string, key: string, value: boolean): Thenable; - recordPerformanceEvent?(name: string, detail?: Record): void; createPanel: typeof vscode.window.createWebviewPanel; getWorkspaceTitle?(): string | undefined; } @@ -81,7 +79,6 @@ export function createDefaultGraphViewProviderWebviewMethodDependencies(): Graph onWebviewMessage: onGraphViewWebviewMessage, setWebviewMessageListener: setGraphViewProviderMessageListener, executeCommand: (command, key, value) => vscode.commands.executeCommand(command, key, value), - recordPerformanceEvent: recordExtensionPerformanceEvent, createPanel: (viewType, title, column, options) => vscode.window.createWebviewPanel(viewType, title, column, options), getWorkspaceTitle: () => vscode.workspace.workspaceFolders?.[0]?.name, diff --git a/packages/extension/src/extension/graphView/provider/webview/messages.ts b/packages/extension/src/extension/graphView/provider/webview/messages.ts index ed17cdf30..7627e8bf1 100644 --- a/packages/extension/src/extension/graphView/provider/webview/messages.ts +++ b/packages/extension/src/extension/graphView/provider/webview/messages.ts @@ -8,15 +8,10 @@ export interface GraphViewProviderWebviewMessageSource extends GraphViewProvider export function sendGraphViewProviderWebviewMessage( source: GraphViewProviderWebviewMessageSource, - dependencies: Pick, + dependencies: Pick, message: unknown, ): void { const sidebarViews = getGraphViewProviderSidebarViews(source); - dependencies.recordPerformanceEvent?.('graphWebview.message.send', { - panelCount: source._panels.length, - sidebarViewCount: sidebarViews.length, - type: getGraphViewProviderWebviewMessageType(message), - }); dependencies.sendWebviewMessage( sidebarViews, source._panels, @@ -24,12 +19,3 @@ export function sendGraphViewProviderWebviewMessage( ); source._notifyExtensionMessage(message); } - -function getGraphViewProviderWebviewMessageType(message: unknown): string | undefined { - if (!message || typeof message !== 'object') { - return undefined; - } - - const type = (message as { type?: unknown }).type; - return typeof type === 'string' ? type : undefined; -} diff --git a/packages/extension/src/extension/graphView/provider/webview/resolve.ts b/packages/extension/src/extension/graphView/provider/webview/resolve.ts index 453a709d6..22eb417f9 100644 --- a/packages/extension/src/extension/graphView/provider/webview/resolve.ts +++ b/packages/extension/src/extension/graphView/provider/webview/resolve.ts @@ -64,23 +64,17 @@ export function resolveGraphViewProviderWebviewView( source: GraphViewProviderWebviewResolveSource, dependencies: Pick< GraphViewProviderWebviewMethodDependencies, - 'createHtml' | 'executeCommand' | 'getWorkspaceTitle' | 'recordPerformanceEvent' | 'resolveWebviewView' | 'setWebviewMessageListener' + 'createHtml' | 'executeCommand' | 'getWorkspaceTitle' | 'resolveWebviewView' | 'setWebviewMessageListener' >, webviewView: vscode.WebviewView, ): void { const viewKind = getWebviewKind(webviewView); - dependencies.recordPerformanceEvent?.('graphWebview.providerResolve.start', { - viewKind, - viewType: webviewView.viewType, - visible: webviewView.visible, - }); assignResolvedWebviewView( source, webviewView, viewKind, dependencies.getWorkspaceTitle?.(), ); - dependencies.recordPerformanceEvent?.('graphWebview.providerResolve.assigned'); webviewView.onDidDispose(() => { clearResolvedWebviewView(source, webviewView, viewKind); @@ -90,7 +84,6 @@ export function resolveGraphViewProviderWebviewView( maybeFlushPendingWorkspaceRefresh(source, webviewView, viewKind); }); - dependencies.recordPerformanceEvent?.('graphWebview.providerResolve.delegate.start'); dependencies.resolveWebviewView(webviewView, { getLocalResourceRoots: () => source._getLocalResourceRoots(), setWebviewMessageListener: (nextWebview: vscode.Webview) => @@ -103,13 +96,6 @@ export function resolveGraphViewProviderWebviewView( ), executeCommand: (command: string, key: string, value: boolean) => dependencies.executeCommand(command, key, value), - recordPerformanceEvent: dependencies.recordPerformanceEvent, } as never); - dependencies.recordPerformanceEvent?.('graphWebview.providerResolve.delegate.end'); - - if (viewKind === 'graph' && webviewView.visible) { - dependencies.recordPerformanceEvent?.('graphWebview.providerResolve.flushPendingRefresh'); - } maybeFlushPendingWorkspaceRefresh(source, webviewView, viewKind); - dependencies.recordPerformanceEvent?.('graphWebview.providerResolve.end'); } diff --git a/packages/extension/src/extension/graphView/webview/dispatch/primary.ts b/packages/extension/src/extension/graphView/webview/dispatch/primary.ts index 94d1615d9..6d82a9cf8 100644 --- a/packages/extension/src/extension/graphView/webview/dispatch/primary.ts +++ b/packages/extension/src/extension/graphView/webview/dispatch/primary.ts @@ -1,4 +1,4 @@ -import * as vscode from 'vscode'; +import type * as vscode from 'vscode'; import type { IGraphData } from '../../../../shared/graph/contracts'; import type { WebviewToExtensionMessage } from '../../../../shared/protocol/webviewToExtension'; import type { IPluginFilterPatternGroup } from '../../../../shared/protocol/extensionToWebview'; @@ -8,7 +8,6 @@ import type { IPhysicsSettings } from '../../../../shared/settings/physics'; import type { IViewContext } from '../../../../core/views/contracts'; import type { IFileAnalysisResult } from '../../../../core/plugins/types/contracts'; import type { WorkspaceAnalysisDatabaseSnapshot } from '../../../pipeline/database/cache/storage'; -import { recordExtensionPerformanceEvent } from '../../../performance/marks'; import { dispatchGraphViewPrimaryRouteMessage } from './routed'; import { dispatchGraphViewPrimaryStateMessage } from './stateful'; @@ -111,80 +110,10 @@ export interface GraphViewPrimaryMessageResult { filterPatterns?: string[]; } -function recordAcceptanceLiveUpdateSaveStage( - stage: string, - detail: Record, -): void { - recordExtensionPerformanceEvent(`graphWebview.acceptanceLiveUpdateSave.${stage}`, detail); -} - -async function runAcceptanceLiveUpdateSaveStage( - stage: string, - filePath: string, - action: () => PromiseLike, -): Promise { - const startedAt = Date.now(); - const result = await action(); - recordAcceptanceLiveUpdateSaveStage(stage, { - durationMs: Date.now() - startedAt, - filePath, - }); - return result; -} - -async function saveAcceptanceLiveUpdateFile(filePath: string): Promise { - const startedAt = Date.now(); - recordAcceptanceLiveUpdateSaveStage('start', { filePath }); - try { - const document = await runAcceptanceLiveUpdateSaveStage( - 'openDocument', - filePath, - () => vscode.workspace.openTextDocument(vscode.Uri.file(filePath)), - ); - const editor = await runAcceptanceLiveUpdateSaveStage( - 'showDocument', - filePath, - () => vscode.window.showTextDocument(document, { - preserveFocus: true, - preview: false, - }), - ); - await runAcceptanceLiveUpdateSaveStage('edit', filePath, () => - editor.edit(editBuilder => { - editBuilder.insert( - new vscode.Position(document.lineCount, 0), - `\n// CodeGraphy live update perf marker ${Date.now()}\n`, - ); - }), - ); - await runAcceptanceLiveUpdateSaveStage('save', filePath, () => document.save()); - recordAcceptanceLiveUpdateSaveStage('completed', { - durationMs: Date.now() - startedAt, - filePath, - }); - } catch (error) { - recordAcceptanceLiveUpdateSaveStage('failed', { - durationMs: Date.now() - startedAt, - filePath, - message: error instanceof Error ? error.message : String(error), - }); - throw error; - } -} - export async function dispatchGraphViewPrimaryMessage( message: WebviewToExtensionMessage, context: GraphViewPrimaryMessageContext, ): Promise { - if (message.type === 'PERF_SAVE_LIVE_UPDATE_FILE') { - if (process.env.CODEGRAPHY_ACCEPTANCE === '1') { - await saveAcceptanceLiveUpdateFile(message.payload.path); - return { handled: true }; - } - - return { handled: false }; - } - const routedResult = await dispatchGraphViewPrimaryRouteMessage(message, context); if (routedResult.handled) { return routedResult; diff --git a/packages/extension/src/extension/graphView/webview/messages/listener.ts b/packages/extension/src/extension/graphView/webview/messages/listener.ts index d20b28a9b..b68efa7a7 100644 --- a/packages/extension/src/extension/graphView/webview/messages/listener.ts +++ b/packages/extension/src/extension/graphView/webview/messages/listener.ts @@ -10,7 +10,6 @@ import { type GraphViewPrimaryMessageContext, } from '../dispatch/primary'; import { replayDuplicateWebviewReady } from './ready'; -import { recordExtensionPerformanceEvent } from '../../../performance/marks'; export interface GraphViewMessageListenerContext extends GraphViewPrimaryMessageContext, @@ -102,13 +101,6 @@ function createGraphViewWebviewMessageHandler( const isWebviewReadyMessage = message.type === 'WEBVIEW_READY'; if (message.type === 'WEBVIEW_READY') { const delivery = getWebviewReadyDelivery(message); - recordExtensionPerformanceEvent('graphWebview.ready.received', { - duplicate: webviewReadyHandled, - pageId: delivery.pageId, - postedAt: delivery.postedAt, - previousPageId: webviewReadyPageId, - completedAt: webviewReadyCompletedAt, - }); if (webviewReadyHandled) { const isSamePage = delivery.pageId !== undefined && delivery.pageId === webviewReadyPageId; const wasPostedBeforeCompletedBootstrap = delivery.postedAt !== undefined diff --git a/packages/extension/src/extension/graphView/webview/resolve.ts b/packages/extension/src/extension/graphView/webview/resolve.ts index 3b74e4a5b..f46aae7d2 100644 --- a/packages/extension/src/extension/graphView/webview/resolve.ts +++ b/packages/extension/src/extension/graphView/webview/resolve.ts @@ -18,7 +18,6 @@ interface ResolveGraphViewWebviewOptions { setWebviewMessageListener: (webview: GraphViewWebviewLike) => void; getHtml: (webview: GraphViewWebviewLike) => string; executeCommand: (command: string, key: string, value: boolean) => unknown; - recordPerformanceEvent?: (name: string, detail?: Record) => void; } export function resolveGraphViewWebviewView( @@ -28,37 +27,21 @@ export function resolveGraphViewWebviewView( setWebviewMessageListener, getHtml, executeCommand, - recordPerformanceEvent, }: ResolveGraphViewWebviewOptions, ): void { - recordPerformanceEvent?.('graphWebview.resolve.start', { - visible: webviewView.visible, - }); - recordPerformanceEvent?.('graphWebview.options.start'); const localResourceRoots = getLocalResourceRoots(); webviewView.webview.options = { enableScripts: true, localResourceRoots, retainContextWhenHidden: true, }; - recordPerformanceEvent?.('graphWebview.options.end', { - localResourceRootCount: localResourceRoots.length, - }); - recordPerformanceEvent?.('graphWebview.listener.start'); setWebviewMessageListener(webviewView.webview); - recordPerformanceEvent?.('graphWebview.listener.end'); - recordPerformanceEvent?.('graphWebview.html.start'); const html = getHtml(webviewView.webview); webviewView.webview.html = html; - recordPerformanceEvent?.('graphWebview.html.assigned', { - htmlLength: html.length, - }); void executeCommand('setContext', 'codegraphy.viewVisible', webviewView.visible); - recordPerformanceEvent?.('graphWebview.context.initial'); - recordPerformanceEvent?.('graphWebview.resolve.end'); webviewView.onDidChangeVisibility(() => { void executeCommand('setContext', 'codegraphy.viewVisible', webviewView.visible); diff --git a/packages/extension/src/extension/performance/marks.ts b/packages/extension/src/extension/performance/marks.ts deleted file mode 100644 index 533f3166e..000000000 --- a/packages/extension/src/extension/performance/marks.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { appendFileSync, mkdirSync } from 'node:fs'; -import path from 'node:path'; - -export const EXTENSION_PERFORMANCE_LOG_PATH_ENV = 'CODEGRAPHY_EXTENSION_PERFORMANCE_LOG'; - -export interface ExtensionPerformanceEventDetail { - readonly [key: string]: unknown; -} - -export function recordExtensionPerformanceEvent( - name: string, - detail?: ExtensionPerformanceEventDetail, -): void { - const logPath = process.env[EXTENSION_PERFORMANCE_LOG_PATH_ENV]?.trim(); - if (!logPath) { - return; - } - - try { - mkdirSync(path.dirname(logPath), { recursive: true }); - appendFileSync(logPath, `${JSON.stringify(createExtensionPerformanceEvent(name, detail))}\n`); - } catch { - // Performance markers are best-effort harness data and must never affect extension behavior. - } -} - -function createExtensionPerformanceEvent( - name: string, - detail?: ExtensionPerformanceEventDetail, -): { name: string; at: number; detail?: ExtensionPerformanceEventDetail } { - return { - name, - at: Date.now(), - ...(detail === undefined ? {} : { detail }), - }; -} diff --git a/packages/extension/src/extension/pipeline/service/discoveryFacade.ts b/packages/extension/src/extension/pipeline/service/discoveryFacade.ts index 0e87f1665..9d5cfc623 100644 --- a/packages/extension/src/extension/pipeline/service/discoveryFacade.ts +++ b/packages/extension/src/extension/pipeline/service/discoveryFacade.ts @@ -29,7 +29,6 @@ import { } from './runtime/run'; import { createEmptyWorkspaceAnalysisCache } from '../cache'; import { createCachedWorkspaceDiscoveryState } from './cache/cachedDiscovery'; -import { recordExtensionPerformanceEvent } from '../../performance/marks'; import { createWorkspacePipelineAnalysisCacheTiers } from './cache/tiers'; export interface WorkspacePipelineCachedGraphLoadOptions { @@ -217,14 +216,8 @@ export abstract class WorkspacePipelineDiscoveryFacade extends WorkspacePipeline signal?: AbortSignal, options: WorkspacePipelineCachedGraphLoadOptions = {}, ): Promise { - const loadStartedAt = Date.now(); throwIfWorkspaceAnalysisAborted(signal); - let stageStartedAt = Date.now(); await this._hydrateCacheFromGraphCache(); - recordExtensionPerformanceEvent('workspacePipeline.loadCachedGraph.hydrate', { - durationMs: Date.now() - stageStartedAt, - fileCount: Object.keys(this._cache.files).length, - }); throwIfWorkspaceAnalysisAborted(signal); const workspaceRoot = this._getWorkspaceRoot(); @@ -233,7 +226,6 @@ export abstract class WorkspacePipelineDiscoveryFacade extends WorkspacePipeline } const config = this._config.getAll(); - stageStartedAt = Date.now(); throwIfWorkspaceAnalysisAborted(signal); const fileAnalysis = new Map( @@ -243,24 +235,12 @@ export abstract class WorkspacePipelineDiscoveryFacade extends WorkspacePipeline ]), ); const cachedFilePaths = Object.keys(this._cache.files); - recordExtensionPerformanceEvent('workspacePipeline.loadCachedGraph.cacheSnapshot', { - durationMs: Date.now() - stageStartedAt, - fileCount: cachedFilePaths.length, - }); - stageStartedAt = Date.now(); const includeCurrentGitignoreMetadata = options.includeCurrentGitignoreMetadata !== false; const cachedDiscovery = createCachedWorkspaceDiscoveryState( workspaceRoot, cachedFilePaths, config.respectGitignore && includeCurrentGitignoreMetadata, ); - recordExtensionPerformanceEvent('workspacePipeline.loadCachedGraph.cachedDiscovery', { - directoryCount: cachedDiscovery.directories.length, - durationMs: Date.now() - stageStartedAt, - fileCount: cachedDiscovery.files.length, - gitIgnoredPathCount: cachedDiscovery.gitIgnoredPaths.length, - includeCurrentGitignoreMetadata, - }); this._lastDiscoveredFiles = cachedDiscovery.files; this._lastDiscoveredDirectories = cachedDiscovery.directories; @@ -271,23 +251,12 @@ export abstract class WorkspacePipelineDiscoveryFacade extends WorkspacePipeline throwIfWorkspaceAnalysisAborted(signal); - stageStartedAt = Date.now(); const graphData = this._buildGraphDataFromAnalysis( fileAnalysis, workspaceRoot, config.showOrphans, disabledPlugins, ); - recordExtensionPerformanceEvent('workspacePipeline.loadCachedGraph.buildGraph', { - durationMs: Date.now() - stageStartedAt, - edgeCount: graphData.edges.length, - nodeCount: graphData.nodes.length, - }); - recordExtensionPerformanceEvent('workspacePipeline.loadCachedGraph.completed', { - durationMs: Date.now() - loadStartedAt, - edgeCount: graphData.edges.length, - nodeCount: graphData.nodes.length, - }); if (options.warmAnalysis !== false) { this._scheduleCachedGraphAnalysisWarmup( @@ -361,7 +330,6 @@ export abstract class WorkspacePipelineDiscoveryFacade extends WorkspacePipeline return; } - const warmupStartedAt = Date.now(); const disabledPluginSnapshot = new Set(disabledPlugins); const pluginIds = this._getActiveAnalysisPluginIds(undefined, disabledPluginSnapshot); const cacheTiers = createWorkspacePipelineAnalysisCacheTiers( @@ -387,24 +355,12 @@ export abstract class WorkspacePipelineDiscoveryFacade extends WorkspacePipeline analysisContext, { disabledPlugins: disabledPluginSnapshot }, ); - recordExtensionPerformanceEvent('workspacePipeline.loadCachedGraph.warmAnalysis', { - durationMs: Date.now() - warmupStartedAt, - filePath: file.relativePath, - pluginIdCount: pluginIds.length, - status: 'completed', - }); })().catch(error => { const status = isWorkspaceAnalysisAbortError(error) ? 'aborted' : isMissingFileError(error) ? 'skipped' : 'failed'; - recordExtensionPerformanceEvent('workspacePipeline.loadCachedGraph.warmAnalysis', { - durationMs: Date.now() - warmupStartedAt, - filePath: file.relativePath, - pluginIdCount: pluginIds.length, - status, - }); if (status === 'failed') { console.warn('[CodeGraphy] Failed to warm cached graph analysis.', error); diff --git a/packages/extension/src/extension/pipeline/service/refreshFacade.ts b/packages/extension/src/extension/pipeline/service/refreshFacade.ts index 2eb17b676..11acbd10c 100644 --- a/packages/extension/src/extension/pipeline/service/refreshFacade.ts +++ b/packages/extension/src/extension/pipeline/service/refreshFacade.ts @@ -7,7 +7,6 @@ import { } from '@codegraphy-dev/core'; import type { IGraphData } from '../../../shared/graph/contracts'; import { WorkspacePipelineDiscoveryFacade } from './discoveryFacade'; -import { recordExtensionPerformanceEvent } from '../../performance/marks'; import { createWorkspacePipelineAnalysisCacheTiers } from './cache/tiers'; import { getCachedGitHistoryChurnCounts } from '../../gitHistory/cache/state'; import { createGitHistoryPluginSignature } from '../../gitHistory/pluginSignature'; @@ -40,40 +39,6 @@ function getGraphMetricNodeFilePath(node: IGraphData['nodes'][number]): string { ); } -function recordChangedFileRefreshPhase( - phase: string, - startedAt: number, - detail: Record = {}, -): void { - recordExtensionPerformanceEvent('workspacePipeline.refreshChangedFiles.phase', { - ...detail, - durationMs: Date.now() - startedAt, - phase, - }); -} - -async function timeChangedFileRefreshPhase( - phase: string, - operation: () => Promise, - describeResult: (result: T) => Record = () => ({}), -): Promise { - const startedAt = Date.now(); - const result = await operation(); - recordChangedFileRefreshPhase(phase, startedAt, describeResult(result)); - return result; -} - -function timeChangedFileRefreshPhaseSync( - phase: string, - operation: () => T, - describeResult: (result: T) => Record = () => ({}), -): T { - const startedAt = Date.now(); - const result = operation(); - recordChangedFileRefreshPhase(phase, startedAt, describeResult(result)); - return result; -} - export abstract class WorkspacePipelineRefreshFacade extends WorkspacePipelineDiscoveryFacade { private _createWorkspaceIndexRefreshSource( disabledPlugins: Set = new Set(), @@ -150,110 +115,6 @@ export abstract class WorkspacePipelineRefreshFacade extends WorkspacePipelineDi return source; } - private _createTimedWorkspaceIndexRefreshSource( - disabledPlugins: Set, - ): WorkspacePipelineRefreshSource { - const source = this._createWorkspaceIndexRefreshSource(disabledPlugins); - - const readAnalysisFiles = source._readAnalysisFiles.bind(source); - source._readAnalysisFiles = files => timeChangedFileRefreshPhase( - 'readAnalysisFiles', - () => readAnalysisFiles(files), - readFiles => ({ - fileCount: files.length, - readFileCount: readFiles.length, - }), - ); - - const analyzeFiles = source._analyzeFiles.bind(source); - source._analyzeFiles = ( - files, - root, - progress, - abortSignal, - pluginIds, - nextDisabledPlugins, - ) => timeChangedFileRefreshPhase( - 'analyzeFiles', - () => analyzeFiles( - files, - root, - progress, - abortSignal, - pluginIds, - nextDisabledPlugins, - ), - result => ({ - cacheHits: result.cacheHits, - cacheMisses: result.cacheMisses, - fileCount: files.length, - pluginIdCount: pluginIds?.length ?? 0, - }), - ); - - const buildGraphData = source._buildGraphData.bind(source); - source._buildGraphData = (fileConnections, root, selectedPlugins) => - timeChangedFileRefreshPhaseSync( - 'buildGraphData', - () => buildGraphData(fileConnections, root, selectedPlugins), - graphData => ({ - edgeCount: graphData.edges.length, - fileCount: fileConnections.size, - nodeCount: graphData.nodes.length, - }), - ); - - const buildGraphDataFromAnalysis = source._buildGraphDataFromAnalysis.bind(source); - source._buildGraphDataFromAnalysis = (fileAnalysis, root, selectedPlugins) => - timeChangedFileRefreshPhaseSync( - 'buildGraphDataFromAnalysis', - () => buildGraphDataFromAnalysis(fileAnalysis, root, selectedPlugins), - graphData => ({ - edgeCount: graphData.edges.length, - fileCount: fileAnalysis.size, - nodeCount: graphData.nodes.length, - }), - ); - - const patchGraphDataNodeMetrics = source._patchGraphDataNodeMetrics?.bind(source); - if (patchGraphDataNodeMetrics) { - source._patchGraphDataNodeMetrics = (graphData, filePaths) => - timeChangedFileRefreshPhaseSync( - 'patchGraphDataNodeMetrics', - () => patchGraphDataNodeMetrics(graphData, filePaths), - patchedGraphData => ({ - changedFileCount: filePaths.length, - edgeCount: patchedGraphData.edges.length, - nodeCount: patchedGraphData.nodes.length, - }), - ); - } - - const analyze = source.analyze.bind(source); - source.analyze = (patterns, nextDisabledPlugins, signal, progress) => - timeChangedFileRefreshPhase( - 'fullAnalyze', - () => analyze(patterns, nextDisabledPlugins, signal, progress), - graphData => ({ - edgeCount: graphData.edges.length, - nodeCount: graphData.nodes.length, - patternCount: patterns?.length ?? 0, - }), - ); - - const invalidateWorkspaceFiles = source.invalidateWorkspaceFiles.bind(source); - source.invalidateWorkspaceFiles = filePaths => timeChangedFileRefreshPhaseSync( - 'invalidateWorkspaceFiles', - () => invalidateWorkspaceFiles(filePaths), - invalidatedFiles => ({ - fileCount: filePaths.length, - invalidatedFileCount: invalidatedFiles.length, - }), - ); - - return source; - } - private _patchGraphDataNodeMetrics( graphData: IGraphData, filePaths: readonly string[], @@ -490,7 +351,6 @@ export abstract class WorkspacePipelineRefreshFacade extends WorkspacePipelineDi signal?: AbortSignal, onProgress?: (progress: { phase: string; current: number; total: number }) => void, ): Promise { - const refreshStartedAt = Date.now(); const workspaceRoot = this._getWorkspaceRoot(); if (!workspaceRoot) { return { nodes: [], edges: [] }; @@ -503,7 +363,6 @@ export abstract class WorkspacePipelineRefreshFacade extends WorkspacePipelineDi workspaceRoot, filePaths, ); - const discoveryStartedAt = Date.now(); let discoveryResult: ChangedFileDiscoveryState; if (reusableDiscoveryState) { discoveryResult = reusableDiscoveryState; @@ -527,15 +386,8 @@ export abstract class WorkspacePipelineRefreshFacade extends WorkspacePipelineDi this._lastDiscoveredDirectories = discoveryResult.directories; this._lastGitIgnoredPaths = discovered.gitIgnoredPaths ?? []; } - recordExtensionPerformanceEvent('workspacePipeline.refreshChangedFiles.discovery', { - changedFileCount: filePaths.length, - directoryCount: discoveryResult.directories?.length ?? 0, - durationMs: Date.now() - discoveryStartedAt, - fileCount: discoveryResult.files.length, - mode: reusableDiscoveryState ? 'cached' : 'discover', - }); - const graphData = await refreshWorkspacePipelineChangedFiles(this._createTimedWorkspaceIndexRefreshSource(disabledPlugins), { + return refreshWorkspacePipelineChangedFiles(this._createWorkspaceIndexRefreshSource(disabledPlugins), { deferMetricOnlyIndexMetadata: true, disabledPlugins, discoveredDirectories: discoveryResult.directories, @@ -548,43 +400,25 @@ export abstract class WorkspacePipelineRefreshFacade extends WorkspacePipelineDi analysisContext, nextDisabledPlugins = disabledPlugins, ) => - timeChangedFileRefreshPhase( - 'notifyFilesChanged', - () => this._registry.notifyFilesChanged( - files, - root, - analysisContext, - nextDisabledPlugins, - ), - result => ({ - additionalFilePathCount: result.additionalFilePaths.length, - fileCount: files.length, - requiresFullRefresh: result.requiresFullRefresh, - }), + this._registry.notifyFilesChanged( + files, + root, + analysisContext, + nextDisabledPlugins, ), onProgress, onDeferredIndexMetadataError: error => { console.warn('[CodeGraphy] Failed to persist metric-only refresh metadata.', error); }, persistCache: () => { - timeChangedFileRefreshPhaseSync('persistCache', () => { - this._persistCache(); - }); + this._persistCache(); }, persistIndexMetadata: async () => { - await timeChangedFileRefreshPhase('persistIndexMetadata', () => - this._persistIndexMetadata(), - ); + await this._persistIndexMetadata(); }, signal, workspaceRoot, }); - recordExtensionPerformanceEvent('workspacePipeline.refreshChangedFiles.completed', { - durationMs: Date.now() - refreshStartedAt, - edgeCount: graphData.edges.length, - nodeCount: graphData.nodes.length, - }); - return graphData; } async refreshGitignoreMetadata( diff --git a/packages/extension/src/extension/pipeline/serviceAdapters.ts b/packages/extension/src/extension/pipeline/serviceAdapters.ts index dae2b8ffa..d0c60deca 100644 --- a/packages/extension/src/extension/pipeline/serviceAdapters.ts +++ b/packages/extension/src/extension/pipeline/serviceAdapters.ts @@ -24,44 +24,11 @@ import { getWorkspacePipelineFileStat, getWorkspacePipelineRoot, } from './io'; -import { recordExtensionPerformanceEvent } from '../performance/marks'; export interface WorkspacePipelineGraphScopeOptions { nodeVisibility?: Readonly>; } -function recordWorkspacePipelineAnalyzeFilesPhase( - phase: string, - startedAt: number, - detail: Record = {}, -): void { - recordExtensionPerformanceEvent('workspacePipeline.analyzeFiles.phase', { - ...detail, - durationMs: Date.now() - startedAt, - phase, - }); -} - -async function timeWorkspacePipelineAnalyzeFilesPhase( - phase: string, - operation: () => Promise, - describeResult: (result: T) => Record = () => ({}), -): Promise { - const startedAt = Date.now(); - const result = await operation(); - recordWorkspacePipelineAnalyzeFilesPhase(phase, startedAt, describeResult(result)); - return result; -} - -function describeFileAnalysisResult( - result: IFileAnalysisResult | null, -): Record { - return { - relationCount: result?.relations?.length ?? 0, - symbolCount: result?.symbols?.length ?? 0, - }; -} - export async function preAnalyzeWorkspacePipelinePlugins( files: IDiscoveredFile[], workspaceRoot: string, @@ -98,107 +65,21 @@ export function analyzeWorkspacePipelineFiles( pluginIds?: readonly string[], disabledPlugins: Set = new Set(), ): Promise { - const timedDiscovery: WorkspacePipelineFilesSource['_discovery'] = { - readContent: file => timeWorkspacePipelineAnalyzeFilesPhase( - 'readContent', - () => discovery.readContent(file), - content => ({ - byteCount: content.length, - filePath: file.relativePath, - }), - ), - }; - const timedRegistry: WorkspacePipelineFilesSource['_registry'] = { - analyzeFileResult: ( - absolutePath, - content, - rootPath, - analysisContext, - options, - ) => timeWorkspacePipelineAnalyzeFilesPhase( - 'analyzeFileResult', - () => registry.analyzeFileResult( - absolutePath, - content, - rootPath, - analysisContext, - options, - ), - result => ({ - filePath: absolutePath, - ...describeFileAnalysisResult(result), - }), - ), - analyzeFileResultForPlugins: registry.analyzeFileResultForPlugins - ? ( - absolutePath, - content, - rootPath, - pluginIds, - analysisContext, - options, - ) => timeWorkspacePipelineAnalyzeFilesPhase( - 'analyzeFileResultForPlugins', - () => registry.analyzeFileResultForPlugins?.( - absolutePath, - content, - rootPath, - pluginIds, - analysisContext, - options, - ) ?? Promise.resolve(null), - result => ({ - filePath: absolutePath, - pluginIdCount: pluginIds.length, - ...describeFileAnalysisResult(result), - }), - ) - : undefined, - }; - const source: WorkspacePipelineFilesSource = { _cache: cache, - _discovery: timedDiscovery, + _discovery: discovery, _eventBus: eventBus, - _getFileStat: filePath => timeWorkspacePipelineAnalyzeFilesPhase( - 'getFileStat', - () => getFileStat(filePath), - stat => ({ - filePath, - found: Boolean(stat), - size: stat?.size, - }), - ), + _getFileStat: getFileStat, _preAnalyzePlugins: (preAnalyzeFiles, rootPath, abortSignal) => - timeWorkspacePipelineAnalyzeFilesPhase( - 'preAnalyzeFiles', - () => preAnalyzeWorkspacePipelinePlugins( - preAnalyzeFiles, - rootPath, - { - notifyPreAnalyze: (analysisFiles, preAnalyzeRootPath, analysisContext, nextDisabledPlugins) => - timeWorkspacePipelineAnalyzeFilesPhase( - 'notifyPreAnalyze', - () => registry.notifyPreAnalyze( - analysisFiles, - preAnalyzeRootPath, - analysisContext, - nextDisabledPlugins, - ), - () => ({ - fileCount: analysisFiles.length, - }), - ), - } as Pick, - timedDiscovery, - abortSignal, - disabledPlugins, - ), - () => ({ - fileCount: preAnalyzeFiles.length, - }), + preAnalyzeWorkspacePipelinePlugins( + preAnalyzeFiles, + rootPath, + registry, + discovery, + abortSignal, + disabledPlugins, ), - _registry: timedRegistry, + _registry: registry, }; return analyzeWorkspacePipelineSourceFiles( diff --git a/packages/extension/src/extension/workspaceFiles/refresh/operations.ts b/packages/extension/src/extension/workspaceFiles/refresh/operations.ts index cd0748428..9690b351a 100644 --- a/packages/extension/src/extension/workspaceFiles/refresh/operations.ts +++ b/packages/extension/src/extension/workspaceFiles/refresh/operations.ts @@ -4,7 +4,6 @@ import { shouldIgnoreSaveForGraphRefresh, shouldIgnoreWorkspaceFileWatcherRefresh, } from '../ignore'; -import { recordExtensionPerformanceEvent } from '../../performance/marks'; import { scheduleWorkspaceRefresh } from './scheduler'; type WorkspaceRenameFiles = vscode.FileRenameEvent['files']; @@ -63,9 +62,6 @@ export function refreshWorkspaceSavedDocument( } const now = Date.now(); - recordExtensionPerformanceEvent('workspaceFiles.savedDocument.received', { - filePath: document.uri.fsPath, - }); pruneRecentSavedDocumentPaths(now); recentSavedDocumentPaths.set( normalizeFileWatcherPath(document.uri.fsPath), diff --git a/packages/extension/src/extension/workspaceFiles/refresh/scheduler.ts b/packages/extension/src/extension/workspaceFiles/refresh/scheduler.ts index 34b97332b..41b3a9893 100644 --- a/packages/extension/src/extension/workspaceFiles/refresh/scheduler.ts +++ b/packages/extension/src/extension/workspaceFiles/refresh/scheduler.ts @@ -1,5 +1,4 @@ import type { GraphViewProvider } from '../../graphViewProvider'; -import { recordExtensionPerformanceEvent } from '../../performance/marks'; interface PendingWorkspaceRefresh { filePaths: Set; @@ -29,18 +28,6 @@ function markWorkspaceRefreshPending( provider.markWorkspaceRefreshPending?.(logMessage, filePaths, options); } -function createRefreshPerformanceDetail( - pending: Pick, - delayMs: number, -): Record { - return { - delayMs, - fileCount: pending.filePaths.size, - fullRefresh: pending.fullRefresh, - gitignoreRefresh: pending.gitignoreRefresh, - }; -} - export function scheduleWorkspaceRefresh( provider: GraphViewProvider, logMessage: string, @@ -75,10 +62,6 @@ export function scheduleWorkspaceRefresh( gitignoreRefresh, logMessage, timeout: setTimeout(() => { - recordExtensionPerformanceEvent( - 'workspaceRefresh.started', - createRefreshPerformanceDetail(nextPending, delayMs), - ); pendingWorkspaceRefreshes.delete(provider); if (!isGraphOpen(provider)) { markWorkspaceRefreshPending( @@ -121,9 +104,5 @@ export function scheduleWorkspaceRefresh( }, delayMs), }; - recordExtensionPerformanceEvent( - 'workspaceRefresh.scheduled', - createRefreshPerformanceDetail(nextPending, delayMs), - ); pendingWorkspaceRefreshes.set(provider, nextPending); } diff --git a/packages/extension/src/shared/protocol/webviewToExtension.ts b/packages/extension/src/shared/protocol/webviewToExtension.ts index 007bbc0c4..90b82ac5e 100644 --- a/packages/extension/src/shared/protocol/webviewToExtension.ts +++ b/packages/extension/src/shared/protocol/webviewToExtension.ts @@ -98,7 +98,6 @@ export type WebviewToExtensionMessage = | { type: 'JUMP_TO_COMMIT'; payload: { sha: string } } | { type: 'RESET_TIMELINE' } | { type: 'PREVIEW_FILE_AT_COMMIT'; payload: { sha: string; filePath: string } } - | { type: 'PERF_SAVE_LIVE_UPDATE_FILE'; payload: { path: string } } | { type: 'NODE_BOUNDS_RESPONSE'; payload: { nodes: Array<{ id: string; x: number; y: number; size: number }> }; diff --git a/packages/extension/src/webview/app/graph/stats.tsx b/packages/extension/src/webview/app/graph/stats.tsx index 272da2edf..aa472c805 100644 --- a/packages/extension/src/webview/app/graph/stats.tsx +++ b/packages/extension/src/webview/app/graph/stats.tsx @@ -1,5 +1,4 @@ -import React, { useEffect } from 'react'; -import { recordWebviewPerformanceEvent } from '../../performance/marks'; +import React from 'react'; const COUNT_FORMATTER = new Intl.NumberFormat('en-US'); @@ -17,10 +16,6 @@ export function buildGraphStatsLabel( } export function GraphStatsBadge({ label }: { label: string }): React.ReactElement { - useEffect(() => { - recordWebviewPerformanceEvent('graphStats.rendered', { label }); - }, [label]); - return (
{label} diff --git a/packages/extension/src/webview/app/shell/graphScopeVisibility.ts b/packages/extension/src/webview/app/shell/graphScopeVisibility.ts index 84a384fa7..1d4a8975a 100644 --- a/packages/extension/src/webview/app/shell/graphScopeVisibility.ts +++ b/packages/extension/src/webview/app/shell/graphScopeVisibility.ts @@ -1,6 +1,5 @@ import { useEffect, useRef, useState } from 'react'; import type { GraphState } from '../../store/state'; -import { recordWebviewPerformanceEvent } from '../../performance/marks'; export const GRAPH_SCOPE_RENDER_DEBOUNCE_MS = 80; @@ -44,10 +43,6 @@ export function useDebouncedGraphScopeVisibility( useEffect(() => { if (renderVisibilityRef.current.nodeVisibility === nodeVisibility) { - recordWebviewPerformanceEvent('graphScope.visibility.renderImmediate', { - edgeVisibilityCount: Object.keys(edgeVisibility).length, - nodeVisibilityCount: Object.keys(nodeVisibility).length, - }); setRenderVisibility({ edgeVisibility, nodeVisibility, @@ -56,10 +51,6 @@ export function useDebouncedGraphScopeVisibility( } const timer = setTimeout(() => { - recordWebviewPerformanceEvent('graphScope.visibility.renderDebounced', { - edgeVisibilityCount: Object.keys(edgeVisibility).length, - nodeVisibilityCount: Object.keys(nodeVisibility).length, - }); setRenderVisibility({ edgeVisibility, nodeVisibility, diff --git a/packages/extension/src/webview/app/shell/messageListener.ts b/packages/extension/src/webview/app/shell/messageListener.ts index a8963cf25..beda660e5 100644 --- a/packages/extension/src/webview/app/shell/messageListener.ts +++ b/packages/extension/src/webview/app/shell/messageListener.ts @@ -11,7 +11,6 @@ import { handlePluginInjectMessage } from './messageListener/pluginInjection'; import { removeDisabledPluginRegistrations } from './messageListener/pluginRegistrations'; import { postWebviewReadyOnce, resetWebviewReadyPosted } from './messageListener/ready'; import { handleCssSnippetsUpdatedMessage } from './messageListener/cssSnippets'; -import { recordWebviewPerformanceEvent } from '../../performance/marks'; export interface InjectAssetsParams { pluginId: string; @@ -63,9 +62,6 @@ export function createMessageHandler( if (!raw || typeof raw !== 'object' || typeof raw.type !== 'string') { return; } - - recordWebviewPerformanceEvent('extensionMessage.received', { type: raw.type }); - if (handlePluginInjectMessage(raw, injectPluginAssets)) { return; } diff --git a/packages/extension/src/webview/app/shell/messageListener/ready.ts b/packages/extension/src/webview/app/shell/messageListener/ready.ts index 73c4b8cdc..b435669ed 100644 --- a/packages/extension/src/webview/app/shell/messageListener/ready.ts +++ b/packages/extension/src/webview/app/shell/messageListener/ready.ts @@ -1,6 +1,5 @@ import { postMessage } from '../../../vscodeApi'; import { graphStore } from '../../../store/state'; -import { recordWebviewPerformanceEvent } from '../../../performance/marks'; type WindowWithCodeGraphyReadyFlag = Window & { __codegraphyWebviewReadyPosted?: boolean; @@ -29,7 +28,6 @@ export function postWebviewReadyOnce(targetWindow: Window): void { const pageId = getWebviewPageId(targetWindow); codeGraphyWindow.__codegraphyWebviewReadyPosted = true; graphStore.getState().beginInitialBootstrap(); - recordWebviewPerformanceEvent('webview.ready.posted', { pageId }); postMessage({ type: 'WEBVIEW_READY', payload: { pageId, postedAt: Date.now() }, diff --git a/packages/extension/src/webview/components/graph/runtime/use/state.ts b/packages/extension/src/webview/components/graph/runtime/use/state.ts index 8bc825b8d..00f803f17 100644 --- a/packages/extension/src/webview/components/graph/runtime/use/state.ts +++ b/packages/extension/src/webview/components/graph/runtime/use/state.ts @@ -31,7 +31,6 @@ import { } from '../../support/contracts/forceGraph'; import type { GraphCursorStyle } from '../../support/dom'; import type { ThemeKind } from '../../../../theme/useTheme'; -import { measureWebviewPerformance } from '../../../../performance/marks'; export interface GraphMouseState { ctrlKey: boolean; @@ -209,25 +208,18 @@ export function useGraphRuntime({ } const graphData = useMemo(() => { - const nextGraphData = measureWebviewPerformance('graphRuntime.buildGraphData', { - edgeCount: data.edges.length, - graphMode: graphMode ?? '2d', - nodeCount: data.nodes.length, - previousNodeCount: graphDataRef.current.nodes.length, - }, () => { - const resolvedGraphMode = graphMode ?? '2d'; - return buildGraphData({ - data, - appearance, - nodeSizeMode: nodeSizeModeRef.current, - theme: themeRef.current, - favorites, - graphViewContributions, - graphMode: resolvedGraphMode, - bidirectionalMode, - timelineActive, - previousNodes: graphDataRef.current.nodes, - }); + const resolvedGraphMode = graphMode ?? '2d'; + const nextGraphData = buildGraphData({ + data, + appearance, + nodeSizeMode: nodeSizeModeRef.current, + theme: themeRef.current, + favorites, + graphViewContributions, + graphMode: resolvedGraphMode, + bidirectionalMode, + timelineActive, + previousNodes: graphDataRef.current.nodes, }); graphDataRef.current = nextGraphData; diff --git a/packages/extension/src/webview/components/graphScope/rows.tsx b/packages/extension/src/webview/components/graphScope/rows.tsx index ab6ded05c..07a4cd203 100644 --- a/packages/extension/src/webview/components/graphScope/rows.tsx +++ b/packages/extension/src/webview/components/graphScope/rows.tsx @@ -13,7 +13,6 @@ import { scheduleEdgeVisibilityMessage, scheduleNodeVisibilityMessage, } from './messages'; -import { recordWebviewPerformanceEvent } from '../../performance/marks'; const FOLDER_NODE_TYPE = 'folder'; @@ -69,7 +68,6 @@ function updateNodeVisibilityOptimistically( [nodeTypeId]: visible, }, })); - recordWebviewPerformanceEvent('graphScope.nodeVisibility.optimistic', { nodeTypeId, visible }); } function updateEdgeVisibilityOptimistically(edgeKind: string, visible: boolean): void { @@ -79,7 +77,6 @@ function updateEdgeVisibilityOptimistically(edgeKind: string, visible: boolean): [edgeKind]: visible, }, })); - recordWebviewPerformanceEvent('graphScope.edgeVisibility.optimistic', { edgeKind, visible }); } export function resolveScopeRowClassName(enabled: boolean): string { diff --git a/packages/extension/src/webview/performance/marks.ts b/packages/extension/src/webview/performance/marks.ts deleted file mode 100644 index f1bc2bbec..000000000 --- a/packages/extension/src/webview/performance/marks.ts +++ /dev/null @@ -1,79 +0,0 @@ -export interface CodeGraphyPerformanceEvent { - name: string; - at: number; - durationMs?: number; - detail?: Record; -} - -export interface CodeGraphyPerformanceSink { - enabled?: boolean; - events?: CodeGraphyPerformanceEvent[]; - limit?: number; -} - -declare global { - interface Window { - __codegraphyPerformance?: CodeGraphyPerformanceSink; - } -} - -const DEFAULT_EVENT_LIMIT = 500; - -function getEnabledSink(): CodeGraphyPerformanceSink | null { - if (typeof window === 'undefined') { - return null; - } - - const sink = window.__codegraphyPerformance; - return sink?.enabled ? sink : null; -} - -function roundMetric(value: number): number { - return Math.round(value * 100) / 100; -} - -export function recordWebviewPerformanceEvent( - name: string, - detail?: Record, - durationMs?: number, -): void { - const sink = getEnabledSink(); - if (!sink) { - return; - } - - const events = Array.isArray(sink.events) ? sink.events : []; - sink.events = events; - events.push({ - name, - at: roundMetric(window.performance.now()), - ...(durationMs === undefined ? {} : { durationMs: roundMetric(durationMs) }), - ...(detail ? { detail } : {}), - }); - - const configuredLimit = sink.limit; - const limit = typeof configuredLimit === 'number' - && Number.isInteger(configuredLimit) - && configuredLimit > 0 - ? configuredLimit - : DEFAULT_EVENT_LIMIT; - if (events.length > limit) { - events.splice(0, events.length - limit); - } -} - -export function measureWebviewPerformance( - name: string, - detail: Record, - callback: () => T, -): T { - const sink = getEnabledSink(); - if (!sink) { - return callback(); - } - - const startedAt = window.performance.now(); - const result = callback(); - recordWebviewPerformanceEvent(name, detail, window.performance.now() - startedAt); - return result; -} diff --git a/packages/extension/src/webview/search/useFilteredGraph.ts b/packages/extension/src/webview/search/useFilteredGraph.ts index a8621e39c..9ed516db9 100644 --- a/packages/extension/src/webview/search/useFilteredGraph.ts +++ b/packages/extension/src/webview/search/useFilteredGraph.ts @@ -25,7 +25,6 @@ import { buildVisibleGraphConfig, withSharedEdgeTypeAliases, } from './visibleGraphConfig'; -import { measureWebviewPerformance } from '../performance/marks'; export interface IFilteredGraph { /** Graph after node/edge search filtering (null when no graph data). */ @@ -256,17 +255,7 @@ export function useFilteredGraph( return cached; } - const result = measureWebviewPerformance('visibleGraph.derive', { - edgeCount: graphData?.edges.length ?? 0, - edgeTypeCount: edgeTypes.length, - edgeVisibilityCount: Object.keys(edgeVisibility).length, - filterPatternCount: filterPatterns.length, - nodeTypeCount: nodeTypes.length, - nodeVisibilityCount: Object.keys(nodeVisibility).length, - nodeCount: graphData?.nodes.length ?? 0, - searchActive: searchQuery.trim().length > 0, - showOrphans, - }, () => deriveVisibleGraph(graphData, buildVisibleGraphConfig({ + const result = deriveVisibleGraph(graphData, buildVisibleGraphConfig({ edgeTypes, edgeVisibility, filterPatterns, @@ -275,7 +264,7 @@ export function useFilteredGraph( searchOptions, searchQuery, showOrphans, - }))); + })); cacheVisibleGraphResult(cache, visibleGraphCacheKey, result); return result; }, [ @@ -304,13 +293,10 @@ export function useFilteredGraph( const edgeTypesForStyling = withSharedEdgeTypeAliases(edgeTypes); - const result = measureWebviewPerformance('visibleGraph.style', { - edgeCount: graph.edges.length, - nodeCount: graph.nodes.length, - }, () => ({ + const result = { nodes: applyNodeTypeColors(withResolvedNodeTypes(graph.nodes), nodeColors), edges: applyEdgeTypeDefaultColors(graph.edges, edgeTypesForStyling), - })); + }; cacheReferenceResult(styledGraphCache.current, graph, styledGraphCacheKey, result); return result; }, [edgeTypes, nodeColors, styledGraphCacheKey, visibleGraph.graphData]); @@ -325,11 +311,7 @@ export function useFilteredGraph( return cached; } - const result = measureWebviewPerformance('visibleGraph.applyLegendRules', { - edgeCount: filteredData?.edges.length ?? 0, - legendCount: legends.length, - nodeCount: filteredData?.nodes.length ?? 0, - }, () => applyLegendRules(filteredData, legends)); + const result = applyLegendRules(filteredData, legends); if (result) { cacheReferenceResult(coloredGraphCache.current, filteredData, legendGraphCacheKey, result); } @@ -337,9 +319,7 @@ export function useFilteredGraph( }, [filteredData, legendGraphCacheKey, legends]); const controlsEdgeDecorations = useMemo( - () => measureWebviewPerformance('visibleGraph.edgeDecorations', { - edgeCount: filteredData?.edges.length ?? 0, - }, () => filterVisibleEdgeDecorations(filteredData?.edges ?? [], edgeDecorations)), + () => filterVisibleEdgeDecorations(filteredData?.edges ?? [], edgeDecorations), [edgeDecorations, filteredData], ); diff --git a/packages/extension/src/webview/store/messageHandlers/graph.ts b/packages/extension/src/webview/store/messageHandlers/graph.ts index b894f2a72..9e6097a70 100644 --- a/packages/extension/src/webview/store/messageHandlers/graph.ts +++ b/packages/extension/src/webview/store/messageHandlers/graph.ts @@ -7,7 +7,6 @@ import { applyPendingUserGroupsUpdate, } from '../optimistic/groups/updates'; import { arePlainValuesEqual } from './equality/compare'; -import { recordWebviewPerformanceEvent } from '../../performance/marks'; type GraphNodeMetricsUpdateMessage = Extract; type GraphNodeMetricsUpdate = GraphNodeMetricsUpdateMessage['payload']['nodes'][number]; @@ -80,17 +79,8 @@ export function handleGraphDataUpdated( message: Extract, ctx?: Pick, ): PartialState | void { - recordWebviewPerformanceEvent('extensionMessage.graphDataUpdated', { - edgeCount: message.payload.edges.length, - nodeCount: message.payload.nodes.length, - }); - const state = ctx?.getState(); if (state && shouldSkipDuplicateGraphData(state, message.payload)) { - recordWebviewPerformanceEvent('extensionMessage.graphDataSkipped', { - edgeCount: message.payload.edges.length, - nodeCount: message.payload.nodes.length, - }); return undefined; } @@ -116,10 +106,6 @@ export function handleGraphNodeMetricsUpdated( message: GraphNodeMetricsUpdateMessage, ctx?: Pick, ): PartialState | void { - recordWebviewPerformanceEvent('extensionMessage.graphNodeMetricsUpdated', { - nodeCount: message.payload.nodes.length, - }); - const state = ctx?.getState(); if (!state?.graphData) { return undefined; @@ -133,12 +119,7 @@ export function handleGraphNodeMetricsUpdated( if (!nodeSizeModeUsesNodeMetrics(state.nodeSizeMode)) { // Metrics do not affect the current visual graph, so keep graphData referentially stable. - const changed = applyMetricUpdatesInPlace(state.graphData, updatesById); - if (changed) { - recordWebviewPerformanceEvent('extensionMessage.graphNodeMetricsPatchedInPlace', { - nodeCount: message.payload.nodes.length, - }); - } + applyMetricUpdatesInPlace(state.graphData, updatesById); return { isLoading: waitingForInitialBootstrap, @@ -186,7 +167,6 @@ export function handleAppBootstrapComplete( ): PartialState { const state = ctx.getState(); const graphReady = state.graphData !== null; - recordWebviewPerformanceEvent('extensionMessage.appBootstrapComplete', { graphReady }); return { bootstrapComplete: true, diff --git a/packages/extension/tests/acceptance/graphView/vscode.ts b/packages/extension/tests/acceptance/graphView/vscode.ts index 1a912d3ba..5f4a31feb 100644 --- a/packages/extension/tests/acceptance/graphView/vscode.ts +++ b/packages/extension/tests/acceptance/graphView/vscode.ts @@ -14,7 +14,6 @@ export const VSCODE_TEST_VERSION = process.env.CODEGRAPHY_VSCODE_TEST_VERSION ?? interface LaunchVSCodeWithWorkspaceOptions { readonly pluginPackageRelativePaths?: readonly string[]; - readonly extensionPerformanceLogPath?: string; } export async function launchVSCodeWithWorkspace( @@ -46,9 +45,6 @@ export async function launchVSCodeWithWorkspace( env: { ...process.env, CODEGRAPHY_ACCEPTANCE: '1', - ...(options.extensionPerformanceLogPath - ? { CODEGRAPHY_EXTENSION_PERFORMANCE_LOG: options.extensionPerformanceLogPath } - : {}), HOME: homePath, }, }); diff --git a/packages/extension/tests/extension/commands/navigation.test.ts b/packages/extension/tests/extension/commands/navigation.test.ts index 28ba21775..7bdcfa8f7 100644 --- a/packages/extension/tests/extension/commands/navigation.test.ts +++ b/packages/extension/tests/extension/commands/navigation.test.ts @@ -1,12 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import * as vscode from 'vscode'; -const recordExtensionPerformanceEvent = vi.hoisted(() => vi.fn()); - -vi.mock('../../../src/extension/performance/marks', () => ({ - recordExtensionPerformanceEvent, -})); - import { getNavCommands } from '../../../src/extension/commands/navigation'; function makeProvider() { @@ -48,22 +42,6 @@ describe('getNavCommands', () => { 'workbench.view.extension.codegraphy' ); }); - - it('records the open command lifecycle for startup timing', async () => { - vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined); - const provider = makeProvider(); - const commands = getNavCommands(provider as never); - const cmd = commands.find((cmd) => cmd.id === 'codegraphy.open')!; - - cmd.handler(); - await Promise.resolve(); - - expect(recordExtensionPerformanceEvent.mock.calls.map(([name]) => name)).toEqual([ - 'command.open.start', - 'command.open.dispatched', - 'command.open.completed', - ]); - }); }); describe('openInEditor command', () => { diff --git a/packages/extension/tests/extension/graphView/analysis/execution/publish.test.ts b/packages/extension/tests/extension/graphView/analysis/execution/publish.test.ts index f23ffb5d8..ab26699b7 100644 --- a/packages/extension/tests/extension/graphView/analysis/execution/publish.test.ts +++ b/packages/extension/tests/extension/graphView/analysis/execution/publish.test.ts @@ -1,14 +1,6 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import type { IGraphData } from '../../../../../src/shared/graph/contracts'; -const performanceMocks = vi.hoisted(() => ({ - recordExtensionPerformanceEvent: vi.fn(), -})); - -vi.mock('../../../../../src/extension/performance/marks', () => ({ - recordExtensionPerformanceEvent: performanceMocks.recordExtensionPerformanceEvent, -})); - import { publishAnalyzedGraph, publishAnalysisFailure, @@ -21,10 +13,6 @@ import { } from './fixtures'; describe('graph view analysis execution publish', () => { - beforeEach(() => { - performanceMocks.recordExtensionPerformanceEvent.mockReset(); - }); - it('publishes an empty graph and index state', () => { const { handlers } = createExecutionHandlers(); @@ -95,61 +83,6 @@ describe('graph view analysis execution publish', () => { getGraphData(), state.disabledPlugins, ); - expect(performanceMocks.recordExtensionPerformanceEvent).toHaveBeenCalledWith( - 'graphAnalysis.publish.setRawGraphData', - expect.objectContaining({ - durationMs: expect.any(Number), - rawEdgeCount: 0, - rawNodeCount: 1, - }), - ); - expect(performanceMocks.recordExtensionPerformanceEvent).toHaveBeenCalledWith( - 'graphAnalysis.publish.viewTransform', - expect.objectContaining({ - durationMs: expect.any(Number), - }), - ); - expect(performanceMocks.recordExtensionPerformanceEvent).toHaveBeenCalledWith( - 'graphAnalysis.publish.groups', - expect.objectContaining({ - durationMs: expect.any(Number), - }), - ); - expect(performanceMocks.recordExtensionPerformanceEvent).toHaveBeenCalledWith( - 'graphAnalysis.publish.broadcasts', - expect.objectContaining({ - durationMs: expect.any(Number), - }), - ); - expect(performanceMocks.recordExtensionPerformanceEvent).toHaveBeenCalledWith( - 'graphAnalysis.publish.getGraphData', - expect.objectContaining({ - durationMs: expect.any(Number), - edgeCount: 0, - nodeCount: 1, - }), - ); - expect(performanceMocks.recordExtensionPerformanceEvent).toHaveBeenCalledWith( - 'graphAnalysis.publish.graph', - { - mode: 'analyze', - rawNodeCount: 1, - rawEdgeCount: 0, - nodeCount: 1, - edgeCount: 0, - hasIndex: true, - freshness: 'fresh', - freshnessDetail: 'CodeGraphy index is fresh.', - }, - ); - expect(performanceMocks.recordExtensionPerformanceEvent).toHaveBeenCalledWith( - 'graphAnalysis.publish.sendGraphData', - expect.objectContaining({ - durationMs: expect.any(Number), - edgeCount: 0, - nodeCount: 1, - }), - ); }); it('reports graph view update progress before publishing an explicit index result', () => { @@ -315,14 +248,6 @@ describe('graph view analysis execution publish', () => { getGraphData(), state.disabledPlugins, ); - expect(performanceMocks.recordExtensionPerformanceEvent).toHaveBeenCalledWith( - 'graphAnalysis.publish.unchangedGraph', - expect.objectContaining({ - durationMs: expect.any(Number), - edgeCount: 1, - nodeCount: 1, - }), - ); }); it('skips group publication when an incremental refresh only changes node sizing metrics', () => { @@ -368,13 +293,6 @@ describe('graph view analysis execution publish', () => { expect(handlers.computeMergedGroups).not.toHaveBeenCalled(); expect(handlers.sendGroupsUpdated).not.toHaveBeenCalled(); expect(handlers.sendGraphDataUpdated).toHaveBeenCalledWith(nextGraphData); - expect(performanceMocks.recordExtensionPerformanceEvent).toHaveBeenCalledWith( - 'graphAnalysis.publish.groupsSkipped', - expect.objectContaining({ - durationMs: expect.any(Number), - reason: 'groupInputsUnchanged', - }), - ); }); it('sends node metric patches instead of full graph data for metric-only incremental refreshes', () => { diff --git a/packages/extension/tests/extension/graphView/analysis/request.test.ts b/packages/extension/tests/extension/graphView/analysis/request.test.ts index 4ce2153b2..c43893ce9 100644 --- a/packages/extension/tests/extension/graphView/analysis/request.test.ts +++ b/packages/extension/tests/extension/graphView/analysis/request.test.ts @@ -1,13 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -const performanceMocks = vi.hoisted(() => ({ - recordExtensionPerformanceEvent: vi.fn(), -})); - -vi.mock('../../../../src/extension/performance/marks', () => ({ - recordExtensionPerformanceEvent: performanceMocks.recordExtensionPerformanceEvent, -})); - import { runGraphViewAnalysisRequest, type GraphViewAnalysisRequestState, @@ -25,43 +17,47 @@ function createState( describe('graph view analysis request', () => { beforeEach(() => { - performanceMocks.recordExtensionPerformanceEvent.mockReset(); + vi.clearAllMocks(); }); - it('records request lifecycle performance markers with mode context', async () => { + it('emits request lifecycle diagnostics with mode context', async () => { const state = createState({ mode: 'load', filterPatterns: ['src/**'], disabledPlugins: new Set(['plugin.test']), } as Partial); + const emitDiagnostic = vi.fn(); await runGraphViewAnalysisRequest(state, { executeAnalysis: vi.fn(() => Promise.resolve()), + emitDiagnostic, isAbortError: vi.fn(() => false), logError: vi.fn(), updateAnalysisController: vi.fn(), updateAnalysisRequestId: vi.fn(), }); - expect(performanceMocks.recordExtensionPerformanceEvent).toHaveBeenCalledWith( - 'graphAnalysis.request.start', - { + expect(emitDiagnostic).toHaveBeenCalledWith({ + area: 'extension.analysis', + event: 'request-started', + context: { requestId: 1, mode: 'load', filterPatternCount: 1, disabledPluginCount: 1, }, - ); - expect(performanceMocks.recordExtensionPerformanceEvent).toHaveBeenCalledWith( - 'graphAnalysis.request.completed', - expect.objectContaining({ + }); + expect(emitDiagnostic).toHaveBeenCalledWith({ + area: 'extension.analysis', + event: 'request-completed', + context: expect.objectContaining({ requestId: 1, mode: 'load', filterPatternCount: 1, disabledPluginCount: 1, durationMs: expect.any(Number), }), - ); + }); }); it('aborts the previous controller and clears the active request on success', async () => { diff --git a/packages/extension/tests/extension/graphView/provider/refresh.test.ts b/packages/extension/tests/extension/graphView/provider/refresh.test.ts index a88d263e7..d32f62c5b 100644 --- a/packages/extension/tests/extension/graphView/provider/refresh.test.ts +++ b/packages/extension/tests/extension/graphView/provider/refresh.test.ts @@ -2,14 +2,6 @@ import { describe, expect, it, vi } from 'vitest'; import { createGraphViewProviderRefreshMethods } from '../../../../src/extension/graphView/provider/refresh'; import { createSource } from './refresh/fixture'; -const performanceMocks = vi.hoisted(() => ({ - record: vi.fn(), -})); - -vi.mock('../../../../src/extension/performance/marks', () => ({ - recordExtensionPerformanceEvent: performanceMocks.record, -})); - describe('graphView/provider/refresh', () => { describe('refresh', () => { @@ -169,13 +161,6 @@ describe('graphView/provider/refresh', () => { await methods.refreshChangedFiles(['src/example.ts']); - expect(performanceMocks.record).toHaveBeenCalledWith( - 'graphView.refreshChangedFiles.received', - { - fileCount: 1, - indexRefreshInFlight: false, - }, - ); expect(source._loadDisabledRulesAndPlugins).not.toHaveBeenCalled(); expect(source._loadGroupsAndFilterPatterns).not.toHaveBeenCalled(); expect(source._incrementalAnalyzeAndSendData).toHaveBeenCalledWith(['src/example.ts']); diff --git a/packages/extension/tests/extension/graphView/provider/refresh/run.test.ts b/packages/extension/tests/extension/graphView/provider/refresh/run.test.ts index 67f39489d..ff1a8868f 100644 --- a/packages/extension/tests/extension/graphView/provider/refresh/run.test.ts +++ b/packages/extension/tests/extension/graphView/provider/refresh/run.test.ts @@ -1,13 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -const mocks = vi.hoisted(() => ({ - recordExtensionPerformanceEvent: vi.fn(), -})); - -vi.mock('../../../../../src/extension/performance/marks', () => ({ - recordExtensionPerformanceEvent: mocks.recordExtensionPerformanceEvent, -})); - import { runChangedFileRefresh, runIndexRefresh, @@ -38,9 +30,6 @@ describe('graphView/provider/refresh/run', () => { expect(() => sendRefreshState(source as never, 'refresh')).not.toThrow(); expect(source._sendAllSettings).toHaveBeenCalledOnce(); expect(source._sendFavorites).not.toHaveBeenCalled(); - expect(mocks.recordExtensionPerformanceEvent).toHaveBeenCalledWith('graphWebview.refreshState.send', { - reason: 'refresh', - }); }); it('falls back to full analysis when no primary load helper is available', async () => { diff --git a/packages/extension/tests/extension/graphView/provider/webview/defaultDependencies.test.ts b/packages/extension/tests/extension/graphView/provider/webview/defaultDependencies.test.ts index 4a4afa555..92a2467f6 100644 --- a/packages/extension/tests/extension/graphView/provider/webview/defaultDependencies.test.ts +++ b/packages/extension/tests/extension/graphView/provider/webview/defaultDependencies.test.ts @@ -8,7 +8,6 @@ const mocks = vi.hoisted(() => ({ resolveGraphViewWebviewView: vi.fn(), sendGraphViewWebviewMessage: vi.fn(), onGraphViewWebviewMessage: vi.fn(() => ({ dispose: vi.fn() })), - recordExtensionPerformanceEvent: vi.fn(), executeCommand: vi.fn(() => Promise.resolve('executed')), createWebviewPanel: vi.fn(() => ({ id: 'panel-1', @@ -67,10 +66,6 @@ vi.mock('../../../../../src/extension/graphView/webview/bridge', () => ({ onGraphViewWebviewMessage: mocks.onGraphViewWebviewMessage, })); -vi.mock('../../../../../src/extension/performance/marks', () => ({ - recordExtensionPerformanceEvent: mocks.recordExtensionPerformanceEvent, -})); - import { createDefaultGraphViewProviderWebviewMethodDependencies } from '../../../../../src/extension/graphView/provider/webview/defaultDependencies'; describe('graphView/provider/webview/defaultDependencies', () => { @@ -82,7 +77,6 @@ describe('graphView/provider/webview/defaultDependencies', () => { mocks.resolveGraphViewWebviewView.mockClear(); mocks.sendGraphViewWebviewMessage.mockClear(); mocks.onGraphViewWebviewMessage.mockClear(); - mocks.recordExtensionPerformanceEvent.mockClear(); mocks.executeCommand.mockClear(); mocks.createWebviewPanel.mockClear(); mocks.onGraphViewWebviewMessage.mockReturnValue({ dispose: vi.fn() }); @@ -97,7 +91,6 @@ describe('graphView/provider/webview/defaultDependencies', () => { expect(dependencies.sendWebviewMessage).toBe(mocks.sendGraphViewWebviewMessage); expect(dependencies.onWebviewMessage).toBe(mocks.onGraphViewWebviewMessage); expect(dependencies.setWebviewMessageListener).toBe(mocks.setGraphViewProviderMessageListener); - expect(dependencies.recordPerformanceEvent).toBe(mocks.recordExtensionPerformanceEvent); }); it('creates graph html with a fresh nonce', () => { diff --git a/packages/extension/tests/extension/graphView/provider/webview/messages.test.ts b/packages/extension/tests/extension/graphView/provider/webview/messages.test.ts index 09024e103..3648185f7 100644 --- a/packages/extension/tests/extension/graphView/provider/webview/messages.test.ts +++ b/packages/extension/tests/extension/graphView/provider/webview/messages.test.ts @@ -8,7 +8,6 @@ describe('graphView/provider/webview/messages', () => { const timelineView = { webview: { postMessage: vi.fn(() => true) } } as unknown as vscode.WebviewView; const panel = { webview: { postMessage: vi.fn(() => true) } } as unknown as vscode.WebviewPanel; const notifyExtensionMessage = vi.fn(); - const recordPerformanceEvent = vi.fn(); const sendWebviewMessage = vi.fn(); sendGraphViewProviderWebviewMessage( @@ -19,7 +18,6 @@ describe('graphView/provider/webview/messages', () => { _notifyExtensionMessage: notifyExtensionMessage, }, { - recordPerformanceEvent, sendWebviewMessage, }, { type: 'PING' }, @@ -28,11 +26,6 @@ describe('graphView/provider/webview/messages', () => { expect(sendWebviewMessage).toHaveBeenCalledWith([graphView, timelineView], [panel], { type: 'PING', }); - expect(recordPerformanceEvent).toHaveBeenCalledWith('graphWebview.message.send', { - panelCount: 1, - sidebarViewCount: 2, - type: 'PING', - }); expect(notifyExtensionMessage).toHaveBeenCalledWith({ type: 'PING' }); }); }); diff --git a/packages/extension/tests/extension/graphView/provider/webview/resolve.test.ts b/packages/extension/tests/extension/graphView/provider/webview/resolve.test.ts index e5fee99ef..1efb14607 100644 --- a/packages/extension/tests/extension/graphView/provider/webview/resolve.test.ts +++ b/packages/extension/tests/extension/graphView/provider/webview/resolve.test.ts @@ -111,57 +111,6 @@ describe('graphView/provider/webview/resolve', () => { expect(source._timelineView).toBe(webviewView); }); - it('records provider resolve markers and forwards the timing sink to the webview resolver', () => { - const recordPerformanceEvent = vi.fn(); - const webview = { - options: {}, - html: '', - } as unknown as vscode.Webview; - const webviewView = { - viewType: 'codegraphy.graphView', - webview, - visible: true, - onDidChangeVisibility: vi.fn(() => undefined), - onDidDispose: vi.fn(() => ({ dispose: vi.fn() })), - } as unknown as vscode.WebviewView; - const resolveWebviewView = vi.fn((_view, options) => { - options.recordPerformanceEvent('graphWebview.resolve.inner'); - }); - const source = { - _extensionUri: vscode.Uri.file('/test/extension'), - _view: undefined, - _timelineView: undefined, - _getLocalResourceRoots: vi.fn(() => []), - flushPendingWorkspaceRefresh: vi.fn(), - }; - - resolveGraphViewProviderWebviewView(source as never, { - createHtml: vi.fn(() => ''), - executeCommand: vi.fn(() => Promise.resolve(undefined)), - recordPerformanceEvent, - resolveWebviewView, - setWebviewMessageListener: vi.fn(), - }, webviewView); - - expect(recordPerformanceEvent.mock.calls.map(([name]) => name)).toEqual([ - 'graphWebview.providerResolve.start', - 'graphWebview.providerResolve.assigned', - 'graphWebview.providerResolve.delegate.start', - 'graphWebview.resolve.inner', - 'graphWebview.providerResolve.delegate.end', - 'graphWebview.providerResolve.flushPendingRefresh', - 'graphWebview.providerResolve.end', - ]); - expect(recordPerformanceEvent).toHaveBeenCalledWith( - 'graphWebview.providerResolve.start', - { - viewKind: 'graph', - viewType: 'codegraphy.graphView', - visible: true, - }, - ); - }); - it('keeps a different timeline view attached when the graph view disposes', () => { let disposeListener: (() => void) | undefined; const resourceRoots = [vscode.Uri.file('/test/root')]; diff --git a/packages/extension/tests/extension/graphView/webview/dispatch/primary/dispatch.test.ts b/packages/extension/tests/extension/graphView/webview/dispatch/primary/dispatch.test.ts index f0c9c21b8..c7aa378c8 100644 --- a/packages/extension/tests/extension/graphView/webview/dispatch/primary/dispatch.test.ts +++ b/packages/extension/tests/extension/graphView/webview/dispatch/primary/dispatch.test.ts @@ -1,5 +1,4 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import * as vscode from 'vscode'; import type { GraphViewPrimaryMessageResult } from '../../../../../../src/extension/graphView/webview/dispatch/primary'; import { dispatchGraphViewPrimaryMessage } from '../../../../../../src/extension/graphView/webview/dispatch/primary'; import { createPrimaryMessageContext } from '../context'; @@ -8,9 +7,6 @@ const primaryDispatchMocks = vi.hoisted(() => ({ route: vi.fn(), stateful: vi.fn(), })); -const performanceMocks = vi.hoisted(() => ({ - record: vi.fn(), -})); vi.mock('../../../../../../src/extension/graphView/webview/dispatch/routed', () => ({ dispatchGraphViewPrimaryRouteMessage: primaryDispatchMocks.route, @@ -20,17 +16,11 @@ vi.mock('../../../../../../src/extension/graphView/webview/dispatch/stateful', ( dispatchGraphViewPrimaryStateMessage: primaryDispatchMocks.stateful, })); -vi.mock('../../../../../../src/extension/performance/marks', () => ({ - recordExtensionPerformanceEvent: performanceMocks.record, -})); - describe('graph view primary message dispatch', () => { beforeEach(() => { vi.clearAllMocks(); primaryDispatchMocks.route.mockReset(); primaryDispatchMocks.stateful.mockReset(); - performanceMocks.record.mockReset(); - delete process.env.CODEGRAPHY_ACCEPTANCE; }); it('returns the routed result when the routed handlers handle the message', async () => { @@ -72,64 +62,4 @@ describe('graph view primary message dispatch', () => { context, ); }); - - it('saves the requested file through VS Code when the acceptance live-update perf message is enabled', async () => { - process.env.CODEGRAPHY_ACCEPTANCE = '1'; - const context = createPrimaryMessageContext(); - const insert = vi.fn(); - const edit = vi.fn(async (callback: (builder: { insert: typeof insert }) => void) => { - callback({ insert }); - return true; - }); - const save = vi.fn(async () => true); - const document = { - lineCount: 7, - save, - }; - const editor = { edit }; - (vscode.workspace as unknown as { openTextDocument: ReturnType }) - .openTextDocument = vi.fn(async () => document); - (vscode.window as unknown as { showTextDocument: ReturnType }) - .showTextDocument = vi.fn(async () => editor); - - await expect( - dispatchGraphViewPrimaryMessage( - { - type: 'PERF_SAVE_LIVE_UPDATE_FILE', - payload: { path: '/workspace/src/app.ts' }, - }, - context, - ), - ).resolves.toEqual({ handled: true }); - - expect(vscode.workspace.openTextDocument).toHaveBeenCalledWith( - vscode.Uri.file('/workspace/src/app.ts'), - ); - expect(vscode.window.showTextDocument).toHaveBeenCalledWith(document, { - preserveFocus: true, - preview: false, - }); - expect(insert).toHaveBeenCalledWith( - new vscode.Position(7, 0), - expect.stringContaining('CodeGraphy live update perf marker'), - ); - expect(save).toHaveBeenCalledOnce(); - expect(performanceMocks.record.mock.calls.map(([name]) => name)).toEqual([ - 'graphWebview.acceptanceLiveUpdateSave.start', - 'graphWebview.acceptanceLiveUpdateSave.openDocument', - 'graphWebview.acceptanceLiveUpdateSave.showDocument', - 'graphWebview.acceptanceLiveUpdateSave.edit', - 'graphWebview.acceptanceLiveUpdateSave.save', - 'graphWebview.acceptanceLiveUpdateSave.completed', - ]); - expect(performanceMocks.record).toHaveBeenCalledWith( - 'graphWebview.acceptanceLiveUpdateSave.completed', - expect.objectContaining({ - durationMs: expect.any(Number), - filePath: '/workspace/src/app.ts', - }), - ); - expect(primaryDispatchMocks.route).not.toHaveBeenCalled(); - expect(primaryDispatchMocks.stateful).not.toHaveBeenCalled(); - }); }); diff --git a/packages/extension/tests/extension/graphView/webview/resolve.test.ts b/packages/extension/tests/extension/graphView/webview/resolve.test.ts index 136e80d9f..9022cb235 100644 --- a/packages/extension/tests/extension/graphView/webview/resolve.test.ts +++ b/packages/extension/tests/extension/graphView/webview/resolve.test.ts @@ -36,50 +36,6 @@ describe('graphView/webview/resolve', () => { expect(visibilityHandler).toBeTypeOf('function'); }); - it('records timing markers around resolver startup work', () => { - const recordPerformanceEvent = vi.fn(); - const webviewView = { - visible: true, - webview: { - options: {}, - html: '', - }, - onDidChangeVisibility: vi.fn(() => ({ dispose: () => {} })), - }; - - resolveGraphViewWebviewView(webviewView as never, { - getLocalResourceRoots: () => ['/workspace'], - setWebviewMessageListener: vi.fn(), - getHtml: () => '
', - executeCommand: vi.fn(() => Promise.resolve()), - recordPerformanceEvent, - }); - - expect(recordPerformanceEvent.mock.calls.map(([name]) => name)).toEqual([ - 'graphWebview.resolve.start', - 'graphWebview.options.start', - 'graphWebview.options.end', - 'graphWebview.listener.start', - 'graphWebview.listener.end', - 'graphWebview.html.start', - 'graphWebview.html.assigned', - 'graphWebview.context.initial', - 'graphWebview.resolve.end', - ]); - expect(recordPerformanceEvent).toHaveBeenCalledWith( - 'graphWebview.resolve.start', - { visible: true }, - ); - expect(recordPerformanceEvent).toHaveBeenCalledWith( - 'graphWebview.options.end', - { localResourceRootCount: 1 }, - ); - expect(recordPerformanceEvent).toHaveBeenCalledWith( - 'graphWebview.html.assigned', - { htmlLength: 21 }, - ); - }); - it('updates visibility context without triggering reload work when the view becomes visible again', () => { const executeCommand = vi.fn(() => Promise.resolve()); let visibilityHandler: (() => void) | undefined; diff --git a/packages/extension/tests/extension/performance/marks.test.ts b/packages/extension/tests/extension/performance/marks.test.ts deleted file mode 100644 index 9cf76f2c3..000000000 --- a/packages/extension/tests/extension/performance/marks.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { mkdtemp, readFile, stat } from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { - EXTENSION_PERFORMANCE_LOG_PATH_ENV, - recordExtensionPerformanceEvent, -} from '../../../src/extension/performance/marks'; - -describe('extension/performance/marks', () => { - let originalLogPath: string | undefined; - - beforeEach(() => { - originalLogPath = process.env[EXTENSION_PERFORMANCE_LOG_PATH_ENV]; - vi.useFakeTimers(); - vi.setSystemTime(new Date('2026-06-22T12:00:00.123Z')); - }); - - afterEach(() => { - if (originalLogPath === undefined) { - delete process.env[EXTENSION_PERFORMANCE_LOG_PATH_ENV]; - } else { - process.env[EXTENSION_PERFORMANCE_LOG_PATH_ENV] = originalLogPath; - } - - vi.useRealTimers(); - }); - - it('does nothing when the extension performance log path is not configured', async () => { - delete process.env[EXTENSION_PERFORMANCE_LOG_PATH_ENV]; - const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'codegraphy-perf-')); - const logPath = path.join(tempRoot, 'extension-host.jsonl'); - - recordExtensionPerformanceEvent('graphWebview.resolve.start'); - - await expect(stat(logPath)).rejects.toMatchObject({ code: 'ENOENT' }); - }); - - it('appends one JSONL event per extension host performance marker', async () => { - const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'codegraphy-perf-')); - const logPath = path.join(tempRoot, 'nested', 'extension-host.jsonl'); - process.env[EXTENSION_PERFORMANCE_LOG_PATH_ENV] = logPath; - - recordExtensionPerformanceEvent('graphWebview.resolve.start', { - visible: true, - viewKind: 'graph', - }); - vi.setSystemTime(new Date('2026-06-22T12:00:00.456Z')); - recordExtensionPerformanceEvent('graphWebview.resolve.end'); - - const lines = (await readFile(logPath, 'utf8')).trim().split('\n'); - - expect(lines.map(line => JSON.parse(line))).toEqual([ - { - name: 'graphWebview.resolve.start', - at: 1782129600123, - detail: { - visible: true, - viewKind: 'graph', - }, - }, - { - name: 'graphWebview.resolve.end', - at: 1782129600456, - }, - ]); - }); -}); diff --git a/packages/extension/tests/extension/pipeline/service/discoveryFacade.test.ts b/packages/extension/tests/extension/pipeline/service/discoveryFacade.test.ts index d87ba7bd5..4a1f32b5e 100644 --- a/packages/extension/tests/extension/pipeline/service/discoveryFacade.test.ts +++ b/packages/extension/tests/extension/pipeline/service/discoveryFacade.test.ts @@ -22,10 +22,6 @@ import { rebuildWorkspacePipelineGraph, } from '../../../../src/extension/pipeline/service/runtime/run'; -const performanceMocks = vi.hoisted(() => ({ - recordExtensionPerformanceEvent: vi.fn(), -})); - vi.mock('../../../../src/extension/pipeline/service/runtime/discovery', () => ({ createWorkspacePipelineDiscoveryDependencies: vi.fn(), discoverWorkspacePipelineFilesWithWarnings: vi.fn(), @@ -50,10 +46,6 @@ vi.mock('node:child_process', () => ({ spawnSync: vi.fn(), })); -vi.mock('../../../../src/extension/performance/marks', () => ({ - recordExtensionPerformanceEvent: performanceMocks.recordExtensionPerformanceEvent, -})); - vi.mock('vscode', () => ({ workspace: { workspaceFolders: [{ uri: { fsPath: '/workspace' } }], @@ -143,7 +135,6 @@ describe('pipeline/service/discoveryFacade', () => { beforeEach(() => { vi.clearAllMocks(); - performanceMocks.recordExtensionPerformanceEvent.mockReset(); vi.mocked(spawnSync).mockReturnValue({ error: undefined, status: 1, @@ -600,15 +591,6 @@ describe('pipeline/service/discoveryFacade', () => { }), { disabledPlugins: new Set() }, ); - expect(performanceMocks.recordExtensionPerformanceEvent).toHaveBeenCalledWith( - 'workspacePipeline.loadCachedGraph.warmAnalysis', - expect.objectContaining({ - durationMs: expect.any(Number), - filePath: 'src/nested/cached.ts', - pluginIdCount: 1, - status: 'completed', - }), - ); }); it('does not warm cached source analysis when cached replay disables warm-up', async () => { @@ -653,10 +635,6 @@ describe('pipeline/service/discoveryFacade', () => { expect(facade._discovery.readContent).not.toHaveBeenCalled(); expect(analyzeFileResultForPlugins).not.toHaveBeenCalled(); - expect(performanceMocks.recordExtensionPerformanceEvent).not.toHaveBeenCalledWith( - 'workspacePipeline.loadCachedGraph.warmAnalysis', - expect.anything(), - ); }); it('skips cached analysis warm-up quietly when the selected file disappeared', async () => { @@ -698,15 +676,7 @@ describe('pipeline/service/discoveryFacade', () => { await facade.loadCachedGraph(); - await vi.waitFor(() => - expect(performanceMocks.recordExtensionPerformanceEvent).toHaveBeenCalledWith( - 'workspacePipeline.loadCachedGraph.warmAnalysis', - expect.objectContaining({ - filePath: 'src/gone.ts', - status: 'skipped', - }), - ), - ); + await vi.waitFor(() => expect(facade._discovery.readContent).toHaveBeenCalledOnce()); expect(analyzeFileResultForPlugins).not.toHaveBeenCalled(); expect(warnSpy).not.toHaveBeenCalled(); @@ -792,71 +762,4 @@ describe('pipeline/service/discoveryFacade', () => { expect(spawnSync).not.toHaveBeenCalled(); expect(discoveryState(facade)._lastGitIgnoredPaths).toEqual([]); }); - - it('records cached graph load stage performance markers', async () => { - const facade = new TestDiscoveryFacade(); - const cachedAnalysis = { - filePath: '/workspace/src/cached.ts', - relations: [], - }; - facade._cache = { - version: 'test', - files: { - 'src/cached.ts': { - mtime: 1, - analysis: cachedAnalysis, - }, - }, - } as never; - vi.spyOn( - facade as unknown as { - _buildGraphDataFromAnalysis: (...args: unknown[]) => unknown; - }, - '_buildGraphDataFromAnalysis', - ).mockReturnValue({ - nodes: [{ id: 'src/cached.ts', label: 'cached.ts', color: '#333333' }], - edges: [], - }); - - await facade.loadCachedGraph(); - - expect(performanceMocks.recordExtensionPerformanceEvent).toHaveBeenCalledWith( - 'workspacePipeline.loadCachedGraph.hydrate', - expect.objectContaining({ - durationMs: expect.any(Number), - fileCount: 1, - }), - ); - expect(performanceMocks.recordExtensionPerformanceEvent).toHaveBeenCalledWith( - 'workspacePipeline.loadCachedGraph.cacheSnapshot', - expect.objectContaining({ - durationMs: expect.any(Number), - fileCount: 1, - }), - ); - expect(performanceMocks.recordExtensionPerformanceEvent).toHaveBeenCalledWith( - 'workspacePipeline.loadCachedGraph.cachedDiscovery', - expect.objectContaining({ - directoryCount: 1, - durationMs: expect.any(Number), - fileCount: 1, - }), - ); - expect(performanceMocks.recordExtensionPerformanceEvent).toHaveBeenCalledWith( - 'workspacePipeline.loadCachedGraph.buildGraph', - expect.objectContaining({ - durationMs: expect.any(Number), - edgeCount: 0, - nodeCount: 1, - }), - ); - expect(performanceMocks.recordExtensionPerformanceEvent).toHaveBeenCalledWith( - 'workspacePipeline.loadCachedGraph.completed', - expect.objectContaining({ - durationMs: expect.any(Number), - edgeCount: 0, - nodeCount: 1, - }), - ); - }); }); diff --git a/packages/extension/tests/extension/pipeline/service/refreshFacade.test.ts b/packages/extension/tests/extension/pipeline/service/refreshFacade.test.ts index 2154faeef..47a5a9434 100644 --- a/packages/extension/tests/extension/pipeline/service/refreshFacade.test.ts +++ b/packages/extension/tests/extension/pipeline/service/refreshFacade.test.ts @@ -17,10 +17,6 @@ import { PLUGIN_SIGNATURE_KEY, } from '../../../../src/extension/gitHistory/cache/stateKeys'; -const performanceMocks = vi.hoisted(() => ({ - recordExtensionPerformanceEvent: vi.fn(), -})); - vi.mock('../../../../src/extension/pipeline/service/runtime/discovery', () => ({ createWorkspacePipelineDiscoveryDependencies: vi.fn(), discoverWorkspacePipelineFilesWithWarnings: vi.fn(), @@ -31,10 +27,6 @@ vi.mock('../../../../src/extension/pipeline/service/runtime/refresh', () => ({ refreshWorkspacePipelineChangedFiles: vi.fn(), })); -vi.mock('../../../../src/extension/performance/marks', () => ({ - recordExtensionPerformanceEvent: performanceMocks.recordExtensionPerformanceEvent, -})); - vi.mock('vscode', () => ({ workspace: { workspaceFolders: undefined, @@ -169,7 +161,6 @@ class TestRefreshFacade extends WorkspacePipelineRefreshFacade { describe('pipeline/service/refreshFacade', () => { beforeEach(() => { vi.clearAllMocks(); - performanceMocks.recordExtensionPerformanceEvent.mockReset(); vi.mocked(createWorkspacePipelineDiscoveryDependencies).mockReturnValue('discovery-deps' as never); vi.mocked(discoverWorkspacePipelineFilesWithWarnings).mockResolvedValue({ files: [{ absolutePath: '/workspace/src/a.ts', relativePath: 'src/a.ts' }], @@ -351,66 +342,6 @@ describe('pipeline/service/refreshFacade', () => { ]); }); - it('records phase timings for delegated changed-file refresh work', async () => { - const facade = new TestRefreshFacade(); - await facade.refreshChangedFiles(['/workspace/src/a.ts']); - const [refreshSource, refreshDependencies] = vi.mocked(refreshWorkspacePipelineChangedFiles).mock.calls[0]; - - await refreshDependencies.notifyFilesChanged([ - { absolutePath: '/workspace/src/a.ts', relativePath: 'src/a.ts', content: 'content:a' }, - ], '/workspace'); - refreshDependencies.persistCache(); - await refreshDependencies.persistIndexMetadata(); - await refreshSource._readAnalysisFiles([{ - absolutePath: '/workspace/src/a.ts', - relativePath: 'src/a.ts', - extension: '.ts', - name: 'a.ts', - }]); - await refreshSource._analyzeFiles([{ - absolutePath: '/workspace/src/a.ts', - relativePath: 'src/a.ts', - extension: '.ts', - name: 'a.ts', - }], '/workspace'); - refreshSource._buildGraphData(new Map(), '/workspace', new Set()); - refreshSource._buildGraphDataFromAnalysis(new Map(), '/workspace', new Set()); - await refreshSource.analyze(['*.ts'], new Set(), undefined, undefined); - refreshSource.invalidateWorkspaceFiles(['/workspace/src/a.ts']); - - const phaseCalls = performanceMocks.recordExtensionPerformanceEvent.mock.calls - .filter(([name]) => name === 'workspacePipeline.refreshChangedFiles.phase'); - expect(phaseCalls.map(([, detail]) => (detail as { phase: string }).phase)).toEqual([ - 'notifyFilesChanged', - 'persistCache', - 'persistIndexMetadata', - 'readAnalysisFiles', - 'analyzeFiles', - 'buildGraphData', - 'buildGraphDataFromAnalysis', - 'fullAnalyze', - 'invalidateWorkspaceFiles', - ]); - expect(phaseCalls).toContainEqual([ - 'workspacePipeline.refreshChangedFiles.phase', - expect.objectContaining({ - durationMs: expect.any(Number), - fileCount: 1, - phase: 'notifyFilesChanged', - }), - ]); - expect(phaseCalls).toContainEqual([ - 'workspacePipeline.refreshChangedFiles.phase', - expect.objectContaining({ - cacheHits: 0, - cacheMisses: 0, - durationMs: expect.any(Number), - fileCount: 1, - phase: 'analyzeFiles', - }), - ]); - }); - it('patches delegated graph metrics for file and symbol nodes', async () => { const facade = new TestRefreshFacade(); facade._cache = { diff --git a/packages/extension/tests/extension/pipeline/serviceAdapters.test.ts b/packages/extension/tests/extension/pipeline/serviceAdapters.test.ts index 80e30d579..f8d55ca0f 100644 --- a/packages/extension/tests/extension/pipeline/serviceAdapters.test.ts +++ b/packages/extension/tests/extension/pipeline/serviceAdapters.test.ts @@ -9,17 +9,9 @@ import { } from '../../../src/extension/pipeline/serviceAdapters'; import { CACHE_VERSION } from '../../../src/extension/gitHistory/cache/stateKeys'; -const performanceMocks = vi.hoisted(() => ({ - recordExtensionPerformanceEvent: vi.fn(), -})); - -vi.mock('../../../src/extension/performance/marks', () => ({ - recordExtensionPerformanceEvent: performanceMocks.recordExtensionPerformanceEvent, -})); - describe('pipeline/serviceAdapters', () => { beforeEach(() => { - performanceMocks.recordExtensionPerformanceEvent.mockReset(); + vi.clearAllMocks(); }); it('pre-analyzes files with shared registry and discovery adapters', async () => { @@ -122,59 +114,6 @@ describe('pipeline/serviceAdapters', () => { await expect(readWorkspacePipelineFileStat('/workspace/src/app.ts', fileSystem as never)).resolves.toEqual(stat); }); - it('records phase timings while analyzing workspace pipeline files', async () => { - const cache = { files: {} }; - const discovery = { - readContent: vi.fn(async () => 'content'), - readAsString: vi.fn(async () => 'content'), - readAsBytes: vi.fn(async () => new Uint8Array()), - }; - const registry = { - notifyPreAnalyze: vi.fn(async () => undefined), - analyzeFileResult: vi.fn(async () => ({ - filePath: '/workspace/src/app.ts', - relations: [], - })), - }; - - await analyzeWorkspacePipelineFiles( - cache as never, - discovery as never, - undefined, - registry as never, - vi.fn(async () => ({ mtime: 5, size: 12 })), - [{ absolutePath: '/workspace/src/app.ts', relativePath: 'src/app.ts' } as never], - '/workspace', - ); - - const phaseCalls = performanceMocks.recordExtensionPerformanceEvent.mock.calls - .filter(([name]) => name === 'workspacePipeline.analyzeFiles.phase'); - expect(phaseCalls.map(([, detail]) => (detail as { phase: string }).phase)).toEqual([ - 'getFileStat', - 'readContent', - 'notifyPreAnalyze', - 'preAnalyzeFiles', - 'readContent', - 'analyzeFileResult', - ]); - expect(phaseCalls).toContainEqual([ - 'workspacePipeline.analyzeFiles.phase', - expect.objectContaining({ - durationMs: expect.any(Number), - fileCount: 1, - phase: 'preAnalyzeFiles', - }), - ]); - expect(phaseCalls).toContainEqual([ - 'workspacePipeline.analyzeFiles.phase', - expect.objectContaining({ - durationMs: expect.any(Number), - phase: 'analyzeFileResult', - relationCount: 0, - }), - ]); - }); - it('builds graph nodes with valid cached git history churn counts', () => { const cache = { files: { diff --git a/packages/extension/tests/extension/workspaceFiles/refresh/scheduler.test.ts b/packages/extension/tests/extension/workspaceFiles/refresh/scheduler.test.ts index 1da67adb6..a45794a5d 100644 --- a/packages/extension/tests/extension/workspaceFiles/refresh/scheduler.test.ts +++ b/packages/extension/tests/extension/workspaceFiles/refresh/scheduler.test.ts @@ -1,14 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { scheduleWorkspaceRefresh } from '../../../../src/extension/workspaceFiles/refresh/scheduler'; -const performanceMocks = vi.hoisted(() => ({ - record: vi.fn(), -})); - -vi.mock('../../../../src/extension/performance/marks', () => ({ - recordExtensionPerformanceEvent: performanceMocks.record, -})); - function makeProvider() { return { refreshChangedFiles: vi.fn().mockResolvedValue(undefined), @@ -24,7 +16,6 @@ function makeProvider() { describe('workspaceFiles/refresh/scheduler', () => { beforeEach(() => { vi.clearAllMocks(); - performanceMocks.record.mockReset(); }); afterEach(() => { @@ -81,41 +72,6 @@ describe('workspaceFiles/refresh/scheduler', () => { consoleSpy.mockRestore(); }); - it('records schedule and fire timing markers', () => { - vi.useFakeTimers(); - const provider = makeProvider(); - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); - - scheduleWorkspaceRefresh( - provider as never, - '[CodeGraphy] File changed, refreshing graph', - ['/workspace/src/a.ts'], - 123, - ); - vi.advanceTimersByTime(123); - - expect(performanceMocks.record).toHaveBeenCalledWith( - 'workspaceRefresh.scheduled', - expect.objectContaining({ - delayMs: 123, - fileCount: 1, - fullRefresh: false, - gitignoreRefresh: false, - }), - ); - expect(performanceMocks.record).toHaveBeenCalledWith( - 'workspaceRefresh.started', - expect.objectContaining({ - delayMs: 123, - fileCount: 1, - fullRefresh: false, - gitignoreRefresh: false, - }), - ); - - consoleSpy.mockRestore(); - }); - it('queues a pending refresh instead of refreshing while the graph is closed', () => { vi.useFakeTimers(); const provider = makeProvider(); diff --git a/packages/extension/tests/extension/workspaceFiles/refresh/watchers.test.ts b/packages/extension/tests/extension/workspaceFiles/refresh/watchers.test.ts index 0fc6d169d..1efbf48a2 100644 --- a/packages/extension/tests/extension/workspaceFiles/refresh/watchers.test.ts +++ b/packages/extension/tests/extension/workspaceFiles/refresh/watchers.test.ts @@ -5,14 +5,6 @@ import { registerSaveHandler, } from '../../../../src/extension/workspaceFiles/refresh/watchers'; -const performanceMocks = vi.hoisted(() => ({ - record: vi.fn(), -})); - -vi.mock('../../../../src/extension/performance/marks', () => ({ - recordExtensionPerformanceEvent: performanceMocks.record, -})); - function makeProvider() { return { emitEvent: vi.fn(), @@ -137,7 +129,6 @@ function uri(filePath: string): vscode.Uri { describe('workspaceFiles/refresh/watchers', () => { beforeEach(() => { vi.clearAllMocks(); - performanceMocks.record.mockReset(); installFileSystemWatcher(); }); @@ -201,10 +192,6 @@ describe('workspaceFiles/refresh/watchers', () => { expect(provider.emitEvent).toHaveBeenCalledWith('workspace:fileChanged', { filePath: '/workspace/src/app.ts', }); - expect(performanceMocks.record).toHaveBeenCalledWith( - 'workspaceFiles.savedDocument.received', - { filePath: '/workspace/src/app.ts' }, - ); }); it('suppresses file-system change duplicates after saved document refreshes', () => { diff --git a/packages/extension/tests/webview/app/performance/marks.test.ts b/packages/extension/tests/webview/app/performance/marks.test.ts deleted file mode 100644 index a0ff454a1..000000000 --- a/packages/extension/tests/webview/app/performance/marks.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest'; -import { - measureWebviewPerformance, - recordWebviewPerformanceEvent, -} from '../../../../src/webview/performance/marks'; - -describe('webview/performance/marks', () => { - afterEach(() => { - window.__codegraphyPerformance = undefined; - }); - - it('does not record events until the sink is enabled', () => { - window.__codegraphyPerformance = { enabled: false, events: [] }; - - recordWebviewPerformanceEvent('visibleGraph.derive'); - - expect(window.__codegraphyPerformance.events).toEqual([]); - }); - - it('records enabled events and bounds the event list', () => { - window.__codegraphyPerformance = { enabled: true, events: [], limit: 1 }; - - recordWebviewPerformanceEvent('first', { count: 1 }); - recordWebviewPerformanceEvent('second', { count: 2 }, 12.345); - - expect(window.__codegraphyPerformance.events).toEqual([ - expect.objectContaining({ - detail: { count: 2 }, - durationMs: 12.35, - name: 'second', - }), - ]); - }); - - it('measures a callback while preserving the returned value', () => { - window.__codegraphyPerformance = { enabled: true, events: [] }; - - const result = measureWebviewPerformance('graphRuntime.buildGraphData', { nodeCount: 2 }, () => 'done'); - - expect(result).toBe('done'); - expect(window.__codegraphyPerformance.events).toEqual([ - expect.objectContaining({ - detail: { nodeCount: 2 }, - name: 'graphRuntime.buildGraphData', - }), - ]); - expect(window.__codegraphyPerformance.events?.[0]?.durationMs).toEqual(expect.any(Number)); - }); -}); diff --git a/packages/extension/tests/webview/app/shell/messageListener.test.ts b/packages/extension/tests/webview/app/shell/messageListener.test.ts index eb0d71dbe..93c6b41fa 100644 --- a/packages/extension/tests/webview/app/shell/messageListener.test.ts +++ b/packages/extension/tests/webview/app/shell/messageListener.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { graphStore } from '../../../../src/webview/store/state'; import { createMessageHandler, setupMessageListener, type InjectAssetsParams } from '../../../../src/webview/app/shell/messageListener'; import type { WebviewPluginHost } from '../../../../src/webview/pluginHost/manager'; @@ -23,11 +23,6 @@ describe('app message listener', () => { }).__codegraphyWebviewPageId; }); - afterEach(() => { - vi.restoreAllMocks(); - window.__codegraphyPerformance = undefined; - }); - it('ignores invalid window messages', () => { const injectPluginAssets = vi.fn<(_params: InjectAssetsParams) => Promise>().mockResolvedValue(); const pluginHost = { deliverMessage: vi.fn() } as unknown as WebviewPluginHost; @@ -261,29 +256,6 @@ describe('app message listener', () => { expect(pluginHost.deliverMessage).not.toHaveBeenCalled(); }); - it('records inbound extension messages for performance traces', () => { - window.__codegraphyPerformance = { enabled: true, events: [] }; - const injectPluginAssets = vi.fn<(_params: InjectAssetsParams) => Promise>().mockResolvedValue(); - const pluginHost = { deliverMessage: vi.fn() } as unknown as WebviewPluginHost; - const handleExtensionMessage = vi.fn(); - vi.spyOn(graphStore, 'getState').mockReturnValue({ - handleExtensionMessage, - } as unknown as ReturnType); - - const handler = createMessageHandler(injectPluginAssets, pluginHost); - const message = { type: 'APP_BOOTSTRAP_COMPLETE', payload: null }; - - handler({ data: message } as MessageEvent); - - expect(handleExtensionMessage).toHaveBeenCalledWith(message); - expect(window.__codegraphyPerformance.events).toEqual([ - expect.objectContaining({ - name: 'extensionMessage.received', - detail: { type: 'APP_BOOTSTRAP_COMPLETE' }, - }), - ]); - }); - it('registers the window listener and posts WEBVIEW_READY', () => { const injectPluginAssets = vi.fn<(_params: InjectAssetsParams) => Promise>().mockResolvedValue(); const pluginHost = { deliverMessage: vi.fn() } as unknown as WebviewPluginHost; diff --git a/packages/extension/tests/webview/app/shell/messageListener/ready.test.ts b/packages/extension/tests/webview/app/shell/messageListener/ready.test.ts index b29ca3034..dda0bdd97 100644 --- a/packages/extension/tests/webview/app/shell/messageListener/ready.test.ts +++ b/packages/extension/tests/webview/app/shell/messageListener/ready.test.ts @@ -12,7 +12,6 @@ describe('app/shell/messageListener/ready', () => { beforeEach(() => { vi.restoreAllMocks(); vi.clearAllMocks(); - window.__codegraphyPerformance = undefined; delete (window as Window & { __codegraphyWebviewReadyPosted?: boolean; __codegraphyWebviewPageId?: string; @@ -37,13 +36,4 @@ describe('app/shell/messageListener/ready', () => { }); }); - it('records when the webview ready handshake is posted', () => { - window.__codegraphyPerformance = { enabled: true, events: [] }; - - postWebviewReadyOnce(window); - - expect(window.__codegraphyPerformance.events).toEqual([ - expect.objectContaining({ name: 'webview.ready.posted' }), - ]); - }); }); diff --git a/packages/extension/tests/webview/app/shell/view.test.tsx b/packages/extension/tests/webview/app/shell/view.test.tsx index 11eea6e5f..8322957ba 100644 --- a/packages/extension/tests/webview/app/shell/view.test.tsx +++ b/packages/extension/tests/webview/app/shell/view.test.tsx @@ -138,9 +138,7 @@ describe('App', () => { expect(screen.getByTitle('Graph Scope')).toBeInTheDocument(); }); - it('does not derive the visible graph while startup loading hides the graph', async () => { - window.__codegraphyPerformance = { enabled: true, events: [] }; - + it('applies queued graph and filter updates after startup bootstrap completes', async () => { render(); await act(async () => { @@ -164,24 +162,12 @@ describe('App', () => { }); expect(screen.getByText('Loading graph...')).toBeInTheDocument(); - expect(window.__codegraphyPerformance.events).not.toContainEqual( - expect.objectContaining({ - name: 'visibleGraph.derive', - detail: expect.objectContaining({ nodeCount: 1 }), - }), - ); await act(async () => { sendMessage({ type: 'APP_BOOTSTRAP_COMPLETE' }); }); expect(screen.getByText('1 node • 0 connections')).toBeInTheDocument(); - expect(window.__codegraphyPerformance.events).toContainEqual( - expect.objectContaining({ - name: 'visibleGraph.derive', - detail: expect.objectContaining({ filterPatternCount: 1, nodeCount: 1 }), - }), - ); }); it('keeps the first graph visible while startup plugin assets finish loading', async () => { diff --git a/packages/extension/tests/webview/store/messageHandlers/graph.test.ts b/packages/extension/tests/webview/store/messageHandlers/graph.test.ts index 7852f6cca..420605e08 100644 --- a/packages/extension/tests/webview/store/messageHandlers/graph.test.ts +++ b/packages/extension/tests/webview/store/messageHandlers/graph.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { handleActiveFileUpdated, handleAppBootstrapComplete, @@ -94,10 +94,6 @@ function createState( } describe('webview/store/messageHandlers/graph', () => { - afterEach(() => { - window.__codegraphyPerformance = undefined; - }); - it('maps graph payload updates into loading and indexing state', () => { const payload = { nodes: [{ id: 'src/app.ts', label: 'App', color: '#fff' }], edges: [] }; @@ -109,26 +105,6 @@ describe('webview/store/messageHandlers/graph', () => { }); }); - it('records graph payload receipt for startup timing', () => { - window.__codegraphyPerformance = { enabled: true, events: [] }; - const payload = { - nodes: [ - { id: 'src/app.ts', label: 'App', color: '#fff' }, - { id: 'src/lib.ts', label: 'Lib', color: '#fff' }, - ], - edges: [{ id: 'src/app.ts->src/lib.ts', from: 'src/app.ts', to: 'src/lib.ts', kind: 'import' as const, sources: [] }], - }; - - handleGraphDataUpdated({ type: 'GRAPH_DATA_UPDATED', payload }); - - expect(window.__codegraphyPerformance.events).toEqual([ - expect.objectContaining({ - detail: { edgeCount: 1, nodeCount: 2 }, - name: 'extensionMessage.graphDataUpdated', - }), - ]); - }); - it('applies node metric patches to the current graph data', () => { const graphData = { nodes: [ @@ -202,7 +178,6 @@ describe('webview/store/messageHandlers/graph', () => { }); it('skips duplicate graph payloads after bootstrap has settled', () => { - window.__codegraphyPerformance = { enabled: true, events: [] }; const payload = { nodes: [{ id: 'src/app.ts', label: 'App', color: '#fff' }], edges: [{ id: 'src/app.ts->src/lib.ts', from: 'src/app.ts', to: 'src/lib.ts', kind: 'import' as const, sources: [] }], @@ -218,15 +193,9 @@ describe('webview/store/messageHandlers/graph', () => { { type: 'GRAPH_DATA_UPDATED', payload }, { getState: () => state }, )).toBeUndefined(); - - expect(window.__codegraphyPerformance.events).toEqual([ - expect.objectContaining({ name: 'extensionMessage.graphDataUpdated' }), - expect.objectContaining({ name: 'extensionMessage.graphDataSkipped' }), - ]); }); it('skips duplicate graph payloads while waiting for initial bootstrap completion', () => { - window.__codegraphyPerformance = { enabled: true, events: [] }; const payload = { nodes: [{ id: 'src/app.ts', label: 'App', color: '#fff' }], edges: [{ id: 'src/app.ts->src/lib.ts', from: 'src/app.ts', to: 'src/lib.ts', kind: 'import' as const, sources: [] }], @@ -243,11 +212,6 @@ describe('webview/store/messageHandlers/graph', () => { { type: 'GRAPH_DATA_UPDATED', payload }, { getState: () => state }, )).toBeUndefined(); - - expect(window.__codegraphyPerformance.events).toEqual([ - expect.objectContaining({ name: 'extensionMessage.graphDataUpdated' }), - expect.objectContaining({ name: 'extensionMessage.graphDataSkipped' }), - ]); }); it('settles initial bootstrap when graph data arrives after bootstrap and plugin assets are ready', () => { @@ -288,25 +252,6 @@ describe('webview/store/messageHandlers/graph', () => { }); }); - it('records app bootstrap completion for startup timing', () => { - window.__codegraphyPerformance = { enabled: true, events: [] }; - const state = createState({ - graphData: { nodes: [{ id: 'src/app.ts', label: 'App', color: '#fff' }], edges: [] }, - }); - - handleAppBootstrapComplete( - { type: 'APP_BOOTSTRAP_COMPLETE' }, - { getState: () => state }, - ); - - expect(window.__codegraphyPerformance.events).toEqual([ - expect.objectContaining({ - detail: { graphReady: true }, - name: 'extensionMessage.appBootstrapComplete', - }), - ]); - }); - it('settles initial bootstrap when graph data and app bootstrap are ready while plugin assets continue loading', () => { const state = createState({ graphData: { nodes: [{ id: 'src/app.ts', label: 'App', color: '#fff' }], edges: [] }, diff --git a/scripts/performance/measure-codegraphy-monorepo.mjs b/scripts/performance/measure-codegraphy-monorepo.mjs deleted file mode 100644 index 4505d8f63..000000000 --- a/scripts/performance/measure-codegraphy-monorepo.mjs +++ /dev/null @@ -1,219 +0,0 @@ -import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const DEFAULT_OUTPUT_PATH = 'reports/performance/monorepo-latest.json'; - -function createMetricsRecord({ workspacePath, measurements }) { - return { - version: 1, - recordedAt: new Date().toISOString(), - workspacePath: path.resolve(workspacePath), - measurements: { ...measurements }, - }; -} - -function roundMs(value) { - return Math.round(value); -} - -function readPercentile(sortedSamples, percentile) { - if (sortedSamples.length === 0) { - return 0; - } - - const rank = Math.ceil((percentile / 100) * sortedSamples.length); - return sortedSamples[Math.max(0, Math.min(sortedSamples.length - 1, rank - 1))]; -} - -export function summarizeDurations(samples) { - const sortedSamples = [...samples].sort((left, right) => left - right); - const midpoint = Math.floor(sortedSamples.length / 2); - const median = sortedSamples.length % 2 === 0 - ? (sortedSamples[midpoint - 1] + sortedSamples[midpoint]) / 2 - : sortedSamples[midpoint]; - - return { - iterations: sortedSamples.length, - minMs: roundMs(sortedSamples[0] ?? 0), - medianMs: roundMs(median ?? 0), - p95Ms: roundMs(readPercentile(sortedSamples, 95)), - maxMs: roundMs(sortedSamples.at(-1) ?? 0), - }; -} - -export async function writeMetrics({ outputPath, workspacePath, measurements }) { - const metrics = createMetricsRecord({ workspacePath, measurements }); - await mkdir(path.dirname(outputPath), { recursive: true }); - await writeFile(outputPath, `${JSON.stringify(metrics, null, 2)}\n`); - return metrics; -} - -function parseJsonResult(logText) { - const jsonStart = logText.lastIndexOf('\n{'); - if (jsonStart < 0) { - return {}; - } - - const jsonEnd = logText.indexOf('\n}', jsonStart); - if (jsonEnd < 0) { - return {}; - } - - try { - return JSON.parse(logText.slice(jsonStart + 1, jsonEnd + 2)); - } catch { - return {}; - } -} - -function parseTimeSummary(logText) { - const match = logText.match(/^\s*([\d.]+)\s+real\s+([\d.]+)\s+user\s+([\d.]+)\s+sys$/m); - if (!match) { - return {}; - } - - return { - coldIndexMs: Math.round(Number(match[1]) * 1000), - coldIndexUserMs: Math.round(Number(match[2]) * 1000), - coldIndexSystemMs: Math.round(Number(match[3]) * 1000), - }; -} - -function parseMemorySummary(logText) { - const maxResidentMatch = logText.match(/^\s*(\d+)\s+maximum resident set size$/m); - const peakMemoryMatch = logText.match(/^\s*(\d+)\s+peak memory footprint$/m); - - return { - ...(maxResidentMatch ? { maxResidentSetBytes: Number(maxResidentMatch[1]) } : {}), - ...(peakMemoryMatch ? { peakMemoryFootprintBytes: Number(peakMemoryMatch[1]) } : {}), - }; -} - -function parseLogScalar(value) { - if (/^-?\d+(?:\.\d+)?$/.test(value)) { - return Number(value); - } - - if (value === 'true') { - return true; - } - - if (value === 'false') { - return false; - } - - return value; -} - -function parsePhaseDetail(detail) { - const separatorIndex = detail.indexOf('='); - if (separatorIndex < 0) { - return {}; - } - - const key = detail.slice(0, separatorIndex).trim(); - const value = detail.slice(separatorIndex + 1).trim(); - if (!key) { - return {}; - } - - return { [key]: parseLogScalar(value) }; -} - -function parseIndexPhaseSummaries(logText) { - const indexPhases = {}; - const phaseLinePattern = /^\[CodeGraphy\] Indexing phase complete: (.+)$/gm; - for (const match of logText.matchAll(phaseLinePattern)) { - const phaseMetrics = match[1] - .split(',') - .map(detail => parsePhaseDetail(detail)) - .reduce((merged, detail) => ({ ...merged, ...detail }), {}); - if (typeof phaseMetrics.phase === 'string') { - indexPhases[phaseMetrics.phase] = phaseMetrics; - } - } - - return Object.keys(indexPhases).length > 0 ? { indexPhases } : {}; -} - -async function readGraphCacheBytes(workspacePath, graphCache) { - if (typeof graphCache !== 'string' || graphCache.length === 0) { - return {}; - } - - try { - const graphCachePath = path.resolve(workspacePath, graphCache); - return { graphCacheBytes: (await stat(graphCachePath)).size }; - } catch { - return {}; - } -} - -export async function readColdIndexLogMetrics({ logPath, workspacePath }) { - const logText = await readFile(logPath, 'utf8'); - const result = parseJsonResult(logText); - const graphCache = result.graphCache; - - return { - ...parseTimeSummary(logText), - ...parseMemorySummary(logText), - ...parseIndexPhaseSummaries(logText), - ...(typeof result.files === 'number' ? { fileCount: result.files } : {}), - ...(typeof result.nodes === 'number' ? { nodeCount: result.nodes } : {}), - ...(typeof result.edges === 'number' ? { edgeCount: result.edges } : {}), - ...(typeof graphCache === 'string' ? { graphCache } : {}), - ...(typeof graphCache === 'string' - ? await readGraphCacheBytes(workspacePath, graphCache) - : {}), - }; -} - -function readOptionValue(args, name) { - const index = args.indexOf(name); - return index >= 0 ? args[index + 1] : undefined; -} - -function hasFlag(args, name) { - return args.includes(name); -} - -function printUsage() { - process.stdout.write([ - 'Usage:', - ' node scripts/performance/measure-codegraphy-monorepo.mjs --index-log [--workspace ] [--output ]', - '', - 'Writes a bounded JSON metric summary. Raw logs stay under reports/performance/.', - ].join('\n')); -} - -async function runCli(argv) { - if (hasFlag(argv, '--help')) { - printUsage(); - return; - } - - const workspacePath = readOptionValue(argv, '--workspace') ?? process.cwd(); - const outputPath = readOptionValue(argv, '--output') ?? DEFAULT_OUTPUT_PATH; - const indexLogPath = readOptionValue(argv, '--index-log'); - - if (!indexLogPath) { - throw new Error('Missing required --index-log '); - } - - const measurements = await readColdIndexLogMetrics({ - logPath: indexLogPath, - workspacePath, - }); - await writeMetrics({ outputPath, workspacePath, measurements }); -} - -const isDirectRun = process.argv[1] - && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); - -if (isDirectRun) { - runCli(process.argv.slice(2)).catch((error) => { - process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); - process.exitCode = 1; - }); -} diff --git a/scripts/performance/measure-visible-graph-monorepo.mjs b/scripts/performance/measure-visible-graph-monorepo.mjs deleted file mode 100644 index cd2c3ee77..000000000 --- a/scripts/performance/measure-visible-graph-monorepo.mjs +++ /dev/null @@ -1,537 +0,0 @@ -import { Buffer } from 'node:buffer'; -import { existsSync, readFileSync } from 'node:fs'; -import { performance } from 'node:perf_hooks'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { - summarizeDurations, - writeMetrics, -} from './measure-codegraphy-monorepo.mjs'; - -function unwrapModule(module) { - return module.default ?? module; -} - -const [ - graphDataModule, - graphCacheStorageModule, - analysisFactsModule, - activityStateModule, - installedPluginCacheModule, - settingsStorageModule, - settingsDefaultsModule, - indexingRegistryModule, - visibleGraphModule, - edgeTypesModule, - nodeTypesModule, - visibleGraphConfigModule, - nodeVisibilityDefaultsModule, - materialExtensionMatchModule, - materialPathMatchModule, - materialFileGroupsModule, - materialFolderGroupsModule, - materialGroupsModule, - symbolGroupsModule, - mergedGroupsModule, - legendRulesModule, - graphControlsRegistryModule, - graphControlsSnapshotModule, - visibleGraphModelModule, -] = await Promise.all([ - import('../../packages/core/src/graph/data.ts').then(unwrapModule), - import('../../packages/core/src/graphCache/database/storage.ts').then(unwrapModule), - import('../../packages/core/src/plugins/activityState/analysisFacts.ts').then(unwrapModule), - import('../../packages/core/src/plugins/activityState/model.ts').then(unwrapModule), - import('../../packages/core/src/plugins/installedPluginCache/storage.ts').then(unwrapModule), - import('../../packages/core/src/workspace/settingsStorage.ts').then(unwrapModule), - import('../../packages/core/src/workspace/settingsDefaults.ts').then(unwrapModule), - import('../../packages/core/src/indexing/registry.ts').then(unwrapModule), - import('../../packages/extension/src/shared/visibleGraph/index.ts').then(unwrapModule), - import('../../packages/extension/src/shared/graphControls/defaults/edgeTypes.ts').then(unwrapModule), - import('../../packages/extension/src/shared/graphControls/defaults/nodeTypes.ts').then(unwrapModule), - import('../../packages/extension/src/webview/search/visibleGraphConfig.ts').then(unwrapModule), - import('../../packages/extension/src/shared/graphControls/defaults/maps.ts').then(unwrapModule), - import('../../packages/extension/src/extension/graphView/groups/defaults/materialTheme/extensionMatch.ts').then(unwrapModule), - import('../../packages/extension/src/extension/graphView/groups/defaults/materialTheme/pathMatch.ts').then(unwrapModule), - import('../../packages/extension/src/extension/graphView/groups/defaults/materialTheme/files.ts').then(unwrapModule), - import('../../packages/extension/src/extension/graphView/groups/defaults/materialTheme/folders.ts').then(unwrapModule), - import('../../packages/extension/src/extension/graphView/groups/defaults/materialTheme/groups.ts').then(unwrapModule), - import('../../packages/extension/src/extension/graphView/groups/defaults/symbols.ts').then(unwrapModule), - import('../../packages/extension/src/extension/graphView/groups/merged.ts').then(unwrapModule), - import('../../packages/extension/src/webview/search/filtering/rules.ts').then(unwrapModule), - import('../../packages/extension/src/extension/graphView/controls/send/definitions/registry.ts').then(unwrapModule), - import('../../packages/extension/src/extension/graphView/controls/send/definitions/snapshot.ts').then(unwrapModule), - import('../../packages/extension/src/shared/visibleGraph/model.ts').then(unwrapModule), -]); - -const { buildWorkspaceGraphDataFromAnalysis } = graphDataModule; -const { loadWorkspaceAnalysisDatabaseCache } = graphCacheStorageModule; -const { filterInactivePluginFileAnalysis } = analysisFactsModule; -const { createDisabledPluginSet, createPluginActivityState } = activityStateModule; -const { readCodeGraphyInstalledPluginCache } = installedPluginCacheModule; -const { readCodeGraphyWorkspaceSettings } = settingsStorageModule; -const { CODEGRAPHY_MARKDOWN_PLUGIN_ID } = settingsDefaultsModule; -const { createWorkspaceIndexRegistry } = indexingRegistryModule; -const { deriveVisibleGraph } = visibleGraphModule; -const { CORE_GRAPH_EDGE_TYPES } = edgeTypesModule; -const { CORE_GRAPH_NODE_TYPES } = nodeTypesModule; -const { buildVisibleGraphConfig } = visibleGraphConfigModule; -const { createDefaultNodeVisibility } = nodeVisibilityDefaultsModule; -const { createMaterialExtensionMatcher } = materialExtensionMatchModule; -const { createMaterialPathRuleMatcher } = materialPathMatchModule; -const { collectMaterialFileGroups } = materialFileGroupsModule; -const { collectMaterialFolderGroups } = materialFolderGroupsModule; -const { getManualGroups, sortMaterialGroups } = materialGroupsModule; -const { getSymbolDefaultGroups } = symbolGroupsModule; -const { buildGraphViewMergedGroups } = mergedGroupsModule; -const { applyLegendRules } = legendRulesModule; -const { readEdgeTypes, readGraphScopeCapabilities, readNodeTypes } = graphControlsRegistryModule; -const { captureGraphControlsSnapshot } = graphControlsSnapshotModule; -const { isFileNode } = visibleGraphModelModule; - -const DEFAULT_OUTPUT_PATH = 'reports/performance/visible-graph-latest.json'; -const DEFAULT_ITERATIONS = 40; -const DEFAULT_WARMUP_ITERATIONS = 5; -const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); -const MATERIAL_THEME_MANIFEST_PATH = path.join( - REPO_ROOT, - 'packages', - 'extension', - 'node_modules', - 'material-icon-theme', - 'dist', - 'material-icons.json', -); - -function readRawWorkspaceSettings(workspaceRoot) { - try { - const parsed = JSON.parse(readFileSync( - path.join(workspaceRoot, '.codegraphy', 'settings.json'), - 'utf8', - )); - return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}; - } catch { - return {}; - } -} - -function readBenchmarkWorkspaceSettings(workspaceRoot) { - return { - ...readCodeGraphyWorkspaceSettings(workspaceRoot), - ...readRawWorkspaceSettings(workspaceRoot), - }; -} - -function readOptionValue(args, name) { - const index = args.indexOf(name); - return index >= 0 ? args[index + 1] : undefined; -} - -function hasFlag(args, name) { - return args.includes(name); -} - -function toPositiveInteger(value, defaultValue) { - if (!value) { - return defaultValue; - } - - const parsed = Number(value); - return Number.isInteger(parsed) && parsed > 0 ? parsed : defaultValue; -} - -function collectDirectoryPaths(filePaths) { - const directories = new Set(); - - for (const filePath of filePaths) { - let directory = path.posix.dirname(filePath.replace(/\\/g, '/')); - while (directory && directory !== '.') { - directories.add(directory); - directory = path.posix.dirname(directory); - } - } - - return [...directories].sort(); -} - -function createActivePluginSet(settings, userHomeDir) { - const installedPluginCache = readCodeGraphyInstalledPluginCache({ - ...(userHomeDir ? { homeDir: userHomeDir } : {}), - }); - const activityState = createPluginActivityState({ - settings, - installedPlugins: installedPluginCache.plugins, - builtInPluginIds: [CODEGRAPHY_MARKDOWN_PLUGIN_ID], - }); - return new Set(activityState.activePluginIds); -} - -function createPluginDefaultGroup(pluginInfo, pattern, value) { - const group = { - id: `plugin:${pluginInfo.plugin.id}:${pattern}`, - pattern, - color: typeof value === 'string' ? value : value.color, - isPluginDefault: true, - pluginId: pluginInfo.plugin.id, - pluginName: pluginInfo.plugin.name, - }; - - if (typeof value === 'object') { - if (value.shape2D) group.shape2D = value.shape2D; - if (value.shape3D) group.shape3D = value.shape3D; - if (value.imagePath) { - group.imagePath = value.imagePath; - group.imageUrl = value.imagePath; - } - } - - return group; -} - -function getPluginDefaultGroups(registry, disabledPlugins) { - const result = []; - const addedIds = new Set(); - - for (const pluginInfo of registry.list()) { - if (disabledPlugins.has(pluginInfo.plugin.id)) continue; - const fileColors = pluginInfo.plugin.fileColors; - if (!fileColors) continue; - - for (const [pattern, value] of Object.entries(fileColors)) { - const id = `plugin:${pluginInfo.plugin.id}:${pattern}`; - if (addedIds.has(id)) continue; - - result.push(createPluginDefaultGroup(pluginInfo, pattern, value)); - addedIds.add(id); - } - } - - return result; -} - -function loadMaterialTheme() { - if (!existsSync(MATERIAL_THEME_MANIFEST_PATH)) { - return null; - } - - const manifest = JSON.parse(readFileSync(MATERIAL_THEME_MANIFEST_PATH, 'utf8')); - return { - extensionMatcher: manifest.fileExtensions - ? createMaterialExtensionMatcher(manifest.fileExtensions) - : undefined, - iconDataByName: new Map(), - manifest, - manifestPath: MATERIAL_THEME_MANIFEST_PATH, - pathMatchers: { - fileNames: manifest.fileNames - ? createMaterialPathRuleMatcher(manifest.fileNames) - : undefined, - folderNames: manifest.folderNames - ? createMaterialPathRuleMatcher(manifest.folderNames) - : undefined, - folderNamesExpanded: manifest.folderNamesExpanded - ? createMaterialPathRuleMatcher(manifest.folderNamesExpanded) - : undefined, - }, - }; -} - -function getMaterialThemeDefaultGroups(graphData, settings) { - const theme = loadMaterialTheme(); - if (!theme) { - return []; - } - - const defaultNodeVisibility = createDefaultNodeVisibility(); - const includeFolderMatches = - settings.nodeVisibility?.folder ?? defaultNodeVisibility.folder; - const groupsById = new Map(); - - for (const group of collectMaterialFileGroups(graphData, theme)) { - groupsById.set(group.id, group); - } - - if (includeFolderMatches) { - for (const group of collectMaterialFolderGroups(graphData, theme)) { - groupsById.set(group.id, group); - } - } - - for (const group of getManualGroups()) { - groupsById.set(group.id, group); - } - - return sortMaterialGroups([...groupsById.values()]); -} - -function getBuiltInDefaultGroups(graphData, settings) { - return [ - ...getMaterialThemeDefaultGroups(graphData, settings), - ...getSymbolDefaultGroups(graphData), - ]; -} - -function getResolvedLegendGroups({ graphData, registry, disabledPlugins, settings }) { - return buildGraphViewMergedGroups( - settings.legend ?? [], - getBuiltInDefaultGroups(graphData, settings), - getPluginDefaultGroups(registry, disabledPlugins), - settings.legendVisibility ?? {}, - settings.legendOrder ?? [], - ); -} - -function createSettingsConfiguration(settings) { - return { - get(key, defaultValue) { - return settings[key] ?? defaultValue; - }, - }; -} - -function captureGraphControls({ graphData, registry, disabledPlugins, settings }) { - const filePaths = graphData.nodes - .filter(isFileNode) - .map((node) => node.id); - - return captureGraphControlsSnapshot( - createSettingsConfiguration(settings), - graphData, - readNodeTypes(registry, disabledPlugins), - readEdgeTypes(registry, disabledPlugins), - readGraphScopeCapabilities(registry, filePaths, disabledPlugins), - ); -} - -async function buildGraphDataFromGraphCache(workspacePath, userHomeDir) { - const workspaceRoot = path.resolve(workspacePath); - const settings = readBenchmarkWorkspaceSettings(workspaceRoot); - const disabledPlugins = createDisabledPluginSet(settings); - const activePluginIds = createActivePluginSet(settings, userHomeDir); - const { registry } = await createWorkspaceIndexRegistry( - { userHomeDir }, - settings, - workspaceRoot, - disabledPlugins, - ); - const pluginFilterPatterns = registry.getPluginFilterPatterns(disabledPlugins); - const cache = loadWorkspaceAnalysisDatabaseCache(workspaceRoot); - const fileAnalysis = new Map( - Object.entries(cache.files).map(([filePath, entry]) => [filePath, entry.analysis]), - ); - const graphBuildStartedAt = performance.now(); - const graphData = buildWorkspaceGraphDataFromAnalysis({ - cacheFiles: cache.files, - churnCounts: {}, - directoryPaths: collectDirectoryPaths(Object.keys(cache.files)), - disabledPlugins, - fileAnalysis: filterInactivePluginFileAnalysis(fileAnalysis, activePluginIds), - getPluginForFile: () => undefined, - nodeVisibility: settings.nodeVisibility, - showOrphans: settings.showOrphans, - workspaceRoot, - }); - - return { - graphData, - graphControls: captureGraphControls({ - graphData, - registry, - disabledPlugins, - settings, - }), - legends: getResolvedLegendGroups({ - graphData, - registry, - disabledPlugins, - settings, - }), - warmCacheGraphBuildMs: Math.round(performance.now() - graphBuildStartedAt), - settings, - pluginFilterPatterns, - }; -} - -function createActiveFilterPatterns(settings, pluginFilterPatterns = []) { - const disabledCustomPatterns = new Set(settings.disabledCustomFilterPatterns ?? []); - const disabledPluginPatterns = new Set(settings.disabledPluginFilterPatterns ?? []); - return [ - ...pluginFilterPatterns.filter(pattern => !disabledPluginPatterns.has(pattern)), - ...(settings.filterPatterns ?? []).filter(pattern => !disabledCustomPatterns.has(pattern)), - ]; -} - -function createVisibleGraphScenarioConfig(settings, pluginFilterPatterns, graphControls, overrides = {}) { - const nodeVisibility = { - ...(settings.nodeVisibility ?? {}), - ...(overrides.nodeVisibility ?? {}), - }; - const edgeVisibility = { - ...(settings.edgeVisibility ?? {}), - ...(overrides.edgeVisibility ?? {}), - }; - - return buildVisibleGraphConfig({ - edgeTypes: graphControls?.edgeTypes ?? CORE_GRAPH_EDGE_TYPES, - edgeVisibility, - filterPatterns: overrides.filterPatterns ?? createActiveFilterPatterns(settings, pluginFilterPatterns), - nodeTypes: graphControls?.nodeTypes ?? CORE_GRAPH_NODE_TYPES, - nodeVisibility, - searchOptions: overrides.searchOptions ?? { matchCase: false, wholeWord: false, regex: false }, - searchQuery: overrides.searchQuery ?? '', - showOrphans: overrides.showOrphans ?? settings.showOrphans ?? true, - }); -} - -function createVisibleGraphScenarios(settings, pluginFilterPatterns, graphControls) { - return { - current: createVisibleGraphScenarioConfig(settings, pluginFilterPatterns, graphControls), - noFilters: createVisibleGraphScenarioConfig(settings, pluginFilterPatterns, graphControls, { filterPatterns: [] }), - foldersOn: createVisibleGraphScenarioConfig(settings, pluginFilterPatterns, graphControls, { - nodeVisibility: { folder: true }, - }), - importsOff: createVisibleGraphScenarioConfig(settings, pluginFilterPatterns, graphControls, { - edgeVisibility: { import: false }, - }), - searchGraph: createVisibleGraphScenarioConfig(settings, pluginFilterPatterns, graphControls, { - searchQuery: 'graph', - }), - }; -} - -function measureVisibleGraphScenario(graphData, config, options) { - for (let index = 0; index < options.warmupIterations; index += 1) { - deriveVisibleGraph(graphData, config); - } - - const durations = []; - let visibleGraph = graphData; - let regexError = null; - - for (let index = 0; index < options.iterations; index += 1) { - const startedAt = performance.now(); - const result = deriveVisibleGraph(graphData, config); - durations.push(performance.now() - startedAt); - visibleGraph = result.graphData ?? { nodes: [], edges: [] }; - regexError = result.regexError; - } - - return { - ...summarizeDurations(durations), - nodeCount: visibleGraph.nodes.length, - edgeCount: visibleGraph.edges.length, - payloadBytes: Buffer.byteLength(JSON.stringify(visibleGraph)), - ...(regexError ? { regexError } : {}), - }; -} - -function measureLegendRulesScenario(graphData, settings, pluginFilterPatterns, graphControls, legends, options) { - const config = createVisibleGraphScenarioConfig(settings, pluginFilterPatterns, graphControls); - const visibleGraph = deriveVisibleGraph(graphData, config).graphData ?? { nodes: [], edges: [] }; - - for (let index = 0; index < options.warmupIterations; index += 1) { - applyLegendRules(visibleGraph, legends); - } - - const durations = []; - let coloredGraph = visibleGraph; - - for (let index = 0; index < options.iterations; index += 1) { - const startedAt = performance.now(); - coloredGraph = applyLegendRules(visibleGraph, legends) ?? { nodes: [], edges: [] }; - durations.push(performance.now() - startedAt); - } - - return { - ...summarizeDurations(durations), - activeLegendRuleCount: legends.filter(legend => !legend.disabled).length, - edgeCount: visibleGraph.edges.length, - legendCount: legends.length, - nodeCount: visibleGraph.nodes.length, - payloadBytes: Buffer.byteLength(JSON.stringify(coloredGraph)), - }; -} - -export function measureVisibleGraphScenarios(graphData, settings, options = {}) { - const iterations = options.iterations ?? DEFAULT_ITERATIONS; - const warmupIterations = options.warmupIterations ?? DEFAULT_WARMUP_ITERATIONS; - const pluginFilterPatterns = options.pluginFilterPatterns ?? []; - const graphControls = options.graphControls; - const scenarios = createVisibleGraphScenarios(settings, pluginFilterPatterns, graphControls); - - return Object.fromEntries( - Object.entries(scenarios).map(([scenarioName, config]) => [ - scenarioName, - measureVisibleGraphScenario(graphData, config, { iterations, warmupIterations }), - ]), - ); -} - -async function measureVisibleGraph({ workspacePath, userHomeDir, iterations, warmupIterations }) { - const { graphControls, graphData, legends, settings, pluginFilterPatterns, warmCacheGraphBuildMs } = - await buildGraphDataFromGraphCache(workspacePath, userHomeDir); - const visibleGraphScenarios = measureVisibleGraphScenarios(graphData, settings, { - graphControls, - iterations, - pluginFilterPatterns, - warmupIterations, - }); - const legendRules = { - current: measureLegendRulesScenario(graphData, settings, pluginFilterPatterns, graphControls, legends, { - iterations, - warmupIterations, - }), - }; - - return { - warmCacheGraphBuildMs, - activeFilterPatternCount: createActiveFilterPatterns(settings, pluginFilterPatterns).length, - pluginFilterPatternCount: pluginFilterPatterns.length, - graphNodeCount: graphData.nodes.length, - graphEdgeCount: graphData.edges.length, - graphNodeTypeCount: graphControls.nodeTypes.length, - graphEdgeTypeCount: graphControls.edgeTypes.length, - visibleGraphScenarios, - legendRules, - }; -} - -function printUsage() { - process.stdout.write([ - 'Usage:', - ' pnpm exec tsx scripts/performance/measure-visible-graph-monorepo.mjs [--workspace ] [--user-home ] [--iterations ] [--warmup ] [--output ]', - '', - 'Loads the existing Graph Cache and times webview visible-graph derivation scenarios.', - ].join('\n')); -} - -async function runCli(argv) { - if (hasFlag(argv, '--help')) { - printUsage(); - return; - } - - const workspacePath = readOptionValue(argv, '--workspace') ?? process.cwd(); - const outputPath = readOptionValue(argv, '--output') ?? DEFAULT_OUTPUT_PATH; - const userHomeDir = readOptionValue(argv, '--user-home'); - const iterations = toPositiveInteger(readOptionValue(argv, '--iterations'), DEFAULT_ITERATIONS); - const warmupIterations = toPositiveInteger(readOptionValue(argv, '--warmup'), DEFAULT_WARMUP_ITERATIONS); - const measurements = await measureVisibleGraph({ - workspacePath, - userHomeDir, - iterations, - warmupIterations, - }); - - await writeMetrics({ outputPath, workspacePath, measurements }); -} - -const isDirectRun = process.argv[1] - && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); - -if (isDirectRun) { - runCli(process.argv.slice(2)).catch((error) => { - process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); - process.exitCode = 1; - }); -} diff --git a/scripts/performance/measure-vscode-graph-view.mjs b/scripts/performance/measure-vscode-graph-view.mjs deleted file mode 100644 index 93a5b3dcc..000000000 --- a/scripts/performance/measure-vscode-graph-view.mjs +++ /dev/null @@ -1,1094 +0,0 @@ -import { mkdir, readFile, writeFile } from 'node:fs/promises'; -import { performance } from 'node:perf_hooks'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { - summarizeDurations, - writeMetrics, -} from './measure-codegraphy-monorepo.mjs'; - -const DEFAULT_OUTPUT_PATH = 'reports/performance/vscode-graph-view-latest.json'; -const DEFAULT_ITERATIONS = 5; -const DEFAULT_WARMUP_ITERATIONS = 1; -const DEFAULT_TIMEOUT_MS = 120_000; -const WEBVIEW_PERFORMANCE_EVENT_LIMIT = 500; -const IMPORTS_TOGGLE_START_EVENT = 'graphScope.edgeVisibility.optimistic'; -const IMPORTS_TOGGLE_RENDERED_EVENT = 'graphStats.rendered'; -const ANALYZE_REQUEST_MODE = 'analyze'; -const LIVE_UPDATE_REQUEST_MODE = 'incremental'; -const LIVE_UPDATE_TRIGGER_FILESYSTEM = 'filesystem'; -const LIVE_UPDATE_TRIGGER_EDITOR_SAVE = 'editor-save'; -const GRAPH_UPDATE_MESSAGE_TYPES = new Set([ - 'GRAPH_DATA_UPDATED', - 'GRAPH_NODE_METRICS_UPDATED', -]); -const DEFAULT_PLUGIN_PACKAGE_RELATIVE_PATHS = [ - 'packages/plugin-godot', - 'packages/plugin-markdown', - 'packages/plugin-particles', - 'packages/plugin-svelte', - 'packages/plugin-typescript', - 'packages/plugin-unity', - 'packages/plugin-vue', -]; - -function readOptionValue(args, name) { - const index = args.indexOf(name); - return index >= 0 ? args[index + 1] : undefined; -} - -function hasFlag(args, name) { - return args.includes(name); -} - -function toPositiveInteger(value, defaultValue) { - if (!value) { - return defaultValue; - } - - const parsed = Number(value); - return Number.isInteger(parsed) && parsed > 0 ? parsed : defaultValue; -} - -function parseLiveUpdateTrigger(value) { - if (!value) { - return LIVE_UPDATE_TRIGGER_FILESYSTEM; - } - - if ( - value === LIVE_UPDATE_TRIGGER_FILESYSTEM - || value === LIVE_UPDATE_TRIGGER_EDITOR_SAVE - ) { - return value; - } - - throw new Error(`Unsupported live update trigger: ${value}`); -} - -function parseCount(value) { - return Number(value.replace(/,/g, '')); -} - -export function parseGraphStatsLabel(label) { - const match = /([\d,]+)\s+nodes?.*?([\d,]+)\s+connections?/i.exec(label); - if (!match) { - return null; - } - - return { - nodeCount: parseCount(match[1]), - edgeCount: parseCount(match[2]), - }; -} - -function sameGraphStats(left, right) { - return left.nodeCount === right.nodeCount && left.edgeCount === right.edgeCount; -} - -function findWebviewEventAt(sample, eventName) { - const event = sample.webviewEvents?.find(item => item.name === eventName); - return typeof event?.at === 'number' ? event.at : undefined; -} - -function findWebviewEventAtOrAfter(sample, eventName, minimumAt) { - const event = sample.webviewEvents?.find(item => - item.name === eventName && typeof item.at === 'number' && item.at >= minimumAt); - return typeof event?.at === 'number' ? event.at : undefined; -} - -function isPlainObject(value) { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value); -} - -export function parseExtensionHostPerformanceLog(logText) { - const events = []; - - for (const line of logText.split('\n')) { - if (!line.trim()) { - continue; - } - - try { - const parsed = JSON.parse(line); - if (typeof parsed.name !== 'string' || typeof parsed.at !== 'number') { - continue; - } - - events.push({ - name: parsed.name, - at: parsed.at, - ...(isPlainObject(parsed.detail) ? { detail: parsed.detail } : {}), - }); - } catch { - // Ignore partial or unrelated lines so a long-running host process cannot poison the metrics file. - } - } - - const firstEventAt = events[0]?.at ?? 0; - return events.map(event => ({ - ...event, - offsetMs: Math.round(event.at - firstEventAt), - })); -} - -export function findCompletedExtensionHostRequestAfter(events, { mode, startedAt }) { - const matchingRequestIds = new Set(); - - for (const event of events) { - const requestId = event.detail?.requestId; - if (requestId === undefined || event.detail?.mode !== mode) { - continue; - } - - if (event.name === 'graphAnalysis.request.start' && event.at >= startedAt) { - matchingRequestIds.add(requestId); - continue; - } - - if ( - event.name === 'graphAnalysis.request.completed' - && matchingRequestIds.has(requestId) - ) { - return event; - } - } - - return undefined; -} - -export function findActiveExtensionHostRequestIds(events, mode) { - const activeRequestIds = new Set(); - - for (const event of events) { - const requestId = event.detail?.requestId; - if (requestId === undefined || event.detail?.mode !== mode) { - continue; - } - - if (event.name === 'graphAnalysis.request.start') { - activeRequestIds.add(requestId); - continue; - } - - if (event.name === 'graphAnalysis.request.completed') { - activeRequestIds.delete(requestId); - } - } - - return [...activeRequestIds]; -} - -function findLatestEventBetween(events, name, startedAt, completedAt) { - return events - .filter(event => - event.name === name - && typeof event.at === 'number' - && event.at >= startedAt - && event.at <= completedAt) - .at(-1); -} - -export function computeLiveUpdatePhaseDelays(events, { requestEvent, startedAt }) { - const requestDurationMs = requestEvent.detail?.durationMs; - if (typeof requestDurationMs !== 'number') { - return {}; - } - - const requestStartedAt = requestEvent.at - requestDurationMs; - const savedDocumentEvent = findLatestEventBetween( - events, - 'workspaceFiles.savedDocument.received', - startedAt, - requestStartedAt, - ); - const workspaceRefreshStartedEvent = findLatestEventBetween( - events, - 'workspaceRefresh.started', - startedAt, - requestStartedAt, - ); - const providerReceivedEvent = findLatestEventBetween( - events, - 'graphView.refreshChangedFiles.received', - startedAt, - requestStartedAt, - ); - - return { - ...(providerReceivedEvent - ? { providerToRequestStartDelayMs: Math.round(requestStartedAt - providerReceivedEvent.at) } - : {}), - ...(savedDocumentEvent - ? { - saveEventToRequestCompletionDelayMs: Math.round(requestEvent.at - savedDocumentEvent.at), - saveEventToRequestStartDelayMs: Math.round(requestStartedAt - savedDocumentEvent.at), - } - : {}), - ...(workspaceRefreshStartedEvent - ? { - workspaceRefreshStartToRequestStartDelayMs: Math.round( - requestStartedAt - workspaceRefreshStartedEvent.at, - ), - } - : {}), - }; -} - -async function readExtensionHostPerformanceEvents(logPath) { - const logText = await readFile(logPath, 'utf8').catch(() => ''); - return parseExtensionHostPerformanceLog(logText); -} - -function createExtensionHostPerformanceLogPath(outputPath) { - const resolvedOutputPath = path.resolve(outputPath); - const extension = path.extname(resolvedOutputPath); - const basename = path.basename(resolvedOutputPath, extension); - return path.join(path.dirname(resolvedOutputPath), `${basename}-extension-host.jsonl`); -} - -function isWebviewFrameUrl(url) { - return url.includes('fake.html') || url.startsWith('vscode-webview://'); -} - -export function createGraphFrameLifecycleRecorder(startedAt = performance.now()) { - const events = []; - - function record(name, at = performance.now(), detail = {}) { - events.push({ - name, - offsetMs: Math.round(at - startedAt), - ...detail, - }); - } - - function recordFrame(name, frame, at = performance.now()) { - const url = frame.url(); - record(name, at, { - url, - webviewFrame: isWebviewFrameUrl(url), - }); - } - - return { - events, - record, - recordFrame, - }; -} - -export function getWebviewEventDeltaMs( - sample, - startEventName = IMPORTS_TOGGLE_START_EVENT, - renderedEventName = IMPORTS_TOGGLE_RENDERED_EVENT, -) { - const startedAt = findWebviewEventAt(sample, startEventName); - if (startedAt === undefined) { - return undefined; - } - - const renderedAt = findWebviewEventAtOrAfter(sample, renderedEventName, startedAt); - if (renderedAt === undefined) { - return undefined; - } - - return renderedAt - startedAt; -} - -export function summarizeSwitchTransitionSamples(samples) { - const webviewEventDeltas = samples - .map(sample => getWebviewEventDeltaMs(sample)) - .filter(value => value !== undefined); - - return { - ...summarizeDurations(samples.map(sample => sample.durationMs)), - ...(webviewEventDeltas.length > 0 - ? { webviewEventDelta: summarizeDurations(webviewEventDeltas) } - : {}), - }; -} - -export function summarizeLiveUpdateSamples(samples) { - const requestDurations = samples - .map(sample => sample.requestDurationMs) - .filter(value => value !== undefined); - const requestStartDelays = samples - .map(sample => sample.requestStartDelayMs) - .filter(value => value !== undefined); - const requestCompletionDelays = samples - .map(sample => sample.requestCompletionDelayMs) - .filter(value => value !== undefined); - const saveEventToRequestStartDelays = samples - .map(sample => sample.saveEventToRequestStartDelayMs) - .filter(value => value !== undefined); - const saveEventToRequestCompletionDelays = samples - .map(sample => sample.saveEventToRequestCompletionDelayMs) - .filter(value => value !== undefined); - const workspaceRefreshStartToRequestStartDelays = samples - .map(sample => sample.workspaceRefreshStartToRequestStartDelayMs) - .filter(value => value !== undefined); - const providerToRequestStartDelays = samples - .map(sample => sample.providerToRequestStartDelayMs) - .filter(value => value !== undefined); - - return { - ...summarizeDurations(samples.map(sample => sample.durationMs)), - ...(requestDurations.length > 0 - ? { requestDuration: summarizeDurations(requestDurations) } - : {}), - ...(requestStartDelays.length > 0 - ? { requestStartDelay: summarizeDurations(requestStartDelays) } - : {}), - ...(requestCompletionDelays.length > 0 - ? { requestCompletionDelay: summarizeDurations(requestCompletionDelays) } - : {}), - ...(saveEventToRequestStartDelays.length > 0 - ? { saveEventToRequestStartDelay: summarizeDurations(saveEventToRequestStartDelays) } - : {}), - ...(saveEventToRequestCompletionDelays.length > 0 - ? { saveEventToRequestCompletionDelay: summarizeDurations(saveEventToRequestCompletionDelays) } - : {}), - ...(workspaceRefreshStartToRequestStartDelays.length > 0 - ? { - workspaceRefreshStartToRequestStartDelay: summarizeDurations( - workspaceRefreshStartToRequestStartDelays, - ), - } - : {}), - ...(providerToRequestStartDelays.length > 0 - ? { providerToRequestStartDelay: summarizeDurations(providerToRequestStartDelays) } - : {}), - }; -} - -export function summarizeWebviewEventDurations(events) { - const durationsByEventName = new Map(); - - for (const event of events) { - if (typeof event.durationMs !== 'number') { - continue; - } - - const durations = durationsByEventName.get(event.name) ?? []; - durations.push(event.durationMs); - durationsByEventName.set(event.name, durations); - } - - return Object.fromEntries( - [...durationsByEventName.entries()] - .sort(([left], [right]) => left.localeCompare(right)) - .map(([name, durations]) => [name, summarizeDurations(durations)]), - ); -} - -function findFirstWebviewEvent(events, predicate) { - return events.find(event => typeof event.at === 'number' && predicate(event)); -} - -function findFirstExtensionHostEvent(events, predicate) { - return events.find(event => typeof event.offsetMs === 'number' && predicate(event)); -} - -function addRoundedDelta(target, key, endAt, startAt) { - if (typeof endAt !== 'number' || typeof startAt !== 'number') { - return; - } - - target[key] = Math.round(endAt - startAt); -} - -export function computeFirstGraphReadyBreakdown({ - extensionHostEvents, - firstGraphReadyPhases, - firstGraphReadyWebviewEvents, -}) { - const readyPosted = findFirstWebviewEvent( - firstGraphReadyWebviewEvents, - event => event.name === 'webview.ready.posted', - ); - const graphDataReceived = findFirstWebviewEvent( - firstGraphReadyWebviewEvents, - event => event.name === 'extensionMessage.received' - && event.detail?.type === 'GRAPH_DATA_UPDATED', - ); - const bootstrapComplete = findFirstWebviewEvent( - firstGraphReadyWebviewEvents, - event => event.name === 'extensionMessage.appBootstrapComplete' - || ( - event.name === 'extensionMessage.received' - && event.detail?.type === 'APP_BOOTSTRAP_COMPLETE' - ), - ); - const statsRendered = findFirstWebviewEvent( - firstGraphReadyWebviewEvents, - event => event.name === 'graphStats.rendered', - ); - const htmlAssigned = findFirstExtensionHostEvent( - extensionHostEvents, - event => event.name === 'graphWebview.html.assigned', - ); - const graphDataSent = findFirstExtensionHostEvent( - extensionHostEvents, - event => event.name === 'graphWebview.message.send' - && event.detail?.type === 'GRAPH_DATA_UPDATED', - ); - const loadRequestCompleted = findFirstExtensionHostEvent( - extensionHostEvents, - event => event.name === 'graphAnalysis.request.completed' - && event.detail?.mode === 'load', - ); - - const breakdown = { - commandAndViewOpenMs: firstGraphReadyPhases.openGraphCommandMs, - frameReadyMs: firstGraphReadyPhases.graphFrameReadyMs, - statsAfterFrameMs: firstGraphReadyPhases.graphStatsReadyMs, - ...(typeof htmlAssigned?.offsetMs === 'number' - ? { extensionHostHtmlAssignedOffsetMs: htmlAssigned.offsetMs } - : {}), - ...(typeof graphDataSent?.offsetMs === 'number' - ? { extensionHostFirstGraphDataSendOffsetMs: graphDataSent.offsetMs } - : {}), - ...(typeof loadRequestCompleted?.offsetMs === 'number' - ? { extensionHostFirstLoadRequestCompletedOffsetMs: loadRequestCompleted.offsetMs } - : {}), - ...(typeof loadRequestCompleted?.detail?.durationMs === 'number' - ? { extensionHostFirstLoadRequestDurationMs: loadRequestCompleted.detail.durationMs } - : {}), - }; - - addRoundedDelta( - breakdown, - 'webviewDocumentReadyToStatsMs', - statsRendered?.at, - readyPosted?.at, - ); - addRoundedDelta( - breakdown, - 'webviewGraphDataToStatsMs', - statsRendered?.at, - graphDataReceived?.at, - ); - addRoundedDelta( - breakdown, - 'webviewBootstrapToStatsMs', - statsRendered?.at, - bootstrapComplete?.at, - ); - - return breakdown; -} - -export function createStartupMeasurements({ - extensionHostEvents, - extensionHostLogPath, - firstGraphReadyMs, - firstGraphReadyPhases, - firstGraphReadyWebviewEvents, - frameLifecycleEvents, - initialStats, - vscodeLaunchMs, -}) { - return { - status: 'startup-ready', - vscodeLaunchMs, - firstGraphReadyMs, - firstGraphReadyPhases, - firstGraphReadyBreakdown: computeFirstGraphReadyBreakdown({ - extensionHostEvents, - firstGraphReadyPhases, - firstGraphReadyWebviewEvents, - }), - firstGraphReadyWebviewStages: summarizeWebviewEventDurations(firstGraphReadyWebviewEvents), - firstGraphReadyWebviewEvents, - firstGraphReadyFrameLifecycleEvents: frameLifecycleEvents, - firstGraphReadyExtensionHostLogPath: extensionHostLogPath, - extensionHostEvents, - initialStats, - }; -} - -export function createCompleteMeasurements({ - extensionHostEvents, - importsToggleSamples, - liveUpdateSamples, - startupMeasurements, -}) { - return { - ...startupMeasurements, - status: 'complete', - extensionHostEvents, - importsToggle: { - ...summarizeSwitchTransitionSamples(importsToggleSamples), - samples: importsToggleSamples, - }, - ...(liveUpdateSamples.length > 0 - ? { - liveUpdate: { - ...summarizeLiveUpdateSamples(liveUpdateSamples), - samples: liveUpdateSamples, - }, - } - : {}), - }; -} - -async function readGraphStats(frame) { - const text = await frame - .getByText(/[\d,]+\s+nodes?.*?[\d,]+\s+connections?/i) - .first() - .textContent({ timeout: 1_000 }) - .catch(() => null); - return text ? parseGraphStatsLabel(text) : null; -} - -async function waitForGraphStats(frame, predicate, timeoutMs = DEFAULT_TIMEOUT_MS) { - const startedAt = performance.now(); - let lastStats = null; - - while (performance.now() - startedAt < timeoutMs) { - const stats = await readGraphStats(frame); - if (stats) { - lastStats = stats; - if (predicate(stats)) { - return stats; - } - } - - await frame.waitForTimeout(100); - } - - throw new Error(`Timed out waiting for graph stats. Last stats: ${JSON.stringify(lastStats)}`); -} - -async function installWebviewPerformanceInitScript(page) { - await page.addInitScript((limit) => { - window.__codegraphyPerformance = { - enabled: true, - events: [], - limit, - }; - }, WEBVIEW_PERFORMANCE_EVENT_LIMIT); -} - -async function openGraphScopeEdgeTypes(frame) { - await frame.getByTitle('Graph Scope').click({ force: true }); - const edgeTypesButton = frame.getByRole('button', { name: 'Edge Types' }); - await edgeTypesButton.click({ timeout: DEFAULT_TIMEOUT_MS }); -} - -function graphScopeSwitch(frame, label) { - return frame.getByRole('switch', { name: `Toggle ${label}`, exact: true }); -} - -async function readSwitchEnabled(frame, label) { - const value = await graphScopeSwitch(frame, label).getAttribute('aria-checked', { - timeout: DEFAULT_TIMEOUT_MS, - }); - return value === 'true'; -} - -async function waitForSwitchEnabled(frame, label, enabled) { - const expected = String(enabled); - const startedAt = performance.now(); - - while (performance.now() - startedAt < DEFAULT_TIMEOUT_MS) { - const value = await graphScopeSwitch(frame, label).getAttribute('aria-checked').catch(() => null); - if (value === expected) { - return; - } - - await frame.waitForTimeout(50); - } - - throw new Error(`Timed out waiting for ${label} switch to become ${expected}`); -} - -async function enableWebviewPerformanceEvents(frame) { - await frame.evaluate((limit) => { - const existing = window.__codegraphyPerformance; - window.__codegraphyPerformance = { - enabled: true, - events: Array.isArray(existing?.events) ? existing.events : [], - limit, - }; - }, WEBVIEW_PERFORMANCE_EVENT_LIMIT); -} - -async function resetWebviewPerformanceEvents(frame) { - await frame.evaluate((limit) => { - window.__codegraphyPerformance = { - enabled: true, - events: [], - limit, - }; - }, WEBVIEW_PERFORMANCE_EVENT_LIMIT); -} - -async function readWebviewPerformanceEvents(frame) { - return frame.evaluate(() => window.__codegraphyPerformance?.events ?? []); -} - -async function waitForWebviewPerformanceEvent(frame, name, timeoutMs = DEFAULT_TIMEOUT_MS) { - const startedAt = performance.now(); - - while (performance.now() - startedAt < timeoutMs) { - const matched = await frame.evaluate((eventName) => - Boolean(window.__codegraphyPerformance?.events?.some(event => event.name === eventName)), name); - if (matched) { - return; - } - - await frame.waitForTimeout(25); - } - - throw new Error(`Timed out waiting for webview performance event: ${name}`); -} - -async function countWebviewMessagesReceived(frame, type) { - const count = await frame.evaluate((messageType) => - window.__codegraphyPerformance?.events?.filter(event => - event.name === 'extensionMessage.received' - && event.detail?.type === messageType).length ?? 0, type); - return Number.isInteger(count) ? count : 0; -} - -async function countWebviewGraphUpdateMessagesReceived(frame) { - const counts = new Map(); - for (const messageType of GRAPH_UPDATE_MESSAGE_TYPES) { - counts.set(messageType, await countWebviewMessagesReceived(frame, messageType)); - } - - return counts; -} - -async function waitForWebviewMessageReceived(frame, type, minimumCount = 0, timeoutMs = DEFAULT_TIMEOUT_MS) { - const startedAt = performance.now(); - - while (performance.now() - startedAt < timeoutMs) { - if (await countWebviewMessagesReceived(frame, type) > minimumCount) { - return; - } - - await frame.waitForTimeout(25); - } - - throw new Error(`Timed out waiting for webview message: ${type}`); -} - -export async function saveLiveUpdateFileThroughEditor({ - absoluteFilePath, - frame, -}) { - if (!frame) { - throw new Error('The editor-save live update trigger requires a graph webview frame.'); - } - - await frame.evaluate((filePath) => { - const vscode = window.vscode; - if (!vscode) { - throw new Error('Graph webview did not expose the VS Code API.'); - } - - vscode.postMessage({ - type: 'PERF_SAVE_LIVE_UPDATE_FILE', - payload: { path: filePath }, - }); - }, absoluteFilePath); -} - -async function writeLiveUpdateMarker({ - absoluteFilePath, - frame, - liveUpdateTrigger, - marker, - originalContent, - page, - saveFileThroughEditor, -}) { - if (liveUpdateTrigger === LIVE_UPDATE_TRIGGER_EDITOR_SAVE) { - await saveFileThroughEditor({ - absoluteFilePath, - frame, - marker, - originalContent, - page, - }); - return; - } - - await writeFile(absoluteFilePath, `${originalContent}${marker}`); -} - -async function waitForExtensionHostPerformanceEvent(logPath, predicate, timeoutMs = DEFAULT_TIMEOUT_MS) { - const startedAt = performance.now(); - - while (performance.now() - startedAt < timeoutMs) { - const events = await readExtensionHostPerformanceEvents(logPath); - const event = predicate(events); - if (event) { - return event; - } - - await new Promise(resolve => setTimeout(resolve, 25)); - } - - throw new Error('Timed out waiting for extension host performance event'); -} - -async function waitForExtensionHostRequestIdle( - logPath, - mode, - quietMs = 500, - timeoutMs = DEFAULT_TIMEOUT_MS, -) { - const startedAt = performance.now(); - - while (performance.now() - startedAt < timeoutMs) { - const events = await readExtensionHostPerformanceEvents(logPath); - const activeRequestIds = findActiveExtensionHostRequestIds(events, mode); - const latestModeEventAt = events - .filter(event => event.name.startsWith('graphAnalysis.request.') - && event.detail?.mode === mode) - .at(-1)?.at; - - if ( - activeRequestIds.length === 0 - && (latestModeEventAt === undefined || Date.now() - latestModeEventAt >= quietMs) - ) { - return; - } - - await new Promise(resolve => setTimeout(resolve, 25)); - } - - throw new Error(`Timed out waiting for ${mode} extension host requests to become idle`); -} - -function findGraphUpdateMessageSentForRequest(events, requestEvent) { - const requestId = requestEvent.detail?.requestId; - const mode = requestEvent.detail?.mode; - const startedAt = events.find(event => - event.name === 'graphAnalysis.request.start' - && event.detail?.requestId === requestId - && event.detail?.mode === mode)?.at; - - if (typeof startedAt !== 'number') { - return undefined; - } - - return events.find(event => - event.name === 'graphWebview.message.send' - && event.at >= startedAt - && event.at <= requestEvent.at - && GRAPH_UPDATE_MESSAGE_TYPES.has(event.detail?.type))?.detail?.type; -} - -async function waitForWebviewGraphUpdateMessageIfSent( - logPath, - frame, - requestEvent, - previousMessageCounts = new Map(), -) { - const events = await readExtensionHostPerformanceEvents(logPath); - const messageType = findGraphUpdateMessageSentForRequest(events, requestEvent); - if (!messageType) { - return; - } - - await waitForWebviewMessageReceived(frame, messageType, previousMessageCounts.get(messageType) ?? 0); -} - -async function measureSwitchTransition(frame, label, enabled) { - const beforeStats = await waitForGraphStats(frame, stats => stats.nodeCount > 0); - await resetWebviewPerformanceEvents(frame); - const startedAt = performance.now(); - await graphScopeSwitch(frame, label).click(); - await waitForSwitchEnabled(frame, label, enabled); - const afterStats = await waitForGraphStats(frame, stats => !sameGraphStats(stats, beforeStats)); - await waitForWebviewPerformanceEvent(frame, 'graphStats.rendered'); - - return { - durationMs: Math.round(performance.now() - startedAt), - enabled, - beforeStats, - afterStats, - webviewEvents: await readWebviewPerformanceEvents(frame), - }; -} - -export async function measureLiveUpdateTransition({ - extensionHostLogPath, - frame, - liveUpdateFilePath, - liveUpdateTrigger = LIVE_UPDATE_TRIGGER_FILESYSTEM, - page, - saveFileThroughEditor = saveLiveUpdateFileThroughEditor, - waitForAnalyzeIdle = true, - workspaceRoot, -}) { - const normalizedLiveUpdateTrigger = parseLiveUpdateTrigger(liveUpdateTrigger); - const absoluteFilePath = path.isAbsolute(liveUpdateFilePath) - ? liveUpdateFilePath - : path.join(workspaceRoot, liveUpdateFilePath); - const originalContent = await readFile(absoluteFilePath, 'utf8'); - const marker = `\n// CodeGraphy live update perf marker ${Date.now()}\n`; - - if (waitForAnalyzeIdle) { - await waitForExtensionHostRequestIdle(extensionHostLogPath, ANALYZE_REQUEST_MODE); - } - await waitForExtensionHostRequestIdle(extensionHostLogPath, LIVE_UPDATE_REQUEST_MODE); - await resetWebviewPerformanceEvents(frame); - const startedAtEpoch = Date.now(); - const startedAt = performance.now(); - let markerWritten = false; - let updateRequestCompletedAt; - - try { - await writeLiveUpdateMarker({ - absoluteFilePath, - frame, - liveUpdateTrigger: normalizedLiveUpdateTrigger, - marker, - originalContent, - page, - saveFileThroughEditor, - }); - markerWritten = true; - const requestEvent = await waitForExtensionHostPerformanceEvent( - extensionHostLogPath, - events => findCompletedExtensionHostRequestAfter(events, { - mode: LIVE_UPDATE_REQUEST_MODE, - startedAt: startedAtEpoch, - }), - ); - updateRequestCompletedAt = requestEvent.at; - await waitForWebviewGraphUpdateMessageIfSent(extensionHostLogPath, frame, requestEvent); - const extensionHostEvents = await readExtensionHostPerformanceEvents(extensionHostLogPath); - const requestDurationMs = requestEvent.detail?.durationMs; - const requestCompletionDelayMs = Math.round(requestEvent.at - startedAtEpoch); - const requestStartDelayMs = typeof requestDurationMs === 'number' - ? Math.round(requestEvent.at - requestDurationMs - startedAtEpoch) - : undefined; - const phaseDelays = computeLiveUpdatePhaseDelays(extensionHostEvents, { - requestEvent, - startedAt: startedAtEpoch, - }); - - return { - durationMs: Math.round(performance.now() - startedAt), - filePath: path.relative(workspaceRoot, absoluteFilePath).replace(/\\/g, '/'), - ...phaseDelays, - requestDurationMs, - requestStartDelayMs, - requestCompletionDelayMs, - requestOffsetMs: requestEvent.offsetMs, - trigger: normalizedLiveUpdateTrigger, - webviewEvents: await readWebviewPerformanceEvents(frame), - }; - } finally { - if (markerWritten) { - const restoreStartedAtEpoch = Date.now(); - const previousMessageCounts = await countWebviewGraphUpdateMessagesReceived(frame); - await writeFile(absoluteFilePath, originalContent); - const requestEvent = await waitForExtensionHostPerformanceEvent( - extensionHostLogPath, - events => findCompletedExtensionHostRequestAfter(events, { - mode: LIVE_UPDATE_REQUEST_MODE, - startedAt: typeof updateRequestCompletedAt === 'number' - ? Math.max(restoreStartedAtEpoch, updateRequestCompletedAt + 1) - : restoreStartedAtEpoch, - }), - ); - await waitForWebviewGraphUpdateMessageIfSent( - extensionHostLogPath, - frame, - requestEvent, - previousMessageCounts, - ); - await waitForExtensionHostRequestIdle(extensionHostLogPath, LIVE_UPDATE_REQUEST_MODE); - } - } -} - -async function restoreWorkspaceSettings(settingsPath, originalSettings) { - if (originalSettings === null) { - return; - } - - await writeFile(settingsPath, originalSettings); -} - -async function measureVSCodeGraphView({ - iterations, - liveUpdateFilePath, - liveUpdateTrigger, - outputPath, - waitForAnalyzeIdle, - warmupIterations, - workspacePath, -}) { - const workspaceRoot = path.resolve(workspacePath); - const settingsPath = path.join(workspaceRoot, '.codegraphy', 'settings.json'); - const extensionHostLogPath = createExtensionHostPerformanceLogPath(outputPath); - const originalSettings = await readFile(settingsPath, 'utf8').catch(() => null); - const { - cleanupVSCode, - launchVSCodeWithWorkspace, - openGraphView, - waitForGraphFrame, - } = await import('../../packages/extension/tests/acceptance/graphView/vscode.ts'); - let vscode = null; - - try { - await mkdir(path.dirname(extensionHostLogPath), { recursive: true }); - await writeFile(extensionHostLogPath, ''); - const launchStartedAt = performance.now(); - vscode = await launchVSCodeWithWorkspace(workspaceRoot, { - extensionPerformanceLogPath: extensionHostLogPath, - pluginPackageRelativePaths: DEFAULT_PLUGIN_PACKAGE_RELATIVE_PATHS, - }); - const launchMs = Math.round(performance.now() - launchStartedAt); - await installWebviewPerformanceInitScript(vscode.page); - const openStartedAt = performance.now(); - const frameLifecycle = createGraphFrameLifecycleRecorder(openStartedAt); - const recordFrameAttached = frame => frameLifecycle.recordFrame('frame.attached', frame); - const recordFrameNavigated = frame => frameLifecycle.recordFrame('frame.navigated', frame); - vscode.page.on('frameattached', recordFrameAttached); - vscode.page.on('framenavigated', recordFrameNavigated); - frameLifecycle.record('graphOpen.start', openStartedAt, { - frameCount: vscode.page.frames().length, - }); - for (const frame of vscode.page.frames()) { - frameLifecycle.recordFrame('frame.existingAtOpen', frame, openStartedAt); - } - await openGraphView(vscode.page); - const openGraphCommandMs = Math.round(performance.now() - openStartedAt); - frameLifecycle.record('graphOpen.commandCompleted', performance.now(), { - frameCount: vscode.page.frames().length, - }); - const frameStartedAt = performance.now(); - const frame = await waitForGraphFrame(vscode.page, DEFAULT_TIMEOUT_MS); - const graphFrameReadyMs = Math.round(performance.now() - frameStartedAt); - frameLifecycle.record('graphFrame.ready', performance.now(), { - frameCount: vscode.page.frames().length, - url: frame.url(), - }); - vscode.page.off('frameattached', recordFrameAttached); - vscode.page.off('framenavigated', recordFrameNavigated); - await enableWebviewPerformanceEvents(frame); - const statsStartedAt = performance.now(); - const initialStats = await waitForGraphStats(frame, stats => stats.nodeCount > 0); - const graphStatsReadyMs = Math.round(performance.now() - statsStartedAt); - const firstGraphReadyMs = Math.round(performance.now() - openStartedAt); - const firstGraphReadyWebviewEvents = await readWebviewPerformanceEvents(frame); - const extensionHostEvents = await readExtensionHostPerformanceEvents(extensionHostLogPath); - const startupMeasurements = createStartupMeasurements({ - extensionHostEvents, - extensionHostLogPath, - firstGraphReadyMs, - firstGraphReadyPhases: { - openGraphCommandMs, - graphFrameReadyMs, - graphStatsReadyMs, - }, - firstGraphReadyWebviewEvents, - frameLifecycleEvents: frameLifecycle.events, - initialStats, - vscodeLaunchMs: launchMs, - }); - await writeMetrics({ - outputPath, - workspacePath: workspaceRoot, - measurements: startupMeasurements, - }); - - await openGraphScopeEdgeTypes(frame); - const initialImportsEnabled = await readSwitchEnabled(frame, 'Imports'); - let nextImportsEnabled = !initialImportsEnabled; - const samples = []; - - for (let index = 0; index < warmupIterations + iterations; index += 1) { - const sample = await measureSwitchTransition(frame, 'Imports', nextImportsEnabled); - if (index >= warmupIterations) { - samples.push(sample); - } - nextImportsEnabled = !nextImportsEnabled; - } - - if (await readSwitchEnabled(frame, 'Imports') !== initialImportsEnabled) { - await measureSwitchTransition(frame, 'Imports', initialImportsEnabled); - } - - const liveUpdateSamples = []; - if (liveUpdateFilePath) { - liveUpdateSamples.push(await measureLiveUpdateTransition({ - extensionHostLogPath, - frame, - liveUpdateFilePath, - liveUpdateTrigger, - page: vscode.page, - waitForAnalyzeIdle, - workspaceRoot, - })); - } - - const completeExtensionHostEvents = await readExtensionHostPerformanceEvents(extensionHostLogPath); - const measurements = createCompleteMeasurements({ - extensionHostEvents: completeExtensionHostEvents, - importsToggleSamples: samples, - liveUpdateSamples, - startupMeasurements, - }); - - await writeMetrics({ outputPath, workspacePath: workspaceRoot, measurements }); - return measurements; - } finally { - if (vscode) { - await cleanupVSCode(vscode); - } - await restoreWorkspaceSettings(settingsPath, originalSettings); - } -} - -function printUsage() { - process.stdout.write([ - 'Usage:', - ' pnpm exec tsx scripts/performance/measure-vscode-graph-view.mjs [--workspace ] [--iterations ] [--warmup ] [--live-update-file ] [--live-update-trigger filesystem|editor-save] [--live-update-no-analyze-idle-wait] [--output ]', - '', - 'Launches Extension Development Host, opens CodeGraphy, and times rendered Graph Scope toggle latency.', - ].join('\n')); -} - -async function runCli(argv) { - if (hasFlag(argv, '--help')) { - printUsage(); - return; - } - - const workspacePath = readOptionValue(argv, '--workspace') ?? process.cwd(); - const outputPath = readOptionValue(argv, '--output') ?? DEFAULT_OUTPUT_PATH; - const iterations = toPositiveInteger(readOptionValue(argv, '--iterations'), DEFAULT_ITERATIONS); - const warmupIterations = toPositiveInteger(readOptionValue(argv, '--warmup'), DEFAULT_WARMUP_ITERATIONS); - const liveUpdateFilePath = readOptionValue(argv, '--live-update-file'); - const liveUpdateTrigger = parseLiveUpdateTrigger(readOptionValue(argv, '--live-update-trigger')); - const waitForAnalyzeIdle = !hasFlag(argv, '--live-update-no-analyze-idle-wait'); - - await measureVSCodeGraphView({ - iterations, - liveUpdateFilePath, - liveUpdateTrigger, - outputPath, - waitForAnalyzeIdle, - warmupIterations, - workspacePath, - }); -} - -const isDirectRun = process.argv[1] - && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); - -if (isDirectRun) { - runCli(process.argv.slice(2)).catch((error) => { - process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); - process.exitCode = 1; - }); -} diff --git a/tests/scripts/measure-codegraphy-monorepo.test.mjs b/tests/scripts/measure-codegraphy-monorepo.test.mjs deleted file mode 100644 index fcb926d53..000000000 --- a/tests/scripts/measure-codegraphy-monorepo.test.mjs +++ /dev/null @@ -1,110 +0,0 @@ -import assert from 'node:assert/strict'; -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import path from 'node:path'; -import test from 'node:test'; -import { pathToFileURL } from 'node:url'; - -test('performance runner writes bounded JSON metrics', async () => { - const tempDir = await mkdtemp(path.join(tmpdir(), 'codegraphy-perf-')); - const outputPath = path.join(tempDir, 'metrics.json'); - - try { - const moduleUrl = pathToFileURL( - path.resolve('scripts/performance/measure-codegraphy-monorepo.mjs'), - ).href; - const { writeMetrics } = await import(moduleUrl); - - await writeMetrics({ - outputPath, - workspacePath: tempDir, - measurements: { - coldIndexMs: 100, - warmCacheLoadMs: 20, - nodeCount: 2, - edgeCount: 1, - payloadBytes: 512, - }, - }); - - const metrics = JSON.parse(await readFile(outputPath, 'utf8')); - assert.equal(metrics.workspacePath, tempDir); - assert.equal(metrics.measurements.coldIndexMs, 100); - assert.equal(metrics.measurements.warmCacheLoadMs, 20); - assert.equal(metrics.measurements.nodeCount, 2); - assert.equal(metrics.measurements.edgeCount, 1); - assert.equal(metrics.measurements.payloadBytes, 512); - assert.match(metrics.recordedAt, /^\d{4}-\d{2}-\d{2}T/); - } finally { - await rm(tempDir, { recursive: true, force: true }); - } -}); - -test('performance runner parses verbose indexing phase timings', async () => { - const tempDir = await mkdtemp(path.join(tmpdir(), 'codegraphy-perf-phases-')); - const logPath = path.join(tempDir, 'index.log'); - - try { - await writeFile(logPath, [ - '[CodeGraphy] Indexing phase complete: phase=discover-files, durationMs=1900, files=2367, directories=3180, totalFound=2367, limitReached=false', - '[CodeGraphy] Indexing phase complete: phase=analyze-files, durationMs=88321, files=2367, cacheHits=0, cacheMisses=2367', - '[CodeGraphy] Indexing phase complete: phase=build-graph, durationMs=62, nodes=5078, edges=9114', - ' 213.93 real 93.33 user 25.41 sys', - '{', - ' "graphCache": ".codegraphy/graph.lbug",', - ' "files": 2367,', - ' "nodes": 5078,', - ' "edges": 9114', - '}', - '', - ].join('\n')); - - const moduleUrl = pathToFileURL( - path.resolve('scripts/performance/measure-codegraphy-monorepo.mjs'), - ).href; - const { readColdIndexLogMetrics } = await import(moduleUrl); - const metrics = await readColdIndexLogMetrics({ - logPath, - workspacePath: tempDir, - }); - - assert.deepEqual(metrics.indexPhases['discover-files'], { - phase: 'discover-files', - durationMs: 1900, - files: 2367, - directories: 3180, - totalFound: 2367, - limitReached: false, - }); - assert.deepEqual(metrics.indexPhases['analyze-files'], { - phase: 'analyze-files', - durationMs: 88321, - files: 2367, - cacheHits: 0, - cacheMisses: 2367, - }); - assert.deepEqual(metrics.indexPhases['build-graph'], { - phase: 'build-graph', - durationMs: 62, - nodes: 5078, - edges: 9114, - }); - } finally { - await rm(tempDir, { recursive: true, force: true }); - } -}); - -test('performance runner summarizes repeated timing samples deterministically', async () => { - const moduleUrl = pathToFileURL( - path.resolve('scripts/performance/measure-codegraphy-monorepo.mjs'), - ).href; - const { summarizeDurations } = await import(moduleUrl); - - assert.deepEqual(summarizeDurations([50.4, 10.2, 30.6, 20.1, 40.5]), { - iterations: 5, - minMs: 10, - medianMs: 31, - p95Ms: 50, - maxMs: 50, - }); -}); diff --git a/tests/scripts/measure-vscode-graph-view.test.mjs b/tests/scripts/measure-vscode-graph-view.test.mjs deleted file mode 100644 index 6c89b0a0f..000000000 --- a/tests/scripts/measure-vscode-graph-view.test.mjs +++ /dev/null @@ -1,1067 +0,0 @@ -import assert from 'node:assert/strict'; -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import test from 'node:test'; -import { pathToFileURL } from 'node:url'; - -test('VS Code graph view runner parses graph stats labels', async () => { - const moduleUrl = pathToFileURL( - path.resolve('scripts/performance/measure-vscode-graph-view.mjs'), - ).href; - const { parseGraphStatsLabel } = await import(moduleUrl); - - assert.deepEqual(parseGraphStatsLabel('2,246 nodes • 3,130 connections'), { - nodeCount: 2246, - edgeCount: 3130, - }); - assert.deepEqual(parseGraphStatsLabel('1 node • 1 connection'), { - nodeCount: 1, - edgeCount: 1, - }); - assert.equal(parseGraphStatsLabel('Loading graph...'), null); -}); - -test('VS Code graph view runner summarizes in-webview toggle event deltas', async () => { - const moduleUrl = pathToFileURL( - path.resolve('scripts/performance/measure-vscode-graph-view.mjs'), - ).href; - const { summarizeSwitchTransitionSamples } = await import(moduleUrl); - - assert.deepEqual(summarizeSwitchTransitionSamples([ - { - durationMs: 220, - webviewEvents: [ - { name: 'graphScope.edgeVisibility.optimistic', at: 10 }, - { name: 'graphStats.rendered', at: 63.2 }, - ], - }, - { - durationMs: 190, - webviewEvents: [ - { name: 'graphScope.edgeVisibility.optimistic', at: 20 }, - { name: 'graphStats.rendered', at: 75.6 }, - ], - }, - { - durationMs: 205, - webviewEvents: [ - { name: 'graphScope.edgeVisibility.optimistic', at: 30 }, - { name: 'graphStats.rendered', at: 79.4 }, - ], - }, - ]), { - iterations: 3, - minMs: 190, - medianMs: 205, - p95Ms: 220, - maxMs: 220, - webviewEventDelta: { - iterations: 3, - minMs: 49, - medianMs: 53, - p95Ms: 56, - maxMs: 56, - }, - }); -}); - -test('VS Code graph view runner ignores rendered events before the toggle start', async () => { - const moduleUrl = pathToFileURL( - path.resolve('scripts/performance/measure-vscode-graph-view.mjs'), - ).href; - const { getWebviewEventDeltaMs } = await import(moduleUrl); - - assert.equal(getWebviewEventDeltaMs({ - webviewEvents: [ - { name: 'graphStats.rendered', at: 5 }, - { name: 'graphScope.edgeVisibility.optimistic', at: 10 }, - { name: 'graphStats.rendered', at: 62.4 }, - ], - }), 52.4); -}); - -test('VS Code graph view runner summarizes startup webview stage durations', async () => { - const moduleUrl = pathToFileURL( - path.resolve('scripts/performance/measure-vscode-graph-view.mjs'), - ).href; - const { summarizeWebviewEventDurations } = await import(moduleUrl); - - assert.deepEqual(summarizeWebviewEventDurations([ - { name: 'visibleGraph.derive', durationMs: 110.2 }, - { name: 'graphStats.rendered', at: 215 }, - { name: 'visibleGraph.derive', durationMs: 90.8 }, - { name: 'graphRuntime.buildGraphData', durationMs: 8.4 }, - ]), { - 'graphRuntime.buildGraphData': { - iterations: 1, - minMs: 8, - medianMs: 8, - p95Ms: 8, - maxMs: 8, - }, - 'visibleGraph.derive': { - iterations: 2, - minMs: 91, - medianMs: 101, - p95Ms: 110, - maxMs: 110, - }, - }); -}); - -test('VS Code graph view runner computes first graph ready timing breakdown', async () => { - const moduleUrl = pathToFileURL( - path.resolve('scripts/performance/measure-vscode-graph-view.mjs'), - ).href; - const { computeFirstGraphReadyBreakdown } = await import(moduleUrl); - - assert.deepEqual(computeFirstGraphReadyBreakdown({ - extensionHostEvents: [ - { name: 'command.open.start', offsetMs: 0 }, - { name: 'graphWebview.html.assigned', offsetMs: 42 }, - { - name: 'graphWebview.message.send', - offsetMs: 310, - detail: { type: 'GRAPH_DATA_UPDATED' }, - }, - { - name: 'graphAnalysis.request.completed', - offsetMs: 350, - detail: { mode: 'load', durationMs: 120 }, - }, - ], - firstGraphReadyPhases: { - openGraphCommandMs: 100, - graphFrameReadyMs: 1000, - graphStatsReadyMs: 20, - }, - firstGraphReadyWebviewEvents: [ - { name: 'webview.ready.posted', at: 10.2 }, - { name: 'extensionMessage.received', at: 40.3, detail: { type: 'GRAPH_DATA_UPDATED' } }, - { name: 'extensionMessage.appBootstrapComplete', at: 70.4 }, - { name: 'graphStats.rendered', at: 130.8 }, - ], - }), { - commandAndViewOpenMs: 100, - frameReadyMs: 1000, - statsAfterFrameMs: 20, - extensionHostHtmlAssignedOffsetMs: 42, - extensionHostFirstGraphDataSendOffsetMs: 310, - extensionHostFirstLoadRequestCompletedOffsetMs: 350, - extensionHostFirstLoadRequestDurationMs: 120, - webviewDocumentReadyToStatsMs: 121, - webviewGraphDataToStatsMs: 91, - webviewBootstrapToStatsMs: 60, - }); -}); - -test('VS Code graph view runner summarizes live-update request durations', async () => { - const moduleUrl = pathToFileURL( - path.resolve('scripts/performance/measure-vscode-graph-view.mjs'), - ).href; - const { summarizeLiveUpdateSamples } = await import(moduleUrl); - - assert.deepEqual(summarizeLiveUpdateSamples([ - { - durationMs: 640, - requestDurationMs: 120, - requestStartDelayMs: 420, - requestCompletionDelayMs: 540, - saveEventToRequestStartDelayMs: 70, - saveEventToRequestCompletionDelayMs: 190, - workspaceRefreshStartToRequestStartDelayMs: 38, - providerToRequestStartDelayMs: 36, - }, - { - durationMs: 590, - requestDurationMs: 90, - requestStartDelayMs: 390, - requestCompletionDelayMs: 480, - saveEventToRequestStartDelayMs: 40, - saveEventToRequestCompletionDelayMs: 130, - workspaceRefreshStartToRequestStartDelayMs: 8, - providerToRequestStartDelayMs: 6, - }, - { - durationMs: 615, - requestDurationMs: 105, - requestStartDelayMs: 405, - requestCompletionDelayMs: 510, - saveEventToRequestStartDelayMs: 55, - saveEventToRequestCompletionDelayMs: 160, - workspaceRefreshStartToRequestStartDelayMs: 23, - providerToRequestStartDelayMs: 21, - }, - ]), { - iterations: 3, - minMs: 590, - medianMs: 615, - p95Ms: 640, - maxMs: 640, - requestDuration: { - iterations: 3, - minMs: 90, - medianMs: 105, - p95Ms: 120, - maxMs: 120, - }, - requestStartDelay: { - iterations: 3, - minMs: 390, - medianMs: 405, - p95Ms: 420, - maxMs: 420, - }, - requestCompletionDelay: { - iterations: 3, - minMs: 480, - medianMs: 510, - p95Ms: 540, - maxMs: 540, - }, - saveEventToRequestStartDelay: { - iterations: 3, - minMs: 40, - medianMs: 55, - p95Ms: 70, - maxMs: 70, - }, - saveEventToRequestCompletionDelay: { - iterations: 3, - minMs: 130, - medianMs: 160, - p95Ms: 190, - maxMs: 190, - }, - workspaceRefreshStartToRequestStartDelay: { - iterations: 3, - minMs: 8, - medianMs: 23, - p95Ms: 38, - maxMs: 38, - }, - providerToRequestStartDelay: { - iterations: 3, - minMs: 6, - medianMs: 21, - p95Ms: 36, - maxMs: 36, - }, - }); -}); - -test('VS Code graph view runner computes live-update phase delays from extension-host events', async () => { - const moduleUrl = pathToFileURL( - path.resolve('scripts/performance/measure-vscode-graph-view.mjs'), - ).href; - const { computeLiveUpdatePhaseDelays } = await import(moduleUrl); - - const requestEvent = { - name: 'graphAnalysis.request.completed', - at: 1_300, - detail: { requestId: 3, mode: 'incremental', durationMs: 80 }, - }; - - assert.deepEqual(computeLiveUpdatePhaseDelays([ - { name: 'workspaceFiles.savedDocument.received', at: 1_100 }, - { name: 'workspaceRefresh.started', at: 1_132 }, - { name: 'graphView.refreshChangedFiles.received', at: 1_135 }, - { name: 'graphAnalysis.request.start', at: 1_220 }, - requestEvent, - ], { - requestEvent, - startedAt: 1_000, - }), { - providerToRequestStartDelayMs: 85, - saveEventToRequestCompletionDelayMs: 200, - saveEventToRequestStartDelayMs: 120, - workspaceRefreshStartToRequestStartDelayMs: 88, - }); -}); - -test('VS Code graph view runner parses extension host performance JSONL', async () => { - const moduleUrl = pathToFileURL( - path.resolve('scripts/performance/measure-vscode-graph-view.mjs'), - ).href; - const { parseExtensionHostPerformanceLog } = await import(moduleUrl); - - assert.deepEqual(parseExtensionHostPerformanceLog([ - '{"name":"graphWebview.resolve.start","at":1782129600100,"detail":{"visible":true}}', - 'not json', - '{"name":"graphWebview.html.assigned","at":1782129600125,"detail":{"htmlLength":21}}', - '{"name":12,"at":1782129600200}', - ].join('\n')), [ - { - name: 'graphWebview.resolve.start', - at: 1782129600100, - offsetMs: 0, - detail: { visible: true }, - }, - { - name: 'graphWebview.html.assigned', - at: 1782129600125, - offsetMs: 25, - detail: { htmlLength: 21 }, - }, - ]); -}); - -test('VS Code graph view runner ignores request completions whose start was before the marker', async () => { - const moduleUrl = pathToFileURL( - path.resolve('scripts/performance/measure-vscode-graph-view.mjs'), - ).href; - const { findCompletedExtensionHostRequestAfter } = await import(moduleUrl); - - assert.deepEqual(findCompletedExtensionHostRequestAfter([ - { - name: 'graphAnalysis.request.start', - at: 90, - detail: { requestId: 1, mode: 'incremental' }, - }, - { - name: 'graphAnalysis.request.completed', - at: 140, - detail: { requestId: 1, mode: 'incremental', durationMs: 50 }, - }, - { - name: 'graphAnalysis.request.start', - at: 150, - detail: { requestId: 2, mode: 'incremental' }, - }, - { - name: 'graphAnalysis.request.completed', - at: 190, - detail: { requestId: 2, mode: 'incremental', durationMs: 40 }, - }, - ], { - mode: 'incremental', - startedAt: 100, - }), { - name: 'graphAnalysis.request.completed', - at: 190, - detail: { requestId: 2, mode: 'incremental', durationMs: 40 }, - }); -}); - -test('VS Code graph view runner detects active extension-host requests', async () => { - const moduleUrl = pathToFileURL( - path.resolve('scripts/performance/measure-vscode-graph-view.mjs'), - ).href; - const { findActiveExtensionHostRequestIds } = await import(moduleUrl); - - assert.deepEqual(findActiveExtensionHostRequestIds([ - { - name: 'graphAnalysis.request.start', - at: 100, - detail: { requestId: 1, mode: 'incremental' }, - }, - { - name: 'graphAnalysis.request.start', - at: 120, - detail: { requestId: 2, mode: 'incremental' }, - }, - { - name: 'graphAnalysis.request.completed', - at: 150, - detail: { requestId: 1, mode: 'incremental', durationMs: 50 }, - }, - { - name: 'graphAnalysis.request.start', - at: 180, - detail: { requestId: 3, mode: 'analyze' }, - }, - ], 'incremental'), [2]); -}); - -test('VS Code graph view runner records frame lifecycle offsets from graph open', async () => { - const moduleUrl = pathToFileURL( - path.resolve('scripts/performance/measure-vscode-graph-view.mjs'), - ).href; - const { createGraphFrameLifecycleRecorder } = await import(moduleUrl); - const recorder = createGraphFrameLifecycleRecorder(1_000); - - recorder.record('graphOpen.start', 1_000); - recorder.recordFrame('frame.attached', { url: () => 'about:blank' }, 1_025); - recorder.recordFrame('frame.navigated', { url: () => 'vscode-webview://test/fake.html' }, 1_150); - recorder.record('graphFrame.ready', 1_425, { frameCount: 3 }); - - assert.deepEqual(recorder.events, [ - { - name: 'graphOpen.start', - offsetMs: 0, - }, - { - name: 'frame.attached', - offsetMs: 25, - url: 'about:blank', - webviewFrame: false, - }, - { - name: 'frame.navigated', - offsetMs: 150, - url: 'vscode-webview://test/fake.html', - webviewFrame: true, - }, - { - name: 'graphFrame.ready', - offsetMs: 425, - frameCount: 3, - }, - ]); -}); - -test('VS Code graph view runner builds a startup-ready measurement payload before interactions', async () => { - const moduleUrl = pathToFileURL( - path.resolve('scripts/performance/measure-vscode-graph-view.mjs'), - ).href; - const { createStartupMeasurements } = await import(moduleUrl); - - assert.deepEqual(createStartupMeasurements({ - extensionHostEvents: [{ name: 'command.open.start', offsetMs: 0 }], - extensionHostLogPath: '/tmp/extension-host.jsonl', - firstGraphReadyMs: 1200, - firstGraphReadyPhases: { - openGraphCommandMs: 100, - graphFrameReadyMs: 1000, - graphStatsReadyMs: 20, - }, - firstGraphReadyWebviewEvents: [ - { name: 'visibleGraph.derive', durationMs: 12.2 }, - { name: 'graphStats.rendered', at: 30 }, - ], - frameLifecycleEvents: [{ name: 'graphFrame.ready', offsetMs: 1100 }], - initialStats: { nodeCount: 10, edgeCount: 5 }, - vscodeLaunchMs: 900, - }), { - status: 'startup-ready', - vscodeLaunchMs: 900, - firstGraphReadyMs: 1200, - firstGraphReadyPhases: { - openGraphCommandMs: 100, - graphFrameReadyMs: 1000, - graphStatsReadyMs: 20, - }, - firstGraphReadyWebviewStages: { - 'visibleGraph.derive': { - iterations: 1, - minMs: 12, - medianMs: 12, - p95Ms: 12, - maxMs: 12, - }, - }, - firstGraphReadyBreakdown: { - commandAndViewOpenMs: 100, - frameReadyMs: 1000, - statsAfterFrameMs: 20, - }, - firstGraphReadyWebviewEvents: [ - { name: 'visibleGraph.derive', durationMs: 12.2 }, - { name: 'graphStats.rendered', at: 30 }, - ], - firstGraphReadyFrameLifecycleEvents: [{ name: 'graphFrame.ready', offsetMs: 1100 }], - firstGraphReadyExtensionHostLogPath: '/tmp/extension-host.jsonl', - extensionHostEvents: [{ name: 'command.open.start', offsetMs: 0 }], - initialStats: { nodeCount: 10, edgeCount: 5 }, - }); -}); - -test('VS Code graph view runner carries post-interaction extension-host events into completed metrics', async () => { - const moduleUrl = pathToFileURL( - path.resolve('scripts/performance/measure-vscode-graph-view.mjs'), - ).href; - const { createCompleteMeasurements } = await import(moduleUrl); - - const startupMeasurements = { - status: 'startup-ready', - extensionHostEvents: [{ name: 'graphAnalysis.request.completed', offsetMs: 10 }], - }; - const extensionHostEvents = [ - { name: 'graphAnalysis.request.completed', offsetMs: 10 }, - { name: 'graphAnalysis.publish.broadcasts', offsetMs: 210 }, - ]; - - assert.deepEqual(createCompleteMeasurements({ - extensionHostEvents, - importsToggleSamples: [{ durationMs: 25 }], - liveUpdateSamples: [{ - durationMs: 40, - requestDurationMs: 30, - requestStartDelayMs: 10, - requestCompletionDelayMs: 40, - }], - startupMeasurements, - }), { - status: 'complete', - extensionHostEvents, - importsToggle: { - iterations: 1, - minMs: 25, - medianMs: 25, - p95Ms: 25, - maxMs: 25, - samples: [{ durationMs: 25 }], - }, - liveUpdate: { - iterations: 1, - minMs: 40, - medianMs: 40, - p95Ms: 40, - maxMs: 40, - requestDuration: { - iterations: 1, - minMs: 30, - medianMs: 30, - p95Ms: 30, - maxMs: 30, - }, - requestStartDelay: { - iterations: 1, - minMs: 10, - medianMs: 10, - p95Ms: 10, - maxMs: 10, - }, - requestCompletionDelay: { - iterations: 1, - minMs: 40, - medianMs: 40, - p95Ms: 40, - maxMs: 40, - }, - samples: [{ - durationMs: 40, - requestDurationMs: 30, - requestStartDelayMs: 10, - requestCompletionDelayMs: 40, - }], - }, - }); -}); - -test('VS Code graph view runner waits for the live-update restore request before finishing', async (t) => { - const moduleUrl = pathToFileURL( - path.resolve('scripts/performance/measure-vscode-graph-view.mjs'), - ).href; - const { measureLiveUpdateTransition } = await import(moduleUrl); - const workspaceRoot = await mkdtemp(path.join(os.tmpdir(), 'codegraphy-live-update-')); - t.after(() => rm(workspaceRoot, { recursive: true, force: true })); - - const liveUpdateFilePath = 'src/example.ts'; - const absoluteFilePath = path.join(workspaceRoot, liveUpdateFilePath); - const extensionHostLogPath = path.join(workspaceRoot, 'extension-host.jsonl'); - const originalContent = 'export const value = 1;\n'; - await mkdir(path.dirname(absoluteFilePath), { recursive: true }); - await writeFile(absoluteFilePath, originalContent); - await writeFile(extensionHostLogPath, ''); - - let stopped = false; - let markerRequestRecorded = false; - let restoreRequestCompleted = false; - const frame = { - evaluate: async (callback) => { - if (String(callback).includes('__codegraphyPerformance?.events')) { - return []; - } - return undefined; - }, - waitForTimeout: async ms => new Promise(resolve => setTimeout(resolve, ms)), - }; - - async function appendIncrementalRequest(requestId) { - const startedAt = Date.now(); - await writeFile(extensionHostLogPath, [ - JSON.stringify({ - name: 'graphAnalysis.request.start', - at: startedAt, - detail: { requestId, mode: 'incremental' }, - }), - JSON.stringify({ - name: 'graphAnalysis.request.completed', - at: startedAt + 5, - detail: { requestId, mode: 'incremental', durationMs: 5 }, - }), - '', - ].join('\n'), { flag: 'a' }); - } - - const observer = (async () => { - while (!stopped) { - const content = await readFile(absoluteFilePath, 'utf8'); - if (!markerRequestRecorded && content.includes('CodeGraphy live update perf marker')) { - markerRequestRecorded = true; - await appendIncrementalRequest(1); - } else if ( - markerRequestRecorded - && !restoreRequestCompleted - && content === originalContent - ) { - await new Promise(resolve => setTimeout(resolve, 50)); - await appendIncrementalRequest(2); - restoreRequestCompleted = true; - } - - await new Promise(resolve => setTimeout(resolve, 5)); - } - })(); - - try { - const sample = await measureLiveUpdateTransition({ - extensionHostLogPath, - frame, - liveUpdateFilePath, - workspaceRoot, - }); - - assert.equal(sample.filePath, liveUpdateFilePath); - assert.equal(restoreRequestCompleted, true); - assert.equal(await readFile(absoluteFilePath, 'utf8'), originalContent); - } finally { - stopped = true; - await observer; - } -}); - -test('VS Code graph view runner waits for the live-update graph message in the webview', async (t) => { - const moduleUrl = pathToFileURL( - path.resolve('scripts/performance/measure-vscode-graph-view.mjs'), - ).href; - const { measureLiveUpdateTransition } = await import(moduleUrl); - const workspaceRoot = await mkdtemp(path.join(os.tmpdir(), 'codegraphy-live-update-webview-')); - t.after(() => rm(workspaceRoot, { recursive: true, force: true })); - - const liveUpdateFilePath = 'src/example.ts'; - const absoluteFilePath = path.join(workspaceRoot, liveUpdateFilePath); - const extensionHostLogPath = path.join(workspaceRoot, 'extension-host.jsonl'); - const originalContent = 'export const value = 1;\n'; - await mkdir(path.dirname(absoluteFilePath), { recursive: true }); - await writeFile(absoluteFilePath, originalContent); - await writeFile(extensionHostLogPath, ''); - - let stopped = false; - let markerRequestRecorded = false; - let restoreRequestCompleted = false; - let markerWebviewMessageReceived = false; - const webviewEvents = []; - const frame = { - evaluate: async (callback, argument) => { - const source = String(callback); - if (source.includes('window.__codegraphyPerformance =')) { - webviewEvents.length = 0; - return undefined; - } - if (source.includes('.filter(event')) { - return webviewEvents.filter(event => - event.name === 'extensionMessage.received' - && event.detail?.type === argument).length; - } - if (source.includes('__codegraphyPerformance?.events')) { - return webviewEvents; - } - return undefined; - }, - waitForTimeout: async ms => new Promise(resolve => setTimeout(resolve, ms)), - }; - - async function appendIncrementalRequest(requestId) { - const startedAt = Date.now(); - await writeFile(extensionHostLogPath, [ - JSON.stringify({ - name: 'graphAnalysis.request.start', - at: startedAt, - detail: { requestId, mode: 'incremental' }, - }), - JSON.stringify({ - name: 'graphWebview.message.send', - at: startedAt + 1, - detail: { type: 'GRAPH_NODE_METRICS_UPDATED' }, - }), - JSON.stringify({ - name: 'graphAnalysis.request.completed', - at: startedAt + 2, - detail: { requestId, mode: 'incremental', durationMs: 2 }, - }), - '', - ].join('\n'), { flag: 'a' }); - } - - function enqueueGraphMessageReceived() { - setTimeout(() => { - markerWebviewMessageReceived = true; - webviewEvents.push({ - name: 'extensionMessage.received', - at: performance.now(), - detail: { type: 'GRAPH_NODE_METRICS_UPDATED' }, - }); - }, 40); - } - - const observer = (async () => { - while (!stopped) { - const content = await readFile(absoluteFilePath, 'utf8'); - if (!markerRequestRecorded && content.includes('CodeGraphy live update perf marker')) { - markerRequestRecorded = true; - await appendIncrementalRequest(1); - enqueueGraphMessageReceived(); - } else if ( - markerRequestRecorded - && !restoreRequestCompleted - && content === originalContent - ) { - await appendIncrementalRequest(2); - enqueueGraphMessageReceived(); - restoreRequestCompleted = true; - } - - await new Promise(resolve => setTimeout(resolve, 5)); - } - })(); - - try { - const sample = await measureLiveUpdateTransition({ - extensionHostLogPath, - frame, - liveUpdateFilePath, - workspaceRoot, - }); - - assert.equal(sample.filePath, liveUpdateFilePath); - assert.equal(markerWebviewMessageReceived, true); - assert.equal( - sample.webviewEvents.some(event => event.detail?.type === 'GRAPH_NODE_METRICS_UPDATED'), - true, - ); - assert.equal(restoreRequestCompleted, true); - } finally { - stopped = true; - await observer; - } -}); - -test('VS Code graph view runner can trigger live updates through editor save', async (t) => { - const moduleUrl = pathToFileURL( - path.resolve('scripts/performance/measure-vscode-graph-view.mjs'), - ).href; - const { measureLiveUpdateTransition } = await import(moduleUrl); - const workspaceRoot = await mkdtemp(path.join(os.tmpdir(), 'codegraphy-live-update-editor-')); - t.after(() => rm(workspaceRoot, { recursive: true, force: true })); - - const liveUpdateFilePath = 'src/example.ts'; - const absoluteFilePath = path.join(workspaceRoot, liveUpdateFilePath); - const extensionHostLogPath = path.join(workspaceRoot, 'extension-host.jsonl'); - const originalContent = 'export const value = 1;\n'; - await mkdir(path.dirname(absoluteFilePath), { recursive: true }); - await writeFile(absoluteFilePath, originalContent); - await writeFile(extensionHostLogPath, ''); - - let stopped = false; - let editorSaveTriggered = false; - let markerRequestRecorded = false; - let restoreRequestCompleted = false; - const frame = { - evaluate: async (callback) => { - if (String(callback).includes('__codegraphyPerformance?.events')) { - return []; - } - return undefined; - }, - waitForTimeout: async ms => new Promise(resolve => setTimeout(resolve, ms)), - }; - - async function saveFileThroughEditor({ absoluteFilePath: targetPath, marker, originalContent: content }) { - editorSaveTriggered = true; - await writeFile(targetPath, `${content}${marker}`); - } - - async function appendIncrementalRequest(requestId) { - const startedAt = Date.now(); - await writeFile(extensionHostLogPath, [ - JSON.stringify({ - name: 'graphAnalysis.request.start', - at: startedAt, - detail: { requestId, mode: 'incremental' }, - }), - JSON.stringify({ - name: 'graphAnalysis.request.completed', - at: startedAt + 3, - detail: { requestId, mode: 'incremental', durationMs: 3 }, - }), - '', - ].join('\n'), { flag: 'a' }); - } - - const observer = (async () => { - while (!stopped) { - const content = await readFile(absoluteFilePath, 'utf8'); - if (!markerRequestRecorded && content.includes('CodeGraphy live update perf marker')) { - markerRequestRecorded = true; - await appendIncrementalRequest(1); - } else if ( - markerRequestRecorded - && !restoreRequestCompleted - && content === originalContent - ) { - await appendIncrementalRequest(2); - restoreRequestCompleted = true; - } - - await new Promise(resolve => setTimeout(resolve, 5)); - } - })(); - - try { - const sample = await measureLiveUpdateTransition({ - extensionHostLogPath, - frame, - liveUpdateFilePath, - liveUpdateTrigger: 'editor-save', - saveFileThroughEditor, - workspaceRoot, - }); - - assert.equal(sample.filePath, liveUpdateFilePath); - assert.equal(sample.trigger, 'editor-save'); - assert.equal(editorSaveTriggered, true); - assert.equal(restoreRequestCompleted, true); - } finally { - stopped = true; - await observer; - } -}); - -test('VS Code graph view runner asks the webview to trigger editor-save live updates', async () => { - const moduleUrl = pathToFileURL( - path.resolve('scripts/performance/measure-vscode-graph-view.mjs'), - ).href; - const { saveLiveUpdateFileThroughEditor } = await import(moduleUrl); - const messages = []; - const frame = { - evaluate: async (callback, filePath) => { - const previousWindow = globalThis.window; - globalThis.window = { - vscode: { - postMessage: message => messages.push(message), - }, - }; - try { - return callback(filePath); - } finally { - globalThis.window = previousWindow; - } - }, - }; - - await saveLiveUpdateFileThroughEditor({ - absoluteFilePath: '/workspace/src/app.ts', - frame, - }); - - assert.deepEqual(messages, [{ - type: 'PERF_SAVE_LIVE_UPDATE_FILE', - payload: { path: '/workspace/src/app.ts' }, - }]); -}); - -test('VS Code graph view runner waits for active analyze requests before live-update markers', async (t) => { - const moduleUrl = pathToFileURL( - path.resolve('scripts/performance/measure-vscode-graph-view.mjs'), - ).href; - const { measureLiveUpdateTransition } = await import(moduleUrl); - const workspaceRoot = await mkdtemp(path.join(os.tmpdir(), 'codegraphy-live-update-analyze-')); - t.after(() => rm(workspaceRoot, { recursive: true, force: true })); - - const liveUpdateFilePath = 'src/example.ts'; - const absoluteFilePath = path.join(workspaceRoot, liveUpdateFilePath); - const extensionHostLogPath = path.join(workspaceRoot, 'extension-host.jsonl'); - const originalContent = 'export const value = 1;\n'; - await mkdir(path.dirname(absoluteFilePath), { recursive: true }); - await writeFile(absoluteFilePath, originalContent); - await writeFile(extensionHostLogPath, `${JSON.stringify({ - name: 'graphAnalysis.request.start', - at: Date.now() - 10, - detail: { requestId: 7, mode: 'analyze' }, - })}\n`); - - let stopped = false; - let markerSeenAt = 0; - let analyzeCompletedAt = 0; - let markerRequestRecorded = false; - let restoreRequestCompleted = false; - const frame = { - evaluate: async (callback) => { - if (String(callback).includes('__codegraphyPerformance?.events')) { - return []; - } - return undefined; - }, - waitForTimeout: async ms => new Promise(resolve => setTimeout(resolve, ms)), - }; - - async function appendRequestEvent(name, requestId, mode) { - const at = Date.now(); - await writeFile(extensionHostLogPath, `${JSON.stringify({ - name, - at, - detail: { requestId, mode, durationMs: 5 }, - })}\n`, { flag: 'a' }); - return at; - } - - async function appendIncrementalRequest(requestId) { - await appendRequestEvent('graphAnalysis.request.start', requestId, 'incremental'); - await appendRequestEvent('graphAnalysis.request.completed', requestId, 'incremental'); - } - - const analyzeCompletion = new Promise((resolve, reject) => { - setTimeout(() => { - appendRequestEvent('graphAnalysis.request.completed', 7, 'analyze') - .then((at) => { - analyzeCompletedAt = at; - resolve(); - }) - .catch(reject); - }, 80); - }); - - const observer = (async () => { - while (!stopped) { - const content = await readFile(absoluteFilePath, 'utf8'); - if (!markerRequestRecorded && content.includes('CodeGraphy live update perf marker')) { - markerSeenAt = Date.now(); - markerRequestRecorded = true; - await appendIncrementalRequest(8); - } else if ( - markerRequestRecorded - && !restoreRequestCompleted - && content === originalContent - ) { - await appendIncrementalRequest(9); - restoreRequestCompleted = true; - } - - await new Promise(resolve => setTimeout(resolve, 5)); - } - })(); - - try { - await measureLiveUpdateTransition({ - extensionHostLogPath, - frame, - liveUpdateFilePath, - workspaceRoot, - }); - await analyzeCompletion; - - assert.equal(restoreRequestCompleted, true); - assert.ok( - markerSeenAt >= analyzeCompletedAt, - `marker was written before analyze completed: marker=${markerSeenAt} analyze=${analyzeCompletedAt}`, - ); - } finally { - stopped = true; - await observer; - } -}); - -test('VS Code graph view runner can skip the analyze idle wait for live-update markers', async (t) => { - const moduleUrl = pathToFileURL( - path.resolve('scripts/performance/measure-vscode-graph-view.mjs'), - ).href; - const { measureLiveUpdateTransition } = await import(moduleUrl); - const workspaceRoot = await mkdtemp(path.join(os.tmpdir(), 'codegraphy-live-update-no-wait-')); - t.after(() => rm(workspaceRoot, { recursive: true, force: true })); - - const liveUpdateFilePath = 'src/example.ts'; - const absoluteFilePath = path.join(workspaceRoot, liveUpdateFilePath); - const extensionHostLogPath = path.join(workspaceRoot, 'extension-host.jsonl'); - const originalContent = 'export const value = 1;\n'; - await mkdir(path.dirname(absoluteFilePath), { recursive: true }); - await writeFile(absoluteFilePath, originalContent); - await writeFile(extensionHostLogPath, `${JSON.stringify({ - name: 'graphAnalysis.request.start', - at: Date.now() - 10, - detail: { requestId: 7, mode: 'analyze' }, - })}\n`); - - let stopped = false; - let markerSeenAt = 0; - let analyzeCompletedAt = 0; - let markerRequestRecorded = false; - let restoreRequestCompleted = false; - const frame = { - evaluate: async (callback) => { - if (String(callback).includes('__codegraphyPerformance?.events')) { - return []; - } - return undefined; - }, - waitForTimeout: async ms => new Promise(resolve => setTimeout(resolve, ms)), - }; - - async function appendRequestEvent(name, requestId, mode) { - const at = Date.now(); - await writeFile(extensionHostLogPath, `${JSON.stringify({ - name, - at, - detail: { requestId, mode, durationMs: 5 }, - })}\n`, { flag: 'a' }); - return at; - } - - async function appendIncrementalRequest(requestId) { - await appendRequestEvent('graphAnalysis.request.start', requestId, 'incremental'); - await appendRequestEvent('graphAnalysis.request.completed', requestId, 'incremental'); - } - - const analyzeCompletion = new Promise((resolve, reject) => { - setTimeout(() => { - appendRequestEvent('graphAnalysis.request.completed', 7, 'analyze') - .then((at) => { - analyzeCompletedAt = at; - resolve(); - }) - .catch(reject); - }, 80); - }); - - const observer = (async () => { - while (!stopped) { - const content = await readFile(absoluteFilePath, 'utf8'); - if (!markerRequestRecorded && content.includes('CodeGraphy live update perf marker')) { - markerSeenAt = Date.now(); - markerRequestRecorded = true; - await appendIncrementalRequest(8); - } else if ( - markerRequestRecorded - && !restoreRequestCompleted - && content === originalContent - ) { - await appendIncrementalRequest(9); - restoreRequestCompleted = true; - } - - await new Promise(resolve => setTimeout(resolve, 5)); - } - })(); - - try { - await measureLiveUpdateTransition({ - extensionHostLogPath, - frame, - liveUpdateFilePath, - waitForAnalyzeIdle: false, - workspaceRoot, - }); - await analyzeCompletion; - - assert.equal(restoreRequestCompleted, true); - assert.ok( - markerSeenAt < analyzeCompletedAt, - `marker waited for analyze completion: marker=${markerSeenAt} analyze=${analyzeCompletedAt}`, - ); - } finally { - stopped = true; - await observer; - } -}); From 194ffa6e182cdd841fb3fb2b856336596483e326 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 09:34:46 -0700 Subject: [PATCH 093/192] fix: keep graph physics active for positioned nodes --- .../graph/rendering/surface/sharedProps.ts | 18 +----------------- .../graph/rendering/sharedProps.test.ts | 8 +++----- 2 files changed, 4 insertions(+), 22 deletions(-) diff --git a/packages/extension/src/webview/components/graph/rendering/surface/sharedProps.ts b/packages/extension/src/webview/components/graph/rendering/surface/sharedProps.ts index 21d40eaa5..e61e0e1fc 100644 --- a/packages/extension/src/webview/components/graph/rendering/surface/sharedProps.ts +++ b/packages/extension/src/webview/components/graph/rendering/surface/sharedProps.ts @@ -3,7 +3,6 @@ import type { LinkObject, NodeObject } from 'react-force-graph-2d'; import type { FGLink, FGNode } from '../../model/build'; export const INTERACTIVE_COOLDOWN_TICKS = 60; -export const POSITIONED_INTERACTIVE_COOLDOWN_TICKS = 0; export const TIMELINE_COOLDOWN_TICKS = 50; export interface GraphContainerSize { @@ -56,21 +55,6 @@ export function normalizeGraphDimension(value: number): number | undefined { return value === 0 ? undefined : value; } -function everyNodeHasFinitePosition(nodes: readonly FGNode[]): boolean { - return nodes.length > 0 - && nodes.every(node => Number.isFinite(node.x) && Number.isFinite(node.y)); -} - -function getCooldownTicks(options: Pick): number { - if (options.timelineActive) { - return TIMELINE_COOLDOWN_TICKS; - } - - return everyNodeHasFinitePosition(options.graphData.nodes) - ? POSITIONED_INTERACTIVE_COOLDOWN_TICKS - : INTERACTIVE_COOLDOWN_TICKS; -} - export function buildSharedGraphProps( options: BuildSharedGraphPropsOptions, ): GraphSurfaceSharedProps { @@ -99,7 +83,7 @@ export function buildSharedGraphProps( d3VelocityDecay: options.damping, d3AlphaDecay: 0.0228, warmupTicks: 0, - cooldownTicks: getCooldownTicks(options), + cooldownTicks: options.timelineActive ? TIMELINE_COOLDOWN_TICKS : INTERACTIVE_COOLDOWN_TICKS, nodeId: 'id', onNodeHover: (node) => options.onNodeHover(node as FGNode | null), dagMode: options.dagMode ?? undefined, diff --git a/packages/extension/tests/webview/graph/rendering/sharedProps.test.ts b/packages/extension/tests/webview/graph/rendering/sharedProps.test.ts index 6d34a1c99..0a262307b 100644 --- a/packages/extension/tests/webview/graph/rendering/sharedProps.test.ts +++ b/packages/extension/tests/webview/graph/rendering/sharedProps.test.ts @@ -4,7 +4,6 @@ import { buildSharedGraphProps, INTERACTIVE_COOLDOWN_TICKS, normalizeGraphDimension, - POSITIONED_INTERACTIVE_COOLDOWN_TICKS, TIMELINE_COOLDOWN_TICKS, type BuildSharedGraphPropsOptions, } from '../../../../src/webview/components/graph/rendering/surface/sharedProps'; @@ -79,7 +78,7 @@ describe('graph/rendering/surface/sharedProps', () => { expect(props.d3VelocityDecay).toBe(0.7); expect(props.d3AlphaDecay).toBe(0.0228); expect(props.warmupTicks).toBe(0); - expect(props.cooldownTicks).toBe(POSITIONED_INTERACTIVE_COOLDOWN_TICKS); + expect(props.cooldownTicks).toBe(INTERACTIVE_COOLDOWN_TICKS); expect(props.dagMode).toBe('td'); expect(props.dagLevelDistance).toBe(60); }); @@ -98,7 +97,7 @@ describe('graph/rendering/surface/sharedProps', () => { expect(props.dagLevelDistance).toBeUndefined(); }); - it('uses the short physics cooldown once every interactive node has a position', () => { + it('keeps positioned interactive graphs on the normal physics cooldown', () => { const props = buildSharedGraphProps(createOptions({ graphData: { links: [createLink()], @@ -106,8 +105,7 @@ describe('graph/rendering/surface/sharedProps', () => { }, })); - expect(POSITIONED_INTERACTIVE_COOLDOWN_TICKS).toBe(0); - expect(props.cooldownTicks).toBe(POSITIONED_INTERACTIVE_COOLDOWN_TICKS); + expect(props.cooldownTicks).toBe(INTERACTIVE_COOLDOWN_TICKS); }); it('keeps unpositioned interactive graphs on the normal physics cooldown', () => { From 0b51fd61fac98d8d0581ecd38e33967f5882c0ca Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 10:42:47 -0700 Subject: [PATCH 094/192] fix: guard graph shortcuts against stale data --- .../graphView/analysis/execution/publish.ts | 105 ++++++++++++++---- .../analysis/execution/publish.test.ts | 78 +++++++++++++ .../src/aliasImport/compilerOptions.ts | 65 +++++++++-- .../tests/aliasImport/compilerOptions.test.ts | 57 ++++++++++ 4 files changed, 276 insertions(+), 29 deletions(-) diff --git a/packages/extension/src/extension/graphView/analysis/execution/publish.ts b/packages/extension/src/extension/graphView/analysis/execution/publish.ts index c96ec3fb5..3b8b96c92 100644 --- a/packages/extension/src/extension/graphView/analysis/execution/publish.ts +++ b/packages/extension/src/extension/graphView/analysis/execution/publish.ts @@ -157,27 +157,90 @@ function createNodeMap(nodes: readonly IGraphNode[]): Map { return new Map(nodes.map(node => [node.id, node])); } -function normalizeNodeForMetricOnlyComparison(node: IGraphNode): Omit { - const comparableNode: Partial = { ...node }; - delete comparableNode.churn; - delete comparableNode.fileSize; - return comparableNode as Omit; +function areGraphValuesEqual(left: unknown, right: unknown): boolean { + if (Object.is(left, right)) { + return true; + } + + if (Array.isArray(left) || Array.isArray(right)) { + if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) { + return false; + } + + return left.every((leftValue, index) => areGraphValuesEqual(leftValue, right[index])); + } + + if ( + left === null + || right === null + || typeof left !== 'object' + || typeof right !== 'object' + ) { + return false; + } + + const leftRecord = left as unknown as Record; + const rightRecord = right as unknown as Record; + const keys = new Set([...Object.keys(leftRecord), ...Object.keys(rightRecord)]); + for (const key of keys) { + if (!areGraphValuesEqual(leftRecord[key], rightRecord[key])) { + return false; + } + } + + return true; } function areNodesEqualIgnoringMetrics(left: IGraphNode, right: IGraphNode): boolean { - return JSON.stringify(normalizeNodeForMetricOnlyComparison(left)) - === JSON.stringify(normalizeNodeForMetricOnlyComparison(right)); + if (left === right) { + return true; + } + + const leftRecord = left as unknown as Record; + const rightRecord = right as unknown as Record; + const keys = new Set([...Object.keys(leftRecord), ...Object.keys(rightRecord)]); + keys.delete('churn'); + keys.delete('fileSize'); + + for (const key of keys) { + if (!areGraphValuesEqual(leftRecord[key], rightRecord[key])) { + return false; + } + } + + return true; } -function collectAffectedEdgeSignature( - graphData: IGraphData, - affectedNodeIds: ReadonlySet, -): string { - return JSON.stringify( - graphData.edges - .filter(edge => affectedNodeIds.has(edge.from) || affectedNodeIds.has(edge.to)) - .sort((left, right) => left.id.localeCompare(right.id)), - ); +function areGraphDataEqualIgnoringNodeMetrics( + currentRawGraphData: IGraphData, + nextRawGraphData: IGraphData, +): boolean { + if ( + currentRawGraphData.nodes.length !== nextRawGraphData.nodes.length + || currentRawGraphData.edges.length !== nextRawGraphData.edges.length + ) { + return false; + } + + for (let index = 0; index < currentRawGraphData.nodes.length; index += 1) { + if (!areNodesEqualIgnoringMetrics( + currentRawGraphData.nodes[index], + nextRawGraphData.nodes[index], + )) { + return false; + } + } + + for (let index = 0; index < currentRawGraphData.edges.length; index += 1) { + if (!areGraphValuesEqual( + currentRawGraphData.edges[index], + nextRawGraphData.edges[index], + )) { + return false; + } + } + + return true; } function createMetricOnlyGraphUpdate( @@ -194,6 +257,10 @@ function createMetricOnlyGraphUpdate( return undefined; } + if (!areGraphDataEqualIgnoringNodeMetrics(currentRawGraphData, nextRawGraphData)) { + return undefined; + } + const currentNodes = collectChangedPathNodes(currentRawGraphData, changedFilePaths); const nextNodes = collectChangedPathNodes(nextRawGraphData, changedFilePaths); if (currentNodes.length === 0 || currentNodes.length !== nextNodes.length) { @@ -201,7 +268,6 @@ function createMetricOnlyGraphUpdate( } const nextNodesById = createNodeMap(nextNodes); - const affectedNodeIds = new Set(); const updates: IGraphNodeMetricsUpdate[] = []; for (const currentNode of currentNodes) { @@ -210,7 +276,6 @@ function createMetricOnlyGraphUpdate( return undefined; } - affectedNodeIds.add(currentNode.id); if ( currentNode.fileSize !== nextNode.fileSize || currentNode.churn !== nextNode.churn @@ -227,9 +292,7 @@ function createMetricOnlyGraphUpdate( return undefined; } - const currentEdgeSignature = collectAffectedEdgeSignature(currentRawGraphData, affectedNodeIds); - const nextEdgeSignature = collectAffectedEdgeSignature(nextRawGraphData, affectedNodeIds); - return currentEdgeSignature === nextEdgeSignature ? updates : undefined; + return updates; } function canReuseCurrentGraphPublication( diff --git a/packages/extension/tests/extension/graphView/analysis/execution/publish.test.ts b/packages/extension/tests/extension/graphView/analysis/execution/publish.test.ts index ab26699b7..6ece49173 100644 --- a/packages/extension/tests/extension/graphView/analysis/execution/publish.test.ts +++ b/packages/extension/tests/extension/graphView/analysis/execution/publish.test.ts @@ -459,6 +459,84 @@ describe('graph view analysis execution publish', () => { expect(handlers.sendGraphDataUpdated).toHaveBeenCalledWith(nextGraphData); }); + it('falls back to full graph publication when an unrelated edge changes during a metric update', () => { + const currentGraphData: IGraphData = { + nodes: [ + { + id: 'src/index.ts', + label: 'index.ts', + color: '#ffffff', + fileSize: 100, + }, + { + id: 'src/other.ts', + label: 'other.ts', + color: '#ffffff', + }, + { + id: 'src/leaf.ts', + label: 'leaf.ts', + color: '#ffffff', + }, + ], + edges: [{ + id: 'src/other.ts->src/leaf.ts#import', + from: 'src/other.ts', + to: 'src/leaf.ts', + kind: 'import', + sources: [], + }], + }; + const nextGraphData: IGraphData = { + nodes: [ + { + id: 'src/index.ts', + label: 'index.ts', + color: '#ffffff', + fileSize: 120, + }, + { + id: 'src/other.ts', + label: 'other.ts', + color: '#ffffff', + }, + { + id: 'src/leaf.ts', + label: 'leaf.ts', + color: '#ffffff', + }, + ], + edges: [{ + id: 'src/other.ts->src/leaf.ts#reference', + from: 'src/other.ts', + to: 'src/leaf.ts', + kind: 'reference', + sources: [], + }], + }; + const state = createExecutionState({ + mode: 'incremental', + changedFilePaths: ['/workspace/src/index.ts'], + analyzer: createExecutionAnalyzer(), + }); + const sendGraphNodeMetricsUpdated = vi.fn(); + const { handlers } = createExecutionHandlers({ + applyViewTransform: vi.fn(() => { + handlers.setGraphData(nextGraphData); + }), + sendGraphNodeMetricsUpdated, + }); + handlers.setRawGraphData(currentGraphData); + handlers.setGraphData(currentGraphData); + vi.mocked(handlers.setRawGraphData).mockClear(); + vi.mocked(handlers.setGraphData).mockClear(); + + publishAnalyzedGraph(state, handlers, nextGraphData, true); + + expect(sendGraphNodeMetricsUpdated).not.toHaveBeenCalled(); + expect(handlers.sendGraphDataUpdated).toHaveBeenCalledWith(nextGraphData); + }); + it('skips unrelated edge serialization when a changed node metric already differs', () => { let serializedUnrelatedEdgeCount = 0; const affectedEdge = { diff --git a/packages/plugin-typescript/src/aliasImport/compilerOptions.ts b/packages/plugin-typescript/src/aliasImport/compilerOptions.ts index 38303a5d0..fc691336d 100644 --- a/packages/plugin-typescript/src/aliasImport/compilerOptions.ts +++ b/packages/plugin-typescript/src/aliasImport/compilerOptions.ts @@ -8,10 +8,15 @@ export type TypeScriptAliasConfig = { }; type CompilerOptionsCacheEntry = { - mtimeMs: number; + configFileStamps: Map; parsed: ts.ParsedCommandLine | null; }; +type FileStamp = { + mtimeMs: number; + size: number; +} | null; + const compilerOptionsCache = new Map(); export function clearTypeScriptAliasConfigCache(): void { @@ -55,25 +60,28 @@ function findNearestTypeScriptConfig(filePath: string, workspaceRoot: string): s } function readCompilerOptions(tsconfigPath: string): ts.ParsedCommandLine | null { - const mtimeMs = fs.statSync(tsconfigPath).mtimeMs; const cached = compilerOptionsCache.get(tsconfigPath); - if (cached?.mtimeMs === mtimeMs) { + if (cached && isCompilerOptionsCacheEntryFresh(cached)) { return cached.parsed; } - const readResult = ts.readConfigFile(tsconfigPath, fileName => ts.sys.readFile(fileName)); + const configFilePaths = new Set([normalizeConfigFilePath(tsconfigPath)]); + const readResult = ts.readConfigFile(tsconfigPath, fileName => { + configFilePaths.add(normalizeConfigFilePath(fileName)); + return ts.sys.readFile(fileName); + }); const parsed = readResult.error ? null : ts.parseJsonConfigFileContent( readResult.config, - createCompilerOptionsParseHost(), + createCompilerOptionsParseHost(configFilePaths), path.dirname(tsconfigPath), undefined, tsconfigPath, ); compilerOptionsCache.set(tsconfigPath, { - mtimeMs, + configFileStamps: createConfigFileStamps(configFilePaths), parsed, }); @@ -84,13 +92,54 @@ function readCompilerOptions(tsconfigPath: string): ts.ParsedCommandLine | null return parsed; } -function createCompilerOptionsParseHost(): ts.ParseConfigHost { +function normalizeConfigFilePath(filePath: string): string { + return path.resolve(filePath); +} + +function getFileStamp(filePath: string): FileStamp { + try { + const stat = fs.statSync(filePath); + return { + mtimeMs: stat.mtimeMs, + size: stat.size, + }; + } catch { + return null; + } +} + +function areFileStampsEqual(left: FileStamp, right: FileStamp): boolean { + if (left === null || right === null) { + return left === right; + } + + return left.mtimeMs === right.mtimeMs && left.size === right.size; +} + +function createConfigFileStamps(filePaths: ReadonlySet): Map { + return new Map([...filePaths].map(filePath => [filePath, getFileStamp(filePath)])); +} + +function isCompilerOptionsCacheEntryFresh(entry: CompilerOptionsCacheEntry): boolean { + for (const [filePath, stamp] of entry.configFileStamps) { + if (!areFileStampsEqual(getFileStamp(filePath), stamp)) { + return false; + } + } + + return true; +} + +function createCompilerOptionsParseHost(configFilePaths: Set): ts.ParseConfigHost { return { directoryExists: directoryName => ts.sys.directoryExists?.(directoryName) ?? false, fileExists: fileName => ts.sys.fileExists(fileName), getCurrentDirectory: () => ts.sys.getCurrentDirectory(), readDirectory: () => [], - readFile: fileName => ts.sys.readFile(fileName), + readFile: fileName => { + configFilePaths.add(normalizeConfigFilePath(fileName)); + return ts.sys.readFile(fileName); + }, realpath: pathName => ts.sys.realpath?.(pathName) ?? pathName, useCaseSensitiveFileNames: ts.sys.useCaseSensitiveFileNames, }; diff --git a/packages/plugin-typescript/tests/aliasImport/compilerOptions.test.ts b/packages/plugin-typescript/tests/aliasImport/compilerOptions.test.ts index 6c93d7265..5a235e1ba 100644 --- a/packages/plugin-typescript/tests/aliasImport/compilerOptions.test.ts +++ b/packages/plugin-typescript/tests/aliasImport/compilerOptions.test.ts @@ -346,6 +346,63 @@ describe('TypeScript Alias Import compiler options support', () => { } }); + it('invalidates parsed path aliases when an extended tsconfig changes on disk', async () => { + const workspaceRoot = createWorkspaceRoot(); + try { + const baseConfig = (target: string) => JSON.stringify({ + compilerOptions: { + baseUrl: '.', + paths: { + '#/*': [`${target}/*`], + }, + }, + }); + writeWorkspaceFile(workspaceRoot, 'tsconfig.base.json', baseConfig('src-a')); + writeWorkspaceFile( + workspaceRoot, + 'tsconfig.json', + JSON.stringify({ + extends: './tsconfig.base.json', + }), + ); + const sourcePath = writeWorkspaceFile( + workspaceRoot, + 'src/app.ts', + "import { token } from '#/token';\n", + ); + const firstTargetPath = writeWorkspaceFile( + workspaceRoot, + 'src-a/token.ts', + 'export const token = 1;\n', + ); + const secondTargetPath = writeWorkspaceFile( + workspaceRoot, + 'src-b/token.ts', + 'export const token = 2;\n', + ); + + const plugin = createTypeScriptPlugin(); + const firstResult = await plugin.analyzeFile?.( + sourcePath, + "import { token } from '#/token';\n", + workspaceRoot, + ); + + writeWorkspaceFile(workspaceRoot, 'tsconfig.base.json', baseConfig('src-b')); + + const secondResult = await plugin.analyzeFile?.( + sourcePath, + "import { token } from '#/token';\n", + workspaceRoot, + ); + + expect(firstResult?.relations?.[0]?.resolvedPath).toBe(firstTargetPath); + expect(secondResult?.relations?.[0]?.resolvedPath).toBe(secondTargetPath); + } finally { + removeWorkspaceRoot(workspaceRoot); + } + }); + it('emits no relationships when nearest tsconfig has no paths', async () => { const workspaceRoot = createWorkspaceRoot(); try { From 964c6c38d6787a3a44b099b0e901b19bc1474a39 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 11:39:56 -0700 Subject: [PATCH 095/192] refactor: clear changed-code crap findings --- packages/core/src/diagnostics/events.ts | 255 ++++++++-------- .../src/graphCache/database/io/connection.ts | 20 +- packages/core/src/indexing/refresh.ts | 102 ++++--- .../src/treeSitter/runtime/languages/load.ts | 72 ++--- .../core/tests/diagnostics/events.test.ts | 37 +++ packages/core/tests/indexing/refresh.test.ts | 67 ++++ .../graphView/analysis/execution/load.ts | 104 ++++--- .../graphView/analysis/execution/publish.ts | 289 ++++++++++++------ .../graphView/provider/analysis/methods.ts | 104 +++---- .../graphView/webview/messages/listener.ts | 81 +++-- .../graphView/webview/messages/ready.ts | 22 +- .../pipeline/service/cache/cachedDiscovery.ts | 36 ++- .../pipeline/service/discoveryFacade.ts | 168 ++++++---- .../pipeline/service/refreshFacade.ts | 98 ++++-- packages/extension/src/shared/globMatch.ts | 189 ++++++++---- .../src/shared/visibleGraph/scope.ts | 55 +++- .../shared/visibleGraph/scope/symbolMatch.ts | 47 ++- .../components/graph/viewport/shell.tsx | 131 +++++--- .../components/graph/viewport/view.tsx | 248 +++++++++++---- .../webview/search/filtering/rules/nodes.ts | 73 +++-- .../src/webview/search/useFilteredGraph.ts | 176 ++++++++--- .../webview/store/messageHandlers/graph.ts | 35 +-- .../graphView/webview/messages/ready.test.ts | 27 ++ 23 files changed, 1579 insertions(+), 857 deletions(-) diff --git a/packages/core/src/diagnostics/events.ts b/packages/core/src/diagnostics/events.ts index 64be8f995..4a31fb8bb 100644 --- a/packages/core/src/diagnostics/events.ts +++ b/packages/core/src/diagnostics/events.ts @@ -22,6 +22,10 @@ export interface DiagnosticEventSink { emit(event: DiagnosticEvent): void; } +type DiagnosticEventFormatter = ( + context: Record | undefined, +) => string | undefined; + function normalizeError(error: Error): Record { return { name: error.name, @@ -29,15 +33,14 @@ function normalizeError(error: Error): Record { }; } -function normalizeContextValue(value: unknown): DiagnosticContextValue { - if (value === null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { - return value; - } - - if (value instanceof Error) { - return normalizeError(value); - } +function isScalarContextValue(value: unknown): value is null | string | number | boolean { + return value === null + || typeof value === 'string' + || typeof value === 'number' + || typeof value === 'boolean'; +} +function normalizeCollectionContextValue(value: unknown): DiagnosticContextValue | undefined { if (Array.isArray(value)) { return value.map(normalizeContextValue); } @@ -53,14 +56,22 @@ function normalizeContextValue(value: unknown): DiagnosticContextValue { })); } - if (typeof value === 'object') { - const normalized: Record = {}; - for (const [key, entryValue] of Object.entries(value)) { - normalized[key] = normalizeContextValue(entryValue); - } - return normalized; + return undefined; +} + +function normalizeObjectContextValue(value: unknown): DiagnosticContextValue | undefined { + if (value === null || typeof value !== 'object') { + return undefined; } + const normalized: Record = {}; + for (const [key, entryValue] of Object.entries(value)) { + normalized[key] = normalizeContextValue(entryValue); + } + return normalized; +} + +function normalizeNonJsonPrimitiveContextValue(value: unknown): DiagnosticContextValue | undefined { if (typeof value === 'undefined') { return 'undefined'; } @@ -77,7 +88,22 @@ function normalizeContextValue(value: unknown): DiagnosticContextValue { return value.name ? `[Function: ${value.name}]` : '[Function]'; } - return 'unknown'; + return undefined; +} + +function normalizeContextValue(value: unknown): DiagnosticContextValue { + if (isScalarContextValue(value)) { + return value; + } + + if (value instanceof Error) { + return normalizeError(value); + } + + return normalizeCollectionContextValue(value) + ?? normalizeObjectContextValue(value) + ?? normalizeNonJsonPrimitiveContextValue(value) + ?? 'unknown'; } function normalizeContext(context: Record | undefined): Record | undefined { @@ -221,132 +247,91 @@ function formatAnalysisEvent( event: string, context: Record | undefined, ): string | undefined { - if (event === 'request-started') { - return `Starting analysis: ${joinDetails([ - formatContextDetail(context, 'requestId', 'request'), - formatContextDetail(context, 'mode'), - formatContextDetail(context, 'filterPatternCount', 'filters'), - formatContextDetail(context, 'disabledPluginCount', 'disabledPlugins'), - ])}`; - } - - if (event === 'request-completed') { - return `Analysis complete: ${joinDetails([ - formatContextDetail(context, 'requestId', 'request'), - formatContextDetail(context, 'mode'), - formatContextDetail(context, 'durationMs', 'durationMs'), - ])}`; - } - - if (event === 'request-failed') { - return `Analysis failed: ${joinDetails([ - formatContextDetail(context, 'requestId', 'request'), - formatContextDetail(context, 'mode'), - formatContextDetail(context, 'durationMs', 'durationMs'), - formatContextDetail(context, 'error'), - ])}`; - } - - if (event === 'load-decision') { - return `Analysis load decision: ${joinDetails([ - formatContextDetail(context, 'route'), - formatContextDetail(context, 'mode'), - formatContextDetail(context, 'shouldDiscover'), - formatContextDetail(context, 'canReplayCache'), - formatContextDetail(context, 'indexFreshness', 'freshness'), - ])}`; - } - - return undefined; + return ANALYSIS_EVENT_FORMATTERS.get(event)?.(context); } -function formatKnownEvent(event: DiagnosticEvent): string | undefined { - const context = event.context; +const ANALYSIS_EVENT_FORMATTERS = new Map([ + ['request-started', context => `Starting analysis: ${joinDetails([ + formatContextDetail(context, 'requestId', 'request'), + formatContextDetail(context, 'mode'), + formatContextDetail(context, 'filterPatternCount', 'filters'), + formatContextDetail(context, 'disabledPluginCount', 'disabledPlugins'), + ])}`], + ['request-completed', context => `Analysis complete: ${joinDetails([ + formatContextDetail(context, 'requestId', 'request'), + formatContextDetail(context, 'mode'), + formatContextDetail(context, 'durationMs', 'durationMs'), + ])}`], + ['request-failed', context => `Analysis failed: ${joinDetails([ + formatContextDetail(context, 'requestId', 'request'), + formatContextDetail(context, 'mode'), + formatContextDetail(context, 'durationMs', 'durationMs'), + formatContextDetail(context, 'error'), + ])}`], + ['load-decision', context => `Analysis load decision: ${joinDetails([ + formatContextDetail(context, 'route'), + formatContextDetail(context, 'mode'), + formatContextDetail(context, 'shouldDiscover'), + formatContextDetail(context, 'canReplayCache'), + formatContextDetail(context, 'indexFreshness', 'freshness'), + ])}`], +]); + +const KNOWN_EVENT_FORMATTERS = new Map([ + ['workspace:index-started', context => `Starting indexing: ${joinDetails([ + formatContextDetail(context, 'workspaceRoot', 'workspace'), + formatContextDetail(context, 'operationId', 'operation'), + ])}`], + ['workspace:status-read', formatStatusRead], + ['indexing:completed', formatIndexingComplete], + ['indexing:phase-completed', formatIndexingPhaseCompleted], + ['graph-query:started', context => `Starting Graph Query: ${joinDetails([ + formatContextDetail(context, 'report'), + formatContextDetail(context, 'operationId', 'operation'), + formatContextDetail(context, 'workspaceRoot', 'workspace'), + ])}`], + ['graph-query:cache-missing', context => `Graph Cache missing: ${joinDetails([ + formatContextDetail(context, 'report'), + formatContextDetail(context, 'cacheState'), + formatContextDetail(context, 'operationId', 'operation'), + formatContextDetail(context, 'workspaceRoot', 'workspace'), + ])}`], + ['graph-query:completed', context => `Graph Query complete: ${joinDetails([ + formatContextDetail(context, 'report'), + formatCount(context?.nodeCount, 'node'), + formatCount(context?.edgeCount, 'edge'), + formatContextDetail(context, 'durationMs', 'durationMs'), + formatContextDetail(context, 'operationId', 'operation'), + ])}`], + ['extension.lifecycle:activation-started', context => `Extension activation started: ${joinDetails([ + formatContextDetail(context, 'workspaceFolders'), + ])}`], + ['extension.lifecycle:activation-completed', context => `Extension activation complete: ${joinDetails([ + formatContextDetail(context, 'registeredWebviewProviders'), + ])}`], + ['extension.webview:ready-replayed', context => `Webview ready replayed: ${joinDetails([ + formatContextDetail(context, 'hasWorkspace'), + formatContextDetail(context, 'firstAnalysis'), + formatContextDetail(context, 'readyNotified'), + formatContextDetail(context, 'maxFiles'), + ])}`], + ['extension.webview:bootstrap-completed', context => `Webview bootstrap complete: ${joinDetails([ + formatContextDetail(context, 'hasWorkspace'), + formatContextDetail(context, 'firstAnalysis'), + formatContextDetail(context, 'readyNotified'), + ])}`], +]); +function formatKnownEvent(event: DiagnosticEvent): string | undefined { if (event.area === 'cli') { - return formatCommandEvent(event.event, context); - } - - if (event.area === 'workspace' && event.event === 'index-started') { - return `Starting indexing: ${joinDetails([ - formatContextDetail(context, 'workspaceRoot', 'workspace'), - formatContextDetail(context, 'operationId', 'operation'), - ])}`; - } - - if (event.area === 'workspace' && event.event === 'status-read') { - return formatStatusRead(context); - } - - if (event.area === 'indexing' && event.event === 'completed') { - return formatIndexingComplete(context); - } - - if (event.area === 'indexing' && event.event === 'phase-completed') { - return formatIndexingPhaseCompleted(context); - } - - if (event.area === 'graph-query' && event.event === 'started') { - return `Starting Graph Query: ${joinDetails([ - formatContextDetail(context, 'report'), - formatContextDetail(context, 'operationId', 'operation'), - formatContextDetail(context, 'workspaceRoot', 'workspace'), - ])}`; - } - - if (event.area === 'graph-query' && event.event === 'cache-missing') { - return `Graph Cache missing: ${joinDetails([ - formatContextDetail(context, 'report'), - formatContextDetail(context, 'cacheState'), - formatContextDetail(context, 'operationId', 'operation'), - formatContextDetail(context, 'workspaceRoot', 'workspace'), - ])}`; - } - - if (event.area === 'graph-query' && event.event === 'completed') { - return `Graph Query complete: ${joinDetails([ - formatContextDetail(context, 'report'), - formatCount(context?.nodeCount, 'node'), - formatCount(context?.edgeCount, 'edge'), - formatContextDetail(context, 'durationMs', 'durationMs'), - formatContextDetail(context, 'operationId', 'operation'), - ])}`; - } - - if (event.area === 'extension.lifecycle' && event.event === 'activation-started') { - return `Extension activation started: ${joinDetails([ - formatContextDetail(context, 'workspaceFolders'), - ])}`; - } - - if (event.area === 'extension.lifecycle' && event.event === 'activation-completed') { - return `Extension activation complete: ${joinDetails([ - formatContextDetail(context, 'registeredWebviewProviders'), - ])}`; - } - - if (event.area === 'extension.webview' && event.event === 'ready-replayed') { - return `Webview ready replayed: ${joinDetails([ - formatContextDetail(context, 'hasWorkspace'), - formatContextDetail(context, 'firstAnalysis'), - formatContextDetail(context, 'readyNotified'), - formatContextDetail(context, 'maxFiles'), - ])}`; - } - - if (event.area === 'extension.webview' && event.event === 'bootstrap-completed') { - return `Webview bootstrap complete: ${joinDetails([ - formatContextDetail(context, 'hasWorkspace'), - formatContextDetail(context, 'firstAnalysis'), - formatContextDetail(context, 'readyNotified'), - ])}`; + return formatCommandEvent(event.event, event.context); } if (event.area === 'extension.analysis') { - return formatAnalysisEvent(event.event, context); + return formatAnalysisEvent(event.event, event.context); } - return undefined; + return KNOWN_EVENT_FORMATTERS.get(`${event.area}:${event.event}`)?.(event.context); } function humanizeEventName(event: string): string { @@ -365,7 +350,11 @@ function formatFallbackEvent(event: DiagnosticEvent): string { ...Object.keys(event.context ?? {}).map(key => formatContextDetail(event.context, key)), ]); const message = humanizeEventName(event.event); - return details ? `${message}: ${details}` : message; + if (details) { + return `${message}: ${details}`; + } + + return message; } export function formatDiagnosticEventLine(event: DiagnosticEvent): string { diff --git a/packages/core/src/graphCache/database/io/connection.ts b/packages/core/src/graphCache/database/io/connection.ts index ed15132f7..04051df6d 100644 --- a/packages/core/src/graphCache/database/io/connection.ts +++ b/packages/core/src/graphCache/database/io/connection.ts @@ -20,6 +20,20 @@ function closeQueryResults(result: unknown): void { } } +function firstQueryResult(result: unknown): LadybugQueryResultLike | undefined { + return (Array.isArray(result) ? result[0] : result) as LadybugQueryResultLike | undefined; +} + +function readRowsFromQueryResultSync(queryResult: LadybugQueryResultLike | undefined): FileAnalysisRow[] { + return queryResult?.getAllSync?.() ?? []; +} + +async function readRowsFromQueryResultAsync( + queryResult: LadybugQueryResultLike | undefined, +): Promise { + return queryResult?.getAll?.() ?? []; +} + export function runStatementSync(connection: lb.Connection, statement: string): void { const result = connection.querySync(statement); closeQueryResults(result); @@ -74,8 +88,7 @@ export function readRowsSync(connection: lb.Connection, statement: string): File const result = connection.querySync(statement); try { - const queryResult = Array.isArray(result) ? result[0] : result; - return (queryResult as LadybugQueryResultLike | undefined)?.getAllSync?.() ?? []; + return readRowsFromQueryResultSync(firstQueryResult(result)); } finally { closeQueryResults(result); } @@ -85,8 +98,7 @@ export async function readRowsAsync(connection: lb.Connection, statement: string const result = await connection.query(statement); try { - const queryResult = Array.isArray(result) ? result[0] : result; - return await ((queryResult as LadybugQueryResultLike | undefined)?.getAll?.() ?? []); + return await readRowsFromQueryResultAsync(firstQueryResult(result)); } finally { closeQueryResults(result); } diff --git a/packages/core/src/indexing/refresh.ts b/packages/core/src/indexing/refresh.ts index 6bf97e9d5..46dee7add 100644 --- a/packages/core/src/indexing/refresh.ts +++ b/packages/core/src/indexing/refresh.ts @@ -44,6 +44,7 @@ export interface WorkspaceIndexRefreshSource { _lastGraphData: IGraphData; _lastWorkspaceRoot: string; _patchGraphDataNodeMetrics?( + this: void, graphData: IGraphData, filePaths: readonly string[], ): IGraphData; @@ -194,14 +195,18 @@ function buildWorkspaceIndexGraphFromRefreshState( return graphData; } +function listOrEmpty(value: readonly T[] | undefined): readonly T[] { + return value ?? []; +} + function serializeWorkspaceIndexGraphAnalysis(analysis: IFileAnalysisResult): string { return JSON.stringify({ - edgeTypes: analysis.edgeTypes ?? [], + edgeTypes: listOrEmpty(analysis.edgeTypes), filePath: analysis.filePath, - nodeTypes: analysis.nodeTypes ?? [], - nodes: analysis.nodes ?? [], - relations: analysis.relations ?? [], - symbols: analysis.symbols ?? [], + nodeTypes: listOrEmpty(analysis.nodeTypes), + nodes: listOrEmpty(analysis.nodes), + relations: listOrEmpty(analysis.relations), + symbols: listOrEmpty(analysis.symbols), }); } @@ -216,33 +221,67 @@ interface WorkspaceIndexRefreshGraphSnapshot { fileConnectionsByPath: Map; } +function canCaptureWorkspaceIndexRefreshGraphSnapshot(source: WorkspaceIndexRefreshSource): boolean { + return Boolean(source._patchGraphDataNodeMetrics) && !isWorkspaceIndexGraphDataEmpty(source._lastGraphData); +} + +function isWorkspaceIndexGraphDataEmpty(graphData: IGraphData): boolean { + return graphData.nodes.length === 0 && graphData.edges.length === 0; +} + function captureWorkspaceIndexRefreshGraphSnapshot( source: WorkspaceIndexRefreshSource, files: readonly IDiscoveredFile[], ): WorkspaceIndexRefreshGraphSnapshot | undefined { - if ( - !source._patchGraphDataNodeMetrics - || (source._lastGraphData.nodes.length === 0 && source._lastGraphData.edges.length === 0) - ) { + if (!canCaptureWorkspaceIndexRefreshGraphSnapshot(source)) { return undefined; } - const fileAnalysisByPath = new Map(); - const fileConnectionsByPath = new Map(); + const snapshot: WorkspaceIndexRefreshGraphSnapshot = { + fileAnalysisByPath: new Map(), + fileConnectionsByPath: new Map(), + }; + for (const file of files) { - const analysis = source._lastFileAnalysis.get(file.relativePath); - if (!analysis) { + if (!captureWorkspaceIndexRefreshSnapshotFile(source, snapshot, file.relativePath)) { return undefined; } + } - fileAnalysisByPath.set(file.relativePath, serializeWorkspaceIndexGraphAnalysis(analysis)); - fileConnectionsByPath.set( - file.relativePath, - serializeWorkspaceIndexConnections(source._lastFileConnections.get(file.relativePath)), - ); + return snapshot; +} + +function captureWorkspaceIndexRefreshSnapshotFile( + source: WorkspaceIndexRefreshSource, + snapshot: WorkspaceIndexRefreshGraphSnapshot, + relativePath: string, +): boolean { + const analysis = source._lastFileAnalysis.get(relativePath); + if (!analysis) { + return false; } - return { fileAnalysisByPath, fileConnectionsByPath }; + snapshot.fileAnalysisByPath.set(relativePath, serializeWorkspaceIndexGraphAnalysis(analysis)); + snapshot.fileConnectionsByPath.set( + relativePath, + serializeWorkspaceIndexConnections(source._lastFileConnections.get(relativePath)), + ); + return true; +} + +function workspaceIndexRefreshSnapshotMatchesFile( + snapshot: WorkspaceIndexRefreshGraphSnapshot, + analysisResult: IWorkspaceFileAnalysisResult, + relativePath: string, +): boolean { + const analysis = analysisResult.fileAnalysis.get(relativePath); + if (!analysis) { + return false; + } + + return snapshot.fileAnalysisByPath.get(relativePath) === serializeWorkspaceIndexGraphAnalysis(analysis) + && snapshot.fileConnectionsByPath.get(relativePath) + === serializeWorkspaceIndexConnections(analysisResult.fileConnections.get(relativePath)); } function canPatchWorkspaceIndexRefreshGraphData( @@ -254,28 +293,9 @@ function canPatchWorkspaceIndexRefreshGraphData( return false; } - for (const file of files) { - const analysis = analysisResult.fileAnalysis.get(file.relativePath); - if (!analysis) { - return false; - } - - if ( - snapshot.fileAnalysisByPath.get(file.relativePath) - !== serializeWorkspaceIndexGraphAnalysis(analysis) - ) { - return false; - } - - if ( - snapshot.fileConnectionsByPath.get(file.relativePath) - !== serializeWorkspaceIndexConnections(analysisResult.fileConnections.get(file.relativePath)) - ) { - return false; - } - } - - return true; + return files.every(file => + workspaceIndexRefreshSnapshotMatchesFile(snapshot, analysisResult, file.relativePath), + ); } function persistMetricOnlyIndexMetadata( diff --git a/packages/core/src/treeSitter/runtime/languages/load.ts b/packages/core/src/treeSitter/runtime/languages/load.ts index 40efd89ea..d486006f9 100644 --- a/packages/core/src/treeSitter/runtime/languages/load.ts +++ b/packages/core/src/treeSitter/runtime/languages/load.ts @@ -3,6 +3,7 @@ import type { TreeSitterRuntimeBinding } from './kinds'; type TreeSitterConstructor = new () => Parser; type TreeSitterLanguageBindingName = TreeSitterRuntimeBinding['language']; +type TreeSitterLanguageLoader = () => Promise; export interface ITreeSitterBindings { ParserCtor: TreeSitterConstructor; @@ -46,53 +47,36 @@ function loadTreeSitterParserCtor(): Promise { return treeSitterParserCtorPromise; } +const TREE_SITTER_LANGUAGE_LOADERS: Record = { + cLanguage: async () => (await import('tree-sitter-c')).default as unknown as Parser.Language, + cpp: async () => (await import('tree-sitter-cpp')).default as unknown as Parser.Language, + csharp: async () => (await import('tree-sitter-c-sharp')).default as unknown as Parser.Language, + dart: async () => (await import('@driftlog/tree-sitter-dart')).default as unknown as Parser.Language, + go: async () => (await import('tree-sitter-go')).default as unknown as Parser.Language, + haskell: async () => (await import('tree-sitter-haskell')).default as unknown as Parser.Language, + java: async () => (await import('tree-sitter-java')).default as unknown as Parser.Language, + javaScript: async () => (await import('tree-sitter-javascript')).default as unknown as Parser.Language, + kotlin: async () => (await import('@tree-sitter-grammars/tree-sitter-kotlin')).default as unknown as Parser.Language, + lua: async () => (await import('@tree-sitter-grammars/tree-sitter-lua')).default as unknown as Parser.Language, + objectiveC: async () => (await import('tree-sitter-objc')).default as unknown as Parser.Language, + php: async () => ((await import('tree-sitter-php')).default as unknown as { php: Parser.Language }).php, + python: async () => (await import('tree-sitter-python')).default as unknown as Parser.Language, + ruby: async () => (await import('tree-sitter-ruby')).default as unknown as Parser.Language, + rust: async () => (await import('tree-sitter-rust')).default as unknown as Parser.Language, + scala: async () => (await import('tree-sitter-scala')).default as unknown as Parser.Language, + swift: async () => (await import('tree-sitter-swift')).default as unknown as Parser.Language, + tsx: async () => ((await import('tree-sitter-typescript')).default as unknown as { + tsx: Parser.Language; + }).tsx, + typeScript: async () => ((await import('tree-sitter-typescript')).default as unknown as { + typescript: Parser.Language; + }).typescript, +}; + async function loadTreeSitterLanguage( language: TreeSitterLanguageBindingName, ): Promise { - switch (language) { - case 'cLanguage': - return (await import('tree-sitter-c')).default as unknown as Parser.Language; - case 'cpp': - return (await import('tree-sitter-cpp')).default as unknown as Parser.Language; - case 'csharp': - return (await import('tree-sitter-c-sharp')).default as unknown as Parser.Language; - case 'dart': - return (await import('@driftlog/tree-sitter-dart')).default as unknown as Parser.Language; - case 'go': - return (await import('tree-sitter-go')).default as unknown as Parser.Language; - case 'haskell': - return (await import('tree-sitter-haskell')).default as unknown as Parser.Language; - case 'java': - return (await import('tree-sitter-java')).default as unknown as Parser.Language; - case 'javaScript': - return (await import('tree-sitter-javascript')).default as unknown as Parser.Language; - case 'kotlin': - return (await import('@tree-sitter-grammars/tree-sitter-kotlin')).default as unknown as Parser.Language; - case 'lua': - return (await import('@tree-sitter-grammars/tree-sitter-lua')).default as unknown as Parser.Language; - case 'objectiveC': - return (await import('tree-sitter-objc')).default as unknown as Parser.Language; - case 'php': - return ((await import('tree-sitter-php')).default as unknown as { php: Parser.Language }).php; - case 'python': - return (await import('tree-sitter-python')).default as unknown as Parser.Language; - case 'ruby': - return (await import('tree-sitter-ruby')).default as unknown as Parser.Language; - case 'rust': - return (await import('tree-sitter-rust')).default as unknown as Parser.Language; - case 'scala': - return (await import('tree-sitter-scala')).default as unknown as Parser.Language; - case 'swift': - return (await import('tree-sitter-swift')).default as unknown as Parser.Language; - case 'tsx': - return ((await import('tree-sitter-typescript')).default as unknown as { - tsx: Parser.Language; - }).tsx; - case 'typeScript': - return ((await import('tree-sitter-typescript')).default as unknown as { - typescript: Parser.Language; - }).typescript; - } + return TREE_SITTER_LANGUAGE_LOADERS[language](); } const treeSitterLanguageBindingPromises = new Map< diff --git a/packages/core/tests/diagnostics/events.test.ts b/packages/core/tests/diagnostics/events.test.ts index d648e120d..70f40c94a 100644 --- a/packages/core/tests/diagnostics/events.test.ts +++ b/packages/core/tests/diagnostics/events.test.ts @@ -69,6 +69,16 @@ describe('diagnostics/events', () => { })).toBe('[CodeGraphy] Indexing phase complete: phase=analyze-files, durationMs=2750, files=42, cacheHits=20, cacheMisses=22'); }); + it('formats unknown events with readable fallback context', () => { + expect(formatDiagnosticEventLine({ + area: 'graph-cache', + event: 'cache-load-failed', + context: { + operationId: 'index-1', + }, + })).toBe('[CodeGraphy] Cache load failed: area=graph-cache, operationId=index-1'); + }); + it('normalizes non-JSON primitive context values into readable strings', () => { function namedDiagnosticFunction(): void { // The function name is the diagnostic payload under test. @@ -94,4 +104,31 @@ describe('diagnostics/events', () => { }, }); }); + + it('normalizes nested object context values into JSON-safe values', () => { + expect(createDiagnosticEvent({ + area: 'diagnostics', + event: 'nested', + context: { + payload: { + enabled: true, + paths: new Set(['src/app.ts']), + error: new Error('nested failure'), + }, + }, + })).toEqual({ + area: 'diagnostics', + event: 'nested', + context: { + payload: { + enabled: true, + paths: ['src/app.ts'], + error: { + name: 'Error', + message: 'nested failure', + }, + }, + }, + }); + }); }); diff --git a/packages/core/tests/indexing/refresh.test.ts b/packages/core/tests/indexing/refresh.test.ts index 12cdeae43..d42aa4acc 100644 --- a/packages/core/tests/indexing/refresh.test.ts +++ b/packages/core/tests/indexing/refresh.test.ts @@ -368,4 +368,71 @@ describe('indexing/refresh', () => { ); expect(source._buildGraphData).not.toHaveBeenCalled(); }); + + it('patches only node metrics when changed-file analysis preserves graph structure', async () => { + const graph: IGraphData = { + nodes: [createGraphNode('src/app.ts')], + edges: [], + }; + const patchGraphDataNodeMetrics = vi.fn(() => graph); + const source = createSource({ + _lastFileAnalysis: new Map([ + ['src/app.ts', createFileAnalysis('/workspace/src/app.ts')], + ]), + _lastFileConnections: new Map([ + ['src/app.ts', []], + ]), + _patchGraphDataNodeMetrics: patchGraphDataNodeMetrics, + }); + const previousGraphData = source._lastGraphData; + + await expect(refreshWorkspaceIndexChangedFiles(source, refreshOptions({ + persistIndexMetadata: vi.fn(async () => undefined), + }))).resolves.toBe(graph); + + expect(patchGraphDataNodeMetrics).toHaveBeenCalledWith( + previousGraphData, + ['src/app.ts'], + ); + expect(source._buildGraphDataFromAnalysis).not.toHaveBeenCalled(); + }); + + it('rebuilds the graph when changed-file analysis changes graph structure', async () => { + const graph: IGraphData = { + nodes: [createGraphNode('src/app.ts'), createGraphNode('src/next.ts')], + edges: [], + }; + const source = createSource({ + _analyzeFiles: vi.fn(async () => ({ + cacheHits: 0, + cacheMisses: 1, + fileAnalysis: new Map([ + ['src/app.ts', createFileAnalysis('/workspace/src/app.changed.ts')], + ]), + fileConnections: new Map([ + ['src/app.ts', []], + ]), + })), + _buildGraphDataFromAnalysis: vi.fn(() => graph), + _lastFileAnalysis: new Map([ + ['src/app.ts', createFileAnalysis('/workspace/src/app.ts')], + ]), + _lastFileConnections: new Map([ + ['src/app.ts', []], + ]) as Map, + _patchGraphDataNodeMetrics: vi.fn(() => ({ + nodes: [createGraphNode('patched')], + edges: [], + })), + }); + + await expect(refreshWorkspaceIndexChangedFiles(source, refreshOptions())).resolves.toBe(graph); + + expect(source._patchGraphDataNodeMetrics).not.toHaveBeenCalled(); + expect(source._buildGraphDataFromAnalysis).toHaveBeenCalledWith( + source._lastFileAnalysis, + '/workspace', + new Set(), + ); + }); }); diff --git a/packages/extension/src/extension/graphView/analysis/execution/load.ts b/packages/extension/src/extension/graphView/analysis/execution/load.ts index ab08be259..91aaacf9f 100644 --- a/packages/extension/src/extension/graphView/analysis/execution/load.ts +++ b/packages/extension/src/extension/graphView/analysis/execution/load.ts @@ -22,6 +22,17 @@ import { selectGraphViewRawDataLoadDecision } from './load/routing'; import type { GraphViewRawDataLoadDecision } from './load/routing'; import type { CodeGraphyIndexFreshness } from '../../../repoSettings/freshness'; +type GraphViewRawDataRoute = GraphViewRawDataLoadDecision['route']; +type GraphViewAnalyzer = NonNullable; + +interface GraphViewRawDataLoadContext { + analyzer: GraphViewAnalyzer; + forwardProgress: ReturnType; + indexFreshness: CodeGraphyIndexFreshness | undefined; + signal: AbortSignal; + state: GraphViewAnalysisExecutionState; +} + function hasReplayableGraphData(graphData: IGraphData): boolean { return graphData.nodes.length > 0 || graphData.edges.length > 0; } @@ -51,6 +62,48 @@ function selectGraphViewRawDataLoadDecisionForState( }; } +async function loadDiscoveredGraphViewRawData(context: GraphViewRawDataLoadContext): Promise { + return discoverGraphViewRawData(context.signal, context.state, context.analyzer); +} + +async function loadCachedOrRefreshedGraphViewRawData(context: GraphViewRawDataLoadContext): Promise { + const cachedGraphData = await loadCachedGraphViewRawData(context.signal, context.state, context.analyzer, { + includeCurrentGitignoreMetadata: context.indexFreshness !== 'stale', + ...(context.indexFreshness === 'stale' ? { warmAnalysis: false } : {}), + }); + + return hasReplayableGraphData(cachedGraphData) + ? cachedGraphData + : refreshGraphViewRawData(context.signal, context.state, context.forwardProgress); +} + +async function loadRefreshedGraphViewRawData(context: GraphViewRawDataLoadContext): Promise { + return refreshGraphViewRawData(context.signal, context.state, context.forwardProgress); +} + +async function loadIncrementalGraphViewRawData(context: GraphViewRawDataLoadContext): Promise { + return refreshIncrementalGraphViewRawData(context.signal, context.state, context.forwardProgress); +} + +async function loadAnalyzedGraphViewRawData(context: GraphViewRawDataLoadContext): Promise { + return analyzeGraphViewRawData( + context.signal, + context.state, + context.analyzer, + context.forwardProgress, + ); +} + +const GRAPH_VIEW_RAW_DATA_LOADERS: Record Promise> = { + analyze: loadAnalyzedGraphViewRawData, + cached: loadCachedOrRefreshedGraphViewRawData, + discover: loadDiscoveredGraphViewRawData, + incremental: loadIncrementalGraphViewRawData, + refresh: loadRefreshedGraphViewRawData, +}; + export async function loadGraphViewRawData( signal: AbortSignal, state: GraphViewAnalysisExecutionState, @@ -80,50 +133,13 @@ export async function loadGraphViewRawData( sendInitialGraphViewAnalysisProgress(state.mode, handlers); } - if (decision.route === 'discover') { - const rawGraphData = await discoverGraphViewRawData(signal, state, analyzer); - return { - rawGraphData, - shouldDiscover: decision.shouldDiscover, - }; - } - - if (decision.route === 'cached') { - const cachedGraphData = await loadCachedGraphViewRawData(signal, state, analyzer, { - includeCurrentGitignoreMetadata: indexFreshness !== 'stale', - ...(indexFreshness === 'stale' ? { warmAnalysis: false } : {}), - }); - if (hasReplayableGraphData(cachedGraphData)) { - return { - rawGraphData: cachedGraphData, - shouldDiscover: decision.shouldDiscover, - }; - } - - const rawGraphData = await refreshGraphViewRawData(signal, state, forwardProgress); - return { - rawGraphData, - shouldDiscover: decision.shouldDiscover, - }; - } - - if (decision.route === 'refresh') { - const rawGraphData = await refreshGraphViewRawData(signal, state, forwardProgress); - return { - rawGraphData, - shouldDiscover: decision.shouldDiscover, - }; - } - - if (decision.route === 'incremental') { - const rawGraphData = await refreshIncrementalGraphViewRawData(signal, state, forwardProgress); - return { - rawGraphData, - shouldDiscover: decision.shouldDiscover, - }; - } - - const rawGraphData = await analyzeGraphViewRawData(signal, state, analyzer, forwardProgress); + const rawGraphData = await GRAPH_VIEW_RAW_DATA_LOADERS[decision.route]({ + analyzer, + forwardProgress, + indexFreshness, + signal, + state, + }); return { rawGraphData, shouldDiscover: decision.shouldDiscover, diff --git a/packages/extension/src/extension/graphView/analysis/execution/publish.ts b/packages/extension/src/extension/graphView/analysis/execution/publish.ts index 3b8b96c92..ec761b51d 100644 --- a/packages/extension/src/extension/graphView/analysis/execution/publish.ts +++ b/packages/extension/src/extension/graphView/analysis/execution/publish.ts @@ -51,19 +51,21 @@ function areGraphGroupSymbolInputsEqual( left: IGraphNode['symbol'], right: IGraphNode['symbol'], ): boolean { - if (left === right) { - return true; - } + return createGraphGroupSymbolSignature(left) === createGraphGroupSymbolSignature(right); +} - if (!left || !right) { - return false; +function createGraphGroupSymbolSignature(symbol: IGraphNode['symbol']): string | undefined { + if (!symbol) { + return undefined; } - return left.kind === right.kind - && left.pluginKind === right.pluginKind - && left.source === right.source - && left.language === right.language - && left.filePath === right.filePath; + return JSON.stringify([ + symbol.kind, + symbol.pluginKind, + symbol.source, + symbol.language, + symbol.filePath, + ]); } function areGraphGroupNodeInputsEqual(left: IGraphNode, right: IGraphNode): boolean { @@ -157,28 +159,7 @@ function createNodeMap(nodes: readonly IGraphNode[]): Map { return new Map(nodes.map(node => [node.id, node])); } -function areGraphValuesEqual(left: unknown, right: unknown): boolean { - if (Object.is(left, right)) { - return true; - } - - if (Array.isArray(left) || Array.isArray(right)) { - if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) { - return false; - } - - return left.every((leftValue, index) => areGraphValuesEqual(leftValue, right[index])); - } - - if ( - left === null - || right === null - || typeof left !== 'object' - || typeof right !== 'object' - ) { - return false; - } - +function areGraphRecordsEqual(left: Record, right: Record): boolean { const leftRecord = left as unknown as Record; const rightRecord = right as unknown as Record; const keys = new Set([...Object.keys(leftRecord), ...Object.keys(rightRecord)]); @@ -191,6 +172,37 @@ function areGraphValuesEqual(left: unknown, right: unknown): boolean { return true; } +function areGraphArraysEqual(left: readonly unknown[], right: readonly unknown[]): boolean { + return left.length === right.length + && left.every((leftValue, index) => areGraphValuesEqual(leftValue, right[index])); +} + +function isGraphRecord(value: unknown): value is Record { + return value !== null + && typeof value === 'object' + && !Array.isArray(value); +} + +function compareGraphArrayValues(left: unknown, right: unknown): boolean | undefined { + if (!Array.isArray(left) && !Array.isArray(right)) { + return undefined; + } + + return Array.isArray(left) && Array.isArray(right) && areGraphArraysEqual(left, right); +} + +function compareGraphRecordValues(left: unknown, right: unknown): boolean { + return isGraphRecord(left) && isGraphRecord(right) && areGraphRecordsEqual(left, right); +} + +function areGraphValuesEqual(left: unknown, right: unknown): boolean { + if (Object.is(left, right)) { + return true; + } + + return compareGraphArrayValues(left, right) ?? compareGraphRecordValues(left, right); +} + function areNodesEqualIgnoringMetrics(left: IGraphNode, right: IGraphNode): boolean { if (left === right) { return true; @@ -248,12 +260,8 @@ function createMetricOnlyGraphUpdate( nextRawGraphData: IGraphData, changedFilePaths: readonly string[] | undefined, ): IGraphNodeMetricsUpdate[] | undefined { - if ( - !currentRawGraphData - || !changedFilePaths?.length - || currentRawGraphData.nodes.length !== nextRawGraphData.nodes.length - || currentRawGraphData.edges.length !== nextRawGraphData.edges.length - ) { + const changedPaths = changedFilePaths ?? []; + if (!canConsiderMetricOnlyGraphUpdate(currentRawGraphData, nextRawGraphData, changedFilePaths)) { return undefined; } @@ -261,13 +269,44 @@ function createMetricOnlyGraphUpdate( return undefined; } - const currentNodes = collectChangedPathNodes(currentRawGraphData, changedFilePaths); - const nextNodes = collectChangedPathNodes(nextRawGraphData, changedFilePaths); + const currentNodes = collectChangedPathNodes(currentRawGraphData, changedPaths); + const nextNodes = collectChangedPathNodes(nextRawGraphData, changedPaths); if (currentNodes.length === 0 || currentNodes.length !== nextNodes.length) { return undefined; } - const nextNodesById = createNodeMap(nextNodes); + return collectMetricOnlyGraphUpdates(currentNodes, createNodeMap(nextNodes)); +} + +function canConsiderMetricOnlyGraphUpdate( + currentRawGraphData: IGraphData | undefined, + nextRawGraphData: IGraphData, + changedFilePaths: readonly string[] | undefined, +): currentRawGraphData is IGraphData { + return Boolean( + currentRawGraphData + && changedFilePaths?.length + && currentRawGraphData.nodes.length === nextRawGraphData.nodes.length + && currentRawGraphData.edges.length === nextRawGraphData.edges.length, + ); +} + +function haveGraphNodeMetricsChanged(currentNode: IGraphNode, nextNode: IGraphNode): boolean { + return currentNode.fileSize !== nextNode.fileSize || currentNode.churn !== nextNode.churn; +} + +function createGraphNodeMetricsUpdate(nextNode: IGraphNode): IGraphNodeMetricsUpdate { + return { + id: nextNode.id, + fileSize: nextNode.fileSize, + churn: nextNode.churn, + }; +} + +function collectMetricOnlyGraphUpdates( + currentNodes: readonly IGraphNode[], + nextNodesById: ReadonlyMap, +): IGraphNodeMetricsUpdate[] | undefined { const updates: IGraphNodeMetricsUpdate[] = []; for (const currentNode of currentNodes) { @@ -276,23 +315,12 @@ function createMetricOnlyGraphUpdate( return undefined; } - if ( - currentNode.fileSize !== nextNode.fileSize - || currentNode.churn !== nextNode.churn - ) { - updates.push({ - id: nextNode.id, - fileSize: nextNode.fileSize, - churn: nextNode.churn, - }); + if (haveGraphNodeMetricsChanged(currentNode, nextNode)) { + updates.push(createGraphNodeMetricsUpdate(nextNode)); } } - if (updates.length === 0) { - return undefined; - } - - return updates; + return updates.length > 0 ? updates : undefined; } function canReuseCurrentGraphPublication( @@ -309,7 +337,105 @@ function canReuseCurrentGraphPublication( return currentRawGraphData ? !hasChangedNodeMetricDifference(currentRawGraphData, rawGraphData, state.changedFilePaths) && areGraphDataPayloadsEqual(currentRawGraphData, rawGraphData) - : false; + : false; +} + +interface GraphPublicationPlan { + currentRawGraphData: IGraphData | undefined; + metricOnlyUpdate: IGraphNodeMetricsUpdate[] | undefined; + reuseCurrentGraphPublication: boolean; + shouldSendMetricPatch: boolean; +} + +function createGraphPublicationPlan( + state: GraphViewAnalysisExecutionState, + handlers: GraphViewAnalysisExecutionHandlers, + rawGraphData: IGraphData, + actualHasIndex: boolean, + freshness: CodeGraphyIndexFreshness, +): GraphPublicationPlan { + const currentRawGraphData = handlers.getRawGraphData?.(); + const metricOnlyUpdate = createMetricOnlyGraphUpdate( + currentRawGraphData, + rawGraphData, + state.changedFilePaths, + ); + + return { + currentRawGraphData, + metricOnlyUpdate, + reuseCurrentGraphPublication: canReuseCurrentGraphPublication( + state, + currentRawGraphData, + rawGraphData, + actualHasIndex, + freshness, + ), + shouldSendMetricPatch: metricOnlyUpdate !== undefined + && handlers.sendGraphNodeMetricsUpdated !== undefined, + }; +} + +function publishRawGraphUpdate( + state: GraphViewAnalysisExecutionState, + handlers: GraphViewAnalysisExecutionHandlers, + rawGraphData: IGraphData, + plan: GraphPublicationPlan, +): void { + if (plan.reuseCurrentGraphPublication) { + return; + } + + handlers.setRawGraphData(rawGraphData); + handlers.updateViewContext(); + handlers.applyViewTransform(); + publishGraphGroupsIfNeeded(state, handlers, rawGraphData, plan.currentRawGraphData); +} + +function publishGraphGroupsIfNeeded( + state: GraphViewAnalysisExecutionState, + handlers: GraphViewAnalysisExecutionHandlers, + rawGraphData: IGraphData, + currentRawGraphData: IGraphData | undefined, +): void { + const canSkipGroupPublication = state.mode === 'incremental' + && currentRawGraphData + && !doGraphViewGroupsNeedRecompute(currentRawGraphData, rawGraphData); + + if (canSkipGroupPublication) { + return; + } + + handlers.computeMergedGroups(); + handlers.sendGroupsUpdated(); +} + +function publishStaticGraphMessages(handlers: GraphViewAnalysisExecutionHandlers): void { + handlers.sendDepthState(); + handlers.sendPluginStatuses(); + handlers.sendDecorations(); + handlers.sendContextMenuItems(); + handlers.sendPluginExporters?.(); + handlers.sendPluginToolbarActions?.(); + handlers.sendGraphViewContributionStatuses?.(); + handlers.sendPluginWebviewInjections?.(); +} + +function publishGraphDataMessage( + handlers: GraphViewAnalysisExecutionHandlers, + graphData: IGraphData, + plan: GraphPublicationPlan, +): void { + if (plan.reuseCurrentGraphPublication) { + return; + } + + if (plan.shouldSendMetricPatch && plan.metricOnlyUpdate) { + handlers.sendGraphNodeMetricsUpdated?.(plan.metricOnlyUpdate); + return; + } + + handlers.sendGraphDataUpdated(graphData); } export function publishEmptyGraph( @@ -333,6 +459,7 @@ export function publishAnalyzedGraph( ): void { const actualHasIndex = state.analyzer?.hasIndex() ?? hasIndex; const status = resolveGraphIndexStatus(state, actualHasIndex); + if (shouldReportGraphViewUpdateProgress(state)) { handlers.sendIndexProgress?.({ phase: 'Updating Graph View', @@ -341,57 +468,21 @@ export function publishAnalyzedGraph( }); } - const currentRawGraphData = handlers.getRawGraphData?.(); - const metricOnlyUpdate = createMetricOnlyGraphUpdate( - currentRawGraphData, - rawGraphData, - state.changedFilePaths, - ); - const shouldSendMetricPatch = metricOnlyUpdate !== undefined - && handlers.sendGraphNodeMetricsUpdated !== undefined; - const reuseCurrentGraphPublication = canReuseCurrentGraphPublication( + const plan = createGraphPublicationPlan( state, - currentRawGraphData, + handlers, rawGraphData, actualHasIndex, status.freshness, ); - - if (!reuseCurrentGraphPublication) { - handlers.setRawGraphData(rawGraphData); - - handlers.updateViewContext(); - handlers.applyViewTransform(); - - const canSkipGroupPublication = state.mode === 'incremental' - && currentRawGraphData - && !doGraphViewGroupsNeedRecompute(currentRawGraphData, rawGraphData); - if (!canSkipGroupPublication) { - handlers.computeMergedGroups(); - - handlers.sendGroupsUpdated(); - } - } - - if (!shouldSendMetricPatch) { - handlers.sendDepthState(); - handlers.sendPluginStatuses(); - handlers.sendDecorations(); - handlers.sendContextMenuItems(); - handlers.sendPluginExporters?.(); - handlers.sendPluginToolbarActions?.(); - handlers.sendGraphViewContributionStatuses?.(); - handlers.sendPluginWebviewInjections?.(); - } + publishRawGraphUpdate(state, handlers, rawGraphData, plan); const graphData = handlers.getGraphData(); - if (!reuseCurrentGraphPublication) { - if (shouldSendMetricPatch) { - handlers.sendGraphNodeMetricsUpdated?.(metricOnlyUpdate); - } else { - handlers.sendGraphDataUpdated(graphData); - } + if (!plan.shouldSendMetricPatch) { + publishStaticGraphMessages(handlers); } + publishGraphDataMessage(handlers, graphData, plan); + handlers.sendGraphIndexStatusUpdated(actualHasIndex, status.freshness, status.detail); state.analyzer?.registry.notifyPostAnalyze(graphData, state.disabledPlugins); handlers.markWorkspaceReady(graphData, state.disabledPlugins); diff --git a/packages/extension/src/extension/graphView/provider/analysis/methods.ts b/packages/extension/src/extension/graphView/provider/analysis/methods.ts index 8e9ae0994..980ea2a3c 100644 --- a/packages/extension/src/extension/graphView/provider/analysis/methods.ts +++ b/packages/extension/src/extension/graphView/provider/analysis/methods.ts @@ -145,105 +145,107 @@ interface FullIndexAnalysisCoordinator { waitForForegroundFullIndexAnalysis(): Promise; } -function createFullIndexAnalysisCoordinator( - dependencies: Pick, -): FullIndexAnalysisCoordinator { - let fullIndexAnalysisPromise: Promise | undefined; - let fullIndexAnalysisKind: 'background' | 'foreground' | undefined; - let scheduledBackgroundAnalysis: ReturnType | undefined; +type FullIndexAnalysisKind = 'background' | 'foreground'; + +class FullIndexAnalysisCoordinatorState implements FullIndexAnalysisCoordinator { + private _fullIndexAnalysisPromise: Promise | undefined; + private _fullIndexAnalysisKind: FullIndexAnalysisKind | undefined; + private _scheduledBackgroundAnalysis: ReturnType | undefined; + + constructor( + private readonly _dependencies: Pick, + ) {} - const clearScheduledBackgroundAnalysis = (): void => { - if (scheduledBackgroundAnalysis === undefined) { + private _clearScheduledBackgroundAnalysis(): void { + if (this._scheduledBackgroundAnalysis === undefined) { return; } - clearTimeout(scheduledBackgroundAnalysis); - scheduledBackgroundAnalysis = undefined; - }; + clearTimeout(this._scheduledBackgroundAnalysis); + this._scheduledBackgroundAnalysis = undefined; + } - const waitForFullIndexAnalysis = async (): Promise => { - if (!fullIndexAnalysisPromise) { + async waitForFullIndexAnalysis(): Promise { + if (!this._fullIndexAnalysisPromise) { return false; } try { - await fullIndexAnalysisPromise; + await this._fullIndexAnalysisPromise; } catch { // The request that owns the reindex reports the failure. Competing // fire-and-forget webview loads should not create duplicate errors. } return true; - }; + } - const waitForForegroundFullIndexAnalysis = async (): Promise => { - if (fullIndexAnalysisKind === 'background') { + async waitForForegroundFullIndexAnalysis(): Promise { + if (this._fullIndexAnalysisKind === 'background') { return false; } - return waitForFullIndexAnalysis(); - }; + return this.waitForFullIndexAnalysis(); + } - const runFullIndexAnalysis = async ( + async runFullIndexAnalysis( runAnalysis: () => Promise, - kind: 'background' | 'foreground' = 'foreground', - ): Promise => { + kind: FullIndexAnalysisKind = 'foreground', + ): Promise { if (kind === 'foreground') { - clearScheduledBackgroundAnalysis(); + this._clearScheduledBackgroundAnalysis(); } - if (fullIndexAnalysisPromise) { - await fullIndexAnalysisPromise; + if (this._fullIndexAnalysisPromise) { + await this._fullIndexAnalysisPromise; return; } const analysisPromise = runAnalysis(); - fullIndexAnalysisPromise = analysisPromise; - fullIndexAnalysisKind = kind; + this._fullIndexAnalysisPromise = analysisPromise; + this._fullIndexAnalysisKind = kind; try { await analysisPromise; } finally { - if (fullIndexAnalysisPromise === analysisPromise) { - fullIndexAnalysisPromise = undefined; - fullIndexAnalysisKind = undefined; + if (this._fullIndexAnalysisPromise === analysisPromise) { + this._fullIndexAnalysisPromise = undefined; + this._fullIndexAnalysisKind = undefined; } } - }; + } - const runFullIndexAnalysisInBackground = ( + runFullIndexAnalysisInBackground( runAnalysis: () => Promise, shouldStart: () => boolean = () => true, - ): void => { - if (scheduledBackgroundAnalysis !== undefined || fullIndexAnalysisPromise) { + ): void { + if (this._scheduledBackgroundAnalysis !== undefined || this._fullIndexAnalysisPromise) { return; } - scheduledBackgroundAnalysis = setTimeout(() => { - scheduledBackgroundAnalysis = undefined; + this._scheduledBackgroundAnalysis = setTimeout(() => { + this._scheduledBackgroundAnalysis = undefined; if (!shouldStart()) { return; } - void runFullIndexAnalysis(runAnalysis, 'background').catch(error => { - dependencies.logError('[CodeGraphy] Background cache sync failed:', error); + void this.runFullIndexAnalysis(runAnalysis, 'background').catch(error => { + this._dependencies.logError('[CodeGraphy] Background cache sync failed:', error); }); }, 0); - }; + } - const runAfterFullIndexAnalysis = async ( + async runAfterFullIndexAnalysis( runAnalysis: () => Promise, - ): Promise => { - clearScheduledBackgroundAnalysis(); - await waitForFullIndexAnalysis(); + ): Promise { + this._clearScheduledBackgroundAnalysis(); + await this.waitForFullIndexAnalysis(); await runAnalysis(); - }; + } +} - return { - runAfterFullIndexAnalysis, - runFullIndexAnalysis, - runFullIndexAnalysisInBackground, - waitForFullIndexAnalysis, - waitForForegroundFullIndexAnalysis, - }; +function createFullIndexAnalysisCoordinator( + dependencies: Pick, +): FullIndexAnalysisCoordinator { + return new FullIndexAnalysisCoordinatorState(dependencies); } function canReplayStaleCache(source: GraphViewProviderAnalysisMethodsSource): boolean { diff --git a/packages/extension/src/extension/graphView/webview/messages/listener.ts b/packages/extension/src/extension/graphView/webview/messages/listener.ts index b68efa7a7..9506b58ab 100644 --- a/packages/extension/src/extension/graphView/webview/messages/listener.ts +++ b/packages/extension/src/extension/graphView/webview/messages/listener.ts @@ -31,6 +31,12 @@ interface WebviewReadyDelivery { postedAt?: number; } +interface WebviewReadyTracking { + completedAt?: number; + handled: boolean; + pageId?: string; +} + function getWebviewReadyDelivery(message: WebviewReadyMessage): WebviewReadyDelivery { const payload = (message as { payload?: unknown }).payload; if (!payload || typeof payload !== 'object') { @@ -89,32 +95,69 @@ function createReadyState(context: GraphViewMessageListenerContext) { }; } +function isSameReadyPage(delivery: WebviewReadyDelivery, tracking: WebviewReadyTracking): boolean { + return delivery.pageId !== undefined && delivery.pageId === tracking.pageId; +} + +function wasReadyPostedBeforeBootstrapCompleted( + delivery: WebviewReadyDelivery, + tracking: WebviewReadyTracking, +): boolean { + return delivery.postedAt !== undefined + && tracking.completedAt !== undefined + && delivery.postedAt <= tracking.completedAt; +} + +function shouldIgnoreDuplicateReady( + delivery: WebviewReadyDelivery, + tracking: WebviewReadyTracking, +): boolean { + return isSameReadyPage(delivery, tracking) + || wasReadyPostedBeforeBootstrapCompleted(delivery, tracking); +} + +async function handleWebviewReadyMessage( + context: GraphViewMessageListenerContext, + delivery: WebviewReadyDelivery, + tracking: WebviewReadyTracking, +): Promise { + if (!tracking.handled) { + tracking.handled = true; + tracking.pageId = delivery.pageId; + return false; + } + + if (shouldIgnoreDuplicateReady(delivery, tracking)) { + return true; + } + + tracking.pageId = delivery.pageId; + await replayDuplicateWebviewReady(createReadyState(context), context); + return true; +} + +function markWebviewReadyCompleted( + tracking: WebviewReadyTracking, + isWebviewReadyMessage: boolean, +): void { + if (isWebviewReadyMessage) { + tracking.completedAt = Date.now(); + } +} + function createGraphViewWebviewMessageHandler( webview: vscode.Webview, context: GraphViewMessageListenerContext, ): (message: WebviewToExtensionMessage) => Promise { - let webviewReadyHandled = false; - let webviewReadyPageId: string | undefined; - let webviewReadyCompletedAt: number | undefined; + const webviewReadyTracking: WebviewReadyTracking = { handled: false }; return async function handleGraphViewWebviewMessage(message: WebviewToExtensionMessage): Promise { const isWebviewReadyMessage = message.type === 'WEBVIEW_READY'; - if (message.type === 'WEBVIEW_READY') { + if (isWebviewReadyMessage) { const delivery = getWebviewReadyDelivery(message); - if (webviewReadyHandled) { - const isSamePage = delivery.pageId !== undefined && delivery.pageId === webviewReadyPageId; - const wasPostedBeforeCompletedBootstrap = delivery.postedAt !== undefined - && webviewReadyCompletedAt !== undefined - && delivery.postedAt <= webviewReadyCompletedAt; - if (isSamePage || wasPostedBeforeCompletedBootstrap) { - return; - } - webviewReadyPageId = delivery.pageId; - await replayDuplicateWebviewReady(createReadyState(context), context); + if (await handleWebviewReadyMessage(context, delivery, webviewReadyTracking)) { return; } - webviewReadyHandled = true; - webviewReadyPageId = delivery.pageId; } const primaryResult = await dispatchGraphViewPrimaryMessage(message, { @@ -122,16 +165,14 @@ function createGraphViewWebviewMessageHandler( asWebviewUri: uri => webview.asWebviewUri(uri), }); if (applyGraphViewPrimaryMessageResult(primaryResult, context)) { - if (isWebviewReadyMessage) { - webviewReadyCompletedAt = Date.now(); - } + markWebviewReadyCompleted(webviewReadyTracking, isWebviewReadyMessage); return; } const pluginResult = await dispatchGraphViewPluginMessage(message, context); applyGraphViewPluginMessageResult(pluginResult, context); if (isWebviewReadyMessage && pluginResult.handled) { - webviewReadyCompletedAt = Date.now(); + markWebviewReadyCompleted(webviewReadyTracking, isWebviewReadyMessage); } }; } diff --git a/packages/extension/src/extension/graphView/webview/messages/ready.ts b/packages/extension/src/extension/graphView/webview/messages/ready.ts index b6d7f8f1b..679776f9a 100644 --- a/packages/extension/src/extension/graphView/webview/messages/ready.ts +++ b/packages/extension/src/extension/graphView/webview/messages/ready.ts @@ -63,17 +63,25 @@ function areStringArraysEqual(left: readonly string[], right: readonly string[]) return left.length === right.length && left.every((value, index) => value === right[index]); } +function arePluginFilterPatternGroupEqual( + left: IPluginFilterPatternGroup, + right: IPluginFilterPatternGroup | undefined, +): boolean { + if (!right) { + return false; + } + + return left.pluginId === right.pluginId + && left.pluginName === right.pluginName + && areStringArraysEqual(left.patterns, right.patterns); +} + function arePluginFilterPatternGroupsEqual( left: readonly IPluginFilterPatternGroup[], right: readonly IPluginFilterPatternGroup[], ): boolean { - return left.length === right.length && left.every((leftGroup, index) => { - const rightGroup = right[index]; - return Boolean(rightGroup) - && leftGroup.pluginId === rightGroup.pluginId - && leftGroup.pluginName === rightGroup.pluginName - && areStringArraysEqual(leftGroup.patterns, rightGroup.patterns); - }); + return left.length === right.length + && left.every((leftGroup, index) => arePluginFilterPatternGroupEqual(leftGroup, right[index])); } function areWebviewReadyFilterPatternsEqual( diff --git a/packages/extension/src/extension/pipeline/service/cache/cachedDiscovery.ts b/packages/extension/src/extension/pipeline/service/cache/cachedDiscovery.ts index afeef48ca..7c0620411 100644 --- a/packages/extension/src/extension/pipeline/service/cache/cachedDiscovery.ts +++ b/packages/extension/src/extension/pipeline/service/cache/cachedDiscovery.ts @@ -38,6 +38,28 @@ function toGitPath(relativePath: string): string { return relativePath.split(path.sep).join('/'); } +function createCachedGitPathLookup(relativePaths: readonly string[]): Map { + return new Map(relativePaths.map(relativePath => [toGitPath(relativePath), relativePath])); +} + +function createGitCheckIgnoreInput(pathsByGitPath: ReadonlyMap): string { + return `${[...pathsByGitPath.keys()].join('\n')}\n`; +} + +function didGitCheckIgnoreFail(result: ReturnType): boolean { + return Boolean(result.error) || (result.status !== 0 && result.status !== 1); +} + +function readGitIgnoredCachedPaths( + stdout: string, + pathsByGitPath: ReadonlyMap, +): string[] { + return stdout + .split(/\r?\n/) + .filter(Boolean) + .map(gitPath => pathsByGitPath.get(gitPath) ?? gitPath); +} + export function collectCachedGitIgnoredPaths( workspaceRoot: string, relativePaths: readonly string[], @@ -47,24 +69,18 @@ export function collectCachedGitIgnoredPaths( return []; } - const pathsByGitPath = new Map(); - for (const relativePath of relativePaths) { - pathsByGitPath.set(toGitPath(relativePath), relativePath); - } + const pathsByGitPath = createCachedGitPathLookup(relativePaths); const result = spawnSync('git', ['-C', workspaceRoot, 'check-ignore', '--stdin'], { encoding: 'utf8', - input: `${[...pathsByGitPath.keys()].join('\n')}\n`, + input: createGitCheckIgnoreInput(pathsByGitPath), }); - if (result.error || (result.status !== 0 && result.status !== 1)) { + if (didGitCheckIgnoreFail(result)) { return []; } - return result.stdout - .split(/\r?\n/) - .filter(Boolean) - .map(gitPath => pathsByGitPath.get(gitPath) ?? gitPath); + return readGitIgnoredCachedPaths(result.stdout, pathsByGitPath); } export function createCachedWorkspaceDiscoveryState( diff --git a/packages/extension/src/extension/pipeline/service/discoveryFacade.ts b/packages/extension/src/extension/pipeline/service/discoveryFacade.ts index 9d5cfc623..40b197087 100644 --- a/packages/extension/src/extension/pipeline/service/discoveryFacade.ts +++ b/packages/extension/src/extension/pipeline/service/discoveryFacade.ts @@ -36,6 +36,15 @@ export interface WorkspacePipelineCachedGraphLoadOptions { warmAnalysis?: boolean; } +interface CachedGraphAnalysisWarmupInput { + analysisContext: ReturnType; + disabledPluginSnapshot: Set; + file: IDiscoveredFile; + pluginIds: readonly string[]; + signal?: AbortSignal; + workspaceRoot: string; +} + function isWorkspaceAnalysisAbortError(error: unknown): boolean { return error instanceof Error && error.name === 'AbortError'; } @@ -64,6 +73,35 @@ function isCachedGraphAnalysisWarmupCandidate(file: IDiscoveredFile): boolean { return !segments.some(segment => CACHED_GRAPH_ANALYSIS_WARMUP_IGNORED_SEGMENTS.has(segment)); } +function selectMostRepresentedCachedGraphWarmupFile( + files: readonly IDiscoveredFile[], +): IDiscoveredFile | undefined { + const extensionStats = new Map(); + + for (const [index, file] of files.entries()) { + const extension = file.extension; + const stats = extensionStats.get(extension); + if (stats) { + stats.count += 1; + continue; + } + + extensionStats.set(extension, { + count: 1, + file, + firstIndex: index, + }); + } + + return [...extensionStats.values()] + .sort((left, right) => right.count - left.count || left.firstIndex - right.firstIndex)[0] + ?.file; +} + export abstract class WorkspacePipelineDiscoveryFacade extends WorkspacePipelineInternalBase { private _workspacePluginReloadQueue: Promise = Promise.resolve(); @@ -277,42 +315,23 @@ export abstract class WorkspacePipelineDiscoveryFacade extends WorkspacePipeline return files[0]; } - const sourceFiles = files.filter(isCachedGraphAnalysisWarmupCandidate); - const supportedFiles = sourceFiles.filter(file => - this._registry.supportsFile(file.absolutePath) - || this._registry.supportsFile(file.relativePath), + const supportedFiles = this._getSupportedCachedGraphAnalysisWarmupFiles( + files.filter(isCachedGraphAnalysisWarmupCandidate), ); if (supportedFiles.length === 0) { - return files.find(file => - this._registry.supportsFile(file.absolutePath) - || this._registry.supportsFile(file.relativePath), - ) ?? files[0]; + return this._getSupportedCachedGraphAnalysisWarmupFiles(files)[0] ?? files[0]; } - const extensionStats = new Map(); - for (const [index, file] of supportedFiles.entries()) { - const extension = file.extension; - const stats = extensionStats.get(extension); - if (stats) { - stats.count += 1; - continue; - } - - extensionStats.set(extension, { - count: 1, - file, - firstIndex: index, - }); - } + return selectMostRepresentedCachedGraphWarmupFile(supportedFiles); + } - return [...extensionStats.values()] - .sort((left, right) => - right.count - left.count || left.firstIndex - right.firstIndex, - )[0]?.file; + private _getSupportedCachedGraphAnalysisWarmupFiles( + files: readonly IDiscoveredFile[], + ): IDiscoveredFile[] { + return files.filter(file => + this._registry.supportsFile?.(file.absolutePath) + || this._registry.supportsFile?.(file.relativePath), + ); } private _scheduleCachedGraphAnalysisWarmup( @@ -321,13 +340,42 @@ export abstract class WorkspacePipelineDiscoveryFacade extends WorkspacePipeline disabledPlugins: Set, signal?: AbortSignal, ): void { - if (typeof this._registry.analyzeFileResultForPlugins !== 'function') { + const input = this._createCachedGraphAnalysisWarmupInput( + files, + workspaceRoot, + disabledPlugins, + signal, + ); + if (!input) { return; } + void this._warmCachedGraphAnalysisFile(input).catch(error => { + const status = isWorkspaceAnalysisAbortError(error) + ? 'aborted' + : isMissingFileError(error) + ? 'skipped' + : 'failed'; + + if (status === 'failed') { + console.warn('[CodeGraphy] Failed to warm cached graph analysis.', error); + } + }); + } + + private _createCachedGraphAnalysisWarmupInput( + files: readonly IDiscoveredFile[], + workspaceRoot: string, + disabledPlugins: Set, + signal?: AbortSignal, + ): CachedGraphAnalysisWarmupInput | undefined { + if (typeof this._registry.analyzeFileResultForPlugins !== 'function') { + return undefined; + } + const file = this._selectCachedGraphAnalysisWarmupFile(files); if (!file) { - return; + return undefined; } const disabledPluginSnapshot = new Set(disabledPlugins); @@ -336,36 +384,34 @@ export abstract class WorkspacePipelineDiscoveryFacade extends WorkspacePipeline this._config.get>('nodeVisibility', {}) ?? {}, pluginIds, ); - const analysisContext = createWorkspacePluginAnalysisContext(workspaceRoot, { - features: { - symbols: cacheTiers.active === undefined - || cacheTiers.active.includes(SYMBOLS_ANALYSIS_CACHE_TIER), - }, - }); - void (async () => { - throwIfWorkspaceAnalysisAborted(signal); - const content = await this._discovery.readContent(file); - throwIfWorkspaceAnalysisAborted(signal); - await this._registry.analyzeFileResultForPlugins( - file.absolutePath, - content, - workspaceRoot, - pluginIds, - analysisContext, - { disabledPlugins: disabledPluginSnapshot }, - ); - })().catch(error => { - const status = isWorkspaceAnalysisAbortError(error) - ? 'aborted' - : isMissingFileError(error) - ? 'skipped' - : 'failed'; + return { + analysisContext: createWorkspacePluginAnalysisContext(workspaceRoot, { + features: { + symbols: cacheTiers.active === undefined + || cacheTiers.active.includes(SYMBOLS_ANALYSIS_CACHE_TIER), + }, + }), + disabledPluginSnapshot, + file, + pluginIds, + signal, + workspaceRoot, + }; + } - if (status === 'failed') { - console.warn('[CodeGraphy] Failed to warm cached graph analysis.', error); - } - }); + private async _warmCachedGraphAnalysisFile(input: CachedGraphAnalysisWarmupInput): Promise { + throwIfWorkspaceAnalysisAborted(input.signal); + const content = await this._discovery.readContent(input.file); + throwIfWorkspaceAnalysisAborted(input.signal); + await this._registry.analyzeFileResultForPlugins( + input.file.absolutePath, + content, + input.workspaceRoot, + input.pluginIds, + input.analysisContext, + { disabledPlugins: input.disabledPluginSnapshot }, + ); } rebuildGraph(disabledPlugins: Set, showOrphans: boolean): IGraphData { diff --git a/packages/extension/src/extension/pipeline/service/refreshFacade.ts b/packages/extension/src/extension/pipeline/service/refreshFacade.ts index 11acbd10c..b245902a0 100644 --- a/packages/extension/src/extension/pipeline/service/refreshFacade.ts +++ b/packages/extension/src/extension/pipeline/service/refreshFacade.ts @@ -26,6 +26,11 @@ interface ChangedFileDiscoveryState { files: IDiscoveredFile[]; } +interface GraphMetricPatchResult { + changed: boolean; + node: IGraphData['nodes'][number]; +} + function normalizeGraphMetricFilePath(filePath: string): string { return filePath.replace(/\\/g, '/'); } @@ -130,24 +135,33 @@ export abstract class WorkspacePipelineRefreshFacade extends WorkspacePipelineDi ) ?? {}; let changed = false; const nodes = graphData.nodes.map((node) => { - const filePath = getGraphMetricNodeFilePath(node); - if (!metricFilePaths.has(filePath)) { - return node; - } - - const fileSize = this._cache.files[filePath]?.size; - const churn = churnCounts[filePath] ?? 0; - if (node.fileSize === fileSize && node.churn === churn) { - return node; - } - - changed = true; - return { ...node, fileSize, churn }; + const result = this._patchGraphDataNodeMetric(node, metricFilePaths, churnCounts); + changed ||= result.changed; + return result.node; }); return changed ? { ...graphData, nodes } : graphData; } + private _patchGraphDataNodeMetric( + node: IGraphData['nodes'][number], + metricFilePaths: ReadonlySet, + churnCounts: Record, + ): GraphMetricPatchResult { + const filePath = getGraphMetricNodeFilePath(node); + if (!metricFilePaths.has(filePath)) { + return { changed: false, node }; + } + + const fileSize = this._cache.files[filePath]?.size; + const churn = churnCounts[filePath] ?? 0; + if (node.fileSize === fileSize && node.churn === churn) { + return { changed: false, node }; + } + + return { changed: true, node: { ...node, fileSize, churn } }; + } + private _canReuseCurrentAnalysisForScope( discoveredFiles: readonly IDiscoveredFile[], disabledPlugins: Set, @@ -208,31 +222,14 @@ export abstract class WorkspacePipelineRefreshFacade extends WorkspacePipelineDi workspaceRoot: string, filePaths: readonly string[], ): ChangedFileDiscoveryState | undefined { - if ( - filePaths.length === 0 - || this._lastWorkspaceRoot !== workspaceRoot - || this._lastDiscoveredFiles.length === 0 - ) { + if (!this._hasReusableChangedFileDiscoveryState(workspaceRoot, filePaths)) { return undefined; } - const discoveredByRelativePath = new Map( - this._lastDiscoveredFiles.map(file => [ - file.relativePath.replace(/\\/g, '/'), - file, - ]), - ); + const discoveredByRelativePath = this._createDiscoveredFilesByRelativePath(); for (const filePath of filePaths) { - const relativePath = this._toWorkspaceRelativePath(workspaceRoot, filePath); - if (!relativePath || !discoveredByRelativePath.has(relativePath)) { - return undefined; - } - - const absolutePath = path.isAbsolute(filePath) - ? filePath - : path.join(workspaceRoot, filePath); - if (!fs.existsSync(absolutePath)) { + if (!this._canReuseChangedFileDiscovery(filePath, workspaceRoot, discoveredByRelativePath)) { return undefined; } } @@ -243,6 +240,41 @@ export abstract class WorkspacePipelineRefreshFacade extends WorkspacePipelineDi }; } + private _hasReusableChangedFileDiscoveryState( + workspaceRoot: string, + filePaths: readonly string[], + ): boolean { + return filePaths.length > 0 + && this._lastWorkspaceRoot === workspaceRoot + && this._lastDiscoveredFiles.length > 0; + } + + private _createDiscoveredFilesByRelativePath(): Map { + return new Map( + this._lastDiscoveredFiles.map(file => [ + normalizeGraphMetricFilePath(file.relativePath), + file, + ]), + ); + } + + private _canReuseChangedFileDiscovery( + filePath: string, + workspaceRoot: string, + discoveredByRelativePath: ReadonlyMap, + ): boolean { + const relativePath = this._toWorkspaceRelativePath(workspaceRoot, filePath); + return Boolean( + relativePath + && discoveredByRelativePath.has(relativePath) + && fs.existsSync(this._toAbsoluteChangedFilePath(workspaceRoot, filePath)), + ); + } + + private _toAbsoluteChangedFilePath(workspaceRoot: string, filePath: string): string { + return path.isAbsolute(filePath) ? filePath : path.join(workspaceRoot, filePath); + } + async refreshAnalysisScope( filterPatterns: string[] = [], disabledPlugins: Set = new Set(), diff --git a/packages/extension/src/shared/globMatch.ts b/packages/extension/src/shared/globMatch.ts index 3d1677fff..6b986462f 100644 --- a/packages/extension/src/shared/globMatch.ts +++ b/packages/extension/src/shared/globMatch.ts @@ -48,6 +48,12 @@ interface CombinedFastGlobMatchers { suffixes: string[]; } +type FastGlobPattern = + | { kind: 'directChild'; directoryPath: string } + | { kind: 'literal'; suffix: string } + | { kind: 'recursiveDirectory'; directoryPath: string } + | { kind: 'suffix'; suffix: string }; + export function createGlobMatcher(pattern: string): GlobMatcher { const fastMatcher = createFastGlobMatcher(pattern); if (fastMatcher) { @@ -90,47 +96,92 @@ function createDirectChildMatcher(directoryPath: string): GlobMatcher { }; } -function collectFastMatcher( - fastMatchers: CombinedFastGlobMatchers, - pattern: string, -): boolean { - const recursivePattern = pattern.startsWith('**/') ? pattern.slice(3) : pattern; +function createSuffixMatcher(suffix: string): GlobMatcher { + const suffixLength = suffix.length; + const suffixFirstCode = suffix.charCodeAt(0); + return (filePath: string): boolean => ( + filePath.length >= suffixLength + && filePath.charCodeAt(filePath.length - suffixLength) === suffixFirstCode + && filePath.endsWith(suffix) + ); +} + +function removeRecursivePrefix(pattern: string): string { + return pattern.startsWith('**/') ? pattern.slice(3) : pattern; +} + +function getExtensionSuffixPattern(pattern: string): string | undefined { + const hasOnlyLeadingWildcard = pattern.startsWith('*.') && pattern.indexOf('*', 1) === -1; + return hasOnlyLeadingWildcard && !pattern.includes('/') ? pattern.slice(1) : undefined; +} + +function getDirectoryPattern(pattern: string, ending: '/**' | '/*'): string | undefined { + if (!pattern.endsWith(ending)) { + return undefined; + } + + const directoryPath = pattern.slice(0, -ending.length); + return directoryPath && !directoryPath.includes('*') ? directoryPath : undefined; +} + +function classifyFastGlobPattern(pattern: string): FastGlobPattern | undefined { + const recursivePattern = removeRecursivePrefix(pattern); if (!recursivePattern.includes('*')) { - fastMatchers.literalSuffixes.push(recursivePattern); - return true; + return { kind: 'literal', suffix: recursivePattern }; } - if ( - recursivePattern.startsWith('*.') - && recursivePattern.indexOf('*', 1) === -1 - && !recursivePattern.includes('/') - ) { - fastMatchers.suffixes.push(recursivePattern.slice(1)); - return true; + const suffix = getExtensionSuffixPattern(recursivePattern); + if (suffix) { + return { kind: 'suffix', suffix }; } - if (recursivePattern.endsWith('/**')) { - const directoryPath = recursivePattern.slice(0, -3); - if (directoryPath && !directoryPath.includes('*')) { - if (!directoryPath.includes('/')) { - fastMatchers.recursiveDirectoryNames.add(directoryPath); - } else { - fastMatchers.directMatchers.push(createRecursiveDirectoryMatcher(directoryPath)); - } - return true; - } + const recursiveDirectoryPath = getDirectoryPattern(recursivePattern, '/**'); + if (recursiveDirectoryPath) { + return { kind: 'recursiveDirectory', directoryPath: recursiveDirectoryPath }; } - if (recursivePattern.endsWith('/*')) { - const directoryPath = recursivePattern.slice(0, -2); - if (directoryPath && !directoryPath.includes('*')) { - fastMatchers.directMatchers.push(createDirectChildMatcher(directoryPath)); - return true; - } + const directChildDirectoryPath = getDirectoryPattern(recursivePattern, '/*'); + return directChildDirectoryPath + ? { kind: 'directChild', directoryPath: directChildDirectoryPath } + : undefined; +} + +function addFastMatcher(fastMatchers: CombinedFastGlobMatchers, pattern: FastGlobPattern): void { + if (pattern.kind === 'literal') { + fastMatchers.literalSuffixes.push(pattern.suffix); + return; } - return false; + if (pattern.kind === 'suffix') { + fastMatchers.suffixes.push(pattern.suffix); + return; + } + + if (pattern.kind === 'directChild') { + fastMatchers.directMatchers.push(createDirectChildMatcher(pattern.directoryPath)); + return; + } + + if (!pattern.directoryPath.includes('/')) { + fastMatchers.recursiveDirectoryNames.add(pattern.directoryPath); + return; + } + + fastMatchers.directMatchers.push(createRecursiveDirectoryMatcher(pattern.directoryPath)); +} + +function collectFastMatcher( + fastMatchers: CombinedFastGlobMatchers, + pattern: string, +): boolean { + const fastPattern = classifyFastGlobPattern(pattern); + if (!fastPattern) { + return false; + } + + addFastMatcher(fastMatchers, fastPattern); + return true; } function matchesAnyPathSuffix(filePath: string, suffixes: readonly string[]): boolean { @@ -183,48 +234,28 @@ function createFastGlobMatcher(pattern: string): GlobMatcher | undefined { return () => false; } - const recursivePattern = pattern.startsWith('**/') ? pattern.slice(3) : pattern; - - if (!recursivePattern.includes('*')) { - return (filePath: string): boolean => matchesPathSuffix(filePath, recursivePattern); - } - - if ( - recursivePattern.startsWith('*.') - && recursivePattern.indexOf('*', 1) === -1 - && !recursivePattern.includes('/') - ) { - const suffix = recursivePattern.slice(1); - return (filePath: string): boolean => filePath.endsWith(suffix); + const fastPattern = classifyFastGlobPattern(pattern); + if (!fastPattern) { + return undefined; } - if (recursivePattern.endsWith('/**')) { - const directoryPath = recursivePattern.slice(0, -3); - if (directoryPath && !directoryPath.includes('*')) { - return createRecursiveDirectoryMatcher(directoryPath); - } + if (fastPattern.kind === 'literal') { + return (filePath: string): boolean => matchesPathSuffix(filePath, fastPattern.suffix); } - if (recursivePattern.endsWith('/*')) { - const directoryPath = recursivePattern.slice(0, -2); - if (directoryPath && !directoryPath.includes('*')) { - return createDirectChildMatcher(directoryPath); - } + if (fastPattern.kind === 'suffix') { + return createSuffixMatcher(fastPattern.suffix); } - return undefined; + return fastPattern.kind === 'directChild' + ? createDirectChildMatcher(fastPattern.directoryPath) + : createRecursiveDirectoryMatcher(fastPattern.directoryPath); } -export function createCombinedGlobMatcher(patterns: readonly string[]): (filePath: string) => boolean { - if (patterns.length === 0) { - return () => false; - } - - if (patterns.length === 1) { - const pattern = patterns[0] ?? ''; - return createFastGlobMatcher(pattern) ?? createGlobMatcher(pattern); - } - +function collectCombinedFastMatchers(patterns: readonly string[]): { + fastMatchers: CombinedFastGlobMatchers; + regexPatterns: string[]; +} { const fastMatchers: CombinedFastGlobMatchers = { directMatchers: [], literalSuffixes: [], @@ -238,10 +269,19 @@ export function createCombinedGlobMatcher(patterns: readonly string[]): (filePat } } - const regex = regexPatterns.length > 0 + return { fastMatchers, regexPatterns }; +} + +function createCombinedRegexMatcher(regexPatterns: readonly string[]): RegExp | null { + return regexPatterns.length > 0 ? new RegExp(regexPatterns.map(pattern => `(?:${globToRegex(pattern).source})`).join('|')) : null; +} +function createCombinedFastMatcher( + fastMatchers: CombinedFastGlobMatchers, + regex: RegExp | null, +): GlobMatcher { return (filePath: string): boolean => { if ( containsRecursiveDirectoryName(filePath, fastMatchers.recursiveDirectoryNames) @@ -261,6 +301,23 @@ export function createCombinedGlobMatcher(patterns: readonly string[]): (filePat }; } +export function createCombinedGlobMatcher(patterns: readonly string[]): (filePath: string) => boolean { + if (patterns.length === 0) { + return () => false; + } + + if (patterns.length === 1) { + const pattern = patterns[0] ?? ''; + return createFastGlobMatcher(pattern) ?? createGlobMatcher(pattern); + } + + const { fastMatchers, regexPatterns } = collectCombinedFastMatchers(patterns); + const regex = regexPatterns.length > 0 + ? createCombinedRegexMatcher(regexPatterns) + : null; + return createCombinedFastMatcher(fastMatchers, regex); +} + export function globMatch(filePath: string, pattern: string): boolean { return createGlobMatcher(pattern)(filePath); } diff --git a/packages/extension/src/shared/visibleGraph/scope.ts b/packages/extension/src/shared/visibleGraph/scope.ts index 0a5077ced..e4fc7edde 100644 --- a/packages/extension/src/shared/visibleGraph/scope.ts +++ b/packages/extension/src/shared/visibleGraph/scope.ts @@ -22,6 +22,43 @@ interface ScopedEdgeCandidate { key?: string; } +function rememberBestEndpointPreference( + bestEndpointPreferenceByKey: Map, + key: string, + endpointPreference: number, +): void { + const currentEndpointPreference = bestEndpointPreferenceByKey.get(key); + bestEndpointPreferenceByKey.set( + key, + currentEndpointPreference === undefined + ? endpointPreference + : Math.max(currentEndpointPreference, endpointPreference), + ); +} + +function createScopedEdgeCandidate( + edge: IGraphData['edges'][number], + nodeById: ReadonlyMap, + bestEndpointPreferenceByKey: Map, +): ScopedEdgeCandidate { + if (edge.kind === 'contains') { + return { edge }; + } + + const key = getEdgeContainingFileKey(edge, nodeById); + const endpointPreference = getEndpointPreference(edge, nodeById); + rememberBestEndpointPreference(bestEndpointPreferenceByKey, key, endpointPreference); + return { edge, endpointPreference, key }; +} + +function shouldKeepScopedEdgeCandidate( + candidate: ScopedEdgeCandidate, + bestEndpointPreferenceByKey: ReadonlyMap, +): boolean { + return !candidate.key + || candidate.endpointPreference === (bestEndpointPreferenceByKey.get(candidate.key) ?? candidate.endpointPreference); +} + function keepMostSpecificUniqueEdges( nodes: IGraphData['nodes'], edges: IGraphData['edges'], @@ -31,28 +68,14 @@ function keepMostSpecificUniqueEdges( const candidates: ScopedEdgeCandidate[] = []; for (const edge of edges) { - if (edge.kind === 'contains') { - candidates.push({ edge }); - continue; - } - const key = getEdgeContainingFileKey(edge, nodeById); - const endpointPreference = getEndpointPreference(edge, nodeById); - const currentEndpointPreference = bestEndpointPreferenceByKey.get(key); - bestEndpointPreferenceByKey.set( - key, - currentEndpointPreference === undefined - ? endpointPreference - : Math.max(currentEndpointPreference, endpointPreference), - ); - candidates.push({ edge, endpointPreference, key }); + candidates.push(createScopedEdgeCandidate(edge, nodeById, bestEndpointPreferenceByKey)); } const seenEdgeIds = new Set(); const uniqueEdges: IGraphData['edges'] = []; for (const candidate of candidates) { - if (candidate.key - && candidate.endpointPreference !== (bestEndpointPreferenceByKey.get(candidate.key) ?? candidate.endpointPreference)) { + if (!shouldKeepScopedEdgeCandidate(candidate, bestEndpointPreferenceByKey)) { continue; } diff --git a/packages/extension/src/shared/visibleGraph/scope/symbolMatch.ts b/packages/extension/src/shared/visibleGraph/scope/symbolMatch.ts index 05b0483da..f82688081 100644 --- a/packages/extension/src/shared/visibleGraph/scope/symbolMatch.ts +++ b/packages/extension/src/shared/visibleGraph/scope/symbolMatch.ts @@ -5,6 +5,13 @@ import type { ScopedSymbolDefinition } from './definitions'; import { getDefinitionSymbolKinds } from './definitions'; type ScopedSymbolMatcher = IGraphNodeTypeDefinition | ScopedSymbolDefinition; +type GraphNode = IGraphData['nodes'][number]; +type GraphNodeSymbol = NonNullable; +type ScopedSymbolConstraintMatcher = ( + symbol: GraphNodeSymbol, + scopedDefinition: ScopedSymbolMatcher, + definition: IGraphNodeTypeDefinition, +) => boolean; function isCompiledScopedSymbolDefinition( definition: ScopedSymbolMatcher, @@ -17,7 +24,7 @@ function getMatcherDefinition(definition: ScopedSymbolMatcher): IGraphNodeTypeDe } export function symbolMatchesScopedDefinition( - node: IGraphData['nodes'][number], + node: GraphNode, scopedDefinition: ScopedSymbolMatcher, ): boolean { const symbol = node.symbol; @@ -26,23 +33,25 @@ export function symbolMatchesScopedDefinition( } const definition = getMatcherDefinition(scopedDefinition); - const definitionSymbolKinds = getDefinitionSymbolKinds(definition); - if (definitionSymbolKinds && !definitionSymbolKinds.includes(symbol.kind)) { - return false; - } - - if (definition.matchSymbolPluginKind && definition.matchSymbolPluginKind !== symbol.pluginKind) { - return false; - } + return SCOPED_SYMBOL_CONSTRAINT_MATCHERS.every(matcher => + matcher(symbol, scopedDefinition, definition), + ); +} - if (definition.matchSymbolSource && definition.matchSymbolSource !== symbol.source) { - return false; - } +function optionalValueMatches(expected: T | undefined, actual: T | undefined): boolean { + return expected === undefined || expected === actual; +} - if (definition.matchSymbolLanguage && definition.matchSymbolLanguage !== symbol.language) { - return false; - } +function symbolKindMatchesDefinition(symbol: GraphNodeSymbol, definition: IGraphNodeTypeDefinition): boolean { + const definitionSymbolKinds = getDefinitionSymbolKinds(definition); + return definitionSymbolKinds === undefined || definitionSymbolKinds.includes(symbol.kind); +} +function symbolFilePathMatchesDefinition( + symbol: GraphNodeSymbol, + scopedDefinition: ScopedSymbolMatcher, + definition: IGraphNodeTypeDefinition, +): boolean { if (!definition.matchSymbolFilePath) { return true; } @@ -53,3 +62,11 @@ export function symbolMatchesScopedDefinition( return globMatch(symbol.filePath, definition.matchSymbolFilePath); } + +const SCOPED_SYMBOL_CONSTRAINT_MATCHERS: readonly ScopedSymbolConstraintMatcher[] = [ + (symbol, _scopedDefinition, definition) => symbolKindMatchesDefinition(symbol, definition), + (symbol, _scopedDefinition, definition) => optionalValueMatches(definition.matchSymbolPluginKind, symbol.pluginKind), + (symbol, _scopedDefinition, definition) => optionalValueMatches(definition.matchSymbolSource, symbol.source), + (symbol, _scopedDefinition, definition) => optionalValueMatches(definition.matchSymbolLanguage, symbol.language), + symbolFilePathMatchesDefinition, +]; diff --git a/packages/extension/src/webview/components/graph/viewport/shell.tsx b/packages/extension/src/webview/components/graph/viewport/shell.tsx index f0e4acf06..7150c32cf 100644 --- a/packages/extension/src/webview/components/graph/viewport/shell.tsx +++ b/packages/extension/src/webview/components/graph/viewport/shell.tsx @@ -1,4 +1,13 @@ -import { useCallback, useEffect, useRef, useState, type ReactElement } from 'react'; +import { + useCallback, + useEffect, + useRef, + useState, + type Dispatch, + type MutableRefObject, + type ReactElement, + type SetStateAction, +} from 'react'; import type { CoreGraphViewContributionSet } from '@codegraphy-dev/core'; import type { ThemeKind } from '../../../theme/useTheme'; import type { GraphAppearance } from '../appearance/model'; @@ -57,6 +66,74 @@ function resolveLinkEndpoint(endpoint: string | FGNode): string { return typeof endpoint === 'string' ? endpoint : endpoint.id; } +function areGraphAccessibilityNodePositionsReady(nodes: readonly FGNode[]): boolean { + return nodes.every(node => Number.isFinite(node.x) && Number.isFinite(node.y)); +} + +function publishPluginGraphViewViewportState({ + globalScale, + graph, + graphMode, + nodes, + pluginHost, + timelineActive, +}: { + globalScale: number; + graph: GraphViewport2dControls | undefined; + graphMode: GraphViewStoreState['graphMode']; + nodes: readonly FGNode[]; + pluginHost: WebviewPluginHost | undefined; + timelineActive: boolean; +}): void { + if (!pluginHost || pluginHost.hasGraphViewViewportConsumers?.() === false) { + return; + } + + pluginHost.setGraphViewViewportState(createGraphViewViewportState({ + globalScale, + graph, + graphMode, + nodes: [...nodes], + timelineActive, + })); +} + +function publishCurrentGraphAccessibilityItems({ + accessibilityDirtyRef, + graph, + graphMode, + lastAccessibilitySignatureRef, + links, + nodes, + setAccessibilityItems, +}: { + accessibilityDirtyRef: MutableRefObject; + graph: GraphScreenProjector | undefined; + graphMode: GraphViewStoreState['graphMode']; + lastAccessibilitySignatureRef: MutableRefObject; + links: readonly FGLink[]; + nodes: readonly FGNode[]; + setAccessibilityItems: Dispatch>; +}): void { + if (graphMode !== '2d' || !accessibilityDirtyRef.current) { + return; + } + + if (!areGraphAccessibilityNodePositionsReady(nodes)) { + return; + } + + const signature = createGraphAccessibilitySignature(nodes, links); + if (signature === lastAccessibilitySignatureRef.current) { + accessibilityDirtyRef.current = false; + return; + } + + lastAccessibilitySignatureRef.current = signature; + setAccessibilityItems(createGraphAccessibilityItems(nodes, links, graph)); + accessibilityDirtyRef.current = false; +} + export function GraphViewportShell({ appearance, callbacks, @@ -124,55 +201,29 @@ export function GraphViewportShell({ }; const publishGraphViewViewportState = (globalScale: number): void => { - if (!pluginHost) { - return; - } - - if (pluginHost.hasGraphViewViewportConsumers?.() === false) { - return; - } - - const graph = graphState.renderer.fg2dRef.current as GraphViewport2dControls | undefined; - pluginHost.setGraphViewViewportState(createGraphViewViewportState({ + publishPluginGraphViewViewportState({ globalScale, - graph, + graph: graphState.renderer.fg2dRef.current as GraphViewport2dControls | undefined, graphMode: viewState.graphMode, nodes: graphState.renderer.graphDataRef.current.nodes, + pluginHost, timelineActive: viewState.timelineActive, - })); + }); }; const publishGraphAccessibilityItems = (): void => { - if (viewState.graphMode !== '2d') { - return; - } - - if (!accessibilityDirtyRef.current) { - return; - } - - const graph = graphState.renderer.fg2dRef.current as GraphScreenProjector | undefined; const nodes = graphState.renderer.graphDataRef.current.nodes; const links = graphState.renderer.graphDataRef.current.links; - const ready = nodes.every(node => Number.isFinite(node.x) && Number.isFinite(node.y)); - if (!ready) { - return; - } - - const signature = createGraphAccessibilitySignature(nodes, links); - if (signature === lastAccessibilitySignatureRef.current) { - accessibilityDirtyRef.current = false; - return; - } - - lastAccessibilitySignatureRef.current = signature; - const items = createGraphAccessibilityItems( - nodes, + const graph = graphState.renderer.fg2dRef.current as GraphScreenProjector | undefined; + publishCurrentGraphAccessibilityItems({ + accessibilityDirtyRef, + graph: typeof graph?.graph2ScreenCoords === 'function' ? graph : undefined, + graphMode: viewState.graphMode, + lastAccessibilitySignatureRef, links, - typeof graph?.graph2ScreenCoords === 'function' ? graph : undefined, - ); - setAccessibilityItems(items); - accessibilityDirtyRef.current = false; + nodes, + setAccessibilityItems, + }); }; renderFramePostRef.current = (ctx, globalScale) => { diff --git a/packages/extension/src/webview/components/graph/viewport/view.tsx b/packages/extension/src/webview/components/graph/viewport/view.tsx index 6bf8c7ada..32544a52f 100644 --- a/packages/extension/src/webview/components/graph/viewport/view.tsx +++ b/packages/extension/src/webview/components/graph/viewport/view.tsx @@ -3,6 +3,7 @@ import { memo, Suspense, useRef, + type ComponentProps, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactElement, @@ -68,6 +69,64 @@ export interface ViewportProps { pluginHost?: WebviewPluginHost; } +const EMPTY_ACCESSIBILITY_ITEMS: GraphAccessibilityItems = { nodes: [], edges: [] }; +const ignoreEdgeContextMenu: NonNullable = () => undefined; +const ignoreNodeClick: NonNullable = () => undefined; +const ignoreNodeContextMenu: NonNullable = () => undefined; +const ignoreNodeHover: NonNullable = () => undefined; + +type NodeTooltipComponentProps = ComponentProps; + +interface ResolvedViewportHandlers { + handleEdgeContextMenu: NonNullable; + handleNodeClick: NonNullable; + handleNodeContextMenu: NonNullable; + handleNodeHover: NonNullable; +} + +function resolveViewportAccessibilityItems( + accessibilityItems: ViewportProps['accessibilityItems'], +): GraphAccessibilityItems { + return accessibilityItems ?? EMPTY_ACCESSIBILITY_ITEMS; +} + +function resolveViewportHandlers({ + handleEdgeContextMenu, + handleNodeClick, + handleNodeContextMenu, + handleNodeHover, +}: Pick< + ViewportProps, + 'handleEdgeContextMenu' | 'handleNodeClick' | 'handleNodeContextMenu' | 'handleNodeHover' +>): ResolvedViewportHandlers { + return { + handleEdgeContextMenu: handleEdgeContextMenu ?? ignoreEdgeContextMenu, + handleNodeClick: handleNodeClick ?? ignoreNodeClick, + handleNodeContextMenu: handleNodeContextMenu ?? ignoreNodeContextMenu, + handleNodeHover: handleNodeHover ?? ignoreNodeHover, + }; +} + +function createNodeTooltipProps({ + pluginHost, + tooltipData, +}: Pick): NodeTooltipComponentProps { + return { + extraActions: tooltipData.pluginActions, + extraSections: tooltipData.pluginSections, + incomingCount: tooltipData.incomingCount ?? tooltipData.info?.incomingCount ?? 0, + lastModified: tooltipData.info?.lastModified, + nodeRect: tooltipData.nodeRect, + outgoingCount: tooltipData.outgoingCount ?? tooltipData.info?.outgoingCount ?? 0, + path: tooltipData.path, + plugin: tooltipData.info?.plugin ?? tooltipData.symbol?.plugin, + pluginHost, + size: tooltipData.info?.size, + symbol: tooltipData.symbol, + visible: tooltipData.visible, + }; +} + interface ViewportSurfaceProps { canvasBackgroundColor: string; directionMode: DirectionMode; @@ -125,39 +184,63 @@ function areSurface2dPropsEqual( previous: ViewportSurfaceProps['surface2dProps'], next: ViewportSurfaceProps['surface2dProps'], ): boolean { - return previous.fg2dRef === next.fg2dRef - && previous.getArrowColor === next.getArrowColor - && previous.getArrowRelPos === next.getArrowRelPos - && previous.getLinkColor === next.getLinkColor - && previous.getLinkParticles === next.getLinkParticles - && previous.getLinkWidth === next.getLinkWidth - && previous.getParticleColor === next.getParticleColor - && previous.linkCanvasObject === next.linkCanvasObject - && previous.nodeCanvasObject === next.nodeCanvasObject - && previous.nodePointerAreaPaint === next.nodePointerAreaPaint - && previous.onRenderFramePost === next.onRenderFramePost - && previous.particleSize === next.particleSize - && previous.particleSpeed === next.particleSpeed - && previous.sharedProps === next.sharedProps; + return propsEqualByKeys(previous, next, SURFACE_2D_PROP_KEYS); } function areSurface3dPropsEqual( previous: ViewportSurfaceProps['surface3dProps'], next: ViewportSurfaceProps['surface3dProps'], ): boolean { - return previous.fg3dRef === next.fg3dRef - && previous.getArrowColor === next.getArrowColor - && previous.getLinkColor === next.getLinkColor - && previous.getLinkParticles === next.getLinkParticles - && previous.getLinkWidth === next.getLinkWidth - && previous.nodeThreeObjectContext.graphAppearanceRef === next.nodeThreeObjectContext.graphAppearanceRef - && previous.nodeThreeObjectContext.meshesRef === next.nodeThreeObjectContext.meshesRef - && previous.nodeThreeObjectContext.showLabelsRef === next.nodeThreeObjectContext.showLabelsRef - && previous.nodeThreeObjectContext.spritesRef === next.nodeThreeObjectContext.spritesRef - && previous.getParticleColor === next.getParticleColor - && previous.particleSize === next.particleSize - && previous.particleSpeed === next.particleSpeed - && previous.sharedProps === next.sharedProps; + return propsEqualByKeys(previous, next, SURFACE_3D_PROP_KEYS) + && propsEqualByKeys( + previous.nodeThreeObjectContext, + next.nodeThreeObjectContext, + NODE_THREE_OBJECT_CONTEXT_KEYS, + ); +} + +const SURFACE_2D_PROP_KEYS = [ + 'fg2dRef', + 'getArrowColor', + 'getArrowRelPos', + 'getLinkColor', + 'getLinkParticles', + 'getLinkWidth', + 'getParticleColor', + 'linkCanvasObject', + 'nodeCanvasObject', + 'nodePointerAreaPaint', + 'onRenderFramePost', + 'particleSize', + 'particleSpeed', + 'sharedProps', +] as const; + +const SURFACE_3D_PROP_KEYS = [ + 'fg3dRef', + 'getArrowColor', + 'getLinkColor', + 'getLinkParticles', + 'getLinkWidth', + 'getParticleColor', + 'particleSize', + 'particleSpeed', + 'sharedProps', +] as const; + +const NODE_THREE_OBJECT_CONTEXT_KEYS = [ + 'graphAppearanceRef', + 'meshesRef', + 'showLabelsRef', + 'spritesRef', +] as const; + +function propsEqualByKeys( + previous: T, + next: T, + keys: readonly K[], +): boolean { + return keys.every(key => previous[key] === next[key]); } function areViewportSurfacePropsEqual( @@ -313,7 +396,7 @@ function createMenuEntriesSignature(menuEntries: readonly GraphContextMenuEntry[ } export function Viewport({ - accessibilityItems = { nodes: [], edges: [] }, + accessibilityItems, canvasBackgroundColor, containerBackgroundColor, borderColor, @@ -326,10 +409,10 @@ export function Viewport({ handleMouseLeave, handleMouseMoveCapture, handleMouseUpCapture, - handleEdgeContextMenu = () => undefined, - handleNodeClick = () => undefined, - handleNodeContextMenu = () => undefined, - handleNodeHover = () => undefined, + handleEdgeContextMenu, + handleNodeClick, + handleNodeContextMenu, + handleNodeHover, marqueeSelection, menuEntries, surface2dProps, @@ -339,6 +422,13 @@ export function Viewport({ pluginHost, }: ViewportProps): ReactElement { const menuEntriesSignature = createMenuEntriesSignature(menuEntries); + const resolvedAccessibilityItems = resolveViewportAccessibilityItems(accessibilityItems); + const resolvedHandlers = resolveViewportHandlers({ + handleEdgeContextMenu, + handleNodeClick, + handleNodeContextMenu, + handleNodeHover, + }); return ( @@ -368,13 +458,13 @@ export function Viewport({
@@ -386,20 +476,7 @@ export function Viewport({ /> - + ); } @@ -408,27 +485,69 @@ function toNativeMouseEvent( type: 'click' | 'contextmenu', event: MouseEvent | ReactMouseEvent | ReactKeyboardEvent, ): MouseEvent { - if (event instanceof MouseEvent) { - return event; - } - - if (event.nativeEvent instanceof MouseEvent) { - return event.nativeEvent; + const nativeEvent = getNativeMouseEvent(event); + if (nativeEvent) { + return nativeEvent; } return new MouseEvent(type, { bubbles: true, cancelable: true, - button: type === 'contextmenu' ? 2 : 0, - buttons: type === 'contextmenu' ? 2 : 0, - clientX: 'clientX' in event ? event.clientX : 0, - clientY: 'clientY' in event ? event.clientY : 0, + button: mouseButtonForEventType(type), + buttons: mouseButtonForEventType(type), + clientX: getMouseEventCoordinate(event, 'clientX'), + clientY: getMouseEventCoordinate(event, 'clientY'), ctrlKey: event.ctrlKey, metaKey: event.metaKey, shiftKey: event.shiftKey, }); } +function getNativeMouseEvent( + event: MouseEvent | ReactMouseEvent | ReactKeyboardEvent, +): MouseEvent | undefined { + if (event instanceof MouseEvent) { + return event; + } + + return event.nativeEvent instanceof MouseEvent ? event.nativeEvent : undefined; +} + +function mouseButtonForEventType(type: 'click' | 'contextmenu'): number { + return type === 'contextmenu' ? 2 : 0; +} + +function getMouseEventCoordinate( + event: MouseEvent | ReactMouseEvent | ReactKeyboardEvent, + key: 'clientX' | 'clientY', +): number { + if (!(key in event)) { + return 0; + } + + return (event as MouseEvent | ReactMouseEvent)[key]; +} + +function isKeyboardActivation(key: string): boolean { + return key === 'Enter' || key === ' '; +} + +function handleAccessibilityNodeKeyDown( + nodeId: string, + handleNodeClick: ( + nodeId: string, + event: MouseEvent | ReactMouseEvent | ReactKeyboardEvent, + ) => void, + event: ReactKeyboardEvent, +): void { + if (!isKeyboardActivation(event.key)) { + return; + } + + event.preventDefault(); + handleNodeClick(nodeId, event); +} + function GraphAccessibilityOverlay({ accessibilityItems, graphLinks, @@ -499,12 +618,7 @@ function GraphAccessibilityOverlay({ onClick={event => handleNodeClick(node.id, event)} onContextMenu={event => handleNodeContextMenu(node.id, event)} onFocus={() => handleNodeHover(node.id)} - onKeyDown={event => { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault(); - handleNodeClick(node.id, event); - } - }} + onKeyDown={event => handleAccessibilityNodeKeyDown(node.id, handleNodeClick, event)} onMouseOut={() => onNodeHover(null)} onMouseOver={() => handleNodeHover(node.id)} style={{ diff --git a/packages/extension/src/webview/search/filtering/rules/nodes.ts b/packages/extension/src/webview/search/filtering/rules/nodes.ts index 1b04f8ad2..d3eeb0b03 100644 --- a/packages/extension/src/webview/search/filtering/rules/nodes.ts +++ b/packages/extension/src/webview/search/filtering/rules/nodes.ts @@ -4,6 +4,14 @@ import type { IGroup } from '../../../../shared/settings/groups'; import { createGlobMatcher } from '../../../globMatch'; import { ruleTargetsNodes } from './nodeMatcher'; +type GraphNode = IGraphData['nodes'][number]; +type GraphNodeSymbol = GraphNode['symbol']; +type NodeLegendConstraintMatcher = ( + node: GraphNode, + symbol: GraphNodeSymbol, + compiledRule: CompiledNodeLegendRule, +) => boolean; + export interface CompiledNodeLegendRule { caseInsensitivePatternMatches: (value: string) => boolean; hasConstraints: boolean; @@ -75,48 +83,37 @@ function getCaseInsensitiveNodeCandidates( .map((candidate) => candidate.toLowerCase()); } -function compiledRuleConstraintsMatchNode( - node: IGraphData['nodes'][number], - compiledRule: CompiledNodeLegendRule, -): boolean { - if (!compiledRule.hasConstraints) { - return true; - } - - const { rule } = compiledRule; - const symbol = node.symbol; - if (rule.matchNodeType && rule.matchNodeType !== node.nodeType) { - return false; - } - - if (rule.matchSymbolKind && rule.matchSymbolKind !== symbol?.kind) { - return false; - } - - if (rule.matchSymbolPluginKind && rule.matchSymbolPluginKind !== symbol?.pluginKind) { - return false; - } - - if (rule.matchSymbolSource && rule.matchSymbolSource !== symbol?.source) { - return false; - } +function optionalRuleValueMatches(expected: T | undefined, actual: T | undefined): boolean { + return expected === undefined || expected === actual; +} - if (rule.matchSymbolLanguage && rule.matchSymbolLanguage !== symbol?.language) { - return false; - } +function optionalSymbolKindsMatch(expected: readonly string[] | undefined, actual: string | undefined): boolean { + return expected === undefined || Boolean(actual && expected.includes(actual)); +} - if (rule.matchSymbolKinds && (!symbol?.kind || !rule.matchSymbolKinds.includes(symbol.kind))) { - return false; - } +function optionalSymbolFilePathMatches(compiledRule: CompiledNodeLegendRule, filePath: string | undefined): boolean { + return compiledRule.symbolFilePathMatches === undefined + || Boolean(filePath && compiledRule.symbolFilePathMatches(filePath)); +} - if ( - compiledRule.symbolFilePathMatches - && (!symbol?.filePath || !compiledRule.symbolFilePathMatches(symbol.filePath)) - ) { - return false; - } +const NODE_LEGEND_CONSTRAINT_MATCHERS: readonly NodeLegendConstraintMatcher[] = [ + (node, _symbol, { rule }) => optionalRuleValueMatches(rule.matchNodeType, node.nodeType), + (_node, symbol, { rule }) => optionalRuleValueMatches(rule.matchSymbolKind, symbol?.kind), + (_node, symbol, { rule }) => optionalRuleValueMatches(rule.matchSymbolPluginKind, symbol?.pluginKind), + (_node, symbol, { rule }) => optionalRuleValueMatches(rule.matchSymbolSource, symbol?.source), + (_node, symbol, { rule }) => optionalRuleValueMatches(rule.matchSymbolLanguage, symbol?.language), + (_node, symbol, { rule }) => optionalSymbolKindsMatch(rule.matchSymbolKinds, symbol?.kind), + (_node, symbol, compiledRule) => optionalSymbolFilePathMatches(compiledRule, symbol?.filePath), +]; - return true; +function compiledRuleConstraintsMatchNode( + node: GraphNode, + compiledRule: CompiledNodeLegendRule, +): boolean { + return !compiledRule.hasConstraints + || NODE_LEGEND_CONSTRAINT_MATCHERS.every(matcher => + matcher(node, node.symbol, compiledRule), + ); } function pathCandidateMatchesNodeRule( diff --git a/packages/extension/src/webview/search/useFilteredGraph.ts b/packages/extension/src/webview/search/useFilteredGraph.ts index 9ed516db9..dfbf7a9d2 100644 --- a/packages/extension/src/webview/search/useFilteredGraph.ts +++ b/packages/extension/src/webview/search/useFilteredGraph.ts @@ -192,6 +192,115 @@ function cacheReferenceResult( } } +function getVisibleGraphResult({ + cache, + edgeTypes, + edgeVisibility, + filterPatterns, + graphData, + key, + nodeTypes, + nodeVisibility, + searchOptions, + searchQuery, + showOrphans, +}: { + cache: VisibleGraphCache; + edgeTypes: IGraphEdgeTypeDefinition[]; + edgeVisibility: Record; + filterPatterns: readonly string[]; + graphData: IGraphData | null; + key: string; + nodeTypes: IGraphNodeTypeDefinition[]; + nodeVisibility: Record; + searchOptions: SearchOptions; + searchQuery: string; + showOrphans: boolean; +}): VisibleGraphResult { + if (cache.graphData !== graphData) { + cache.graphData = graphData; + cache.entries.clear(); + } + + const cached = cache.entries.get(key); + if (cached) { + cache.entries.delete(key); + cache.entries.set(key, cached); + return cached; + } + + const result = deriveVisibleGraph(graphData, buildVisibleGraphConfig({ + edgeTypes, + edgeVisibility, + filterPatterns, + nodeTypes, + nodeVisibility, + searchOptions, + searchQuery, + showOrphans, + })); + cacheVisibleGraphResult(cache, key, result); + return result; +} + +function getStyledGraphResult({ + cache, + edgeTypes, + graph, + key, + nodeColors, +}: { + cache: ReferenceResultCache; + edgeTypes: IGraphEdgeTypeDefinition[]; + graph: IGraphData | null; + key: string; + nodeColors: Record; +}): IGraphData | null { + if (!graph) { + return null; + } + + const cached = getReferenceResult(cache, graph, key); + if (cached) { + return cached; + } + + const edgeTypesForStyling = withSharedEdgeTypeAliases(edgeTypes); + const result = { + nodes: applyNodeTypeColors(withResolvedNodeTypes(graph.nodes), nodeColors), + edges: applyEdgeTypeDefaultColors(graph.edges, edgeTypesForStyling), + }; + cacheReferenceResult(cache, graph, key, result); + return result; +} + +function getColoredGraphResult({ + cache, + filteredData, + key, + legends, +}: { + cache: ReferenceResultCache; + filteredData: IGraphData | null; + key: string; + legends: IGroup[]; +}): IGraphData | null { + if (!filteredData) { + return null; + } + + const cached = getReferenceResult(cache, filteredData, key); + if (cached) { + return cached; + } + + const result = applyLegendRules(filteredData, legends); + if (result) { + cacheReferenceResult(cache, filteredData, key, result); + } + return result; +} + /** * Derives the filtered + colored graph data. * Both memos recompute only when their specific inputs change. @@ -242,31 +351,19 @@ export function useFilteredGraph( ]); const visibleGraph = useMemo(() => { - const cache = visibleGraphCache.current; - if (cache.graphData !== graphData) { - cache.graphData = graphData; - cache.entries.clear(); - } - - const cached = cache.entries.get(visibleGraphCacheKey); - if (cached) { - cache.entries.delete(visibleGraphCacheKey); - cache.entries.set(visibleGraphCacheKey, cached); - return cached; - } - - const result = deriveVisibleGraph(graphData, buildVisibleGraphConfig({ + return getVisibleGraphResult({ + cache: visibleGraphCache.current, edgeTypes, edgeVisibility, filterPatterns, + graphData, + key: visibleGraphCacheKey, nodeTypes, nodeVisibility, searchOptions, searchQuery, showOrphans, - })); - cacheVisibleGraphResult(cache, visibleGraphCacheKey, result); - return result; + }); }, [ edgeTypes, edgeVisibility, @@ -281,41 +378,22 @@ export function useFilteredGraph( ]); const filteredData = useMemo(() => { - const graph = visibleGraph.graphData; - if (!graph) { - return null; - } - - const cached = getReferenceResult(styledGraphCache.current, graph, styledGraphCacheKey); - if (cached) { - return cached; - } - - const edgeTypesForStyling = withSharedEdgeTypeAliases(edgeTypes); - - const result = { - nodes: applyNodeTypeColors(withResolvedNodeTypes(graph.nodes), nodeColors), - edges: applyEdgeTypeDefaultColors(graph.edges, edgeTypesForStyling), - }; - cacheReferenceResult(styledGraphCache.current, graph, styledGraphCacheKey, result); - return result; + return getStyledGraphResult({ + cache: styledGraphCache.current, + edgeTypes, + graph: visibleGraph.graphData, + key: styledGraphCacheKey, + nodeColors, + }); }, [edgeTypes, nodeColors, styledGraphCacheKey, visibleGraph.graphData]); const coloredData = useMemo(() => { - if (!filteredData) { - return null; - } - - const cached = getReferenceResult(coloredGraphCache.current, filteredData, legendGraphCacheKey); - if (cached) { - return cached; - } - - const result = applyLegendRules(filteredData, legends); - if (result) { - cacheReferenceResult(coloredGraphCache.current, filteredData, legendGraphCacheKey, result); - } - return result; + return getColoredGraphResult({ + cache: coloredGraphCache.current, + filteredData, + key: legendGraphCacheKey, + legends, + }); }, [filteredData, legendGraphCacheKey, legends]); const controlsEdgeDecorations = useMemo( diff --git a/packages/extension/src/webview/store/messageHandlers/graph.ts b/packages/extension/src/webview/store/messageHandlers/graph.ts index 9e6097a70..04a065008 100644 --- a/packages/extension/src/webview/store/messageHandlers/graph.ts +++ b/packages/extension/src/webview/store/messageHandlers/graph.ts @@ -200,6 +200,17 @@ export function handleGraphIndexProgress( }; } +function assignChangedGraphControl( + next: PartialState, + key: K, + currentValue: PartialState[K], + nextValue: PartialState[K], +): void { + if (!arePlainValuesEqual(currentValue, nextValue)) { + next[key] = nextValue; + } +} + export function handleGraphControlsUpdated( message: Extract, ctx?: Pick, @@ -217,25 +228,11 @@ export function handleGraphControlsUpdated( const next: PartialState = {}; - if (!arePlainValuesEqual(state.graphNodeTypes, message.payload.nodeTypes)) { - next.graphNodeTypes = message.payload.nodeTypes; - } - - if (!arePlainValuesEqual(state.graphEdgeTypes, message.payload.edgeTypes)) { - next.graphEdgeTypes = message.payload.edgeTypes; - } - - if (!arePlainValuesEqual(state.nodeColors, message.payload.nodeColors)) { - next.nodeColors = message.payload.nodeColors; - } - - if (!arePlainValuesEqual(state.nodeVisibility, message.payload.nodeVisibility)) { - next.nodeVisibility = message.payload.nodeVisibility; - } - - if (!arePlainValuesEqual(state.edgeVisibility, message.payload.edgeVisibility)) { - next.edgeVisibility = message.payload.edgeVisibility; - } + assignChangedGraphControl(next, 'graphNodeTypes', state.graphNodeTypes, message.payload.nodeTypes); + assignChangedGraphControl(next, 'graphEdgeTypes', state.graphEdgeTypes, message.payload.edgeTypes); + assignChangedGraphControl(next, 'nodeColors', state.nodeColors, message.payload.nodeColors); + assignChangedGraphControl(next, 'nodeVisibility', state.nodeVisibility, message.payload.nodeVisibility); + assignChangedGraphControl(next, 'edgeVisibility', state.edgeVisibility, message.payload.edgeVisibility); return Object.keys(next).length > 0 ? next : undefined; } diff --git a/packages/extension/tests/extension/graphView/webview/messages/ready.test.ts b/packages/extension/tests/extension/graphView/webview/messages/ready.test.ts index d8a583ce3..20f6dda48 100644 --- a/packages/extension/tests/extension/graphView/webview/messages/ready.test.ts +++ b/packages/extension/tests/extension/graphView/webview/messages/ready.test.ts @@ -187,6 +187,33 @@ describe('graph view ready message', () => { )).toHaveLength(1); }); + it('does not replay unchanged plugin filter groups after graph loading', async () => { + const handlers = createHandlers(); + handlers.getPluginFilterPatterns.mockReturnValue(['**/*.meta']); + handlers.getPluginFilterGroups = vi.fn(() => [ + { pluginId: 'codegraphy.unity', pluginName: 'Unity', patterns: ['**/*.meta'] }, + ]); + + await applyWebviewReady( + { + maxFiles: 500, + verboseDiagnostics: false, + playbackSpeed: 1, + dagMode: null, + nodeSizeMode: 'connections', + focusedFile: undefined, + hasWorkspace: true, + firstAnalysis: true, + readyNotified: false, + }, + handlers + ); + + expect(handlers.sendMessage.mock.calls.filter(([message]) => + (message as { type?: string }).type === 'FILTER_PATTERNS_UPDATED' + )).toHaveLength(1); + }); + it('replays plugin filters that become available while loading graph data', async () => { const handlers = createHandlers(); let pluginPatterns: string[] = []; From cb3dc66ac0dfbceadf85bb70d581aab7eba96e83 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 11:49:30 -0700 Subject: [PATCH 096/192] test: split changed tests for scrap --- packages/core/tests/indexing/refresh.test.ts | 107 +---- .../core/tests/indexing/refresh/fixture.ts | 106 +++++ .../provider/analysis/methods.test.ts | 68 +--- .../provider/analysis/methods/fixture.ts | 70 ++++ .../webview/messages/listener.test.ts | 101 +---- .../webview/messages/listener/fixture.ts | 102 +++++ .../graphView/webview/messages/ready.test.ts | 34 +- .../webview/messages/ready/fixture.ts | 34 ++ .../tests/shared/visibleGraph/scope.test.ts | 385 +----------------- .../shared/visibleGraph/scope/fixture.ts | 38 ++ .../visibleGraph/scope/pluginSymbols.test.ts | 352 ++++++++++++++++ .../webview/app/shell/view.messages.test.tsx | 112 +++++ .../tests/webview/app/shell/view.test.tsx | 174 +------- .../tests/webview/app/shell/view/fixture.ts | 65 +++ .../store/messageHandlers/graph.test.ts | 72 +--- .../store/messageHandlers/graph/fixture.ts | 71 ++++ 16 files changed, 963 insertions(+), 928 deletions(-) create mode 100644 packages/core/tests/indexing/refresh/fixture.ts create mode 100644 packages/extension/tests/extension/graphView/provider/analysis/methods/fixture.ts create mode 100644 packages/extension/tests/extension/graphView/webview/messages/listener/fixture.ts create mode 100644 packages/extension/tests/extension/graphView/webview/messages/ready/fixture.ts create mode 100644 packages/extension/tests/shared/visibleGraph/scope/fixture.ts create mode 100644 packages/extension/tests/shared/visibleGraph/scope/pluginSymbols.test.ts create mode 100644 packages/extension/tests/webview/app/shell/view.messages.test.tsx create mode 100644 packages/extension/tests/webview/app/shell/view/fixture.ts create mode 100644 packages/extension/tests/webview/store/messageHandlers/graph/fixture.ts diff --git a/packages/core/tests/indexing/refresh.test.ts b/packages/core/tests/indexing/refresh.test.ts index d42aa4acc..ab687b6df 100644 --- a/packages/core/tests/indexing/refresh.test.ts +++ b/packages/core/tests/indexing/refresh.test.ts @@ -5,108 +5,15 @@ import { refreshWorkspaceIndexAnalysisScope, refreshWorkspaceIndexChangedFiles, refreshWorkspaceIndexPluginFiles, - type WorkspaceIndexRefreshDependencies, - type WorkspaceIndexRefreshSource, } from '../../src/indexing/refresh'; -import type { IDiscoveredFile } from '../../src/discovery/contracts'; import type { IGraphData } from '../../src/graph/contracts'; - -function createDiscoveredFile(relativePath: string): IDiscoveredFile { - const name = relativePath.split('/').at(-1) ?? relativePath; - return { - absolutePath: `/workspace/${relativePath}`, - extension: name.includes('.') ? name.slice(name.lastIndexOf('.')) : '', - name, - relativePath, - }; -} - -function createFileAnalysis(filePath: string): IFileAnalysisResult { - return { - filePath, - relations: [], - }; -} - -function createGraphNode(id: string) { - return { - color: '#808080', - id, - label: id.split('/').at(-1) ?? id, - }; -} - -function createSource( - overrides: Partial = {}, -): WorkspaceIndexRefreshSource { - const graph: IGraphData = { - nodes: [{ color: '#808080', id: 'src/app.ts', label: 'app.ts', nodeType: 'file' }], - edges: [], - }; - - return { - _analyzeFiles: vi.fn(async (files: IDiscoveredFile[]) => ({ - cacheHits: 0, - cacheMisses: files.length, - fileAnalysis: new Map(files.map(file => [ - file.relativePath, - createFileAnalysis(file.absolutePath), - ])), - fileConnections: new Map(files.map(file => [file.relativePath, []])), - })), - _buildGraphData: vi.fn((fileConnections: Map) => ({ - nodes: [...fileConnections.keys()].map(createGraphNode), - edges: [], - })), - _buildGraphDataFromAnalysis: vi.fn((fileAnalysis: Map) => ({ - nodes: [...fileAnalysis.keys()].map(createGraphNode), - edges: [], - })), - _lastDiscoveredDirectories: ['src'], - _lastDiscoveredFiles: [ - createDiscoveredFile('README.md'), - createDiscoveredFile('src/plugin.ts'), - createDiscoveredFile('src/plain.txt'), - ], - _lastFileAnalysis: new Map(), - _lastFileConnections: new Map([ - ['README.md', []], - ['src/plugin.ts', []], - ['src/plain.txt', []], - ]) as Map, - _lastGraphData: graph, - _lastWorkspaceRoot: '/workspace', - _preAnalyzePlugins: vi.fn(async () => undefined), - _readAnalysisFiles: vi.fn(async (files: IDiscoveredFile[]) => files.map(file => ({ - absolutePath: file.absolutePath, - relativePath: file.relativePath, - content: '', - }))), - analyze: vi.fn(async () => graph), - invalidateWorkspaceFiles: vi.fn(() => []), - ...overrides, - }; -} - -function refreshOptions( - overrides: Partial = {}, -): WorkspaceIndexRefreshDependencies { - return { - disabledPlugins: new Set(), - discoveredDirectories: ['src'], - discoveredFiles: [createDiscoveredFile('src/app.ts')], - filePaths: ['/workspace/src/app.ts'], - filterPatterns: [], - notifyFilesChanged: vi.fn(async () => ({ - additionalFilePaths: [], - requiresFullRefresh: false, - })), - persistCache: vi.fn(), - persistIndexMetadata: vi.fn(), - workspaceRoot: '/workspace', - ...overrides, - }; -} +import { + createDiscoveredFile, + createFileAnalysis, + createGraphNode, + createSource, + refreshOptions, +} from './refresh/fixture'; describe('indexing/refresh', () => { it('lets file analysis own pre-analysis during analysis scope refreshes', async () => { diff --git a/packages/core/tests/indexing/refresh/fixture.ts b/packages/core/tests/indexing/refresh/fixture.ts new file mode 100644 index 000000000..482ce0e27 --- /dev/null +++ b/packages/core/tests/indexing/refresh/fixture.ts @@ -0,0 +1,106 @@ +import { vi } from 'vitest'; + +import type { IFileAnalysisResult } from '@codegraphy-dev/plugin-api'; +import type { IDiscoveredFile } from '../../../src/discovery/contracts'; +import type { IGraphData } from '../../../src/graph/contracts'; +import type { + WorkspaceIndexRefreshDependencies, + WorkspaceIndexRefreshSource, +} from '../../../src/indexing/refresh'; + +export function createDiscoveredFile(relativePath: string): IDiscoveredFile { + const name = relativePath.split('/').at(-1) ?? relativePath; + return { + absolutePath: `/workspace/${relativePath}`, + extension: name.includes('.') ? name.slice(name.lastIndexOf('.')) : '', + name, + relativePath, + }; +} + +export function createFileAnalysis(filePath: string): IFileAnalysisResult { + return { + filePath, + relations: [], + }; +} + +export function createGraphNode(id: string) { + return { + color: '#808080', + id, + label: id.split('/').at(-1) ?? id, + }; +} + +export function createSource( + overrides: Partial = {}, +): WorkspaceIndexRefreshSource { + const graph: IGraphData = { + nodes: [{ color: '#808080', id: 'src/app.ts', label: 'app.ts', nodeType: 'file' }], + edges: [], + }; + + return { + _analyzeFiles: vi.fn(async (files: IDiscoveredFile[]) => ({ + cacheHits: 0, + cacheMisses: files.length, + fileAnalysis: new Map(files.map(file => [ + file.relativePath, + createFileAnalysis(file.absolutePath), + ])), + fileConnections: new Map(files.map(file => [file.relativePath, []])), + })), + _buildGraphData: vi.fn((fileConnections: Map) => ({ + nodes: [...fileConnections.keys()].map(createGraphNode), + edges: [], + })), + _buildGraphDataFromAnalysis: vi.fn((fileAnalysis: Map) => ({ + nodes: [...fileAnalysis.keys()].map(createGraphNode), + edges: [], + })), + _lastDiscoveredDirectories: ['src'], + _lastDiscoveredFiles: [ + createDiscoveredFile('README.md'), + createDiscoveredFile('src/plugin.ts'), + createDiscoveredFile('src/plain.txt'), + ], + _lastFileAnalysis: new Map(), + _lastFileConnections: new Map([ + ['README.md', []], + ['src/plugin.ts', []], + ['src/plain.txt', []], + ]) as Map, + _lastGraphData: graph, + _lastWorkspaceRoot: '/workspace', + _preAnalyzePlugins: vi.fn(async () => undefined), + _readAnalysisFiles: vi.fn(async (files: IDiscoveredFile[]) => files.map(file => ({ + absolutePath: file.absolutePath, + relativePath: file.relativePath, + content: '', + }))), + analyze: vi.fn(async () => graph), + invalidateWorkspaceFiles: vi.fn(() => []), + ...overrides, + }; +} + +export function refreshOptions( + overrides: Partial = {}, +): WorkspaceIndexRefreshDependencies { + return { + disabledPlugins: new Set(), + discoveredDirectories: ['src'], + discoveredFiles: [createDiscoveredFile('src/app.ts')], + filePaths: ['/workspace/src/app.ts'], + filterPatterns: [], + notifyFilesChanged: vi.fn(async () => ({ + additionalFilePaths: [], + requiresFullRefresh: false, + })), + persistCache: vi.fn(), + persistIndexMetadata: vi.fn(), + workspaceRoot: '/workspace', + ...overrides, + }; +} diff --git a/packages/extension/tests/extension/graphView/provider/analysis/methods.test.ts b/packages/extension/tests/extension/graphView/provider/analysis/methods.test.ts index 7877cfc43..adcf4945f 100644 --- a/packages/extension/tests/extension/graphView/provider/analysis/methods.test.ts +++ b/packages/extension/tests/extension/graphView/provider/analysis/methods.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import type { IGraphData } from '../../../../../src/shared/graph/contracts'; import { createGraphViewProviderAnalysisDelegates } from '../../../../../src/extension/graphView/provider/analysis/delegates'; import { createGraphViewProviderAnalysisMethods } from '../../../../../src/extension/graphView/provider/analysis/methods'; +import { createSource } from './methods/fixture'; vi.mock('../../../../../src/extension/graphView/provider/analysis/delegates', async importOriginal => { const actual = await importOriginal< @@ -14,73 +15,6 @@ vi.mock('../../../../../src/extension/graphView/provider/analysis/delegates', as }; }); -function createSource( - overrides: Partial> = {}, -): { - _analysisController?: AbortController; - _analysisRequestId: number; - _analyzer?: { - registry: { - notifyWorkspaceReady: ReturnType; - }; - }; - _analyzerInitialized: boolean; - _analyzerInitPromise?: Promise; - _filterPatterns: string[]; - _disabledPlugins: Set; - _graphData: IGraphData; - _rawGraphData: IGraphData; - _firstAnalysis: boolean; - _resolveFirstWorkspaceReady?: ReturnType; - _firstWorkspaceReadyPromise: Promise; - _sendMessage: ReturnType; - _sendDepthState: ReturnType; - _computeMergedGroups: ReturnType; - _sendGroupsUpdated: ReturnType; - _updateViewContext: ReturnType; - _applyViewTransform: ReturnType; - _sendPluginStatuses: ReturnType; - _sendDecorations: ReturnType; - _sendContextMenuItems: ReturnType; - _analyzeAndSendData?: () => Promise; - _doAnalyzeAndSendData?: (signal: AbortSignal, requestId: number) => Promise; - _markWorkspaceReady?: (graph: IGraphData) => void; - _isAnalysisStale?: (signal: AbortSignal, requestId: number) => boolean; - _isAbortError?: (error: unknown) => boolean; - [key: string]: unknown; -} { - const firstWorkspaceReadyPromise = Promise.resolve(); - - return { - _analysisController: undefined, - _analysisRequestId: 7, - _analyzer: { - registry: { - notifyWorkspaceReady: vi.fn(), - }, - }, - _analyzerInitialized: false, - _analyzerInitPromise: undefined, - _filterPatterns: [], - _disabledPlugins: new Set(), - _graphData: { nodes: [], edges: [] }, - _rawGraphData: { nodes: [], edges: [] }, - _firstAnalysis: true, - _resolveFirstWorkspaceReady: vi.fn(), - _firstWorkspaceReadyPromise: firstWorkspaceReadyPromise, - _sendMessage: vi.fn(), - _sendDepthState: vi.fn(), - _computeMergedGroups: vi.fn(), - _sendGroupsUpdated: vi.fn(), - _updateViewContext: vi.fn(), - _applyViewTransform: vi.fn(), - _sendPluginStatuses: vi.fn(), - _sendDecorations: vi.fn(), - _sendContextMenuItems: vi.fn(), - ...overrides, - }; -} - describe('graphView/provider/analysis/methods', () => { it('returns analysis helpers without mutating the provider source', () => { const source = createSource(); diff --git a/packages/extension/tests/extension/graphView/provider/analysis/methods/fixture.ts b/packages/extension/tests/extension/graphView/provider/analysis/methods/fixture.ts new file mode 100644 index 000000000..f8eb532e6 --- /dev/null +++ b/packages/extension/tests/extension/graphView/provider/analysis/methods/fixture.ts @@ -0,0 +1,70 @@ +import { vi } from 'vitest'; + +import type { IGraphData } from '../../../../../../src/shared/graph/contracts'; + +export function createSource( + overrides: Partial> = {}, +): { + _analysisController?: AbortController; + _analysisRequestId: number; + _analyzer?: { + registry: { + notifyWorkspaceReady: ReturnType; + }; + }; + _analyzerInitialized: boolean; + _analyzerInitPromise?: Promise; + _filterPatterns: string[]; + _disabledPlugins: Set; + _graphData: IGraphData; + _rawGraphData: IGraphData; + _firstAnalysis: boolean; + _resolveFirstWorkspaceReady?: ReturnType; + _firstWorkspaceReadyPromise: Promise; + _sendMessage: ReturnType; + _sendDepthState: ReturnType; + _computeMergedGroups: ReturnType; + _sendGroupsUpdated: ReturnType; + _updateViewContext: ReturnType; + _applyViewTransform: ReturnType; + _sendPluginStatuses: ReturnType; + _sendDecorations: ReturnType; + _sendContextMenuItems: ReturnType; + _analyzeAndSendData?: () => Promise; + _doAnalyzeAndSendData?: (signal: AbortSignal, requestId: number) => Promise; + _markWorkspaceReady?: (graph: IGraphData) => void; + _isAnalysisStale?: (signal: AbortSignal, requestId: number) => boolean; + _isAbortError?: (error: unknown) => boolean; + [key: string]: unknown; +} { + const firstWorkspaceReadyPromise = Promise.resolve(); + + return { + _analysisController: undefined, + _analysisRequestId: 7, + _analyzer: { + registry: { + notifyWorkspaceReady: vi.fn(), + }, + }, + _analyzerInitialized: false, + _analyzerInitPromise: undefined, + _filterPatterns: [], + _disabledPlugins: new Set(), + _graphData: { nodes: [], edges: [] }, + _rawGraphData: { nodes: [], edges: [] }, + _firstAnalysis: true, + _resolveFirstWorkspaceReady: vi.fn(), + _firstWorkspaceReadyPromise: firstWorkspaceReadyPromise, + _sendMessage: vi.fn(), + _sendDepthState: vi.fn(), + _computeMergedGroups: vi.fn(), + _sendGroupsUpdated: vi.fn(), + _updateViewContext: vi.fn(), + _applyViewTransform: vi.fn(), + _sendPluginStatuses: vi.fn(), + _sendDecorations: vi.fn(), + _sendContextMenuItems: vi.fn(), + ...overrides, + }; +} diff --git a/packages/extension/tests/extension/graphView/webview/messages/listener.test.ts b/packages/extension/tests/extension/graphView/webview/messages/listener.test.ts index d43aff31b..cf82018f6 100644 --- a/packages/extension/tests/extension/graphView/webview/messages/listener.test.ts +++ b/packages/extension/tests/extension/graphView/webview/messages/listener.test.ts @@ -1,108 +1,9 @@ import { describe, expect, it, vi } from 'vitest'; -import type { NodeSizeMode } from '../../../../../src/shared/settings/modes'; -import type { IGraphData } from '../../../../../src/shared/graph/contracts'; import type { IGroup } from '../../../../../src/shared/settings/groups'; -import type { IViewContext } from '../../../../../src/core/views/contracts'; import { setGraphViewWebviewMessageListener, - type GraphViewMessageListenerContext, } from '../../../../../src/extension/graphView/webview/messages/listener'; - -function createContext( - overrides: Partial = {}, -): GraphViewMessageListenerContext { - const context = { - getTimelineActive: vi.fn(() => false), - getCurrentCommitSha: vi.fn(() => undefined), - getCanMutateGraphRevision: vi.fn(() => true), - getUserGroups: vi.fn(() => []), - getFilterPatterns: vi.fn(() => []), - getGraphData: vi.fn(() => ({ nodes: [], edges: [] } satisfies IGraphData)), - getViewContext: vi.fn(() => ({ activePlugins: new Set() } satisfies IViewContext)), - getFocusedFile: vi.fn(() => undefined), - setFocusedFile: vi.fn(), - openSelectedNode: vi.fn(() => Promise.resolve()), - activateNode: vi.fn(() => Promise.resolve()), - previewFileAtCommit: vi.fn(() => Promise.resolve()), - openFile: vi.fn(() => Promise.resolve()), - revealInExplorer: vi.fn(() => Promise.resolve()), - copyToClipboard: vi.fn(() => Promise.resolve()), - deleteFiles: vi.fn(() => Promise.resolve()), - renameFile: vi.fn(() => Promise.resolve()), - createFile: vi.fn(() => Promise.resolve()), - createFolder: vi.fn(() => Promise.resolve()), - toggleFavorites: vi.fn(() => Promise.resolve()), - addToExclude: vi.fn(() => Promise.resolve()), - analyzeAndSendData: vi.fn(() => Promise.resolve()), - refreshIndex: vi.fn(() => Promise.resolve()), - clearCacheAndRefresh: vi.fn(() => Promise.resolve()), - getFileInfo: vi.fn(() => Promise.resolve()), - undo: vi.fn(() => Promise.resolve(undefined)), - redo: vi.fn(() => Promise.resolve(undefined)), - showInformationMessage: vi.fn(), - changeView: vi.fn(() => Promise.resolve()), - setDepthLimit: vi.fn(() => Promise.resolve()), - updateDagMode: vi.fn(() => Promise.resolve()), - updateNodeSizeMode: vi.fn(() => Promise.resolve()), - indexRepository: vi.fn(() => Promise.resolve()), - jumpToCommit: vi.fn(() => Promise.resolve()), - resetTimeline: vi.fn(() => Promise.resolve()), - sendPhysicsSettings: vi.fn(), - updatePhysicsSetting: vi.fn(() => Promise.resolve()), - resetPhysicsSettings: vi.fn(() => Promise.resolve()), - workspaceFolder: undefined, - persistLegends: vi.fn(() => Promise.resolve()), - persistDefaultLegendVisibility: vi.fn(() => Promise.resolve()), - recomputeGroups: vi.fn(), - sendGroupsUpdated: vi.fn(), - showOpenDialog: vi.fn(() => Promise.resolve(undefined)), - createDirectory: vi.fn(() => Promise.resolve()), - copyFile: vi.fn(() => Promise.resolve()), - getConfig: vi.fn((_key: string, defaultValue: T) => defaultValue), - updateConfig: vi.fn(() => Promise.resolve()), - getPluginFilterPatterns: vi.fn(() => []), - getPluginFilterGroups: vi.fn(() => []), - sendGraphControls: vi.fn(), - sendMessage: vi.fn(), - applyViewTransform: vi.fn(), - smartRebuild: vi.fn(), - resetAllSettings: vi.fn(() => Promise.resolve()), - getMaxFiles: vi.fn(() => 500), - getPlaybackSpeed: vi.fn(() => 1), - getDagMode: vi.fn(() => null), - getNodeSizeMode: vi.fn(() => 'connections' as NodeSizeMode), - hasWorkspace: vi.fn(() => false), - isFirstAnalysis: vi.fn(() => false), - isWebviewReadyNotified: vi.fn(() => false), - loadGroupsAndFilterPatterns: vi.fn(), - loadDisabledRulesAndPlugins: vi.fn(), - sendDepthState: vi.fn(), - loadAndSendData: vi.fn(() => Promise.resolve()), - sendFavorites: vi.fn(), - sendSettings: vi.fn(), - sendCachedTimeline: vi.fn(), - sendDecorations: vi.fn(), - sendContextMenuItems: vi.fn(), - sendPluginWebviewInjections: vi.fn(), - sendActiveFile: vi.fn(), - waitForFirstWorkspaceReady: vi.fn(() => Promise.resolve()), - notifyWebviewReady: vi.fn(), - getInteractionPluginApi: vi.fn(), - getContextMenuPluginApi: vi.fn(), - emitEvent: vi.fn(), - findNode: vi.fn(), - findEdge: vi.fn(), - logError: vi.fn(), - setUserGroups: vi.fn(), - setFilterPatterns: vi.fn(), - setWebviewReadyNotified: vi.fn(), - ...overrides, - }; - - context.sendGraphControls ??= vi.fn(); - - return context as GraphViewMessageListenerContext; -} +import { createContext } from './listener/fixture'; describe('graph view webview message listener', () => { it('stores user group updates from primary dispatch flows', async () => { diff --git a/packages/extension/tests/extension/graphView/webview/messages/listener/fixture.ts b/packages/extension/tests/extension/graphView/webview/messages/listener/fixture.ts new file mode 100644 index 000000000..6468acc42 --- /dev/null +++ b/packages/extension/tests/extension/graphView/webview/messages/listener/fixture.ts @@ -0,0 +1,102 @@ +import { vi } from 'vitest'; + +import type { IViewContext } from '../../../../../../src/core/views/contracts'; +import type { GraphViewMessageListenerContext } from '../../../../../../src/extension/graphView/webview/messages/listener'; +import type { IGraphData } from '../../../../../../src/shared/graph/contracts'; +import type { NodeSizeMode } from '../../../../../../src/shared/settings/modes'; + +export function createContext( + overrides: Partial = {}, +): GraphViewMessageListenerContext { + const context = { + getTimelineActive: vi.fn(() => false), + getCurrentCommitSha: vi.fn(() => undefined), + getCanMutateGraphRevision: vi.fn(() => true), + getUserGroups: vi.fn(() => []), + getFilterPatterns: vi.fn(() => []), + getGraphData: vi.fn(() => ({ nodes: [], edges: [] } satisfies IGraphData)), + getViewContext: vi.fn(() => ({ activePlugins: new Set() } satisfies IViewContext)), + getFocusedFile: vi.fn(() => undefined), + setFocusedFile: vi.fn(), + openSelectedNode: vi.fn(() => Promise.resolve()), + activateNode: vi.fn(() => Promise.resolve()), + previewFileAtCommit: vi.fn(() => Promise.resolve()), + openFile: vi.fn(() => Promise.resolve()), + revealInExplorer: vi.fn(() => Promise.resolve()), + copyToClipboard: vi.fn(() => Promise.resolve()), + deleteFiles: vi.fn(() => Promise.resolve()), + renameFile: vi.fn(() => Promise.resolve()), + createFile: vi.fn(() => Promise.resolve()), + createFolder: vi.fn(() => Promise.resolve()), + toggleFavorites: vi.fn(() => Promise.resolve()), + addToExclude: vi.fn(() => Promise.resolve()), + analyzeAndSendData: vi.fn(() => Promise.resolve()), + refreshIndex: vi.fn(() => Promise.resolve()), + clearCacheAndRefresh: vi.fn(() => Promise.resolve()), + getFileInfo: vi.fn(() => Promise.resolve()), + undo: vi.fn(() => Promise.resolve(undefined)), + redo: vi.fn(() => Promise.resolve(undefined)), + showInformationMessage: vi.fn(), + changeView: vi.fn(() => Promise.resolve()), + setDepthLimit: vi.fn(() => Promise.resolve()), + updateDagMode: vi.fn(() => Promise.resolve()), + updateNodeSizeMode: vi.fn(() => Promise.resolve()), + indexRepository: vi.fn(() => Promise.resolve()), + jumpToCommit: vi.fn(() => Promise.resolve()), + resetTimeline: vi.fn(() => Promise.resolve()), + sendPhysicsSettings: vi.fn(), + updatePhysicsSetting: vi.fn(() => Promise.resolve()), + resetPhysicsSettings: vi.fn(() => Promise.resolve()), + workspaceFolder: undefined, + persistLegends: vi.fn(() => Promise.resolve()), + persistDefaultLegendVisibility: vi.fn(() => Promise.resolve()), + recomputeGroups: vi.fn(), + sendGroupsUpdated: vi.fn(), + showOpenDialog: vi.fn(() => Promise.resolve(undefined)), + createDirectory: vi.fn(() => Promise.resolve()), + copyFile: vi.fn(() => Promise.resolve()), + getConfig: vi.fn((_key: string, defaultValue: T) => defaultValue), + updateConfig: vi.fn(() => Promise.resolve()), + getPluginFilterPatterns: vi.fn(() => []), + getPluginFilterGroups: vi.fn(() => []), + sendGraphControls: vi.fn(), + sendMessage: vi.fn(), + applyViewTransform: vi.fn(), + smartRebuild: vi.fn(), + resetAllSettings: vi.fn(() => Promise.resolve()), + getMaxFiles: vi.fn(() => 500), + getPlaybackSpeed: vi.fn(() => 1), + getDagMode: vi.fn(() => null), + getNodeSizeMode: vi.fn(() => 'connections' as NodeSizeMode), + hasWorkspace: vi.fn(() => false), + isFirstAnalysis: vi.fn(() => false), + isWebviewReadyNotified: vi.fn(() => false), + loadGroupsAndFilterPatterns: vi.fn(), + loadDisabledRulesAndPlugins: vi.fn(), + sendDepthState: vi.fn(), + loadAndSendData: vi.fn(() => Promise.resolve()), + sendFavorites: vi.fn(), + sendSettings: vi.fn(), + sendCachedTimeline: vi.fn(), + sendDecorations: vi.fn(), + sendContextMenuItems: vi.fn(), + sendPluginWebviewInjections: vi.fn(), + sendActiveFile: vi.fn(), + waitForFirstWorkspaceReady: vi.fn(() => Promise.resolve()), + notifyWebviewReady: vi.fn(), + getInteractionPluginApi: vi.fn(), + getContextMenuPluginApi: vi.fn(), + emitEvent: vi.fn(), + findNode: vi.fn(), + findEdge: vi.fn(), + logError: vi.fn(), + setUserGroups: vi.fn(), + setFilterPatterns: vi.fn(), + setWebviewReadyNotified: vi.fn(), + ...overrides, + }; + + context.sendGraphControls ??= vi.fn(); + + return context as GraphViewMessageListenerContext; +} diff --git a/packages/extension/tests/extension/graphView/webview/messages/ready.test.ts b/packages/extension/tests/extension/graphView/webview/messages/ready.test.ts index 20f6dda48..46c2dcf01 100644 --- a/packages/extension/tests/extension/graphView/webview/messages/ready.test.ts +++ b/packages/extension/tests/extension/graphView/webview/messages/ready.test.ts @@ -3,39 +3,7 @@ import { applyWebviewReady, replayDuplicateWebviewReady, } from '../../../../../src/extension/graphView/webview/messages/ready'; - -function createHandlers() { - return { - getGraphData: vi.fn(() => ({ - nodes: [{ id: 'cached.ts', label: 'cached.ts', color: '#ffffff' }], - edges: [], - })), - getFilterPatterns: vi.fn(() => ['dist/**']), - getPluginFilterPatterns: vi.fn(() => ['venv/**']), - getPluginFilterGroups: vi.fn(() => []), - getConfig: vi.fn((_key: string, defaultValue: T): T => defaultValue), - loadGroupsAndFilterPatterns: vi.fn(), - loadDisabledRulesAndPlugins: vi.fn(), - sendDepthState: vi.fn(), - sendGraphControls: vi.fn(), - loadAndSendData: vi.fn(), - sendFavorites: vi.fn(), - sendSettings: vi.fn(), - sendPhysicsSettings: vi.fn(), - sendGroupsUpdated: vi.fn(), - sendMessage: vi.fn(), - sendCachedTimeline: vi.fn(), - sendDecorations: vi.fn(), - sendContextMenuItems: vi.fn(), - sendPluginStatuses: vi.fn(), - sendPluginWebviewInjections: vi.fn(), - sendPluginToolbarActions: vi.fn(), - sendGraphViewContributionStatuses: vi.fn(), - sendActiveFile: vi.fn(), - waitForFirstWorkspaceReady: vi.fn(() => Promise.resolve()), - notifyWebviewReady: vi.fn(), - }; -} +import { createHandlers } from './ready/fixture'; describe('graph view ready message', () => { it('sends the initial webview payloads and notifies readiness', async () => { diff --git a/packages/extension/tests/extension/graphView/webview/messages/ready/fixture.ts b/packages/extension/tests/extension/graphView/webview/messages/ready/fixture.ts new file mode 100644 index 000000000..a45a12a7b --- /dev/null +++ b/packages/extension/tests/extension/graphView/webview/messages/ready/fixture.ts @@ -0,0 +1,34 @@ +import { vi } from 'vitest'; + +export function createHandlers() { + return { + getGraphData: vi.fn(() => ({ + nodes: [{ id: 'cached.ts', label: 'cached.ts', color: '#ffffff' }], + edges: [], + })), + getFilterPatterns: vi.fn(() => ['dist/**']), + getPluginFilterPatterns: vi.fn(() => ['venv/**']), + getPluginFilterGroups: vi.fn(() => []), + getConfig: vi.fn((_key: string, defaultValue: T): T => defaultValue), + loadGroupsAndFilterPatterns: vi.fn(), + loadDisabledRulesAndPlugins: vi.fn(), + sendDepthState: vi.fn(), + sendGraphControls: vi.fn(), + loadAndSendData: vi.fn(), + sendFavorites: vi.fn(), + sendSettings: vi.fn(), + sendPhysicsSettings: vi.fn(), + sendGroupsUpdated: vi.fn(), + sendMessage: vi.fn(), + sendCachedTimeline: vi.fn(), + sendDecorations: vi.fn(), + sendContextMenuItems: vi.fn(), + sendPluginStatuses: vi.fn(), + sendPluginWebviewInjections: vi.fn(), + sendPluginToolbarActions: vi.fn(), + sendGraphViewContributionStatuses: vi.fn(), + sendActiveFile: vi.fn(), + waitForFirstWorkspaceReady: vi.fn(() => Promise.resolve()), + notifyWebviewReady: vi.fn(), + }; +} diff --git a/packages/extension/tests/shared/visibleGraph/scope.test.ts b/packages/extension/tests/shared/visibleGraph/scope.test.ts index 000fe7ec5..a5daff7bd 100644 --- a/packages/extension/tests/shared/visibleGraph/scope.test.ts +++ b/packages/extension/tests/shared/visibleGraph/scope.test.ts @@ -1,47 +1,10 @@ import { describe, expect, it } from 'vitest'; -import type { IGraphData, IGraphEdge, IGraphNode } from '../../../src/shared/graph/contracts'; import { applyGraphScope } from '../../../src/shared/visibleGraph/scope'; import { getDefinitionSymbolKinds, getScopedSymbolDefinitions, } from '../../../src/shared/visibleGraph/scope/definitions'; - -function node(id: string, nodeType = 'file'): IGraphNode { - return { - id, - label: id.split('/').pop() ?? id, - color: '#111111', - nodeType, - }; -} - -function symbolNode( - id: string, - symbol: NonNullable, - nodeType = 'symbol', -): IGraphNode { - return { - ...node(id, nodeType), - symbol, - }; -} - -function edge(from: string, to: string, kind: IGraphEdge['kind']): IGraphEdge { - return { - id: `${from}->${to}#${kind}`, - from, - to, - kind, - sources: [], - }; -} - -function ids(graphData: IGraphData): { nodes: string[]; edges: string[] } { - return { - nodes: graphData.nodes.map((item) => item.id), - edges: graphData.edges.map((item) => item.id), - }; -} +import { edge, ids, node, symbolNode } from './scope/fixture'; describe('shared/visibleGraph/scope', () => { it('filters disabled nodes, disabled edge kinds, and edges attached to hidden nodes', () => { @@ -374,105 +337,6 @@ describe('shared/visibleGraph/scope', () => { }); }); - it('keeps Unity file to GameObject containment when Component symbols are visible', () => { - const result = applyGraphScope( - { - nodes: [ - node('Assets/Prefabs/Enemy1.prefab'), - symbolNode('Assets/Prefabs/Enemy1.prefab#Enemy1:game-object', { - id: 'Assets/Prefabs/Enemy1.prefab#Enemy1:game-object', - name: 'Enemy1', - kind: 'game-object', - filePath: 'Assets/Prefabs/Enemy1.prefab', - pluginKind: 'game-object', - source: 'codegraphy.unity', - language: 'unity', - }), - symbolNode('Assets/Prefabs/Enemy1.prefab#EnemyMovement:component', { - id: 'Assets/Prefabs/Enemy1.prefab#EnemyMovement:component', - name: 'EnemyMovement', - kind: 'component', - filePath: 'Assets/Prefabs/Enemy1.prefab', - pluginKind: 'component', - source: 'codegraphy.unity', - language: 'unity', - }), - ], - edges: [ - edge('Assets/Prefabs/Enemy1.prefab', 'Assets/Prefabs/Enemy1.prefab#Enemy1:game-object', 'contains'), - edge('Assets/Prefabs/Enemy1.prefab#Enemy1:game-object', 'Assets/Prefabs/Enemy1.prefab#EnemyMovement:component', 'contains'), - ], - }, - { - nodes: [ - { type: 'file', enabled: true }, - { type: 'symbol', enabled: true }, - { type: 'plugin:codegraphy.unity:symbol', enabled: true }, - { type: 'plugin:codegraphy.unity:symbol:game-object', enabled: true }, - { type: 'plugin:codegraphy.unity:symbol:component', enabled: true }, - ], - edges: [{ type: 'contains', enabled: true }], - }, - ); - - expect(ids(result)).toEqual({ - nodes: [ - 'Assets/Prefabs/Enemy1.prefab', - 'Assets/Prefabs/Enemy1.prefab#Enemy1:game-object', - 'Assets/Prefabs/Enemy1.prefab#EnemyMovement:component', - ], - edges: [ - 'Assets/Prefabs/Enemy1.prefab->Assets/Prefabs/Enemy1.prefab#Enemy1:game-object#contains', - 'Assets/Prefabs/Enemy1.prefab#Enemy1:game-object->Assets/Prefabs/Enemy1.prefab#EnemyMovement:component#contains', - ], - }); - }); - - it('projects hidden symbol endpoints back to visible containing files', () => { - const result = applyGraphScope( - { - nodes: [ - node('scripts/spawning/enemy_spawner.gd'), - node('resources/enemy_spawn_config.tres'), - symbolNode('resources/enemy_spawn_config.tres#EnemySpawnConfig:resource', { - id: 'resources/enemy_spawn_config.tres#EnemySpawnConfig:resource', - name: 'EnemySpawnConfig', - kind: 'resource', - filePath: 'resources/enemy_spawn_config.tres', - pluginKind: 'resource', - source: 'codegraphy.gdscript', - }), - ], - edges: [{ - id: 'scripts/spawning/enemy_spawner.gd->resources/enemy_spawn_config.tres#EnemySpawnConfig:resource#load:static', - from: 'scripts/spawning/enemy_spawner.gd', - to: 'resources/enemy_spawn_config.tres#EnemySpawnConfig:resource', - kind: 'load', - sources: [], - }], - }, - { - nodes: [ - { type: 'file', enabled: true }, - { type: 'symbol', enabled: false }, - { type: 'plugin:codegraphy.gdscript:symbol:resource', enabled: false }, - ], - edges: [ - { type: 'load', enabled: true }, - ], - }, - ); - - expect(ids(result)).toEqual({ - nodes: [ - 'scripts/spawning/enemy_spawner.gd', - 'resources/enemy_spawn_config.tres', - ], - edges: [ - 'scripts/spawning/enemy_spawner.gd->resources/enemy_spawn_config.tres#load:static', - ], - }); - }); it('keeps file-level type imports when imported type symbols are visible', () => { const result = applyGraphScope( @@ -561,252 +425,5 @@ describe('shared/visibleGraph/scope', () => { }); }); - it('uses the most specific plugin symbol rule before a general symbol kind rule', () => { - const result = applyGraphScope( - { - nodes: [ - node('src/user.ts'), - symbolNode('src/user.ts#User:class', { - id: 'src/user.ts#User:class', - name: 'User', - kind: 'class', - filePath: 'src/user.ts', - }), - symbolNode('scripts/player.gd#Player:class', { - id: 'scripts/player.gd#Player:class', - name: 'Player', - kind: 'class', - filePath: 'scripts/player.gd', - pluginKind: 'godot-class-name', - source: 'codegraphy.gdscript', - language: 'gdscript', - }), - ], - edges: [ - edge('src/user.ts', 'src/user.ts#User:class', 'contains'), - edge('src/user.ts', 'scripts/player.gd#Player:class', 'reference'), - ], - }, - { - nodes: [ - { type: 'file', enabled: true }, - { type: 'symbol', enabled: true }, - { type: 'symbol:class', enabled: true }, - { type: 'plugin:codegraphy.gdscript:symbol:godot-class-name', enabled: false }, - ], - edges: [ - { type: 'contains', enabled: true }, - { type: 'reference', enabled: true }, - ], - }, - ); - - expect(ids(result)).toEqual({ - nodes: ['src/user.ts', 'src/user.ts#User:class'], - edges: ['src/user.ts->src/user.ts#User:class#contains'], - }); - }); - - it('matches Godot scene-node and exported-property scoped rows', () => { - const result = applyGraphScope( - { - nodes: [ - node('scenes/player.tscn'), - node('scripts/player.gd'), - symbolNode('scenes/player.tscn#HealthComponent:scene-node', { - id: 'scenes/player.tscn#HealthComponent:scene-node', - name: 'HealthComponent', - kind: 'scene-node', - filePath: 'scenes/player.tscn', - pluginKind: 'scene-node', - source: 'codegraphy.gdscript', - language: 'godot-resource', - }), - symbolNode('scripts/player.gd#projectile_scene:variable', { - id: 'scripts/player.gd#projectile_scene:variable', - name: 'projectile_scene', - kind: 'variable', - filePath: 'scripts/player.gd', - pluginKind: 'exported-property', - source: 'codegraphy.gdscript', - language: 'gdscript', - }, 'variable'), - symbolNode('scripts/player.gd#_can_fire:variable', { - id: 'scripts/player.gd#_can_fire:variable', - name: '_can_fire', - kind: 'variable', - filePath: 'scripts/player.gd', - source: 'codegraphy.gdscript', - language: 'gdscript', - }, 'variable'), - ], - edges: [ - edge('scenes/player.tscn', 'scenes/player.tscn#HealthComponent:scene-node', 'contains'), - edge('scripts/player.gd', 'scripts/player.gd#projectile_scene:variable', 'contains'), - edge('scripts/player.gd', 'scripts/player.gd#_can_fire:variable', 'contains'), - ], - }, - { - nodes: [ - { type: 'file', enabled: true }, - { type: 'symbol', enabled: true }, - { type: 'variable', enabled: true }, - { type: 'plugin:codegraphy.gdscript:symbol:scene-node', enabled: true }, - { type: 'plugin:codegraphy.gdscript:symbol:exported-property', enabled: true }, - ], - edges: [ - { type: 'contains', enabled: true }, - ], - }, - ); - - expect(ids(result)).toEqual({ - nodes: [ - 'scenes/player.tscn', - 'scripts/player.gd', - 'scenes/player.tscn#HealthComponent:scene-node', - 'scripts/player.gd#projectile_scene:variable', - ], - edges: [ - 'scenes/player.tscn->scenes/player.tscn#HealthComponent:scene-node#contains', - 'scripts/player.gd->scripts/player.gd#projectile_scene:variable#contains', - ], - }); - }); - - it('requires every plugin-specific symbol field to match', () => { - const matching = symbolNode('scripts/player.gd#Player:class', { - id: 'scripts/player.gd#Player:class', - name: 'Player', - kind: 'class', - filePath: 'scripts/player.gd', - pluginKind: 'godot-class-name', - source: 'codegraphy.gdscript', - language: 'gdscript', - }); - const wrongPluginKind = symbolNode('scripts/enemy.gd#Enemy:class', { - id: 'scripts/enemy.gd#Enemy:class', - name: 'Enemy', - kind: 'class', - filePath: 'scripts/enemy.gd', - pluginKind: 'ordinary-class', - source: 'codegraphy.gdscript', - language: 'gdscript', - }); - const wrongSource = symbolNode('scripts/npc.gd#Npc:class', { - id: 'scripts/npc.gd#Npc:class', - name: 'Npc', - kind: 'class', - filePath: 'scripts/npc.gd', - pluginKind: 'godot-class-name', - source: 'other-plugin', - language: 'gdscript', - }); - const wrongLanguage = symbolNode('scripts/item.gd#Item:class', { - id: 'scripts/item.gd#Item:class', - name: 'Item', - kind: 'class', - filePath: 'scripts/item.gd', - pluginKind: 'godot-class-name', - source: 'codegraphy.gdscript', - language: 'typescript', - }); - const wrongFilePath = symbolNode('src/player.ts#Player:class', { - id: 'src/player.ts#Player:class', - name: 'Player', - kind: 'class', - filePath: 'src/player.ts', - pluginKind: 'godot-class-name', - source: 'codegraphy.gdscript', - language: 'gdscript', - }); - - const result = applyGraphScope( - { - nodes: [ - matching, - wrongPluginKind, - wrongSource, - wrongLanguage, - wrongFilePath, - ], - edges: [], - }, - { - nodes: [ - { type: 'symbol', enabled: true }, - { type: 'symbol:class', enabled: false }, - { type: 'plugin:codegraphy.gdscript:symbol:godot-class-name', enabled: true }, - ], - edges: [], - }, - ); - - expect(ids(result)).toEqual({ - nodes: ['scripts/player.gd#Player:class'], - edges: [], - }); - }); - - - it('filters Unity component symbols through plugin scope rows', () => { - const result = applyGraphScope( - { - nodes: [ - node('Assets/Scenes/SampleScene.unity'), - symbolNode('Assets/Scenes/SampleScene.unity#unity:game-object:1000', { - id: 'Assets/Scenes/SampleScene.unity#unity:game-object:1000', - name: 'Player', - kind: 'game-object', - filePath: 'Assets/Scenes/SampleScene.unity', - pluginKind: 'game-object', - source: 'codegraphy.unity', - language: 'unity', - }), - symbolNode('Assets/Scenes/SampleScene.unity#unity:component:1001', { - id: 'Assets/Scenes/SampleScene.unity#unity:component:1001', - name: 'Transform', - kind: 'component', - filePath: 'Assets/Scenes/SampleScene.unity', - pluginKind: 'component', - source: 'codegraphy.unity', - language: 'unity', - }), - ], - edges: [ - edge( - 'Assets/Scenes/SampleScene.unity', - 'Assets/Scenes/SampleScene.unity#unity:game-object:1000', - 'contains', - ), - edge( - 'Assets/Scenes/SampleScene.unity#unity:game-object:1000', - 'Assets/Scenes/SampleScene.unity#unity:component:1001', - 'contains', - ), - ], - }, - { - nodes: [ - { type: 'file', enabled: true }, - { type: 'symbol', enabled: true }, - { type: 'plugin:codegraphy.unity:symbol', enabled: true }, - { type: 'plugin:codegraphy.unity:symbol:game-object', enabled: true }, - { type: 'plugin:codegraphy.unity:symbol:component', enabled: false }, - ], - edges: [{ type: 'contains', enabled: true }], - }, - ); - - expect(ids(result)).toEqual({ - nodes: [ - 'Assets/Scenes/SampleScene.unity', - 'Assets/Scenes/SampleScene.unity#unity:game-object:1000', - ], - edges: [ - 'Assets/Scenes/SampleScene.unity->Assets/Scenes/SampleScene.unity#unity:game-object:1000#contains', - ], - }); - }); }); diff --git a/packages/extension/tests/shared/visibleGraph/scope/fixture.ts b/packages/extension/tests/shared/visibleGraph/scope/fixture.ts new file mode 100644 index 000000000..8317474da --- /dev/null +++ b/packages/extension/tests/shared/visibleGraph/scope/fixture.ts @@ -0,0 +1,38 @@ +import type { IGraphData, IGraphEdge, IGraphNode } from '../../../../src/shared/graph/contracts'; + +export function node(id: string, nodeType = 'file'): IGraphNode { + return { + id, + label: id.split('/').pop() ?? id, + color: '#111111', + nodeType, + }; +} + +export function symbolNode( + id: string, + symbol: NonNullable, + nodeType = 'symbol', +): IGraphNode { + return { + ...node(id, nodeType), + symbol, + }; +} + +export function edge(from: string, to: string, kind: IGraphEdge['kind']): IGraphEdge { + return { + id: `${from}->${to}#${kind}`, + from, + to, + kind, + sources: [], + }; +} + +export function ids(graphData: IGraphData): { nodes: string[]; edges: string[] } { + return { + nodes: graphData.nodes.map((item) => item.id), + edges: graphData.edges.map((item) => item.id), + }; +} diff --git a/packages/extension/tests/shared/visibleGraph/scope/pluginSymbols.test.ts b/packages/extension/tests/shared/visibleGraph/scope/pluginSymbols.test.ts new file mode 100644 index 000000000..518bbdb1c --- /dev/null +++ b/packages/extension/tests/shared/visibleGraph/scope/pluginSymbols.test.ts @@ -0,0 +1,352 @@ +import { describe, expect, it } from 'vitest'; +import { applyGraphScope } from '../../../../src/shared/visibleGraph/scope'; +import { edge, ids, node, symbolNode } from './fixture'; + +describe('shared/visibleGraph/scope plugin symbols', () => { +it('keeps Unity file to GameObject containment when Component symbols are visible', () => { + const result = applyGraphScope( + { + nodes: [ + node('Assets/Prefabs/Enemy1.prefab'), + symbolNode('Assets/Prefabs/Enemy1.prefab#Enemy1:game-object', { + id: 'Assets/Prefabs/Enemy1.prefab#Enemy1:game-object', + name: 'Enemy1', + kind: 'game-object', + filePath: 'Assets/Prefabs/Enemy1.prefab', + pluginKind: 'game-object', + source: 'codegraphy.unity', + language: 'unity', + }), + symbolNode('Assets/Prefabs/Enemy1.prefab#EnemyMovement:component', { + id: 'Assets/Prefabs/Enemy1.prefab#EnemyMovement:component', + name: 'EnemyMovement', + kind: 'component', + filePath: 'Assets/Prefabs/Enemy1.prefab', + pluginKind: 'component', + source: 'codegraphy.unity', + language: 'unity', + }), + ], + edges: [ + edge('Assets/Prefabs/Enemy1.prefab', 'Assets/Prefabs/Enemy1.prefab#Enemy1:game-object', 'contains'), + edge('Assets/Prefabs/Enemy1.prefab#Enemy1:game-object', 'Assets/Prefabs/Enemy1.prefab#EnemyMovement:component', 'contains'), + ], + }, + { + nodes: [ + { type: 'file', enabled: true }, + { type: 'symbol', enabled: true }, + { type: 'plugin:codegraphy.unity:symbol', enabled: true }, + { type: 'plugin:codegraphy.unity:symbol:game-object', enabled: true }, + { type: 'plugin:codegraphy.unity:symbol:component', enabled: true }, + ], + edges: [{ type: 'contains', enabled: true }], + }, + ); + + expect(ids(result)).toEqual({ + nodes: [ + 'Assets/Prefabs/Enemy1.prefab', + 'Assets/Prefabs/Enemy1.prefab#Enemy1:game-object', + 'Assets/Prefabs/Enemy1.prefab#EnemyMovement:component', + ], + edges: [ + 'Assets/Prefabs/Enemy1.prefab->Assets/Prefabs/Enemy1.prefab#Enemy1:game-object#contains', + 'Assets/Prefabs/Enemy1.prefab#Enemy1:game-object->Assets/Prefabs/Enemy1.prefab#EnemyMovement:component#contains', + ], + }); + }); + + it('projects hidden symbol endpoints back to visible containing files', () => { + const result = applyGraphScope( + { + nodes: [ + node('scripts/spawning/enemy_spawner.gd'), + node('resources/enemy_spawn_config.tres'), + symbolNode('resources/enemy_spawn_config.tres#EnemySpawnConfig:resource', { + id: 'resources/enemy_spawn_config.tres#EnemySpawnConfig:resource', + name: 'EnemySpawnConfig', + kind: 'resource', + filePath: 'resources/enemy_spawn_config.tres', + pluginKind: 'resource', + source: 'codegraphy.gdscript', + }), + ], + edges: [{ + id: 'scripts/spawning/enemy_spawner.gd->resources/enemy_spawn_config.tres#EnemySpawnConfig:resource#load:static', + from: 'scripts/spawning/enemy_spawner.gd', + to: 'resources/enemy_spawn_config.tres#EnemySpawnConfig:resource', + kind: 'load', + sources: [], + }], + }, + { + nodes: [ + { type: 'file', enabled: true }, + { type: 'symbol', enabled: false }, + { type: 'plugin:codegraphy.gdscript:symbol:resource', enabled: false }, + ], + edges: [ + { type: 'load', enabled: true }, + ], + }, + ); + + expect(ids(result)).toEqual({ + nodes: [ + 'scripts/spawning/enemy_spawner.gd', + 'resources/enemy_spawn_config.tres', + ], + edges: [ + 'scripts/spawning/enemy_spawner.gd->resources/enemy_spawn_config.tres#load:static', + ], + }); + }); + it('uses the most specific plugin symbol rule before a general symbol kind rule', () => { + const result = applyGraphScope( + { + nodes: [ + node('src/user.ts'), + symbolNode('src/user.ts#User:class', { + id: 'src/user.ts#User:class', + name: 'User', + kind: 'class', + filePath: 'src/user.ts', + }), + symbolNode('scripts/player.gd#Player:class', { + id: 'scripts/player.gd#Player:class', + name: 'Player', + kind: 'class', + filePath: 'scripts/player.gd', + pluginKind: 'godot-class-name', + source: 'codegraphy.gdscript', + language: 'gdscript', + }), + ], + edges: [ + edge('src/user.ts', 'src/user.ts#User:class', 'contains'), + edge('src/user.ts', 'scripts/player.gd#Player:class', 'reference'), + ], + }, + { + nodes: [ + { type: 'file', enabled: true }, + { type: 'symbol', enabled: true }, + { type: 'symbol:class', enabled: true }, + { type: 'plugin:codegraphy.gdscript:symbol:godot-class-name', enabled: false }, + ], + edges: [ + { type: 'contains', enabled: true }, + { type: 'reference', enabled: true }, + ], + }, + ); + + expect(ids(result)).toEqual({ + nodes: ['src/user.ts', 'src/user.ts#User:class'], + edges: ['src/user.ts->src/user.ts#User:class#contains'], + }); + }); + + it('matches Godot scene-node and exported-property scoped rows', () => { + const result = applyGraphScope( + { + nodes: [ + node('scenes/player.tscn'), + node('scripts/player.gd'), + symbolNode('scenes/player.tscn#HealthComponent:scene-node', { + id: 'scenes/player.tscn#HealthComponent:scene-node', + name: 'HealthComponent', + kind: 'scene-node', + filePath: 'scenes/player.tscn', + pluginKind: 'scene-node', + source: 'codegraphy.gdscript', + language: 'godot-resource', + }), + symbolNode('scripts/player.gd#projectile_scene:variable', { + id: 'scripts/player.gd#projectile_scene:variable', + name: 'projectile_scene', + kind: 'variable', + filePath: 'scripts/player.gd', + pluginKind: 'exported-property', + source: 'codegraphy.gdscript', + language: 'gdscript', + }, 'variable'), + symbolNode('scripts/player.gd#_can_fire:variable', { + id: 'scripts/player.gd#_can_fire:variable', + name: '_can_fire', + kind: 'variable', + filePath: 'scripts/player.gd', + source: 'codegraphy.gdscript', + language: 'gdscript', + }, 'variable'), + ], + edges: [ + edge('scenes/player.tscn', 'scenes/player.tscn#HealthComponent:scene-node', 'contains'), + edge('scripts/player.gd', 'scripts/player.gd#projectile_scene:variable', 'contains'), + edge('scripts/player.gd', 'scripts/player.gd#_can_fire:variable', 'contains'), + ], + }, + { + nodes: [ + { type: 'file', enabled: true }, + { type: 'symbol', enabled: true }, + { type: 'variable', enabled: true }, + { type: 'plugin:codegraphy.gdscript:symbol:scene-node', enabled: true }, + { type: 'plugin:codegraphy.gdscript:symbol:exported-property', enabled: true }, + ], + edges: [ + { type: 'contains', enabled: true }, + ], + }, + ); + + expect(ids(result)).toEqual({ + nodes: [ + 'scenes/player.tscn', + 'scripts/player.gd', + 'scenes/player.tscn#HealthComponent:scene-node', + 'scripts/player.gd#projectile_scene:variable', + ], + edges: [ + 'scenes/player.tscn->scenes/player.tscn#HealthComponent:scene-node#contains', + 'scripts/player.gd->scripts/player.gd#projectile_scene:variable#contains', + ], + }); + }); + + it('requires every plugin-specific symbol field to match', () => { + const matching = symbolNode('scripts/player.gd#Player:class', { + id: 'scripts/player.gd#Player:class', + name: 'Player', + kind: 'class', + filePath: 'scripts/player.gd', + pluginKind: 'godot-class-name', + source: 'codegraphy.gdscript', + language: 'gdscript', + }); + const wrongPluginKind = symbolNode('scripts/enemy.gd#Enemy:class', { + id: 'scripts/enemy.gd#Enemy:class', + name: 'Enemy', + kind: 'class', + filePath: 'scripts/enemy.gd', + pluginKind: 'ordinary-class', + source: 'codegraphy.gdscript', + language: 'gdscript', + }); + const wrongSource = symbolNode('scripts/npc.gd#Npc:class', { + id: 'scripts/npc.gd#Npc:class', + name: 'Npc', + kind: 'class', + filePath: 'scripts/npc.gd', + pluginKind: 'godot-class-name', + source: 'other-plugin', + language: 'gdscript', + }); + const wrongLanguage = symbolNode('scripts/item.gd#Item:class', { + id: 'scripts/item.gd#Item:class', + name: 'Item', + kind: 'class', + filePath: 'scripts/item.gd', + pluginKind: 'godot-class-name', + source: 'codegraphy.gdscript', + language: 'typescript', + }); + const wrongFilePath = symbolNode('src/player.ts#Player:class', { + id: 'src/player.ts#Player:class', + name: 'Player', + kind: 'class', + filePath: 'src/player.ts', + pluginKind: 'godot-class-name', + source: 'codegraphy.gdscript', + language: 'gdscript', + }); + + const result = applyGraphScope( + { + nodes: [ + matching, + wrongPluginKind, + wrongSource, + wrongLanguage, + wrongFilePath, + ], + edges: [], + }, + { + nodes: [ + { type: 'symbol', enabled: true }, + { type: 'symbol:class', enabled: false }, + { type: 'plugin:codegraphy.gdscript:symbol:godot-class-name', enabled: true }, + ], + edges: [], + }, + ); + + expect(ids(result)).toEqual({ + nodes: ['scripts/player.gd#Player:class'], + edges: [], + }); + }); + + + it('filters Unity component symbols through plugin scope rows', () => { + const result = applyGraphScope( + { + nodes: [ + node('Assets/Scenes/SampleScene.unity'), + symbolNode('Assets/Scenes/SampleScene.unity#unity:game-object:1000', { + id: 'Assets/Scenes/SampleScene.unity#unity:game-object:1000', + name: 'Player', + kind: 'game-object', + filePath: 'Assets/Scenes/SampleScene.unity', + pluginKind: 'game-object', + source: 'codegraphy.unity', + language: 'unity', + }), + symbolNode('Assets/Scenes/SampleScene.unity#unity:component:1001', { + id: 'Assets/Scenes/SampleScene.unity#unity:component:1001', + name: 'Transform', + kind: 'component', + filePath: 'Assets/Scenes/SampleScene.unity', + pluginKind: 'component', + source: 'codegraphy.unity', + language: 'unity', + }), + ], + edges: [ + edge( + 'Assets/Scenes/SampleScene.unity', + 'Assets/Scenes/SampleScene.unity#unity:game-object:1000', + 'contains', + ), + edge( + 'Assets/Scenes/SampleScene.unity#unity:game-object:1000', + 'Assets/Scenes/SampleScene.unity#unity:component:1001', + 'contains', + ), + ], + }, + { + nodes: [ + { type: 'file', enabled: true }, + { type: 'symbol', enabled: true }, + { type: 'plugin:codegraphy.unity:symbol', enabled: true }, + { type: 'plugin:codegraphy.unity:symbol:game-object', enabled: true }, + { type: 'plugin:codegraphy.unity:symbol:component', enabled: false }, + ], + edges: [{ type: 'contains', enabled: true }], + }, + ); + + expect(ids(result)).toEqual({ + nodes: [ + 'Assets/Scenes/SampleScene.unity', + 'Assets/Scenes/SampleScene.unity#unity:game-object:1000', + ], + edges: [ + 'Assets/Scenes/SampleScene.unity->Assets/Scenes/SampleScene.unity#unity:game-object:1000#contains', + ], + }); + }); +}); diff --git a/packages/extension/tests/webview/app/shell/view.messages.test.tsx b/packages/extension/tests/webview/app/shell/view.messages.test.tsx new file mode 100644 index 000000000..c98cda675 --- /dev/null +++ b/packages/extension/tests/webview/app/shell/view.messages.test.tsx @@ -0,0 +1,112 @@ +import { act, render } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import App from '../../../../src/webview/app/view'; +import { graphStore } from '../../../../src/webview/store/state'; +import { messageListeners, resetStore, sendMessage } from './view/fixture'; + +describe('App: message handlers', () => { + beforeEach(() => { + messageListeners.length = 0; + delete (window as Window & { __codegraphyWebviewReadyPosted?: boolean }) + .__codegraphyWebviewReadyPosted; + resetStore(); + vi.useRealTimers(); + }); + afterEach(() => vi.useRealTimers()); + + it('SETTINGS_UPDATED updates settings state', async () => { + render(); + await act(async () => { + sendMessage({ + type: 'SETTINGS_UPDATED', + payload: { + bidirectionalEdges: 'combined', + showOrphans: false, + }, + }); + }); + expect(graphStore.getState().bidirectionalMode).toBe('combined'); + expect(graphStore.getState().showOrphans).toBe(false); + }); + + it('DIRECTION_SETTINGS_UPDATED updates direction mode state', async () => { + render(); + await act(async () => { + sendMessage({ type: 'DIRECTION_SETTINGS_UPDATED', payload: { directionMode: 'particles', directionColor: '#00FF00', particleSpeed: 0.01, particleSize: 6 } }); + }); + expect(graphStore.getState().directionMode).toBe('particles'); + expect(graphStore.getState().directionColor).toBe('#00FF00'); + expect(graphStore.getState().particleSpeed).toBe(0.01); + expect(graphStore.getState().particleSize).toBe(6); + }); + + it('FAVORITES_UPDATED message is handled without error', async () => { + render(); + await act(async () => { + sendMessage({ type: 'FAVORITES_UPDATED', payload: { favorites: ['src/index.ts'] } }); + }); + expect(graphStore.getState().favorites).toEqual(new Set(['src/index.ts'])); + }); + + it('FILTER_PATTERNS_UPDATED message is handled', async () => { + render(); + await act(async () => { + sendMessage({ + type: 'FILTER_PATTERNS_UPDATED', + payload: { + patterns: ['**/*.test.ts'], + pluginPatterns: [], + pluginPatternGroups: [], + disabledCustomPatterns: [], + disabledPluginPatterns: [], + }, + }); + }); + expect(graphStore.getState().filterPatterns).toEqual(['**/*.test.ts']); + }); + + it('LEGENDS_UPDATED message is handled', async () => { + render(); + await act(async () => { + sendMessage({ + type: 'LEGENDS_UPDATED', + payload: { legends: [{ id: 'g1', pattern: 'src/**', color: '#ff0000' }] }, + }); + }); + expect(graphStore.getState().legends).toEqual([{ id: 'g1', pattern: 'src/**', color: '#ff0000' }]); + }); + + it('PHYSICS_SETTINGS_UPDATED message is handled', async () => { + render(); + const physics = { + repelForce: 4, + centerForce: 0.02, + linkDistance: 150, + linkForce: 0.05, + damping: 0.5, + }; + await act(async () => { + sendMessage({ + type: 'PHYSICS_SETTINGS_UPDATED', + payload: physics, + }); + }); + expect(graphStore.getState().physicsSettings).toEqual(physics); + }); + + it('DEPTH_LIMIT_UPDATED message is handled', async () => { + render(); + await act(async () => { + sendMessage({ type: 'DEPTH_LIMIT_UPDATED', payload: { depthLimit: 3 } }); + }); + expect(graphStore.getState().depthLimit).toBe(3); + }); + + it('DEPTH_LIMIT_RANGE_UPDATED message is handled', async () => { + render(); + await act(async () => { + sendMessage({ type: 'DEPTH_LIMIT_RANGE_UPDATED', payload: { maxDepthLimit: 2 } }); + }); + expect(graphStore.getState().maxDepthLimit).toBe(2); + }); +}); diff --git a/packages/extension/tests/webview/app/shell/view.test.tsx b/packages/extension/tests/webview/app/shell/view.test.tsx index 8322957ba..677e88021 100644 --- a/packages/extension/tests/webview/app/shell/view.test.tsx +++ b/packages/extension/tests/webview/app/shell/view.test.tsx @@ -2,66 +2,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, act, fireEvent, waitFor } from '@testing-library/react'; import App from '../../../../src/webview/app/view'; import { graphStore } from '../../../../src/webview/store/state'; -import { DEFAULT_DIRECTION_COLOR } from '../../../../src/shared/fileColors'; import { STRUCTURAL_NESTS_EDGE_KIND } from '../../../../src/shared/graphControls/defaults/definitions'; - -// Mock window message listeners -const messageListeners: ((event: MessageEvent) => void)[] = []; - -vi.stubGlobal('addEventListener', (type: string, listener: (event: MessageEvent) => void) => { - if (type === 'message') { - messageListeners.push(listener); - } -}); - -vi.stubGlobal('removeEventListener', (type: string, listener: (event: MessageEvent) => void) => { - if (type === 'message') { - const index = messageListeners.indexOf(listener); - if (index > -1) messageListeners.splice(index, 1); - } -}); - -/** Reset store to initial state between tests */ -function resetStore() { - graphStore.setState({ - graphData: null, - isLoading: true, - searchQuery: '', - searchOptions: { matchCase: false, wholeWord: false, regex: false }, - favorites: new Set(), - bidirectionalMode: 'separate', - showOrphans: true, - directionMode: 'arrows', - directionColor: DEFAULT_DIRECTION_COLOR, - particleSpeed: 0.005, - particleSize: 4, - showLabels: true, - graphMode: '2d', - nodeSizeMode: 'connections', - physicsSettings: { repelForce: 10, linkDistance: 80, linkForce: 0.15, damping: 0.7, centerForce: 0.1 }, - graphHasIndex: false, - graphIsIndexing: false, - graphIndexProgress: null, - awaitingInitialBootstrap: false, - bootstrapComplete: false, - pendingPluginAssetLoads: 0, - depthMode: false, - depthLimit: 1, - maxDepthLimit: 10, - legends: [], - filterPatterns: [], - pluginFilterPatterns: [], - pluginFilterGroups: [], - pluginStatuses: [], - graphNodeTypes: [], - graphEdgeTypes: [], - nodeColors: {}, - nodeVisibility: {}, - edgeVisibility: {}, - activePanel: 'none', - maxFiles: 500, - }); -} +import { messageListeners, resetStore, sendMessage } from './view/fixture'; describe('App', () => { beforeEach(() => { @@ -707,117 +649,3 @@ describe('App', () => { expect(screen.queryByTitle('Open in Editor')).not.toBeInTheDocument(); }); }); - -// ── Message Handler Coverage ──────────────────────────────────────────────── - -function sendMessage(data: unknown) { - const event = new MessageEvent('message', { data }); - messageListeners.forEach((listener) => listener(event)); -} - -describe('App: message handlers', () => { - beforeEach(() => { - messageListeners.length = 0; - delete (window as Window & { __codegraphyWebviewReadyPosted?: boolean }) - .__codegraphyWebviewReadyPosted; - resetStore(); - vi.useRealTimers(); - }); - afterEach(() => vi.useRealTimers()); - - it('SETTINGS_UPDATED updates settings state', async () => { - render(); - await act(async () => { - sendMessage({ - type: 'SETTINGS_UPDATED', - payload: { - bidirectionalEdges: 'combined', - showOrphans: false, - }, - }); - }); - expect(graphStore.getState().bidirectionalMode).toBe('combined'); - expect(graphStore.getState().showOrphans).toBe(false); - }); - - it('DIRECTION_SETTINGS_UPDATED updates direction mode state', async () => { - render(); - await act(async () => { - sendMessage({ type: 'DIRECTION_SETTINGS_UPDATED', payload: { directionMode: 'particles', directionColor: '#00FF00', particleSpeed: 0.01, particleSize: 6 } }); - }); - expect(graphStore.getState().directionMode).toBe('particles'); - expect(graphStore.getState().directionColor).toBe('#00FF00'); - expect(graphStore.getState().particleSpeed).toBe(0.01); - expect(graphStore.getState().particleSize).toBe(6); - }); - - it('FAVORITES_UPDATED message is handled without error', async () => { - render(); - await act(async () => { - sendMessage({ type: 'FAVORITES_UPDATED', payload: { favorites: ['src/index.ts'] } }); - }); - expect(graphStore.getState().favorites).toEqual(new Set(['src/index.ts'])); - }); - - it('FILTER_PATTERNS_UPDATED message is handled', async () => { - render(); - await act(async () => { - sendMessage({ - type: 'FILTER_PATTERNS_UPDATED', - payload: { - patterns: ['**/*.test.ts'], - pluginPatterns: [], - pluginPatternGroups: [], - disabledCustomPatterns: [], - disabledPluginPatterns: [], - }, - }); - }); - expect(graphStore.getState().filterPatterns).toEqual(['**/*.test.ts']); - }); - - it('LEGENDS_UPDATED message is handled', async () => { - render(); - await act(async () => { - sendMessage({ - type: 'LEGENDS_UPDATED', - payload: { legends: [{ id: 'g1', pattern: 'src/**', color: '#ff0000' }] }, - }); - }); - expect(graphStore.getState().legends).toEqual([{ id: 'g1', pattern: 'src/**', color: '#ff0000' }]); - }); - - it('PHYSICS_SETTINGS_UPDATED message is handled', async () => { - render(); - const physics = { - repelForce: 4, - centerForce: 0.02, - linkDistance: 150, - linkForce: 0.05, - damping: 0.5, - }; - await act(async () => { - sendMessage({ - type: 'PHYSICS_SETTINGS_UPDATED', - payload: physics, - }); - }); - expect(graphStore.getState().physicsSettings).toEqual(physics); - }); - - it('DEPTH_LIMIT_UPDATED message is handled', async () => { - render(); - await act(async () => { - sendMessage({ type: 'DEPTH_LIMIT_UPDATED', payload: { depthLimit: 3 } }); - }); - expect(graphStore.getState().depthLimit).toBe(3); - }); - - it('DEPTH_LIMIT_RANGE_UPDATED message is handled', async () => { - render(); - await act(async () => { - sendMessage({ type: 'DEPTH_LIMIT_RANGE_UPDATED', payload: { maxDepthLimit: 2 } }); - }); - expect(graphStore.getState().maxDepthLimit).toBe(2); - }); -}); diff --git a/packages/extension/tests/webview/app/shell/view/fixture.ts b/packages/extension/tests/webview/app/shell/view/fixture.ts new file mode 100644 index 000000000..b6def8244 --- /dev/null +++ b/packages/extension/tests/webview/app/shell/view/fixture.ts @@ -0,0 +1,65 @@ +import { vi } from 'vitest'; + +import { DEFAULT_DIRECTION_COLOR } from '../../../../../src/shared/fileColors'; +import { graphStore } from '../../../../../src/webview/store/state'; + +export const messageListeners: Array<(event: MessageEvent) => void> = []; + +vi.stubGlobal('addEventListener', (type: string, listener: (event: MessageEvent) => void) => { + if (type === 'message') { + messageListeners.push(listener); + } +}); + +vi.stubGlobal('removeEventListener', (type: string, listener: (event: MessageEvent) => void) => { + if (type === 'message') { + const index = messageListeners.indexOf(listener); + if (index > -1) messageListeners.splice(index, 1); + } +}); + +export function resetStore() { + graphStore.setState({ + graphData: null, + isLoading: true, + searchQuery: '', + searchOptions: { matchCase: false, wholeWord: false, regex: false }, + favorites: new Set(), + bidirectionalMode: 'separate', + showOrphans: true, + directionMode: 'arrows', + directionColor: DEFAULT_DIRECTION_COLOR, + particleSpeed: 0.005, + particleSize: 4, + showLabels: true, + graphMode: '2d', + nodeSizeMode: 'connections', + physicsSettings: { repelForce: 10, linkDistance: 80, linkForce: 0.15, damping: 0.7, centerForce: 0.1 }, + graphHasIndex: false, + graphIsIndexing: false, + graphIndexProgress: null, + awaitingInitialBootstrap: false, + bootstrapComplete: false, + pendingPluginAssetLoads: 0, + depthMode: false, + depthLimit: 1, + maxDepthLimit: 10, + legends: [], + filterPatterns: [], + pluginFilterPatterns: [], + pluginFilterGroups: [], + pluginStatuses: [], + graphNodeTypes: [], + graphEdgeTypes: [], + nodeColors: {}, + nodeVisibility: {}, + edgeVisibility: {}, + activePanel: 'none', + maxFiles: 500, + }); +} + +export function sendMessage(data: unknown) { + const event = new MessageEvent('message', { data }); + messageListeners.forEach((listener) => listener(event)); +} diff --git a/packages/extension/tests/webview/store/messageHandlers/graph.test.ts b/packages/extension/tests/webview/store/messageHandlers/graph.test.ts index 420605e08..b33d9c360 100644 --- a/packages/extension/tests/webview/store/messageHandlers/graph.test.ts +++ b/packages/extension/tests/webview/store/messageHandlers/graph.test.ts @@ -20,78 +20,8 @@ import { handleShowLabelsUpdated, handleVerboseDiagnosticsUpdated, } from '../../../../src/webview/store/messageHandlers/graph'; -import type { IStoreFields } from '../../../../src/webview/store/messageTypes'; import type { IGraphControlsSnapshot } from '../../../../src/shared/graphControls/contracts'; - -function createState( - overrides: Partial = {}, -): IStoreFields { - return { - graphData: null, - graphHasIndex: false, - graphIndexFreshness: 'missing', - graphIndexDetail: null, - graphIsIndexing: false, - graphIndexProgress: null, - isLoading: true, - awaitingInitialBootstrap: false, - bootstrapComplete: false, - pendingPluginAssetLoads: 0, - searchQuery: '', - searchOptions: { matchCase: false, wholeWord: false, regex: false }, - favorites: new Set(), - pendingFavoriteSnapshot: null, - bidirectionalMode: 'separate', - showOrphans: true, - directionMode: 'none', - directionColor: '#ffffff', - particleSpeed: 0, - particleSize: 1, - physicsPaused: false, - showLabels: true, - cssSnippets: {}, - graphMode: '2d', - graphViewportScale: null, - nodeSizeMode: 'uniform', - physicsSettings: { repelForce: 10, linkDistance: 80, linkForce: 0.15, damping: 0.7, centerForce: 0.1 }, - depthMode: false, - depthLimit: 2, - maxDepthLimit: 10, - legends: [], - optimisticLegendUpdates: {}, - optimisticUserLegends: null, - filterPatterns: [], - pluginFilterPatterns: [], - pluginFilterGroups: [], - disabledCustomFilterPatterns: [], - disabledPluginFilterPatterns: [], - dagMode: null, - pluginStatuses: [], - graphNodeTypes: [], - graphEdgeTypes: [], - nodeColors: {}, - nodeVisibility: {}, - edgeVisibility: {}, - nodeDecorations: {}, - edgeDecorations: {}, - pluginContextMenuItems: [], - pluginExporters: [], - pluginToolbarActions: [], - graphViewContributionStatuses: [], - activePanel: 'none', - maxFiles: 500, - verboseDiagnostics: false, - activeFilePath: null, - timelineActive: false, - timelineCommits: [], - currentCommitSha: null, - isIndexing: false, - indexProgress: null, - isPlaying: false, - playbackSpeed: 1, - ...overrides, - }; -} +import { createState } from './graph/fixture'; describe('webview/store/messageHandlers/graph', () => { it('maps graph payload updates into loading and indexing state', () => { diff --git a/packages/extension/tests/webview/store/messageHandlers/graph/fixture.ts b/packages/extension/tests/webview/store/messageHandlers/graph/fixture.ts new file mode 100644 index 000000000..ae472a1e6 --- /dev/null +++ b/packages/extension/tests/webview/store/messageHandlers/graph/fixture.ts @@ -0,0 +1,71 @@ +import type { IStoreFields } from '../../../../../src/webview/store/messageTypes'; + +export function createState( + overrides: Partial = {}, +): IStoreFields { + return { + graphData: null, + graphHasIndex: false, + graphIndexFreshness: 'missing', + graphIndexDetail: null, + graphIsIndexing: false, + graphIndexProgress: null, + isLoading: true, + awaitingInitialBootstrap: false, + bootstrapComplete: false, + pendingPluginAssetLoads: 0, + searchQuery: '', + searchOptions: { matchCase: false, wholeWord: false, regex: false }, + favorites: new Set(), + pendingFavoriteSnapshot: null, + bidirectionalMode: 'separate', + showOrphans: true, + directionMode: 'none', + directionColor: '#ffffff', + particleSpeed: 0, + particleSize: 1, + physicsPaused: false, + showLabels: true, + cssSnippets: {}, + graphMode: '2d', + graphViewportScale: null, + nodeSizeMode: 'uniform', + physicsSettings: { repelForce: 10, linkDistance: 80, linkForce: 0.15, damping: 0.7, centerForce: 0.1 }, + depthMode: false, + depthLimit: 2, + maxDepthLimit: 10, + legends: [], + optimisticLegendUpdates: {}, + optimisticUserLegends: null, + filterPatterns: [], + pluginFilterPatterns: [], + pluginFilterGroups: [], + disabledCustomFilterPatterns: [], + disabledPluginFilterPatterns: [], + dagMode: null, + pluginStatuses: [], + graphNodeTypes: [], + graphEdgeTypes: [], + nodeColors: {}, + nodeVisibility: {}, + edgeVisibility: {}, + nodeDecorations: {}, + edgeDecorations: {}, + pluginContextMenuItems: [], + pluginExporters: [], + pluginToolbarActions: [], + graphViewContributionStatuses: [], + activePanel: 'none', + maxFiles: 500, + verboseDiagnostics: false, + activeFilePath: null, + timelineActive: false, + timelineCommits: [], + currentCommitSha: null, + isIndexing: false, + indexProgress: null, + isPlaying: false, + playbackSpeed: 1, + ...overrides, + }; +} From 7ec7dc154b5c684dd9621cb7098b810eff5acb8c Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 11:57:56 -0700 Subject: [PATCH 097/192] refactor: clear changed-code organize findings --- packages/plugin-godot/src/plugin.ts | 2 +- packages/plugin-godot/src/plugin/symbol/className.ts | 2 +- packages/plugin-godot/src/plugin/symbol/declaration.ts | 2 +- packages/plugin-godot/src/plugin/symbol/gdscriptSignals.ts | 2 +- .../src/plugin/symbol/projectSettingsSymbols.ts | 2 +- .../plugin-godot/src/plugin/symbol/textResourceSymbols.ts | 2 +- .../src/plugin/symbol/{godotKinds.ts => vocabulary.ts} | 0 .../symbol/{godotKinds.test.ts => vocabulary.test.ts} | 2 +- packages/plugin-unity/package.json | 6 +++--- packages/plugin-unity/src/{plugin.ts => lifecycle.ts} | 0 .../tests/{plugin.test.ts => lifecycle.test.ts} | 2 +- 11 files changed, 11 insertions(+), 11 deletions(-) rename packages/plugin-godot/src/plugin/symbol/{godotKinds.ts => vocabulary.ts} (100%) rename packages/plugin-godot/tests/plugin/symbol/{godotKinds.test.ts => vocabulary.test.ts} (95%) rename packages/plugin-unity/src/{plugin.ts => lifecycle.ts} (100%) rename packages/plugin-unity/tests/{plugin.test.ts => lifecycle.test.ts} (98%) diff --git a/packages/plugin-godot/src/plugin.ts b/packages/plugin-godot/src/plugin.ts index b0496012f..1c6cee513 100644 --- a/packages/plugin-godot/src/plugin.ts +++ b/packages/plugin-godot/src/plugin.ts @@ -23,7 +23,7 @@ import { extractSymbols } from './plugin/symbol/extract'; import { GODOT_SYMBOL_PLUGIN_KIND, GODOT_SYMBOL_SOURCE, -} from './plugin/symbol/godotKinds'; +} from './plugin/symbol/vocabulary'; import type { GodotWorkspaceFile, IGDScriptAnalyzeFilePlugin, diff --git a/packages/plugin-godot/src/plugin/symbol/className.ts b/packages/plugin-godot/src/plugin/symbol/className.ts index faf49af69..9c3c09819 100644 --- a/packages/plugin-godot/src/plugin/symbol/className.ts +++ b/packages/plugin-godot/src/plugin/symbol/className.ts @@ -6,7 +6,7 @@ import { GODOT_SCRIPT_LANGUAGE, GODOT_SYMBOL_PLUGIN_KIND, GODOT_SYMBOL_SOURCE, -} from './godotKinds'; +} from './vocabulary'; export function extractClassNameSymbols( content: string, diff --git a/packages/plugin-godot/src/plugin/symbol/declaration.ts b/packages/plugin-godot/src/plugin/symbol/declaration.ts index a61faa7b7..7014e11b2 100644 --- a/packages/plugin-godot/src/plugin/symbol/declaration.ts +++ b/packages/plugin-godot/src/plugin/symbol/declaration.ts @@ -8,7 +8,7 @@ import { GODOT_SCRIPT_LANGUAGE, GODOT_SYMBOL_PLUGIN_KIND, GODOT_SYMBOL_SOURCE, -} from './godotKinds'; +} from './vocabulary'; const EXPORT_DECORATOR_PATTERN = /^@export(?:_[A-Za-z_][A-Za-z0-9_]*)?(?:\([^)]*\))?(?:\s+|$)/; diff --git a/packages/plugin-godot/src/plugin/symbol/gdscriptSignals.ts b/packages/plugin-godot/src/plugin/symbol/gdscriptSignals.ts index 74438ad3b..070f2246e 100644 --- a/packages/plugin-godot/src/plugin/symbol/gdscriptSignals.ts +++ b/packages/plugin-godot/src/plugin/symbol/gdscriptSignals.ts @@ -4,7 +4,7 @@ import { GODOT_SCRIPT_LANGUAGE, GODOT_SYMBOL_PLUGIN_KIND, GODOT_SYMBOL_SOURCE, -} from './godotKinds'; +} from './vocabulary'; const SIGNAL_DECLARATION_PATTERN = /^signal\s+([A-Za-z_][A-Za-z0-9_]*)\b/; diff --git a/packages/plugin-godot/src/plugin/symbol/projectSettingsSymbols.ts b/packages/plugin-godot/src/plugin/symbol/projectSettingsSymbols.ts index 25089c55c..d424c91a3 100644 --- a/packages/plugin-godot/src/plugin/symbol/projectSettingsSymbols.ts +++ b/packages/plugin-godot/src/plugin/symbol/projectSettingsSymbols.ts @@ -5,7 +5,7 @@ import { GODOT_PROJECT_SETTINGS_LANGUAGE, GODOT_SYMBOL_PLUGIN_KIND, GODOT_SYMBOL_SOURCE, -} from './godotKinds'; +} from './vocabulary'; export function extractProjectSettingsSymbols( content: string, diff --git a/packages/plugin-godot/src/plugin/symbol/textResourceSymbols.ts b/packages/plugin-godot/src/plugin/symbol/textResourceSymbols.ts index 6ea07c7ef..43d48ee0f 100644 --- a/packages/plugin-godot/src/plugin/symbol/textResourceSymbols.ts +++ b/packages/plugin-godot/src/plugin/symbol/textResourceSymbols.ts @@ -6,7 +6,7 @@ import { GODOT_SYMBOL_PLUGIN_KIND, GODOT_SYMBOL_SOURCE, GODOT_TEXT_RESOURCE_LANGUAGE, -} from './godotKinds'; +} from './vocabulary'; const SCENE_EXTENSIONS = new Set(['.tscn']); const RESOURCE_EXTENSIONS = new Set(['.tres']); diff --git a/packages/plugin-godot/src/plugin/symbol/godotKinds.ts b/packages/plugin-godot/src/plugin/symbol/vocabulary.ts similarity index 100% rename from packages/plugin-godot/src/plugin/symbol/godotKinds.ts rename to packages/plugin-godot/src/plugin/symbol/vocabulary.ts diff --git a/packages/plugin-godot/tests/plugin/symbol/godotKinds.test.ts b/packages/plugin-godot/tests/plugin/symbol/vocabulary.test.ts similarity index 95% rename from packages/plugin-godot/tests/plugin/symbol/godotKinds.test.ts rename to packages/plugin-godot/tests/plugin/symbol/vocabulary.test.ts index 58dd2832d..854fa326a 100644 --- a/packages/plugin-godot/tests/plugin/symbol/godotKinds.test.ts +++ b/packages/plugin-godot/tests/plugin/symbol/vocabulary.test.ts @@ -5,7 +5,7 @@ import { GODOT_SYMBOL_PLUGIN_KIND, GODOT_SYMBOL_SOURCE, GODOT_TEXT_RESOURCE_LANGUAGE, -} from '../../../src/plugin/symbol/godotKinds'; +} from '../../../src/plugin/symbol/vocabulary'; describe('Godot symbol vocabulary', () => { it('uses stable graph scope metadata identifiers', () => { diff --git a/packages/plugin-unity/package.json b/packages/plugin-unity/package.json index 9ce09aee1..12e4273f6 100644 --- a/packages/plugin-unity/package.json +++ b/packages/plugin-unity/package.json @@ -5,10 +5,10 @@ "license": "MIT", "type": "module", "main": "./dist/plugin.js", - "types": "./dist/plugin.d.ts", + "types": "./dist/lifecycle.d.ts", "exports": { ".": { - "types": "./dist/plugin.d.ts", + "types": "./dist/lifecycle.d.ts", "default": "./dist/plugin.js" } }, @@ -42,7 +42,7 @@ "LICENSE" ], "scripts": { - "build": "tsc -p tsconfig.build.json --emitDeclarationOnly && node ../../scripts/build-workspace-package.mjs src/plugin.ts dist/plugin.js", + "build": "tsc -p tsconfig.build.json --emitDeclarationOnly && node ../../scripts/build-workspace-package.mjs src/lifecycle.ts dist/plugin.js", "test": "vitest run", "lint": "eslint \"src/**/*.ts\" \"tests/**/*.ts\"", "typecheck": "tsc --noEmit -p tsconfig.json" diff --git a/packages/plugin-unity/src/plugin.ts b/packages/plugin-unity/src/lifecycle.ts similarity index 100% rename from packages/plugin-unity/src/plugin.ts rename to packages/plugin-unity/src/lifecycle.ts diff --git a/packages/plugin-unity/tests/plugin.test.ts b/packages/plugin-unity/tests/lifecycle.test.ts similarity index 98% rename from packages/plugin-unity/tests/plugin.test.ts rename to packages/plugin-unity/tests/lifecycle.test.ts index a60c28f38..2afbaa6c9 100644 --- a/packages/plugin-unity/tests/plugin.test.ts +++ b/packages/plugin-unity/tests/lifecycle.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import type { IPluginAnalysisContext } from '@codegraphy-dev/plugin-api'; -import { createUnityPlugin } from '../src/plugin'; +import { createUnityPlugin } from '../src/lifecycle'; const playerControllerGuid = '11111111111111111111111111111111'; From e8ca30df24d646ce9f5694c24577262f398a1360 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 12:01:50 -0700 Subject: [PATCH 098/192] refactor: clear boundaries findings --- .../surface/view/threeDimensional.tsx | 1 + .../components/graph/viewport/view.tsx | 1 - .../src/gdscript/classNameLine.ts | 5 -- .../tests/gdscript/classNameLine.test.ts | 28 ---------- quality.config.json | 54 +++++++++++++++++++ 5 files changed, 55 insertions(+), 34 deletions(-) delete mode 100644 packages/plugin-godot/src/gdscript/classNameLine.ts delete mode 100644 packages/plugin-godot/tests/gdscript/classNameLine.test.ts diff --git a/packages/extension/src/webview/components/graph/rendering/surface/view/threeDimensional.tsx b/packages/extension/src/webview/components/graph/rendering/surface/view/threeDimensional.tsx index 75a4b184a..649722af8 100644 --- a/packages/extension/src/webview/components/graph/rendering/surface/view/threeDimensional.tsx +++ b/packages/extension/src/webview/components/graph/rendering/surface/view/threeDimensional.tsx @@ -1,3 +1,4 @@ +import '../../../../../three/runtime'; import { useEffect, useState, type MutableRefObject, type ReactElement } from 'react'; import ForceGraph3D from 'react-force-graph-3d'; import type { diff --git a/packages/extension/src/webview/components/graph/viewport/view.tsx b/packages/extension/src/webview/components/graph/viewport/view.tsx index 32544a52f..ad139fbb4 100644 --- a/packages/extension/src/webview/components/graph/viewport/view.tsx +++ b/packages/extension/src/webview/components/graph/viewport/view.tsx @@ -37,7 +37,6 @@ import type { GraphAccessibilityItems } from './accessibility'; import type { FGLink, FGNode } from '../model/build'; const LazyDeferredSurface3d = lazy(async () => { - await import('../../../three/runtime'); const module = await import('../rendering/surface/view/threeDimensional'); return { default: module.DeferredSurface3d }; }); diff --git a/packages/plugin-godot/src/gdscript/classNameLine.ts b/packages/plugin-godot/src/gdscript/classNameLine.ts deleted file mode 100644 index 49fe725fa..000000000 --- a/packages/plugin-godot/src/gdscript/classNameLine.ts +++ /dev/null @@ -1,5 +0,0 @@ -export function isLeadingClassNameStatement(content: string, offset: number): boolean { - const beforeOffset = content.slice(0, offset); - const lineStart = beforeOffset.lastIndexOf('\n') + 1; - return content.slice(lineStart, offset).trim() === '' && content.startsWith('class_name', offset); -} diff --git a/packages/plugin-godot/tests/gdscript/classNameLine.test.ts b/packages/plugin-godot/tests/gdscript/classNameLine.test.ts deleted file mode 100644 index b05fc7b72..000000000 --- a/packages/plugin-godot/tests/gdscript/classNameLine.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { isLeadingClassNameStatement } from '../../src/gdscript/classNameLine'; - -describe('isLeadingClassNameStatement', () => { - it('accepts class_name at the start of a source line', () => { - const content = [ - 'extends Node2D', - ' class_name Player', - ].join('\n'); - - expect(isLeadingClassNameStatement(content, content.indexOf('class_name Player'))).toBe(true); - }); - - it('rejects class_name when earlier code appears on the same line', () => { - const content = [ - 'var class_name AlsoIgnored', - 'class_name LaterDeclaration', - ].join('\n'); - - expect(isLeadingClassNameStatement(content, content.indexOf('class_name AlsoIgnored'))).toBe(false); - }); - - it('rejects other text at a leading line offset', () => { - const content = ' not_class_name Player'; - - expect(isLeadingClassNameStatement(content, content.indexOf('not_class_name Player'))).toBe(false); - }); -}); diff --git a/quality.config.json b/quality.config.json index fe1c8ccc6..f878ef672 100644 --- a/quality.config.json +++ b/quality.config.json @@ -224,6 +224,39 @@ ] } }, + "codegraphy-svelte-example": { + "boundaries": { + "entrypoints": [ + "src/loadFeature.ts", + "src/main.ts", + "src/types.ts" + ] + } + }, + "codegraphy-typescript-example": { + "boundaries": { + "entrypoints": [ + "src/alias/themePack.ts", + "src/index.ts", + "src/lazyPreview.ts", + "src/palette.ts", + "src/paletteRunner.ts", + "src/registry.ts", + "src/scratchpad.ts", + "src/seedSettings.ts", + "src/themeLabels.ts" + ] + } + }, + "codegraphy-vue-example": { + "boundaries": { + "entrypoints": [ + "src/composables/useCounter.ts", + "src/data/users.ts", + "src/main.ts" + ] + } + }, "mcp": { "crap": { "coverage": { @@ -338,6 +371,13 @@ ] } }, + "plugin-svelte": { + "boundaries": { + "entrypoints": [ + "src/plugin.ts" + ] + } + }, "plugin-typescript": { "crap": { "coverage": { @@ -361,6 +401,20 @@ "src/plugin.ts" ] } + }, + "plugin-unity": { + "boundaries": { + "entrypoints": [ + "src/lifecycle.ts" + ] + } + }, + "plugin-vue": { + "boundaries": { + "entrypoints": [ + "src/plugin.ts" + ] + } } } } From 1db6d4abc8fa70d54a7d4ae75066767f905806c5 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 12:58:47 -0700 Subject: [PATCH 099/192] refactor: split mutation-heavy changed modules --- .../2026-06-23-mutation-site-file-splits.md | 149 +++++++++ .../core/src/graphCache/database/io/save.ts | 73 +---- .../src/graphCache/database/io/saveAsync.ts | 64 ++++ .../src/graphCache/database/io/temporary.ts | 15 + .../graphView/analysis/execution/load.ts | 50 +-- .../analysis/execution/load/context.ts | 51 +++ .../defaults/materialTheme/pathMatch.ts | 54 +--- .../defaults/materialTheme/pathMatcher.ts | 45 +++ .../graphView/provider/analysis/fullIndex.ts | 131 ++++++++ .../graphView/provider/analysis/methods.ts | 123 +------ .../pipeline/service/cachedGraphWarmup.ts | 186 +++++++++++ .../pipeline/service/discoveryFacade.ts | 245 +++----------- .../extension/pipeline/service/indexStatus.ts | 36 +++ .../extension/pipeline/service/pluginState.ts | 86 +++++ .../webview/store/messageHandlers/graph.ts | 300 +----------------- .../store/messageHandlers/graphControls.ts | 133 ++++++++ .../store/messageHandlers/graphData.ts | 171 ++++++++++ 17 files changed, 1142 insertions(+), 770 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-23-mutation-site-file-splits.md create mode 100644 packages/core/src/graphCache/database/io/saveAsync.ts create mode 100644 packages/core/src/graphCache/database/io/temporary.ts create mode 100644 packages/extension/src/extension/graphView/analysis/execution/load/context.ts create mode 100644 packages/extension/src/extension/graphView/groups/defaults/materialTheme/pathMatcher.ts create mode 100644 packages/extension/src/extension/graphView/provider/analysis/fullIndex.ts create mode 100644 packages/extension/src/extension/pipeline/service/cachedGraphWarmup.ts create mode 100644 packages/extension/src/extension/pipeline/service/indexStatus.ts create mode 100644 packages/extension/src/extension/pipeline/service/pluginState.ts create mode 100644 packages/extension/src/webview/store/messageHandlers/graphControls.ts create mode 100644 packages/extension/src/webview/store/messageHandlers/graphData.ts diff --git a/docs/superpowers/plans/2026-06-23-mutation-site-file-splits.md b/docs/superpowers/plans/2026-06-23-mutation-site-file-splits.md new file mode 100644 index 000000000..e28c9a4ef --- /dev/null +++ b/docs/superpowers/plans/2026-06-23-mutation-site-file-splits.md @@ -0,0 +1,149 @@ +# Mutation Site File Splits Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Split PR-touched source files that exceed the 50 mutation-site threshold into smaller feature-owned modules without running mutation survivor/kill tests. + +**Architecture:** Use the existing main-branch mutation seed reports only as a read-only locator for over-threshold files. Keep public imports stable by leaving the original files as the exported behavior surface where practical, and move independently changing helpers into sibling feature modules with matching existing test coverage. + +**Tech Stack:** TypeScript source modules, existing Vitest suites, `quality-tools organize`, and the checked-in PR branch worktree. + +--- + +### Task 1: Split Cached Discovery Warmup From Pipeline Discovery + +**Files:** +- Modify: `packages/extension/src/extension/pipeline/service/discoveryFacade.ts` +- Create: `packages/extension/src/extension/pipeline/service/cachedGraphWarmup.ts` +- Create: `packages/extension/src/extension/pipeline/service/indexStatus.ts` +- Create: `packages/extension/src/extension/pipeline/service/pluginState.ts` +- Test: `packages/extension/tests/extension/pipeline/service/discoveryFacade.test.ts` + +- [x] **Step 1: Move cached warmup helpers** + +Move the cached graph warmup input type, ignored segment set, candidate selection helpers, supported-file filtering, and warmup input creation into `cachedGraphWarmup.ts`. Export a small factory that receives registry/config/discovery callbacks and returns the selected warmup input. + +- [x] **Step 2: Move plugin and index status helpers** + +Move plugin initialization/reload/sync queueing plus effective filter pattern helpers into `pluginState.ts`, and move index status construction into `indexStatus.ts`. + +- [x] **Step 3: Keep facade behavior stable** + +Update `discoveryFacade.ts` so `loadCachedGraph` still schedules the same best-effort warmup, still ignores abort and missing-file errors, and still warns only for unexpected failures. + +- [x] **Step 4: Run the targeted test** + +```bash +pnpm --filter @codegraphy-dev/extension exec vitest run --config vitest.config.ts tests/extension/pipeline/service/discoveryFacade.test.ts +``` + +Expected: passes. + +### Task 2: Split Graph View Analysis Coordination + +**Files:** +- Modify: `packages/extension/src/extension/graphView/provider/analysis/methods.ts` +- Create: `packages/extension/src/extension/graphView/provider/analysis/fullIndex.ts` +- Test: `packages/extension/tests/extension/graphView/provider/analysis/methods.test.ts` + +- [x] **Step 1: Move full-index coordination** + +Move `FullIndexAnalysisCoordinator`, `FullIndexAnalysisCoordinatorState`, `FullIndexAnalysisKind`, and `canReplayStaleCache` into `fullIndex.ts`. + +- [x] **Step 2: Reuse from methods** + +Import the coordinator factory and stale-cache predicate from `methods.ts` so the method factory remains focused on wiring load/analyze/index/refresh actions. + +- [x] **Step 3: Run the targeted test** + +```bash +pnpm --filter @codegraphy-dev/extension exec vitest run --config vitest.config.ts tests/extension/graphView/provider/analysis/methods.test.ts +``` + +Expected: passes. + +### Task 3: Split Webview Graph Message Domains + +**Files:** +- Modify: `packages/extension/src/webview/store/messageHandlers/graph.ts` +- Create: `packages/extension/src/webview/store/messageHandlers/graphData.ts` +- Create: `packages/extension/src/webview/store/messageHandlers/graphControls.ts` +- Test: `packages/extension/tests/webview/store/messageHandlers/graph.test.ts` + +- [x] **Step 1: Move graph-data handlers** + +Move graph-data duplicate detection, graph data updates, and node metric updates into `graphData.ts`. + +- [x] **Step 2: Move graph-control handlers** + +Move graph control equality assignment and control/settings/depth/direction/physics handlers into `graphControls.ts`. + +- [x] **Step 3: Re-export public handlers** + +Keep `graph.ts` as the existing import surface by re-exporting the moved handlers and retaining the remaining legend/filter/favorite handlers. + +- [x] **Step 4: Run the targeted test** + +```bash +pnpm --filter @codegraphy-dev/extension exec vitest run --config vitest.config.ts tests/webview/store/messageHandlers/graph.test.ts +``` + +Expected: passes. + +### Task 4: Split Smaller Over-Threshold Helpers + +**Files:** +- Modify: `packages/core/src/graphCache/database/io/save.ts` +- Create: `packages/core/src/graphCache/database/io/saveAsync.ts` +- Create: `packages/core/src/graphCache/database/io/temporary.ts` +- Modify: `packages/extension/src/extension/graphView/analysis/execution/load.ts` +- Create: `packages/extension/src/extension/graphView/analysis/execution/load/context.ts` +- Modify: `packages/extension/src/extension/graphView/groups/defaults/materialTheme/pathMatch.ts` +- Create: `packages/extension/src/extension/graphView/groups/defaults/materialTheme/pathMatcher.ts` +- Tests: + - `packages/core/tests/graphCache/database/storage.test.ts` + - `packages/extension/tests/extension/graphView/analysis/execution/load.test.ts` + - `packages/extension/tests/extension/graphView/groups/defaults/materialTheme/pathMatch.test.ts` + +- [x] **Step 1: Move async database save** + +Move async save progress/yield logic into `saveAsync.ts`; keep sync save and clear behavior in `save.ts`. Move temp-path rename/cleanup helpers into `temporary.ts`. + +- [x] **Step 2: Move load context helpers** + +Move raw-data context types, replayable-data predicate, and decision selection into `load/context.ts`. + +- [x] **Step 3: Move material matcher construction** + +Move matcher entry creation, basename indexing, and sorting into `pathMatcher.ts`. + +- [x] **Step 4: Run targeted tests** + +```bash +pnpm --filter @codegraphy-dev/core exec vitest run --config vitest.config.ts tests/graphCache/database/storage.test.ts +pnpm --filter @codegraphy-dev/extension exec vitest run --config vitest.config.ts tests/extension/graphView/analysis/execution/load.test.ts tests/extension/graphView/groups/defaults/materialTheme/pathMatch.test.ts +``` + +Expected: passes. + +### Task 5: Organize and Commit + +**Files:** +- Modify only if `organize` reports actionable issues from the new split files. + +- [x] **Step 1: Run organize** + +```bash +pnpm run organize -- . +``` + +Expected: no new organization issues in the changed files. + +- [ ] **Step 2: Commit and push** + +```bash +git status --short +git add docs/superpowers/plans/2026-06-23-mutation-site-file-splits.md packages/core/src/graphCache/database/io/save.ts packages/core/src/graphCache/database/io/saveAsync.ts packages/core/src/graphCache/database/io/temporary.ts packages/extension/src/extension/pipeline/service/discoveryFacade.ts packages/extension/src/extension/pipeline/service/cachedGraphWarmup.ts packages/extension/src/extension/pipeline/service/indexStatus.ts packages/extension/src/extension/pipeline/service/pluginState.ts packages/extension/src/extension/graphView/provider/analysis/methods.ts packages/extension/src/extension/graphView/provider/analysis/fullIndex.ts packages/extension/src/webview/store/messageHandlers/graph.ts packages/extension/src/webview/store/messageHandlers/graphData.ts packages/extension/src/webview/store/messageHandlers/graphControls.ts packages/extension/src/extension/graphView/analysis/execution/load.ts packages/extension/src/extension/graphView/analysis/execution/load/context.ts packages/extension/src/extension/graphView/groups/defaults/materialTheme/pathMatch.ts packages/extension/src/extension/graphView/groups/defaults/materialTheme/pathMatcher.ts +git commit -m "refactor: split mutation-heavy changed modules" +git push +``` diff --git a/packages/core/src/graphCache/database/io/save.ts b/packages/core/src/graphCache/database/io/save.ts index 4f550ac52..b0056b517 100644 --- a/packages/core/src/graphCache/database/io/save.ts +++ b/packages/core/src/graphCache/database/io/save.ts @@ -1,16 +1,20 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import { setImmediate as waitForImmediate } from 'node:timers/promises'; import type { IWorkspaceAnalysisCache } from '../../../analysis/cache'; -import { runStatementAsync, runStatementSync, withConnection, withConnectionAsync } from './connection'; +import { runStatementSync, withConnection } from './connection'; import { ensureDatabaseDirectory, getWorkspaceAnalysisDatabasePath } from './paths'; import { createWorkspaceAnalysisCacheWriter, - createWorkspaceAnalysisCacheWriterAsync, persistAnalysisEntry, - persistAnalysisEntryAsync, sortedCacheEntries, } from '../query/write'; +import { + cleanupTemporaryDatabase, + createTemporaryDatabasePath, + replaceDatabaseCache, +} from './temporary'; + +export { saveWorkspaceAnalysisDatabaseCacheAsync } from './saveAsync'; export interface WorkspaceAnalysisDatabaseSaveProgress { current: number; @@ -22,20 +26,6 @@ export interface WorkspaceAnalysisDatabaseSaveOptions { yieldEvery?: number; } -function createTemporaryDatabasePath(databasePath: string): string { - return `${databasePath}.${process.pid}.${Date.now()}.tmp`; -} - -function replaceDatabaseCache(tempDatabasePath: string, databasePath: string): void { - fs.renameSync(tempDatabasePath, databasePath); -} - -function cleanupTemporaryDatabase(tempDatabasePath: string): void { - if (fs.existsSync(tempDatabasePath)) { - fs.rmSync(tempDatabasePath, { force: true }); - } -} - export function saveWorkspaceAnalysisDatabaseCache( workspaceRoot: string, cache: IWorkspaceAnalysisCache, @@ -65,53 +55,6 @@ export function saveWorkspaceAnalysisDatabaseCache( } } -export async function saveWorkspaceAnalysisDatabaseCacheAsync( - workspaceRoot: string, - cache: IWorkspaceAnalysisCache, - options: WorkspaceAnalysisDatabaseSaveOptions = {}, -): Promise { - ensureDatabaseDirectory(workspaceRoot); - const databasePath = getWorkspaceAnalysisDatabasePath(workspaceRoot); - if (!fs.existsSync(path.dirname(databasePath))) { - return; - } - - const entries = sortedCacheEntries(cache); - const total = entries.length; - const yieldEvery = options.yieldEvery ?? 100; - const tempDatabasePath = createTemporaryDatabasePath(databasePath); - options.onProgress?.({ current: 0, total }); - - try { - await withConnectionAsync(tempDatabasePath, async (connection) => { - await runStatementAsync(connection, 'MATCH (entry:FileAnalysis) DELETE entry'); - await runStatementAsync(connection, 'MATCH (entry:Symbol) DELETE entry'); - await runStatementAsync(connection, 'MATCH (entry:Relation) DELETE entry'); - const writer = await createWorkspaceAnalysisCacheWriterAsync(connection); - - let current = 0; - let statementsSinceYield = 0; - const yieldAfterStatement = async (): Promise => { - statementsSinceYield += 1; - if (yieldEvery > 0 && statementsSinceYield >= yieldEvery) { - statementsSinceYield = 0; - await waitForImmediate(); - } - }; - - for (const [filePath, entry] of entries) { - await persistAnalysisEntryAsync(writer, filePath, entry, yieldAfterStatement); - current += 1; - options.onProgress?.({ current, total }); - } - }); - replaceDatabaseCache(tempDatabasePath, databasePath); - } catch (error) { - cleanupTemporaryDatabase(tempDatabasePath); - throw error; - } -} - export function clearWorkspaceAnalysisDatabaseCache(workspaceRoot: string): void { const databasePath = getWorkspaceAnalysisDatabasePath(workspaceRoot); if (!fs.existsSync(databasePath)) { diff --git a/packages/core/src/graphCache/database/io/saveAsync.ts b/packages/core/src/graphCache/database/io/saveAsync.ts new file mode 100644 index 000000000..4be06af8f --- /dev/null +++ b/packages/core/src/graphCache/database/io/saveAsync.ts @@ -0,0 +1,64 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { setImmediate as waitForImmediate } from 'node:timers/promises'; +import type { IWorkspaceAnalysisCache } from '../../../analysis/cache'; +import { runStatementAsync, withConnectionAsync } from './connection'; +import { ensureDatabaseDirectory, getWorkspaceAnalysisDatabasePath } from './paths'; +import { + cleanupTemporaryDatabase, + createTemporaryDatabasePath, + replaceDatabaseCache, +} from './temporary'; +import { + createWorkspaceAnalysisCacheWriterAsync, + persistAnalysisEntryAsync, + sortedCacheEntries, +} from '../query/write'; +import type { WorkspaceAnalysisDatabaseSaveOptions } from './save'; + +export async function saveWorkspaceAnalysisDatabaseCacheAsync( + workspaceRoot: string, + cache: IWorkspaceAnalysisCache, + options: WorkspaceAnalysisDatabaseSaveOptions = {}, +): Promise { + ensureDatabaseDirectory(workspaceRoot); + const databasePath = getWorkspaceAnalysisDatabasePath(workspaceRoot); + if (!fs.existsSync(path.dirname(databasePath))) { + return; + } + + const entries = sortedCacheEntries(cache); + const total = entries.length; + const yieldEvery = options.yieldEvery ?? 100; + const tempDatabasePath = createTemporaryDatabasePath(databasePath); + options.onProgress?.({ current: 0, total }); + + try { + await withConnectionAsync(tempDatabasePath, async (connection) => { + await runStatementAsync(connection, 'MATCH (entry:FileAnalysis) DELETE entry'); + await runStatementAsync(connection, 'MATCH (entry:Symbol) DELETE entry'); + await runStatementAsync(connection, 'MATCH (entry:Relation) DELETE entry'); + const writer = await createWorkspaceAnalysisCacheWriterAsync(connection); + + let current = 0; + let statementsSinceYield = 0; + const yieldAfterStatement = async (): Promise => { + statementsSinceYield += 1; + if (yieldEvery > 0 && statementsSinceYield >= yieldEvery) { + statementsSinceYield = 0; + await waitForImmediate(); + } + }; + + for (const [filePath, entry] of entries) { + await persistAnalysisEntryAsync(writer, filePath, entry, yieldAfterStatement); + current += 1; + options.onProgress?.({ current, total }); + } + }); + replaceDatabaseCache(tempDatabasePath, databasePath); + } catch (error) { + cleanupTemporaryDatabase(tempDatabasePath); + throw error; + } +} diff --git a/packages/core/src/graphCache/database/io/temporary.ts b/packages/core/src/graphCache/database/io/temporary.ts new file mode 100644 index 000000000..eace72551 --- /dev/null +++ b/packages/core/src/graphCache/database/io/temporary.ts @@ -0,0 +1,15 @@ +import * as fs from 'node:fs'; + +export function createTemporaryDatabasePath(databasePath: string): string { + return `${databasePath}.${process.pid}.${Date.now()}.tmp`; +} + +export function replaceDatabaseCache(tempDatabasePath: string, databasePath: string): void { + fs.renameSync(tempDatabasePath, databasePath); +} + +export function cleanupTemporaryDatabase(tempDatabasePath: string): void { + if (fs.existsSync(tempDatabasePath)) { + fs.rmSync(tempDatabasePath, { force: true }); + } +} diff --git a/packages/extension/src/extension/graphView/analysis/execution/load.ts b/packages/extension/src/extension/graphView/analysis/execution/load.ts index 91aaacf9f..525c5f08c 100644 --- a/packages/extension/src/extension/graphView/analysis/execution/load.ts +++ b/packages/extension/src/extension/graphView/analysis/execution/load.ts @@ -17,50 +17,12 @@ import { discoverGraphViewRawData, loadCachedGraphViewRawData, } from './load/analyzerData'; -import { getGraphIndexFreshness } from './load/freshness'; -import { selectGraphViewRawDataLoadDecision } from './load/routing'; -import type { GraphViewRawDataLoadDecision } from './load/routing'; -import type { CodeGraphyIndexFreshness } from '../../../repoSettings/freshness'; - -type GraphViewRawDataRoute = GraphViewRawDataLoadDecision['route']; -type GraphViewAnalyzer = NonNullable; - -interface GraphViewRawDataLoadContext { - analyzer: GraphViewAnalyzer; - forwardProgress: ReturnType; - indexFreshness: CodeGraphyIndexFreshness | undefined; - signal: AbortSignal; - state: GraphViewAnalysisExecutionState; -} - -function hasReplayableGraphData(graphData: IGraphData): boolean { - return graphData.nodes.length > 0 || graphData.edges.length > 0; -} - -function selectGraphViewRawDataLoadDecisionForState( - state: GraphViewAnalysisExecutionState, - analyzer: NonNullable, -): { - decision: GraphViewRawDataLoadDecision; - indexFreshness: CodeGraphyIndexFreshness | undefined; -} { - if (state.mode === 'incremental') { - return { - decision: { route: 'incremental', shouldDiscover: false }, - indexFreshness: undefined, - }; - } - - const indexFreshness = getGraphIndexFreshness(analyzer); - return { - decision: selectGraphViewRawDataLoadDecision( - state.mode, - indexFreshness, - typeof analyzer.loadCachedGraph === 'function', - ), - indexFreshness, - }; -} +import { + hasReplayableGraphData, + selectGraphViewRawDataLoadDecisionForState, + type GraphViewRawDataLoadContext, + type GraphViewRawDataRoute, +} from './load/context'; async function loadDiscoveredGraphViewRawData(context: GraphViewRawDataLoadContext): Promise { return discoverGraphViewRawData(context.signal, context.state, context.analyzer); diff --git a/packages/extension/src/extension/graphView/analysis/execution/load/context.ts b/packages/extension/src/extension/graphView/analysis/execution/load/context.ts new file mode 100644 index 000000000..5eba3d7c3 --- /dev/null +++ b/packages/extension/src/extension/graphView/analysis/execution/load/context.ts @@ -0,0 +1,51 @@ +import type { IGraphData } from '../../../../../shared/graph/contracts'; +import type { CodeGraphyIndexFreshness } from '../../../../repoSettings/freshness'; +import type { + GraphViewIndexingProgress, + GraphViewAnalysisExecutionState, +} from '../../execution'; +import { getGraphIndexFreshness } from './freshness'; +import { + selectGraphViewRawDataLoadDecision, + type GraphViewRawDataLoadDecision, +} from './routing'; + +export type GraphViewRawDataRoute = GraphViewRawDataLoadDecision['route']; +export type GraphViewAnalyzer = NonNullable; + +export interface GraphViewRawDataLoadContext { + analyzer: GraphViewAnalyzer; + forwardProgress: (progress: GraphViewIndexingProgress) => void; + indexFreshness: CodeGraphyIndexFreshness | undefined; + signal: AbortSignal; + state: GraphViewAnalysisExecutionState; +} + +export function hasReplayableGraphData(graphData: IGraphData): boolean { + return graphData.nodes.length > 0 || graphData.edges.length > 0; +} + +export function selectGraphViewRawDataLoadDecisionForState( + state: GraphViewAnalysisExecutionState, + analyzer: GraphViewAnalyzer, +): { + decision: GraphViewRawDataLoadDecision; + indexFreshness: CodeGraphyIndexFreshness | undefined; +} { + if (state.mode === 'incremental') { + return { + decision: { route: 'incremental', shouldDiscover: false }, + indexFreshness: undefined, + }; + } + + const indexFreshness = getGraphIndexFreshness(analyzer); + return { + decision: selectGraphViewRawDataLoadDecision( + state.mode, + indexFreshness, + typeof analyzer.loadCachedGraph === 'function', + ), + indexFreshness, + }; +} diff --git a/packages/extension/src/extension/graphView/groups/defaults/materialTheme/pathMatch.ts b/packages/extension/src/extension/graphView/groups/defaults/materialTheme/pathMatch.ts index 06bf96fc4..c8a44da2d 100644 --- a/packages/extension/src/extension/graphView/groups/defaults/materialTheme/pathMatch.ts +++ b/packages/extension/src/extension/graphView/groups/defaults/materialTheme/pathMatch.ts @@ -1,5 +1,15 @@ import type { MaterialMatch } from './model'; import { getMaterialBaseName, normalizePathSeparators } from './paths'; +import { + createMaterialPathRuleMatcher, + type MaterialPathRuleEntry, + type MaterialPathRuleMatcher, +} from './pathMatcher'; + +export { + createMaterialPathRuleMatcher, + type MaterialPathRuleMatcher, +}; type PathMatchKind = Extract; @@ -10,50 +20,6 @@ interface PathMatchContext { subjectPath: string; } -interface MaterialPathRuleEntry { - iconName: string; - lowerRule: string; - normalizedRule: string; -} - -export interface MaterialPathRuleMatcher { - baseNameRules: Map; - pathRules: MaterialPathRuleEntry[]; - pathRulesByLowerBaseName: Map; -} - -export function createMaterialPathRuleMatcher( - rules: Record, -): MaterialPathRuleMatcher { - const baseNameRules = new Map(); - const pathRules: MaterialPathRuleEntry[] = []; - const pathRulesByLowerBaseName = new Map(); - - for (const [ruleKey, iconName] of Object.entries(rules)) { - const normalizedRule = normalizePathSeparators(ruleKey); - const lowerRule = normalizedRule.toLowerCase(); - const entry = { iconName, lowerRule, normalizedRule }; - - if (normalizedRule.includes('/')) { - pathRules.push(entry); - const lowerBaseName = getMaterialBaseName(normalizedRule).toLowerCase(); - const rulesForBaseName = pathRulesByLowerBaseName.get(lowerBaseName) ?? []; - rulesForBaseName.push(entry); - pathRulesByLowerBaseName.set(lowerBaseName, rulesForBaseName); - continue; - } - - baseNameRules.set(lowerRule, entry); - } - - pathRules.sort((left, right) => right.normalizedRule.length - left.normalizedRule.length); - for (const rulesForBaseName of pathRulesByLowerBaseName.values()) { - rulesForBaseName.sort((left, right) => right.normalizedRule.length - left.normalizedRule.length); - } - - return { baseNameRules, pathRules, pathRulesByLowerBaseName }; -} - export function findLongestPathMatch( subjectPath: string, rules: Record, diff --git a/packages/extension/src/extension/graphView/groups/defaults/materialTheme/pathMatcher.ts b/packages/extension/src/extension/graphView/groups/defaults/materialTheme/pathMatcher.ts new file mode 100644 index 000000000..4fb1b37dc --- /dev/null +++ b/packages/extension/src/extension/graphView/groups/defaults/materialTheme/pathMatcher.ts @@ -0,0 +1,45 @@ +import { getMaterialBaseName, normalizePathSeparators } from './paths'; + +export interface MaterialPathRuleEntry { + iconName: string; + lowerRule: string; + normalizedRule: string; +} + +export interface MaterialPathRuleMatcher { + baseNameRules: Map; + pathRules: MaterialPathRuleEntry[]; + pathRulesByLowerBaseName: Map; +} + +export function createMaterialPathRuleMatcher( + rules: Record, +): MaterialPathRuleMatcher { + const baseNameRules = new Map(); + const pathRules: MaterialPathRuleEntry[] = []; + const pathRulesByLowerBaseName = new Map(); + + for (const [ruleKey, iconName] of Object.entries(rules)) { + const normalizedRule = normalizePathSeparators(ruleKey); + const lowerRule = normalizedRule.toLowerCase(); + const entry = { iconName, lowerRule, normalizedRule }; + + if (normalizedRule.includes('/')) { + pathRules.push(entry); + const lowerBaseName = getMaterialBaseName(normalizedRule).toLowerCase(); + const rulesForBaseName = pathRulesByLowerBaseName.get(lowerBaseName) ?? []; + rulesForBaseName.push(entry); + pathRulesByLowerBaseName.set(lowerBaseName, rulesForBaseName); + continue; + } + + baseNameRules.set(lowerRule, entry); + } + + pathRules.sort((left, right) => right.normalizedRule.length - left.normalizedRule.length); + for (const rulesForBaseName of pathRulesByLowerBaseName.values()) { + rulesForBaseName.sort((left, right) => right.normalizedRule.length - left.normalizedRule.length); + } + + return { baseNameRules, pathRules, pathRulesByLowerBaseName }; +} diff --git a/packages/extension/src/extension/graphView/provider/analysis/fullIndex.ts b/packages/extension/src/extension/graphView/provider/analysis/fullIndex.ts new file mode 100644 index 000000000..1efa9e1b4 --- /dev/null +++ b/packages/extension/src/extension/graphView/provider/analysis/fullIndex.ts @@ -0,0 +1,131 @@ +interface FullIndexAnalysisLogger { + logError(message: string, error: unknown): void; +} + +interface ReplayableCacheAnalyzer { + getIndexStatus?(): { freshness: string }; + loadCachedGraph?: unknown; +} + +interface ReplayableCacheSource { + _analyzer?: ReplayableCacheAnalyzer; +} + +export interface FullIndexAnalysisCoordinator { + runAfterFullIndexAnalysis(runAnalysis: () => Promise): Promise; + runFullIndexAnalysis(runAnalysis: () => Promise): Promise; + runFullIndexAnalysisInBackground( + runAnalysis: () => Promise, + shouldStart?: () => boolean, + ): void; + waitForFullIndexAnalysis(): Promise; + waitForForegroundFullIndexAnalysis(): Promise; +} + +type FullIndexAnalysisKind = 'background' | 'foreground'; + +class FullIndexAnalysisCoordinatorState implements FullIndexAnalysisCoordinator { + private _fullIndexAnalysisPromise: Promise | undefined; + private _fullIndexAnalysisKind: FullIndexAnalysisKind | undefined; + private _scheduledBackgroundAnalysis: ReturnType | undefined; + + constructor( + private readonly _dependencies: FullIndexAnalysisLogger, + ) {} + + private _clearScheduledBackgroundAnalysis(): void { + if (this._scheduledBackgroundAnalysis === undefined) { + return; + } + + clearTimeout(this._scheduledBackgroundAnalysis); + this._scheduledBackgroundAnalysis = undefined; + } + + async waitForFullIndexAnalysis(): Promise { + if (!this._fullIndexAnalysisPromise) { + return false; + } + + try { + await this._fullIndexAnalysisPromise; + } catch { + // The request that owns the reindex reports the failure. Competing + // fire-and-forget webview loads should not create duplicate errors. + } + return true; + } + + async waitForForegroundFullIndexAnalysis(): Promise { + if (this._fullIndexAnalysisKind === 'background') { + return false; + } + + return this.waitForFullIndexAnalysis(); + } + + async runFullIndexAnalysis( + runAnalysis: () => Promise, + kind: FullIndexAnalysisKind = 'foreground', + ): Promise { + if (kind === 'foreground') { + this._clearScheduledBackgroundAnalysis(); + } + + if (this._fullIndexAnalysisPromise) { + await this._fullIndexAnalysisPromise; + return; + } + + const analysisPromise = runAnalysis(); + this._fullIndexAnalysisPromise = analysisPromise; + this._fullIndexAnalysisKind = kind; + try { + await analysisPromise; + } finally { + if (this._fullIndexAnalysisPromise === analysisPromise) { + this._fullIndexAnalysisPromise = undefined; + this._fullIndexAnalysisKind = undefined; + } + } + } + + runFullIndexAnalysisInBackground( + runAnalysis: () => Promise, + shouldStart: () => boolean = () => true, + ): void { + if (this._scheduledBackgroundAnalysis !== undefined || this._fullIndexAnalysisPromise) { + return; + } + + this._scheduledBackgroundAnalysis = setTimeout(() => { + this._scheduledBackgroundAnalysis = undefined; + if (!shouldStart()) { + return; + } + + void this.runFullIndexAnalysis(runAnalysis, 'background').catch(error => { + this._dependencies.logError('[CodeGraphy] Background cache sync failed:', error); + }); + }, 0); + } + + async runAfterFullIndexAnalysis( + runAnalysis: () => Promise, + ): Promise { + this._clearScheduledBackgroundAnalysis(); + await this.waitForFullIndexAnalysis(); + await runAnalysis(); + } +} + +export function createFullIndexAnalysisCoordinator( + dependencies: FullIndexAnalysisLogger, +): FullIndexAnalysisCoordinator { + return new FullIndexAnalysisCoordinatorState(dependencies); +} + +export function canReplayStaleCache(source: ReplayableCacheSource): boolean { + return source._analyzer?.getIndexStatus?.().freshness === 'stale' + && typeof source._analyzer.loadCachedGraph === 'function'; +} diff --git a/packages/extension/src/extension/graphView/provider/analysis/methods.ts b/packages/extension/src/extension/graphView/provider/analysis/methods.ts index 980ea2a3c..73a1e12d0 100644 --- a/packages/extension/src/extension/graphView/provider/analysis/methods.ts +++ b/packages/extension/src/extension/graphView/provider/analysis/methods.ts @@ -21,6 +21,10 @@ import { } from './state'; import { createGraphViewProviderDoAnalyzeAndSendData } from './execution'; import { createGraphViewProviderAnalyzeAndSendData } from './request'; +import { + canReplayStaleCache, + createFullIndexAnalysisCoordinator, +} from './fullIndex'; interface GraphViewProviderWorkspaceReadyRegistryLike { notifyWorkspaceReady( @@ -134,125 +138,6 @@ export function createDefaultGraphViewProviderAnalysisMethodDependencies(): Grap }; } -interface FullIndexAnalysisCoordinator { - runAfterFullIndexAnalysis(runAnalysis: () => Promise): Promise; - runFullIndexAnalysis(runAnalysis: () => Promise): Promise; - runFullIndexAnalysisInBackground( - runAnalysis: () => Promise, - shouldStart?: () => boolean, - ): void; - waitForFullIndexAnalysis(): Promise; - waitForForegroundFullIndexAnalysis(): Promise; -} - -type FullIndexAnalysisKind = 'background' | 'foreground'; - -class FullIndexAnalysisCoordinatorState implements FullIndexAnalysisCoordinator { - private _fullIndexAnalysisPromise: Promise | undefined; - private _fullIndexAnalysisKind: FullIndexAnalysisKind | undefined; - private _scheduledBackgroundAnalysis: ReturnType | undefined; - - constructor( - private readonly _dependencies: Pick, - ) {} - - private _clearScheduledBackgroundAnalysis(): void { - if (this._scheduledBackgroundAnalysis === undefined) { - return; - } - - clearTimeout(this._scheduledBackgroundAnalysis); - this._scheduledBackgroundAnalysis = undefined; - } - - async waitForFullIndexAnalysis(): Promise { - if (!this._fullIndexAnalysisPromise) { - return false; - } - - try { - await this._fullIndexAnalysisPromise; - } catch { - // The request that owns the reindex reports the failure. Competing - // fire-and-forget webview loads should not create duplicate errors. - } - return true; - } - - async waitForForegroundFullIndexAnalysis(): Promise { - if (this._fullIndexAnalysisKind === 'background') { - return false; - } - - return this.waitForFullIndexAnalysis(); - } - - async runFullIndexAnalysis( - runAnalysis: () => Promise, - kind: FullIndexAnalysisKind = 'foreground', - ): Promise { - if (kind === 'foreground') { - this._clearScheduledBackgroundAnalysis(); - } - - if (this._fullIndexAnalysisPromise) { - await this._fullIndexAnalysisPromise; - return; - } - - const analysisPromise = runAnalysis(); - this._fullIndexAnalysisPromise = analysisPromise; - this._fullIndexAnalysisKind = kind; - try { - await analysisPromise; - } finally { - if (this._fullIndexAnalysisPromise === analysisPromise) { - this._fullIndexAnalysisPromise = undefined; - this._fullIndexAnalysisKind = undefined; - } - } - } - - runFullIndexAnalysisInBackground( - runAnalysis: () => Promise, - shouldStart: () => boolean = () => true, - ): void { - if (this._scheduledBackgroundAnalysis !== undefined || this._fullIndexAnalysisPromise) { - return; - } - - this._scheduledBackgroundAnalysis = setTimeout(() => { - this._scheduledBackgroundAnalysis = undefined; - if (!shouldStart()) { - return; - } - - void this.runFullIndexAnalysis(runAnalysis, 'background').catch(error => { - this._dependencies.logError('[CodeGraphy] Background cache sync failed:', error); - }); - }, 0); - } - - async runAfterFullIndexAnalysis( - runAnalysis: () => Promise, - ): Promise { - this._clearScheduledBackgroundAnalysis(); - await this.waitForFullIndexAnalysis(); - await runAnalysis(); - } -} - -function createFullIndexAnalysisCoordinator( - dependencies: Pick, -): FullIndexAnalysisCoordinator { - return new FullIndexAnalysisCoordinatorState(dependencies); -} - -function canReplayStaleCache(source: GraphViewProviderAnalysisMethodsSource): boolean { - return source._analyzer?.getIndexStatus?.().freshness === 'stale' - && typeof source._analyzer.loadCachedGraph === 'function'; -} - export function createGraphViewProviderAnalysisMethods( source: GraphViewProviderAnalysisMethodsSource, dependencies: GraphViewProviderAnalysisMethodDependencies = diff --git a/packages/extension/src/extension/pipeline/service/cachedGraphWarmup.ts b/packages/extension/src/extension/pipeline/service/cachedGraphWarmup.ts new file mode 100644 index 000000000..6c449bc3c --- /dev/null +++ b/packages/extension/src/extension/pipeline/service/cachedGraphWarmup.ts @@ -0,0 +1,186 @@ +import { + createWorkspacePluginAnalysisContext, + type IDiscoveredFile, + SYMBOLS_ANALYSIS_CACHE_TIER, + throwIfWorkspaceAnalysisAborted, +} from '@codegraphy-dev/core'; +import { createWorkspacePipelineAnalysisCacheTiers } from './cache/tiers'; + +interface CachedGraphWarmupRegistry { + analyzeFileResultForPlugins?: ( + absolutePath: string, + content: string, + workspaceRoot: string, + pluginIds: readonly string[], + analysisContext: ReturnType, + options: { disabledPlugins: Set }, + ) => Promise; + supportsFile?: (filePath: string) => boolean; +} + +interface CachedGraphWarmupDiscovery { + readContent(file: IDiscoveredFile): Promise; +} + +export interface CachedGraphAnalysisWarmupInput { + analysisContext: ReturnType; + disabledPluginSnapshot: Set; + file: IDiscoveredFile; + pluginIds: readonly string[]; + signal?: AbortSignal; + workspaceRoot: string; +} + +export interface CachedGraphAnalysisWarmupOptions { + disabledPlugins: Set; + files: readonly IDiscoveredFile[]; + getActiveAnalysisPluginIds( + disabledPluginSnapshot: Set, + ): readonly string[]; + nodeVisibility: Record; + registry: CachedGraphWarmupRegistry; + signal?: AbortSignal; + workspaceRoot: string; +} + +export function isWorkspaceAnalysisAbortError(error: unknown): boolean { + return error instanceof Error && error.name === 'AbortError'; +} + +export function isMissingFileError(error: unknown): boolean { + return error instanceof Error + && 'code' in error + && (error as { code?: unknown }).code === 'ENOENT'; +} + +const CACHED_GRAPH_ANALYSIS_WARMUP_IGNORED_SEGMENTS = new Set([ + '.codegraphy', + '.git', + '.stryker-tmp', + '.turbo', + '.worktrees', + 'coverage', + 'dist', + 'node_modules', + 'out', + 'reports', +]); + +function isCachedGraphAnalysisWarmupCandidate(file: IDiscoveredFile): boolean { + const segments = file.relativePath.replace(/\\/g, '/').split('/'); + return !segments.some(segment => CACHED_GRAPH_ANALYSIS_WARMUP_IGNORED_SEGMENTS.has(segment)); +} + +function selectMostRepresentedCachedGraphWarmupFile( + files: readonly IDiscoveredFile[], +): IDiscoveredFile | undefined { + const extensionStats = new Map(); + + for (const [index, file] of files.entries()) { + const extension = file.extension; + const stats = extensionStats.get(extension); + if (stats) { + stats.count += 1; + continue; + } + + extensionStats.set(extension, { + count: 1, + file, + firstIndex: index, + }); + } + + return [...extensionStats.values()] + .sort((left, right) => right.count - left.count || left.firstIndex - right.firstIndex)[0] + ?.file; +} + +function getSupportedCachedGraphAnalysisWarmupFiles( + registry: CachedGraphWarmupRegistry, + files: readonly IDiscoveredFile[], +): IDiscoveredFile[] { + return files.filter(file => + registry.supportsFile?.(file.absolutePath) + || registry.supportsFile?.(file.relativePath), + ); +} + +function selectCachedGraphAnalysisWarmupFile( + registry: CachedGraphWarmupRegistry, + files: readonly IDiscoveredFile[], +): IDiscoveredFile | undefined { + if (typeof registry.supportsFile !== 'function') { + return files[0]; + } + + const supportedFiles = getSupportedCachedGraphAnalysisWarmupFiles( + registry, + files.filter(isCachedGraphAnalysisWarmupCandidate), + ); + if (supportedFiles.length === 0) { + return getSupportedCachedGraphAnalysisWarmupFiles(registry, files)[0] ?? files[0]; + } + + return selectMostRepresentedCachedGraphWarmupFile(supportedFiles); +} + +export function createCachedGraphAnalysisWarmupInput( + options: CachedGraphAnalysisWarmupOptions, +): CachedGraphAnalysisWarmupInput | undefined { + if (typeof options.registry.analyzeFileResultForPlugins !== 'function') { + return undefined; + } + + const file = selectCachedGraphAnalysisWarmupFile(options.registry, options.files); + if (!file) { + return undefined; + } + + const disabledPluginSnapshot = new Set(options.disabledPlugins); + const pluginIds = options.getActiveAnalysisPluginIds(disabledPluginSnapshot); + const cacheTiers = createWorkspacePipelineAnalysisCacheTiers( + options.nodeVisibility, + pluginIds, + ); + + return { + analysisContext: createWorkspacePluginAnalysisContext(options.workspaceRoot, { + features: { + symbols: cacheTiers.active === undefined + || cacheTiers.active.includes(SYMBOLS_ANALYSIS_CACHE_TIER), + }, + }), + disabledPluginSnapshot, + file, + pluginIds, + signal: options.signal, + workspaceRoot: options.workspaceRoot, + }; +} + +export async function warmCachedGraphAnalysisFile( + input: CachedGraphAnalysisWarmupInput, + discovery: CachedGraphWarmupDiscovery, + registry: CachedGraphWarmupRegistry, +): Promise { + if (typeof registry.analyzeFileResultForPlugins !== 'function') { + return; + } + + throwIfWorkspaceAnalysisAborted(input.signal); + const content = await discovery.readContent(input.file); + throwIfWorkspaceAnalysisAborted(input.signal); + await registry.analyzeFileResultForPlugins( + input.file.absolutePath, + content, + input.workspaceRoot, + input.pluginIds, + input.analysisContext, + { disabledPlugins: input.disabledPluginSnapshot }, + ); +} diff --git a/packages/extension/src/extension/pipeline/service/discoveryFacade.ts b/packages/extension/src/extension/pipeline/service/discoveryFacade.ts index 40b197087..7db085aa5 100644 --- a/packages/extension/src/extension/pipeline/service/discoveryFacade.ts +++ b/packages/extension/src/extension/pipeline/service/discoveryFacade.ts @@ -1,21 +1,12 @@ import * as vscode from 'vscode'; import { - createWorkspacePluginAnalysisContext, type IDiscoveredFile, projectFileAnalysisConnections, - readCodeGraphyWorkspaceStatus, - SYMBOLS_ANALYSIS_CACHE_TIER, throwIfWorkspaceAnalysisAborted, } from '@codegraphy-dev/core'; import type { IProjectedConnection } from '../../../core/plugins/types/contracts'; import type { IGraphData } from '../../../shared/graph/contracts'; import type { IPluginFilterPatternGroup } from '../../../shared/protocol/extensionToWebview'; -import { - getWorkspacePipelinePluginFilterGroups, - getWorkspacePipelinePluginFilterPatterns, - initializeWorkspacePipeline, - syncWorkspacePipelinePlugins, -} from '../plugins/bootstrap'; import type { WorkspacePipelineSourceOwner } from '../analysisSource'; import { WorkspacePipelineInternalBase } from './base/internal'; import { @@ -29,130 +20,75 @@ import { } from './runtime/run'; import { createEmptyWorkspaceAnalysisCache } from '../cache'; import { createCachedWorkspaceDiscoveryState } from './cache/cachedDiscovery'; -import { createWorkspacePipelineAnalysisCacheTiers } from './cache/tiers'; +import { + createCachedGraphAnalysisWarmupInput, + isMissingFileError, + isWorkspaceAnalysisAbortError, + warmCachedGraphAnalysisFile, +} from './cachedGraphWarmup'; +import { getWorkspacePipelineIndexStatus } from './indexStatus'; +import { + getEffectiveCustomFilterPatterns, + getEffectivePluginFilterPatterns, + getPipelinePluginFilterGroups, + getPipelinePluginFilterPatterns, + initializeWorkspacePipelinePlugins, + queueWorkspacePipelinePluginReload, + queueWorkspacePipelinePluginSync, +} from './pluginState'; export interface WorkspacePipelineCachedGraphLoadOptions { includeCurrentGitignoreMetadata?: boolean; warmAnalysis?: boolean; } -interface CachedGraphAnalysisWarmupInput { - analysisContext: ReturnType; - disabledPluginSnapshot: Set; - file: IDiscoveredFile; - pluginIds: readonly string[]; - signal?: AbortSignal; - workspaceRoot: string; -} - -function isWorkspaceAnalysisAbortError(error: unknown): boolean { - return error instanceof Error && error.name === 'AbortError'; -} - -function isMissingFileError(error: unknown): boolean { - return error instanceof Error - && 'code' in error - && (error as { code?: unknown }).code === 'ENOENT'; -} - -const CACHED_GRAPH_ANALYSIS_WARMUP_IGNORED_SEGMENTS = new Set([ - '.codegraphy', - '.git', - '.stryker-tmp', - '.turbo', - '.worktrees', - 'coverage', - 'dist', - 'node_modules', - 'out', - 'reports', -]); - -function isCachedGraphAnalysisWarmupCandidate(file: IDiscoveredFile): boolean { - const segments = file.relativePath.replace(/\\/g, '/').split('/'); - return !segments.some(segment => CACHED_GRAPH_ANALYSIS_WARMUP_IGNORED_SEGMENTS.has(segment)); -} - -function selectMostRepresentedCachedGraphWarmupFile( - files: readonly IDiscoveredFile[], -): IDiscoveredFile | undefined { - const extensionStats = new Map(); - - for (const [index, file] of files.entries()) { - const extension = file.extension; - const stats = extensionStats.get(extension); - if (stats) { - stats.count += 1; - continue; - } - - extensionStats.set(extension, { - count: 1, - file, - firstIndex: index, - }); - } - - return [...extensionStats.values()] - .sort((left, right) => right.count - left.count || left.firstIndex - right.firstIndex)[0] - ?.file; -} - export abstract class WorkspacePipelineDiscoveryFacade extends WorkspacePipelineInternalBase { private _workspacePluginReloadQueue: Promise = Promise.resolve(); async initialize(): Promise { - await initializeWorkspacePipeline(this._registry, { - getWorkspaceRoot: () => this._getWorkspaceRoot(), - }); + await initializeWorkspacePipelinePlugins(this._registry, () => this._getWorkspaceRoot()); console.log('[CodeGraphy] WorkspacePipeline initialized'); } async reloadWorkspacePlugins(): Promise { - const reload = this._workspacePluginReloadQueue.then(async () => { - this._registry.disposeAll(); - await this.initialize(); - }); - this._workspacePluginReloadQueue = reload.catch(() => undefined); + const { reload, nextQueue } = queueWorkspacePipelinePluginReload( + this._workspacePluginReloadQueue, + this._registry, + () => this.initialize(), + ); + this._workspacePluginReloadQueue = nextQueue; return reload; } async syncWorkspacePlugins(): Promise { - const sync = this._workspacePluginReloadQueue.then(async () => { - await syncWorkspacePipelinePlugins(this._registry, { - getWorkspaceRoot: () => this._getWorkspaceRoot(), - }); - }); - this._workspacePluginReloadQueue = sync.catch(() => undefined); + const { sync, nextQueue } = queueWorkspacePipelinePluginSync( + this._workspacePluginReloadQueue, + this._registry, + () => this._getWorkspaceRoot(), + ); + this._workspacePluginReloadQueue = nextQueue; return sync; } getPluginFilterPatterns( disabledPlugins: ReadonlySet = new Set(), ): string[] { - return getWorkspacePipelinePluginFilterPatterns(this._registry, disabledPlugins); + return getPipelinePluginFilterPatterns(this._registry, disabledPlugins); } getPluginFilterGroups( disabledPlugins: ReadonlySet = new Set(), ): IPluginFilterPatternGroup[] { - return getWorkspacePipelinePluginFilterGroups(this._registry, disabledPlugins); + return getPipelinePluginFilterGroups(this._registry, disabledPlugins); } private _getEffectiveCustomFilterPatterns(filterPatterns: string[]): string[] { - const disabledPatterns = new Set(this._config.disabledCustomFilterPatterns); - return filterPatterns.filter(pattern => !disabledPatterns.has(pattern)); + return getEffectiveCustomFilterPatterns(this._config, filterPatterns); } private _getEffectivePluginFilterPatterns(disabledPlugins: ReadonlySet): string[] { - const disabledPatterns = new Set(this._config.disabledPluginFilterPatterns); - return this.getPluginFilterPatterns(disabledPlugins) - .filter(pattern => !disabledPatterns.has(pattern)); + return getEffectivePluginFilterPatterns(this._registry, this._config, disabledPlugins); } hasIndex(): boolean { @@ -160,30 +96,12 @@ export abstract class WorkspacePipelineDiscoveryFacade extends WorkspacePipeline } getIndexStatus(): { freshness: 'fresh' | 'stale' | 'missing'; detail: string } { - const workspaceRoot = this._getWorkspaceRoot(); - if (!workspaceRoot) { - return { - freshness: 'missing', - detail: 'CodeGraphy index is missing. Index the workspace to build the graph.', - }; - } - - if (!this.hasIndex()) { - return { - freshness: 'missing', - detail: 'CodeGraphy index is missing. Index the workspace to build the graph.', - }; - } - - const status = readCodeGraphyWorkspaceStatus(workspaceRoot, { + return getWorkspacePipelineIndexStatus({ + hasIndex: () => this.hasIndex(), pluginSignature: this._getPluginSignature(), settingsSignature: this._getSettingsSignature(), + workspaceRoot: this._getWorkspaceRoot(), }); - - return { - freshness: status.state, - detail: status.detail, - }; } async discoverGraph( @@ -308,49 +226,27 @@ export abstract class WorkspacePipelineDiscoveryFacade extends WorkspacePipeline return graphData; } - private _selectCachedGraphAnalysisWarmupFile( - files: readonly IDiscoveredFile[], - ): IDiscoveredFile | undefined { - if (typeof this._registry.supportsFile !== 'function') { - return files[0]; - } - - const supportedFiles = this._getSupportedCachedGraphAnalysisWarmupFiles( - files.filter(isCachedGraphAnalysisWarmupCandidate), - ); - if (supportedFiles.length === 0) { - return this._getSupportedCachedGraphAnalysisWarmupFiles(files)[0] ?? files[0]; - } - - return selectMostRepresentedCachedGraphWarmupFile(supportedFiles); - } - - private _getSupportedCachedGraphAnalysisWarmupFiles( - files: readonly IDiscoveredFile[], - ): IDiscoveredFile[] { - return files.filter(file => - this._registry.supportsFile?.(file.absolutePath) - || this._registry.supportsFile?.(file.relativePath), - ); - } - private _scheduleCachedGraphAnalysisWarmup( files: readonly IDiscoveredFile[], workspaceRoot: string, disabledPlugins: Set, signal?: AbortSignal, ): void { - const input = this._createCachedGraphAnalysisWarmupInput( - files, - workspaceRoot, + const input = createCachedGraphAnalysisWarmupInput({ disabledPlugins, + files, + getActiveAnalysisPluginIds: disabledPluginSnapshot => + this._getActiveAnalysisPluginIds(undefined, disabledPluginSnapshot), + nodeVisibility: this._config.get>('nodeVisibility', {}) ?? {}, + registry: this._registry, signal, - ); + workspaceRoot, + }); if (!input) { return; } - void this._warmCachedGraphAnalysisFile(input).catch(error => { + void warmCachedGraphAnalysisFile(input, this._discovery, this._registry).catch(error => { const status = isWorkspaceAnalysisAbortError(error) ? 'aborted' : isMissingFileError(error) @@ -363,57 +259,6 @@ export abstract class WorkspacePipelineDiscoveryFacade extends WorkspacePipeline }); } - private _createCachedGraphAnalysisWarmupInput( - files: readonly IDiscoveredFile[], - workspaceRoot: string, - disabledPlugins: Set, - signal?: AbortSignal, - ): CachedGraphAnalysisWarmupInput | undefined { - if (typeof this._registry.analyzeFileResultForPlugins !== 'function') { - return undefined; - } - - const file = this._selectCachedGraphAnalysisWarmupFile(files); - if (!file) { - return undefined; - } - - const disabledPluginSnapshot = new Set(disabledPlugins); - const pluginIds = this._getActiveAnalysisPluginIds(undefined, disabledPluginSnapshot); - const cacheTiers = createWorkspacePipelineAnalysisCacheTiers( - this._config.get>('nodeVisibility', {}) ?? {}, - pluginIds, - ); - - return { - analysisContext: createWorkspacePluginAnalysisContext(workspaceRoot, { - features: { - symbols: cacheTiers.active === undefined - || cacheTiers.active.includes(SYMBOLS_ANALYSIS_CACHE_TIER), - }, - }), - disabledPluginSnapshot, - file, - pluginIds, - signal, - workspaceRoot, - }; - } - - private async _warmCachedGraphAnalysisFile(input: CachedGraphAnalysisWarmupInput): Promise { - throwIfWorkspaceAnalysisAborted(input.signal); - const content = await this._discovery.readContent(input.file); - throwIfWorkspaceAnalysisAborted(input.signal); - await this._registry.analyzeFileResultForPlugins( - input.file.absolutePath, - content, - input.workspaceRoot, - input.pluginIds, - input.analysisContext, - { disabledPlugins: input.disabledPluginSnapshot }, - ); - } - rebuildGraph(disabledPlugins: Set, showOrphans: boolean): IGraphData { return rebuildWorkspacePipelineGraph( this as unknown as WorkspacePipelineSourceOwner, diff --git a/packages/extension/src/extension/pipeline/service/indexStatus.ts b/packages/extension/src/extension/pipeline/service/indexStatus.ts new file mode 100644 index 000000000..2211c6038 --- /dev/null +++ b/packages/extension/src/extension/pipeline/service/indexStatus.ts @@ -0,0 +1,36 @@ +import { readCodeGraphyWorkspaceStatus } from '@codegraphy-dev/core'; + +export interface WorkspacePipelineIndexStatusInput { + hasIndex(): boolean; + pluginSignature: string | null; + settingsSignature: string; + workspaceRoot: string | undefined; +} + +export function getWorkspacePipelineIndexStatus( + input: WorkspacePipelineIndexStatusInput, +): { freshness: 'fresh' | 'stale' | 'missing'; detail: string } { + if (!input.workspaceRoot) { + return { + freshness: 'missing', + detail: 'CodeGraphy index is missing. Index the workspace to build the graph.', + }; + } + + if (!input.hasIndex()) { + return { + freshness: 'missing', + detail: 'CodeGraphy index is missing. Index the workspace to build the graph.', + }; + } + + const status = readCodeGraphyWorkspaceStatus(input.workspaceRoot, { + pluginSignature: input.pluginSignature, + settingsSignature: input.settingsSignature, + }); + + return { + freshness: status.state, + detail: status.detail, + }; +} diff --git a/packages/extension/src/extension/pipeline/service/pluginState.ts b/packages/extension/src/extension/pipeline/service/pluginState.ts new file mode 100644 index 000000000..7c2d9f3b4 --- /dev/null +++ b/packages/extension/src/extension/pipeline/service/pluginState.ts @@ -0,0 +1,86 @@ +import type { IPluginFilterPatternGroup } from '../../../shared/protocol/extensionToWebview'; +import { + getWorkspacePipelinePluginFilterGroups, + getWorkspacePipelinePluginFilterPatterns, + initializeWorkspacePipeline, + syncWorkspacePipelinePlugins, +} from '../plugins/bootstrap'; + +type WorkspacePipelinePluginRegistry = Parameters[0] & { + disposeAll(): void; +}; + +interface WorkspacePipelinePluginFilterConfig { + disabledCustomFilterPatterns: readonly string[]; + disabledPluginFilterPatterns: readonly string[]; +} + +export async function initializeWorkspacePipelinePlugins( + registry: WorkspacePipelinePluginRegistry, + getWorkspaceRoot: () => string | undefined, +): Promise { + await initializeWorkspacePipeline(registry, { getWorkspaceRoot }); +} + +export function queueWorkspacePipelinePluginReload( + queue: Promise, + registry: WorkspacePipelinePluginRegistry, + initialize: () => Promise, +): { nextQueue: Promise; reload: Promise } { + const reload = queue.then(async () => { + registry.disposeAll(); + await initialize(); + }); + + return { + nextQueue: reload.catch(() => undefined), + reload, + }; +} + +export function queueWorkspacePipelinePluginSync( + queue: Promise, + registry: WorkspacePipelinePluginRegistry, + getWorkspaceRoot: () => string | undefined, +): { nextQueue: Promise; sync: Promise } { + const sync = queue.then(async () => { + await syncWorkspacePipelinePlugins(registry, { getWorkspaceRoot }); + }); + + return { + nextQueue: sync.catch(() => undefined), + sync, + }; +} + +export function getPipelinePluginFilterPatterns( + registry: WorkspacePipelinePluginRegistry, + disabledPlugins: ReadonlySet = new Set(), +): string[] { + return getWorkspacePipelinePluginFilterPatterns(registry, disabledPlugins); +} + +export function getPipelinePluginFilterGroups( + registry: WorkspacePipelinePluginRegistry, + disabledPlugins: ReadonlySet = new Set(), +): IPluginFilterPatternGroup[] { + return getWorkspacePipelinePluginFilterGroups(registry, disabledPlugins); +} + +export function getEffectiveCustomFilterPatterns( + config: WorkspacePipelinePluginFilterConfig, + filterPatterns: string[], +): string[] { + const disabledPatterns = new Set(config.disabledCustomFilterPatterns); + return filterPatterns.filter(pattern => !disabledPatterns.has(pattern)); +} + +export function getEffectivePluginFilterPatterns( + registry: WorkspacePipelinePluginRegistry, + config: WorkspacePipelinePluginFilterConfig, + disabledPlugins: ReadonlySet, +): string[] { + const disabledPatterns = new Set(config.disabledPluginFilterPatterns); + return getPipelinePluginFilterPatterns(registry, disabledPlugins) + .filter(pattern => !disabledPatterns.has(pattern)); +} diff --git a/packages/extension/src/webview/store/messageHandlers/graph.ts b/packages/extension/src/webview/store/messageHandlers/graph.ts index 04a065008..dc8e13d6b 100644 --- a/packages/extension/src/webview/store/messageHandlers/graph.ts +++ b/packages/extension/src/webview/store/messageHandlers/graph.ts @@ -1,241 +1,13 @@ import type { IHandlerContext, PartialState } from '../messageTypes'; import type { ExtensionToWebviewMessage } from '../../../shared/protocol/extensionToWebview'; -import type { IGraphData } from '../../../shared/graph/contracts'; -import type { NodeSizeMode } from '../../../shared/settings/modes'; import { applyPendingGroupUpdates, applyPendingUserGroupsUpdate, } from '../optimistic/groups/updates'; import { arePlainValuesEqual } from './equality/compare'; -type GraphNodeMetricsUpdateMessage = Extract; -type GraphNodeMetricsUpdate = GraphNodeMetricsUpdateMessage['payload']['nodes'][number]; - -function areGraphDataPayloadsEqual(left: IGraphData, right: IGraphData): boolean { - if (left.nodes.length !== right.nodes.length || left.edges.length !== right.edges.length) { - return false; - } - - try { - return JSON.stringify(left) === JSON.stringify(right); - } catch { - return false; - } -} - -function shouldSkipDuplicateGraphData( - state: ReturnType>, - payload: IGraphData, -): boolean { - if (!state.graphData || state.graphIsIndexing || !areGraphDataPayloadsEqual(state.graphData, payload)) { - return false; - } - - return ( - ( - state.bootstrapComplete - && !state.awaitingInitialBootstrap - && !state.isLoading - ) - || ( - state.awaitingInitialBootstrap - && !state.bootstrapComplete - ) - ); -} - -function nodeSizeModeUsesNodeMetrics(mode: NodeSizeMode): boolean { - return mode === 'file-size' || mode === 'churn'; -} - -function nodeMetricsDiffer( - node: IGraphData['nodes'][number], - update: GraphNodeMetricsUpdate, -): boolean { - return node.fileSize !== update.fileSize || node.churn !== update.churn; -} - -function applyMetricUpdatesInPlace( - graphData: IGraphData, - updatesById: ReadonlyMap, -): boolean { - let changed = false; - - for (const node of graphData.nodes) { - const update = updatesById.get(node.id); - if (!update || !nodeMetricsDiffer(node, update)) { - continue; - } - - node.fileSize = update.fileSize; - node.churn = update.churn; - changed = true; - } - - return changed; -} - -export function handleGraphDataUpdated( - message: Extract, - ctx?: Pick, -): PartialState | void { - const state = ctx?.getState(); - if (state && shouldSkipDuplicateGraphData(state, message.payload)) { - return undefined; - } - - const waitingForInitialBootstrap = Boolean( - state?.awaitingInitialBootstrap - && !state.bootstrapComplete, - ); - const initialBootstrapFinished = Boolean( - state?.awaitingInitialBootstrap - && state.bootstrapComplete - ); - - return { - graphData: message.payload, - ...(initialBootstrapFinished ? { awaitingInitialBootstrap: false } : {}), - isLoading: waitingForInitialBootstrap, - graphIsIndexing: false, - graphIndexProgress: null, - }; -} - -export function handleGraphNodeMetricsUpdated( - message: GraphNodeMetricsUpdateMessage, - ctx?: Pick, -): PartialState | void { - const state = ctx?.getState(); - if (!state?.graphData) { - return undefined; - } - - const updatesById = new Map(message.payload.nodes.map(node => [node.id, node])); - const waitingForInitialBootstrap = Boolean( - state.awaitingInitialBootstrap - && !state.bootstrapComplete, - ); - - if (!nodeSizeModeUsesNodeMetrics(state.nodeSizeMode)) { - // Metrics do not affect the current visual graph, so keep graphData referentially stable. - applyMetricUpdatesInPlace(state.graphData, updatesById); - - return { - isLoading: waitingForInitialBootstrap, - graphIsIndexing: false, - graphIndexProgress: null, - }; - } - - let changed = false; - const nodes = state.graphData.nodes.map((node) => { - const update = updatesById.get(node.id); - if (!update || !nodeMetricsDiffer(node, update)) { - return node; - } - - changed = true; - return { - ...node, - fileSize: update.fileSize, - churn: update.churn, - }; - }); - - if (!changed) { - return { - graphIsIndexing: false, - graphIndexProgress: null, - }; - } - - return { - graphData: { - ...state.graphData, - nodes, - }, - isLoading: waitingForInitialBootstrap, - graphIsIndexing: false, - graphIndexProgress: null, - }; -} - -export function handleAppBootstrapComplete( - _message: Extract, - ctx: Pick, -): PartialState { - const state = ctx.getState(); - const graphReady = state.graphData !== null; - - return { - bootstrapComplete: true, - awaitingInitialBootstrap: graphReady ? false : state.awaitingInitialBootstrap, - isLoading: graphReady ? false : state.isLoading, - }; -} - -export function handleGraphIndexStatusUpdated( - message: Extract, -): PartialState { - const indexIsReady = message.payload.hasIndex && message.payload.freshness === 'fresh'; - - return { - graphHasIndex: message.payload.hasIndex, - graphIndexFreshness: message.payload.freshness, - graphIndexDetail: message.payload.detail, - ...(indexIsReady ? { - graphIsIndexing: false, - graphIndexProgress: null, - } : {}), - }; -} - -export function handleGraphIndexProgress( - message: Extract, -): PartialState { - return { - graphIsIndexing: true, - graphIndexProgress: message.payload, - }; -} - -function assignChangedGraphControl( - next: PartialState, - key: K, - currentValue: PartialState[K], - nextValue: PartialState[K], -): void { - if (!arePlainValuesEqual(currentValue, nextValue)) { - next[key] = nextValue; - } -} - -export function handleGraphControlsUpdated( - message: Extract, - ctx?: Pick, -): PartialState | void { - const state = ctx?.getState(); - if (!state) { - return { - graphNodeTypes: message.payload.nodeTypes, - graphEdgeTypes: message.payload.edgeTypes, - nodeColors: message.payload.nodeColors, - nodeVisibility: message.payload.nodeVisibility, - edgeVisibility: message.payload.edgeVisibility, - }; - } - - const next: PartialState = {}; - - assignChangedGraphControl(next, 'graphNodeTypes', state.graphNodeTypes, message.payload.nodeTypes); - assignChangedGraphControl(next, 'graphEdgeTypes', state.graphEdgeTypes, message.payload.edgeTypes); - assignChangedGraphControl(next, 'nodeColors', state.nodeColors, message.payload.nodeColors); - assignChangedGraphControl(next, 'nodeVisibility', state.nodeVisibility, message.payload.nodeVisibility); - assignChangedGraphControl(next, 'edgeVisibility', state.edgeVisibility, message.payload.edgeVisibility); - - return Object.keys(next).length > 0 ? next : undefined; -} +export * from './graphControls'; +export * from './graphData'; export function handleFavoritesUpdated( message: Extract, @@ -268,15 +40,6 @@ function areSetsEqual(left: ReadonlySet, right: ReadonlySet): bo return true; } -export function handleSettingsUpdated( - message: Extract, -): PartialState { - return { - bidirectionalMode: message.payload.bidirectionalEdges, - showOrphans: message.payload.showOrphans, - }; -} - export function handleLegendsUpdated( message: Extract, ctx: IHandlerContext, @@ -317,62 +80,3 @@ export function handleFilterPatternsUpdated( disabledPluginFilterPatterns: message.payload.disabledPluginPatterns, }; } - -export function handleDepthModeUpdated( - message: Extract, -): PartialState { - return { depthMode: message.payload.depthMode }; -} - -export function handlePhysicsSettingsUpdated( - message: Extract, -): PartialState { - return { physicsSettings: message.payload }; -} - -export function handleDepthLimitUpdated( - message: Extract, -): PartialState { - return { depthLimit: message.payload.depthLimit }; -} - -export function handleDepthLimitRangeUpdated( - message: Extract, -): PartialState { - return { maxDepthLimit: message.payload.maxDepthLimit }; -} - -export function handleDirectionSettingsUpdated( - message: Extract, -): PartialState { - return { - directionMode: message.payload.directionMode, - directionColor: message.payload.directionColor, - particleSpeed: message.payload.particleSpeed, - particleSize: message.payload.particleSize, - }; -} - -export function handleShowLabelsUpdated( - message: Extract, -): PartialState { - return { showLabels: message.payload.showLabels }; -} - -export function handleMaxFilesUpdated( - message: Extract, -): PartialState { - return { maxFiles: message.payload.maxFiles }; -} - -export function handleVerboseDiagnosticsUpdated( - message: Extract, -): PartialState { - return { verboseDiagnostics: message.payload.verboseDiagnostics }; -} - -export function handleActiveFileUpdated( - message: Extract, -): PartialState { - return { activeFilePath: message.payload.filePath ?? null }; -} diff --git a/packages/extension/src/webview/store/messageHandlers/graphControls.ts b/packages/extension/src/webview/store/messageHandlers/graphControls.ts new file mode 100644 index 000000000..9abc9b1eb --- /dev/null +++ b/packages/extension/src/webview/store/messageHandlers/graphControls.ts @@ -0,0 +1,133 @@ +import type { ExtensionToWebviewMessage } from '../../../shared/protocol/extensionToWebview'; +import type { IHandlerContext, PartialState } from '../messageTypes'; +import { arePlainValuesEqual } from './equality/compare'; + +export function handleGraphIndexStatusUpdated( + message: Extract, +): PartialState { + const indexIsReady = message.payload.hasIndex && message.payload.freshness === 'fresh'; + + return { + graphHasIndex: message.payload.hasIndex, + graphIndexFreshness: message.payload.freshness, + graphIndexDetail: message.payload.detail, + ...(indexIsReady ? { + graphIsIndexing: false, + graphIndexProgress: null, + } : {}), + }; +} + +export function handleGraphIndexProgress( + message: Extract, +): PartialState { + return { + graphIsIndexing: true, + graphIndexProgress: message.payload, + }; +} + +function assignChangedGraphControl( + next: PartialState, + key: K, + currentValue: PartialState[K], + nextValue: PartialState[K], +): void { + if (!arePlainValuesEqual(currentValue, nextValue)) { + next[key] = nextValue; + } +} + +export function handleGraphControlsUpdated( + message: Extract, + ctx?: Pick, +): PartialState | void { + const state = ctx?.getState(); + if (!state) { + return { + graphNodeTypes: message.payload.nodeTypes, + graphEdgeTypes: message.payload.edgeTypes, + nodeColors: message.payload.nodeColors, + nodeVisibility: message.payload.nodeVisibility, + edgeVisibility: message.payload.edgeVisibility, + }; + } + + const next: PartialState = {}; + + assignChangedGraphControl(next, 'graphNodeTypes', state.graphNodeTypes, message.payload.nodeTypes); + assignChangedGraphControl(next, 'graphEdgeTypes', state.graphEdgeTypes, message.payload.edgeTypes); + assignChangedGraphControl(next, 'nodeColors', state.nodeColors, message.payload.nodeColors); + assignChangedGraphControl(next, 'nodeVisibility', state.nodeVisibility, message.payload.nodeVisibility); + assignChangedGraphControl(next, 'edgeVisibility', state.edgeVisibility, message.payload.edgeVisibility); + + return Object.keys(next).length > 0 ? next : undefined; +} + +export function handleSettingsUpdated( + message: Extract, +): PartialState { + return { + bidirectionalMode: message.payload.bidirectionalEdges, + showOrphans: message.payload.showOrphans, + }; +} + +export function handleDepthModeUpdated( + message: Extract, +): PartialState { + return { depthMode: message.payload.depthMode }; +} + +export function handlePhysicsSettingsUpdated( + message: Extract, +): PartialState { + return { physicsSettings: message.payload }; +} + +export function handleDepthLimitUpdated( + message: Extract, +): PartialState { + return { depthLimit: message.payload.depthLimit }; +} + +export function handleDepthLimitRangeUpdated( + message: Extract, +): PartialState { + return { maxDepthLimit: message.payload.maxDepthLimit }; +} + +export function handleDirectionSettingsUpdated( + message: Extract, +): PartialState { + return { + directionMode: message.payload.directionMode, + directionColor: message.payload.directionColor, + particleSpeed: message.payload.particleSpeed, + particleSize: message.payload.particleSize, + }; +} + +export function handleShowLabelsUpdated( + message: Extract, +): PartialState { + return { showLabels: message.payload.showLabels }; +} + +export function handleMaxFilesUpdated( + message: Extract, +): PartialState { + return { maxFiles: message.payload.maxFiles }; +} + +export function handleVerboseDiagnosticsUpdated( + message: Extract, +): PartialState { + return { verboseDiagnostics: message.payload.verboseDiagnostics }; +} + +export function handleActiveFileUpdated( + message: Extract, +): PartialState { + return { activeFilePath: message.payload.filePath ?? null }; +} diff --git a/packages/extension/src/webview/store/messageHandlers/graphData.ts b/packages/extension/src/webview/store/messageHandlers/graphData.ts new file mode 100644 index 000000000..5ae48e555 --- /dev/null +++ b/packages/extension/src/webview/store/messageHandlers/graphData.ts @@ -0,0 +1,171 @@ +import type { IGraphData } from '../../../shared/graph/contracts'; +import type { ExtensionToWebviewMessage } from '../../../shared/protocol/extensionToWebview'; +import type { NodeSizeMode } from '../../../shared/settings/modes'; +import type { IHandlerContext, PartialState } from '../messageTypes'; + +type GraphNodeMetricsUpdateMessage = Extract; +type GraphNodeMetricsUpdate = GraphNodeMetricsUpdateMessage['payload']['nodes'][number]; + +function areGraphDataPayloadsEqual(left: IGraphData, right: IGraphData): boolean { + if (left.nodes.length !== right.nodes.length || left.edges.length !== right.edges.length) { + return false; + } + + try { + return JSON.stringify(left) === JSON.stringify(right); + } catch { + return false; + } +} + +function shouldSkipDuplicateGraphData( + state: ReturnType>, + payload: IGraphData, +): boolean { + if (!state.graphData || state.graphIsIndexing || !areGraphDataPayloadsEqual(state.graphData, payload)) { + return false; + } + + return ( + ( + state.bootstrapComplete + && !state.awaitingInitialBootstrap + && !state.isLoading + ) + || ( + state.awaitingInitialBootstrap + && !state.bootstrapComplete + ) + ); +} + +function nodeSizeModeUsesNodeMetrics(mode: NodeSizeMode): boolean { + return mode === 'file-size' || mode === 'churn'; +} + +function nodeMetricsDiffer( + node: IGraphData['nodes'][number], + update: GraphNodeMetricsUpdate, +): boolean { + return node.fileSize !== update.fileSize || node.churn !== update.churn; +} + +function applyMetricUpdatesInPlace( + graphData: IGraphData, + updatesById: ReadonlyMap, +): boolean { + let changed = false; + + for (const node of graphData.nodes) { + const update = updatesById.get(node.id); + if (!update || !nodeMetricsDiffer(node, update)) { + continue; + } + + node.fileSize = update.fileSize; + node.churn = update.churn; + changed = true; + } + + return changed; +} + +export function handleGraphDataUpdated( + message: Extract, + ctx?: Pick, +): PartialState | void { + const state = ctx?.getState(); + if (state && shouldSkipDuplicateGraphData(state, message.payload)) { + return undefined; + } + + const waitingForInitialBootstrap = Boolean( + state?.awaitingInitialBootstrap + && !state.bootstrapComplete, + ); + const initialBootstrapFinished = Boolean( + state?.awaitingInitialBootstrap + && state.bootstrapComplete + ); + + return { + graphData: message.payload, + ...(initialBootstrapFinished ? { awaitingInitialBootstrap: false } : {}), + isLoading: waitingForInitialBootstrap, + graphIsIndexing: false, + graphIndexProgress: null, + }; +} + +export function handleGraphNodeMetricsUpdated( + message: GraphNodeMetricsUpdateMessage, + ctx?: Pick, +): PartialState | void { + const state = ctx?.getState(); + if (!state?.graphData) { + return undefined; + } + + const updatesById = new Map(message.payload.nodes.map(node => [node.id, node])); + const waitingForInitialBootstrap = Boolean( + state.awaitingInitialBootstrap + && !state.bootstrapComplete, + ); + + if (!nodeSizeModeUsesNodeMetrics(state.nodeSizeMode)) { + // Metrics do not affect the current visual graph, so keep graphData referentially stable. + applyMetricUpdatesInPlace(state.graphData, updatesById); + + return { + isLoading: waitingForInitialBootstrap, + graphIsIndexing: false, + graphIndexProgress: null, + }; + } + + let changed = false; + const nodes = state.graphData.nodes.map((node) => { + const update = updatesById.get(node.id); + if (!update || !nodeMetricsDiffer(node, update)) { + return node; + } + + changed = true; + return { + ...node, + fileSize: update.fileSize, + churn: update.churn, + }; + }); + + if (!changed) { + return { + graphIsIndexing: false, + graphIndexProgress: null, + }; + } + + return { + graphData: { + ...state.graphData, + nodes, + }, + isLoading: waitingForInitialBootstrap, + graphIsIndexing: false, + graphIndexProgress: null, + }; +} + +export function handleAppBootstrapComplete( + _message: Extract, + ctx: Pick, +): PartialState { + const state = ctx.getState(); + const graphReady = state.graphData !== null; + + return { + bootstrapComplete: true, + awaitingInitialBootstrap: graphReady ? false : state.awaitingInitialBootstrap, + isLoading: graphReady ? false : state.isLoading, + }; +} From c94a84adcffe0b95b24f876d1f6af027f2790177 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 14:06:40 -0700 Subject: [PATCH 100/192] refactor: split pipeline mutation sites --- .../pipeline/service/analysisFacade.ts | 58 ++++ .../extension/pipeline/service/cachedGraph.ts | 116 +++++++ .../pipeline/service/cachedGraphWarmup.ts | 186 ----------- .../service/cachedGraphWarmup/candidates.ts | 19 ++ .../service/cachedGraphWarmup/contracts.ts | 41 +++ .../service/cachedGraphWarmup/errors.ts | 9 + .../service/cachedGraphWarmup/execution.ts | 28 ++ .../service/cachedGraphWarmup/input.ts | 44 +++ .../service/cachedGraphWarmup/ranking.ts | 30 ++ .../service/cachedGraphWarmup/selection.ts | 24 ++ .../service/cachedGraphWarmup/support.ts | 12 + .../pipeline/service/discoveryFacade.ts | 291 +----------------- .../pipeline/service/graphDiscovery.ts | 52 ++++ .../pipeline/service/pluginFacade.ts | 76 +++++ stryker.extension.config.cjs | 10 +- 15 files changed, 515 insertions(+), 481 deletions(-) create mode 100644 packages/extension/src/extension/pipeline/service/analysisFacade.ts create mode 100644 packages/extension/src/extension/pipeline/service/cachedGraph.ts delete mode 100644 packages/extension/src/extension/pipeline/service/cachedGraphWarmup.ts create mode 100644 packages/extension/src/extension/pipeline/service/cachedGraphWarmup/candidates.ts create mode 100644 packages/extension/src/extension/pipeline/service/cachedGraphWarmup/contracts.ts create mode 100644 packages/extension/src/extension/pipeline/service/cachedGraphWarmup/errors.ts create mode 100644 packages/extension/src/extension/pipeline/service/cachedGraphWarmup/execution.ts create mode 100644 packages/extension/src/extension/pipeline/service/cachedGraphWarmup/input.ts create mode 100644 packages/extension/src/extension/pipeline/service/cachedGraphWarmup/ranking.ts create mode 100644 packages/extension/src/extension/pipeline/service/cachedGraphWarmup/selection.ts create mode 100644 packages/extension/src/extension/pipeline/service/cachedGraphWarmup/support.ts create mode 100644 packages/extension/src/extension/pipeline/service/graphDiscovery.ts create mode 100644 packages/extension/src/extension/pipeline/service/pluginFacade.ts diff --git a/packages/extension/src/extension/pipeline/service/analysisFacade.ts b/packages/extension/src/extension/pipeline/service/analysisFacade.ts new file mode 100644 index 000000000..0d1385e9b --- /dev/null +++ b/packages/extension/src/extension/pipeline/service/analysisFacade.ts @@ -0,0 +1,58 @@ +import type { IGraphData } from '../../../shared/graph/contracts'; +import type { WorkspacePipelineSourceOwner } from '../analysisSource'; +import { createEmptyWorkspaceAnalysisCache } from '../cache'; +import { WorkspacePipelineGraphDiscoveryFacade } from './graphDiscovery'; +import { + analyzeWorkspacePipeline, + rebuildWorkspacePipelineGraph, +} from './runtime/run'; + +export abstract class WorkspacePipelineAnalysisFacade extends WorkspacePipelineGraphDiscoveryFacade { + async analyze( + filterPatterns: string[] = [], + disabledPlugins: Set = new Set(), + signal?: AbortSignal, + onProgress?: (progress: { phase: string; current: number; total: number }) => void, + ): Promise { + return analyzeWorkspacePipeline( + this as unknown as WorkspacePipelineSourceOwner, + this._cache, + this._config, + this._discovery, + () => this._getWorkspaceRoot(), + this._getEffectiveCustomFilterPatterns(filterPatterns), + disabledPlugins, + onProgress, + signal, + async () => this._persistIndexMetadata(), + ); + } + + rebuildGraph(disabledPlugins: Set, showOrphans: boolean): IGraphData { + return rebuildWorkspacePipelineGraph( + this as unknown as WorkspacePipelineSourceOwner, + disabledPlugins, + showOrphans, + ); + } + + protected resetCacheForIndexRefresh(): void { + this._cache = createEmptyWorkspaceAnalysisCache(); + console.log('[CodeGraphy] Cache cleared'); + } + + async refreshIndex( + filterPatterns: string[] = [], + disabledPlugins: Set = new Set(), + signal?: AbortSignal, + onProgress?: (progress: { phase: string; current: number; total: number }) => void, + ): Promise { + this.resetCacheForIndexRefresh(); + return this.analyze(filterPatterns, disabledPlugins, signal, progress => { + onProgress?.({ + ...progress, + phase: progress.phase || 'Refreshing Index', + }); + }); + } +} diff --git a/packages/extension/src/extension/pipeline/service/cachedGraph.ts b/packages/extension/src/extension/pipeline/service/cachedGraph.ts new file mode 100644 index 000000000..6c9a69bd4 --- /dev/null +++ b/packages/extension/src/extension/pipeline/service/cachedGraph.ts @@ -0,0 +1,116 @@ +import { + type IDiscoveredFile, + projectFileAnalysisConnections, + throwIfWorkspaceAnalysisAborted, +} from '@codegraphy-dev/core'; +import type { IGraphData } from '../../../shared/graph/contracts'; +import { createCachedWorkspaceDiscoveryState } from './cache/cachedDiscovery'; +import { + isMissingFileError, + isWorkspaceAnalysisAbortError, +} from './cachedGraphWarmup/errors'; +import { warmCachedGraphAnalysisFile } from './cachedGraphWarmup/execution'; +import { createCachedGraphAnalysisWarmupInput } from './cachedGraphWarmup/input'; +import { + WorkspacePipelineAnalysisFacade, +} from './analysisFacade'; + +export interface WorkspacePipelineCachedGraphLoadOptions { + includeCurrentGitignoreMetadata?: boolean; + warmAnalysis?: boolean; +} + +export abstract class WorkspacePipelineCachedGraphFacade extends WorkspacePipelineAnalysisFacade { + async loadCachedGraph( + _filterPatterns: string[] = [], + disabledPlugins: Set = new Set(), + signal?: AbortSignal, + options: WorkspacePipelineCachedGraphLoadOptions = {}, + ): Promise { + throwIfWorkspaceAnalysisAborted(signal); + await this._hydrateCacheFromGraphCache(); + throwIfWorkspaceAnalysisAborted(signal); + + const workspaceRoot = this._getWorkspaceRoot(); + if (!workspaceRoot) { + return { nodes: [], edges: [] }; + } + + const config = this._config.getAll(); + throwIfWorkspaceAnalysisAborted(signal); + + const fileAnalysis = new Map( + Object.entries(this._cache.files).map(([filePath, entry]) => [ + filePath, + entry.analysis, + ]), + ); + const cachedFilePaths = Object.keys(this._cache.files); + const includeCurrentGitignoreMetadata = options.includeCurrentGitignoreMetadata !== false; + const cachedDiscovery = createCachedWorkspaceDiscoveryState( + workspaceRoot, + cachedFilePaths, + config.respectGitignore && includeCurrentGitignoreMetadata, + ); + + this._lastDiscoveredFiles = cachedDiscovery.files; + this._lastDiscoveredDirectories = cachedDiscovery.directories; + this._lastGitIgnoredPaths = cachedDiscovery.gitIgnoredPaths; + this._lastFileAnalysis = fileAnalysis; + this._lastFileConnections = projectFileAnalysisConnections(fileAnalysis, workspaceRoot); + this._lastWorkspaceRoot = workspaceRoot; + + throwIfWorkspaceAnalysisAborted(signal); + + const graphData = this._buildGraphDataFromAnalysis( + fileAnalysis, + workspaceRoot, + config.showOrphans, + disabledPlugins, + ); + + if (options.warmAnalysis !== false) { + this._scheduleCachedGraphAnalysisWarmup( + cachedDiscovery.files, + workspaceRoot, + disabledPlugins, + signal, + ); + } + + return graphData; + } + + private _scheduleCachedGraphAnalysisWarmup( + files: readonly IDiscoveredFile[], + workspaceRoot: string, + disabledPlugins: Set, + signal?: AbortSignal, + ): void { + const input = createCachedGraphAnalysisWarmupInput({ + disabledPlugins, + files, + getActiveAnalysisPluginIds: disabledPluginSnapshot => + this._getActiveAnalysisPluginIds(undefined, disabledPluginSnapshot), + nodeVisibility: this._config.get>('nodeVisibility', {}) ?? {}, + registry: this._registry, + signal, + workspaceRoot, + }); + if (!input) { + return; + } + + void warmCachedGraphAnalysisFile(input, this._discovery, this._registry).catch(error => { + const status = isWorkspaceAnalysisAbortError(error) + ? 'aborted' + : isMissingFileError(error) + ? 'skipped' + : 'failed'; + + if (status === 'failed') { + console.warn('[CodeGraphy] Failed to warm cached graph analysis.', error); + } + }); + } +} diff --git a/packages/extension/src/extension/pipeline/service/cachedGraphWarmup.ts b/packages/extension/src/extension/pipeline/service/cachedGraphWarmup.ts deleted file mode 100644 index 6c449bc3c..000000000 --- a/packages/extension/src/extension/pipeline/service/cachedGraphWarmup.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { - createWorkspacePluginAnalysisContext, - type IDiscoveredFile, - SYMBOLS_ANALYSIS_CACHE_TIER, - throwIfWorkspaceAnalysisAborted, -} from '@codegraphy-dev/core'; -import { createWorkspacePipelineAnalysisCacheTiers } from './cache/tiers'; - -interface CachedGraphWarmupRegistry { - analyzeFileResultForPlugins?: ( - absolutePath: string, - content: string, - workspaceRoot: string, - pluginIds: readonly string[], - analysisContext: ReturnType, - options: { disabledPlugins: Set }, - ) => Promise; - supportsFile?: (filePath: string) => boolean; -} - -interface CachedGraphWarmupDiscovery { - readContent(file: IDiscoveredFile): Promise; -} - -export interface CachedGraphAnalysisWarmupInput { - analysisContext: ReturnType; - disabledPluginSnapshot: Set; - file: IDiscoveredFile; - pluginIds: readonly string[]; - signal?: AbortSignal; - workspaceRoot: string; -} - -export interface CachedGraphAnalysisWarmupOptions { - disabledPlugins: Set; - files: readonly IDiscoveredFile[]; - getActiveAnalysisPluginIds( - disabledPluginSnapshot: Set, - ): readonly string[]; - nodeVisibility: Record; - registry: CachedGraphWarmupRegistry; - signal?: AbortSignal; - workspaceRoot: string; -} - -export function isWorkspaceAnalysisAbortError(error: unknown): boolean { - return error instanceof Error && error.name === 'AbortError'; -} - -export function isMissingFileError(error: unknown): boolean { - return error instanceof Error - && 'code' in error - && (error as { code?: unknown }).code === 'ENOENT'; -} - -const CACHED_GRAPH_ANALYSIS_WARMUP_IGNORED_SEGMENTS = new Set([ - '.codegraphy', - '.git', - '.stryker-tmp', - '.turbo', - '.worktrees', - 'coverage', - 'dist', - 'node_modules', - 'out', - 'reports', -]); - -function isCachedGraphAnalysisWarmupCandidate(file: IDiscoveredFile): boolean { - const segments = file.relativePath.replace(/\\/g, '/').split('/'); - return !segments.some(segment => CACHED_GRAPH_ANALYSIS_WARMUP_IGNORED_SEGMENTS.has(segment)); -} - -function selectMostRepresentedCachedGraphWarmupFile( - files: readonly IDiscoveredFile[], -): IDiscoveredFile | undefined { - const extensionStats = new Map(); - - for (const [index, file] of files.entries()) { - const extension = file.extension; - const stats = extensionStats.get(extension); - if (stats) { - stats.count += 1; - continue; - } - - extensionStats.set(extension, { - count: 1, - file, - firstIndex: index, - }); - } - - return [...extensionStats.values()] - .sort((left, right) => right.count - left.count || left.firstIndex - right.firstIndex)[0] - ?.file; -} - -function getSupportedCachedGraphAnalysisWarmupFiles( - registry: CachedGraphWarmupRegistry, - files: readonly IDiscoveredFile[], -): IDiscoveredFile[] { - return files.filter(file => - registry.supportsFile?.(file.absolutePath) - || registry.supportsFile?.(file.relativePath), - ); -} - -function selectCachedGraphAnalysisWarmupFile( - registry: CachedGraphWarmupRegistry, - files: readonly IDiscoveredFile[], -): IDiscoveredFile | undefined { - if (typeof registry.supportsFile !== 'function') { - return files[0]; - } - - const supportedFiles = getSupportedCachedGraphAnalysisWarmupFiles( - registry, - files.filter(isCachedGraphAnalysisWarmupCandidate), - ); - if (supportedFiles.length === 0) { - return getSupportedCachedGraphAnalysisWarmupFiles(registry, files)[0] ?? files[0]; - } - - return selectMostRepresentedCachedGraphWarmupFile(supportedFiles); -} - -export function createCachedGraphAnalysisWarmupInput( - options: CachedGraphAnalysisWarmupOptions, -): CachedGraphAnalysisWarmupInput | undefined { - if (typeof options.registry.analyzeFileResultForPlugins !== 'function') { - return undefined; - } - - const file = selectCachedGraphAnalysisWarmupFile(options.registry, options.files); - if (!file) { - return undefined; - } - - const disabledPluginSnapshot = new Set(options.disabledPlugins); - const pluginIds = options.getActiveAnalysisPluginIds(disabledPluginSnapshot); - const cacheTiers = createWorkspacePipelineAnalysisCacheTiers( - options.nodeVisibility, - pluginIds, - ); - - return { - analysisContext: createWorkspacePluginAnalysisContext(options.workspaceRoot, { - features: { - symbols: cacheTiers.active === undefined - || cacheTiers.active.includes(SYMBOLS_ANALYSIS_CACHE_TIER), - }, - }), - disabledPluginSnapshot, - file, - pluginIds, - signal: options.signal, - workspaceRoot: options.workspaceRoot, - }; -} - -export async function warmCachedGraphAnalysisFile( - input: CachedGraphAnalysisWarmupInput, - discovery: CachedGraphWarmupDiscovery, - registry: CachedGraphWarmupRegistry, -): Promise { - if (typeof registry.analyzeFileResultForPlugins !== 'function') { - return; - } - - throwIfWorkspaceAnalysisAborted(input.signal); - const content = await discovery.readContent(input.file); - throwIfWorkspaceAnalysisAborted(input.signal); - await registry.analyzeFileResultForPlugins( - input.file.absolutePath, - content, - input.workspaceRoot, - input.pluginIds, - input.analysisContext, - { disabledPlugins: input.disabledPluginSnapshot }, - ); -} diff --git a/packages/extension/src/extension/pipeline/service/cachedGraphWarmup/candidates.ts b/packages/extension/src/extension/pipeline/service/cachedGraphWarmup/candidates.ts new file mode 100644 index 000000000..22171d1c3 --- /dev/null +++ b/packages/extension/src/extension/pipeline/service/cachedGraphWarmup/candidates.ts @@ -0,0 +1,19 @@ +import type { IDiscoveredFile } from '@codegraphy-dev/core'; + +const CACHED_GRAPH_ANALYSIS_WARMUP_IGNORED_SEGMENTS = new Set([ + '.codegraphy', + '.git', + '.stryker-tmp', + '.turbo', + '.worktrees', + 'coverage', + 'dist', + 'node_modules', + 'out', + 'reports', +]); + +export function isCachedGraphAnalysisWarmupCandidate(file: IDiscoveredFile): boolean { + const segments = file.relativePath.replace(/\\/g, '/').split('/'); + return !segments.some(segment => CACHED_GRAPH_ANALYSIS_WARMUP_IGNORED_SEGMENTS.has(segment)); +} diff --git a/packages/extension/src/extension/pipeline/service/cachedGraphWarmup/contracts.ts b/packages/extension/src/extension/pipeline/service/cachedGraphWarmup/contracts.ts new file mode 100644 index 000000000..ccc09bc8b --- /dev/null +++ b/packages/extension/src/extension/pipeline/service/cachedGraphWarmup/contracts.ts @@ -0,0 +1,41 @@ +import { + createWorkspacePluginAnalysisContext, + type IDiscoveredFile, +} from '@codegraphy-dev/core'; + +export interface CachedGraphWarmupRegistry { + analyzeFileResultForPlugins?: ( + absolutePath: string, + content: string, + workspaceRoot: string, + pluginIds: readonly string[], + analysisContext: ReturnType, + options: { disabledPlugins: Set }, + ) => Promise; + supportsFile?: (filePath: string) => boolean; +} + +export interface CachedGraphWarmupDiscovery { + readContent(file: IDiscoveredFile): Promise; +} + +export interface CachedGraphAnalysisWarmupInput { + analysisContext: ReturnType; + disabledPluginSnapshot: Set; + file: IDiscoveredFile; + pluginIds: readonly string[]; + signal?: AbortSignal; + workspaceRoot: string; +} + +export interface CachedGraphAnalysisWarmupOptions { + disabledPlugins: Set; + files: readonly IDiscoveredFile[]; + getActiveAnalysisPluginIds( + disabledPluginSnapshot: Set, + ): readonly string[]; + nodeVisibility: Record; + registry: CachedGraphWarmupRegistry; + signal?: AbortSignal; + workspaceRoot: string; +} diff --git a/packages/extension/src/extension/pipeline/service/cachedGraphWarmup/errors.ts b/packages/extension/src/extension/pipeline/service/cachedGraphWarmup/errors.ts new file mode 100644 index 000000000..935eb7582 --- /dev/null +++ b/packages/extension/src/extension/pipeline/service/cachedGraphWarmup/errors.ts @@ -0,0 +1,9 @@ +export function isWorkspaceAnalysisAbortError(error: unknown): boolean { + return error instanceof Error && error.name === 'AbortError'; +} + +export function isMissingFileError(error: unknown): boolean { + return error instanceof Error + && 'code' in error + && (error as { code?: unknown }).code === 'ENOENT'; +} diff --git a/packages/extension/src/extension/pipeline/service/cachedGraphWarmup/execution.ts b/packages/extension/src/extension/pipeline/service/cachedGraphWarmup/execution.ts new file mode 100644 index 000000000..ee6e69be4 --- /dev/null +++ b/packages/extension/src/extension/pipeline/service/cachedGraphWarmup/execution.ts @@ -0,0 +1,28 @@ +import { throwIfWorkspaceAnalysisAborted } from '@codegraphy-dev/core'; +import type { + CachedGraphAnalysisWarmupInput, + CachedGraphWarmupDiscovery, + CachedGraphWarmupRegistry, +} from './contracts'; + +export async function warmCachedGraphAnalysisFile( + input: CachedGraphAnalysisWarmupInput, + discovery: CachedGraphWarmupDiscovery, + registry: CachedGraphWarmupRegistry, +): Promise { + if (typeof registry.analyzeFileResultForPlugins !== 'function') { + return; + } + + throwIfWorkspaceAnalysisAborted(input.signal); + const content = await discovery.readContent(input.file); + throwIfWorkspaceAnalysisAborted(input.signal); + await registry.analyzeFileResultForPlugins( + input.file.absolutePath, + content, + input.workspaceRoot, + input.pluginIds, + input.analysisContext, + { disabledPlugins: input.disabledPluginSnapshot }, + ); +} diff --git a/packages/extension/src/extension/pipeline/service/cachedGraphWarmup/input.ts b/packages/extension/src/extension/pipeline/service/cachedGraphWarmup/input.ts new file mode 100644 index 000000000..935432307 --- /dev/null +++ b/packages/extension/src/extension/pipeline/service/cachedGraphWarmup/input.ts @@ -0,0 +1,44 @@ +import { + createWorkspacePluginAnalysisContext, + SYMBOLS_ANALYSIS_CACHE_TIER, +} from '@codegraphy-dev/core'; +import { createWorkspacePipelineAnalysisCacheTiers } from '../cache/tiers'; +import type { + CachedGraphAnalysisWarmupInput, + CachedGraphAnalysisWarmupOptions, +} from './contracts'; +import { selectCachedGraphAnalysisWarmupFile } from './selection'; + +export function createCachedGraphAnalysisWarmupInput( + options: CachedGraphAnalysisWarmupOptions, +): CachedGraphAnalysisWarmupInput | undefined { + if (typeof options.registry.analyzeFileResultForPlugins !== 'function') { + return undefined; + } + + const file = selectCachedGraphAnalysisWarmupFile(options.registry, options.files); + if (!file) { + return undefined; + } + + const disabledPluginSnapshot = new Set(options.disabledPlugins); + const pluginIds = options.getActiveAnalysisPluginIds(disabledPluginSnapshot); + const cacheTiers = createWorkspacePipelineAnalysisCacheTiers( + options.nodeVisibility, + pluginIds, + ); + + return { + analysisContext: createWorkspacePluginAnalysisContext(options.workspaceRoot, { + features: { + symbols: cacheTiers.active === undefined + || cacheTiers.active.includes(SYMBOLS_ANALYSIS_CACHE_TIER), + }, + }), + disabledPluginSnapshot, + file, + pluginIds, + signal: options.signal, + workspaceRoot: options.workspaceRoot, + }; +} diff --git a/packages/extension/src/extension/pipeline/service/cachedGraphWarmup/ranking.ts b/packages/extension/src/extension/pipeline/service/cachedGraphWarmup/ranking.ts new file mode 100644 index 000000000..162025307 --- /dev/null +++ b/packages/extension/src/extension/pipeline/service/cachedGraphWarmup/ranking.ts @@ -0,0 +1,30 @@ +import type { IDiscoveredFile } from '@codegraphy-dev/core'; + +export function selectMostRepresentedCachedGraphWarmupFile( + files: readonly IDiscoveredFile[], +): IDiscoveredFile | undefined { + const extensionStats = new Map(); + + for (const [index, file] of files.entries()) { + const extension = file.extension; + const stats = extensionStats.get(extension); + if (stats) { + stats.count += 1; + continue; + } + + extensionStats.set(extension, { + count: 1, + file, + firstIndex: index, + }); + } + + return [...extensionStats.values()] + .sort((left, right) => right.count - left.count || left.firstIndex - right.firstIndex)[0] + ?.file; +} diff --git a/packages/extension/src/extension/pipeline/service/cachedGraphWarmup/selection.ts b/packages/extension/src/extension/pipeline/service/cachedGraphWarmup/selection.ts new file mode 100644 index 000000000..3fb540010 --- /dev/null +++ b/packages/extension/src/extension/pipeline/service/cachedGraphWarmup/selection.ts @@ -0,0 +1,24 @@ +import type { IDiscoveredFile } from '@codegraphy-dev/core'; +import { isCachedGraphAnalysisWarmupCandidate } from './candidates'; +import type { CachedGraphWarmupRegistry } from './contracts'; +import { selectMostRepresentedCachedGraphWarmupFile } from './ranking'; +import { getSupportedCachedGraphAnalysisWarmupFiles } from './support'; + +export function selectCachedGraphAnalysisWarmupFile( + registry: CachedGraphWarmupRegistry, + files: readonly IDiscoveredFile[], +): IDiscoveredFile | undefined { + if (typeof registry.supportsFile !== 'function') { + return files[0]; + } + + const supportedFiles = getSupportedCachedGraphAnalysisWarmupFiles( + registry, + files.filter(isCachedGraphAnalysisWarmupCandidate), + ); + if (supportedFiles.length === 0) { + return getSupportedCachedGraphAnalysisWarmupFiles(registry, files)[0] ?? files[0]; + } + + return selectMostRepresentedCachedGraphWarmupFile(supportedFiles); +} diff --git a/packages/extension/src/extension/pipeline/service/cachedGraphWarmup/support.ts b/packages/extension/src/extension/pipeline/service/cachedGraphWarmup/support.ts new file mode 100644 index 000000000..697e0cb40 --- /dev/null +++ b/packages/extension/src/extension/pipeline/service/cachedGraphWarmup/support.ts @@ -0,0 +1,12 @@ +import type { IDiscoveredFile } from '@codegraphy-dev/core'; +import type { CachedGraphWarmupRegistry } from './contracts'; + +export function getSupportedCachedGraphAnalysisWarmupFiles( + registry: CachedGraphWarmupRegistry, + files: readonly IDiscoveredFile[], +): IDiscoveredFile[] { + return files.filter(file => + registry.supportsFile?.(file.absolutePath) + || registry.supportsFile?.(file.relativePath), + ); +} diff --git a/packages/extension/src/extension/pipeline/service/discoveryFacade.ts b/packages/extension/src/extension/pipeline/service/discoveryFacade.ts index 7db085aa5..cbe7e4f4d 100644 --- a/packages/extension/src/extension/pipeline/service/discoveryFacade.ts +++ b/packages/extension/src/extension/pipeline/service/discoveryFacade.ts @@ -1,291 +1,10 @@ -import * as vscode from 'vscode'; import { - type IDiscoveredFile, - projectFileAnalysisConnections, - throwIfWorkspaceAnalysisAborted, -} from '@codegraphy-dev/core'; -import type { IProjectedConnection } from '../../../core/plugins/types/contracts'; -import type { IGraphData } from '../../../shared/graph/contracts'; -import type { IPluginFilterPatternGroup } from '../../../shared/protocol/extensionToWebview'; -import type { WorkspacePipelineSourceOwner } from '../analysisSource'; -import { WorkspacePipelineInternalBase } from './base/internal'; -import { - createWorkspacePipelineDiscoveryDependencies, - discoverWorkspacePipelineFilesWithWarnings, -} from './runtime/discovery'; -import { hasWorkspacePipelineIndex } from './cache/index'; -import { - analyzeWorkspacePipeline, - rebuildWorkspacePipelineGraph, -} from './runtime/run'; -import { createEmptyWorkspaceAnalysisCache } from '../cache'; -import { createCachedWorkspaceDiscoveryState } from './cache/cachedDiscovery'; -import { - createCachedGraphAnalysisWarmupInput, - isMissingFileError, - isWorkspaceAnalysisAbortError, - warmCachedGraphAnalysisFile, -} from './cachedGraphWarmup'; -import { getWorkspacePipelineIndexStatus } from './indexStatus'; -import { - getEffectiveCustomFilterPatterns, - getEffectivePluginFilterPatterns, - getPipelinePluginFilterGroups, - getPipelinePluginFilterPatterns, - initializeWorkspacePipelinePlugins, - queueWorkspacePipelinePluginReload, - queueWorkspacePipelinePluginSync, -} from './pluginState'; - -export interface WorkspacePipelineCachedGraphLoadOptions { - includeCurrentGitignoreMetadata?: boolean; - warmAnalysis?: boolean; -} - -export abstract class WorkspacePipelineDiscoveryFacade extends WorkspacePipelineInternalBase { - private _workspacePluginReloadQueue: Promise = Promise.resolve(); - - async initialize(): Promise { - await initializeWorkspacePipelinePlugins(this._registry, () => this._getWorkspaceRoot()); - - console.log('[CodeGraphy] WorkspacePipeline initialized'); - } - - async reloadWorkspacePlugins(): Promise { - const { reload, nextQueue } = queueWorkspacePipelinePluginReload( - this._workspacePluginReloadQueue, - this._registry, - () => this.initialize(), - ); - this._workspacePluginReloadQueue = nextQueue; - return reload; - } - - async syncWorkspacePlugins(): Promise { - const { sync, nextQueue } = queueWorkspacePipelinePluginSync( - this._workspacePluginReloadQueue, - this._registry, - () => this._getWorkspaceRoot(), - ); - this._workspacePluginReloadQueue = nextQueue; - return sync; - } - - getPluginFilterPatterns( - disabledPlugins: ReadonlySet = new Set(), - ): string[] { - return getPipelinePluginFilterPatterns(this._registry, disabledPlugins); - } - - getPluginFilterGroups( - disabledPlugins: ReadonlySet = new Set(), - ): IPluginFilterPatternGroup[] { - return getPipelinePluginFilterGroups(this._registry, disabledPlugins); - } - - private _getEffectiveCustomFilterPatterns(filterPatterns: string[]): string[] { - return getEffectiveCustomFilterPatterns(this._config, filterPatterns); - } - - private _getEffectivePluginFilterPatterns(disabledPlugins: ReadonlySet): string[] { - return getEffectivePluginFilterPatterns(this._registry, this._config, disabledPlugins); - } - - hasIndex(): boolean { - return hasWorkspacePipelineIndex(this._getWorkspaceRoot()); - } - - getIndexStatus(): { freshness: 'fresh' | 'stale' | 'missing'; detail: string } { - return getWorkspacePipelineIndexStatus({ - hasIndex: () => this.hasIndex(), - pluginSignature: this._getPluginSignature(), - settingsSignature: this._getSettingsSignature(), - workspaceRoot: this._getWorkspaceRoot(), - }); - } - - async discoverGraph( - filterPatterns: string[] = [], - disabledPlugins: Set = new Set(), - signal?: AbortSignal, - ): Promise { - const workspaceRoot = this._getWorkspaceRoot(); - if (!workspaceRoot) { - console.log('[CodeGraphy] No workspace folder open'); - return { nodes: [], edges: [] }; - } - - const config = this._config.getAll(); - const discoveryResult = await discoverWorkspacePipelineFilesWithWarnings( - createWorkspacePipelineDiscoveryDependencies(this._discovery), - workspaceRoot, - config, - this._getEffectiveCustomFilterPatterns(filterPatterns), - this._getEffectivePluginFilterPatterns(disabledPlugins), - signal, - message => { - vscode.window.showWarningMessage(message); - }, - ); - const fileConnections = new Map( - discoveryResult.files.map(file => [file.relativePath, []]), - ); - - this._lastDiscoveredDirectories = discoveryResult.directories ?? []; - this._lastDiscoveredFiles = discoveryResult.files; - this._lastGitIgnoredPaths = discoveryResult.gitIgnoredPaths ?? []; - this._lastFileAnalysis = new Map(); - this._lastFileConnections = fileConnections; - this._lastWorkspaceRoot = workspaceRoot; - - return this._buildGraphData( - fileConnections, - workspaceRoot, - true, - disabledPlugins, - ); - } - - async analyze( - filterPatterns: string[] = [], - disabledPlugins: Set = new Set(), - signal?: AbortSignal, - onProgress?: (progress: { phase: string; current: number; total: number }) => void, - ): Promise { - return analyzeWorkspacePipeline( - this as unknown as WorkspacePipelineSourceOwner, - this._cache, - this._config, - this._discovery, - () => this._getWorkspaceRoot(), - this._getEffectiveCustomFilterPatterns(filterPatterns), - disabledPlugins, - onProgress, - signal, - async () => this._persistIndexMetadata(), - ); - } - - async loadCachedGraph( - _filterPatterns: string[] = [], - disabledPlugins: Set = new Set(), - signal?: AbortSignal, - options: WorkspacePipelineCachedGraphLoadOptions = {}, - ): Promise { - throwIfWorkspaceAnalysisAborted(signal); - await this._hydrateCacheFromGraphCache(); - throwIfWorkspaceAnalysisAborted(signal); - - const workspaceRoot = this._getWorkspaceRoot(); - if (!workspaceRoot) { - return { nodes: [], edges: [] }; - } - - const config = this._config.getAll(); - throwIfWorkspaceAnalysisAborted(signal); - - const fileAnalysis = new Map( - Object.entries(this._cache.files).map(([filePath, entry]) => [ - filePath, - entry.analysis, - ]), - ); - const cachedFilePaths = Object.keys(this._cache.files); - const includeCurrentGitignoreMetadata = options.includeCurrentGitignoreMetadata !== false; - const cachedDiscovery = createCachedWorkspaceDiscoveryState( - workspaceRoot, - cachedFilePaths, - config.respectGitignore && includeCurrentGitignoreMetadata, - ); - - this._lastDiscoveredFiles = cachedDiscovery.files; - this._lastDiscoveredDirectories = cachedDiscovery.directories; - this._lastGitIgnoredPaths = cachedDiscovery.gitIgnoredPaths; - this._lastFileAnalysis = fileAnalysis; - this._lastFileConnections = projectFileAnalysisConnections(fileAnalysis, workspaceRoot); - this._lastWorkspaceRoot = workspaceRoot; - - throwIfWorkspaceAnalysisAborted(signal); - - const graphData = this._buildGraphDataFromAnalysis( - fileAnalysis, - workspaceRoot, - config.showOrphans, - disabledPlugins, - ); - - if (options.warmAnalysis !== false) { - this._scheduleCachedGraphAnalysisWarmup( - cachedDiscovery.files, - workspaceRoot, - disabledPlugins, - signal, - ); - } - - return graphData; - } - - private _scheduleCachedGraphAnalysisWarmup( - files: readonly IDiscoveredFile[], - workspaceRoot: string, - disabledPlugins: Set, - signal?: AbortSignal, - ): void { - const input = createCachedGraphAnalysisWarmupInput({ - disabledPlugins, - files, - getActiveAnalysisPluginIds: disabledPluginSnapshot => - this._getActiveAnalysisPluginIds(undefined, disabledPluginSnapshot), - nodeVisibility: this._config.get>('nodeVisibility', {}) ?? {}, - registry: this._registry, - signal, - workspaceRoot, - }); - if (!input) { - return; - } - - void warmCachedGraphAnalysisFile(input, this._discovery, this._registry).catch(error => { - const status = isWorkspaceAnalysisAbortError(error) - ? 'aborted' - : isMissingFileError(error) - ? 'skipped' - : 'failed'; - - if (status === 'failed') { - console.warn('[CodeGraphy] Failed to warm cached graph analysis.', error); - } - }); - } - - rebuildGraph(disabledPlugins: Set, showOrphans: boolean): IGraphData { - return rebuildWorkspacePipelineGraph( - this as unknown as WorkspacePipelineSourceOwner, - disabledPlugins, - showOrphans, - ); - } - - protected resetCacheForIndexRefresh(): void { - this._cache = createEmptyWorkspaceAnalysisCache(); - console.log('[CodeGraphy] Cache cleared'); - } + WorkspacePipelineCachedGraphFacade, + type WorkspacePipelineCachedGraphLoadOptions, +} from './cachedGraph'; - async refreshIndex( - filterPatterns: string[] = [], - disabledPlugins: Set = new Set(), - signal?: AbortSignal, - onProgress?: (progress: { phase: string; current: number; total: number }) => void, - ): Promise { - this.resetCacheForIndexRefresh(); - return this.analyze(filterPatterns, disabledPlugins, signal, progress => { - onProgress?.({ - ...progress, - phase: progress.phase || 'Refreshing Index', - }); - }); - } +export type { WorkspacePipelineCachedGraphLoadOptions }; +export abstract class WorkspacePipelineDiscoveryFacade extends WorkspacePipelineCachedGraphFacade { abstract clearCache(): void; } diff --git a/packages/extension/src/extension/pipeline/service/graphDiscovery.ts b/packages/extension/src/extension/pipeline/service/graphDiscovery.ts new file mode 100644 index 000000000..3cdefcb11 --- /dev/null +++ b/packages/extension/src/extension/pipeline/service/graphDiscovery.ts @@ -0,0 +1,52 @@ +import * as vscode from 'vscode'; +import type { IGraphData } from '../../../shared/graph/contracts'; +import type { IProjectedConnection } from '../../../core/plugins/types/contracts'; +import { WorkspacePipelinePluginFacade } from './pluginFacade'; +import { + createWorkspacePipelineDiscoveryDependencies, + discoverWorkspacePipelineFilesWithWarnings, +} from './runtime/discovery'; + +export abstract class WorkspacePipelineGraphDiscoveryFacade extends WorkspacePipelinePluginFacade { + async discoverGraph( + filterPatterns: string[] = [], + disabledPlugins: Set = new Set(), + signal?: AbortSignal, + ): Promise { + const workspaceRoot = this._getWorkspaceRoot(); + if (!workspaceRoot) { + console.log('[CodeGraphy] No workspace folder open'); + return { nodes: [], edges: [] }; + } + + const config = this._config.getAll(); + const discoveryResult = await discoverWorkspacePipelineFilesWithWarnings( + createWorkspacePipelineDiscoveryDependencies(this._discovery), + workspaceRoot, + config, + this._getEffectiveCustomFilterPatterns(filterPatterns), + this._getEffectivePluginFilterPatterns(disabledPlugins), + signal, + message => { + vscode.window.showWarningMessage(message); + }, + ); + const fileConnections = new Map( + discoveryResult.files.map(file => [file.relativePath, []]), + ); + + this._lastDiscoveredDirectories = discoveryResult.directories ?? []; + this._lastDiscoveredFiles = discoveryResult.files; + this._lastGitIgnoredPaths = discoveryResult.gitIgnoredPaths ?? []; + this._lastFileAnalysis = new Map(); + this._lastFileConnections = fileConnections; + this._lastWorkspaceRoot = workspaceRoot; + + return this._buildGraphData( + fileConnections, + workspaceRoot, + true, + disabledPlugins, + ); + } +} diff --git a/packages/extension/src/extension/pipeline/service/pluginFacade.ts b/packages/extension/src/extension/pipeline/service/pluginFacade.ts new file mode 100644 index 000000000..8f8b114ea --- /dev/null +++ b/packages/extension/src/extension/pipeline/service/pluginFacade.ts @@ -0,0 +1,76 @@ +import type { IPluginFilterPatternGroup } from '../../../shared/protocol/extensionToWebview'; +import { hasWorkspacePipelineIndex } from './cache/index'; +import { WorkspacePipelineInternalBase } from './base/internal'; +import { getWorkspacePipelineIndexStatus } from './indexStatus'; +import { + getEffectiveCustomFilterPatterns, + getEffectivePluginFilterPatterns, + getPipelinePluginFilterGroups, + getPipelinePluginFilterPatterns, + initializeWorkspacePipelinePlugins, + queueWorkspacePipelinePluginReload, + queueWorkspacePipelinePluginSync, +} from './pluginState'; + +export abstract class WorkspacePipelinePluginFacade extends WorkspacePipelineInternalBase { + private _workspacePluginReloadQueue: Promise = Promise.resolve(); + + async initialize(): Promise { + await initializeWorkspacePipelinePlugins(this._registry, () => this._getWorkspaceRoot()); + + console.log('[CodeGraphy] WorkspacePipeline initialized'); + } + + async reloadWorkspacePlugins(): Promise { + const { reload, nextQueue } = queueWorkspacePipelinePluginReload( + this._workspacePluginReloadQueue, + this._registry, + () => this.initialize(), + ); + this._workspacePluginReloadQueue = nextQueue; + return reload; + } + + async syncWorkspacePlugins(): Promise { + const { sync, nextQueue } = queueWorkspacePipelinePluginSync( + this._workspacePluginReloadQueue, + this._registry, + () => this._getWorkspaceRoot(), + ); + this._workspacePluginReloadQueue = nextQueue; + return sync; + } + + getPluginFilterPatterns( + disabledPlugins: ReadonlySet = new Set(), + ): string[] { + return getPipelinePluginFilterPatterns(this._registry, disabledPlugins); + } + + getPluginFilterGroups( + disabledPlugins: ReadonlySet = new Set(), + ): IPluginFilterPatternGroup[] { + return getPipelinePluginFilterGroups(this._registry, disabledPlugins); + } + + protected _getEffectiveCustomFilterPatterns(filterPatterns: string[]): string[] { + return getEffectiveCustomFilterPatterns(this._config, filterPatterns); + } + + protected _getEffectivePluginFilterPatterns(disabledPlugins: ReadonlySet): string[] { + return getEffectivePluginFilterPatterns(this._registry, this._config, disabledPlugins); + } + + hasIndex(): boolean { + return hasWorkspacePipelineIndex(this._getWorkspaceRoot()); + } + + getIndexStatus(): { freshness: 'fresh' | 'stale' | 'missing'; detail: string } { + return getWorkspacePipelineIndexStatus({ + hasIndex: () => this.hasIndex(), + pluginSignature: this._getPluginSignature(), + settingsSignature: this._getSettingsSignature(), + workspaceRoot: this._getWorkspaceRoot(), + }); + } +} diff --git a/stryker.extension.config.cjs b/stryker.extension.config.cjs index 2dcc04db2..160643d16 100644 --- a/stryker.extension.config.cjs +++ b/stryker.extension.config.cjs @@ -1,9 +1 @@ -const base = require('@poleski/quality-tools/stryker.config.cjs'); - -module.exports = { - ...base, - vitest: { - ...base.vitest, - configFile: 'packages/extension/vitest.config.ts', - }, -}; +module.exports = require('./stryker.config.cjs'); From 470c2e62151f9110f07680237087d4f56f0787e7 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 14:16:15 -0700 Subject: [PATCH 101/192] refactor: split diagnostics mutation sites --- packages/core/src/diagnostics/collector.ts | 15 + packages/core/src/diagnostics/contracts.ts | 27 ++ packages/core/src/diagnostics/create.ts | 10 + packages/core/src/diagnostics/events.ts | 371 +----------------- .../src/diagnostics/format/events/analysis.ts | 42 ++ .../src/diagnostics/format/events/command.ts | 31 ++ .../diagnostics/format/events/extension.ts | 48 +++ .../diagnostics/format/events/graphQuery.ts | 40 ++ .../src/diagnostics/format/events/indexing.ts | 46 +++ .../diagnostics/format/events/workspace.ts | 34 ++ .../core/src/diagnostics/format/fallback.ts | 19 + .../core/src/diagnostics/format/humanize.ts | 9 + packages/core/src/diagnostics/format/known.ts | 44 +++ packages/core/src/diagnostics/format/line.ts | 7 + packages/core/src/diagnostics/format/parts.ts | 33 ++ .../src/diagnostics/normalize/collections.ts | 25 ++ .../core/src/diagnostics/normalize/context.ts | 35 ++ .../core/src/diagnostics/normalize/objects.ts | 18 + .../src/diagnostics/normalize/primitives.ts | 35 ++ 19 files changed, 536 insertions(+), 353 deletions(-) create mode 100644 packages/core/src/diagnostics/collector.ts create mode 100644 packages/core/src/diagnostics/contracts.ts create mode 100644 packages/core/src/diagnostics/create.ts create mode 100644 packages/core/src/diagnostics/format/events/analysis.ts create mode 100644 packages/core/src/diagnostics/format/events/command.ts create mode 100644 packages/core/src/diagnostics/format/events/extension.ts create mode 100644 packages/core/src/diagnostics/format/events/graphQuery.ts create mode 100644 packages/core/src/diagnostics/format/events/indexing.ts create mode 100644 packages/core/src/diagnostics/format/events/workspace.ts create mode 100644 packages/core/src/diagnostics/format/fallback.ts create mode 100644 packages/core/src/diagnostics/format/humanize.ts create mode 100644 packages/core/src/diagnostics/format/known.ts create mode 100644 packages/core/src/diagnostics/format/line.ts create mode 100644 packages/core/src/diagnostics/format/parts.ts create mode 100644 packages/core/src/diagnostics/normalize/collections.ts create mode 100644 packages/core/src/diagnostics/normalize/context.ts create mode 100644 packages/core/src/diagnostics/normalize/objects.ts create mode 100644 packages/core/src/diagnostics/normalize/primitives.ts diff --git a/packages/core/src/diagnostics/collector.ts b/packages/core/src/diagnostics/collector.ts new file mode 100644 index 000000000..3cec10f0f --- /dev/null +++ b/packages/core/src/diagnostics/collector.ts @@ -0,0 +1,15 @@ +import type { DiagnosticEvent, DiagnosticEventSink } from './contracts'; + +export function collectDiagnosticEvents(enabled: boolean): DiagnosticEventSink & { readonly events: DiagnosticEvent[] } { + const events: DiagnosticEvent[] = []; + return { + get events(): DiagnosticEvent[] { + return events; + }, + emit(event: DiagnosticEvent): void { + if (enabled) { + events.push(event); + } + }, + }; +} diff --git a/packages/core/src/diagnostics/contracts.ts b/packages/core/src/diagnostics/contracts.ts new file mode 100644 index 000000000..47e0fa07a --- /dev/null +++ b/packages/core/src/diagnostics/contracts.ts @@ -0,0 +1,27 @@ +export type DiagnosticContextValue = + | null + | string + | number + | boolean + | DiagnosticContextValue[] + | { [key: string]: DiagnosticContextValue }; + +export interface DiagnosticEvent { + area: string; + event: string; + context?: Record; +} + +export interface DiagnosticEventInput { + area: string; + event: string; + context?: Record; +} + +export interface DiagnosticEventSink { + emit(event: DiagnosticEvent): void; +} + +export type DiagnosticEventFormatter = ( + context: Record | undefined, +) => string | undefined; diff --git a/packages/core/src/diagnostics/create.ts b/packages/core/src/diagnostics/create.ts new file mode 100644 index 000000000..5a4544ab5 --- /dev/null +++ b/packages/core/src/diagnostics/create.ts @@ -0,0 +1,10 @@ +import type { DiagnosticEvent, DiagnosticEventInput } from './contracts'; +import { normalizeContext } from './normalize/context'; + +export function createDiagnosticEvent(input: DiagnosticEventInput): DiagnosticEvent { + return { + area: input.area, + event: input.event, + ...(input.context ? { context: normalizeContext(input.context) } : {}), + }; +} diff --git a/packages/core/src/diagnostics/events.ts b/packages/core/src/diagnostics/events.ts index 4a31fb8bb..62a3289b7 100644 --- a/packages/core/src/diagnostics/events.ts +++ b/packages/core/src/diagnostics/events.ts @@ -1,362 +1,27 @@ -export type DiagnosticContextValue = - | null - | string - | number - | boolean - | DiagnosticContextValue[] - | { [key: string]: DiagnosticContextValue }; - -export interface DiagnosticEvent { - area: string; - event: string; - context?: Record; -} - -export interface DiagnosticEventInput { - area: string; - event: string; - context?: Record; -} - -export interface DiagnosticEventSink { - emit(event: DiagnosticEvent): void; -} - -type DiagnosticEventFormatter = ( - context: Record | undefined, -) => string | undefined; - -function normalizeError(error: Error): Record { - return { - name: error.name, - message: error.message, - }; -} - -function isScalarContextValue(value: unknown): value is null | string | number | boolean { - return value === null - || typeof value === 'string' - || typeof value === 'number' - || typeof value === 'boolean'; -} - -function normalizeCollectionContextValue(value: unknown): DiagnosticContextValue | undefined { - if (Array.isArray(value)) { - return value.map(normalizeContextValue); - } - - if (value instanceof Set) { - return [...value].map(normalizeContextValue); - } - - if (value instanceof Map) { - return [...value.entries()].map(([key, entryValue]) => ({ - key: normalizeContextValue(key), - value: normalizeContextValue(entryValue), - })); - } - - return undefined; -} - -function normalizeObjectContextValue(value: unknown): DiagnosticContextValue | undefined { - if (value === null || typeof value !== 'object') { - return undefined; - } - - const normalized: Record = {}; - for (const [key, entryValue] of Object.entries(value)) { - normalized[key] = normalizeContextValue(entryValue); - } - return normalized; -} - -function normalizeNonJsonPrimitiveContextValue(value: unknown): DiagnosticContextValue | undefined { - if (typeof value === 'undefined') { - return 'undefined'; - } - - if (typeof value === 'bigint') { - return value.toString(); - } - - if (typeof value === 'symbol') { - return value.description ? `Symbol(${value.description})` : 'Symbol()'; - } - - if (typeof value === 'function') { - return value.name ? `[Function: ${value.name}]` : '[Function]'; - } - - return undefined; -} - -function normalizeContextValue(value: unknown): DiagnosticContextValue { - if (isScalarContextValue(value)) { - return value; - } - - if (value instanceof Error) { - return normalizeError(value); - } - - return normalizeCollectionContextValue(value) - ?? normalizeObjectContextValue(value) - ?? normalizeNonJsonPrimitiveContextValue(value) - ?? 'unknown'; -} - -function normalizeContext(context: Record | undefined): Record | undefined { - if (!context) { - return undefined; - } - - const normalized: Record = {}; - for (const [key, value] of Object.entries(context)) { - normalized[key] = normalizeContextValue(value); - } - return normalized; -} +import { collectDiagnosticEvents as collectDiagnosticEventsImpl } from './collector'; +import type { + DiagnosticEvent, + DiagnosticEventInput, + DiagnosticEventSink, +} from './contracts'; +import { createDiagnosticEvent as createDiagnosticEventImpl } from './create'; +import { formatDiagnosticEventLine as formatDiagnosticEventLineImpl } from './format/line'; + +export type { + DiagnosticContextValue, + DiagnosticEvent, + DiagnosticEventInput, + DiagnosticEventSink, +} from './contracts'; export function createDiagnosticEvent(input: DiagnosticEventInput): DiagnosticEvent { - return { - area: input.area, - event: input.event, - ...(input.context ? { context: normalizeContext(input.context) } : {}), - }; + return createDiagnosticEventImpl(input); } export function collectDiagnosticEvents(enabled: boolean): DiagnosticEventSink & { readonly events: DiagnosticEvent[] } { - const events: DiagnosticEvent[] = []; - return { - get events(): DiagnosticEvent[] { - return events; - }, - emit(event: DiagnosticEvent): void { - if (enabled) { - events.push(event); - } - }, - }; -} - -function formatCount(value: DiagnosticContextValue | undefined, noun: string): string | undefined { - if (typeof value !== 'number') { - return undefined; - } - return `${value} ${noun}${value === 1 ? '' : 's'}`; -} - -function formatScalar(value: DiagnosticContextValue | undefined): string | undefined { - if (value === null || typeof value === 'undefined') { - return undefined; - } - - if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { - return String(value); - } - - return JSON.stringify(value); -} - -function formatContextDetail( - context: Record | undefined, - key: string, - label: string = key, -): string | undefined { - const value = formatScalar(context?.[key]); - return value ? `${label}=${value}` : undefined; -} - -function joinDetails(details: Array): string { - return details.filter((detail): detail is string => Boolean(detail)).join(', '); -} - -function formatStatusRead(context: Record | undefined): string { - const state = formatScalar(context?.state); - const cacheDescription = state ? `${state} Graph Cache` : 'Graph Cache'; - const details = joinDetails([ - formatContextDetail(context, 'workspaceRoot', 'workspace'), - formatContextDetail(context, 'graphCache', 'cache'), - formatContextDetail(context, 'enabledPluginCount', 'plugins'), - ]); - return details - ? `Workspace status read: ${cacheDescription}, ${details}` - : `Workspace status read: ${cacheDescription}`; -} - -function formatIndexingComplete(context: Record | undefined): string { - const counts = joinDetails([ - formatCount(context?.files, 'file'), - formatCount(context?.nodes, 'node'), - formatCount(context?.edges, 'edge'), - ]); - const details = joinDetails([ - counts, - formatContextDetail(context, 'durationMs', 'durationMs'), - formatContextDetail(context, 'operationId', 'operation'), - formatContextDetail(context, 'graphCache', 'cache'), - ]); - return details ? `Indexing complete: ${details}` : 'Indexing complete'; -} - -function formatIndexingPhaseCompleted(context: Record | undefined): string { - const details = joinDetails([ - formatContextDetail(context, 'phase'), - formatContextDetail(context, 'durationMs', 'durationMs'), - formatContextDetail(context, 'files'), - formatContextDetail(context, 'directories'), - formatContextDetail(context, 'totalFound', 'totalFound'), - formatContextDetail(context, 'limitReached', 'limitReached'), - formatContextDetail(context, 'cacheHits', 'cacheHits'), - formatContextDetail(context, 'cacheMisses', 'cacheMisses'), - formatContextDetail(context, 'nodes'), - formatContextDetail(context, 'edges'), - formatContextDetail(context, 'loadedPackagePlugins', 'loadedPackagePlugins'), - formatContextDetail(context, 'registeredPlugins', 'registeredPlugins'), - ]); - return details ? `Indexing phase complete: ${details}` : 'Indexing phase complete'; -} - -function formatCommandEvent( - event: string, - context: Record | undefined, -): string | undefined { - const command = formatScalar(context?.command); - if (event === 'command-started') { - const details = joinDetails([ - command, - formatContextDetail(context, 'action'), - formatContextDetail(context, 'workspacePath', 'workspace'), - ]); - return details ? `Starting command: ${details}` : 'Starting command'; - } - - if (event === 'command-completed') { - const details = joinDetails([ - command, - formatContextDetail(context, 'exitCode'), - ]); - return details ? `Command complete: ${details}` : 'Command complete'; - } - - return undefined; -} - -function formatAnalysisEvent( - event: string, - context: Record | undefined, -): string | undefined { - return ANALYSIS_EVENT_FORMATTERS.get(event)?.(context); -} - -const ANALYSIS_EVENT_FORMATTERS = new Map([ - ['request-started', context => `Starting analysis: ${joinDetails([ - formatContextDetail(context, 'requestId', 'request'), - formatContextDetail(context, 'mode'), - formatContextDetail(context, 'filterPatternCount', 'filters'), - formatContextDetail(context, 'disabledPluginCount', 'disabledPlugins'), - ])}`], - ['request-completed', context => `Analysis complete: ${joinDetails([ - formatContextDetail(context, 'requestId', 'request'), - formatContextDetail(context, 'mode'), - formatContextDetail(context, 'durationMs', 'durationMs'), - ])}`], - ['request-failed', context => `Analysis failed: ${joinDetails([ - formatContextDetail(context, 'requestId', 'request'), - formatContextDetail(context, 'mode'), - formatContextDetail(context, 'durationMs', 'durationMs'), - formatContextDetail(context, 'error'), - ])}`], - ['load-decision', context => `Analysis load decision: ${joinDetails([ - formatContextDetail(context, 'route'), - formatContextDetail(context, 'mode'), - formatContextDetail(context, 'shouldDiscover'), - formatContextDetail(context, 'canReplayCache'), - formatContextDetail(context, 'indexFreshness', 'freshness'), - ])}`], -]); - -const KNOWN_EVENT_FORMATTERS = new Map([ - ['workspace:index-started', context => `Starting indexing: ${joinDetails([ - formatContextDetail(context, 'workspaceRoot', 'workspace'), - formatContextDetail(context, 'operationId', 'operation'), - ])}`], - ['workspace:status-read', formatStatusRead], - ['indexing:completed', formatIndexingComplete], - ['indexing:phase-completed', formatIndexingPhaseCompleted], - ['graph-query:started', context => `Starting Graph Query: ${joinDetails([ - formatContextDetail(context, 'report'), - formatContextDetail(context, 'operationId', 'operation'), - formatContextDetail(context, 'workspaceRoot', 'workspace'), - ])}`], - ['graph-query:cache-missing', context => `Graph Cache missing: ${joinDetails([ - formatContextDetail(context, 'report'), - formatContextDetail(context, 'cacheState'), - formatContextDetail(context, 'operationId', 'operation'), - formatContextDetail(context, 'workspaceRoot', 'workspace'), - ])}`], - ['graph-query:completed', context => `Graph Query complete: ${joinDetails([ - formatContextDetail(context, 'report'), - formatCount(context?.nodeCount, 'node'), - formatCount(context?.edgeCount, 'edge'), - formatContextDetail(context, 'durationMs', 'durationMs'), - formatContextDetail(context, 'operationId', 'operation'), - ])}`], - ['extension.lifecycle:activation-started', context => `Extension activation started: ${joinDetails([ - formatContextDetail(context, 'workspaceFolders'), - ])}`], - ['extension.lifecycle:activation-completed', context => `Extension activation complete: ${joinDetails([ - formatContextDetail(context, 'registeredWebviewProviders'), - ])}`], - ['extension.webview:ready-replayed', context => `Webview ready replayed: ${joinDetails([ - formatContextDetail(context, 'hasWorkspace'), - formatContextDetail(context, 'firstAnalysis'), - formatContextDetail(context, 'readyNotified'), - formatContextDetail(context, 'maxFiles'), - ])}`], - ['extension.webview:bootstrap-completed', context => `Webview bootstrap complete: ${joinDetails([ - formatContextDetail(context, 'hasWorkspace'), - formatContextDetail(context, 'firstAnalysis'), - formatContextDetail(context, 'readyNotified'), - ])}`], -]); - -function formatKnownEvent(event: DiagnosticEvent): string | undefined { - if (event.area === 'cli') { - return formatCommandEvent(event.event, event.context); - } - - if (event.area === 'extension.analysis') { - return formatAnalysisEvent(event.event, event.context); - } - - return KNOWN_EVENT_FORMATTERS.get(`${event.area}:${event.event}`)?.(event.context); -} - -function humanizeEventName(event: string): string { - return event - .split('-') - .filter(Boolean) - .map((part, index) => index === 0 - ? `${part.charAt(0).toUpperCase()}${part.slice(1)}` - : part) - .join(' '); -} - -function formatFallbackEvent(event: DiagnosticEvent): string { - const details = joinDetails([ - formatContextDetail({ area: event.area }, 'area'), - ...Object.keys(event.context ?? {}).map(key => formatContextDetail(event.context, key)), - ]); - const message = humanizeEventName(event.event); - if (details) { - return `${message}: ${details}`; - } - - return message; + return collectDiagnosticEventsImpl(enabled); } export function formatDiagnosticEventLine(event: DiagnosticEvent): string { - return `[CodeGraphy] ${formatKnownEvent(event) ?? formatFallbackEvent(event)}`; + return formatDiagnosticEventLineImpl(event); } diff --git a/packages/core/src/diagnostics/format/events/analysis.ts b/packages/core/src/diagnostics/format/events/analysis.ts new file mode 100644 index 000000000..f8a070f84 --- /dev/null +++ b/packages/core/src/diagnostics/format/events/analysis.ts @@ -0,0 +1,42 @@ +import type { + DiagnosticContextValue, + DiagnosticEventFormatter, +} from '../../contracts'; +import { + formatContextDetail, + joinDetails, +} from '../parts'; + +const ANALYSIS_EVENT_FORMATTERS = new Map([ + ['request-started', context => `Starting analysis: ${joinDetails([ + formatContextDetail(context, 'requestId', 'request'), + formatContextDetail(context, 'mode'), + formatContextDetail(context, 'filterPatternCount', 'filters'), + formatContextDetail(context, 'disabledPluginCount', 'disabledPlugins'), + ])}`], + ['request-completed', context => `Analysis complete: ${joinDetails([ + formatContextDetail(context, 'requestId', 'request'), + formatContextDetail(context, 'mode'), + formatContextDetail(context, 'durationMs', 'durationMs'), + ])}`], + ['request-failed', context => `Analysis failed: ${joinDetails([ + formatContextDetail(context, 'requestId', 'request'), + formatContextDetail(context, 'mode'), + formatContextDetail(context, 'durationMs', 'durationMs'), + formatContextDetail(context, 'error'), + ])}`], + ['load-decision', context => `Analysis load decision: ${joinDetails([ + formatContextDetail(context, 'route'), + formatContextDetail(context, 'mode'), + formatContextDetail(context, 'shouldDiscover'), + formatContextDetail(context, 'canReplayCache'), + formatContextDetail(context, 'indexFreshness', 'freshness'), + ])}`], +]); + +export function formatAnalysisEvent( + event: string, + context: Record | undefined, +): string | undefined { + return ANALYSIS_EVENT_FORMATTERS.get(event)?.(context); +} diff --git a/packages/core/src/diagnostics/format/events/command.ts b/packages/core/src/diagnostics/format/events/command.ts new file mode 100644 index 000000000..2e0e4c007 --- /dev/null +++ b/packages/core/src/diagnostics/format/events/command.ts @@ -0,0 +1,31 @@ +import type { DiagnosticContextValue } from '../../contracts'; +import { + formatContextDetail, + formatScalar, + joinDetails, +} from '../parts'; + +export function formatCommandEvent( + event: string, + context: Record | undefined, +): string | undefined { + const command = formatScalar(context?.command); + if (event === 'command-started') { + const details = joinDetails([ + command, + formatContextDetail(context, 'action'), + formatContextDetail(context, 'workspacePath', 'workspace'), + ]); + return details ? `Starting command: ${details}` : 'Starting command'; + } + + if (event === 'command-completed') { + const details = joinDetails([ + command, + formatContextDetail(context, 'exitCode'), + ]); + return details ? `Command complete: ${details}` : 'Command complete'; + } + + return undefined; +} diff --git a/packages/core/src/diagnostics/format/events/extension.ts b/packages/core/src/diagnostics/format/events/extension.ts new file mode 100644 index 000000000..52011cf54 --- /dev/null +++ b/packages/core/src/diagnostics/format/events/extension.ts @@ -0,0 +1,48 @@ +import type { DiagnosticContextValue } from '../../contracts'; +import { + formatContextDetail, + joinDetails, +} from '../parts'; + +export function formatExtensionLifecycleEvent( + event: string, + context: Record | undefined, +): string | undefined { + if (event === 'activation-started') { + return `Extension activation started: ${joinDetails([ + formatContextDetail(context, 'workspaceFolders'), + ])}`; + } + + if (event === 'activation-completed') { + return `Extension activation complete: ${joinDetails([ + formatContextDetail(context, 'registeredWebviewProviders'), + ])}`; + } + + return undefined; +} + +export function formatExtensionWebviewEvent( + event: string, + context: Record | undefined, +): string | undefined { + if (event === 'ready-replayed') { + return `Webview ready replayed: ${joinDetails([ + formatContextDetail(context, 'hasWorkspace'), + formatContextDetail(context, 'firstAnalysis'), + formatContextDetail(context, 'readyNotified'), + formatContextDetail(context, 'maxFiles'), + ])}`; + } + + if (event === 'bootstrap-completed') { + return `Webview bootstrap complete: ${joinDetails([ + formatContextDetail(context, 'hasWorkspace'), + formatContextDetail(context, 'firstAnalysis'), + formatContextDetail(context, 'readyNotified'), + ])}`; + } + + return undefined; +} diff --git a/packages/core/src/diagnostics/format/events/graphQuery.ts b/packages/core/src/diagnostics/format/events/graphQuery.ts new file mode 100644 index 000000000..92d03f1da --- /dev/null +++ b/packages/core/src/diagnostics/format/events/graphQuery.ts @@ -0,0 +1,40 @@ +import type { DiagnosticContextValue } from '../../contracts'; +import { + formatContextDetail, + formatCount, + joinDetails, +} from '../parts'; + +export function formatGraphQueryEvent( + event: string, + context: Record | undefined, +): string | undefined { + if (event === 'started') { + return `Starting Graph Query: ${joinDetails([ + formatContextDetail(context, 'report'), + formatContextDetail(context, 'operationId', 'operation'), + formatContextDetail(context, 'workspaceRoot', 'workspace'), + ])}`; + } + + if (event === 'cache-missing') { + return `Graph Cache missing: ${joinDetails([ + formatContextDetail(context, 'report'), + formatContextDetail(context, 'cacheState'), + formatContextDetail(context, 'operationId', 'operation'), + formatContextDetail(context, 'workspaceRoot', 'workspace'), + ])}`; + } + + if (event === 'completed') { + return `Graph Query complete: ${joinDetails([ + formatContextDetail(context, 'report'), + formatCount(context?.nodeCount, 'node'), + formatCount(context?.edgeCount, 'edge'), + formatContextDetail(context, 'durationMs', 'durationMs'), + formatContextDetail(context, 'operationId', 'operation'), + ])}`; + } + + return undefined; +} diff --git a/packages/core/src/diagnostics/format/events/indexing.ts b/packages/core/src/diagnostics/format/events/indexing.ts new file mode 100644 index 000000000..aa83909fe --- /dev/null +++ b/packages/core/src/diagnostics/format/events/indexing.ts @@ -0,0 +1,46 @@ +import type { DiagnosticContextValue } from '../../contracts'; +import { + formatContextDetail, + formatCount, + joinDetails, +} from '../parts'; + +export function formatIndexingEvent( + event: string, + context: Record | undefined, +): string | undefined { + if (event === 'completed') { + const counts = joinDetails([ + formatCount(context?.files, 'file'), + formatCount(context?.nodes, 'node'), + formatCount(context?.edges, 'edge'), + ]); + const details = joinDetails([ + counts, + formatContextDetail(context, 'durationMs', 'durationMs'), + formatContextDetail(context, 'operationId', 'operation'), + formatContextDetail(context, 'graphCache', 'cache'), + ]); + return details ? `Indexing complete: ${details}` : 'Indexing complete'; + } + + if (event === 'phase-completed') { + const details = joinDetails([ + formatContextDetail(context, 'phase'), + formatContextDetail(context, 'durationMs', 'durationMs'), + formatContextDetail(context, 'files'), + formatContextDetail(context, 'directories'), + formatContextDetail(context, 'totalFound', 'totalFound'), + formatContextDetail(context, 'limitReached', 'limitReached'), + formatContextDetail(context, 'cacheHits', 'cacheHits'), + formatContextDetail(context, 'cacheMisses', 'cacheMisses'), + formatContextDetail(context, 'nodes'), + formatContextDetail(context, 'edges'), + formatContextDetail(context, 'loadedPackagePlugins', 'loadedPackagePlugins'), + formatContextDetail(context, 'registeredPlugins', 'registeredPlugins'), + ]); + return details ? `Indexing phase complete: ${details}` : 'Indexing phase complete'; + } + + return undefined; +} diff --git a/packages/core/src/diagnostics/format/events/workspace.ts b/packages/core/src/diagnostics/format/events/workspace.ts new file mode 100644 index 000000000..ed211c73d --- /dev/null +++ b/packages/core/src/diagnostics/format/events/workspace.ts @@ -0,0 +1,34 @@ +import type { DiagnosticContextValue } from '../../contracts'; +import { + formatContextDetail, + formatScalar, + joinDetails, +} from '../parts'; + +export function formatWorkspaceEvent( + event: string, + context: Record | undefined, +): string | undefined { + if (event === 'index-started') { + const details = joinDetails([ + formatContextDetail(context, 'workspaceRoot', 'workspace'), + formatContextDetail(context, 'operationId', 'operation'), + ]); + return `Starting indexing: ${details}`; + } + + if (event === 'status-read') { + const state = formatScalar(context?.state); + const cacheDescription = state ? `${state} Graph Cache` : 'Graph Cache'; + const details = joinDetails([ + formatContextDetail(context, 'workspaceRoot', 'workspace'), + formatContextDetail(context, 'graphCache', 'cache'), + formatContextDetail(context, 'enabledPluginCount', 'plugins'), + ]); + return details + ? `Workspace status read: ${cacheDescription}, ${details}` + : `Workspace status read: ${cacheDescription}`; + } + + return undefined; +} diff --git a/packages/core/src/diagnostics/format/fallback.ts b/packages/core/src/diagnostics/format/fallback.ts new file mode 100644 index 000000000..814578665 --- /dev/null +++ b/packages/core/src/diagnostics/format/fallback.ts @@ -0,0 +1,19 @@ +import type { DiagnosticEvent } from '../contracts'; +import { + formatContextDetail, + joinDetails, +} from './parts'; +import { humanizeEventName } from './humanize'; + +export function formatFallbackEvent(event: DiagnosticEvent): string { + const details = joinDetails([ + formatContextDetail({ area: event.area }, 'area'), + ...Object.keys(event.context ?? {}).map(key => formatContextDetail(event.context, key)), + ]); + const message = humanizeEventName(event.event); + if (details) { + return `${message}: ${details}`; + } + + return message; +} diff --git a/packages/core/src/diagnostics/format/humanize.ts b/packages/core/src/diagnostics/format/humanize.ts new file mode 100644 index 000000000..2ef514647 --- /dev/null +++ b/packages/core/src/diagnostics/format/humanize.ts @@ -0,0 +1,9 @@ +export function humanizeEventName(event: string): string { + return event + .split('-') + .filter(Boolean) + .map((part, index) => index === 0 + ? `${part.charAt(0).toUpperCase()}${part.slice(1)}` + : part) + .join(' '); +} diff --git a/packages/core/src/diagnostics/format/known.ts b/packages/core/src/diagnostics/format/known.ts new file mode 100644 index 000000000..2b12c8c6e --- /dev/null +++ b/packages/core/src/diagnostics/format/known.ts @@ -0,0 +1,44 @@ +import type { DiagnosticEvent } from '../contracts'; +import { formatAnalysisEvent } from './events/analysis'; +import { formatCommandEvent } from './events/command'; +import { + formatExtensionLifecycleEvent, + formatExtensionWebviewEvent, +} from './events/extension'; +import { formatGraphQueryEvent } from './events/graphQuery'; +import { + formatIndexingEvent, +} from './events/indexing'; +import { formatWorkspaceEvent } from './events/workspace'; + +export function formatKnownEvent(event: DiagnosticEvent): string | undefined { + if (event.area === 'cli') { + return formatCommandEvent(event.event, event.context); + } + + if (event.area === 'extension.analysis') { + return formatAnalysisEvent(event.event, event.context); + } + + if (event.area === 'workspace') { + return formatWorkspaceEvent(event.event, event.context); + } + + if (event.area === 'indexing') { + return formatIndexingEvent(event.event, event.context); + } + + if (event.area === 'graph-query') { + return formatGraphQueryEvent(event.event, event.context); + } + + if (event.area === 'extension.lifecycle') { + return formatExtensionLifecycleEvent(event.event, event.context); + } + + if (event.area === 'extension.webview') { + return formatExtensionWebviewEvent(event.event, event.context); + } + + return undefined; +} diff --git a/packages/core/src/diagnostics/format/line.ts b/packages/core/src/diagnostics/format/line.ts new file mode 100644 index 000000000..27dd2347d --- /dev/null +++ b/packages/core/src/diagnostics/format/line.ts @@ -0,0 +1,7 @@ +import type { DiagnosticEvent } from '../contracts'; +import { formatFallbackEvent } from './fallback'; +import { formatKnownEvent } from './known'; + +export function formatDiagnosticEventLine(event: DiagnosticEvent): string { + return `[CodeGraphy] ${formatKnownEvent(event) ?? formatFallbackEvent(event)}`; +} diff --git a/packages/core/src/diagnostics/format/parts.ts b/packages/core/src/diagnostics/format/parts.ts new file mode 100644 index 000000000..d359657ca --- /dev/null +++ b/packages/core/src/diagnostics/format/parts.ts @@ -0,0 +1,33 @@ +import type { DiagnosticContextValue } from '../contracts'; + +export function formatCount(value: DiagnosticContextValue | undefined, noun: string): string | undefined { + if (typeof value !== 'number') { + return undefined; + } + return `${value} ${noun}${value === 1 ? '' : 's'}`; +} + +export function formatScalar(value: DiagnosticContextValue | undefined): string | undefined { + if (value === null || typeof value === 'undefined') { + return undefined; + } + + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return String(value); + } + + return JSON.stringify(value); +} + +export function formatContextDetail( + context: Record | undefined, + key: string, + label: string = key, +): string | undefined { + const value = formatScalar(context?.[key]); + return value ? `${label}=${value}` : undefined; +} + +export function joinDetails(details: Array): string { + return details.filter((detail): detail is string => Boolean(detail)).join(', '); +} diff --git a/packages/core/src/diagnostics/normalize/collections.ts b/packages/core/src/diagnostics/normalize/collections.ts new file mode 100644 index 000000000..7310ca327 --- /dev/null +++ b/packages/core/src/diagnostics/normalize/collections.ts @@ -0,0 +1,25 @@ +import type { DiagnosticContextValue } from '../contracts'; + +type NormalizeValue = (value: unknown) => DiagnosticContextValue; + +export function normalizeCollectionContextValue( + value: unknown, + normalizeValue: NormalizeValue, +): DiagnosticContextValue | undefined { + if (Array.isArray(value)) { + return value.map(normalizeValue); + } + + if (value instanceof Set) { + return [...value].map(normalizeValue); + } + + if (value instanceof Map) { + return [...value.entries()].map(([key, entryValue]) => ({ + key: normalizeValue(key), + value: normalizeValue(entryValue), + })); + } + + return undefined; +} diff --git a/packages/core/src/diagnostics/normalize/context.ts b/packages/core/src/diagnostics/normalize/context.ts new file mode 100644 index 000000000..e10def8c8 --- /dev/null +++ b/packages/core/src/diagnostics/normalize/context.ts @@ -0,0 +1,35 @@ +import type { DiagnosticContextValue } from '../contracts'; +import { normalizeCollectionContextValue } from './collections'; +import { normalizeObjectContextValue } from './objects'; +import { + isScalarContextValue, + normalizeError, + normalizeNonJsonPrimitiveContextValue, +} from './primitives'; + +function normalizeContextValue(value: unknown): DiagnosticContextValue { + if (isScalarContextValue(value)) { + return value; + } + + if (value instanceof Error) { + return normalizeError(value); + } + + return normalizeCollectionContextValue(value, normalizeContextValue) + ?? normalizeObjectContextValue(value, normalizeContextValue) + ?? normalizeNonJsonPrimitiveContextValue(value) + ?? 'unknown'; +} + +export function normalizeContext(context: Record | undefined): Record | undefined { + if (!context) { + return undefined; + } + + const normalized: Record = {}; + for (const [key, value] of Object.entries(context)) { + normalized[key] = normalizeContextValue(value); + } + return normalized; +} diff --git a/packages/core/src/diagnostics/normalize/objects.ts b/packages/core/src/diagnostics/normalize/objects.ts new file mode 100644 index 000000000..db7b0bd44 --- /dev/null +++ b/packages/core/src/diagnostics/normalize/objects.ts @@ -0,0 +1,18 @@ +import type { DiagnosticContextValue } from '../contracts'; + +type NormalizeValue = (value: unknown) => DiagnosticContextValue; + +export function normalizeObjectContextValue( + value: unknown, + normalizeValue: NormalizeValue, +): DiagnosticContextValue | undefined { + if (value === null || typeof value !== 'object') { + return undefined; + } + + const normalized: Record = {}; + for (const [key, entryValue] of Object.entries(value)) { + normalized[key] = normalizeValue(entryValue); + } + return normalized; +} diff --git a/packages/core/src/diagnostics/normalize/primitives.ts b/packages/core/src/diagnostics/normalize/primitives.ts new file mode 100644 index 000000000..2dcc6aa32 --- /dev/null +++ b/packages/core/src/diagnostics/normalize/primitives.ts @@ -0,0 +1,35 @@ +import type { DiagnosticContextValue } from '../contracts'; + +export function normalizeError(error: Error): Record { + return { + name: error.name, + message: error.message, + }; +} + +export function isScalarContextValue(value: unknown): value is null | string | number | boolean { + return value === null + || typeof value === 'string' + || typeof value === 'number' + || typeof value === 'boolean'; +} + +export function normalizeNonJsonPrimitiveContextValue(value: unknown): DiagnosticContextValue | undefined { + if (typeof value === 'undefined') { + return 'undefined'; + } + + if (typeof value === 'bigint') { + return value.toString(); + } + + if (typeof value === 'symbol') { + return value.description ? `Symbol(${value.description})` : 'Symbol()'; + } + + if (typeof value === 'function') { + return value.name ? `[Function: ${value.name}]` : '[Function]'; + } + + return undefined; +} From cb6fda5bd96057a386a2b17bd289c39a114475e4 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 14:23:43 -0700 Subject: [PATCH 102/192] refactor: split graph publish mutation sites --- .../graphView/analysis/execution/publish.ts | 444 +----------------- .../execution/publish/equality/collections.ts | 53 +++ .../execution/publish/equality/graph.ts | 35 ++ .../execution/publish/equality/node.ts | 22 + .../execution/publish/equality/payload.ts | 17 + .../execution/publish/equality/values.ts | 13 + .../analysis/execution/publish/groupInputs.ts | 47 ++ .../analysis/execution/publish/messages.ts | 69 +++ .../execution/publish/metrics/changedPaths.ts | 63 +++ .../execution/publish/metrics/patch.ts | 44 ++ .../execution/publish/metrics/updates.ts | 39 ++ .../analysis/execution/publish/plan.ts | 63 +++ .../analysis/execution/publish/status.ts | 25 + 13 files changed, 501 insertions(+), 433 deletions(-) create mode 100644 packages/extension/src/extension/graphView/analysis/execution/publish/equality/collections.ts create mode 100644 packages/extension/src/extension/graphView/analysis/execution/publish/equality/graph.ts create mode 100644 packages/extension/src/extension/graphView/analysis/execution/publish/equality/node.ts create mode 100644 packages/extension/src/extension/graphView/analysis/execution/publish/equality/payload.ts create mode 100644 packages/extension/src/extension/graphView/analysis/execution/publish/equality/values.ts create mode 100644 packages/extension/src/extension/graphView/analysis/execution/publish/groupInputs.ts create mode 100644 packages/extension/src/extension/graphView/analysis/execution/publish/messages.ts create mode 100644 packages/extension/src/extension/graphView/analysis/execution/publish/metrics/changedPaths.ts create mode 100644 packages/extension/src/extension/graphView/analysis/execution/publish/metrics/patch.ts create mode 100644 packages/extension/src/extension/graphView/analysis/execution/publish/metrics/updates.ts create mode 100644 packages/extension/src/extension/graphView/analysis/execution/publish/plan.ts create mode 100644 packages/extension/src/extension/graphView/analysis/execution/publish/status.ts diff --git a/packages/extension/src/extension/graphView/analysis/execution/publish.ts b/packages/extension/src/extension/graphView/analysis/execution/publish.ts index ec761b51d..f872babd8 100644 --- a/packages/extension/src/extension/graphView/analysis/execution/publish.ts +++ b/packages/extension/src/extension/graphView/analysis/execution/publish.ts @@ -1,443 +1,21 @@ -import type { IGraphData, IGraphNode } from '../../../../shared/graph/contracts'; +import type { IGraphData } from '../../../../shared/graph/contracts'; import type { GraphViewAnalysisExecutionHandlers, GraphViewAnalysisExecutionState, } from '../execution'; -import type { IGraphNodeMetricsUpdate } from '../../../../shared/protocol/extensionToWebview'; -import type { CodeGraphyIndexFreshness } from '../../../repoSettings/freshness'; +import { + publishGraphDataMessage, + publishRawGraphUpdate, + publishStaticGraphMessages, +} from './publish/messages'; +import { createGraphPublicationPlan } from './publish/plan'; +import { + resolveGraphIndexStatus, + shouldReportGraphViewUpdateProgress, +} from './publish/status'; export const EMPTY_GRAPH_DATA: IGraphData = { nodes: [], edges: [] }; -function resolveGraphIndexStatus( - state: GraphViewAnalysisExecutionState | undefined, - hasIndex: boolean, -): { freshness: CodeGraphyIndexFreshness; detail: string } { - const status = state?.analyzer?.getIndexStatus?.(); - if (status) { - return status; - } - - return { - freshness: hasIndex ? 'fresh' : 'missing', - detail: hasIndex - ? 'CodeGraphy index is fresh.' - : 'CodeGraphy index is missing. Index the workspace to build the graph.', - }; -} - -function shouldReportGraphViewUpdateProgress( - state: GraphViewAnalysisExecutionState, -): boolean { - return state.mode === 'index' || state.mode === 'refresh' || state.mode === 'incremental'; -} - -function areGraphDataPayloadsEqual(left: IGraphData, right: IGraphData): boolean { - if (left === right) { - return true; - } - - if (left.nodes.length !== right.nodes.length || left.edges.length !== right.edges.length) { - return false; - } - - try { - return JSON.stringify(left) === JSON.stringify(right); - } catch { - return false; - } -} - -function areGraphGroupSymbolInputsEqual( - left: IGraphNode['symbol'], - right: IGraphNode['symbol'], -): boolean { - return createGraphGroupSymbolSignature(left) === createGraphGroupSymbolSignature(right); -} - -function createGraphGroupSymbolSignature(symbol: IGraphNode['symbol']): string | undefined { - if (!symbol) { - return undefined; - } - - return JSON.stringify([ - symbol.kind, - symbol.pluginKind, - symbol.source, - symbol.language, - symbol.filePath, - ]); -} - -function areGraphGroupNodeInputsEqual(left: IGraphNode, right: IGraphNode): boolean { - return left.id === right.id - && left.nodeType === right.nodeType - && areGraphGroupSymbolInputsEqual(left.symbol, right.symbol); -} - -function doGraphViewGroupsNeedRecompute( - currentRawGraphData: IGraphData, - nextRawGraphData: IGraphData, -): boolean { - if (currentRawGraphData.nodes.length !== nextRawGraphData.nodes.length) { - return true; - } - - const nextNodesById = new Map(nextRawGraphData.nodes.map(node => [node.id, node])); - for (const currentNode of currentRawGraphData.nodes) { - const nextNode = nextNodesById.get(currentNode.id); - if (!nextNode || !areGraphGroupNodeInputsEqual(currentNode, nextNode)) { - return true; - } - } - - return false; -} - -function normalizeGraphPath(filePath: string): string { - return filePath.replace(/\\/g, '/'); -} - -function isGraphNodeForChangedPath(nodeId: string, changedFilePath: string): boolean { - const normalizedNodeId = normalizeGraphPath(nodeId); - const normalizedChangedFilePath = normalizeGraphPath(changedFilePath); - return normalizedChangedFilePath === normalizedNodeId - || normalizedChangedFilePath.endsWith(`/${normalizedNodeId}`); -} - -function isGraphNodeAffectedByChangedPath(node: IGraphNode, changedFilePath: string): boolean { - const symbolFilePath = node.symbol?.filePath; - return isGraphNodeForChangedPath(node.id, changedFilePath) - || (symbolFilePath ? isGraphNodeForChangedPath(symbolFilePath, changedFilePath) : false); -} - -function findGraphNodeByChangedPath( - graphData: IGraphData, - changedFilePath: string, -): IGraphNode | undefined { - return graphData.nodes.find(node => isGraphNodeForChangedPath(node.id, changedFilePath)); -} - -function hasChangedNodeMetricDifference( - currentRawGraphData: IGraphData, - nextRawGraphData: IGraphData, - changedFilePaths: readonly string[] | undefined, -): boolean { - if (!changedFilePaths?.length) { - return false; - } - - for (const changedFilePath of changedFilePaths) { - const currentNode = findGraphNodeByChangedPath(currentRawGraphData, changedFilePath); - const nextNode = findGraphNodeByChangedPath(nextRawGraphData, changedFilePath); - if (!currentNode || !nextNode) { - continue; - } - - if ( - currentNode.fileSize !== nextNode.fileSize - || currentNode.churn !== nextNode.churn - ) { - return true; - } - } - - return false; -} - -function collectChangedPathNodes( - graphData: IGraphData, - changedFilePaths: readonly string[], -): IGraphNode[] { - return graphData.nodes.filter(node => - changedFilePaths.some(changedFilePath => - isGraphNodeAffectedByChangedPath(node, changedFilePath), - ), - ); -} - -function createNodeMap(nodes: readonly IGraphNode[]): Map { - return new Map(nodes.map(node => [node.id, node])); -} - -function areGraphRecordsEqual(left: Record, right: Record): boolean { - const leftRecord = left as unknown as Record; - const rightRecord = right as unknown as Record; - const keys = new Set([...Object.keys(leftRecord), ...Object.keys(rightRecord)]); - for (const key of keys) { - if (!areGraphValuesEqual(leftRecord[key], rightRecord[key])) { - return false; - } - } - - return true; -} - -function areGraphArraysEqual(left: readonly unknown[], right: readonly unknown[]): boolean { - return left.length === right.length - && left.every((leftValue, index) => areGraphValuesEqual(leftValue, right[index])); -} - -function isGraphRecord(value: unknown): value is Record { - return value !== null - && typeof value === 'object' - && !Array.isArray(value); -} - -function compareGraphArrayValues(left: unknown, right: unknown): boolean | undefined { - if (!Array.isArray(left) && !Array.isArray(right)) { - return undefined; - } - - return Array.isArray(left) && Array.isArray(right) && areGraphArraysEqual(left, right); -} - -function compareGraphRecordValues(left: unknown, right: unknown): boolean { - return isGraphRecord(left) && isGraphRecord(right) && areGraphRecordsEqual(left, right); -} - -function areGraphValuesEqual(left: unknown, right: unknown): boolean { - if (Object.is(left, right)) { - return true; - } - - return compareGraphArrayValues(left, right) ?? compareGraphRecordValues(left, right); -} - -function areNodesEqualIgnoringMetrics(left: IGraphNode, right: IGraphNode): boolean { - if (left === right) { - return true; - } - - const leftRecord = left as unknown as Record; - const rightRecord = right as unknown as Record; - const keys = new Set([...Object.keys(leftRecord), ...Object.keys(rightRecord)]); - keys.delete('churn'); - keys.delete('fileSize'); - - for (const key of keys) { - if (!areGraphValuesEqual(leftRecord[key], rightRecord[key])) { - return false; - } - } - - return true; -} - -function areGraphDataEqualIgnoringNodeMetrics( - currentRawGraphData: IGraphData, - nextRawGraphData: IGraphData, -): boolean { - if ( - currentRawGraphData.nodes.length !== nextRawGraphData.nodes.length - || currentRawGraphData.edges.length !== nextRawGraphData.edges.length - ) { - return false; - } - - for (let index = 0; index < currentRawGraphData.nodes.length; index += 1) { - if (!areNodesEqualIgnoringMetrics( - currentRawGraphData.nodes[index], - nextRawGraphData.nodes[index], - )) { - return false; - } - } - - for (let index = 0; index < currentRawGraphData.edges.length; index += 1) { - if (!areGraphValuesEqual( - currentRawGraphData.edges[index], - nextRawGraphData.edges[index], - )) { - return false; - } - } - - return true; -} - -function createMetricOnlyGraphUpdate( - currentRawGraphData: IGraphData | undefined, - nextRawGraphData: IGraphData, - changedFilePaths: readonly string[] | undefined, -): IGraphNodeMetricsUpdate[] | undefined { - const changedPaths = changedFilePaths ?? []; - if (!canConsiderMetricOnlyGraphUpdate(currentRawGraphData, nextRawGraphData, changedFilePaths)) { - return undefined; - } - - if (!areGraphDataEqualIgnoringNodeMetrics(currentRawGraphData, nextRawGraphData)) { - return undefined; - } - - const currentNodes = collectChangedPathNodes(currentRawGraphData, changedPaths); - const nextNodes = collectChangedPathNodes(nextRawGraphData, changedPaths); - if (currentNodes.length === 0 || currentNodes.length !== nextNodes.length) { - return undefined; - } - - return collectMetricOnlyGraphUpdates(currentNodes, createNodeMap(nextNodes)); -} - -function canConsiderMetricOnlyGraphUpdate( - currentRawGraphData: IGraphData | undefined, - nextRawGraphData: IGraphData, - changedFilePaths: readonly string[] | undefined, -): currentRawGraphData is IGraphData { - return Boolean( - currentRawGraphData - && changedFilePaths?.length - && currentRawGraphData.nodes.length === nextRawGraphData.nodes.length - && currentRawGraphData.edges.length === nextRawGraphData.edges.length, - ); -} - -function haveGraphNodeMetricsChanged(currentNode: IGraphNode, nextNode: IGraphNode): boolean { - return currentNode.fileSize !== nextNode.fileSize || currentNode.churn !== nextNode.churn; -} - -function createGraphNodeMetricsUpdate(nextNode: IGraphNode): IGraphNodeMetricsUpdate { - return { - id: nextNode.id, - fileSize: nextNode.fileSize, - churn: nextNode.churn, - }; -} - -function collectMetricOnlyGraphUpdates( - currentNodes: readonly IGraphNode[], - nextNodesById: ReadonlyMap, -): IGraphNodeMetricsUpdate[] | undefined { - const updates: IGraphNodeMetricsUpdate[] = []; - - for (const currentNode of currentNodes) { - const nextNode = nextNodesById.get(currentNode.id); - if (!nextNode || !areNodesEqualIgnoringMetrics(currentNode, nextNode)) { - return undefined; - } - - if (haveGraphNodeMetricsChanged(currentNode, nextNode)) { - updates.push(createGraphNodeMetricsUpdate(nextNode)); - } - } - - return updates.length > 0 ? updates : undefined; -} - -function canReuseCurrentGraphPublication( - state: GraphViewAnalysisExecutionState, - currentRawGraphData: IGraphData | undefined, - rawGraphData: IGraphData, - actualHasIndex: boolean, - freshness: CodeGraphyIndexFreshness, -): boolean { - if (state.mode !== 'incremental' || !actualHasIndex || freshness !== 'fresh') { - return false; - } - - return currentRawGraphData - ? !hasChangedNodeMetricDifference(currentRawGraphData, rawGraphData, state.changedFilePaths) - && areGraphDataPayloadsEqual(currentRawGraphData, rawGraphData) - : false; -} - -interface GraphPublicationPlan { - currentRawGraphData: IGraphData | undefined; - metricOnlyUpdate: IGraphNodeMetricsUpdate[] | undefined; - reuseCurrentGraphPublication: boolean; - shouldSendMetricPatch: boolean; -} - -function createGraphPublicationPlan( - state: GraphViewAnalysisExecutionState, - handlers: GraphViewAnalysisExecutionHandlers, - rawGraphData: IGraphData, - actualHasIndex: boolean, - freshness: CodeGraphyIndexFreshness, -): GraphPublicationPlan { - const currentRawGraphData = handlers.getRawGraphData?.(); - const metricOnlyUpdate = createMetricOnlyGraphUpdate( - currentRawGraphData, - rawGraphData, - state.changedFilePaths, - ); - - return { - currentRawGraphData, - metricOnlyUpdate, - reuseCurrentGraphPublication: canReuseCurrentGraphPublication( - state, - currentRawGraphData, - rawGraphData, - actualHasIndex, - freshness, - ), - shouldSendMetricPatch: metricOnlyUpdate !== undefined - && handlers.sendGraphNodeMetricsUpdated !== undefined, - }; -} - -function publishRawGraphUpdate( - state: GraphViewAnalysisExecutionState, - handlers: GraphViewAnalysisExecutionHandlers, - rawGraphData: IGraphData, - plan: GraphPublicationPlan, -): void { - if (plan.reuseCurrentGraphPublication) { - return; - } - - handlers.setRawGraphData(rawGraphData); - handlers.updateViewContext(); - handlers.applyViewTransform(); - publishGraphGroupsIfNeeded(state, handlers, rawGraphData, plan.currentRawGraphData); -} - -function publishGraphGroupsIfNeeded( - state: GraphViewAnalysisExecutionState, - handlers: GraphViewAnalysisExecutionHandlers, - rawGraphData: IGraphData, - currentRawGraphData: IGraphData | undefined, -): void { - const canSkipGroupPublication = state.mode === 'incremental' - && currentRawGraphData - && !doGraphViewGroupsNeedRecompute(currentRawGraphData, rawGraphData); - - if (canSkipGroupPublication) { - return; - } - - handlers.computeMergedGroups(); - handlers.sendGroupsUpdated(); -} - -function publishStaticGraphMessages(handlers: GraphViewAnalysisExecutionHandlers): void { - handlers.sendDepthState(); - handlers.sendPluginStatuses(); - handlers.sendDecorations(); - handlers.sendContextMenuItems(); - handlers.sendPluginExporters?.(); - handlers.sendPluginToolbarActions?.(); - handlers.sendGraphViewContributionStatuses?.(); - handlers.sendPluginWebviewInjections?.(); -} - -function publishGraphDataMessage( - handlers: GraphViewAnalysisExecutionHandlers, - graphData: IGraphData, - plan: GraphPublicationPlan, -): void { - if (plan.reuseCurrentGraphPublication) { - return; - } - - if (plan.shouldSendMetricPatch && plan.metricOnlyUpdate) { - handlers.sendGraphNodeMetricsUpdated?.(plan.metricOnlyUpdate); - return; - } - - handlers.sendGraphDataUpdated(graphData); -} - export function publishEmptyGraph( handlers: GraphViewAnalysisExecutionHandlers, hasIndex: boolean = false, diff --git a/packages/extension/src/extension/graphView/analysis/execution/publish/equality/collections.ts b/packages/extension/src/extension/graphView/analysis/execution/publish/equality/collections.ts new file mode 100644 index 000000000..6c42cc9e6 --- /dev/null +++ b/packages/extension/src/extension/graphView/analysis/execution/publish/equality/collections.ts @@ -0,0 +1,53 @@ +type CompareGraphValue = (left: unknown, right: unknown) => boolean; + +function areGraphRecordsEqual( + left: Record, + right: Record, + compareValue: CompareGraphValue, +): boolean { + const leftRecord = left as unknown as Record; + const rightRecord = right as unknown as Record; + const keys = new Set([...Object.keys(leftRecord), ...Object.keys(rightRecord)]); + for (const key of keys) { + if (!compareValue(leftRecord[key], rightRecord[key])) { + return false; + } + } + + return true; +} + +function areGraphArraysEqual( + left: readonly unknown[], + right: readonly unknown[], + compareValue: CompareGraphValue, +): boolean { + return left.length === right.length + && left.every((leftValue, index) => compareValue(leftValue, right[index])); +} + +function isGraphRecord(value: unknown): value is Record { + return value !== null + && typeof value === 'object' + && !Array.isArray(value); +} + +export function compareGraphArrayValues( + left: unknown, + right: unknown, + compareValue: CompareGraphValue, +): boolean | undefined { + if (!Array.isArray(left) && !Array.isArray(right)) { + return undefined; + } + + return Array.isArray(left) && Array.isArray(right) && areGraphArraysEqual(left, right, compareValue); +} + +export function compareGraphRecordValues( + left: unknown, + right: unknown, + compareValue: CompareGraphValue, +): boolean { + return isGraphRecord(left) && isGraphRecord(right) && areGraphRecordsEqual(left, right, compareValue); +} diff --git a/packages/extension/src/extension/graphView/analysis/execution/publish/equality/graph.ts b/packages/extension/src/extension/graphView/analysis/execution/publish/equality/graph.ts new file mode 100644 index 000000000..4607180fd --- /dev/null +++ b/packages/extension/src/extension/graphView/analysis/execution/publish/equality/graph.ts @@ -0,0 +1,35 @@ +import type { IGraphData } from '../../../../../../shared/graph/contracts'; +import { areNodesEqualIgnoringMetrics } from './node'; +import { areGraphValuesEqual } from './values'; + +export function areGraphDataEqualIgnoringNodeMetrics( + currentRawGraphData: IGraphData, + nextRawGraphData: IGraphData, +): boolean { + if ( + currentRawGraphData.nodes.length !== nextRawGraphData.nodes.length + || currentRawGraphData.edges.length !== nextRawGraphData.edges.length + ) { + return false; + } + + for (let index = 0; index < currentRawGraphData.nodes.length; index += 1) { + if (!areNodesEqualIgnoringMetrics( + currentRawGraphData.nodes[index], + nextRawGraphData.nodes[index], + )) { + return false; + } + } + + for (let index = 0; index < currentRawGraphData.edges.length; index += 1) { + if (!areGraphValuesEqual( + currentRawGraphData.edges[index], + nextRawGraphData.edges[index], + )) { + return false; + } + } + + return true; +} diff --git a/packages/extension/src/extension/graphView/analysis/execution/publish/equality/node.ts b/packages/extension/src/extension/graphView/analysis/execution/publish/equality/node.ts new file mode 100644 index 000000000..224070340 --- /dev/null +++ b/packages/extension/src/extension/graphView/analysis/execution/publish/equality/node.ts @@ -0,0 +1,22 @@ +import type { IGraphNode } from '../../../../../../shared/graph/contracts'; +import { areGraphValuesEqual } from './values'; + +export function areNodesEqualIgnoringMetrics(left: IGraphNode, right: IGraphNode): boolean { + if (left === right) { + return true; + } + + const leftRecord = left as unknown as Record; + const rightRecord = right as unknown as Record; + const keys = new Set([...Object.keys(leftRecord), ...Object.keys(rightRecord)]); + keys.delete('churn'); + keys.delete('fileSize'); + + for (const key of keys) { + if (!areGraphValuesEqual(leftRecord[key], rightRecord[key])) { + return false; + } + } + + return true; +} diff --git a/packages/extension/src/extension/graphView/analysis/execution/publish/equality/payload.ts b/packages/extension/src/extension/graphView/analysis/execution/publish/equality/payload.ts new file mode 100644 index 000000000..6b9f2af51 --- /dev/null +++ b/packages/extension/src/extension/graphView/analysis/execution/publish/equality/payload.ts @@ -0,0 +1,17 @@ +import type { IGraphData } from '../../../../../../shared/graph/contracts'; + +export function areGraphDataPayloadsEqual(left: IGraphData, right: IGraphData): boolean { + if (left === right) { + return true; + } + + if (left.nodes.length !== right.nodes.length || left.edges.length !== right.edges.length) { + return false; + } + + try { + return JSON.stringify(left) === JSON.stringify(right); + } catch { + return false; + } +} diff --git a/packages/extension/src/extension/graphView/analysis/execution/publish/equality/values.ts b/packages/extension/src/extension/graphView/analysis/execution/publish/equality/values.ts new file mode 100644 index 000000000..801a8dfb9 --- /dev/null +++ b/packages/extension/src/extension/graphView/analysis/execution/publish/equality/values.ts @@ -0,0 +1,13 @@ +import { + compareGraphArrayValues, + compareGraphRecordValues, +} from './collections'; + +export function areGraphValuesEqual(left: unknown, right: unknown): boolean { + if (Object.is(left, right)) { + return true; + } + + return compareGraphArrayValues(left, right, areGraphValuesEqual) + ?? compareGraphRecordValues(left, right, areGraphValuesEqual); +} diff --git a/packages/extension/src/extension/graphView/analysis/execution/publish/groupInputs.ts b/packages/extension/src/extension/graphView/analysis/execution/publish/groupInputs.ts new file mode 100644 index 000000000..96d809d01 --- /dev/null +++ b/packages/extension/src/extension/graphView/analysis/execution/publish/groupInputs.ts @@ -0,0 +1,47 @@ +import type { IGraphData, IGraphNode } from '../../../../../shared/graph/contracts'; + +function createGraphGroupSymbolSignature(symbol: IGraphNode['symbol']): string | undefined { + if (!symbol) { + return undefined; + } + + return JSON.stringify([ + symbol.kind, + symbol.pluginKind, + symbol.source, + symbol.language, + symbol.filePath, + ]); +} + +function areGraphGroupSymbolInputsEqual( + left: IGraphNode['symbol'], + right: IGraphNode['symbol'], +): boolean { + return createGraphGroupSymbolSignature(left) === createGraphGroupSymbolSignature(right); +} + +function areGraphGroupNodeInputsEqual(left: IGraphNode, right: IGraphNode): boolean { + return left.id === right.id + && left.nodeType === right.nodeType + && areGraphGroupSymbolInputsEqual(left.symbol, right.symbol); +} + +export function doGraphViewGroupsNeedRecompute( + currentRawGraphData: IGraphData, + nextRawGraphData: IGraphData, +): boolean { + if (currentRawGraphData.nodes.length !== nextRawGraphData.nodes.length) { + return true; + } + + const nextNodesById = new Map(nextRawGraphData.nodes.map(node => [node.id, node])); + for (const currentNode of currentRawGraphData.nodes) { + const nextNode = nextNodesById.get(currentNode.id); + if (!nextNode || !areGraphGroupNodeInputsEqual(currentNode, nextNode)) { + return true; + } + } + + return false; +} diff --git a/packages/extension/src/extension/graphView/analysis/execution/publish/messages.ts b/packages/extension/src/extension/graphView/analysis/execution/publish/messages.ts new file mode 100644 index 000000000..85ac19dd6 --- /dev/null +++ b/packages/extension/src/extension/graphView/analysis/execution/publish/messages.ts @@ -0,0 +1,69 @@ +import type { IGraphData } from '../../../../../shared/graph/contracts'; +import type { + GraphViewAnalysisExecutionHandlers, + GraphViewAnalysisExecutionState, +} from '../../execution'; +import { doGraphViewGroupsNeedRecompute } from './groupInputs'; +import type { GraphPublicationPlan } from './plan'; + +export function publishRawGraphUpdate( + state: GraphViewAnalysisExecutionState, + handlers: GraphViewAnalysisExecutionHandlers, + rawGraphData: IGraphData, + plan: GraphPublicationPlan, +): void { + if (plan.reuseCurrentGraphPublication) { + return; + } + + handlers.setRawGraphData(rawGraphData); + handlers.updateViewContext(); + handlers.applyViewTransform(); + publishGraphGroupsIfNeeded(state, handlers, rawGraphData, plan.currentRawGraphData); +} + +function publishGraphGroupsIfNeeded( + state: GraphViewAnalysisExecutionState, + handlers: GraphViewAnalysisExecutionHandlers, + rawGraphData: IGraphData, + currentRawGraphData: IGraphData | undefined, +): void { + const canSkipGroupPublication = state.mode === 'incremental' + && currentRawGraphData + && !doGraphViewGroupsNeedRecompute(currentRawGraphData, rawGraphData); + + if (canSkipGroupPublication) { + return; + } + + handlers.computeMergedGroups(); + handlers.sendGroupsUpdated(); +} + +export function publishStaticGraphMessages(handlers: GraphViewAnalysisExecutionHandlers): void { + handlers.sendDepthState(); + handlers.sendPluginStatuses(); + handlers.sendDecorations(); + handlers.sendContextMenuItems(); + handlers.sendPluginExporters?.(); + handlers.sendPluginToolbarActions?.(); + handlers.sendGraphViewContributionStatuses?.(); + handlers.sendPluginWebviewInjections?.(); +} + +export function publishGraphDataMessage( + handlers: GraphViewAnalysisExecutionHandlers, + graphData: IGraphData, + plan: GraphPublicationPlan, +): void { + if (plan.reuseCurrentGraphPublication) { + return; + } + + if (plan.shouldSendMetricPatch && plan.metricOnlyUpdate) { + handlers.sendGraphNodeMetricsUpdated?.(plan.metricOnlyUpdate); + return; + } + + handlers.sendGraphDataUpdated(graphData); +} diff --git a/packages/extension/src/extension/graphView/analysis/execution/publish/metrics/changedPaths.ts b/packages/extension/src/extension/graphView/analysis/execution/publish/metrics/changedPaths.ts new file mode 100644 index 000000000..dcb07a6c5 --- /dev/null +++ b/packages/extension/src/extension/graphView/analysis/execution/publish/metrics/changedPaths.ts @@ -0,0 +1,63 @@ +import type { IGraphData, IGraphNode } from '../../../../../../shared/graph/contracts'; + +function normalizeGraphPath(filePath: string): string { + return filePath.replace(/\\/g, '/'); +} + +function isGraphNodeForChangedPath(nodeId: string, changedFilePath: string): boolean { + const normalizedNodeId = normalizeGraphPath(nodeId); + const normalizedChangedFilePath = normalizeGraphPath(changedFilePath); + return normalizedChangedFilePath === normalizedNodeId + || normalizedChangedFilePath.endsWith(`/${normalizedNodeId}`); +} + +function isGraphNodeAffectedByChangedPath(node: IGraphNode, changedFilePath: string): boolean { + const symbolFilePath = node.symbol?.filePath; + return isGraphNodeForChangedPath(node.id, changedFilePath) + || (symbolFilePath ? isGraphNodeForChangedPath(symbolFilePath, changedFilePath) : false); +} + +function findGraphNodeByChangedPath( + graphData: IGraphData, + changedFilePath: string, +): IGraphNode | undefined { + return graphData.nodes.find(node => isGraphNodeForChangedPath(node.id, changedFilePath)); +} + +export function hasChangedNodeMetricDifference( + currentRawGraphData: IGraphData, + nextRawGraphData: IGraphData, + changedFilePaths: readonly string[] | undefined, +): boolean { + if (!changedFilePaths?.length) { + return false; + } + + for (const changedFilePath of changedFilePaths) { + const currentNode = findGraphNodeByChangedPath(currentRawGraphData, changedFilePath); + const nextNode = findGraphNodeByChangedPath(nextRawGraphData, changedFilePath); + if (!currentNode || !nextNode) { + continue; + } + + if ( + currentNode.fileSize !== nextNode.fileSize + || currentNode.churn !== nextNode.churn + ) { + return true; + } + } + + return false; +} + +export function collectChangedPathNodes( + graphData: IGraphData, + changedFilePaths: readonly string[], +): IGraphNode[] { + return graphData.nodes.filter(node => + changedFilePaths.some(changedFilePath => + isGraphNodeAffectedByChangedPath(node, changedFilePath), + ), + ); +} diff --git a/packages/extension/src/extension/graphView/analysis/execution/publish/metrics/patch.ts b/packages/extension/src/extension/graphView/analysis/execution/publish/metrics/patch.ts new file mode 100644 index 000000000..a5927e6e4 --- /dev/null +++ b/packages/extension/src/extension/graphView/analysis/execution/publish/metrics/patch.ts @@ -0,0 +1,44 @@ +import type { IGraphData } from '../../../../../../shared/graph/contracts'; +import type { IGraphNodeMetricsUpdate } from '../../../../../../shared/protocol/extensionToWebview'; +import { collectChangedPathNodes } from './changedPaths'; +import { + collectMetricOnlyGraphUpdates, + createNodeMap, +} from './updates'; +import { areGraphDataEqualIgnoringNodeMetrics } from '../equality/graph'; + +function canConsiderMetricOnlyGraphUpdate( + currentRawGraphData: IGraphData | undefined, + nextRawGraphData: IGraphData, + changedFilePaths: readonly string[] | undefined, +): currentRawGraphData is IGraphData { + return Boolean( + currentRawGraphData + && changedFilePaths?.length + && currentRawGraphData.nodes.length === nextRawGraphData.nodes.length + && currentRawGraphData.edges.length === nextRawGraphData.edges.length, + ); +} + +export function createMetricOnlyGraphUpdate( + currentRawGraphData: IGraphData | undefined, + nextRawGraphData: IGraphData, + changedFilePaths: readonly string[] | undefined, +): IGraphNodeMetricsUpdate[] | undefined { + const changedPaths = changedFilePaths ?? []; + if (!canConsiderMetricOnlyGraphUpdate(currentRawGraphData, nextRawGraphData, changedFilePaths)) { + return undefined; + } + + if (!areGraphDataEqualIgnoringNodeMetrics(currentRawGraphData, nextRawGraphData)) { + return undefined; + } + + const currentNodes = collectChangedPathNodes(currentRawGraphData, changedPaths); + const nextNodes = collectChangedPathNodes(nextRawGraphData, changedPaths); + if (currentNodes.length === 0 || currentNodes.length !== nextNodes.length) { + return undefined; + } + + return collectMetricOnlyGraphUpdates(currentNodes, createNodeMap(nextNodes)); +} diff --git a/packages/extension/src/extension/graphView/analysis/execution/publish/metrics/updates.ts b/packages/extension/src/extension/graphView/analysis/execution/publish/metrics/updates.ts new file mode 100644 index 000000000..0e1c47d0d --- /dev/null +++ b/packages/extension/src/extension/graphView/analysis/execution/publish/metrics/updates.ts @@ -0,0 +1,39 @@ +import type { IGraphNode } from '../../../../../../shared/graph/contracts'; +import type { IGraphNodeMetricsUpdate } from '../../../../../../shared/protocol/extensionToWebview'; +import { areNodesEqualIgnoringMetrics } from '../equality/node'; + +export function createNodeMap(nodes: readonly IGraphNode[]): Map { + return new Map(nodes.map(node => [node.id, node])); +} + +function haveGraphNodeMetricsChanged(currentNode: IGraphNode, nextNode: IGraphNode): boolean { + return currentNode.fileSize !== nextNode.fileSize || currentNode.churn !== nextNode.churn; +} + +function createGraphNodeMetricsUpdate(nextNode: IGraphNode): IGraphNodeMetricsUpdate { + return { + id: nextNode.id, + fileSize: nextNode.fileSize, + churn: nextNode.churn, + }; +} + +export function collectMetricOnlyGraphUpdates( + currentNodes: readonly IGraphNode[], + nextNodesById: ReadonlyMap, +): IGraphNodeMetricsUpdate[] | undefined { + const updates: IGraphNodeMetricsUpdate[] = []; + + for (const currentNode of currentNodes) { + const nextNode = nextNodesById.get(currentNode.id); + if (!nextNode || !areNodesEqualIgnoringMetrics(currentNode, nextNode)) { + return undefined; + } + + if (haveGraphNodeMetricsChanged(currentNode, nextNode)) { + updates.push(createGraphNodeMetricsUpdate(nextNode)); + } + } + + return updates.length > 0 ? updates : undefined; +} diff --git a/packages/extension/src/extension/graphView/analysis/execution/publish/plan.ts b/packages/extension/src/extension/graphView/analysis/execution/publish/plan.ts new file mode 100644 index 000000000..11b7c394a --- /dev/null +++ b/packages/extension/src/extension/graphView/analysis/execution/publish/plan.ts @@ -0,0 +1,63 @@ +import type { IGraphData } from '../../../../../shared/graph/contracts'; +import type { IGraphNodeMetricsUpdate } from '../../../../../shared/protocol/extensionToWebview'; +import type { CodeGraphyIndexFreshness } from '../../../../repoSettings/freshness'; +import type { + GraphViewAnalysisExecutionHandlers, + GraphViewAnalysisExecutionState, +} from '../../execution'; +import { hasChangedNodeMetricDifference } from './metrics/changedPaths'; +import { createMetricOnlyGraphUpdate } from './metrics/patch'; +import { areGraphDataPayloadsEqual } from './equality/payload'; + +export interface GraphPublicationPlan { + currentRawGraphData: IGraphData | undefined; + metricOnlyUpdate: IGraphNodeMetricsUpdate[] | undefined; + reuseCurrentGraphPublication: boolean; + shouldSendMetricPatch: boolean; +} + +function canReuseCurrentGraphPublication( + state: GraphViewAnalysisExecutionState, + currentRawGraphData: IGraphData | undefined, + rawGraphData: IGraphData, + actualHasIndex: boolean, + freshness: CodeGraphyIndexFreshness, +): boolean { + if (state.mode !== 'incremental' || !actualHasIndex || freshness !== 'fresh') { + return false; + } + + return currentRawGraphData + ? !hasChangedNodeMetricDifference(currentRawGraphData, rawGraphData, state.changedFilePaths) + && areGraphDataPayloadsEqual(currentRawGraphData, rawGraphData) + : false; +} + +export function createGraphPublicationPlan( + state: GraphViewAnalysisExecutionState, + handlers: GraphViewAnalysisExecutionHandlers, + rawGraphData: IGraphData, + actualHasIndex: boolean, + freshness: CodeGraphyIndexFreshness, +): GraphPublicationPlan { + const currentRawGraphData = handlers.getRawGraphData?.(); + const metricOnlyUpdate = createMetricOnlyGraphUpdate( + currentRawGraphData, + rawGraphData, + state.changedFilePaths, + ); + + return { + currentRawGraphData, + metricOnlyUpdate, + reuseCurrentGraphPublication: canReuseCurrentGraphPublication( + state, + currentRawGraphData, + rawGraphData, + actualHasIndex, + freshness, + ), + shouldSendMetricPatch: metricOnlyUpdate !== undefined + && handlers.sendGraphNodeMetricsUpdated !== undefined, + }; +} diff --git a/packages/extension/src/extension/graphView/analysis/execution/publish/status.ts b/packages/extension/src/extension/graphView/analysis/execution/publish/status.ts new file mode 100644 index 000000000..3247c8de8 --- /dev/null +++ b/packages/extension/src/extension/graphView/analysis/execution/publish/status.ts @@ -0,0 +1,25 @@ +import type { CodeGraphyIndexFreshness } from '../../../../repoSettings/freshness'; +import type { GraphViewAnalysisExecutionState } from '../../execution'; + +export function resolveGraphIndexStatus( + state: GraphViewAnalysisExecutionState | undefined, + hasIndex: boolean, +): { freshness: CodeGraphyIndexFreshness; detail: string } { + const status = state?.analyzer?.getIndexStatus?.(); + if (status) { + return status; + } + + return { + freshness: hasIndex ? 'fresh' : 'missing', + detail: hasIndex + ? 'CodeGraphy index is fresh.' + : 'CodeGraphy index is missing. Index the workspace to build the graph.', + }; +} + +export function shouldReportGraphViewUpdateProgress( + state: GraphViewAnalysisExecutionState, +): boolean { + return state.mode === 'index' || state.mode === 'refresh' || state.mode === 'incremental'; +} From 916db0a7489dbc2bc522adac09fd3a30644d58b0 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 14:32:31 -0700 Subject: [PATCH 103/192] refactor: split shared glob matcher mutation sites --- packages/extension/src/shared/globMatch.ts | 322 +----------------- .../shared/globMatch/combined/collection.ts | 29 ++ .../src/shared/globMatch/combined/matcher.ts | 55 +++ .../shared/globMatch/combined/predicates.ts | 46 +++ .../src/shared/globMatch/contracts.ts | 14 + .../shared/globMatch/fast/classification.ts | 29 ++ .../src/shared/globMatch/fast/collection.ts | 46 +++ .../globMatch/fast/directoryMatchers.ts | 29 ++ .../src/shared/globMatch/fast/matcher.ts | 31 ++ .../src/shared/globMatch/fast/pathSuffix.ts | 3 + .../src/shared/globMatch/fast/patternParts.ts | 17 + .../shared/globMatch/fast/suffixMatcher.ts | 11 + .../extension/src/shared/globMatch/matcher.ts | 17 + .../extension/src/shared/globMatch/regex.ts | 40 +++ 14 files changed, 376 insertions(+), 313 deletions(-) create mode 100644 packages/extension/src/shared/globMatch/combined/collection.ts create mode 100644 packages/extension/src/shared/globMatch/combined/matcher.ts create mode 100644 packages/extension/src/shared/globMatch/combined/predicates.ts create mode 100644 packages/extension/src/shared/globMatch/contracts.ts create mode 100644 packages/extension/src/shared/globMatch/fast/classification.ts create mode 100644 packages/extension/src/shared/globMatch/fast/collection.ts create mode 100644 packages/extension/src/shared/globMatch/fast/directoryMatchers.ts create mode 100644 packages/extension/src/shared/globMatch/fast/matcher.ts create mode 100644 packages/extension/src/shared/globMatch/fast/pathSuffix.ts create mode 100644 packages/extension/src/shared/globMatch/fast/patternParts.ts create mode 100644 packages/extension/src/shared/globMatch/fast/suffixMatcher.ts create mode 100644 packages/extension/src/shared/globMatch/matcher.ts create mode 100644 packages/extension/src/shared/globMatch/regex.ts diff --git a/packages/extension/src/shared/globMatch.ts b/packages/extension/src/shared/globMatch.ts index 6b986462f..5ba785919 100644 --- a/packages/extension/src/shared/globMatch.ts +++ b/packages/extension/src/shared/globMatch.ts @@ -1,323 +1,19 @@ -/** - * Convert a simple glob pattern to a RegExp. - * - * Rules: - * - `**` matches any path segments, including nested `/` - * - `*` matches anything except `/` - * - regex metacharacters are escaped - * - * Patterns are matched against the basename or path suffix, so `src/*` - * works anywhere in the tree while still keeping `*` and `**` semantics. - */ -export function globToRegex(pattern: string): RegExp { - let body = ''; - for (let index = 0; index < pattern.length; index += 1) { - const character = pattern[index]; - const nextCharacter = pattern[index + 1]; - const afterNextCharacter = pattern[index + 2]; - - if (character === '*' && nextCharacter === '*' && afterNextCharacter === '/') { - body += '(?:.*/)?'; - index += 2; - continue; - } - - if (character === '*' && nextCharacter === '*') { - body += '.*'; - index += 1; - continue; - } - - if (character === '*') { - body += '[^/]*'; - continue; - } - - body += character.replace(/([.+^${}()|[\]\\])/g, '\\$1'); - } - - return new RegExp(`(?:^|/)${body}$`); -} - -type GlobMatcher = (filePath: string) => boolean; - -interface CombinedFastGlobMatchers { - directMatchers: GlobMatcher[]; - literalSuffixes: string[]; - recursiveDirectoryNames: Set; - suffixes: string[]; -} - -type FastGlobPattern = - | { kind: 'directChild'; directoryPath: string } - | { kind: 'literal'; suffix: string } - | { kind: 'recursiveDirectory'; directoryPath: string } - | { kind: 'suffix'; suffix: string }; - -export function createGlobMatcher(pattern: string): GlobMatcher { - const fastMatcher = createFastGlobMatcher(pattern); - if (fastMatcher) { - return fastMatcher; - } - - const regex = globToRegex(pattern); - return (filePath: string): boolean => regex.test(filePath); -} - -function matchesPathSuffix(filePath: string, suffix: string): boolean { - return filePath === suffix || filePath.endsWith(`/${suffix}`); -} - -function createRecursiveDirectoryMatcher(directoryPath: string): GlobMatcher { - const rootPrefix = `${directoryPath}/`; - const nestedPrefix = `/${rootPrefix}`; - - return (filePath: string): boolean => ( - filePath.startsWith(rootPrefix) || filePath.includes(nestedPrefix) - ); -} +import { createCombinedGlobMatcher as createCombinedGlobMatcherImpl } from './globMatch/combined/matcher'; +import { createGlobMatcher as createGlobMatcherImpl, globMatch as globMatchImpl } from './globMatch/matcher'; +import { globToRegex as globToRegexImpl } from './globMatch/regex'; -function createDirectChildMatcher(directoryPath: string): GlobMatcher { - const rootPrefix = `${directoryPath}/`; - const nestedPrefix = `/${rootPrefix}`; - - return (filePath: string): boolean => { - let start = 0; - if (!filePath.startsWith(rootPrefix)) { - const nestedStart = filePath.lastIndexOf(nestedPrefix); - if (nestedStart < 0) { - return false; - } - start = nestedStart + 1; - } - - const remainder = filePath.slice(start + rootPrefix.length); - return remainder.length > 0 && !remainder.includes('/'); - }; -} - -function createSuffixMatcher(suffix: string): GlobMatcher { - const suffixLength = suffix.length; - const suffixFirstCode = suffix.charCodeAt(0); - return (filePath: string): boolean => ( - filePath.length >= suffixLength - && filePath.charCodeAt(filePath.length - suffixLength) === suffixFirstCode - && filePath.endsWith(suffix) - ); -} - -function removeRecursivePrefix(pattern: string): string { - return pattern.startsWith('**/') ? pattern.slice(3) : pattern; -} - -function getExtensionSuffixPattern(pattern: string): string | undefined { - const hasOnlyLeadingWildcard = pattern.startsWith('*.') && pattern.indexOf('*', 1) === -1; - return hasOnlyLeadingWildcard && !pattern.includes('/') ? pattern.slice(1) : undefined; -} - -function getDirectoryPattern(pattern: string, ending: '/**' | '/*'): string | undefined { - if (!pattern.endsWith(ending)) { - return undefined; - } - - const directoryPath = pattern.slice(0, -ending.length); - return directoryPath && !directoryPath.includes('*') ? directoryPath : undefined; -} - -function classifyFastGlobPattern(pattern: string): FastGlobPattern | undefined { - const recursivePattern = removeRecursivePrefix(pattern); - - if (!recursivePattern.includes('*')) { - return { kind: 'literal', suffix: recursivePattern }; - } - - const suffix = getExtensionSuffixPattern(recursivePattern); - if (suffix) { - return { kind: 'suffix', suffix }; - } - - const recursiveDirectoryPath = getDirectoryPattern(recursivePattern, '/**'); - if (recursiveDirectoryPath) { - return { kind: 'recursiveDirectory', directoryPath: recursiveDirectoryPath }; - } - - const directChildDirectoryPath = getDirectoryPattern(recursivePattern, '/*'); - return directChildDirectoryPath - ? { kind: 'directChild', directoryPath: directChildDirectoryPath } - : undefined; -} - -function addFastMatcher(fastMatchers: CombinedFastGlobMatchers, pattern: FastGlobPattern): void { - if (pattern.kind === 'literal') { - fastMatchers.literalSuffixes.push(pattern.suffix); - return; - } - - if (pattern.kind === 'suffix') { - fastMatchers.suffixes.push(pattern.suffix); - return; - } - - if (pattern.kind === 'directChild') { - fastMatchers.directMatchers.push(createDirectChildMatcher(pattern.directoryPath)); - return; - } - - if (!pattern.directoryPath.includes('/')) { - fastMatchers.recursiveDirectoryNames.add(pattern.directoryPath); - return; - } - - fastMatchers.directMatchers.push(createRecursiveDirectoryMatcher(pattern.directoryPath)); -} - -function collectFastMatcher( - fastMatchers: CombinedFastGlobMatchers, - pattern: string, -): boolean { - const fastPattern = classifyFastGlobPattern(pattern); - if (!fastPattern) { - return false; - } - - addFastMatcher(fastMatchers, fastPattern); - return true; -} - -function matchesAnyPathSuffix(filePath: string, suffixes: readonly string[]): boolean { - for (const suffix of suffixes) { - if (matchesPathSuffix(filePath, suffix)) { - return true; - } - } - - return false; -} - -function hasAnySuffix(filePath: string, suffixes: readonly string[]): boolean { - for (const suffix of suffixes) { - if (filePath.endsWith(suffix)) { - return true; - } - } - - return false; -} - -function containsRecursiveDirectoryName( - filePath: string, - directoryNames: ReadonlySet, -): boolean { - if (directoryNames.size === 0) { - return false; - } - - let segmentStart = 0; - while (segmentStart < filePath.length) { - const slashIndex = filePath.indexOf('/', segmentStart); - if (slashIndex < 0) { - return false; - } - - if (directoryNames.has(filePath.slice(segmentStart, slashIndex))) { - return true; - } - - segmentStart = slashIndex + 1; - } - - return false; -} - -function createFastGlobMatcher(pattern: string): GlobMatcher | undefined { - if (!pattern) { - return () => false; - } - - const fastPattern = classifyFastGlobPattern(pattern); - if (!fastPattern) { - return undefined; - } - - if (fastPattern.kind === 'literal') { - return (filePath: string): boolean => matchesPathSuffix(filePath, fastPattern.suffix); - } - - if (fastPattern.kind === 'suffix') { - return createSuffixMatcher(fastPattern.suffix); - } - - return fastPattern.kind === 'directChild' - ? createDirectChildMatcher(fastPattern.directoryPath) - : createRecursiveDirectoryMatcher(fastPattern.directoryPath); -} - -function collectCombinedFastMatchers(patterns: readonly string[]): { - fastMatchers: CombinedFastGlobMatchers; - regexPatterns: string[]; -} { - const fastMatchers: CombinedFastGlobMatchers = { - directMatchers: [], - literalSuffixes: [], - recursiveDirectoryNames: new Set(), - suffixes: [], - }; - const regexPatterns: string[] = []; - for (const pattern of patterns) { - if (!collectFastMatcher(fastMatchers, pattern)) { - regexPatterns.push(pattern); - } - } - - return { fastMatchers, regexPatterns }; +export function globToRegex(pattern: string): RegExp { + return globToRegexImpl(pattern); } -function createCombinedRegexMatcher(regexPatterns: readonly string[]): RegExp | null { - return regexPatterns.length > 0 - ? new RegExp(regexPatterns.map(pattern => `(?:${globToRegex(pattern).source})`).join('|')) - : null; -} - -function createCombinedFastMatcher( - fastMatchers: CombinedFastGlobMatchers, - regex: RegExp | null, -): GlobMatcher { - return (filePath: string): boolean => { - if ( - containsRecursiveDirectoryName(filePath, fastMatchers.recursiveDirectoryNames) - || hasAnySuffix(filePath, fastMatchers.suffixes) - || matchesAnyPathSuffix(filePath, fastMatchers.literalSuffixes) - ) { - return true; - } - - for (const matcher of fastMatchers.directMatchers) { - if (matcher(filePath)) { - return true; - } - } - - return regex ? regex.test(filePath) : false; - }; +export function createGlobMatcher(pattern: string): (filePath: string) => boolean { + return createGlobMatcherImpl(pattern); } export function createCombinedGlobMatcher(patterns: readonly string[]): (filePath: string) => boolean { - if (patterns.length === 0) { - return () => false; - } - - if (patterns.length === 1) { - const pattern = patterns[0] ?? ''; - return createFastGlobMatcher(pattern) ?? createGlobMatcher(pattern); - } - - const { fastMatchers, regexPatterns } = collectCombinedFastMatchers(patterns); - const regex = regexPatterns.length > 0 - ? createCombinedRegexMatcher(regexPatterns) - : null; - return createCombinedFastMatcher(fastMatchers, regex); + return createCombinedGlobMatcherImpl(patterns); } export function globMatch(filePath: string, pattern: string): boolean { - return createGlobMatcher(pattern)(filePath); + return globMatchImpl(filePath, pattern); } diff --git a/packages/extension/src/shared/globMatch/combined/collection.ts b/packages/extension/src/shared/globMatch/combined/collection.ts new file mode 100644 index 000000000..d316563c0 --- /dev/null +++ b/packages/extension/src/shared/globMatch/combined/collection.ts @@ -0,0 +1,29 @@ +import type { CombinedFastGlobMatchers } from '../contracts'; +import { collectFastMatcher } from '../fast/collection'; +import { globToRegex } from '../regex'; + +export function collectCombinedFastMatchers(patterns: readonly string[]): { + fastMatchers: CombinedFastGlobMatchers; + regexPatterns: string[]; +} { + const fastMatchers: CombinedFastGlobMatchers = { + directMatchers: [], + literalSuffixes: [], + recursiveDirectoryNames: new Set(), + suffixes: [], + }; + const regexPatterns: string[] = []; + for (const pattern of patterns) { + if (!collectFastMatcher(fastMatchers, pattern)) { + regexPatterns.push(pattern); + } + } + + return { fastMatchers, regexPatterns }; +} + +export function createCombinedRegexMatcher(regexPatterns: readonly string[]): RegExp | null { + return regexPatterns.length > 0 + ? new RegExp(regexPatterns.map(pattern => `(?:${globToRegex(pattern).source})`).join('|')) + : null; +} diff --git a/packages/extension/src/shared/globMatch/combined/matcher.ts b/packages/extension/src/shared/globMatch/combined/matcher.ts new file mode 100644 index 000000000..4a92f3a92 --- /dev/null +++ b/packages/extension/src/shared/globMatch/combined/matcher.ts @@ -0,0 +1,55 @@ +import type { + CombinedFastGlobMatchers, + GlobMatcher, +} from '../contracts'; +import { + collectCombinedFastMatchers, + createCombinedRegexMatcher, +} from './collection'; +import { + containsRecursiveDirectoryName, + hasAnySuffix, + matchesAnyPathSuffix, +} from './predicates'; +import { createFastGlobMatcher } from '../fast/matcher'; +import { createGlobMatcher } from '../matcher'; + +function createCombinedFastMatcher( + fastMatchers: CombinedFastGlobMatchers, + regex: RegExp | null, +): GlobMatcher { + return (filePath: string): boolean => { + if ( + containsRecursiveDirectoryName(filePath, fastMatchers.recursiveDirectoryNames) + || hasAnySuffix(filePath, fastMatchers.suffixes) + || matchesAnyPathSuffix(filePath, fastMatchers.literalSuffixes) + ) { + return true; + } + + for (const matcher of fastMatchers.directMatchers) { + if (matcher(filePath)) { + return true; + } + } + + return regex ? regex.test(filePath) : false; + }; +} + +export function createCombinedGlobMatcher(patterns: readonly string[]): GlobMatcher { + if (patterns.length === 0) { + return () => false; + } + + if (patterns.length === 1) { + const pattern = patterns[0] ?? ''; + return createFastGlobMatcher(pattern) ?? createGlobMatcher(pattern); + } + + const { fastMatchers, regexPatterns } = collectCombinedFastMatchers(patterns); + const regex = regexPatterns.length > 0 + ? createCombinedRegexMatcher(regexPatterns) + : null; + return createCombinedFastMatcher(fastMatchers, regex); +} diff --git a/packages/extension/src/shared/globMatch/combined/predicates.ts b/packages/extension/src/shared/globMatch/combined/predicates.ts new file mode 100644 index 000000000..2a8afa236 --- /dev/null +++ b/packages/extension/src/shared/globMatch/combined/predicates.ts @@ -0,0 +1,46 @@ +import { matchesPathSuffix } from '../fast/pathSuffix'; + +export function matchesAnyPathSuffix(filePath: string, suffixes: readonly string[]): boolean { + for (const suffix of suffixes) { + if (matchesPathSuffix(filePath, suffix)) { + return true; + } + } + + return false; +} + +export function hasAnySuffix(filePath: string, suffixes: readonly string[]): boolean { + for (const suffix of suffixes) { + if (filePath.endsWith(suffix)) { + return true; + } + } + + return false; +} + +export function containsRecursiveDirectoryName( + filePath: string, + directoryNames: ReadonlySet, +): boolean { + if (directoryNames.size === 0) { + return false; + } + + let segmentStart = 0; + while (segmentStart < filePath.length) { + const slashIndex = filePath.indexOf('/', segmentStart); + if (slashIndex < 0) { + return false; + } + + if (directoryNames.has(filePath.slice(segmentStart, slashIndex))) { + return true; + } + + segmentStart = slashIndex + 1; + } + + return false; +} diff --git a/packages/extension/src/shared/globMatch/contracts.ts b/packages/extension/src/shared/globMatch/contracts.ts new file mode 100644 index 000000000..e4d8115ec --- /dev/null +++ b/packages/extension/src/shared/globMatch/contracts.ts @@ -0,0 +1,14 @@ +export type GlobMatcher = (filePath: string) => boolean; + +export interface CombinedFastGlobMatchers { + directMatchers: GlobMatcher[]; + literalSuffixes: string[]; + recursiveDirectoryNames: Set; + suffixes: string[]; +} + +export type FastGlobPattern = + | { kind: 'directChild'; directoryPath: string } + | { kind: 'literal'; suffix: string } + | { kind: 'recursiveDirectory'; directoryPath: string } + | { kind: 'suffix'; suffix: string }; diff --git a/packages/extension/src/shared/globMatch/fast/classification.ts b/packages/extension/src/shared/globMatch/fast/classification.ts new file mode 100644 index 000000000..da3bc5883 --- /dev/null +++ b/packages/extension/src/shared/globMatch/fast/classification.ts @@ -0,0 +1,29 @@ +import type { FastGlobPattern } from '../contracts'; +import { + getDirectoryPattern, + getExtensionSuffixPattern, + removeRecursivePrefix, +} from './patternParts'; + +export function classifyFastGlobPattern(pattern: string): FastGlobPattern | undefined { + const recursivePattern = removeRecursivePrefix(pattern); + + if (!recursivePattern.includes('*')) { + return { kind: 'literal', suffix: recursivePattern }; + } + + const suffix = getExtensionSuffixPattern(recursivePattern); + if (suffix) { + return { kind: 'suffix', suffix }; + } + + const recursiveDirectoryPath = getDirectoryPattern(recursivePattern, '/**'); + if (recursiveDirectoryPath) { + return { kind: 'recursiveDirectory', directoryPath: recursiveDirectoryPath }; + } + + const directChildDirectoryPath = getDirectoryPattern(recursivePattern, '/*'); + return directChildDirectoryPath + ? { kind: 'directChild', directoryPath: directChildDirectoryPath } + : undefined; +} diff --git a/packages/extension/src/shared/globMatch/fast/collection.ts b/packages/extension/src/shared/globMatch/fast/collection.ts new file mode 100644 index 000000000..389b946f0 --- /dev/null +++ b/packages/extension/src/shared/globMatch/fast/collection.ts @@ -0,0 +1,46 @@ +import { classifyFastGlobPattern } from './classification'; +import type { + CombinedFastGlobMatchers, + FastGlobPattern, +} from '../contracts'; +import { + createDirectChildMatcher, + createRecursiveDirectoryMatcher, +} from './directoryMatchers'; + +function addFastMatcher(fastMatchers: CombinedFastGlobMatchers, pattern: FastGlobPattern): void { + if (pattern.kind === 'literal') { + fastMatchers.literalSuffixes.push(pattern.suffix); + return; + } + + if (pattern.kind === 'suffix') { + fastMatchers.suffixes.push(pattern.suffix); + return; + } + + if (pattern.kind === 'directChild') { + fastMatchers.directMatchers.push(createDirectChildMatcher(pattern.directoryPath)); + return; + } + + if (!pattern.directoryPath.includes('/')) { + fastMatchers.recursiveDirectoryNames.add(pattern.directoryPath); + return; + } + + fastMatchers.directMatchers.push(createRecursiveDirectoryMatcher(pattern.directoryPath)); +} + +export function collectFastMatcher( + fastMatchers: CombinedFastGlobMatchers, + pattern: string, +): boolean { + const fastPattern = classifyFastGlobPattern(pattern); + if (!fastPattern) { + return false; + } + + addFastMatcher(fastMatchers, fastPattern); + return true; +} diff --git a/packages/extension/src/shared/globMatch/fast/directoryMatchers.ts b/packages/extension/src/shared/globMatch/fast/directoryMatchers.ts new file mode 100644 index 000000000..a80ed33d8 --- /dev/null +++ b/packages/extension/src/shared/globMatch/fast/directoryMatchers.ts @@ -0,0 +1,29 @@ +import type { GlobMatcher } from '../contracts'; + +export function createRecursiveDirectoryMatcher(directoryPath: string): GlobMatcher { + const rootPrefix = `${directoryPath}/`; + const nestedPrefix = `/${rootPrefix}`; + + return (filePath: string): boolean => ( + filePath.startsWith(rootPrefix) || filePath.includes(nestedPrefix) + ); +} + +export function createDirectChildMatcher(directoryPath: string): GlobMatcher { + const rootPrefix = `${directoryPath}/`; + const nestedPrefix = `/${rootPrefix}`; + + return (filePath: string): boolean => { + let start = 0; + if (!filePath.startsWith(rootPrefix)) { + const nestedStart = filePath.lastIndexOf(nestedPrefix); + if (nestedStart < 0) { + return false; + } + start = nestedStart + 1; + } + + const remainder = filePath.slice(start + rootPrefix.length); + return remainder.length > 0 && !remainder.includes('/'); + }; +} diff --git a/packages/extension/src/shared/globMatch/fast/matcher.ts b/packages/extension/src/shared/globMatch/fast/matcher.ts new file mode 100644 index 000000000..d9a45d311 --- /dev/null +++ b/packages/extension/src/shared/globMatch/fast/matcher.ts @@ -0,0 +1,31 @@ +import { classifyFastGlobPattern } from './classification'; +import type { GlobMatcher } from '../contracts'; +import { + createDirectChildMatcher, + createRecursiveDirectoryMatcher, +} from './directoryMatchers'; +import { matchesPathSuffix } from './pathSuffix'; +import { createSuffixMatcher } from './suffixMatcher'; + +export function createFastGlobMatcher(pattern: string): GlobMatcher | undefined { + if (!pattern) { + return () => false; + } + + const fastPattern = classifyFastGlobPattern(pattern); + if (!fastPattern) { + return undefined; + } + + if (fastPattern.kind === 'literal') { + return (filePath: string): boolean => matchesPathSuffix(filePath, fastPattern.suffix); + } + + if (fastPattern.kind === 'suffix') { + return createSuffixMatcher(fastPattern.suffix); + } + + return fastPattern.kind === 'directChild' + ? createDirectChildMatcher(fastPattern.directoryPath) + : createRecursiveDirectoryMatcher(fastPattern.directoryPath); +} diff --git a/packages/extension/src/shared/globMatch/fast/pathSuffix.ts b/packages/extension/src/shared/globMatch/fast/pathSuffix.ts new file mode 100644 index 000000000..ea6880da1 --- /dev/null +++ b/packages/extension/src/shared/globMatch/fast/pathSuffix.ts @@ -0,0 +1,3 @@ +export function matchesPathSuffix(filePath: string, suffix: string): boolean { + return filePath === suffix || filePath.endsWith(`/${suffix}`); +} diff --git a/packages/extension/src/shared/globMatch/fast/patternParts.ts b/packages/extension/src/shared/globMatch/fast/patternParts.ts new file mode 100644 index 000000000..b7e801996 --- /dev/null +++ b/packages/extension/src/shared/globMatch/fast/patternParts.ts @@ -0,0 +1,17 @@ +export function removeRecursivePrefix(pattern: string): string { + return pattern.startsWith('**/') ? pattern.slice(3) : pattern; +} + +export function getExtensionSuffixPattern(pattern: string): string | undefined { + const hasOnlyLeadingWildcard = pattern.startsWith('*.') && pattern.indexOf('*', 1) === -1; + return hasOnlyLeadingWildcard && !pattern.includes('/') ? pattern.slice(1) : undefined; +} + +export function getDirectoryPattern(pattern: string, ending: '/**' | '/*'): string | undefined { + if (!pattern.endsWith(ending)) { + return undefined; + } + + const directoryPath = pattern.slice(0, -ending.length); + return directoryPath && !directoryPath.includes('*') ? directoryPath : undefined; +} diff --git a/packages/extension/src/shared/globMatch/fast/suffixMatcher.ts b/packages/extension/src/shared/globMatch/fast/suffixMatcher.ts new file mode 100644 index 000000000..e299c0d61 --- /dev/null +++ b/packages/extension/src/shared/globMatch/fast/suffixMatcher.ts @@ -0,0 +1,11 @@ +import type { GlobMatcher } from '../contracts'; + +export function createSuffixMatcher(suffix: string): GlobMatcher { + const suffixLength = suffix.length; + const suffixFirstCode = suffix.charCodeAt(0); + return (filePath: string): boolean => ( + filePath.length >= suffixLength + && filePath.charCodeAt(filePath.length - suffixLength) === suffixFirstCode + && filePath.endsWith(suffix) + ); +} diff --git a/packages/extension/src/shared/globMatch/matcher.ts b/packages/extension/src/shared/globMatch/matcher.ts new file mode 100644 index 000000000..6678519c4 --- /dev/null +++ b/packages/extension/src/shared/globMatch/matcher.ts @@ -0,0 +1,17 @@ +import type { GlobMatcher } from './contracts'; +import { createFastGlobMatcher } from './fast/matcher'; +import { globToRegex } from './regex'; + +export function createGlobMatcher(pattern: string): GlobMatcher { + const fastMatcher = createFastGlobMatcher(pattern); + if (fastMatcher) { + return fastMatcher; + } + + const regex = globToRegex(pattern); + return (filePath: string): boolean => regex.test(filePath); +} + +export function globMatch(filePath: string, pattern: string): boolean { + return createGlobMatcher(pattern)(filePath); +} diff --git a/packages/extension/src/shared/globMatch/regex.ts b/packages/extension/src/shared/globMatch/regex.ts new file mode 100644 index 000000000..b1d31ef78 --- /dev/null +++ b/packages/extension/src/shared/globMatch/regex.ts @@ -0,0 +1,40 @@ +/** + * Convert a simple glob pattern to a RegExp. + * + * Rules: + * - `**` matches any path segments, including nested `/` + * - `*` matches anything except `/` + * - regex metacharacters are escaped + * + * Patterns are matched against the basename or path suffix, so `src/*` + * works anywhere in the tree while still keeping `*` and `**` semantics. + */ +export function globToRegex(pattern: string): RegExp { + let body = ''; + for (let index = 0; index < pattern.length; index += 1) { + const character = pattern[index]; + const nextCharacter = pattern[index + 1]; + const afterNextCharacter = pattern[index + 2]; + + if (character === '*' && nextCharacter === '*' && afterNextCharacter === '/') { + body += '(?:.*/)?'; + index += 2; + continue; + } + + if (character === '*' && nextCharacter === '*') { + body += '.*'; + index += 1; + continue; + } + + if (character === '*') { + body += '[^/]*'; + continue; + } + + body += character.replace(/([.+^${}()|[\]\\])/g, '\\$1'); + } + + return new RegExp(`(?:^|/)${body}$`); +} From ac0703720aa3d44bf6757f80b18d2f090a489cbd Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 14:44:34 -0700 Subject: [PATCH 104/192] refactor: split refresh facade mutation sites --- .../pipeline/service/refresh/context.ts | 31 ++ .../service/refresh/discovery/changed.ts | 76 +++ .../service/refresh/discovery/workspace.ts | 49 ++ .../pipeline/service/refresh/metrics.ts | 61 +++ .../service/refresh/modes/analysisScope.ts | 73 +++ .../service/refresh/modes/changedFiles.ts | 102 ++++ .../refresh/modes/gitignoreMetadata.ts | 46 ++ .../service/refresh/modes/pluginFiles.ts | 55 +++ .../pipeline/service/refresh/scope.ts | 85 ++++ .../pipeline/service/refresh/source.ts | 109 +++++ .../pipeline/service/refreshFacade.ts | 452 +----------------- 11 files changed, 707 insertions(+), 432 deletions(-) create mode 100644 packages/extension/src/extension/pipeline/service/refresh/context.ts create mode 100644 packages/extension/src/extension/pipeline/service/refresh/discovery/changed.ts create mode 100644 packages/extension/src/extension/pipeline/service/refresh/discovery/workspace.ts create mode 100644 packages/extension/src/extension/pipeline/service/refresh/metrics.ts create mode 100644 packages/extension/src/extension/pipeline/service/refresh/modes/analysisScope.ts create mode 100644 packages/extension/src/extension/pipeline/service/refresh/modes/changedFiles.ts create mode 100644 packages/extension/src/extension/pipeline/service/refresh/modes/gitignoreMetadata.ts create mode 100644 packages/extension/src/extension/pipeline/service/refresh/modes/pluginFiles.ts create mode 100644 packages/extension/src/extension/pipeline/service/refresh/scope.ts create mode 100644 packages/extension/src/extension/pipeline/service/refresh/source.ts diff --git a/packages/extension/src/extension/pipeline/service/refresh/context.ts b/packages/extension/src/extension/pipeline/service/refresh/context.ts new file mode 100644 index 000000000..c6303150d --- /dev/null +++ b/packages/extension/src/extension/pipeline/service/refresh/context.ts @@ -0,0 +1,31 @@ +import type { FileDiscovery } from '@codegraphy-dev/core'; +import type { Configuration } from '../../../config/reader'; +import type { PluginRegistry } from '../../../../core/plugins/registry/manager'; +import type { IGraphData } from '../../../../shared/graph/contracts'; +import type { AnalysisScopeRefreshFacade } from './scope'; +import type { RefreshSourceFacade } from './source'; + +export type RefreshProgress = { + phase: string; + current: number; + total: number; +}; + +export interface RefreshFacadeContext + extends AnalysisScopeRefreshFacade, RefreshSourceFacade { + _config: Pick; + _discovery: Pick; + _getActiveAnalysisPluginIds( + pluginIds: readonly string[] | undefined, + disabledPlugins: ReadonlySet, + ): string[]; + _getWorkspaceRoot(): string | undefined; + _lastGitIgnoredPaths: string[]; + _persistCache(): void; + _persistIndexMetadata(): Promise; + _registry: Pick; + _toWorkspaceRelativePath(workspaceRoot: string, filePath: string): string | undefined; + getPluginFilterPatterns(disabledPlugins: Set): string[]; +} + +export const EMPTY_REFRESH_GRAPH: IGraphData = { nodes: [], edges: [] }; diff --git a/packages/extension/src/extension/pipeline/service/refresh/discovery/changed.ts b/packages/extension/src/extension/pipeline/service/refresh/discovery/changed.ts new file mode 100644 index 000000000..e77327daa --- /dev/null +++ b/packages/extension/src/extension/pipeline/service/refresh/discovery/changed.ts @@ -0,0 +1,76 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import type { IDiscoveredFile } from '@codegraphy-dev/core'; + +export interface ChangedFileDiscoveryState { + directories: string[]; + files: IDiscoveredFile[]; +} + +interface ReusableChangedFileDiscoveryInput { + filePaths: readonly string[]; + lastDiscoveredDirectories: readonly string[]; + lastDiscoveredFiles: IDiscoveredFile[]; + lastWorkspaceRoot: string; + toWorkspaceRelativePath(workspaceRoot: string, filePath: string): string | undefined; + workspaceRoot: string; +} + +export function getReusableChangedFileDiscoveryState( + input: ReusableChangedFileDiscoveryInput, +): ChangedFileDiscoveryState | undefined { + if (!hasReusableChangedFileDiscoveryState(input)) { + return undefined; + } + + const discoveredByRelativePath = createDiscoveredFilesByRelativePath(input.lastDiscoveredFiles); + + for (const filePath of input.filePaths) { + if (!canReuseChangedFileDiscovery(filePath, discoveredByRelativePath, input)) { + return undefined; + } + } + + return { + directories: [...input.lastDiscoveredDirectories], + files: input.lastDiscoveredFiles, + }; +} + +function hasReusableChangedFileDiscoveryState(input: ReusableChangedFileDiscoveryInput): boolean { + return input.filePaths.length > 0 + && input.lastWorkspaceRoot === input.workspaceRoot + && input.lastDiscoveredFiles.length > 0; +} + +function createDiscoveredFilesByRelativePath( + discoveredFiles: readonly IDiscoveredFile[], +): Map { + return new Map( + discoveredFiles.map(file => [ + normalizeGraphMetricFilePath(file.relativePath), + file, + ]), + ); +} + +function canReuseChangedFileDiscovery( + filePath: string, + discoveredByRelativePath: ReadonlyMap, + input: ReusableChangedFileDiscoveryInput, +): boolean { + const relativePath = input.toWorkspaceRelativePath(input.workspaceRoot, filePath); + return Boolean( + relativePath + && discoveredByRelativePath.has(relativePath) + && fs.existsSync(toAbsoluteChangedFilePath(input.workspaceRoot, filePath)), + ); +} + +function toAbsoluteChangedFilePath(workspaceRoot: string, filePath: string): string { + return path.isAbsolute(filePath) ? filePath : path.join(workspaceRoot, filePath); +} + +function normalizeGraphMetricFilePath(filePath: string): string { + return filePath.replace(/\\/g, '/'); +} diff --git a/packages/extension/src/extension/pipeline/service/refresh/discovery/workspace.ts b/packages/extension/src/extension/pipeline/service/refresh/discovery/workspace.ts new file mode 100644 index 000000000..40e6cd683 --- /dev/null +++ b/packages/extension/src/extension/pipeline/service/refresh/discovery/workspace.ts @@ -0,0 +1,49 @@ +import * as vscode from 'vscode'; +import type { FileDiscovery, IDiscoveredFile } from '@codegraphy-dev/core'; +import type { ICodeGraphyConfig } from '../../../../config/defaults'; +import type { WorkspacePipelineDiscoveryResult } from '../../../discovery'; +import { + createWorkspacePipelineDiscoveryDependencies, + discoverWorkspacePipelineFilesWithWarnings, +} from '../../runtime/discovery'; + +interface RefreshDiscoveryConfigReader { + getAll(): ICodeGraphyConfig; +} + +interface RefreshWorkspaceFileDiscoveryInput { + configReader: RefreshDiscoveryConfigReader; + disabledPlugins: Set; + discovery: Pick; + filterPatterns: readonly string[]; + getPluginFilterPatterns(disabledPlugins: Set): string[]; + signal?: AbortSignal; + workspaceRoot: string; +} + +interface RefreshWorkspaceFileDiscoveryResult { + config: ICodeGraphyConfig; + discoveryResult: WorkspacePipelineDiscoveryResult; +} + +export async function discoverRefreshWorkspaceFiles( + input: RefreshWorkspaceFileDiscoveryInput, +): Promise { + const config = input.configReader.getAll(); + const disabledCustomPatterns = new Set(config.disabledCustomFilterPatterns); + const disabledPluginPatterns = new Set(config.disabledPluginFilterPatterns); + const discoveryResult = await discoverWorkspacePipelineFilesWithWarnings( + createWorkspacePipelineDiscoveryDependencies(input.discovery), + input.workspaceRoot, + config, + input.filterPatterns.filter(pattern => !disabledCustomPatterns.has(pattern)), + input.getPluginFilterPatterns(input.disabledPlugins) + .filter(pattern => !disabledPluginPatterns.has(pattern)), + input.signal, + message => { + vscode.window.showWarningMessage(message); + }, + ); + + return { config, discoveryResult }; +} diff --git a/packages/extension/src/extension/pipeline/service/refresh/metrics.ts b/packages/extension/src/extension/pipeline/service/refresh/metrics.ts new file mode 100644 index 000000000..0dd5881f8 --- /dev/null +++ b/packages/extension/src/extension/pipeline/service/refresh/metrics.ts @@ -0,0 +1,61 @@ +import type { IGraphData } from '../../../../shared/graph/contracts'; + +interface GraphMetricPatchResult { + changed: boolean; + node: IGraphData['nodes'][number]; +} + +interface PatchGraphDataNodeMetricsInput { + churnCounts: Record; + filePaths: readonly string[]; + fileSizes: Record; + graphData: IGraphData; +} + +export function patchGraphDataNodeMetrics(input: PatchGraphDataNodeMetricsInput): IGraphData { + const metricFilePaths = new Set(input.filePaths.map(normalizeGraphMetricFilePath)); + if (metricFilePaths.size === 0) { + return input.graphData; + } + + let changed = false; + const nodes = input.graphData.nodes.map((node) => { + const result = patchGraphDataNodeMetric(node, metricFilePaths, input); + changed ||= result.changed; + return result.node; + }); + + return changed ? { ...input.graphData, nodes } : input.graphData; +} + +function normalizeGraphMetricFilePath(filePath: string): string { + return filePath.replace(/\\/g, '/'); +} + +function getGraphMetricNodeFilePath(node: IGraphData['nodes'][number]): string { + const symbolFilePath = node.symbol?.filePath; + return normalizeGraphMetricFilePath( + typeof symbolFilePath === 'string' && symbolFilePath.length > 0 + ? symbolFilePath + : node.id, + ); +} + +function patchGraphDataNodeMetric( + node: IGraphData['nodes'][number], + metricFilePaths: ReadonlySet, + input: PatchGraphDataNodeMetricsInput, +): GraphMetricPatchResult { + const filePath = getGraphMetricNodeFilePath(node); + if (!metricFilePaths.has(filePath)) { + return { changed: false, node }; + } + + const fileSize = input.fileSizes[filePath]?.size; + const churn = input.churnCounts[filePath] ?? 0; + if (node.fileSize === fileSize && node.churn === churn) { + return { changed: false, node }; + } + + return { changed: true, node: { ...node, fileSize, churn } }; +} diff --git a/packages/extension/src/extension/pipeline/service/refresh/modes/analysisScope.ts b/packages/extension/src/extension/pipeline/service/refresh/modes/analysisScope.ts new file mode 100644 index 000000000..18473aeae --- /dev/null +++ b/packages/extension/src/extension/pipeline/service/refresh/modes/analysisScope.ts @@ -0,0 +1,73 @@ +import type { IGraphData } from '../../../../../shared/graph/contracts'; +import { refreshWorkspacePipelineAnalysisScope } from '../../runtime/refresh'; +import { + canReuseCurrentAnalysisForScope, + rebuildAnalysisScopeFromCurrentAnalysis, +} from '../scope'; +import { createWorkspaceIndexRefreshSource } from '../source'; +import type { RefreshFacadeContext, RefreshProgress } from '../context'; +import { EMPTY_REFRESH_GRAPH } from '../context'; +import { discoverRefreshWorkspaceFiles } from '../discovery/workspace'; + +interface RefreshAnalysisScopeInput { + disabledPlugins: Set; + filterPatterns: string[]; + onProgress?: (progress: RefreshProgress) => void; + signal?: AbortSignal; +} + +export async function refreshAnalysisScopeForFacade( + facade: RefreshFacadeContext, + input: RefreshAnalysisScopeInput, +): Promise { + const workspaceRoot = facade._getWorkspaceRoot(); + if (!workspaceRoot) { + return EMPTY_REFRESH_GRAPH; + } + + const { config, discoveryResult } = await discoverRefreshWorkspaceFiles({ + configReader: facade._config, + disabledPlugins: input.disabledPlugins, + discovery: facade._discovery, + filterPatterns: input.filterPatterns, + getPluginFilterPatterns: plugins => facade.getPluginFilterPatterns(plugins), + signal: input.signal, + workspaceRoot, + }); + facade._lastGitIgnoredPaths = discoveryResult.gitIgnoredPaths ?? []; + + if (canReuseCurrentAnalysisForScope({ + activePluginIds: facade._getActiveAnalysisPluginIds(undefined, input.disabledPlugins), + disabledPlugins: input.disabledPlugins, + discoveredFiles: discoveryResult.files, + lastFileAnalysis: facade._lastFileAnalysis, + nodeVisibility: facade._config.get>('nodeVisibility', {}) ?? {}, + })) { + return rebuildAnalysisScopeFromCurrentAnalysis(facade, { + disabledPlugins: input.disabledPlugins, + discoveredDirectories: discoveryResult.directories ?? [], + discoveredFiles: discoveryResult.files, + onProgress: input.onProgress, + showOrphans: config.showOrphans ?? true, + workspaceRoot, + }); + } + + return refreshWorkspacePipelineAnalysisScope(createWorkspaceIndexRefreshSource( + facade, + input.disabledPlugins, + ), { + disabledPlugins: input.disabledPlugins, + discoveredDirectories: discoveryResult.directories ?? [], + discoveredFiles: discoveryResult.files, + onProgress: input.onProgress, + persistCache: () => { + facade._persistCache(); + }, + persistIndexMetadata: async () => { + await facade._persistIndexMetadata(); + }, + signal: input.signal, + workspaceRoot, + }); +} diff --git a/packages/extension/src/extension/pipeline/service/refresh/modes/changedFiles.ts b/packages/extension/src/extension/pipeline/service/refresh/modes/changedFiles.ts new file mode 100644 index 000000000..38c6ec18c --- /dev/null +++ b/packages/extension/src/extension/pipeline/service/refresh/modes/changedFiles.ts @@ -0,0 +1,102 @@ +import type { IGraphData } from '../../../../../shared/graph/contracts'; +import { refreshWorkspacePipelineChangedFiles } from '../../runtime/refresh'; +import { + getReusableChangedFileDiscoveryState, + type ChangedFileDiscoveryState, +} from '../discovery/changed'; +import type { RefreshFacadeContext, RefreshProgress } from '../context'; +import { EMPTY_REFRESH_GRAPH } from '../context'; +import { discoverRefreshWorkspaceFiles } from '../discovery/workspace'; +import { createWorkspaceIndexRefreshSource } from '../source'; + +interface RefreshChangedFilesInput { + disabledPlugins: Set; + filePaths: readonly string[]; + filterPatterns: string[]; + onProgress?: (progress: RefreshProgress) => void; + signal?: AbortSignal; +} + +export async function refreshChangedFilesForFacade( + facade: RefreshFacadeContext, + input: RefreshChangedFilesInput, +): Promise { + const workspaceRoot = facade._getWorkspaceRoot(); + if (!workspaceRoot) { + return EMPTY_REFRESH_GRAPH; + } + + const discoveryResult = await getChangedFileDiscoveryState(facade, input, workspaceRoot); + return refreshWorkspacePipelineChangedFiles(createWorkspaceIndexRefreshSource( + facade, + input.disabledPlugins, + ), { + deferMetricOnlyIndexMetadata: true, + disabledPlugins: input.disabledPlugins, + discoveredDirectories: discoveryResult.directories, + discoveredFiles: discoveryResult.files, + filePaths: input.filePaths, + filterPatterns: input.filterPatterns, + notifyFilesChanged: ( + files, + root, + analysisContext, + nextDisabledPlugins = input.disabledPlugins, + ) => + facade._registry.notifyFilesChanged( + files, + root, + analysisContext, + nextDisabledPlugins, + ), + onDeferredIndexMetadataError: error => { + console.warn('[CodeGraphy] Failed to persist metric-only refresh metadata.', error); + }, + onProgress: input.onProgress, + persistCache: () => { + facade._persistCache(); + }, + persistIndexMetadata: async () => { + await facade._persistIndexMetadata(); + }, + signal: input.signal, + workspaceRoot, + }); +} + +async function getChangedFileDiscoveryState( + facade: RefreshFacadeContext, + input: RefreshChangedFilesInput, + workspaceRoot: string, +): Promise { + const reusableDiscoveryState = getReusableChangedFileDiscoveryState({ + filePaths: input.filePaths, + lastDiscoveredDirectories: facade._lastDiscoveredDirectories, + lastDiscoveredFiles: facade._lastDiscoveredFiles, + lastWorkspaceRoot: facade._lastWorkspaceRoot, + toWorkspaceRelativePath: (root, filePath) => + facade._toWorkspaceRelativePath(root, filePath), + workspaceRoot, + }); + + if (reusableDiscoveryState) { + return reusableDiscoveryState; + } + + const discovered = await discoverRefreshWorkspaceFiles({ + configReader: facade._config, + disabledPlugins: input.disabledPlugins, + discovery: facade._discovery, + filterPatterns: input.filterPatterns, + getPluginFilterPatterns: plugins => facade.getPluginFilterPatterns(plugins), + signal: input.signal, + workspaceRoot, + }); + const discoveryResult = { + directories: discovered.discoveryResult.directories ?? [], + files: discovered.discoveryResult.files, + }; + facade._lastDiscoveredDirectories = discoveryResult.directories; + facade._lastGitIgnoredPaths = discovered.discoveryResult.gitIgnoredPaths ?? []; + return discoveryResult; +} diff --git a/packages/extension/src/extension/pipeline/service/refresh/modes/gitignoreMetadata.ts b/packages/extension/src/extension/pipeline/service/refresh/modes/gitignoreMetadata.ts new file mode 100644 index 000000000..3ae75c040 --- /dev/null +++ b/packages/extension/src/extension/pipeline/service/refresh/modes/gitignoreMetadata.ts @@ -0,0 +1,46 @@ +import type { IGraphData } from '../../../../../shared/graph/contracts'; +import type { RefreshFacadeContext } from '../context'; +import { EMPTY_REFRESH_GRAPH } from '../context'; +import { discoverRefreshWorkspaceFiles } from '../discovery/workspace'; + +interface RefreshGitignoreMetadataInput { + disabledPlugins: Set; + filterPatterns: string[]; + signal?: AbortSignal; +} + +export async function refreshGitignoreMetadataForFacade( + facade: RefreshFacadeContext, + input: RefreshGitignoreMetadataInput, +): Promise { + const workspaceRoot = facade._getWorkspaceRoot(); + if (!workspaceRoot) { + return EMPTY_REFRESH_GRAPH; + } + + const { config, discoveryResult } = await discoverRefreshWorkspaceFiles({ + configReader: facade._config, + disabledPlugins: input.disabledPlugins, + discovery: facade._discovery, + filterPatterns: input.filterPatterns, + getPluginFilterPatterns: plugins => facade.getPluginFilterPatterns(plugins), + signal: input.signal, + workspaceRoot, + }); + + facade._lastDiscoveredDirectories = discoveryResult.directories ?? []; + facade._lastDiscoveredFiles = discoveryResult.files; + facade._lastGitIgnoredPaths = discoveryResult.gitIgnoredPaths ?? []; + facade._lastWorkspaceRoot = workspaceRoot; + + void facade._persistIndexMetadata().catch(error => { + console.warn('[CodeGraphy] Failed to persist gitignore metadata refresh.', error); + }); + + return facade._buildGraphDataFromAnalysis( + facade._lastFileAnalysis, + workspaceRoot, + config.showOrphans, + input.disabledPlugins, + ); +} diff --git a/packages/extension/src/extension/pipeline/service/refresh/modes/pluginFiles.ts b/packages/extension/src/extension/pipeline/service/refresh/modes/pluginFiles.ts new file mode 100644 index 000000000..30d7d9695 --- /dev/null +++ b/packages/extension/src/extension/pipeline/service/refresh/modes/pluginFiles.ts @@ -0,0 +1,55 @@ +import type { IGraphData } from '../../../../../shared/graph/contracts'; +import { refreshWorkspacePipelinePluginFiles } from '../../runtime/refresh'; +import { createWorkspaceIndexRefreshSource } from '../source'; +import type { RefreshFacadeContext, RefreshProgress } from '../context'; +import { EMPTY_REFRESH_GRAPH } from '../context'; +import { discoverRefreshWorkspaceFiles } from '../discovery/workspace'; + +interface RefreshPluginFilesInput { + disabledPlugins: Set; + filterPatterns: string[]; + onProgress?: (progress: RefreshProgress) => void; + pluginIds: readonly string[]; + signal?: AbortSignal; +} + +export async function refreshPluginFilesForFacade( + facade: RefreshFacadeContext, + input: RefreshPluginFilesInput, +): Promise { + const workspaceRoot = facade._getWorkspaceRoot(); + if (!workspaceRoot || input.pluginIds.length === 0) { + return EMPTY_REFRESH_GRAPH; + } + + const { discoveryResult } = await discoverRefreshWorkspaceFiles({ + configReader: facade._config, + disabledPlugins: input.disabledPlugins, + discovery: facade._discovery, + filterPatterns: input.filterPatterns, + getPluginFilterPatterns: plugins => facade.getPluginFilterPatterns(plugins), + signal: input.signal, + workspaceRoot, + }); + facade._lastGitIgnoredPaths = discoveryResult.gitIgnoredPaths ?? []; + + return refreshWorkspacePipelinePluginFiles(createWorkspaceIndexRefreshSource( + facade, + input.disabledPlugins, + ), { + disabledPlugins: input.disabledPlugins, + discoveredDirectories: discoveryResult.directories ?? [], + discoveredFiles: discoveryResult.files, + onProgress: input.onProgress, + persistCache: () => { + facade._persistCache(); + }, + persistIndexMetadata: async () => { + await facade._persistIndexMetadata(); + }, + pluginIds: input.pluginIds, + pluginInfos: facade._registry.list(), + signal: input.signal, + workspaceRoot, + }); +} diff --git a/packages/extension/src/extension/pipeline/service/refresh/scope.ts b/packages/extension/src/extension/pipeline/service/refresh/scope.ts new file mode 100644 index 000000000..bcde04c2c --- /dev/null +++ b/packages/extension/src/extension/pipeline/service/refresh/scope.ts @@ -0,0 +1,85 @@ +import { + hasRequiredAnalysisCacheTiers, + type IDiscoveredFile, +} from '@codegraphy-dev/core'; +import type { IGraphData } from '../../../../shared/graph/contracts'; +import { createWorkspacePipelineAnalysisCacheTiers } from '../cache/tiers'; +import type { WorkspacePipelineRefreshSource } from '../runtime/refresh'; + +interface CurrentAnalysisScopeInput { + activePluginIds: readonly string[]; + discoveredFiles: readonly IDiscoveredFile[]; + disabledPlugins: Set; + lastFileAnalysis: WorkspacePipelineRefreshSource['_lastFileAnalysis']; + nodeVisibility: Record; +} + +export function canReuseCurrentAnalysisForScope(input: CurrentAnalysisScopeInput): boolean { + if (input.discoveredFiles.length === 0) { + return false; + } + + const requiredTiers = createWorkspacePipelineAnalysisCacheTiers( + input.nodeVisibility, + input.activePluginIds, + ).required; + + return input.discoveredFiles.every((file) => { + const analysis = input.lastFileAnalysis.get(file.relativePath); + return Boolean(analysis && hasRequiredAnalysisCacheTiers(analysis, requiredTiers)); + }); +} + +export interface AnalysisScopeRefreshFacade { + _buildGraphDataFromAnalysis( + fileAnalysis: WorkspacePipelineRefreshSource['_lastFileAnalysis'], + workspaceRoot: string, + showOrphans: boolean, + disabledPlugins: Set, + ): IGraphData; + _lastDiscoveredDirectories: string[]; + _lastDiscoveredFiles: IDiscoveredFile[]; + _lastFileAnalysis: WorkspacePipelineRefreshSource['_lastFileAnalysis']; + _lastWorkspaceRoot: string; + _persistIndexMetadata(): Promise; +} + +interface RebuildAnalysisScopeInput { + discoveredDirectories: readonly string[]; + discoveredFiles: IDiscoveredFile[]; + disabledPlugins: Set; + onProgress?: (progress: { phase: string; current: number; total: number }) => void; + showOrphans: boolean; + workspaceRoot: string; +} + +export async function rebuildAnalysisScopeFromCurrentAnalysis( + facade: AnalysisScopeRefreshFacade, + input: RebuildAnalysisScopeInput, +): Promise { + input.onProgress?.({ + phase: 'Applying Scope', + current: 0, + total: input.discoveredFiles.length, + }); + + facade._lastDiscoveredDirectories = [...input.discoveredDirectories]; + facade._lastDiscoveredFiles = input.discoveredFiles; + facade._lastWorkspaceRoot = input.workspaceRoot; + + const graphData = facade._buildGraphDataFromAnalysis( + facade._lastFileAnalysis, + input.workspaceRoot, + input.showOrphans, + input.disabledPlugins, + ); + + await facade._persistIndexMetadata(); + input.onProgress?.({ + phase: 'Applying Scope', + current: input.discoveredFiles.length, + total: input.discoveredFiles.length, + }); + + return graphData; +} diff --git a/packages/extension/src/extension/pipeline/service/refresh/source.ts b/packages/extension/src/extension/pipeline/service/refresh/source.ts new file mode 100644 index 000000000..dbb48dfea --- /dev/null +++ b/packages/extension/src/extension/pipeline/service/refresh/source.ts @@ -0,0 +1,109 @@ +import type { IGraphData } from '../../../../shared/graph/contracts'; +import type { WorkspacePipelineRefreshSource } from '../runtime/refresh'; + +type RefreshSourceBuildGraphData = WorkspacePipelineRefreshSource['_buildGraphData']; +type RefreshSourceBuildGraphDataFromAnalysis = + WorkspacePipelineRefreshSource['_buildGraphDataFromAnalysis']; + +export interface RefreshSourceFacade { + _analyzeFiles: WorkspacePipelineRefreshSource['_analyzeFiles']; + _buildGraphData( + fileConnections: Parameters[0], + workspaceRoot: string, + showOrphans: boolean, + disabledPlugins: Set, + ): IGraphData; + _buildGraphDataFromAnalysis( + fileAnalysis: Parameters[0], + workspaceRoot: string, + showOrphans: boolean, + disabledPlugins: Set, + ): IGraphData; + _lastDiscoveredDirectories: WorkspacePipelineRefreshSource['_lastDiscoveredDirectories']; + _lastDiscoveredFiles: WorkspacePipelineRefreshSource['_lastDiscoveredFiles']; + _lastFileAnalysis: WorkspacePipelineRefreshSource['_lastFileAnalysis']; + _lastFileConnections: WorkspacePipelineRefreshSource['_lastFileConnections']; + _lastGraphData: WorkspacePipelineRefreshSource['_lastGraphData']; + _lastWorkspaceRoot: WorkspacePipelineRefreshSource['_lastWorkspaceRoot']; + _patchGraphDataNodeMetrics: NonNullable; + _preAnalyzePlugins: WorkspacePipelineRefreshSource['_preAnalyzePlugins']; + _readAnalysisFiles: WorkspacePipelineRefreshSource['_readAnalysisFiles']; + analyze: WorkspacePipelineRefreshSource['analyze']; + invalidateWorkspaceFiles: WorkspacePipelineRefreshSource['invalidateWorkspaceFiles']; +} + +export function createWorkspaceIndexRefreshSource( + facade: RefreshSourceFacade, + disabledPlugins: Set = new Set(), +): WorkspacePipelineRefreshSource { + const source = { + _analyzeFiles: ( + files, + root, + progress, + abortSignal, + pluginIds, + nextDisabledPlugins = disabledPlugins, + ) => facade._analyzeFiles( + files, + root, + progress, + abortSignal, + pluginIds, + nextDisabledPlugins, + ), + _buildGraphData: (fileConnections, root, selectedPlugins) => + facade._buildGraphData(fileConnections, root, true, selectedPlugins), + _buildGraphDataFromAnalysis: (fileAnalysis, root, selectedPlugins) => + facade._buildGraphDataFromAnalysis(fileAnalysis, root, true, selectedPlugins), + _patchGraphDataNodeMetrics: (graphData, filePaths) => + facade._patchGraphDataNodeMetrics(graphData, filePaths), + _preAnalyzePlugins: (files, root, abortSignal, nextDisabledPlugins = disabledPlugins) => + facade._preAnalyzePlugins(files, root, abortSignal, nextDisabledPlugins), + _readAnalysisFiles: files => facade._readAnalysisFiles(files), + analyze: (patterns, selectedPlugins, abortSignal, progress) => + facade.analyze(patterns, selectedPlugins, abortSignal, progress), + invalidateWorkspaceFiles: paths => facade.invalidateWorkspaceFiles(paths), + } as WorkspacePipelineRefreshSource; + + Object.defineProperties(source, { + _lastDiscoveredDirectories: { + get: () => facade._lastDiscoveredDirectories, + set: (directories: WorkspacePipelineRefreshSource['_lastDiscoveredDirectories']) => { + facade._lastDiscoveredDirectories = directories; + }, + }, + _lastDiscoveredFiles: { + get: () => facade._lastDiscoveredFiles, + set: (files: WorkspacePipelineRefreshSource['_lastDiscoveredFiles']) => { + facade._lastDiscoveredFiles = files; + }, + }, + _lastFileAnalysis: { + get: () => facade._lastFileAnalysis, + set: (fileAnalysis: WorkspacePipelineRefreshSource['_lastFileAnalysis']) => { + facade._lastFileAnalysis = fileAnalysis; + }, + }, + _lastFileConnections: { + get: () => facade._lastFileConnections, + set: (fileConnections: WorkspacePipelineRefreshSource['_lastFileConnections']) => { + facade._lastFileConnections = fileConnections; + }, + }, + _lastGraphData: { + get: () => facade._lastGraphData, + set: (graphData: WorkspacePipelineRefreshSource['_lastGraphData']) => { + facade._lastGraphData = graphData; + }, + }, + _lastWorkspaceRoot: { + get: () => facade._lastWorkspaceRoot, + set: (root: WorkspacePipelineRefreshSource['_lastWorkspaceRoot']) => { + facade._lastWorkspaceRoot = root; + }, + }, + }); + + return source; +} diff --git a/packages/extension/src/extension/pipeline/service/refreshFacade.ts b/packages/extension/src/extension/pipeline/service/refreshFacade.ts index b245902a0..bd49f98a6 100644 --- a/packages/extension/src/extension/pipeline/service/refreshFacade.ts +++ b/packages/extension/src/extension/pipeline/service/refreshFacade.ts @@ -1,278 +1,29 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import * as vscode from 'vscode'; -import { - hasRequiredAnalysisCacheTiers, - type IDiscoveredFile, -} from '@codegraphy-dev/core'; import type { IGraphData } from '../../../shared/graph/contracts'; import { WorkspacePipelineDiscoveryFacade } from './discoveryFacade'; -import { createWorkspacePipelineAnalysisCacheTiers } from './cache/tiers'; import { getCachedGitHistoryChurnCounts } from '../../gitHistory/cache/state'; import { createGitHistoryPluginSignature } from '../../gitHistory/pluginSignature'; -import { - createWorkspacePipelineDiscoveryDependencies, - discoverWorkspacePipelineFilesWithWarnings, -} from './runtime/discovery'; -import { - refreshWorkspacePipelineAnalysisScope, - refreshWorkspacePipelineChangedFiles, - refreshWorkspacePipelinePluginFiles, - type WorkspacePipelineRefreshSource, -} from './runtime/refresh'; - -interface ChangedFileDiscoveryState { - directories: string[]; - files: IDiscoveredFile[]; -} - -interface GraphMetricPatchResult { - changed: boolean; - node: IGraphData['nodes'][number]; -} - -function normalizeGraphMetricFilePath(filePath: string): string { - return filePath.replace(/\\/g, '/'); -} - -function getGraphMetricNodeFilePath(node: IGraphData['nodes'][number]): string { - const symbolFilePath = node.symbol?.filePath; - return normalizeGraphMetricFilePath( - typeof symbolFilePath === 'string' && symbolFilePath.length > 0 - ? symbolFilePath - : node.id, - ); -} +import { refreshAnalysisScopeForFacade } from './refresh/modes/analysisScope'; +import { refreshChangedFilesForFacade } from './refresh/modes/changedFiles'; +import type { RefreshFacadeContext } from './refresh/context'; +import { refreshGitignoreMetadataForFacade } from './refresh/modes/gitignoreMetadata'; +import { patchGraphDataNodeMetrics } from './refresh/metrics'; +import { refreshPluginFilesForFacade } from './refresh/modes/pluginFiles'; export abstract class WorkspacePipelineRefreshFacade extends WorkspacePipelineDiscoveryFacade { - private _createWorkspaceIndexRefreshSource( - disabledPlugins: Set = new Set(), - ): WorkspacePipelineRefreshSource { - const source = { - _analyzeFiles: ( - files, - root, - progress, - abortSignal, - pluginIds, - nextDisabledPlugins = disabledPlugins, - ) => this._analyzeFiles( - files, - root, - progress, - abortSignal, - pluginIds, - nextDisabledPlugins, - ), - _buildGraphData: (fileConnections, root, selectedPlugins) => - this._buildGraphData(fileConnections, root, true, selectedPlugins), - _buildGraphDataFromAnalysis: (fileAnalysis, root, selectedPlugins) => - this._buildGraphDataFromAnalysis(fileAnalysis, root, true, selectedPlugins), - _patchGraphDataNodeMetrics: (graphData, filePaths) => - this._patchGraphDataNodeMetrics(graphData, filePaths), - _preAnalyzePlugins: (files, root, abortSignal, nextDisabledPlugins = disabledPlugins) => - this._preAnalyzePlugins(files, root, abortSignal, nextDisabledPlugins), - _readAnalysisFiles: files => this._readAnalysisFiles(files), - analyze: (patterns, selectedPlugins, abortSignal, progress) => - this.analyze(patterns, selectedPlugins, abortSignal, progress), - invalidateWorkspaceFiles: paths => this.invalidateWorkspaceFiles(paths), - } as WorkspacePipelineRefreshSource; - - Object.defineProperties(source, { - _lastDiscoveredDirectories: { - get: () => this._lastDiscoveredDirectories, - set: (directories: WorkspacePipelineRefreshSource['_lastDiscoveredDirectories']) => { - this._lastDiscoveredDirectories = directories; - }, - }, - _lastDiscoveredFiles: { - get: () => this._lastDiscoveredFiles, - set: (files: WorkspacePipelineRefreshSource['_lastDiscoveredFiles']) => { - this._lastDiscoveredFiles = files; - }, - }, - _lastFileAnalysis: { - get: () => this._lastFileAnalysis, - set: (fileAnalysis: WorkspacePipelineRefreshSource['_lastFileAnalysis']) => { - this._lastFileAnalysis = fileAnalysis; - }, - }, - _lastFileConnections: { - get: () => this._lastFileConnections, - set: (fileConnections: WorkspacePipelineRefreshSource['_lastFileConnections']) => { - this._lastFileConnections = fileConnections; - }, - }, - _lastGraphData: { - get: () => this._lastGraphData, - set: (graphData: WorkspacePipelineRefreshSource['_lastGraphData']) => { - this._lastGraphData = graphData; - }, - }, - _lastWorkspaceRoot: { - get: () => this._lastWorkspaceRoot, - set: (root: WorkspacePipelineRefreshSource['_lastWorkspaceRoot']) => { - this._lastWorkspaceRoot = root; - }, - }, - }); - - return source; - } - - private _patchGraphDataNodeMetrics( + protected _patchGraphDataNodeMetrics( graphData: IGraphData, filePaths: readonly string[], ): IGraphData { - const metricFilePaths = new Set(filePaths.map(normalizeGraphMetricFilePath)); - if (metricFilePaths.size === 0) { - return graphData; - } - const churnCounts = getCachedGitHistoryChurnCounts( this._context.workspaceState, createGitHistoryPluginSignature(this._registry), ) ?? {}; - let changed = false; - const nodes = graphData.nodes.map((node) => { - const result = this._patchGraphDataNodeMetric(node, metricFilePaths, churnCounts); - changed ||= result.changed; - return result.node; - }); - - return changed ? { ...graphData, nodes } : graphData; - } - - private _patchGraphDataNodeMetric( - node: IGraphData['nodes'][number], - metricFilePaths: ReadonlySet, - churnCounts: Record, - ): GraphMetricPatchResult { - const filePath = getGraphMetricNodeFilePath(node); - if (!metricFilePaths.has(filePath)) { - return { changed: false, node }; - } - - const fileSize = this._cache.files[filePath]?.size; - const churn = churnCounts[filePath] ?? 0; - if (node.fileSize === fileSize && node.churn === churn) { - return { changed: false, node }; - } - - return { changed: true, node: { ...node, fileSize, churn } }; - } - - private _canReuseCurrentAnalysisForScope( - discoveredFiles: readonly IDiscoveredFile[], - disabledPlugins: Set, - ): boolean { - if (discoveredFiles.length === 0) { - return false; - } - - const nodeVisibility = this._config.get>('nodeVisibility', {}) ?? {}; - const activePluginIds = this._getActiveAnalysisPluginIds(undefined, disabledPlugins); - const requiredTiers = createWorkspacePipelineAnalysisCacheTiers( - nodeVisibility, - activePluginIds, - ).required; - - return discoveredFiles.every((file) => { - const analysis = this._lastFileAnalysis.get(file.relativePath); - return Boolean(analysis && hasRequiredAnalysisCacheTiers(analysis, requiredTiers)); - }); - } - - private async _rebuildAnalysisScopeFromCurrentAnalysis(input: { - discoveredDirectories: readonly string[]; - discoveredFiles: IDiscoveredFile[]; - disabledPlugins: Set; - onProgress?: (progress: { phase: string; current: number; total: number }) => void; - showOrphans: boolean; - workspaceRoot: string; - }): Promise { - input.onProgress?.({ - phase: 'Applying Scope', - current: 0, - total: input.discoveredFiles.length, - }); - - this._lastDiscoveredDirectories = [...input.discoveredDirectories]; - this._lastDiscoveredFiles = input.discoveredFiles; - this._lastWorkspaceRoot = input.workspaceRoot; - - const graphData = this._buildGraphDataFromAnalysis( - this._lastFileAnalysis, - input.workspaceRoot, - input.showOrphans, - input.disabledPlugins, - ); - - await this._persistIndexMetadata(); - input.onProgress?.({ - phase: 'Applying Scope', - current: input.discoveredFiles.length, - total: input.discoveredFiles.length, + return patchGraphDataNodeMetrics({ + churnCounts, + filePaths, + fileSizes: this._cache.files, + graphData, }); - - return graphData; - } - - private _getReusableChangedFileDiscoveryState( - workspaceRoot: string, - filePaths: readonly string[], - ): ChangedFileDiscoveryState | undefined { - if (!this._hasReusableChangedFileDiscoveryState(workspaceRoot, filePaths)) { - return undefined; - } - - const discoveredByRelativePath = this._createDiscoveredFilesByRelativePath(); - - for (const filePath of filePaths) { - if (!this._canReuseChangedFileDiscovery(filePath, workspaceRoot, discoveredByRelativePath)) { - return undefined; - } - } - - return { - directories: [...this._lastDiscoveredDirectories], - files: this._lastDiscoveredFiles, - }; - } - - private _hasReusableChangedFileDiscoveryState( - workspaceRoot: string, - filePaths: readonly string[], - ): boolean { - return filePaths.length > 0 - && this._lastWorkspaceRoot === workspaceRoot - && this._lastDiscoveredFiles.length > 0; - } - - private _createDiscoveredFilesByRelativePath(): Map { - return new Map( - this._lastDiscoveredFiles.map(file => [ - normalizeGraphMetricFilePath(file.relativePath), - file, - ]), - ); - } - - private _canReuseChangedFileDiscovery( - filePath: string, - workspaceRoot: string, - discoveredByRelativePath: ReadonlyMap, - ): boolean { - const relativePath = this._toWorkspaceRelativePath(workspaceRoot, filePath); - return Boolean( - relativePath - && discoveredByRelativePath.has(relativePath) - && fs.existsSync(this._toAbsoluteChangedFilePath(workspaceRoot, filePath)), - ); - } - - private _toAbsoluteChangedFilePath(workspaceRoot: string, filePath: string): string { - return path.isAbsolute(filePath) ? filePath : path.join(workspaceRoot, filePath); } async refreshAnalysisScope( @@ -281,52 +32,11 @@ export abstract class WorkspacePipelineRefreshFacade extends WorkspacePipelineDi signal?: AbortSignal, onProgress?: (progress: { phase: string; current: number; total: number }) => void, ): Promise { - const workspaceRoot = this._getWorkspaceRoot(); - if (!workspaceRoot) { - return { nodes: [], edges: [] }; - } - - const config = this._config.getAll(); - const disabledCustomPatterns = new Set(config.disabledCustomFilterPatterns); - const disabledPluginPatterns = new Set(config.disabledPluginFilterPatterns); - const discoveryResult = await discoverWorkspacePipelineFilesWithWarnings( - createWorkspacePipelineDiscoveryDependencies(this._discovery), - workspaceRoot, - config, - filterPatterns.filter(pattern => !disabledCustomPatterns.has(pattern)), - this.getPluginFilterPatterns(disabledPlugins) - .filter(pattern => !disabledPluginPatterns.has(pattern)), - signal, - message => { - vscode.window.showWarningMessage(message); - }, - ); - this._lastGitIgnoredPaths = discoveryResult.gitIgnoredPaths ?? []; - - if (this._canReuseCurrentAnalysisForScope(discoveryResult.files, disabledPlugins)) { - return this._rebuildAnalysisScopeFromCurrentAnalysis({ - disabledPlugins, - discoveredDirectories: discoveryResult.directories ?? [], - discoveredFiles: discoveryResult.files, - onProgress, - showOrphans: config.showOrphans ?? true, - workspaceRoot, - }); - } - - return refreshWorkspacePipelineAnalysisScope(this._createWorkspaceIndexRefreshSource(disabledPlugins), { + return refreshAnalysisScopeForFacade(this as unknown as RefreshFacadeContext, { disabledPlugins, - discoveredDirectories: discoveryResult.directories ?? [], - discoveredFiles: discoveryResult.files, + filterPatterns, onProgress, - persistCache: () => { - this._persistCache(); - }, - persistIndexMetadata: async () => { - await this._persistIndexMetadata(); - }, signal, - workspaceRoot, }); } @@ -337,42 +47,12 @@ export abstract class WorkspacePipelineRefreshFacade extends WorkspacePipelineDi signal?: AbortSignal, onProgress?: (progress: { phase: string; current: number; total: number }) => void, ): Promise { - const workspaceRoot = this._getWorkspaceRoot(); - if (!workspaceRoot || pluginIds.length === 0) { - return { nodes: [], edges: [] }; - } - - const config = this._config.getAll(); - const disabledCustomPatterns = new Set(config.disabledCustomFilterPatterns); - const disabledPluginPatterns = new Set(config.disabledPluginFilterPatterns); - const discoveryResult = await discoverWorkspacePipelineFilesWithWarnings( - createWorkspacePipelineDiscoveryDependencies(this._discovery), - workspaceRoot, - config, - filterPatterns.filter(pattern => !disabledCustomPatterns.has(pattern)), - this.getPluginFilterPatterns(disabledPlugins) - .filter(pattern => !disabledPluginPatterns.has(pattern)), - signal, - message => { - vscode.window.showWarningMessage(message); - }, - ); - this._lastGitIgnoredPaths = discoveryResult.gitIgnoredPaths ?? []; - return refreshWorkspacePipelinePluginFiles(this._createWorkspaceIndexRefreshSource(disabledPlugins), { + return refreshPluginFilesForFacade(this as unknown as RefreshFacadeContext, { disabledPlugins, - discoveredDirectories: discoveryResult.directories ?? [], - discoveredFiles: discoveryResult.files, + filterPatterns, onProgress, - persistCache: () => { - this._persistCache(); - }, - persistIndexMetadata: async () => { - await this._persistIndexMetadata(); - }, pluginIds, - pluginInfos: this._registry.list(), signal, - workspaceRoot, }); } @@ -383,73 +63,12 @@ export abstract class WorkspacePipelineRefreshFacade extends WorkspacePipelineDi signal?: AbortSignal, onProgress?: (progress: { phase: string; current: number; total: number }) => void, ): Promise { - const workspaceRoot = this._getWorkspaceRoot(); - if (!workspaceRoot) { - return { nodes: [], edges: [] }; - } - - const config = this._config.getAll(); - const disabledCustomPatterns = new Set(config.disabledCustomFilterPatterns); - const disabledPluginPatterns = new Set(config.disabledPluginFilterPatterns); - const reusableDiscoveryState = this._getReusableChangedFileDiscoveryState( - workspaceRoot, - filePaths, - ); - let discoveryResult: ChangedFileDiscoveryState; - if (reusableDiscoveryState) { - discoveryResult = reusableDiscoveryState; - } else { - const discovered = await discoverWorkspacePipelineFilesWithWarnings( - createWorkspacePipelineDiscoveryDependencies(this._discovery), - workspaceRoot, - config, - filterPatterns.filter(pattern => !disabledCustomPatterns.has(pattern)), - this.getPluginFilterPatterns(disabledPlugins) - .filter(pattern => !disabledPluginPatterns.has(pattern)), - signal, - message => { - vscode.window.showWarningMessage(message); - }, - ); - discoveryResult = { - directories: discovered.directories ?? [], - files: discovered.files, - }; - this._lastDiscoveredDirectories = discoveryResult.directories; - this._lastGitIgnoredPaths = discovered.gitIgnoredPaths ?? []; - } - - return refreshWorkspacePipelineChangedFiles(this._createWorkspaceIndexRefreshSource(disabledPlugins), { - deferMetricOnlyIndexMetadata: true, + return refreshChangedFilesForFacade(this as unknown as RefreshFacadeContext, { disabledPlugins, - discoveredDirectories: discoveryResult.directories, - discoveredFiles: discoveryResult.files, filePaths, filterPatterns, - notifyFilesChanged: ( - files, - root, - analysisContext, - nextDisabledPlugins = disabledPlugins, - ) => - this._registry.notifyFilesChanged( - files, - root, - analysisContext, - nextDisabledPlugins, - ), onProgress, - onDeferredIndexMetadataError: error => { - console.warn('[CodeGraphy] Failed to persist metric-only refresh metadata.', error); - }, - persistCache: () => { - this._persistCache(); - }, - persistIndexMetadata: async () => { - await this._persistIndexMetadata(); - }, signal, - workspaceRoot, }); } @@ -458,42 +77,11 @@ export abstract class WorkspacePipelineRefreshFacade extends WorkspacePipelineDi disabledPlugins: Set = new Set(), signal?: AbortSignal, ): Promise { - const workspaceRoot = this._getWorkspaceRoot(); - if (!workspaceRoot) { - return { nodes: [], edges: [] }; - } - - const config = this._config.getAll(); - const disabledCustomPatterns = new Set(config.disabledCustomFilterPatterns); - const disabledPluginPatterns = new Set(config.disabledPluginFilterPatterns); - const discoveryResult = await discoverWorkspacePipelineFilesWithWarnings( - createWorkspacePipelineDiscoveryDependencies(this._discovery), - workspaceRoot, - config, - filterPatterns.filter(pattern => !disabledCustomPatterns.has(pattern)), - this.getPluginFilterPatterns(disabledPlugins) - .filter(pattern => !disabledPluginPatterns.has(pattern)), + return refreshGitignoreMetadataForFacade(this as unknown as RefreshFacadeContext, { + disabledPlugins, + filterPatterns, signal, - message => { - vscode.window.showWarningMessage(message); - }, - ); - - this._lastDiscoveredDirectories = discoveryResult.directories ?? []; - this._lastDiscoveredFiles = discoveryResult.files; - this._lastGitIgnoredPaths = discoveryResult.gitIgnoredPaths ?? []; - this._lastWorkspaceRoot = workspaceRoot; - - void this._persistIndexMetadata().catch(error => { - console.warn('[CodeGraphy] Failed to persist gitignore metadata refresh.', error); }); - - return this._buildGraphDataFromAnalysis( - this._lastFileAnalysis, - workspaceRoot, - config.showOrphans, - disabledPlugins, - ); } abstract invalidateWorkspaceFiles(filePaths: readonly string[]): string[]; From e85ea24afe24caf31a4910ffbc06ee27dcb2b770 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 14:49:59 -0700 Subject: [PATCH 105/192] refactor: split core refresh mutation sites --- packages/core/src/indexing/refresh.ts | 584 +----------------- .../core/src/indexing/refresh/contracts.ts | 98 +++ packages/core/src/indexing/refresh/graph.ts | 79 +++ .../indexing/refresh/modes/analysisScope.ts | 48 ++ .../indexing/refresh/modes/changedFiles.ts | 148 +++++ .../src/indexing/refresh/modes/pluginFiles.ts | 73 +++ packages/core/src/indexing/refresh/plugins.ts | 26 + .../src/indexing/refresh/snapshot/capture.ts | 82 +++ .../indexing/refresh/snapshot/eligibility.ts | 13 + .../refresh/snapshot/serialization.ts | 23 + packages/core/src/indexing/refresh/state.ts | 41 ++ 11 files changed, 656 insertions(+), 559 deletions(-) create mode 100644 packages/core/src/indexing/refresh/contracts.ts create mode 100644 packages/core/src/indexing/refresh/graph.ts create mode 100644 packages/core/src/indexing/refresh/modes/analysisScope.ts create mode 100644 packages/core/src/indexing/refresh/modes/changedFiles.ts create mode 100644 packages/core/src/indexing/refresh/modes/pluginFiles.ts create mode 100644 packages/core/src/indexing/refresh/plugins.ts create mode 100644 packages/core/src/indexing/refresh/snapshot/capture.ts create mode 100644 packages/core/src/indexing/refresh/snapshot/eligibility.ts create mode 100644 packages/core/src/indexing/refresh/snapshot/serialization.ts create mode 100644 packages/core/src/indexing/refresh/state.ts diff --git a/packages/core/src/indexing/refresh.ts b/packages/core/src/indexing/refresh.ts index 46dee7add..7ce8a6ba0 100644 --- a/packages/core/src/indexing/refresh.ts +++ b/packages/core/src/indexing/refresh.ts @@ -1,573 +1,39 @@ -import type { IFileAnalysisResult } from '@codegraphy-dev/plugin-api'; -import type { IWorkspaceFileAnalysisResult } from '../analysis/fileAnalysis'; -import type { IProjectedConnection } from '../analysis/projectedConnection'; -import type { IDiscoveredFile } from '../discovery/contracts'; import type { IGraphData } from '../graph/contracts'; -import { toRepoRelativeGraphPath } from '../graph/symbolPaths'; -import { getWorkspaceIndexPluginMatchingFiles } from '../plugins/status/extensions'; -import { - mapDiscoveredWorkspaceIndexFilesByRelativePath, - mergeDiscoveredWorkspaceIndexFiles, - selectDiscoveredWorkspaceIndexFileChanges, -} from './changedFiles'; - -type WorkspaceIndexPluginInfo = { - plugin: { - id: string; - supportedExtensions: readonly string[]; - }; -}; - -export interface WorkspaceIndexRefreshSource { - _analyzeFiles( - files: IDiscoveredFile[], - workspaceRoot: string, - onProgress?: (progress: { current: number; total: number; filePath: string }) => void, - signal?: AbortSignal, - pluginIds?: readonly string[], - disabledPlugins?: Set, - ): Promise; - _buildGraphData( - fileConnections: Map, - workspaceRoot: string, - disabledPlugins: Set, - ): IGraphData; - _buildGraphDataFromAnalysis( - fileAnalysis: Map, - workspaceRoot: string, - disabledPlugins: Set, - ): IGraphData; - _lastDiscoveredDirectories: string[]; - _lastDiscoveredFiles: IDiscoveredFile[]; - _lastFileAnalysis: Map; - _lastFileConnections: Map; - _lastGraphData: IGraphData; - _lastWorkspaceRoot: string; - _patchGraphDataNodeMetrics?( - this: void, - graphData: IGraphData, - filePaths: readonly string[], - ): IGraphData; - _preAnalyzePlugins( - files: IDiscoveredFile[], - workspaceRoot: string, - signal?: AbortSignal, - disabledPlugins?: Set, - ): Promise; - _readAnalysisFiles( - files: IDiscoveredFile[], - ): Promise>; - analyze( - filterPatterns?: string[], - disabledPlugins?: Set, - signal?: AbortSignal, - onProgress?: (progress: { phase: string; current: number; total: number }) => void, - ): Promise; - invalidateWorkspaceFiles(filePaths: readonly string[]): string[]; -} - -export interface WorkspaceIndexRefreshDependencies { - deferMetricOnlyIndexMetadata?: boolean; - disabledPlugins: Set; - discoveredDirectories?: string[]; - discoveredFiles: IDiscoveredFile[]; - filePaths: readonly string[]; - filterPatterns: string[]; - notifyFilesChanged( - files: Array<{ absolutePath: string; relativePath: string; content: string }>, - workspaceRoot: string, - analysisContext?: undefined, - disabledPlugins?: Set, - ): Promise<{ additionalFilePaths: string[]; requiresFullRefresh: boolean }>; - onProgress?: (progress: { phase: string; current: number; total: number }) => void; - onDeferredIndexMetadataError?(error: unknown): void; - persistCache(): void; - persistIndexMetadata(): Promise; - signal?: AbortSignal; - workspaceRoot: string; -} - -export interface WorkspaceIndexAnalysisScopeRefreshDependencies { - disabledPlugins: Set; - discoveredDirectories?: string[]; - discoveredFiles: IDiscoveredFile[]; - onProgress?: (progress: { phase: string; current: number; total: number }) => void; - persistCache(): void; - persistIndexMetadata(): Promise; - signal?: AbortSignal; - workspaceRoot: string; -} - -export interface WorkspaceIndexPluginRefreshDependencies - extends WorkspaceIndexAnalysisScopeRefreshDependencies { - pluginIds: readonly string[]; - pluginInfos: readonly WorkspaceIndexPluginInfo[]; -} - -function analyzeWorkspaceIndexFromRefresh( +import type { + WorkspaceIndexAnalysisScopeRefreshDependencies, + WorkspaceIndexPluginRefreshDependencies, + WorkspaceIndexRefreshDependencies, + WorkspaceIndexRefreshSource, +} from './refresh/contracts'; +import { refreshWorkspaceIndexAnalysisScope as refreshWorkspaceIndexAnalysisScopeImpl } from './refresh/modes/analysisScope'; +import { refreshWorkspaceIndexChangedFiles as refreshWorkspaceIndexChangedFilesImpl } from './refresh/modes/changedFiles'; +import { refreshWorkspaceIndexPluginFiles as refreshWorkspaceIndexPluginFilesImpl } from './refresh/modes/pluginFiles'; + +export type { + WorkspaceIndexAnalysisScopeRefreshDependencies, + WorkspaceIndexPluginInfo, + WorkspaceIndexPluginRefreshDependencies, + WorkspaceIndexRefreshDependencies, + WorkspaceIndexRefreshSource, +} from './refresh/contracts'; + +export function refreshWorkspaceIndexAnalysisScope( source: WorkspaceIndexRefreshSource, - dependencies: WorkspaceIndexRefreshDependencies, + dependencies: WorkspaceIndexAnalysisScopeRefreshDependencies, ): Promise { - return source.analyze( - dependencies.filterPatterns, - dependencies.disabledPlugins, - dependencies.signal, - progress => { - dependencies.onProgress?.({ - ...progress, - phase: progress.phase || 'Applying Changes', - }); - }, - ); + return refreshWorkspaceIndexAnalysisScopeImpl(source, dependencies); } -function mergeWorkspaceIndexGraphData( - primaryGraphData: IGraphData, - fallbackGraphData: IGraphData, -): IGraphData { - const nodeIds = new Set(primaryGraphData.nodes.map(node => node.id)); - const edgeIds = new Set(primaryGraphData.edges.map(edge => - edge.id ?? `${edge.from}\0${edge.to}\0${edge.kind}`, - )); - - return { - nodes: [ - ...primaryGraphData.nodes, - ...fallbackGraphData.nodes.filter(node => !nodeIds.has(node.id)), - ], - edges: [ - ...primaryGraphData.edges, - ...fallbackGraphData.edges.filter(edge => - !edgeIds.has(edge.id ?? `${edge.from}\0${edge.to}\0${edge.kind}`), - ), - ], - }; -} - -function workspaceIndexAnalysisCoversConnections( - fileAnalysis: ReadonlyMap, - fileConnections: ReadonlyMap, - workspaceRoot: string, -): boolean { - if (fileConnections.size === 0) { - return true; - } - - const analysisFilePaths = new Set( - [...fileAnalysis.keys()].map(filePath => - toRepoRelativeGraphPath(filePath, workspaceRoot), - ), - ); - - for (const filePath of fileConnections.keys()) { - if (!analysisFilePaths.has(toRepoRelativeGraphPath(filePath, workspaceRoot))) { - return false; - } - } - - return true; -} - -function buildWorkspaceIndexGraphFromRefreshState( +export function refreshWorkspaceIndexChangedFiles( source: WorkspaceIndexRefreshSource, - workspaceRoot: string, - disabledPlugins: Set, -): IGraphData { - const analysisGraphData = source._buildGraphDataFromAnalysis( - source._lastFileAnalysis, - workspaceRoot, - disabledPlugins, - ); - if (workspaceIndexAnalysisCoversConnections( - source._lastFileAnalysis, - source._lastFileConnections, - workspaceRoot, - )) { - source._lastGraphData = analysisGraphData; - return analysisGraphData; - } - - const graphData = mergeWorkspaceIndexGraphData( - analysisGraphData, - source._buildGraphData(source._lastFileConnections, workspaceRoot, disabledPlugins), - ); - source._lastGraphData = graphData; - return graphData; -} - -function listOrEmpty(value: readonly T[] | undefined): readonly T[] { - return value ?? []; -} - -function serializeWorkspaceIndexGraphAnalysis(analysis: IFileAnalysisResult): string { - return JSON.stringify({ - edgeTypes: listOrEmpty(analysis.edgeTypes), - filePath: analysis.filePath, - nodeTypes: listOrEmpty(analysis.nodeTypes), - nodes: listOrEmpty(analysis.nodes), - relations: listOrEmpty(analysis.relations), - symbols: listOrEmpty(analysis.symbols), - }); -} - -function serializeWorkspaceIndexConnections( - connections: IProjectedConnection[] | undefined, -): string { - return JSON.stringify(connections ?? []); -} - -interface WorkspaceIndexRefreshGraphSnapshot { - fileAnalysisByPath: Map; - fileConnectionsByPath: Map; -} - -function canCaptureWorkspaceIndexRefreshGraphSnapshot(source: WorkspaceIndexRefreshSource): boolean { - return Boolean(source._patchGraphDataNodeMetrics) && !isWorkspaceIndexGraphDataEmpty(source._lastGraphData); -} - -function isWorkspaceIndexGraphDataEmpty(graphData: IGraphData): boolean { - return graphData.nodes.length === 0 && graphData.edges.length === 0; -} - -function captureWorkspaceIndexRefreshGraphSnapshot( - source: WorkspaceIndexRefreshSource, - files: readonly IDiscoveredFile[], -): WorkspaceIndexRefreshGraphSnapshot | undefined { - if (!canCaptureWorkspaceIndexRefreshGraphSnapshot(source)) { - return undefined; - } - - const snapshot: WorkspaceIndexRefreshGraphSnapshot = { - fileAnalysisByPath: new Map(), - fileConnectionsByPath: new Map(), - }; - - for (const file of files) { - if (!captureWorkspaceIndexRefreshSnapshotFile(source, snapshot, file.relativePath)) { - return undefined; - } - } - - return snapshot; -} - -function captureWorkspaceIndexRefreshSnapshotFile( - source: WorkspaceIndexRefreshSource, - snapshot: WorkspaceIndexRefreshGraphSnapshot, - relativePath: string, -): boolean { - const analysis = source._lastFileAnalysis.get(relativePath); - if (!analysis) { - return false; - } - - snapshot.fileAnalysisByPath.set(relativePath, serializeWorkspaceIndexGraphAnalysis(analysis)); - snapshot.fileConnectionsByPath.set( - relativePath, - serializeWorkspaceIndexConnections(source._lastFileConnections.get(relativePath)), - ); - return true; -} - -function workspaceIndexRefreshSnapshotMatchesFile( - snapshot: WorkspaceIndexRefreshGraphSnapshot, - analysisResult: IWorkspaceFileAnalysisResult, - relativePath: string, -): boolean { - const analysis = analysisResult.fileAnalysis.get(relativePath); - if (!analysis) { - return false; - } - - return snapshot.fileAnalysisByPath.get(relativePath) === serializeWorkspaceIndexGraphAnalysis(analysis) - && snapshot.fileConnectionsByPath.get(relativePath) - === serializeWorkspaceIndexConnections(analysisResult.fileConnections.get(relativePath)); -} - -function canPatchWorkspaceIndexRefreshGraphData( - snapshot: WorkspaceIndexRefreshGraphSnapshot | undefined, - analysisResult: IWorkspaceFileAnalysisResult, - files: readonly IDiscoveredFile[], -): boolean { - if (!snapshot) { - return false; - } - - return files.every(file => - workspaceIndexRefreshSnapshotMatchesFile(snapshot, analysisResult, file.relativePath), - ); -} - -function persistMetricOnlyIndexMetadata( dependencies: WorkspaceIndexRefreshDependencies, -): Promise | void { - const persistence = dependencies.persistIndexMetadata(); - if (dependencies.deferMetricOnlyIndexMetadata) { - void persistence.catch(error => { - dependencies.onDeferredIndexMetadataError?.(error); - }); - return; - } - - return persistence; -} - -function applyWorkspaceIndexAnalysisResult( - source: WorkspaceIndexRefreshSource, - analysisResult: IWorkspaceFileAnalysisResult, -): void { - for (const [filePath, analysis] of analysisResult.fileAnalysis) { - source._lastFileAnalysis.set(filePath, analysis); - } - for (const [filePath, connections] of analysisResult.fileConnections) { - source._lastFileConnections.set(filePath, connections); - } -} - -function updateWorkspaceIndexDiscoveryState( - source: WorkspaceIndexRefreshSource, - dependencies: Pick< - WorkspaceIndexAnalysisScopeRefreshDependencies, - 'discoveredDirectories' | 'discoveredFiles' | 'workspaceRoot' - >, -): void { - source._lastDiscoveredDirectories = dependencies.discoveredDirectories ?? []; - source._lastDiscoveredFiles = [...dependencies.discoveredFiles]; - source._lastWorkspaceRoot = dependencies.workspaceRoot; -} - -function retainWorkspaceIndexDiscoveredFileConnections( - source: WorkspaceIndexRefreshSource, - discoveredFiles: readonly IDiscoveredFile[], -): void { - for (const file of discoveredFiles) { - if (!source._lastFileConnections.has(file.relativePath)) { - source._lastFileConnections.set(file.relativePath, []); - } - } -} - -function selectWorkspaceIndexPluginInfos( - pluginInfos: readonly WorkspaceIndexPluginInfo[], - pluginIds: readonly string[], -): WorkspaceIndexPluginInfo[] { - const selectedPluginIds = new Set(pluginIds); - return pluginInfos.filter(({ plugin }) => selectedPluginIds.has(plugin.id)); -} - -function selectWorkspaceIndexPluginFiles( - pluginInfos: readonly WorkspaceIndexPluginInfo[], - discoveredFiles: readonly IDiscoveredFile[], -): IDiscoveredFile[] { - const matchingFilePaths = new Set(); - - for (const pluginInfo of pluginInfos) { - for (const file of getWorkspaceIndexPluginMatchingFiles(pluginInfo, [...discoveredFiles])) { - matchingFilePaths.add(file.relativePath); - } - } - - return discoveredFiles.filter(file => matchingFilePaths.has(file.relativePath)); -} - -export async function refreshWorkspaceIndexAnalysisScope( - source: WorkspaceIndexRefreshSource, - dependencies: WorkspaceIndexAnalysisScopeRefreshDependencies, ): Promise { - updateWorkspaceIndexDiscoveryState(source, dependencies); - - dependencies.onProgress?.({ - phase: 'Applying Scope', - current: 0, - total: dependencies.discoveredFiles.length, - }); - - const analysisResult = await source._analyzeFiles( - [...dependencies.discoveredFiles], - dependencies.workspaceRoot, - progress => { - dependencies.onProgress?.({ - phase: 'Applying Scope', - current: progress.current, - total: progress.total, - }); - }, - dependencies.signal, - undefined, - dependencies.disabledPlugins, - ); - - source._lastFileAnalysis = analysisResult.fileAnalysis; - source._lastFileConnections = analysisResult.fileConnections; - dependencies.persistCache(); - - const graphData = buildWorkspaceIndexGraphFromRefreshState( - source, - dependencies.workspaceRoot, - dependencies.disabledPlugins, - ); - await dependencies.persistIndexMetadata(); - - return graphData; + return refreshWorkspaceIndexChangedFilesImpl(source, dependencies); } -export async function refreshWorkspaceIndexPluginFiles( +export function refreshWorkspaceIndexPluginFiles( source: WorkspaceIndexRefreshSource, dependencies: WorkspaceIndexPluginRefreshDependencies, ): Promise { - updateWorkspaceIndexDiscoveryState(source, dependencies); - retainWorkspaceIndexDiscoveredFileConnections(source, dependencies.discoveredFiles); - - const pluginInfos = selectWorkspaceIndexPluginInfos( - dependencies.pluginInfos, - dependencies.pluginIds, - ); - const registeredPluginIds = pluginInfos.map(({ plugin }) => plugin.id); - if (pluginInfos.length === 0) { - const graphData = buildWorkspaceIndexGraphFromRefreshState( - source, - dependencies.workspaceRoot, - dependencies.disabledPlugins, - ); - await dependencies.persistIndexMetadata(); - return graphData; - } - - const pluginFiles = selectWorkspaceIndexPluginFiles(pluginInfos, dependencies.discoveredFiles); - if (pluginFiles.length > 0) { - dependencies.onProgress?.({ - phase: 'Applying Plugin', - current: 0, - total: pluginFiles.length, - }); - const analysisResult = await source._analyzeFiles( - pluginFiles, - dependencies.workspaceRoot, - progress => { - dependencies.onProgress?.({ - phase: 'Applying Plugin', - current: progress.current, - total: progress.total, - }); - }, - dependencies.signal, - registeredPluginIds, - dependencies.disabledPlugins, - ); - - for (const [filePath, analysis] of analysisResult.fileAnalysis) { - source._lastFileAnalysis.set(filePath, analysis); - } - for (const [filePath, connections] of analysisResult.fileConnections) { - source._lastFileConnections.set(filePath, connections); - } - dependencies.persistCache(); - } - - const graphData = buildWorkspaceIndexGraphFromRefreshState( - source, - dependencies.workspaceRoot, - dependencies.disabledPlugins, - ); - await dependencies.persistIndexMetadata(); - - return graphData; -} - -export async function refreshWorkspaceIndexChangedFiles( - source: WorkspaceIndexRefreshSource, - dependencies: WorkspaceIndexRefreshDependencies, -): Promise { - const discoveredByRelativePath = mapDiscoveredWorkspaceIndexFilesByRelativePath( - dependencies.discoveredFiles, - ); - const changeSelection = selectDiscoveredWorkspaceIndexFileChanges( - dependencies.workspaceRoot, - dependencies.filePaths, - discoveredByRelativePath, - ); - const changedFiles = changeSelection.files; - - if (changeSelection.unmatchedFilePaths.length > 0) { - source.invalidateWorkspaceFiles(changeSelection.unmatchedFilePaths); - return analyzeWorkspaceIndexFromRefresh(source, dependencies); - } - - const changedAnalysisFiles = await source._readAnalysisFiles(changedFiles); - const incrementalLifecycle = await dependencies.notifyFilesChanged( - changedAnalysisFiles, - dependencies.workspaceRoot, - undefined, - dependencies.disabledPlugins, - ); - - if (incrementalLifecycle.requiresFullRefresh) { - return analyzeWorkspaceIndexFromRefresh(source, dependencies); - } - - const filesToAnalyze = mergeDiscoveredWorkspaceIndexFiles( - changedFiles, - incrementalLifecycle.additionalFilePaths, - discoveredByRelativePath, - ); - source._lastDiscoveredDirectories = dependencies.discoveredDirectories ?? []; - source._lastDiscoveredFiles = dependencies.discoveredFiles; - source._lastWorkspaceRoot = dependencies.workspaceRoot; - retainWorkspaceIndexDiscoveredFileConnections(source, dependencies.discoveredFiles); - - if (filesToAnalyze.length === 0) { - return buildWorkspaceIndexGraphFromRefreshState( - source, - dependencies.workspaceRoot, - dependencies.disabledPlugins, - ); - } - - const graphSnapshot = captureWorkspaceIndexRefreshGraphSnapshot(source, filesToAnalyze); - source.invalidateWorkspaceFiles(filesToAnalyze.map((file) => file.absolutePath)); - dependencies.onProgress?.({ - phase: 'Applying Changes', - current: 0, - total: filesToAnalyze.length, - }); - - const analysisResult = await source._analyzeFiles( - filesToAnalyze, - dependencies.workspaceRoot, - progress => { - dependencies.onProgress?.({ - phase: 'Applying Changes', - current: progress.current, - total: progress.total, - }); - }, - dependencies.signal, - undefined, - dependencies.disabledPlugins, - ); - - applyWorkspaceIndexAnalysisResult(source, analysisResult); - - dependencies.persistCache(); - if ( - canPatchWorkspaceIndexRefreshGraphData(graphSnapshot, analysisResult, filesToAnalyze) - && source._patchGraphDataNodeMetrics - ) { - const graphData = source._patchGraphDataNodeMetrics( - source._lastGraphData, - filesToAnalyze.map(file => file.relativePath), - ); - source._lastGraphData = graphData; - await persistMetricOnlyIndexMetadata(dependencies); - return graphData; - } - - const graphData = buildWorkspaceIndexGraphFromRefreshState( - source, - dependencies.workspaceRoot, - dependencies.disabledPlugins, - ); - await dependencies.persistIndexMetadata(); - - return graphData; + return refreshWorkspaceIndexPluginFilesImpl(source, dependencies); } diff --git a/packages/core/src/indexing/refresh/contracts.ts b/packages/core/src/indexing/refresh/contracts.ts new file mode 100644 index 000000000..5bb72000b --- /dev/null +++ b/packages/core/src/indexing/refresh/contracts.ts @@ -0,0 +1,98 @@ +import type { IFileAnalysisResult } from '@codegraphy-dev/plugin-api'; +import type { IWorkspaceFileAnalysisResult } from '../../analysis/fileAnalysis'; +import type { IProjectedConnection } from '../../analysis/projectedConnection'; +import type { IDiscoveredFile } from '../../discovery/contracts'; +import type { IGraphData } from '../../graph/contracts'; + +export type WorkspaceIndexPluginInfo = { + plugin: { + id: string; + supportedExtensions: readonly string[]; + }; +}; + +export interface WorkspaceIndexRefreshSource { + _analyzeFiles( + files: IDiscoveredFile[], + workspaceRoot: string, + onProgress?: (progress: { current: number; total: number; filePath: string }) => void, + signal?: AbortSignal, + pluginIds?: readonly string[], + disabledPlugins?: Set, + ): Promise; + _buildGraphData( + fileConnections: Map, + workspaceRoot: string, + disabledPlugins: Set, + ): IGraphData; + _buildGraphDataFromAnalysis( + fileAnalysis: Map, + workspaceRoot: string, + disabledPlugins: Set, + ): IGraphData; + _lastDiscoveredDirectories: string[]; + _lastDiscoveredFiles: IDiscoveredFile[]; + _lastFileAnalysis: Map; + _lastFileConnections: Map; + _lastGraphData: IGraphData; + _lastWorkspaceRoot: string; + _patchGraphDataNodeMetrics?( + this: void, + graphData: IGraphData, + filePaths: readonly string[], + ): IGraphData; + _preAnalyzePlugins( + files: IDiscoveredFile[], + workspaceRoot: string, + signal?: AbortSignal, + disabledPlugins?: Set, + ): Promise; + _readAnalysisFiles( + files: IDiscoveredFile[], + ): Promise>; + analyze( + filterPatterns?: string[], + disabledPlugins?: Set, + signal?: AbortSignal, + onProgress?: (progress: { phase: string; current: number; total: number }) => void, + ): Promise; + invalidateWorkspaceFiles(filePaths: readonly string[]): string[]; +} + +export interface WorkspaceIndexRefreshDependencies { + deferMetricOnlyIndexMetadata?: boolean; + disabledPlugins: Set; + discoveredDirectories?: string[]; + discoveredFiles: IDiscoveredFile[]; + filePaths: readonly string[]; + filterPatterns: string[]; + notifyFilesChanged( + files: Array<{ absolutePath: string; relativePath: string; content: string }>, + workspaceRoot: string, + analysisContext?: undefined, + disabledPlugins?: Set, + ): Promise<{ additionalFilePaths: string[]; requiresFullRefresh: boolean }>; + onProgress?: (progress: { phase: string; current: number; total: number }) => void; + onDeferredIndexMetadataError?(error: unknown): void; + persistCache(): void; + persistIndexMetadata(): Promise; + signal?: AbortSignal; + workspaceRoot: string; +} + +export interface WorkspaceIndexAnalysisScopeRefreshDependencies { + disabledPlugins: Set; + discoveredDirectories?: string[]; + discoveredFiles: IDiscoveredFile[]; + onProgress?: (progress: { phase: string; current: number; total: number }) => void; + persistCache(): void; + persistIndexMetadata(): Promise; + signal?: AbortSignal; + workspaceRoot: string; +} + +export interface WorkspaceIndexPluginRefreshDependencies + extends WorkspaceIndexAnalysisScopeRefreshDependencies { + pluginIds: readonly string[]; + pluginInfos: readonly WorkspaceIndexPluginInfo[]; +} diff --git a/packages/core/src/indexing/refresh/graph.ts b/packages/core/src/indexing/refresh/graph.ts new file mode 100644 index 000000000..ebc5de17a --- /dev/null +++ b/packages/core/src/indexing/refresh/graph.ts @@ -0,0 +1,79 @@ +import type { IFileAnalysisResult } from '@codegraphy-dev/plugin-api'; +import type { IProjectedConnection } from '../../analysis/projectedConnection'; +import type { IGraphData } from '../../graph/contracts'; +import { toRepoRelativeGraphPath } from '../../graph/symbolPaths'; +import type { WorkspaceIndexRefreshSource } from './contracts'; + +export function buildWorkspaceIndexGraphFromRefreshState( + source: WorkspaceIndexRefreshSource, + workspaceRoot: string, + disabledPlugins: Set, +): IGraphData { + const analysisGraphData = source._buildGraphDataFromAnalysis( + source._lastFileAnalysis, + workspaceRoot, + disabledPlugins, + ); + if (workspaceIndexAnalysisCoversConnections( + source._lastFileAnalysis, + source._lastFileConnections, + workspaceRoot, + )) { + source._lastGraphData = analysisGraphData; + return analysisGraphData; + } + + const graphData = mergeWorkspaceIndexGraphData( + analysisGraphData, + source._buildGraphData(source._lastFileConnections, workspaceRoot, disabledPlugins), + ); + source._lastGraphData = graphData; + return graphData; +} + +function mergeWorkspaceIndexGraphData( + primaryGraphData: IGraphData, + fallbackGraphData: IGraphData, +): IGraphData { + const nodeIds = new Set(primaryGraphData.nodes.map(node => node.id)); + const edgeIds = new Set(primaryGraphData.edges.map(edge => + edge.id ?? `${edge.from}\0${edge.to}\0${edge.kind}`, + )); + + return { + nodes: [ + ...primaryGraphData.nodes, + ...fallbackGraphData.nodes.filter(node => !nodeIds.has(node.id)), + ], + edges: [ + ...primaryGraphData.edges, + ...fallbackGraphData.edges.filter(edge => + !edgeIds.has(edge.id ?? `${edge.from}\0${edge.to}\0${edge.kind}`), + ), + ], + }; +} + +function workspaceIndexAnalysisCoversConnections( + fileAnalysis: ReadonlyMap, + fileConnections: ReadonlyMap, + workspaceRoot: string, +): boolean { + if (fileConnections.size === 0) { + return true; + } + + const analysisFilePaths = new Set( + [...fileAnalysis.keys()].map(filePath => + toRepoRelativeGraphPath(filePath, workspaceRoot), + ), + ); + + for (const filePath of fileConnections.keys()) { + if (!analysisFilePaths.has(toRepoRelativeGraphPath(filePath, workspaceRoot))) { + return false; + } + } + + return true; +} diff --git a/packages/core/src/indexing/refresh/modes/analysisScope.ts b/packages/core/src/indexing/refresh/modes/analysisScope.ts new file mode 100644 index 000000000..91d7335a3 --- /dev/null +++ b/packages/core/src/indexing/refresh/modes/analysisScope.ts @@ -0,0 +1,48 @@ +import type { IGraphData } from '../../../graph/contracts'; +import type { + WorkspaceIndexAnalysisScopeRefreshDependencies, + WorkspaceIndexRefreshSource, +} from '../contracts'; +import { buildWorkspaceIndexGraphFromRefreshState } from '../graph'; +import { updateWorkspaceIndexDiscoveryState } from '../state'; + +export async function refreshWorkspaceIndexAnalysisScope( + source: WorkspaceIndexRefreshSource, + dependencies: WorkspaceIndexAnalysisScopeRefreshDependencies, +): Promise { + updateWorkspaceIndexDiscoveryState(source, dependencies); + + dependencies.onProgress?.({ + phase: 'Applying Scope', + current: 0, + total: dependencies.discoveredFiles.length, + }); + + const analysisResult = await source._analyzeFiles( + [...dependencies.discoveredFiles], + dependencies.workspaceRoot, + progress => { + dependencies.onProgress?.({ + phase: 'Applying Scope', + current: progress.current, + total: progress.total, + }); + }, + dependencies.signal, + undefined, + dependencies.disabledPlugins, + ); + + source._lastFileAnalysis = analysisResult.fileAnalysis; + source._lastFileConnections = analysisResult.fileConnections; + dependencies.persistCache(); + + const graphData = buildWorkspaceIndexGraphFromRefreshState( + source, + dependencies.workspaceRoot, + dependencies.disabledPlugins, + ); + await dependencies.persistIndexMetadata(); + + return graphData; +} diff --git a/packages/core/src/indexing/refresh/modes/changedFiles.ts b/packages/core/src/indexing/refresh/modes/changedFiles.ts new file mode 100644 index 000000000..3fb786f92 --- /dev/null +++ b/packages/core/src/indexing/refresh/modes/changedFiles.ts @@ -0,0 +1,148 @@ +import type { IGraphData } from '../../../graph/contracts'; +import { + mapDiscoveredWorkspaceIndexFilesByRelativePath, + mergeDiscoveredWorkspaceIndexFiles, + selectDiscoveredWorkspaceIndexFileChanges, +} from '../../changedFiles'; +import type { + WorkspaceIndexRefreshDependencies, + WorkspaceIndexRefreshSource, +} from '../contracts'; +import { buildWorkspaceIndexGraphFromRefreshState } from '../graph'; +import { + canPatchWorkspaceIndexRefreshGraphData, + captureWorkspaceIndexRefreshGraphSnapshot, +} from '../snapshot/capture'; +import { + applyWorkspaceIndexAnalysisResult, + retainWorkspaceIndexDiscoveredFileConnections, +} from '../state'; + +export async function refreshWorkspaceIndexChangedFiles( + source: WorkspaceIndexRefreshSource, + dependencies: WorkspaceIndexRefreshDependencies, +): Promise { + const discoveredByRelativePath = mapDiscoveredWorkspaceIndexFilesByRelativePath( + dependencies.discoveredFiles, + ); + const changeSelection = selectDiscoveredWorkspaceIndexFileChanges( + dependencies.workspaceRoot, + dependencies.filePaths, + discoveredByRelativePath, + ); + const changedFiles = changeSelection.files; + + if (changeSelection.unmatchedFilePaths.length > 0) { + source.invalidateWorkspaceFiles(changeSelection.unmatchedFilePaths); + return analyzeWorkspaceIndexFromRefresh(source, dependencies); + } + + const changedAnalysisFiles = await source._readAnalysisFiles(changedFiles); + const incrementalLifecycle = await dependencies.notifyFilesChanged( + changedAnalysisFiles, + dependencies.workspaceRoot, + undefined, + dependencies.disabledPlugins, + ); + + if (incrementalLifecycle.requiresFullRefresh) { + return analyzeWorkspaceIndexFromRefresh(source, dependencies); + } + + const filesToAnalyze = mergeDiscoveredWorkspaceIndexFiles( + changedFiles, + incrementalLifecycle.additionalFilePaths, + discoveredByRelativePath, + ); + source._lastDiscoveredDirectories = dependencies.discoveredDirectories ?? []; + source._lastDiscoveredFiles = dependencies.discoveredFiles; + source._lastWorkspaceRoot = dependencies.workspaceRoot; + retainWorkspaceIndexDiscoveredFileConnections(source, dependencies.discoveredFiles); + + if (filesToAnalyze.length === 0) { + return buildWorkspaceIndexGraphFromRefreshState( + source, + dependencies.workspaceRoot, + dependencies.disabledPlugins, + ); + } + + const graphSnapshot = captureWorkspaceIndexRefreshGraphSnapshot(source, filesToAnalyze); + source.invalidateWorkspaceFiles(filesToAnalyze.map((file) => file.absolutePath)); + dependencies.onProgress?.({ + phase: 'Applying Changes', + current: 0, + total: filesToAnalyze.length, + }); + + const analysisResult = await source._analyzeFiles( + filesToAnalyze, + dependencies.workspaceRoot, + progress => { + dependencies.onProgress?.({ + phase: 'Applying Changes', + current: progress.current, + total: progress.total, + }); + }, + dependencies.signal, + undefined, + dependencies.disabledPlugins, + ); + + applyWorkspaceIndexAnalysisResult(source, analysisResult); + + dependencies.persistCache(); + if ( + canPatchWorkspaceIndexRefreshGraphData(graphSnapshot, analysisResult, filesToAnalyze) + && source._patchGraphDataNodeMetrics + ) { + const graphData = source._patchGraphDataNodeMetrics( + source._lastGraphData, + filesToAnalyze.map(file => file.relativePath), + ); + source._lastGraphData = graphData; + await persistMetricOnlyIndexMetadata(dependencies); + return graphData; + } + + const graphData = buildWorkspaceIndexGraphFromRefreshState( + source, + dependencies.workspaceRoot, + dependencies.disabledPlugins, + ); + await dependencies.persistIndexMetadata(); + + return graphData; +} + +function analyzeWorkspaceIndexFromRefresh( + source: WorkspaceIndexRefreshSource, + dependencies: WorkspaceIndexRefreshDependencies, +): Promise { + return source.analyze( + dependencies.filterPatterns, + dependencies.disabledPlugins, + dependencies.signal, + progress => { + dependencies.onProgress?.({ + ...progress, + phase: progress.phase || 'Applying Changes', + }); + }, + ); +} + +function persistMetricOnlyIndexMetadata( + dependencies: WorkspaceIndexRefreshDependencies, +): Promise | void { + const persistence = dependencies.persistIndexMetadata(); + if (dependencies.deferMetricOnlyIndexMetadata) { + void persistence.catch(error => { + dependencies.onDeferredIndexMetadataError?.(error); + }); + return; + } + + return persistence; +} diff --git a/packages/core/src/indexing/refresh/modes/pluginFiles.ts b/packages/core/src/indexing/refresh/modes/pluginFiles.ts new file mode 100644 index 000000000..8796614f4 --- /dev/null +++ b/packages/core/src/indexing/refresh/modes/pluginFiles.ts @@ -0,0 +1,73 @@ +import type { IGraphData } from '../../../graph/contracts'; +import type { + WorkspaceIndexPluginRefreshDependencies, + WorkspaceIndexRefreshSource, +} from '../contracts'; +import { buildWorkspaceIndexGraphFromRefreshState } from '../graph'; +import { + selectWorkspaceIndexPluginFiles, + selectWorkspaceIndexPluginInfos, +} from '../plugins'; +import { + applyWorkspaceIndexAnalysisResult, + retainWorkspaceIndexDiscoveredFileConnections, + updateWorkspaceIndexDiscoveryState, +} from '../state'; + +export async function refreshWorkspaceIndexPluginFiles( + source: WorkspaceIndexRefreshSource, + dependencies: WorkspaceIndexPluginRefreshDependencies, +): Promise { + updateWorkspaceIndexDiscoveryState(source, dependencies); + retainWorkspaceIndexDiscoveredFileConnections(source, dependencies.discoveredFiles); + + const pluginInfos = selectWorkspaceIndexPluginInfos( + dependencies.pluginInfos, + dependencies.pluginIds, + ); + const registeredPluginIds = pluginInfos.map(({ plugin }) => plugin.id); + if (pluginInfos.length === 0) { + const graphData = buildWorkspaceIndexGraphFromRefreshState( + source, + dependencies.workspaceRoot, + dependencies.disabledPlugins, + ); + await dependencies.persistIndexMetadata(); + return graphData; + } + + const pluginFiles = selectWorkspaceIndexPluginFiles(pluginInfos, dependencies.discoveredFiles); + if (pluginFiles.length > 0) { + dependencies.onProgress?.({ + phase: 'Applying Plugin', + current: 0, + total: pluginFiles.length, + }); + const analysisResult = await source._analyzeFiles( + pluginFiles, + dependencies.workspaceRoot, + progress => { + dependencies.onProgress?.({ + phase: 'Applying Plugin', + current: progress.current, + total: progress.total, + }); + }, + dependencies.signal, + registeredPluginIds, + dependencies.disabledPlugins, + ); + + applyWorkspaceIndexAnalysisResult(source, analysisResult); + dependencies.persistCache(); + } + + const graphData = buildWorkspaceIndexGraphFromRefreshState( + source, + dependencies.workspaceRoot, + dependencies.disabledPlugins, + ); + await dependencies.persistIndexMetadata(); + + return graphData; +} diff --git a/packages/core/src/indexing/refresh/plugins.ts b/packages/core/src/indexing/refresh/plugins.ts new file mode 100644 index 000000000..dc0a5a26e --- /dev/null +++ b/packages/core/src/indexing/refresh/plugins.ts @@ -0,0 +1,26 @@ +import type { IDiscoveredFile } from '../../discovery/contracts'; +import { getWorkspaceIndexPluginMatchingFiles } from '../../plugins/status/extensions'; +import type { WorkspaceIndexPluginInfo } from './contracts'; + +export function selectWorkspaceIndexPluginInfos( + pluginInfos: readonly WorkspaceIndexPluginInfo[], + pluginIds: readonly string[], +): WorkspaceIndexPluginInfo[] { + const selectedPluginIds = new Set(pluginIds); + return pluginInfos.filter(({ plugin }) => selectedPluginIds.has(plugin.id)); +} + +export function selectWorkspaceIndexPluginFiles( + pluginInfos: readonly WorkspaceIndexPluginInfo[], + discoveredFiles: readonly IDiscoveredFile[], +): IDiscoveredFile[] { + const matchingFilePaths = new Set(); + + for (const pluginInfo of pluginInfos) { + for (const file of getWorkspaceIndexPluginMatchingFiles(pluginInfo, [...discoveredFiles])) { + matchingFilePaths.add(file.relativePath); + } + } + + return discoveredFiles.filter(file => matchingFilePaths.has(file.relativePath)); +} diff --git a/packages/core/src/indexing/refresh/snapshot/capture.ts b/packages/core/src/indexing/refresh/snapshot/capture.ts new file mode 100644 index 000000000..3d63dc871 --- /dev/null +++ b/packages/core/src/indexing/refresh/snapshot/capture.ts @@ -0,0 +1,82 @@ +import type { IWorkspaceFileAnalysisResult } from '../../../analysis/fileAnalysis'; +import type { IDiscoveredFile } from '../../../discovery/contracts'; +import type { WorkspaceIndexRefreshSource } from '../contracts'; +import { canCaptureWorkspaceIndexRefreshGraphSnapshot } from './eligibility'; +import { + serializeWorkspaceIndexConnections, + serializeWorkspaceIndexGraphAnalysis, +} from './serialization'; + +interface WorkspaceIndexRefreshGraphSnapshot { + fileAnalysisByPath: Map; + fileConnectionsByPath: Map; +} + +export function captureWorkspaceIndexRefreshGraphSnapshot( + source: WorkspaceIndexRefreshSource, + files: readonly IDiscoveredFile[], +): WorkspaceIndexRefreshGraphSnapshot | undefined { + if (!canCaptureWorkspaceIndexRefreshGraphSnapshot(source)) { + return undefined; + } + + const snapshot: WorkspaceIndexRefreshGraphSnapshot = { + fileAnalysisByPath: new Map(), + fileConnectionsByPath: new Map(), + }; + + for (const file of files) { + if (!captureWorkspaceIndexRefreshSnapshotFile(source, snapshot, file.relativePath)) { + return undefined; + } + } + + return snapshot; +} + +export function canPatchWorkspaceIndexRefreshGraphData( + snapshot: WorkspaceIndexRefreshGraphSnapshot | undefined, + analysisResult: IWorkspaceFileAnalysisResult, + files: readonly IDiscoveredFile[], +): boolean { + if (!snapshot) { + return false; + } + + return files.every(file => + workspaceIndexRefreshSnapshotMatchesFile(snapshot, analysisResult, file.relativePath), + ); +} + +function captureWorkspaceIndexRefreshSnapshotFile( + source: WorkspaceIndexRefreshSource, + snapshot: WorkspaceIndexRefreshGraphSnapshot, + relativePath: string, +): boolean { + const analysis = source._lastFileAnalysis.get(relativePath); + if (!analysis) { + return false; + } + + snapshot.fileAnalysisByPath.set(relativePath, serializeWorkspaceIndexGraphAnalysis(analysis)); + snapshot.fileConnectionsByPath.set( + relativePath, + serializeWorkspaceIndexConnections(source._lastFileConnections.get(relativePath)), + ); + return true; +} + +function workspaceIndexRefreshSnapshotMatchesFile( + snapshot: WorkspaceIndexRefreshGraphSnapshot, + analysisResult: IWorkspaceFileAnalysisResult, + relativePath: string, +): boolean { + const analysis = analysisResult.fileAnalysis.get(relativePath); + if (!analysis) { + return false; + } + + return snapshot.fileAnalysisByPath.get(relativePath) === serializeWorkspaceIndexGraphAnalysis(analysis) + && snapshot.fileConnectionsByPath.get(relativePath) + === serializeWorkspaceIndexConnections(analysisResult.fileConnections.get(relativePath)); +} diff --git a/packages/core/src/indexing/refresh/snapshot/eligibility.ts b/packages/core/src/indexing/refresh/snapshot/eligibility.ts new file mode 100644 index 000000000..2dbb9f414 --- /dev/null +++ b/packages/core/src/indexing/refresh/snapshot/eligibility.ts @@ -0,0 +1,13 @@ +import type { IGraphData } from '../../../graph/contracts'; +import type { WorkspaceIndexRefreshSource } from '../contracts'; + +export function canCaptureWorkspaceIndexRefreshGraphSnapshot( + source: WorkspaceIndexRefreshSource, +): boolean { + return Boolean(source._patchGraphDataNodeMetrics) + && !isWorkspaceIndexGraphDataEmpty(source._lastGraphData); +} + +function isWorkspaceIndexGraphDataEmpty(graphData: IGraphData): boolean { + return graphData.nodes.length === 0 && graphData.edges.length === 0; +} diff --git a/packages/core/src/indexing/refresh/snapshot/serialization.ts b/packages/core/src/indexing/refresh/snapshot/serialization.ts new file mode 100644 index 000000000..64b69c1a4 --- /dev/null +++ b/packages/core/src/indexing/refresh/snapshot/serialization.ts @@ -0,0 +1,23 @@ +import type { IFileAnalysisResult } from '@codegraphy-dev/plugin-api'; +import type { IProjectedConnection } from '../../../analysis/projectedConnection'; + +export function serializeWorkspaceIndexGraphAnalysis(analysis: IFileAnalysisResult): string { + return JSON.stringify({ + edgeTypes: listOrEmpty(analysis.edgeTypes), + filePath: analysis.filePath, + nodeTypes: listOrEmpty(analysis.nodeTypes), + nodes: listOrEmpty(analysis.nodes), + relations: listOrEmpty(analysis.relations), + symbols: listOrEmpty(analysis.symbols), + }); +} + +export function serializeWorkspaceIndexConnections( + connections: IProjectedConnection[] | undefined, +): string { + return JSON.stringify(connections ?? []); +} + +function listOrEmpty(value: readonly T[] | undefined): readonly T[] { + return value ?? []; +} diff --git a/packages/core/src/indexing/refresh/state.ts b/packages/core/src/indexing/refresh/state.ts new file mode 100644 index 000000000..5eaeb68a7 --- /dev/null +++ b/packages/core/src/indexing/refresh/state.ts @@ -0,0 +1,41 @@ +import type { IWorkspaceFileAnalysisResult } from '../../analysis/fileAnalysis'; +import type { IDiscoveredFile } from '../../discovery/contracts'; +import type { + WorkspaceIndexAnalysisScopeRefreshDependencies, + WorkspaceIndexRefreshSource, +} from './contracts'; + +export function applyWorkspaceIndexAnalysisResult( + source: WorkspaceIndexRefreshSource, + analysisResult: IWorkspaceFileAnalysisResult, +): void { + for (const [filePath, analysis] of analysisResult.fileAnalysis) { + source._lastFileAnalysis.set(filePath, analysis); + } + for (const [filePath, connections] of analysisResult.fileConnections) { + source._lastFileConnections.set(filePath, connections); + } +} + +export function updateWorkspaceIndexDiscoveryState( + source: WorkspaceIndexRefreshSource, + dependencies: Pick< + WorkspaceIndexAnalysisScopeRefreshDependencies, + 'discoveredDirectories' | 'discoveredFiles' | 'workspaceRoot' + >, +): void { + source._lastDiscoveredDirectories = dependencies.discoveredDirectories ?? []; + source._lastDiscoveredFiles = [...dependencies.discoveredFiles]; + source._lastWorkspaceRoot = dependencies.workspaceRoot; +} + +export function retainWorkspaceIndexDiscoveredFileConnections( + source: WorkspaceIndexRefreshSource, + discoveredFiles: readonly IDiscoveredFile[], +): void { + for (const file of discoveredFiles) { + if (!source._lastFileConnections.has(file.relativePath)) { + source._lastFileConnections.set(file.relativePath, []); + } + } +} From 28865b326e22efef6ef50276574a2b784ddab822 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 14:55:57 -0700 Subject: [PATCH 106/192] refactor: split graph viewport view mutation sites --- .../viewport/accessibilityLayer/events.ts | 71 +++ .../viewport/accessibilityLayer/overlay.tsx | 103 ++++ .../graph/viewport/contextMenu/view.tsx | 81 +++ .../components/graph/viewport/contracts.ts | 48 ++ .../components/graph/viewport/handlers.ts | 31 + .../graph/viewport/overlays/marquee.tsx | 19 + .../graph/viewport/overlays/plugins.tsx | 54 ++ .../graph/viewport/surface/equality.ts | 62 ++ .../graph/viewport/surface/view.tsx | 74 +++ .../graph/viewport/tooltip/props.ts | 25 + .../components/graph/viewport/view.tsx | 571 +----------------- 11 files changed, 589 insertions(+), 550 deletions(-) create mode 100644 packages/extension/src/webview/components/graph/viewport/accessibilityLayer/events.ts create mode 100644 packages/extension/src/webview/components/graph/viewport/accessibilityLayer/overlay.tsx create mode 100644 packages/extension/src/webview/components/graph/viewport/contextMenu/view.tsx create mode 100644 packages/extension/src/webview/components/graph/viewport/contracts.ts create mode 100644 packages/extension/src/webview/components/graph/viewport/handlers.ts create mode 100644 packages/extension/src/webview/components/graph/viewport/overlays/marquee.tsx create mode 100644 packages/extension/src/webview/components/graph/viewport/overlays/plugins.tsx create mode 100644 packages/extension/src/webview/components/graph/viewport/surface/equality.ts create mode 100644 packages/extension/src/webview/components/graph/viewport/surface/view.tsx create mode 100644 packages/extension/src/webview/components/graph/viewport/tooltip/props.ts diff --git a/packages/extension/src/webview/components/graph/viewport/accessibilityLayer/events.ts b/packages/extension/src/webview/components/graph/viewport/accessibilityLayer/events.ts new file mode 100644 index 000000000..f8cb8b5a0 --- /dev/null +++ b/packages/extension/src/webview/components/graph/viewport/accessibilityLayer/events.ts @@ -0,0 +1,71 @@ +import type { + KeyboardEvent as ReactKeyboardEvent, + MouseEvent as ReactMouseEvent, +} from 'react'; + +type AccessibilityEvent = + | MouseEvent + | ReactMouseEvent + | ReactKeyboardEvent; + +export function toNativeMouseEvent( + type: 'click' | 'contextmenu', + event: AccessibilityEvent, +): MouseEvent { + const nativeEvent = getNativeMouseEvent(event); + if (nativeEvent) { + return nativeEvent; + } + + return new MouseEvent(type, { + bubbles: true, + cancelable: true, + button: mouseButtonForEventType(type), + buttons: mouseButtonForEventType(type), + clientX: getMouseEventCoordinate(event, 'clientX'), + clientY: getMouseEventCoordinate(event, 'clientY'), + ctrlKey: event.ctrlKey, + metaKey: event.metaKey, + shiftKey: event.shiftKey, + }); +} + +export function handleAccessibilityNodeKeyDown( + nodeId: string, + handleNodeClick: (nodeId: string, event: AccessibilityEvent) => void, + event: ReactKeyboardEvent, +): void { + if (!isKeyboardActivation(event.key)) { + return; + } + + event.preventDefault(); + handleNodeClick(nodeId, event); +} + +function getNativeMouseEvent(event: AccessibilityEvent): MouseEvent | undefined { + if (event instanceof MouseEvent) { + return event; + } + + return event.nativeEvent instanceof MouseEvent ? event.nativeEvent : undefined; +} + +function mouseButtonForEventType(type: 'click' | 'contextmenu'): number { + return type === 'contextmenu' ? 2 : 0; +} + +function getMouseEventCoordinate( + event: AccessibilityEvent, + key: 'clientX' | 'clientY', +): number { + if (!(key in event)) { + return 0; + } + + return (event as MouseEvent | ReactMouseEvent)[key]; +} + +function isKeyboardActivation(key: string): boolean { + return key === 'Enter' || key === ' '; +} diff --git a/packages/extension/src/webview/components/graph/viewport/accessibilityLayer/overlay.tsx b/packages/extension/src/webview/components/graph/viewport/accessibilityLayer/overlay.tsx new file mode 100644 index 000000000..c07b70552 --- /dev/null +++ b/packages/extension/src/webview/components/graph/viewport/accessibilityLayer/overlay.tsx @@ -0,0 +1,103 @@ +import type { + KeyboardEvent as ReactKeyboardEvent, + MouseEvent as ReactMouseEvent, + ReactElement, +} from 'react'; +import type { GraphAccessibilityItems } from '../accessibility'; +import type { FGLink, FGNode } from '../../model/build'; +import { + handleAccessibilityNodeKeyDown, + toNativeMouseEvent, +} from './events'; + +type AccessibilityEvent = + | MouseEvent + | ReactMouseEvent + | ReactKeyboardEvent; + +export function GraphAccessibilityOverlay({ + accessibilityItems, + graphLinks, + graphNodes, + onEdgeContextMenu, + onNodeClick, + onNodeContextMenu, + onNodeHover, +}: { + accessibilityItems: GraphAccessibilityItems; + graphLinks: readonly FGLink[]; + graphNodes: readonly FGNode[]; + onEdgeContextMenu(this: void, link: FGLink, event: MouseEvent): void; + onNodeClick(this: void, node: FGNode, event: MouseEvent): void; + onNodeContextMenu(this: void, nodeId: string, event: MouseEvent): void; + onNodeHover(this: void, node: FGNode | null): void; +}): ReactElement { + const findNode = (nodeId: string) => graphNodes.find(node => node.id === nodeId) ?? null; + const findLink = (edgeId: string) => graphLinks.find(link => link.id === edgeId) ?? null; + const handleNodeClick = (nodeId: string, event: AccessibilityEvent) => { + const node = findNode(nodeId); + if (!node) return; + + onNodeClick(node, toNativeMouseEvent('click', event)); + }; + const handleNodeContextMenu = (nodeId: string, event: ReactMouseEvent) => { + if (!findNode(nodeId)) return; + + event.preventDefault(); + event.stopPropagation(); + onNodeContextMenu(nodeId, toNativeMouseEvent('contextmenu', event)); + }; + const handleEdgeContextMenu = (edgeId: string, event: ReactMouseEvent) => { + const link = findLink(edgeId); + if (!link) return; + + event.preventDefault(); + event.stopPropagation(); + onEdgeContextMenu(link, toNativeMouseEvent('contextmenu', event)); + }; + const handleNodeHover = (nodeId: string) => { + onNodeHover(findNode(nodeId)); + }; + + return ( +
+ {accessibilityItems.nodes.map(node => ( +
onNodeHover(null)} + onClick={event => handleNodeClick(node.id, event)} + onContextMenu={event => handleNodeContextMenu(node.id, event)} + onFocus={() => handleNodeHover(node.id)} + onKeyDown={event => handleAccessibilityNodeKeyDown(node.id, handleNodeClick, event)} + onMouseOut={() => onNodeHover(null)} + onMouseOver={() => handleNodeHover(node.id)} + style={{ + height: node.radius * 2, + left: node.x, + top: node.y, + transform: 'translate(-50%, -50%)', + width: node.radius * 2, + }} + /> + ))} +
+ {accessibilityItems.edges.map(edge => ( + handleEdgeContextMenu(edge.id, event)} + /> + ))} +
+
+ ); +} diff --git a/packages/extension/src/webview/components/graph/viewport/contextMenu/view.tsx b/packages/extension/src/webview/components/graph/viewport/contextMenu/view.tsx new file mode 100644 index 000000000..5be4e529d --- /dev/null +++ b/packages/extension/src/webview/components/graph/viewport/contextMenu/view.tsx @@ -0,0 +1,81 @@ +import { + useRef, + type ReactElement, +} from 'react'; +import { + ContextMenuItem, + ContextMenuSeparator, + ContextMenuShortcut, +} from '../../../ui/context/menu'; +import type { GraphContextMenuEntry } from '../../contextMenu/contracts'; +import type { ViewportProps } from '../contracts'; + +export function ViewportContextMenuItems({ + handleMenuAction, + menuEntries, +}: Pick): ReactElement { + return ( + <> + {menuEntries.map(entry => { + if (entry.kind === 'separator') { + return ; + } + + return ( + + ); + })} + + ); +} + +export function createMenuEntriesSignature(menuEntries: readonly GraphContextMenuEntry[]): string { + return menuEntries + .map(entry => entry.kind === 'separator' ? `${entry.id}:separator` : `${entry.id}:${entry.label}`) + .join('|'); +} + +function ViewportContextMenuItem({ + entry, + handleMenuAction, +}: { + entry: Extract; + handleMenuAction: ViewportProps['handleMenuAction']; +}): ReactElement { + const handledRef = useRef(false); + const handleAction = (): void => { + if (handledRef.current) { + return; + } + + handledRef.current = true; + queueMicrotask(() => { + handledRef.current = false; + }); + + if (entry.contextSelection) { + handleMenuAction({ + action: entry.action, + contextSelection: entry.contextSelection, + }); + } + }; + + return ( + + {entry.label} + {entry.shortcut ? {entry.shortcut} : null} + + ); +} diff --git a/packages/extension/src/webview/components/graph/viewport/contracts.ts b/packages/extension/src/webview/components/graph/viewport/contracts.ts new file mode 100644 index 000000000..9cd9215d0 --- /dev/null +++ b/packages/extension/src/webview/components/graph/viewport/contracts.ts @@ -0,0 +1,48 @@ +import type { Ref } from 'react'; +import type { MouseEvent as ReactMouseEvent } from 'react'; +import type { DirectionMode } from '../../../../shared/settings/modes'; +import type { WebviewPluginHost } from '../../../pluginHost/manager'; +import type { + GraphContextMenuActionInvocation, + GraphContextMenuEntry, +} from '../contextMenu/contracts'; +import type { GraphMarqueeSelectionState } from '../marqueeSelection/model'; +import type { FGLink, FGNode } from '../model/build'; +import type { Surface2dProps } from '../rendering/surface/view/twoDimensional'; +import type { Surface3dProps } from '../rendering/surface/view/threeDimensional'; +import type { GraphTooltipState } from '../tooltip/model'; +import type { GraphAccessibilityItems } from './accessibility'; + +export interface ViewportProps { + accessibilityItems?: GraphAccessibilityItems; + canvasBackgroundColor: string; + containerBackgroundColor: string; + borderColor: string; + containerRef: Ref; + directionMode: DirectionMode; + graphMode: '2d' | '3d'; + handleContextMenu: (this: void, event: ReactMouseEvent) => void; + handleMenuAction: (this: void, invocation: GraphContextMenuActionInvocation) => void; + handleMouseDownCapture: (this: void, event: ReactMouseEvent) => void; + handleMouseLeave: (this: void) => void; + handleMouseMoveCapture: (this: void, event: ReactMouseEvent) => void; + handleMouseUpCapture: (this: void, event: ReactMouseEvent) => void; + handleEdgeContextMenu?: (this: void, link: FGLink, event: MouseEvent) => void; + handleNodeClick?: (this: void, node: FGNode, event: MouseEvent) => void; + handleNodeContextMenu?: (this: void, nodeId: string, event: MouseEvent) => void; + handleNodeHover?: (this: void, node: FGNode | null) => void; + marqueeSelection?: GraphMarqueeSelectionState | null; + menuEntries: GraphContextMenuEntry[]; + surface2dProps: Omit; + surface3dProps: Omit; + tooltipData: GraphTooltipState; + onSurface3dError?: (error: Error) => void; + pluginHost?: WebviewPluginHost; +} + +export interface ResolvedViewportHandlers { + handleEdgeContextMenu: NonNullable; + handleNodeClick: NonNullable; + handleNodeContextMenu: NonNullable; + handleNodeHover: NonNullable; +} diff --git a/packages/extension/src/webview/components/graph/viewport/handlers.ts b/packages/extension/src/webview/components/graph/viewport/handlers.ts new file mode 100644 index 000000000..8572fdccb --- /dev/null +++ b/packages/extension/src/webview/components/graph/viewport/handlers.ts @@ -0,0 +1,31 @@ +import type { GraphAccessibilityItems } from './accessibility'; +import type { ResolvedViewportHandlers, ViewportProps } from './contracts'; + +const EMPTY_ACCESSIBILITY_ITEMS: GraphAccessibilityItems = { nodes: [], edges: [] }; +const ignoreEdgeContextMenu: NonNullable = () => undefined; +const ignoreNodeClick: NonNullable = () => undefined; +const ignoreNodeContextMenu: NonNullable = () => undefined; +const ignoreNodeHover: NonNullable = () => undefined; + +export function resolveViewportAccessibilityItems( + accessibilityItems: ViewportProps['accessibilityItems'], +): GraphAccessibilityItems { + return accessibilityItems ?? EMPTY_ACCESSIBILITY_ITEMS; +} + +export function resolveViewportHandlers({ + handleEdgeContextMenu, + handleNodeClick, + handleNodeContextMenu, + handleNodeHover, +}: Pick< + ViewportProps, + 'handleEdgeContextMenu' | 'handleNodeClick' | 'handleNodeContextMenu' | 'handleNodeHover' +>): ResolvedViewportHandlers { + return { + handleEdgeContextMenu: handleEdgeContextMenu ?? ignoreEdgeContextMenu, + handleNodeClick: handleNodeClick ?? ignoreNodeClick, + handleNodeContextMenu: handleNodeContextMenu ?? ignoreNodeContextMenu, + handleNodeHover: handleNodeHover ?? ignoreNodeHover, + }; +} diff --git a/packages/extension/src/webview/components/graph/viewport/overlays/marquee.tsx b/packages/extension/src/webview/components/graph/viewport/overlays/marquee.tsx new file mode 100644 index 000000000..05ed51dfa --- /dev/null +++ b/packages/extension/src/webview/components/graph/viewport/overlays/marquee.tsx @@ -0,0 +1,19 @@ +import type { ReactElement } from 'react'; +import type { ViewportProps } from '../contracts'; + +export function ViewportMarqueeSelectionOverlay({ + marqueeSelection, +}: Pick): ReactElement | null { + return marqueeSelection ? ( +
+ ) : null; +} diff --git a/packages/extension/src/webview/components/graph/viewport/overlays/plugins.tsx b/packages/extension/src/webview/components/graph/viewport/overlays/plugins.tsx new file mode 100644 index 000000000..11922d668 --- /dev/null +++ b/packages/extension/src/webview/components/graph/viewport/overlays/plugins.tsx @@ -0,0 +1,54 @@ +import type { ReactElement } from 'react'; +import { SlotHost } from '../../../../pluginHost/slotHost/view'; +import type { ViewportProps } from '../contracts'; + +export function ViewportPluginOverlay({ + pluginHost, +}: Pick): ReactElement | null { + return pluginHost ? ( + <> + + + + ) : null; +} + +export function ViewportPluginBackground({ + pluginHost, +}: Pick): ReactElement | null { + return pluginHost ? ( + + ) : null; +} + +export function ViewportPluginWorldOverlay({ + pluginHost, +}: Pick): ReactElement | null { + return pluginHost ? ( + + ) : null; +} diff --git a/packages/extension/src/webview/components/graph/viewport/surface/equality.ts b/packages/extension/src/webview/components/graph/viewport/surface/equality.ts new file mode 100644 index 000000000..9b042776a --- /dev/null +++ b/packages/extension/src/webview/components/graph/viewport/surface/equality.ts @@ -0,0 +1,62 @@ +import type { ViewportSurfaceProps } from './view'; + +const SURFACE_2D_PROP_KEYS = [ + 'fg2dRef', + 'getArrowColor', + 'getArrowRelPos', + 'getLinkColor', + 'getLinkParticles', + 'getLinkWidth', + 'getParticleColor', + 'linkCanvasObject', + 'nodeCanvasObject', + 'nodePointerAreaPaint', + 'onRenderFramePost', + 'particleSize', + 'particleSpeed', + 'sharedProps', +] as const; + +const SURFACE_3D_PROP_KEYS = [ + 'fg3dRef', + 'getArrowColor', + 'getLinkColor', + 'getLinkParticles', + 'getLinkWidth', + 'getParticleColor', + 'particleSize', + 'particleSpeed', + 'sharedProps', +] as const; + +const NODE_THREE_OBJECT_CONTEXT_KEYS = [ + 'graphAppearanceRef', + 'meshesRef', + 'showLabelsRef', + 'spritesRef', +] as const; + +export function areViewportSurfacePropsEqual( + previous: ViewportSurfaceProps, + next: ViewportSurfaceProps, +): boolean { + return previous.canvasBackgroundColor === next.canvasBackgroundColor + && previous.directionMode === next.directionMode + && previous.graphMode === next.graphMode + && previous.onSurface3dError === next.onSurface3dError + && propsEqualByKeys(previous.surface2dProps, next.surface2dProps, SURFACE_2D_PROP_KEYS) + && propsEqualByKeys(previous.surface3dProps, next.surface3dProps, SURFACE_3D_PROP_KEYS) + && propsEqualByKeys( + previous.surface3dProps.nodeThreeObjectContext, + next.surface3dProps.nodeThreeObjectContext, + NODE_THREE_OBJECT_CONTEXT_KEYS, + ); +} + +function propsEqualByKeys( + previous: T, + next: T, + keys: readonly K[], +): boolean { + return keys.every(key => previous[key] === next[key]); +} diff --git a/packages/extension/src/webview/components/graph/viewport/surface/view.tsx b/packages/extension/src/webview/components/graph/viewport/surface/view.tsx new file mode 100644 index 000000000..9c450980a --- /dev/null +++ b/packages/extension/src/webview/components/graph/viewport/surface/view.tsx @@ -0,0 +1,74 @@ +import { + lazy, + memo, + Suspense, + type ReactElement, +} from 'react'; +import type { DirectionMode } from '../../../../../shared/settings/modes'; +import { + Surface2d, + type Surface2dProps, +} from '../../rendering/surface/view/twoDimensional'; +import type { Surface3dProps } from '../../rendering/surface/view/threeDimensional'; +import { SurfaceFallbackBoundary } from '../../rendering/surface/view/fallbackBoundary'; +import { areViewportSurfacePropsEqual } from './equality'; + +const LazyDeferredSurface3d = lazy(async () => { + const module = await import('../../rendering/surface/view/threeDimensional'); + return { default: module.DeferredSurface3d }; +}); + +export interface ViewportSurfaceProps { + canvasBackgroundColor: string; + directionMode: DirectionMode; + graphMode: '2d' | '3d'; + onSurface3dError?: (error: Error) => void; + surface2dProps: Omit; + surface3dProps: Omit; +} + +function ViewportSurface({ + canvasBackgroundColor, + directionMode, + graphMode, + onSurface3dError, + surface2dProps, + surface3dProps, +}: ViewportSurfaceProps): ReactElement { + if (graphMode === '2d') { + return ( + + ); + } + + const fallback = ( + + ); + + return ( + + + + + + ); +} + +export const MemoizedViewportSurface = memo(ViewportSurface, areViewportSurfacePropsEqual); diff --git a/packages/extension/src/webview/components/graph/viewport/tooltip/props.ts b/packages/extension/src/webview/components/graph/viewport/tooltip/props.ts new file mode 100644 index 000000000..ca4002e31 --- /dev/null +++ b/packages/extension/src/webview/components/graph/viewport/tooltip/props.ts @@ -0,0 +1,25 @@ +import type { ComponentProps } from 'react'; +import { NodeTooltip } from '../../../nodeTooltip/view'; +import type { ViewportProps } from '../contracts'; + +type NodeTooltipComponentProps = ComponentProps; + +export function createNodeTooltipProps({ + pluginHost, + tooltipData, +}: Pick): NodeTooltipComponentProps { + return { + extraActions: tooltipData.pluginActions, + extraSections: tooltipData.pluginSections, + incomingCount: tooltipData.incomingCount ?? tooltipData.info?.incomingCount ?? 0, + lastModified: tooltipData.info?.lastModified, + nodeRect: tooltipData.nodeRect, + outgoingCount: tooltipData.outgoingCount ?? tooltipData.info?.outgoingCount ?? 0, + path: tooltipData.path, + plugin: tooltipData.info?.plugin ?? tooltipData.symbol?.plugin, + pluginHost, + size: tooltipData.info?.size, + symbol: tooltipData.symbol, + visible: tooltipData.visible, + }; +} diff --git a/packages/extension/src/webview/components/graph/viewport/view.tsx b/packages/extension/src/webview/components/graph/viewport/view.tsx index ad139fbb4..7ee93db0d 100644 --- a/packages/extension/src/webview/components/graph/viewport/view.tsx +++ b/packages/extension/src/webview/components/graph/viewport/view.tsx @@ -1,398 +1,31 @@ -import { - lazy, - memo, - Suspense, - useRef, - type ComponentProps, - type KeyboardEvent as ReactKeyboardEvent, - type MouseEvent as ReactMouseEvent, - type ReactElement, - type Ref, -} from 'react'; -import type { DirectionMode } from '../../../../shared/settings/modes'; -import type { GraphMarqueeSelectionState } from '../marqueeSelection/model'; -import type { GraphTooltipState } from '../tooltip/model'; +import type { ReactElement } from 'react'; import { ContextMenu, ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, - ContextMenuShortcut, ContextMenuTrigger, } from '../../ui/context/menu'; import { NodeTooltip } from '../../nodeTooltip/view'; -import type { - GraphContextMenuActionInvocation, - GraphContextMenuEntry, -} from '../contextMenu/contracts'; -import { - Surface2d, - type Surface2dProps, -} from '../rendering/surface/view/twoDimensional'; -import type { Surface3dProps } from '../rendering/surface/view/threeDimensional'; -import { SurfaceFallbackBoundary } from '../rendering/surface/view/fallbackBoundary'; -import type { WebviewPluginHost } from '../../../pluginHost/manager'; -import { SlotHost } from '../../../pluginHost/slotHost/view'; -import type { GraphAccessibilityItems } from './accessibility'; import type { FGLink, FGNode } from '../model/build'; +import { GraphAccessibilityOverlay } from './accessibilityLayer/overlay'; +import { + createMenuEntriesSignature, + ViewportContextMenuItems, +} from './contextMenu/view'; +import type { ViewportProps } from './contracts'; +import { + resolveViewportAccessibilityItems, + resolveViewportHandlers, +} from './handlers'; +import { ViewportMarqueeSelectionOverlay } from './overlays/marquee'; +import { + ViewportPluginBackground, + ViewportPluginOverlay, + ViewportPluginWorldOverlay, +} from './overlays/plugins'; +import { MemoizedViewportSurface } from './surface/view'; +import { createNodeTooltipProps } from './tooltip/props'; -const LazyDeferredSurface3d = lazy(async () => { - const module = await import('../rendering/surface/view/threeDimensional'); - return { default: module.DeferredSurface3d }; -}); - -export interface ViewportProps { - accessibilityItems?: GraphAccessibilityItems; - canvasBackgroundColor: string; - containerBackgroundColor: string; - borderColor: string; - containerRef: Ref; - directionMode: DirectionMode; - graphMode: '2d' | '3d'; - handleContextMenu: (this: void, event: ReactMouseEvent) => void; - handleMenuAction: (this: void, invocation: GraphContextMenuActionInvocation) => void; - handleMouseDownCapture: (this: void, event: ReactMouseEvent) => void; - handleMouseLeave: (this: void) => void; - handleMouseMoveCapture: (this: void, event: ReactMouseEvent) => void; - handleMouseUpCapture: (this: void, event: ReactMouseEvent) => void; - handleEdgeContextMenu?: (this: void, link: FGLink, event: MouseEvent) => void; - handleNodeClick?: (this: void, node: FGNode, event: MouseEvent) => void; - handleNodeContextMenu?: (this: void, nodeId: string, event: MouseEvent) => void; - handleNodeHover?: (this: void, node: FGNode | null) => void; - marqueeSelection?: GraphMarqueeSelectionState | null; - menuEntries: GraphContextMenuEntry[]; - surface2dProps: Omit; - surface3dProps: Omit; - tooltipData: GraphTooltipState; - onSurface3dError?: (error: Error) => void; - pluginHost?: WebviewPluginHost; -} - -const EMPTY_ACCESSIBILITY_ITEMS: GraphAccessibilityItems = { nodes: [], edges: [] }; -const ignoreEdgeContextMenu: NonNullable = () => undefined; -const ignoreNodeClick: NonNullable = () => undefined; -const ignoreNodeContextMenu: NonNullable = () => undefined; -const ignoreNodeHover: NonNullable = () => undefined; - -type NodeTooltipComponentProps = ComponentProps; - -interface ResolvedViewportHandlers { - handleEdgeContextMenu: NonNullable; - handleNodeClick: NonNullable; - handleNodeContextMenu: NonNullable; - handleNodeHover: NonNullable; -} - -function resolveViewportAccessibilityItems( - accessibilityItems: ViewportProps['accessibilityItems'], -): GraphAccessibilityItems { - return accessibilityItems ?? EMPTY_ACCESSIBILITY_ITEMS; -} - -function resolveViewportHandlers({ - handleEdgeContextMenu, - handleNodeClick, - handleNodeContextMenu, - handleNodeHover, -}: Pick< - ViewportProps, - 'handleEdgeContextMenu' | 'handleNodeClick' | 'handleNodeContextMenu' | 'handleNodeHover' ->): ResolvedViewportHandlers { - return { - handleEdgeContextMenu: handleEdgeContextMenu ?? ignoreEdgeContextMenu, - handleNodeClick: handleNodeClick ?? ignoreNodeClick, - handleNodeContextMenu: handleNodeContextMenu ?? ignoreNodeContextMenu, - handleNodeHover: handleNodeHover ?? ignoreNodeHover, - }; -} - -function createNodeTooltipProps({ - pluginHost, - tooltipData, -}: Pick): NodeTooltipComponentProps { - return { - extraActions: tooltipData.pluginActions, - extraSections: tooltipData.pluginSections, - incomingCount: tooltipData.incomingCount ?? tooltipData.info?.incomingCount ?? 0, - lastModified: tooltipData.info?.lastModified, - nodeRect: tooltipData.nodeRect, - outgoingCount: tooltipData.outgoingCount ?? tooltipData.info?.outgoingCount ?? 0, - path: tooltipData.path, - plugin: tooltipData.info?.plugin ?? tooltipData.symbol?.plugin, - pluginHost, - size: tooltipData.info?.size, - symbol: tooltipData.symbol, - visible: tooltipData.visible, - }; -} - -interface ViewportSurfaceProps { - canvasBackgroundColor: string; - directionMode: DirectionMode; - graphMode: '2d' | '3d'; - onSurface3dError?: (error: Error) => void; - surface2dProps: Omit; - surface3dProps: Omit; -} - -function ViewportSurface({ - canvasBackgroundColor, - directionMode, - graphMode, - onSurface3dError, - surface2dProps, - surface3dProps, -}: ViewportSurfaceProps): ReactElement { - if (graphMode === '2d') { - return ( - - ); - } - - const fallback = ( - - ); - - return ( - - - - - - ); -} - -function areSurface2dPropsEqual( - previous: ViewportSurfaceProps['surface2dProps'], - next: ViewportSurfaceProps['surface2dProps'], -): boolean { - return propsEqualByKeys(previous, next, SURFACE_2D_PROP_KEYS); -} - -function areSurface3dPropsEqual( - previous: ViewportSurfaceProps['surface3dProps'], - next: ViewportSurfaceProps['surface3dProps'], -): boolean { - return propsEqualByKeys(previous, next, SURFACE_3D_PROP_KEYS) - && propsEqualByKeys( - previous.nodeThreeObjectContext, - next.nodeThreeObjectContext, - NODE_THREE_OBJECT_CONTEXT_KEYS, - ); -} - -const SURFACE_2D_PROP_KEYS = [ - 'fg2dRef', - 'getArrowColor', - 'getArrowRelPos', - 'getLinkColor', - 'getLinkParticles', - 'getLinkWidth', - 'getParticleColor', - 'linkCanvasObject', - 'nodeCanvasObject', - 'nodePointerAreaPaint', - 'onRenderFramePost', - 'particleSize', - 'particleSpeed', - 'sharedProps', -] as const; - -const SURFACE_3D_PROP_KEYS = [ - 'fg3dRef', - 'getArrowColor', - 'getLinkColor', - 'getLinkParticles', - 'getLinkWidth', - 'getParticleColor', - 'particleSize', - 'particleSpeed', - 'sharedProps', -] as const; - -const NODE_THREE_OBJECT_CONTEXT_KEYS = [ - 'graphAppearanceRef', - 'meshesRef', - 'showLabelsRef', - 'spritesRef', -] as const; - -function propsEqualByKeys( - previous: T, - next: T, - keys: readonly K[], -): boolean { - return keys.every(key => previous[key] === next[key]); -} - -function areViewportSurfacePropsEqual( - previous: ViewportSurfaceProps, - next: ViewportSurfaceProps, -): boolean { - return previous.canvasBackgroundColor === next.canvasBackgroundColor - && previous.directionMode === next.directionMode - && previous.graphMode === next.graphMode - && previous.onSurface3dError === next.onSurface3dError - && areSurface2dPropsEqual(previous.surface2dProps, next.surface2dProps) - && areSurface3dPropsEqual(previous.surface3dProps, next.surface3dProps); -} - -const MemoizedViewportSurface = memo(ViewportSurface, areViewportSurfacePropsEqual); - -function ViewportPluginOverlay({ - pluginHost, -}: Pick): ReactElement | null { - return pluginHost ? ( - <> - - - - ) : null; -} - -function ViewportPluginBackground({ - pluginHost, -}: Pick): ReactElement | null { - return pluginHost ? ( - - ) : null; -} - -function ViewportPluginWorldOverlay({ - pluginHost, -}: Pick): ReactElement | null { - return pluginHost ? ( - - ) : null; -} - -function ViewportMarqueeSelectionOverlay({ - marqueeSelection, -}: Pick): ReactElement | null { - return marqueeSelection ? ( -
- ) : null; -} - -function ViewportContextMenuItems({ - handleMenuAction, - menuEntries, -}: Pick): ReactElement { - return ( - <> - {menuEntries.map(entry => { - if (entry.kind === 'separator') { - return ; - } - - return ( - - ); - })} - - ); -} - -function ViewportContextMenuItem({ - entry, - handleMenuAction, -}: { - entry: Extract; - handleMenuAction: ViewportProps['handleMenuAction']; -}): ReactElement { - const handledRef = useRef(false); - const handleAction = (): void => { - if (handledRef.current) { - return; - } - - handledRef.current = true; - queueMicrotask(() => { - handledRef.current = false; - }); - - if (entry.contextSelection) { - handleMenuAction({ - action: entry.action, - contextSelection: entry.contextSelection, - }); - } - }; - - return ( - - {entry.label} - {entry.shortcut ? {entry.shortcut} : null} - - ); -} - -function createMenuEntriesSignature(menuEntries: readonly GraphContextMenuEntry[]): string { - return menuEntries - .map(entry => entry.kind === 'separator' ? `${entry.id}:separator` : `${entry.id}:${entry.label}`) - .join('|'); -} +export type { ViewportProps } from './contracts'; export function Viewport({ accessibilityItems, @@ -428,6 +61,7 @@ export function Viewport({ handleNodeContextMenu, handleNodeHover, }); + return ( @@ -479,166 +113,3 @@ export function Viewport({ ); } - -function toNativeMouseEvent( - type: 'click' | 'contextmenu', - event: MouseEvent | ReactMouseEvent | ReactKeyboardEvent, -): MouseEvent { - const nativeEvent = getNativeMouseEvent(event); - if (nativeEvent) { - return nativeEvent; - } - - return new MouseEvent(type, { - bubbles: true, - cancelable: true, - button: mouseButtonForEventType(type), - buttons: mouseButtonForEventType(type), - clientX: getMouseEventCoordinate(event, 'clientX'), - clientY: getMouseEventCoordinate(event, 'clientY'), - ctrlKey: event.ctrlKey, - metaKey: event.metaKey, - shiftKey: event.shiftKey, - }); -} - -function getNativeMouseEvent( - event: MouseEvent | ReactMouseEvent | ReactKeyboardEvent, -): MouseEvent | undefined { - if (event instanceof MouseEvent) { - return event; - } - - return event.nativeEvent instanceof MouseEvent ? event.nativeEvent : undefined; -} - -function mouseButtonForEventType(type: 'click' | 'contextmenu'): number { - return type === 'contextmenu' ? 2 : 0; -} - -function getMouseEventCoordinate( - event: MouseEvent | ReactMouseEvent | ReactKeyboardEvent, - key: 'clientX' | 'clientY', -): number { - if (!(key in event)) { - return 0; - } - - return (event as MouseEvent | ReactMouseEvent)[key]; -} - -function isKeyboardActivation(key: string): boolean { - return key === 'Enter' || key === ' '; -} - -function handleAccessibilityNodeKeyDown( - nodeId: string, - handleNodeClick: ( - nodeId: string, - event: MouseEvent | ReactMouseEvent | ReactKeyboardEvent, - ) => void, - event: ReactKeyboardEvent, -): void { - if (!isKeyboardActivation(event.key)) { - return; - } - - event.preventDefault(); - handleNodeClick(nodeId, event); -} - -function GraphAccessibilityOverlay({ - accessibilityItems, - graphLinks, - graphNodes, - onEdgeContextMenu, - onNodeClick, - onNodeContextMenu, - onNodeHover, -}: { - accessibilityItems: GraphAccessibilityItems; - graphLinks: readonly FGLink[]; - graphNodes: readonly FGNode[]; - onEdgeContextMenu(this: void, link: FGLink, event: MouseEvent): void; - onNodeClick(this: void, node: FGNode, event: MouseEvent): void; - onNodeContextMenu(this: void, nodeId: string, event: MouseEvent): void; - onNodeHover(this: void, node: FGNode | null): void; -}): ReactElement { - const findNode = (nodeId: string) => graphNodes.find(node => node.id === nodeId) ?? null; - const findLink = (edgeId: string) => graphLinks.find(link => link.id === edgeId) ?? null; - const handleNodeClick = ( - nodeId: string, - event: MouseEvent | ReactMouseEvent | ReactKeyboardEvent, - ) => { - const node = findNode(nodeId); - if (!node) return; - - onNodeClick(node, toNativeMouseEvent('click', event)); - }; - const handleNodeContextMenu = ( - nodeId: string, - event: ReactMouseEvent, - ) => { - if (!findNode(nodeId)) return; - - event.preventDefault(); - event.stopPropagation(); - onNodeContextMenu(nodeId, toNativeMouseEvent('contextmenu', event)); - }; - const handleEdgeContextMenu = ( - edgeId: string, - event: ReactMouseEvent, - ) => { - const link = findLink(edgeId); - if (!link) return; - - event.preventDefault(); - event.stopPropagation(); - onEdgeContextMenu(link, toNativeMouseEvent('contextmenu', event)); - }; - const handleNodeHover = (nodeId: string) => { - onNodeHover(findNode(nodeId)); - }; - - return ( -
- {accessibilityItems.nodes.map(node => ( -
onNodeHover(null)} - onClick={event => handleNodeClick(node.id, event)} - onContextMenu={event => handleNodeContextMenu(node.id, event)} - onFocus={() => handleNodeHover(node.id)} - onKeyDown={event => handleAccessibilityNodeKeyDown(node.id, handleNodeClick, event)} - onMouseOut={() => onNodeHover(null)} - onMouseOver={() => handleNodeHover(node.id)} - style={{ - height: node.radius * 2, - left: node.x, - top: node.y, - transform: 'translate(-50%, -50%)', - width: node.radius * 2, - }} - /> - ))} -
- {accessibilityItems.edges.map(edge => ( - handleEdgeContextMenu(edge.id, event)} - /> - ))} -
-
- ); -} From c1c9b3dd87250211ccaafb071d0678776264a010 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 14:58:49 -0700 Subject: [PATCH 107/192] refactor: split node legend rule mutation sites --- .../filtering/rules/nodeLegend/apply.ts | 60 ++++++ .../filtering/rules/nodeLegend/compile.ts | 53 +++++ .../filtering/rules/nodeLegend/constraints.ts | 50 +++++ .../filtering/rules/nodeLegend/contracts.ts | 16 ++ .../filtering/rules/nodeLegend/match.ts | 63 ++++++ .../webview/search/filtering/rules/nodes.ts | 202 ++---------------- 6 files changed, 260 insertions(+), 184 deletions(-) create mode 100644 packages/extension/src/webview/search/filtering/rules/nodeLegend/apply.ts create mode 100644 packages/extension/src/webview/search/filtering/rules/nodeLegend/compile.ts create mode 100644 packages/extension/src/webview/search/filtering/rules/nodeLegend/constraints.ts create mode 100644 packages/extension/src/webview/search/filtering/rules/nodeLegend/contracts.ts create mode 100644 packages/extension/src/webview/search/filtering/rules/nodeLegend/match.ts diff --git a/packages/extension/src/webview/search/filtering/rules/nodeLegend/apply.ts b/packages/extension/src/webview/search/filtering/rules/nodeLegend/apply.ts new file mode 100644 index 000000000..79ca9b7c4 --- /dev/null +++ b/packages/extension/src/webview/search/filtering/rules/nodeLegend/apply.ts @@ -0,0 +1,60 @@ +import { DEFAULT_NODE_COLOR } from '../../../../../shared/fileColors'; +import type { IGraphData } from '../../../../../shared/graph/contracts'; +import { normalizeNodeLegendRules } from './compile'; +import type { + CompiledNodeLegendRule, + NodeLegendRuleInput, +} from './contracts'; +import { + compiledRuleMatchesNode, + getCaseInsensitiveNodeCandidates, +} from './match'; + +export function applyCompiledNodeLegendRules( + node: IGraphData['nodes'][number], + activeRules: readonly CompiledNodeLegendRule[], +): IGraphData['nodes'][number] { + const nextNode = { + ...node, + color: node.color || DEFAULT_NODE_COLOR, + }; + let candidates: readonly string[] | undefined; + const getCandidates = (): readonly string[] => { + candidates ??= getCaseInsensitiveNodeCandidates(node); + return candidates; + }; + + for (const compiledRule of activeRules) { + if (!compiledRuleMatchesNode(node, getCandidates, compiledRule)) { + continue; + } + + applyCompiledNodeLegendRule(nextNode, compiledRule); + } + + return nextNode; +} + +export function applyNodeLegendRules( + node: IGraphData['nodes'][number], + activeRules: readonly NodeLegendRuleInput[], +): IGraphData['nodes'][number] { + return applyCompiledNodeLegendRules(node, normalizeNodeLegendRules(activeRules)); +} + +function applyCompiledNodeLegendRule( + node: IGraphData['nodes'][number], + compiledRule: CompiledNodeLegendRule, +): void { + const { rule } = compiledRule; + node.color = rule.color; + if (rule.shape2D) { + node.shape2D = rule.shape2D; + } + if (rule.shape3D) { + node.shape3D = rule.shape3D; + } + if (rule.imageUrl) { + node.imageUrl = rule.imageUrl; + } +} diff --git a/packages/extension/src/webview/search/filtering/rules/nodeLegend/compile.ts b/packages/extension/src/webview/search/filtering/rules/nodeLegend/compile.ts new file mode 100644 index 000000000..a5028d418 --- /dev/null +++ b/packages/extension/src/webview/search/filtering/rules/nodeLegend/compile.ts @@ -0,0 +1,53 @@ +import type { IGroup } from '../../../../../shared/settings/groups'; +import { createGlobMatcher } from '../../../../globMatch'; +import { ruleTargetsNodes } from '../nodeMatcher'; +import type { CompiledNodeLegendRule, NodeLegendRuleInput } from './contracts'; + +export function getOrderedActiveRules(legends: IGroup[]): IGroup[] { + return legends + .filter((group) => !group.disabled) + .reverse(); +} + +export function compileNodeLegendRules(activeRules: IGroup[]): CompiledNodeLegendRule[] { + return activeRules + .filter(ruleTargetsNodes) + .map((rule) => ({ + caseInsensitivePatternMatches: createGlobMatcher(rule.pattern.toLowerCase()), + hasConstraints: hasNodeLegendConstraints(rule), + patternMatches: createGlobMatcher(rule.pattern), + patternHasPathSeparator: rule.pattern.includes('/'), + rule, + ...(rule.matchSymbolFilePath + ? { symbolFilePathMatches: createGlobMatcher(rule.matchSymbolFilePath) } + : {}), + })); +} + +export function normalizeNodeLegendRules( + activeRules: readonly NodeLegendRuleInput[], +): CompiledNodeLegendRule[] { + if (activeRules.every(isCompiledNodeLegendRule)) { + return [...activeRules]; + } + + return compileNodeLegendRules(activeRules.filter((rule): rule is IGroup => + !isCompiledNodeLegendRule(rule), + )); +} + +function hasNodeLegendConstraints(rule: IGroup): boolean { + return Boolean( + rule.matchNodeType + || rule.matchSymbolKind + || rule.matchSymbolKinds?.length + || rule.matchSymbolPluginKind + || rule.matchSymbolSource + || rule.matchSymbolLanguage + || rule.matchSymbolFilePath, + ); +} + +function isCompiledNodeLegendRule(rule: NodeLegendRuleInput): rule is CompiledNodeLegendRule { + return 'patternMatches' in rule && 'rule' in rule; +} diff --git a/packages/extension/src/webview/search/filtering/rules/nodeLegend/constraints.ts b/packages/extension/src/webview/search/filtering/rules/nodeLegend/constraints.ts new file mode 100644 index 000000000..9ed5e3f7a --- /dev/null +++ b/packages/extension/src/webview/search/filtering/rules/nodeLegend/constraints.ts @@ -0,0 +1,50 @@ +import type { + CompiledNodeLegendRule, + GraphNode, + GraphNodeSymbol, +} from './contracts'; + +type NodeLegendConstraintMatcher = ( + node: GraphNode, + symbol: GraphNodeSymbol, + compiledRule: CompiledNodeLegendRule, +) => boolean; + +const NODE_LEGEND_CONSTRAINT_MATCHERS: readonly NodeLegendConstraintMatcher[] = [ + (node, _symbol, { rule }) => optionalRuleValueMatches(rule.matchNodeType, node.nodeType), + (_node, symbol, { rule }) => optionalRuleValueMatches(rule.matchSymbolKind, symbol?.kind), + (_node, symbol, { rule }) => optionalRuleValueMatches(rule.matchSymbolPluginKind, symbol?.pluginKind), + (_node, symbol, { rule }) => optionalRuleValueMatches(rule.matchSymbolSource, symbol?.source), + (_node, symbol, { rule }) => optionalRuleValueMatches(rule.matchSymbolLanguage, symbol?.language), + (_node, symbol, { rule }) => optionalSymbolKindsMatch(rule.matchSymbolKinds, symbol?.kind), + (_node, symbol, compiledRule) => optionalSymbolFilePathMatches(compiledRule, symbol?.filePath), +]; + +export function compiledRuleConstraintsMatchNode( + node: GraphNode, + compiledRule: CompiledNodeLegendRule, +): boolean { + return !compiledRule.hasConstraints + || NODE_LEGEND_CONSTRAINT_MATCHERS.every(matcher => + matcher(node, node.symbol, compiledRule), + ); +} + +function optionalRuleValueMatches(expected: T | undefined, actual: T | undefined): boolean { + return expected === undefined || expected === actual; +} + +function optionalSymbolKindsMatch( + expected: readonly string[] | undefined, + actual: string | undefined, +): boolean { + return expected === undefined || Boolean(actual && expected.includes(actual)); +} + +function optionalSymbolFilePathMatches( + compiledRule: CompiledNodeLegendRule, + filePath: string | undefined, +): boolean { + return compiledRule.symbolFilePathMatches === undefined + || Boolean(filePath && compiledRule.symbolFilePathMatches(filePath)); +} diff --git a/packages/extension/src/webview/search/filtering/rules/nodeLegend/contracts.ts b/packages/extension/src/webview/search/filtering/rules/nodeLegend/contracts.ts new file mode 100644 index 000000000..6f9d9168a --- /dev/null +++ b/packages/extension/src/webview/search/filtering/rules/nodeLegend/contracts.ts @@ -0,0 +1,16 @@ +import type { IGraphData } from '../../../../../shared/graph/contracts'; +import type { IGroup } from '../../../../../shared/settings/groups'; + +export type GraphNode = IGraphData['nodes'][number]; +export type GraphNodeSymbol = GraphNode['symbol']; + +export interface CompiledNodeLegendRule { + caseInsensitivePatternMatches: (value: string) => boolean; + hasConstraints: boolean; + patternMatches: (value: string) => boolean; + patternHasPathSeparator: boolean; + rule: IGroup; + symbolFilePathMatches?: (value: string) => boolean; +} + +export type NodeLegendRuleInput = IGroup | CompiledNodeLegendRule; diff --git a/packages/extension/src/webview/search/filtering/rules/nodeLegend/match.ts b/packages/extension/src/webview/search/filtering/rules/nodeLegend/match.ts new file mode 100644 index 000000000..b265d49fc --- /dev/null +++ b/packages/extension/src/webview/search/filtering/rules/nodeLegend/match.ts @@ -0,0 +1,63 @@ +import type { + CompiledNodeLegendRule, + GraphNode, +} from './contracts'; +import { compiledRuleConstraintsMatchNode } from './constraints'; + +export function compiledRuleMatchesNode( + node: GraphNode, + getCandidates: () => readonly string[], + compiledRule: CompiledNodeLegendRule, +): boolean { + return compiledRuleConstraintsMatchNode(node, compiledRule) + && compiledRulePatternMatchesNode(node, getCandidates, compiledRule); +} + +export function getCaseInsensitiveNodeCandidates(node: GraphNode): string[] { + const symbol = node.symbol; + return [ + node.label, + symbol?.name, + symbol?.kind, + symbol?.pluginKind, + symbol?.filePath, + ] + .filter((candidate): candidate is string => Boolean(candidate)) + .map((candidate) => candidate.toLowerCase()); +} + +function compiledRulePatternMatchesNode( + node: GraphNode, + getCandidates: () => readonly string[], + compiledRule: CompiledNodeLegendRule, +): boolean { + if (compiledRule.patternMatches(node.id)) { + return true; + } + + if (compiledRule.rule.isPluginDefault) { + return false; + } + + if (compiledRule.patternHasPathSeparator) { + const symbol = node.symbol; + return pathCandidateMatchesNodeRule(node.label, compiledRule) + || pathCandidateMatchesNodeRule(symbol?.name, compiledRule) + || pathCandidateMatchesNodeRule(symbol?.kind, compiledRule) + || pathCandidateMatchesNodeRule(symbol?.pluginKind, compiledRule) + || pathCandidateMatchesNodeRule(symbol?.filePath, compiledRule); + } + + return getCandidates().some((candidate) => compiledRule.caseInsensitivePatternMatches(candidate)); +} + +function pathCandidateMatchesNodeRule( + value: string | undefined, + compiledRule: CompiledNodeLegendRule, +): boolean { + return Boolean( + value + && value.includes('/') + && compiledRule.caseInsensitivePatternMatches(value.toLowerCase()), + ); +} diff --git a/packages/extension/src/webview/search/filtering/rules/nodes.ts b/packages/extension/src/webview/search/filtering/rules/nodes.ts index d3eeb0b03..35052e369 100644 --- a/packages/extension/src/webview/search/filtering/rules/nodes.ts +++ b/packages/extension/src/webview/search/filtering/rules/nodes.ts @@ -1,204 +1,38 @@ -import { DEFAULT_NODE_COLOR } from '../../../../shared/fileColors'; import type { IGraphData } from '../../../../shared/graph/contracts'; import type { IGroup } from '../../../../shared/settings/groups'; -import { createGlobMatcher } from '../../../globMatch'; -import { ruleTargetsNodes } from './nodeMatcher'; - -type GraphNode = IGraphData['nodes'][number]; -type GraphNodeSymbol = GraphNode['symbol']; -type NodeLegendConstraintMatcher = ( - node: GraphNode, - symbol: GraphNodeSymbol, - compiledRule: CompiledNodeLegendRule, -) => boolean; - -export interface CompiledNodeLegendRule { - caseInsensitivePatternMatches: (value: string) => boolean; - hasConstraints: boolean; - patternMatches: (value: string) => boolean; - patternHasPathSeparator: boolean; - rule: IGroup; - symbolFilePathMatches?: (value: string) => boolean; -} - -type NodeLegendRuleInput = IGroup | CompiledNodeLegendRule; +import { + applyCompiledNodeLegendRules as applyCompiledNodeLegendRulesImpl, + applyNodeLegendRules as applyNodeLegendRulesImpl, +} from './nodeLegend/apply'; +import { + compileNodeLegendRules as compileNodeLegendRulesImpl, + getOrderedActiveRules as getOrderedActiveRulesImpl, +} from './nodeLegend/compile'; +import type { + CompiledNodeLegendRule, + NodeLegendRuleInput, +} from './nodeLegend/contracts'; + +export type { CompiledNodeLegendRule } from './nodeLegend/contracts'; export function getOrderedActiveRules(legends: IGroup[]): IGroup[] { - return legends - .filter((group) => !group.disabled) - .reverse(); -} - -function hasNodeLegendConstraints(rule: IGroup): boolean { - return Boolean( - rule.matchNodeType - || rule.matchSymbolKind - || rule.matchSymbolKinds?.length - || rule.matchSymbolPluginKind - || rule.matchSymbolSource - || rule.matchSymbolLanguage - || rule.matchSymbolFilePath, - ); + return getOrderedActiveRulesImpl(legends); } export function compileNodeLegendRules(activeRules: IGroup[]): CompiledNodeLegendRule[] { - return activeRules - .filter(ruleTargetsNodes) - .map((rule) => ({ - caseInsensitivePatternMatches: createGlobMatcher(rule.pattern.toLowerCase()), - hasConstraints: hasNodeLegendConstraints(rule), - patternMatches: createGlobMatcher(rule.pattern), - patternHasPathSeparator: rule.pattern.includes('/'), - rule, - ...(rule.matchSymbolFilePath - ? { symbolFilePathMatches: createGlobMatcher(rule.matchSymbolFilePath) } - : {}), - })); -} - -function isCompiledNodeLegendRule(rule: NodeLegendRuleInput): rule is CompiledNodeLegendRule { - return 'patternMatches' in rule && 'rule' in rule; -} - -function normalizeNodeLegendRules(activeRules: readonly NodeLegendRuleInput[]): CompiledNodeLegendRule[] { - if (activeRules.every(isCompiledNodeLegendRule)) { - return [...activeRules]; - } - - return compileNodeLegendRules(activeRules.filter((rule): rule is IGroup => !isCompiledNodeLegendRule(rule))); -} - -function getCaseInsensitiveNodeCandidates( - node: IGraphData['nodes'][number], -): string[] { - const symbol = node.symbol; - return [ - node.label, - symbol?.name, - symbol?.kind, - symbol?.pluginKind, - symbol?.filePath, - ] - .filter((candidate): candidate is string => Boolean(candidate)) - .map((candidate) => candidate.toLowerCase()); -} - -function optionalRuleValueMatches(expected: T | undefined, actual: T | undefined): boolean { - return expected === undefined || expected === actual; -} - -function optionalSymbolKindsMatch(expected: readonly string[] | undefined, actual: string | undefined): boolean { - return expected === undefined || Boolean(actual && expected.includes(actual)); -} - -function optionalSymbolFilePathMatches(compiledRule: CompiledNodeLegendRule, filePath: string | undefined): boolean { - return compiledRule.symbolFilePathMatches === undefined - || Boolean(filePath && compiledRule.symbolFilePathMatches(filePath)); -} - -const NODE_LEGEND_CONSTRAINT_MATCHERS: readonly NodeLegendConstraintMatcher[] = [ - (node, _symbol, { rule }) => optionalRuleValueMatches(rule.matchNodeType, node.nodeType), - (_node, symbol, { rule }) => optionalRuleValueMatches(rule.matchSymbolKind, symbol?.kind), - (_node, symbol, { rule }) => optionalRuleValueMatches(rule.matchSymbolPluginKind, symbol?.pluginKind), - (_node, symbol, { rule }) => optionalRuleValueMatches(rule.matchSymbolSource, symbol?.source), - (_node, symbol, { rule }) => optionalRuleValueMatches(rule.matchSymbolLanguage, symbol?.language), - (_node, symbol, { rule }) => optionalSymbolKindsMatch(rule.matchSymbolKinds, symbol?.kind), - (_node, symbol, compiledRule) => optionalSymbolFilePathMatches(compiledRule, symbol?.filePath), -]; - -function compiledRuleConstraintsMatchNode( - node: GraphNode, - compiledRule: CompiledNodeLegendRule, -): boolean { - return !compiledRule.hasConstraints - || NODE_LEGEND_CONSTRAINT_MATCHERS.every(matcher => - matcher(node, node.symbol, compiledRule), - ); -} - -function pathCandidateMatchesNodeRule( - value: string | undefined, - compiledRule: CompiledNodeLegendRule, -): boolean { - return Boolean( - value - && value.includes('/') - && compiledRule.caseInsensitivePatternMatches(value.toLowerCase()), - ); -} - -function compiledRulePatternMatchesNode( - node: IGraphData['nodes'][number], - getCandidates: () => readonly string[], - compiledRule: CompiledNodeLegendRule, -): boolean { - if (compiledRule.patternMatches(node.id)) { - return true; - } - - if (compiledRule.rule.isPluginDefault) { - return false; - } - - if (compiledRule.patternHasPathSeparator) { - const symbol = node.symbol; - return pathCandidateMatchesNodeRule(node.label, compiledRule) - || pathCandidateMatchesNodeRule(symbol?.name, compiledRule) - || pathCandidateMatchesNodeRule(symbol?.kind, compiledRule) - || pathCandidateMatchesNodeRule(symbol?.pluginKind, compiledRule) - || pathCandidateMatchesNodeRule(symbol?.filePath, compiledRule); - } - - return getCandidates().some((candidate) => compiledRule.caseInsensitivePatternMatches(candidate)); -} - -function compiledRuleMatchesNode( - node: IGraphData['nodes'][number], - getCandidates: () => readonly string[], - compiledRule: CompiledNodeLegendRule, -): boolean { - return compiledRuleConstraintsMatchNode(node, compiledRule) - && compiledRulePatternMatchesNode(node, getCandidates, compiledRule); + return compileNodeLegendRulesImpl(activeRules); } export function applyCompiledNodeLegendRules( node: IGraphData['nodes'][number], activeRules: readonly CompiledNodeLegendRule[], ): IGraphData['nodes'][number] { - const nextNode = { - ...node, - color: node.color || DEFAULT_NODE_COLOR, - }; - let candidates: readonly string[] | undefined; - const getCandidates = (): readonly string[] => { - candidates ??= getCaseInsensitiveNodeCandidates(node); - return candidates; - }; - - for (const compiledRule of activeRules) { - if (!compiledRuleMatchesNode(node, getCandidates, compiledRule)) { - continue; - } - - const { rule } = compiledRule; - nextNode.color = rule.color; - if (rule.shape2D) { - nextNode.shape2D = rule.shape2D; - } - if (rule.shape3D) { - nextNode.shape3D = rule.shape3D; - } - if (rule.imageUrl) { - nextNode.imageUrl = rule.imageUrl; - } - } - - return nextNode; + return applyCompiledNodeLegendRulesImpl(node, activeRules); } export function applyNodeLegendRules( node: IGraphData['nodes'][number], activeRules: readonly NodeLegendRuleInput[], ): IGraphData['nodes'][number] { - return applyCompiledNodeLegendRules(node, normalizeNodeLegendRules(activeRules)); + return applyNodeLegendRulesImpl(node, activeRules); } From 5bf21ba13629f407e328af41078c2428dbd57e1b Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 15:02:46 -0700 Subject: [PATCH 108/192] refactor: split webview ready message mutation sites --- .../graphView/webview/messages/ready.ts | 273 ++---------------- .../webview/messages/webviewReady/apply.ts | 49 ++++ .../messages/webviewReady/contracts.ts | 45 +++ .../messages/webviewReady/diagnostics.ts | 31 ++ .../webviewReady/filterPatternEquality.ts | 40 +++ .../messages/webviewReady/filterPatterns.ts | 31 ++ .../messages/webviewReady/graphBootstrap.ts | 43 +++ .../messages/webviewReady/settingsReplay.ts | 90 ++++++ 8 files changed, 355 insertions(+), 247 deletions(-) create mode 100644 packages/extension/src/extension/graphView/webview/messages/webviewReady/apply.ts create mode 100644 packages/extension/src/extension/graphView/webview/messages/webviewReady/contracts.ts create mode 100644 packages/extension/src/extension/graphView/webview/messages/webviewReady/diagnostics.ts create mode 100644 packages/extension/src/extension/graphView/webview/messages/webviewReady/filterPatternEquality.ts create mode 100644 packages/extension/src/extension/graphView/webview/messages/webviewReady/filterPatterns.ts create mode 100644 packages/extension/src/extension/graphView/webview/messages/webviewReady/graphBootstrap.ts create mode 100644 packages/extension/src/extension/graphView/webview/messages/webviewReady/settingsReplay.ts diff --git a/packages/extension/src/extension/graphView/webview/messages/ready.ts b/packages/extension/src/extension/graphView/webview/messages/ready.ts index 679776f9a..497c50f8a 100644 --- a/packages/extension/src/extension/graphView/webview/messages/ready.ts +++ b/packages/extension/src/extension/graphView/webview/messages/ready.ts @@ -1,277 +1,56 @@ -import type { DagMode, NodeSizeMode } from '../../../../shared/settings/modes'; -import type { IPluginFilterPatternGroup } from '../../../../shared/protocol/extensionToWebview'; -import type { IGraphData } from '../../../../shared/graph/contracts'; -import { createExtensionDiagnosticLogger } from '../../../diagnostics/logger'; -import type { ExtensionToWebviewMessage } from '../../../../shared/protocol/extensionToWebview'; - -export interface GraphViewReadyState { - maxFiles: number; - verboseDiagnostics: boolean; - playbackSpeed: number; - depthMode?: boolean; - dagMode: DagMode; - nodeSizeMode: NodeSizeMode; - focusedFile: string | undefined; - hasWorkspace: boolean; - firstAnalysis: boolean; - readyNotified: boolean; -} - -export interface GraphViewReadyHandlers { - getGraphData(): IGraphData; - getFilterPatterns(): string[]; - getPluginFilterPatterns(): string[]; - getPluginFilterGroups?: () => IPluginFilterPatternGroup[]; - getConfig(key: string, defaultValue: T): T; - loadGroupsAndFilterPatterns(): void; - loadDisabledRulesAndPlugins(): void; - sendDepthState(): void; - sendGraphControls(): void; - loadAndSendData(): void | Promise; - sendFavorites(): void; - sendSettings(): void; - sendPhysicsSettings(): void; - sendGroupsUpdated(): void; - sendMessage(message: { type: string; payload?: unknown }): void; - sendCachedTimeline(): Promise; - sendDecorations(): void; - sendContextMenuItems(): void; - sendPluginStatuses?(): void; - sendPluginExporters?(): void; - sendPluginToolbarActions?(): void; - sendGraphViewContributionStatuses?(): void; - sendPluginWebviewInjections(): void; - sendActiveFile(): void; - waitForFirstWorkspaceReady(): PromiseLike; - notifyWebviewReady(): void; -} - -type FilterPatternsUpdatedMessage = Extract; -type FilterPatternsPayload = FilterPatternsUpdatedMessage['payload']; - -function createWebviewReadyFilterPatternsPayload(handlers: GraphViewReadyHandlers): FilterPatternsPayload { - return { - patterns: handlers.getFilterPatterns(), - pluginPatterns: handlers.getPluginFilterPatterns(), - pluginPatternGroups: handlers.getPluginFilterGroups?.() ?? [], - disabledCustomPatterns: handlers.getConfig('disabledCustomFilterPatterns', []), - disabledPluginPatterns: handlers.getConfig('disabledPluginFilterPatterns', []), - }; -} - -function areStringArraysEqual(left: readonly string[], right: readonly string[]): boolean { - return left.length === right.length && left.every((value, index) => value === right[index]); -} - -function arePluginFilterPatternGroupEqual( - left: IPluginFilterPatternGroup, - right: IPluginFilterPatternGroup | undefined, -): boolean { - if (!right) { - return false; - } - - return left.pluginId === right.pluginId - && left.pluginName === right.pluginName - && areStringArraysEqual(left.patterns, right.patterns); -} - -function arePluginFilterPatternGroupsEqual( - left: readonly IPluginFilterPatternGroup[], - right: readonly IPluginFilterPatternGroup[], -): boolean { - return left.length === right.length - && left.every((leftGroup, index) => arePluginFilterPatternGroupEqual(leftGroup, right[index])); -} - -function areWebviewReadyFilterPatternsEqual( - left: FilterPatternsPayload, - right: FilterPatternsPayload, -): boolean { - return areStringArraysEqual(left.patterns, right.patterns) - && areStringArraysEqual(left.pluginPatterns, right.pluginPatterns) - && arePluginFilterPatternGroupsEqual(left.pluginPatternGroups, right.pluginPatternGroups) - && areStringArraysEqual(left.disabledCustomPatterns, right.disabledCustomPatterns) - && areStringArraysEqual(left.disabledPluginPatterns, right.disabledPluginPatterns); -} - -function sendWebviewReadyFilterPatterns(handlers: GraphViewReadyHandlers): FilterPatternsPayload { - const payload = createWebviewReadyFilterPatternsPayload(handlers); - handlers.sendMessage({ - type: 'FILTER_PATTERNS_UPDATED', - payload, - }); - return payload; -} - -interface ReplayWebviewReadySettingsMessagesOptions { - includeFilterPatterns: boolean; - includePluginBootstrap: boolean; -} - -function replayWebviewReadySettingsMessages( - state: GraphViewReadyState, - handlers: GraphViewReadyHandlers, - options: ReplayWebviewReadySettingsMessagesOptions, -): void { - handlers.loadGroupsAndFilterPatterns(); - handlers.loadDisabledRulesAndPlugins(); - handlers.sendDepthState(); - handlers.sendGraphControls(); - handlers.sendFavorites(); - handlers.sendSettings(); - handlers.sendPhysicsSettings(); - handlers.sendGroupsUpdated(); - if (options.includeFilterPatterns) { - sendWebviewReadyFilterPatterns(handlers); - } - handlers.sendMessage({ - type: 'MAX_FILES_UPDATED', - payload: { maxFiles: state.maxFiles }, - }); - handlers.sendMessage({ - type: 'VERBOSE_DIAGNOSTICS_UPDATED', - payload: { verboseDiagnostics: state.verboseDiagnostics }, - }); - handlers.sendMessage({ - type: 'PLAYBACK_SPEED_UPDATED', - payload: { speed: state.playbackSpeed }, - }); - handlers.sendMessage({ - type: 'DEPTH_MODE_UPDATED', - payload: { depthMode: state.depthMode ?? false }, - }); - handlers.sendMessage({ - type: 'DAG_MODE_UPDATED', - payload: { dagMode: state.dagMode }, - }); - handlers.sendMessage({ - type: 'NODE_SIZE_MODE_UPDATED', - payload: { nodeSizeMode: state.nodeSizeMode }, - }); - handlers.sendDecorations(); - handlers.sendContextMenuItems(); - handlers.sendPluginExporters?.(); - handlers.sendPluginToolbarActions?.(); - if (options.includePluginBootstrap) { - handlers.sendGraphViewContributionStatuses?.(); - handlers.sendPluginWebviewInjections(); - } - handlers.sendActiveFile(); -} +import { applyWebviewReady as applyWebviewReadyImpl } from './webviewReady/apply'; +import type { + GraphViewReadyHandlers, + GraphViewReadyState, +} from './webviewReady/contracts'; +import { + replayDuplicateWebviewReady as replayDuplicateWebviewReadyImpl, + replayWebviewReadyBootstrap as replayWebviewReadyBootstrapImpl, + replayWebviewReadyGraphBootstrap as replayWebviewReadyGraphBootstrapImpl, + shouldWaitForFirstWorkspaceGraph as shouldWaitForFirstWorkspaceGraphImpl, +} from './webviewReady/graphBootstrap'; +import { replayWebviewReadySettings as replayWebviewReadySettingsImpl } from './webviewReady/settingsReplay'; + +export type { + GraphViewReadyHandlers, + GraphViewReadyState, +} from './webviewReady/contracts'; export function replayWebviewReadySettings( state: GraphViewReadyState, handlers: GraphViewReadyHandlers, ): void { - createExtensionDiagnosticLogger({ - isEnabled: () => state.verboseDiagnostics, - }).emit({ - area: 'extension.webview', - event: 'ready-replayed', - context: { - hasWorkspace: state.hasWorkspace, - firstAnalysis: state.firstAnalysis, - readyNotified: state.readyNotified, - maxFiles: state.maxFiles, - }, - }); - replayWebviewReadySettingsMessages(state, handlers, { - includeFilterPatterns: true, - includePluginBootstrap: true, - }); -} - -function shouldReplayHydrationSettingsAfterLoad(state: GraphViewReadyState): boolean { - return state.hasWorkspace && state.firstAnalysis; -} - -function replayWebviewReadyHydrationSettings( - state: GraphViewReadyState, - handlers: GraphViewReadyHandlers, -): void { - replayWebviewReadySettingsMessages(state, handlers, { - includeFilterPatterns: false, - includePluginBootstrap: false, - }); + replayWebviewReadySettingsImpl(state, handlers); } export function replayWebviewReadyBootstrap( state: GraphViewReadyState, handlers: GraphViewReadyHandlers, ): void { - replayWebviewReadySettings(state, handlers); - replayWebviewReadyGraphBootstrap(handlers); -} - -interface ReplayWebviewReadyGraphBootstrapOptions { - includeGraphData?: boolean; + replayWebviewReadyBootstrapImpl(state, handlers); } export function replayWebviewReadyGraphBootstrap( handlers: Pick, - options: ReplayWebviewReadyGraphBootstrapOptions = {}, + options: { includeGraphData?: boolean } = {}, ): void { - if (options.includeGraphData ?? true) { - handlers.sendMessage({ type: 'GRAPH_DATA_UPDATED', payload: handlers.getGraphData() }); - } - handlers.sendMessage({ type: 'APP_BOOTSTRAP_COMPLETE' }); + replayWebviewReadyGraphBootstrapImpl(handlers, options); } export function shouldWaitForFirstWorkspaceGraph(state: GraphViewReadyState): boolean { - return state.hasWorkspace && state.firstAnalysis; + return shouldWaitForFirstWorkspaceGraphImpl(state); } -export async function replayDuplicateWebviewReady( +export function replayDuplicateWebviewReady( state: GraphViewReadyState, handlers: GraphViewReadyHandlers, ): Promise { - if (shouldWaitForFirstWorkspaceGraph(state)) { - return; - } - - replayWebviewReadySettings(state, handlers); - replayWebviewReadyGraphBootstrap(handlers, { includeGraphData: !state.readyNotified }); + return replayDuplicateWebviewReadyImpl(state, handlers); } -export async function applyWebviewReady( +export function applyWebviewReady( state: GraphViewReadyState, handlers: GraphViewReadyHandlers, ): Promise { - replayWebviewReadySettings(state, handlers); - - const initialFilterPatterns = createWebviewReadyFilterPatternsPayload(handlers); - await handlers.sendCachedTimeline(); - await handlers.loadAndSendData(); - const loadedFilterPatterns = createWebviewReadyFilterPatternsPayload(handlers); - if (!areWebviewReadyFilterPatternsEqual(initialFilterPatterns, loadedFilterPatterns)) { - handlers.sendMessage({ - type: 'FILTER_PATTERNS_UPDATED', - payload: loadedFilterPatterns, - }); - } - handlers.sendPluginStatuses?.(); - if (shouldReplayHydrationSettingsAfterLoad(state)) { - replayWebviewReadyHydrationSettings(state, handlers); - } - - handlers.sendMessage({ type: 'APP_BOOTSTRAP_COMPLETE' }); - createExtensionDiagnosticLogger({ - isEnabled: () => state.verboseDiagnostics, - }).emit({ - area: 'extension.webview', - event: 'bootstrap-completed', - context: { - hasWorkspace: state.hasWorkspace, - firstAnalysis: state.firstAnalysis, - readyNotified: state.readyNotified, - }, - }); - - if (state.readyNotified) { - return true; - } - - handlers.notifyWebviewReady(); - return true; + return applyWebviewReadyImpl(state, handlers); } diff --git a/packages/extension/src/extension/graphView/webview/messages/webviewReady/apply.ts b/packages/extension/src/extension/graphView/webview/messages/webviewReady/apply.ts new file mode 100644 index 000000000..09cd3ff6a --- /dev/null +++ b/packages/extension/src/extension/graphView/webview/messages/webviewReady/apply.ts @@ -0,0 +1,49 @@ +import type { + GraphViewReadyHandlers, + GraphViewReadyState, +} from './contracts'; +import { emitWebviewBootstrapCompleted } from './diagnostics'; +import { areWebviewReadyFilterPatternsEqual } from './filterPatternEquality'; +import { + createWebviewReadyFilterPatternsPayload, +} from './filterPatterns'; +import { + replayWebviewReadyHydrationSettings, + replayWebviewReadySettings, +} from './settingsReplay'; + +export async function applyWebviewReady( + state: GraphViewReadyState, + handlers: GraphViewReadyHandlers, +): Promise { + replayWebviewReadySettings(state, handlers); + + const initialFilterPatterns = createWebviewReadyFilterPatternsPayload(handlers); + await handlers.sendCachedTimeline(); + await handlers.loadAndSendData(); + const loadedFilterPatterns = createWebviewReadyFilterPatternsPayload(handlers); + if (!areWebviewReadyFilterPatternsEqual(initialFilterPatterns, loadedFilterPatterns)) { + handlers.sendMessage({ + type: 'FILTER_PATTERNS_UPDATED', + payload: loadedFilterPatterns, + }); + } + handlers.sendPluginStatuses?.(); + if (shouldReplayHydrationSettingsAfterLoad(state)) { + replayWebviewReadyHydrationSettings(state, handlers); + } + + handlers.sendMessage({ type: 'APP_BOOTSTRAP_COMPLETE' }); + emitWebviewBootstrapCompleted(state); + + if (state.readyNotified) { + return true; + } + + handlers.notifyWebviewReady(); + return true; +} + +function shouldReplayHydrationSettingsAfterLoad(state: GraphViewReadyState): boolean { + return state.hasWorkspace && state.firstAnalysis; +} diff --git a/packages/extension/src/extension/graphView/webview/messages/webviewReady/contracts.ts b/packages/extension/src/extension/graphView/webview/messages/webviewReady/contracts.ts new file mode 100644 index 000000000..2b8af8e24 --- /dev/null +++ b/packages/extension/src/extension/graphView/webview/messages/webviewReady/contracts.ts @@ -0,0 +1,45 @@ +import type { IGraphData } from '../../../../../shared/graph/contracts'; +import type { IPluginFilterPatternGroup } from '../../../../../shared/protocol/extensionToWebview'; +import type { DagMode, NodeSizeMode } from '../../../../../shared/settings/modes'; + +export interface GraphViewReadyState { + maxFiles: number; + verboseDiagnostics: boolean; + playbackSpeed: number; + depthMode?: boolean; + dagMode: DagMode; + nodeSizeMode: NodeSizeMode; + focusedFile: string | undefined; + hasWorkspace: boolean; + firstAnalysis: boolean; + readyNotified: boolean; +} + +export interface GraphViewReadyHandlers { + getGraphData(): IGraphData; + getFilterPatterns(): string[]; + getPluginFilterPatterns(): string[]; + getPluginFilterGroups?: () => IPluginFilterPatternGroup[]; + getConfig(key: string, defaultValue: T): T; + loadGroupsAndFilterPatterns(): void; + loadDisabledRulesAndPlugins(): void; + sendDepthState(): void; + sendGraphControls(): void; + loadAndSendData(): void | Promise; + sendFavorites(): void; + sendSettings(): void; + sendPhysicsSettings(): void; + sendGroupsUpdated(): void; + sendMessage(message: { type: string; payload?: unknown }): void; + sendCachedTimeline(): Promise; + sendDecorations(): void; + sendContextMenuItems(): void; + sendPluginStatuses?(): void; + sendPluginExporters?(): void; + sendPluginToolbarActions?(): void; + sendGraphViewContributionStatuses?(): void; + sendPluginWebviewInjections(): void; + sendActiveFile(): void; + waitForFirstWorkspaceReady(): PromiseLike; + notifyWebviewReady(): void; +} diff --git a/packages/extension/src/extension/graphView/webview/messages/webviewReady/diagnostics.ts b/packages/extension/src/extension/graphView/webview/messages/webviewReady/diagnostics.ts new file mode 100644 index 000000000..c6736d5d1 --- /dev/null +++ b/packages/extension/src/extension/graphView/webview/messages/webviewReady/diagnostics.ts @@ -0,0 +1,31 @@ +import { createExtensionDiagnosticLogger } from '../../../../diagnostics/logger'; +import type { GraphViewReadyState } from './contracts'; + +export function emitWebviewReadyReplayed(state: GraphViewReadyState): void { + createExtensionDiagnosticLogger({ + isEnabled: () => state.verboseDiagnostics, + }).emit({ + area: 'extension.webview', + event: 'ready-replayed', + context: { + hasWorkspace: state.hasWorkspace, + firstAnalysis: state.firstAnalysis, + readyNotified: state.readyNotified, + maxFiles: state.maxFiles, + }, + }); +} + +export function emitWebviewBootstrapCompleted(state: GraphViewReadyState): void { + createExtensionDiagnosticLogger({ + isEnabled: () => state.verboseDiagnostics, + }).emit({ + area: 'extension.webview', + event: 'bootstrap-completed', + context: { + hasWorkspace: state.hasWorkspace, + firstAnalysis: state.firstAnalysis, + readyNotified: state.readyNotified, + }, + }); +} diff --git a/packages/extension/src/extension/graphView/webview/messages/webviewReady/filterPatternEquality.ts b/packages/extension/src/extension/graphView/webview/messages/webviewReady/filterPatternEquality.ts new file mode 100644 index 000000000..c1d7f0e27 --- /dev/null +++ b/packages/extension/src/extension/graphView/webview/messages/webviewReady/filterPatternEquality.ts @@ -0,0 +1,40 @@ +import type { IPluginFilterPatternGroup } from '../../../../../shared/protocol/extensionToWebview'; +import type { FilterPatternsPayload } from './filterPatterns'; + +export function areWebviewReadyFilterPatternsEqual( + left: FilterPatternsPayload, + right: FilterPatternsPayload, +): boolean { + return areStringArraysEqual(left.patterns, right.patterns) + && areStringArraysEqual(left.pluginPatterns, right.pluginPatterns) + && arePluginFilterPatternGroupsEqual(left.pluginPatternGroups, right.pluginPatternGroups) + && areStringArraysEqual(left.disabledCustomPatterns, right.disabledCustomPatterns) + && areStringArraysEqual(left.disabledPluginPatterns, right.disabledPluginPatterns); +} + +function areStringArraysEqual(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); +} + +function arePluginFilterPatternGroupsEqual( + left: readonly IPluginFilterPatternGroup[], + right: readonly IPluginFilterPatternGroup[], +): boolean { + return left.length === right.length + && left.every((leftGroup, index) => + arePluginFilterPatternGroupEqual(leftGroup, right[index]), + ); +} + +function arePluginFilterPatternGroupEqual( + left: IPluginFilterPatternGroup, + right: IPluginFilterPatternGroup | undefined, +): boolean { + if (!right) { + return false; + } + + return left.pluginId === right.pluginId + && left.pluginName === right.pluginName + && areStringArraysEqual(left.patterns, right.patterns); +} diff --git a/packages/extension/src/extension/graphView/webview/messages/webviewReady/filterPatterns.ts b/packages/extension/src/extension/graphView/webview/messages/webviewReady/filterPatterns.ts new file mode 100644 index 000000000..4ef4769a2 --- /dev/null +++ b/packages/extension/src/extension/graphView/webview/messages/webviewReady/filterPatterns.ts @@ -0,0 +1,31 @@ +import type { ExtensionToWebviewMessage } from '../../../../../shared/protocol/extensionToWebview'; +import type { GraphViewReadyHandlers } from './contracts'; + +type FilterPatternsUpdatedMessage = Extract< + ExtensionToWebviewMessage, + { type: 'FILTER_PATTERNS_UPDATED' } +>; +export type FilterPatternsPayload = FilterPatternsUpdatedMessage['payload']; + +export function createWebviewReadyFilterPatternsPayload( + handlers: GraphViewReadyHandlers, +): FilterPatternsPayload { + return { + patterns: handlers.getFilterPatterns(), + pluginPatterns: handlers.getPluginFilterPatterns(), + pluginPatternGroups: handlers.getPluginFilterGroups?.() ?? [], + disabledCustomPatterns: handlers.getConfig('disabledCustomFilterPatterns', []), + disabledPluginPatterns: handlers.getConfig('disabledPluginFilterPatterns', []), + }; +} + +export function sendWebviewReadyFilterPatterns( + handlers: GraphViewReadyHandlers, +): FilterPatternsPayload { + const payload = createWebviewReadyFilterPatternsPayload(handlers); + handlers.sendMessage({ + type: 'FILTER_PATTERNS_UPDATED', + payload, + }); + return payload; +} diff --git a/packages/extension/src/extension/graphView/webview/messages/webviewReady/graphBootstrap.ts b/packages/extension/src/extension/graphView/webview/messages/webviewReady/graphBootstrap.ts new file mode 100644 index 000000000..15d7a5ab9 --- /dev/null +++ b/packages/extension/src/extension/graphView/webview/messages/webviewReady/graphBootstrap.ts @@ -0,0 +1,43 @@ +import type { + GraphViewReadyHandlers, + GraphViewReadyState, +} from './contracts'; +import { replayWebviewReadySettings } from './settingsReplay'; + +interface ReplayWebviewReadyGraphBootstrapOptions { + includeGraphData?: boolean; +} + +export function replayWebviewReadyBootstrap( + state: GraphViewReadyState, + handlers: GraphViewReadyHandlers, +): void { + replayWebviewReadySettings(state, handlers); + replayWebviewReadyGraphBootstrap(handlers); +} + +export function replayWebviewReadyGraphBootstrap( + handlers: Pick, + options: ReplayWebviewReadyGraphBootstrapOptions = {}, +): void { + if (options.includeGraphData ?? true) { + handlers.sendMessage({ type: 'GRAPH_DATA_UPDATED', payload: handlers.getGraphData() }); + } + handlers.sendMessage({ type: 'APP_BOOTSTRAP_COMPLETE' }); +} + +export function shouldWaitForFirstWorkspaceGraph(state: GraphViewReadyState): boolean { + return state.hasWorkspace && state.firstAnalysis; +} + +export async function replayDuplicateWebviewReady( + state: GraphViewReadyState, + handlers: GraphViewReadyHandlers, +): Promise { + if (shouldWaitForFirstWorkspaceGraph(state)) { + return; + } + + replayWebviewReadySettings(state, handlers); + replayWebviewReadyGraphBootstrap(handlers, { includeGraphData: !state.readyNotified }); +} diff --git a/packages/extension/src/extension/graphView/webview/messages/webviewReady/settingsReplay.ts b/packages/extension/src/extension/graphView/webview/messages/webviewReady/settingsReplay.ts new file mode 100644 index 000000000..f023e2e04 --- /dev/null +++ b/packages/extension/src/extension/graphView/webview/messages/webviewReady/settingsReplay.ts @@ -0,0 +1,90 @@ +import type { + GraphViewReadyHandlers, + GraphViewReadyState, +} from './contracts'; +import { emitWebviewReadyReplayed } from './diagnostics'; +import { sendWebviewReadyFilterPatterns } from './filterPatterns'; + +interface ReplayWebviewReadySettingsMessagesOptions { + includeFilterPatterns: boolean; + includePluginBootstrap: boolean; +} + +export function replayWebviewReadySettings( + state: GraphViewReadyState, + handlers: GraphViewReadyHandlers, +): void { + emitWebviewReadyReplayed(state); + replayWebviewReadySettingsMessages(state, handlers, { + includeFilterPatterns: true, + includePluginBootstrap: true, + }); +} + +export function replayWebviewReadyHydrationSettings( + state: GraphViewReadyState, + handlers: GraphViewReadyHandlers, +): void { + replayWebviewReadySettingsMessages(state, handlers, { + includeFilterPatterns: false, + includePluginBootstrap: false, + }); +} + +function replayWebviewReadySettingsMessages( + state: GraphViewReadyState, + handlers: GraphViewReadyHandlers, + options: ReplayWebviewReadySettingsMessagesOptions, +): void { + handlers.loadGroupsAndFilterPatterns(); + handlers.loadDisabledRulesAndPlugins(); + handlers.sendDepthState(); + handlers.sendGraphControls(); + handlers.sendFavorites(); + handlers.sendSettings(); + handlers.sendPhysicsSettings(); + handlers.sendGroupsUpdated(); + if (options.includeFilterPatterns) { + sendWebviewReadyFilterPatterns(handlers); + } + sendWebviewReadySettingValues(state, handlers); + handlers.sendDecorations(); + handlers.sendContextMenuItems(); + handlers.sendPluginExporters?.(); + handlers.sendPluginToolbarActions?.(); + if (options.includePluginBootstrap) { + handlers.sendGraphViewContributionStatuses?.(); + handlers.sendPluginWebviewInjections(); + } + handlers.sendActiveFile(); +} + +function sendWebviewReadySettingValues( + state: GraphViewReadyState, + handlers: GraphViewReadyHandlers, +): void { + handlers.sendMessage({ + type: 'MAX_FILES_UPDATED', + payload: { maxFiles: state.maxFiles }, + }); + handlers.sendMessage({ + type: 'VERBOSE_DIAGNOSTICS_UPDATED', + payload: { verboseDiagnostics: state.verboseDiagnostics }, + }); + handlers.sendMessage({ + type: 'PLAYBACK_SPEED_UPDATED', + payload: { speed: state.playbackSpeed }, + }); + handlers.sendMessage({ + type: 'DEPTH_MODE_UPDATED', + payload: { depthMode: state.depthMode ?? false }, + }); + handlers.sendMessage({ + type: 'DAG_MODE_UPDATED', + payload: { dagMode: state.dagMode }, + }); + handlers.sendMessage({ + type: 'NODE_SIZE_MODE_UPDATED', + payload: { nodeSizeMode: state.nodeSizeMode }, + }); +} From 666d8a77f36ca491eeda92301a13cb2fe4b565d4 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 15:07:34 -0700 Subject: [PATCH 109/192] refactor: split graph provider refresh mutation sites --- .../extension/graphView/provider/refresh.ts | 494 +----------------- .../graphView/provider/refresh/contracts.ts | 101 ++++ .../graphView/provider/refresh/coordinator.ts | 23 + .../graphView/provider/refresh/defaults.ts | 10 + .../graphView/provider/refresh/factory.ts | 85 +++ .../graphView/provider/refresh/rebuild.ts | 2 +- .../provider/refresh/requests/methods.ts | 87 +++ .../graphView/provider/refresh/run.ts | 2 +- .../provider/refresh/scoped/lifecycle.ts | 100 ++++ .../provider/refresh/scoped/methods.ts | 103 ++++ 10 files changed, 531 insertions(+), 476 deletions(-) create mode 100644 packages/extension/src/extension/graphView/provider/refresh/contracts.ts create mode 100644 packages/extension/src/extension/graphView/provider/refresh/coordinator.ts create mode 100644 packages/extension/src/extension/graphView/provider/refresh/defaults.ts create mode 100644 packages/extension/src/extension/graphView/provider/refresh/factory.ts create mode 100644 packages/extension/src/extension/graphView/provider/refresh/requests/methods.ts create mode 100644 packages/extension/src/extension/graphView/provider/refresh/scoped/lifecycle.ts create mode 100644 packages/extension/src/extension/graphView/provider/refresh/scoped/methods.ts diff --git a/packages/extension/src/extension/graphView/provider/refresh.ts b/packages/extension/src/extension/graphView/provider/refresh.ts index 035171ea7..b564d54b3 100644 --- a/packages/extension/src/extension/graphView/provider/refresh.ts +++ b/packages/extension/src/extension/graphView/provider/refresh.ts @@ -1,481 +1,27 @@ -import type { IGraphData } from '../../../shared/graph/contracts'; -import type { ExtensionToWebviewMessage } from '../../../shared/protocol/extensionToWebview'; -import { getCodeGraphyConfiguration } from '../../repoSettings/current'; -import { createGraphViewIndexProgressCoalescer } from '../analysis/execution/progress'; -import { rebuildGraphViewData, smartRebuildGraphView } from '../view/rebuild'; -import { createRebuildSenders } from './refresh/rebuild'; import { - canRunIncrementalChangedFileRefresh, - runChangedFileRefresh, - runIndexRefresh, - runPrimaryRefresh, - sendRefreshState, -} from './refresh/run'; - -type GraphViewScopedRefreshProgress = { phase: string; current: number; total: number }; - -interface GraphViewProviderRefreshAnalyzerLike { - hasIndex(): boolean; - rebuildGraph( - disabledPlugins: Set, - showOrphans: boolean, - ): IGraphData; - registry: { - notifyGraphRebuild( - graphData: IGraphData, - disabledPlugins?: ReadonlySet, - ): void; - }; - clearCache(): void; - refreshAnalysisScope?( - filterPatterns?: string[], - disabledPlugins?: Set, - signal?: AbortSignal, - onProgress?: (progress: GraphViewScopedRefreshProgress) => void, - ): Promise; - refreshPluginFiles?( - pluginIds: readonly string[], - filterPatterns?: string[], - disabledPlugins?: Set, - signal?: AbortSignal, - onProgress?: (progress: GraphViewScopedRefreshProgress) => void, - ): Promise; - refreshGitignoreMetadata?( - filterPatterns?: string[], - disabledPlugins?: Set, - signal?: AbortSignal, - ): Promise; -} - -interface RefreshCoordinatorState { - indexRefreshPromise: Promise | undefined; - queuedChangedFilePaths: Set; -} - -interface ScopedRefreshLifecycle { - setController(controller: AbortController): void; - clearController(controller: AbortController): void; - abort(): void; -} - -export interface GraphViewProviderRefreshMethodsSource { - _analyzer: GraphViewProviderRefreshAnalyzerLike | undefined; - _analysisController?: AbortController; - _analysisRequestId: number; - _disabledPlugins: Set; - _filterPatterns: string[]; - _rawGraphData: IGraphData; - _graphData: IGraphData; - _loadDisabledRulesAndPlugins(): boolean; - _loadGroupsAndFilterPatterns(): void; - _loadAndSendData?(): Promise; - _analyzeAndSendData(): Promise; - _refreshAndSendData?(): Promise; - _incrementalAnalyzeAndSendData?(filePaths: readonly string[]): Promise; - _sendAllSettings(): void; - _sendFavorites(favorites?: string[]): void; - _computeMergedGroups(): void; - _sendGroupsUpdated(): void; - _sendGraphControls?(): void; - _sendSettings(): void; - _sendPhysicsSettings(): void; - _updateViewContext(): void; - _applyViewTransform(): void; - _sendDepthState(): void; - _sendPluginStatuses(): void; - _sendDecorations(): void; - _sendMessage(message: ExtensionToWebviewMessage): void; - _rebuildAndSend?(this: void): void; -} - -export interface GraphViewProviderRefreshMethods { - refresh(): Promise; - refreshIndex(): Promise; - refreshGitignoreMetadata(): Promise; - refreshAnalysisScope(): Promise; - refreshPluginFiles(pluginIds: readonly string[]): Promise; - refreshChangedFiles(filePaths: readonly string[]): Promise; - refreshGroupSettings(): void; - refreshPhysicsSettings(): void; - refreshSettings(): void; - refreshToggleSettings(): void; - clearCacheAndRefresh(): Promise; - _rebuildAndSend(): void; - _smartRebuild(id: string): void; -} - -export interface GraphViewProviderRefreshMethodDependencies { - getShowOrphans(): boolean; - rebuildGraphData: typeof rebuildGraphViewData; - smartRebuildGraphData: typeof smartRebuildGraphView; -} - -export const DEFAULT_DEPENDENCIES: GraphViewProviderRefreshMethodDependencies = { - getShowOrphans: () => - getCodeGraphyConfiguration().get('showOrphans', true), - rebuildGraphData: rebuildGraphViewData, - smartRebuildGraphData: smartRebuildGraphView, -}; - -function isScopedRefreshStale( - source: GraphViewProviderRefreshMethodsSource, - signal: AbortSignal, - requestId: number, -): boolean { - return signal.aborted || source._analysisRequestId !== requestId; -} - -async function runScopedRefreshRequest( - source: GraphViewProviderRefreshMethodsSource, - runRefresh: ( - signal: AbortSignal, - onProgress: (progress: GraphViewScopedRefreshProgress) => void, - ) => Promise, - lifecycle: { - setController(controller: AbortController): void; - clearController(controller: AbortController): void; - }, -): Promise { - source._analysisController?.abort(); - const controller = new AbortController(); - source._analysisController = controller; - lifecycle.setController(controller); - const requestId = ++source._analysisRequestId; - - const sendProgress = createGraphViewIndexProgressCoalescer((progress: GraphViewScopedRefreshProgress) => { - if (isScopedRefreshStale(source, controller.signal, requestId)) { - return; - } - source._sendMessage({ type: 'GRAPH_INDEX_PROGRESS', payload: progress }); - }); - - try { - const graphData = await runRefresh(controller.signal, sendProgress); - if (isScopedRefreshStale(source, controller.signal, requestId)) { - return undefined; - } - return graphData; - } catch (error) { - if (isScopedRefreshStale(source, controller.signal, requestId)) { - return undefined; - } - throw error; - } finally { - lifecycle.clearController(controller); - if (source._analysisController === controller) { - source._analysisController = undefined; - } - } -} - -function publishScopedRefreshGraphData( - source: GraphViewProviderRefreshMethodsSource, - graphData: IGraphData, -): void { - source._rawGraphData = graphData; - source._updateViewContext(); - source._applyViewTransform(); - source._computeMergedGroups(); - source._sendGroupsUpdated(); - source._sendMessage({ type: 'GRAPH_DATA_UPDATED', payload: source._graphData }); - source._sendDepthState(); - source._sendGraphControls?.(); - source._sendPluginStatuses(); - source._sendDecorations(); - source._analyzer?.registry.notifyGraphRebuild(source._graphData, source._disabledPlugins); -} - -function createScopedRefreshLifecycle(): ScopedRefreshLifecycle { - let scopedRefreshController: AbortController | undefined; - - return { - setController(controller: AbortController): void { - scopedRefreshController = controller; - }, - clearController(controller: AbortController): void { - if (scopedRefreshController === controller) { - scopedRefreshController = undefined; - } - }, - abort(): void { - scopedRefreshController?.abort(); - }, - }; -} - -function createRefreshCoordinatorState(): RefreshCoordinatorState { - return { - indexRefreshPromise: undefined, - queuedChangedFilePaths: new Set(), - }; -} - -function prepareRefreshInputs(source: GraphViewProviderRefreshMethodsSource): void { - source._loadDisabledRulesAndPlugins(); - source._loadGroupsAndFilterPatterns(); -} - -function canRunIndexedChangedFileRefresh(source: GraphViewProviderRefreshMethodsSource): boolean { - return canRunIncrementalChangedFileRefresh(source); -} - -function createRefreshMethod( - source: GraphViewProviderRefreshMethodsSource, - state: RefreshCoordinatorState, -): () => Promise { - return async (): Promise => { - if (state.indexRefreshPromise) { - await state.indexRefreshPromise; - return; - } - - prepareRefreshInputs(source); - await runPrimaryRefresh(source); - sendRefreshState(source, 'refresh'); - }; -} - -function createRefreshIndexMethod( - source: GraphViewProviderRefreshMethodsSource, - state: RefreshCoordinatorState, - refreshChangedFiles: (filePaths: readonly string[]) => Promise, -): () => Promise { - return async (): Promise => { - if (state.indexRefreshPromise) { - await state.indexRefreshPromise; - return; - } - - state.indexRefreshPromise = (async (): Promise => { - prepareRefreshInputs(source); - await runIndexRefresh(source); - sendRefreshState(source, 'refreshIndex'); - })(); - - try { - await state.indexRefreshPromise; - } finally { - state.indexRefreshPromise = undefined; - } - - const queuedFilePaths = [...state.queuedChangedFilePaths]; - state.queuedChangedFilePaths = new Set(); - if (queuedFilePaths.length > 0) { - await refreshChangedFiles(queuedFilePaths); - } - }; -} - -function createRefreshAnalysisScopeMethod( - source: GraphViewProviderRefreshMethodsSource, - state: RefreshCoordinatorState, - refresh: () => Promise, - scopedRefreshLifecycle: ScopedRefreshLifecycle, -): () => Promise { - return async (): Promise => { - if (state.indexRefreshPromise) { - await state.indexRefreshPromise; - } - - prepareRefreshInputs(source); - if (!source._analyzer?.hasIndex() || !source._analyzer.refreshAnalysisScope) { - await refresh(); - return; - } - - const graphData = await runScopedRefreshRequest( - source, - (signal, onProgress) => source._analyzer!.refreshAnalysisScope!( - source._filterPatterns, - source._disabledPlugins, - signal, - onProgress, - ), - scopedRefreshLifecycle, - ); - publishGraphDataIfPresent(source, graphData, 'analysisScope'); - }; -} - -function createRefreshGitignoreMetadataMethod( - source: GraphViewProviderRefreshMethodsSource, - state: RefreshCoordinatorState, - refreshIndex: () => Promise, - scopedRefreshLifecycle: ScopedRefreshLifecycle, -): () => Promise { - return async (): Promise => { - if (state.indexRefreshPromise) { - await state.indexRefreshPromise; - } - - prepareRefreshInputs(source); - if (!source._analyzer?.refreshGitignoreMetadata) { - await refreshIndex(); - return; - } - - const graphData = await runScopedRefreshRequest( - source, - signal => source._analyzer!.refreshGitignoreMetadata!( - source._filterPatterns, - source._disabledPlugins, - signal, - ), - scopedRefreshLifecycle, - ); - publishGraphDataIfPresent(source, graphData, 'gitignoreMetadata'); - }; -} - -function createRefreshPluginFilesMethod( - source: GraphViewProviderRefreshMethodsSource, - state: RefreshCoordinatorState, - refresh: () => Promise, - scopedRefreshLifecycle: ScopedRefreshLifecycle, -): (pluginIds: readonly string[]) => Promise { - return async (pluginIds: readonly string[]): Promise => { - if (state.indexRefreshPromise) { - await state.indexRefreshPromise; - } - - prepareRefreshInputs(source); - if (!source._analyzer?.refreshPluginFiles) { - await refresh(); - return; - } - - const graphData = await runScopedRefreshRequest( - source, - (signal, onProgress) => source._analyzer!.refreshPluginFiles!( - pluginIds, - source._filterPatterns, - source._disabledPlugins, - signal, - onProgress, - ), - scopedRefreshLifecycle, - ); - publishGraphDataIfPresent(source, graphData, 'pluginFiles'); - }; -} - -function publishGraphDataIfPresent( - source: GraphViewProviderRefreshMethodsSource, - graphData: IGraphData | undefined, - reason: 'analysisScope' | 'gitignoreMetadata' | 'pluginFiles', -): void { - if (!graphData) { - return; - } - - publishScopedRefreshGraphData(source, graphData); - sendRefreshState(source, reason); -} - -function createRefreshChangedFilesMethod( - source: GraphViewProviderRefreshMethodsSource, - state: RefreshCoordinatorState, -): (filePaths: readonly string[]) => Promise { - return async (filePaths: readonly string[]): Promise => { - if (state.indexRefreshPromise) { - state.queuedChangedFilePaths = new Set([ - ...state.queuedChangedFilePaths, - ...filePaths, - ]); - return; - } - - if (!canRunIndexedChangedFileRefresh(source)) { - prepareRefreshInputs(source); - } - const refreshMode = await runChangedFileRefresh(source, filePaths); - if (refreshMode !== 'incremental') { - sendRefreshState(source, 'changedFiles'); - } - }; -} + createGraphViewProviderRefreshMethods as createGraphViewProviderRefreshMethodsImpl, +} from './refresh/factory'; +import type { + GraphViewProviderRefreshMethodDependencies, + GraphViewProviderRefreshMethods, + GraphViewProviderRefreshMethodsSource, +} from './refresh/contracts'; +import { DEFAULT_DEPENDENCIES } from './refresh/defaults'; + +export type { + GraphViewProviderRefreshAnalyzerLike, + GraphViewProviderRefreshMethodDependencies, + GraphViewProviderRefreshMethods, + GraphViewProviderRefreshMethodsSource, + GraphViewScopedRefreshProgress, + RefreshCoordinatorState, + ScopedRefreshLifecycle, +} from './refresh/contracts'; +export { DEFAULT_DEPENDENCIES } from './refresh/defaults'; export function createGraphViewProviderRefreshMethods( source: GraphViewProviderRefreshMethodsSource, dependencies: GraphViewProviderRefreshMethodDependencies = DEFAULT_DEPENDENCIES, ): GraphViewProviderRefreshMethods { - const rebuildSenders = createRebuildSenders(source, dependencies); - const _rebuildAndSend = (): void => rebuildSenders.rebuildAndSend(); - const scopedRefreshLifecycle = createScopedRefreshLifecycle(); - const _smartRebuild = (id: string): void => { - scopedRefreshLifecycle.abort(); - rebuildSenders.smartRebuild(id); - }; - // Full reindex clears the persisted cache first, so competing refreshes - // must wait or they can rebuild from an empty intermediate index. - const state = createRefreshCoordinatorState(); - const refresh = createRefreshMethod(source, state); - const refreshChangedFiles = createRefreshChangedFilesMethod(source, state); - const refreshIndex = createRefreshIndexMethod(source, state, refreshChangedFiles); - const refreshAnalysisScope = createRefreshAnalysisScopeMethod( - source, - state, - refresh, - scopedRefreshLifecycle, - ); - const refreshGitignoreMetadata = createRefreshGitignoreMetadataMethod( - source, - state, - refreshIndex, - scopedRefreshLifecycle, - ); - const refreshPluginFiles = createRefreshPluginFilesMethod( - source, - state, - refresh, - scopedRefreshLifecycle, - ); - - const refreshPhysicsSettings = (): void => { - source._sendPhysicsSettings(); - }; - - const refreshGroupSettings = (): void => { - source._loadGroupsAndFilterPatterns(); - source._sendGroupsUpdated(); - }; - - const refreshSettings = (): void => { - source._sendSettings(); - source._sendGraphControls?.(); - }; - - const refreshToggleSettings = (): void => { - if (!source._loadDisabledRulesAndPlugins()) return; - scopedRefreshLifecycle.abort(); - if (source._rebuildAndSend) { - source._rebuildAndSend(); - return; - } - - _rebuildAndSend(); - }; - - const clearCacheAndRefresh = async (): Promise => { - source._analyzer?.clearCache(); - await refreshIndex(); - }; - - const methods: GraphViewProviderRefreshMethods = { - refresh, - refreshIndex, - refreshGitignoreMetadata, - refreshAnalysisScope, - refreshPluginFiles, - refreshChangedFiles, - refreshGroupSettings, - refreshPhysicsSettings, - refreshSettings, - refreshToggleSettings, - clearCacheAndRefresh, - _rebuildAndSend, - _smartRebuild, - }; - - return methods; + return createGraphViewProviderRefreshMethodsImpl(source, dependencies); } diff --git a/packages/extension/src/extension/graphView/provider/refresh/contracts.ts b/packages/extension/src/extension/graphView/provider/refresh/contracts.ts new file mode 100644 index 000000000..3bdea7f09 --- /dev/null +++ b/packages/extension/src/extension/graphView/provider/refresh/contracts.ts @@ -0,0 +1,101 @@ +import type { IGraphData } from '../../../../shared/graph/contracts'; +import type { ExtensionToWebviewMessage } from '../../../../shared/protocol/extensionToWebview'; +import type { rebuildGraphViewData, smartRebuildGraphView } from '../../view/rebuild'; + +export type GraphViewScopedRefreshProgress = { phase: string; current: number; total: number }; + +export interface GraphViewProviderRefreshAnalyzerLike { + hasIndex(): boolean; + rebuildGraph( + disabledPlugins: Set, + showOrphans: boolean, + ): IGraphData; + registry: { + notifyGraphRebuild( + graphData: IGraphData, + disabledPlugins?: ReadonlySet, + ): void; + }; + clearCache(): void; + refreshAnalysisScope?( + filterPatterns?: string[], + disabledPlugins?: Set, + signal?: AbortSignal, + onProgress?: (progress: GraphViewScopedRefreshProgress) => void, + ): Promise; + refreshPluginFiles?( + pluginIds: readonly string[], + filterPatterns?: string[], + disabledPlugins?: Set, + signal?: AbortSignal, + onProgress?: (progress: GraphViewScopedRefreshProgress) => void, + ): Promise; + refreshGitignoreMetadata?( + filterPatterns?: string[], + disabledPlugins?: Set, + signal?: AbortSignal, + ): Promise; +} + +export interface RefreshCoordinatorState { + indexRefreshPromise: Promise | undefined; + queuedChangedFilePaths: Set; +} + +export interface ScopedRefreshLifecycle { + setController(controller: AbortController): void; + clearController(controller: AbortController): void; + abort(): void; +} + +export interface GraphViewProviderRefreshMethodsSource { + _analyzer: GraphViewProviderRefreshAnalyzerLike | undefined; + _analysisController?: AbortController; + _analysisRequestId: number; + _disabledPlugins: Set; + _filterPatterns: string[]; + _rawGraphData: IGraphData; + _graphData: IGraphData; + _loadDisabledRulesAndPlugins(): boolean; + _loadGroupsAndFilterPatterns(): void; + _loadAndSendData?(): Promise; + _analyzeAndSendData(): Promise; + _refreshAndSendData?(): Promise; + _incrementalAnalyzeAndSendData?(filePaths: readonly string[]): Promise; + _sendAllSettings(): void; + _sendFavorites(favorites?: string[]): void; + _computeMergedGroups(): void; + _sendGroupsUpdated(): void; + _sendGraphControls?(): void; + _sendSettings(): void; + _sendPhysicsSettings(): void; + _updateViewContext(): void; + _applyViewTransform(): void; + _sendDepthState(): void; + _sendPluginStatuses(): void; + _sendDecorations(): void; + _sendMessage(message: ExtensionToWebviewMessage): void; + _rebuildAndSend?(this: void): void; +} + +export interface GraphViewProviderRefreshMethods { + refresh(): Promise; + refreshIndex(): Promise; + refreshGitignoreMetadata(): Promise; + refreshAnalysisScope(): Promise; + refreshPluginFiles(pluginIds: readonly string[]): Promise; + refreshChangedFiles(filePaths: readonly string[]): Promise; + refreshGroupSettings(): void; + refreshPhysicsSettings(): void; + refreshSettings(): void; + refreshToggleSettings(): void; + clearCacheAndRefresh(): Promise; + _rebuildAndSend(): void; + _smartRebuild(id: string): void; +} + +export interface GraphViewProviderRefreshMethodDependencies { + getShowOrphans(): boolean; + rebuildGraphData: typeof rebuildGraphViewData; + smartRebuildGraphData: typeof smartRebuildGraphView; +} diff --git a/packages/extension/src/extension/graphView/provider/refresh/coordinator.ts b/packages/extension/src/extension/graphView/provider/refresh/coordinator.ts new file mode 100644 index 000000000..3a7ea0a1e --- /dev/null +++ b/packages/extension/src/extension/graphView/provider/refresh/coordinator.ts @@ -0,0 +1,23 @@ +import type { + GraphViewProviderRefreshMethodsSource, + RefreshCoordinatorState, +} from './contracts'; +import { canRunIncrementalChangedFileRefresh } from './run'; + +export function createRefreshCoordinatorState(): RefreshCoordinatorState { + return { + indexRefreshPromise: undefined, + queuedChangedFilePaths: new Set(), + }; +} + +export function prepareRefreshInputs(source: GraphViewProviderRefreshMethodsSource): void { + source._loadDisabledRulesAndPlugins(); + source._loadGroupsAndFilterPatterns(); +} + +export function canRunIndexedChangedFileRefresh( + source: GraphViewProviderRefreshMethodsSource, +): boolean { + return canRunIncrementalChangedFileRefresh(source); +} diff --git a/packages/extension/src/extension/graphView/provider/refresh/defaults.ts b/packages/extension/src/extension/graphView/provider/refresh/defaults.ts new file mode 100644 index 000000000..d89ac1424 --- /dev/null +++ b/packages/extension/src/extension/graphView/provider/refresh/defaults.ts @@ -0,0 +1,10 @@ +import { getCodeGraphyConfiguration } from '../../../repoSettings/current'; +import { rebuildGraphViewData, smartRebuildGraphView } from '../../view/rebuild'; +import type { GraphViewProviderRefreshMethodDependencies } from './contracts'; + +export const DEFAULT_DEPENDENCIES: GraphViewProviderRefreshMethodDependencies = { + getShowOrphans: () => + getCodeGraphyConfiguration().get('showOrphans', true), + rebuildGraphData: rebuildGraphViewData, + smartRebuildGraphData: smartRebuildGraphView, +}; diff --git a/packages/extension/src/extension/graphView/provider/refresh/factory.ts b/packages/extension/src/extension/graphView/provider/refresh/factory.ts new file mode 100644 index 000000000..782add154 --- /dev/null +++ b/packages/extension/src/extension/graphView/provider/refresh/factory.ts @@ -0,0 +1,85 @@ +import { createRebuildSenders } from './rebuild'; +import type { + GraphViewProviderRefreshMethodDependencies, + GraphViewProviderRefreshMethods, + GraphViewProviderRefreshMethodsSource, +} from './contracts'; +import { createRefreshCoordinatorState } from './coordinator'; +import { DEFAULT_DEPENDENCIES } from './defaults'; +import { + createRefreshChangedFilesMethod, + createRefreshIndexMethod, + createRefreshMethod, +} from './requests/methods'; +import { createScopedRefreshLifecycle } from './scoped/lifecycle'; +import { + createRefreshAnalysisScopeMethod, + createRefreshGitignoreMetadataMethod, + createRefreshPluginFilesMethod, +} from './scoped/methods'; + +export function createGraphViewProviderRefreshMethods( + source: GraphViewProviderRefreshMethodsSource, + dependencies: GraphViewProviderRefreshMethodDependencies = DEFAULT_DEPENDENCIES, +): GraphViewProviderRefreshMethods { + const rebuildSenders = createRebuildSenders(source, dependencies); + const _rebuildAndSend = (): void => rebuildSenders.rebuildAndSend(); + const scopedRefreshLifecycle = createScopedRefreshLifecycle(); + const _smartRebuild = (id: string): void => { + scopedRefreshLifecycle.abort(); + rebuildSenders.smartRebuild(id); + }; + const state = createRefreshCoordinatorState(); + const refresh = createRefreshMethod(source, state); + const refreshChangedFiles = createRefreshChangedFilesMethod(source, state); + const refreshIndex = createRefreshIndexMethod(source, state, refreshChangedFiles); + const refreshAnalysisScope = createRefreshAnalysisScopeMethod( + source, + state, + refresh, + scopedRefreshLifecycle, + ); + const refreshGitignoreMetadata = createRefreshGitignoreMetadataMethod( + source, + state, + refreshIndex, + scopedRefreshLifecycle, + ); + const refreshPluginFiles = createRefreshPluginFilesMethod( + source, + state, + refresh, + scopedRefreshLifecycle, + ); + + return { + refresh, + refreshIndex, + refreshGitignoreMetadata, + refreshAnalysisScope, + refreshPluginFiles, + refreshChangedFiles, + refreshGroupSettings: () => { + source._loadGroupsAndFilterPatterns(); + source._sendGroupsUpdated(); + }, + refreshPhysicsSettings: () => { + source._sendPhysicsSettings(); + }, + refreshSettings: () => { + source._sendSettings(); + source._sendGraphControls?.(); + }, + refreshToggleSettings: () => { + if (!source._loadDisabledRulesAndPlugins()) return; + scopedRefreshLifecycle.abort(); + (source._rebuildAndSend ?? _rebuildAndSend)(); + }, + clearCacheAndRefresh: async () => { + source._analyzer?.clearCache(); + await refreshIndex(); + }, + _rebuildAndSend, + _smartRebuild, + }; +} diff --git a/packages/extension/src/extension/graphView/provider/refresh/rebuild.ts b/packages/extension/src/extension/graphView/provider/refresh/rebuild.ts index 5428c18d3..2614ec5b8 100644 --- a/packages/extension/src/extension/graphView/provider/refresh/rebuild.ts +++ b/packages/extension/src/extension/graphView/provider/refresh/rebuild.ts @@ -2,7 +2,7 @@ import type { ExtensionToWebviewMessage } from '../../../../shared/protocol/exte import type { GraphViewProviderRefreshMethodDependencies, GraphViewProviderRefreshMethodsSource, -} from '../refresh'; +} from './contracts'; export function createRebuildSenders( source: GraphViewProviderRefreshMethodsSource, diff --git a/packages/extension/src/extension/graphView/provider/refresh/requests/methods.ts b/packages/extension/src/extension/graphView/provider/refresh/requests/methods.ts new file mode 100644 index 000000000..1295b97f9 --- /dev/null +++ b/packages/extension/src/extension/graphView/provider/refresh/requests/methods.ts @@ -0,0 +1,87 @@ +import type { + GraphViewProviderRefreshMethodsSource, + RefreshCoordinatorState, +} from '../contracts'; +import { + canRunIndexedChangedFileRefresh, + prepareRefreshInputs, +} from '../coordinator'; +import { + runChangedFileRefresh, + runIndexRefresh, + runPrimaryRefresh, + sendRefreshState, +} from '../run'; + +export function createRefreshMethod( + source: GraphViewProviderRefreshMethodsSource, + state: RefreshCoordinatorState, +): () => Promise { + return async (): Promise => { + if (state.indexRefreshPromise) { + await state.indexRefreshPromise; + return; + } + + prepareRefreshInputs(source); + await runPrimaryRefresh(source); + sendRefreshState(source, 'refresh'); + }; +} + +export function createRefreshIndexMethod( + source: GraphViewProviderRefreshMethodsSource, + state: RefreshCoordinatorState, + refreshChangedFiles: (filePaths: readonly string[]) => Promise, +): () => Promise { + return async (): Promise => { + if (state.indexRefreshPromise) { + await state.indexRefreshPromise; + return; + } + + state.indexRefreshPromise = runIndexRefreshWithInputs(source); + try { + await state.indexRefreshPromise; + } finally { + state.indexRefreshPromise = undefined; + } + + const queuedFilePaths = [...state.queuedChangedFilePaths]; + state.queuedChangedFilePaths = new Set(); + if (queuedFilePaths.length > 0) { + await refreshChangedFiles(queuedFilePaths); + } + }; +} + +export function createRefreshChangedFilesMethod( + source: GraphViewProviderRefreshMethodsSource, + state: RefreshCoordinatorState, +): (filePaths: readonly string[]) => Promise { + return async (filePaths: readonly string[]): Promise => { + if (state.indexRefreshPromise) { + state.queuedChangedFilePaths = new Set([ + ...state.queuedChangedFilePaths, + ...filePaths, + ]); + return; + } + + if (!canRunIndexedChangedFileRefresh(source)) { + prepareRefreshInputs(source); + } + const refreshMode = await runChangedFileRefresh(source, filePaths); + if (refreshMode !== 'incremental') { + sendRefreshState(source, 'changedFiles'); + } + }; +} + +async function runIndexRefreshWithInputs( + source: GraphViewProviderRefreshMethodsSource, +): Promise { + prepareRefreshInputs(source); + await runIndexRefresh(source); + sendRefreshState(source, 'refreshIndex'); +} diff --git a/packages/extension/src/extension/graphView/provider/refresh/run.ts b/packages/extension/src/extension/graphView/provider/refresh/run.ts index 8cc66feaa..86e799d51 100644 --- a/packages/extension/src/extension/graphView/provider/refresh/run.ts +++ b/packages/extension/src/extension/graphView/provider/refresh/run.ts @@ -1,5 +1,5 @@ -import type { GraphViewProviderRefreshMethodsSource } from '../refresh'; import type { IGraphData } from '../../../../shared/graph/contracts'; +import type { GraphViewProviderRefreshMethodsSource } from './contracts'; export type RefreshStateReason = | 'analysisScope' diff --git a/packages/extension/src/extension/graphView/provider/refresh/scoped/lifecycle.ts b/packages/extension/src/extension/graphView/provider/refresh/scoped/lifecycle.ts new file mode 100644 index 000000000..210d766ef --- /dev/null +++ b/packages/extension/src/extension/graphView/provider/refresh/scoped/lifecycle.ts @@ -0,0 +1,100 @@ +import type { IGraphData } from '../../../../../shared/graph/contracts'; +import { createGraphViewIndexProgressCoalescer } from '../../../analysis/execution/progress'; +import type { + GraphViewProviderRefreshMethodsSource, + GraphViewScopedRefreshProgress, + ScopedRefreshLifecycle, +} from '../contracts'; +import { sendRefreshState, type RefreshStateReason } from '../run'; + +export function createScopedRefreshLifecycle(): ScopedRefreshLifecycle { + let scopedRefreshController: AbortController | undefined; + + return { + setController(controller: AbortController): void { + scopedRefreshController = controller; + }, + clearController(controller: AbortController): void { + if (scopedRefreshController === controller) { + scopedRefreshController = undefined; + } + }, + abort(): void { + scopedRefreshController?.abort(); + }, + }; +} + +export async function runScopedRefreshRequest( + source: GraphViewProviderRefreshMethodsSource, + runRefresh: ( + signal: AbortSignal, + onProgress: (progress: GraphViewScopedRefreshProgress) => void, + ) => Promise, + lifecycle: Pick, +): Promise { + source._analysisController?.abort(); + const controller = new AbortController(); + source._analysisController = controller; + lifecycle.setController(controller); + const requestId = ++source._analysisRequestId; + + const sendProgress = createGraphViewIndexProgressCoalescer((progress: GraphViewScopedRefreshProgress) => { + if (!isScopedRefreshStale(source, controller.signal, requestId)) { + source._sendMessage({ type: 'GRAPH_INDEX_PROGRESS', payload: progress }); + } + }); + + try { + const graphData = await runRefresh(controller.signal, sendProgress); + return isScopedRefreshStale(source, controller.signal, requestId) ? undefined : graphData; + } catch (error) { + if (!isScopedRefreshStale(source, controller.signal, requestId)) { + throw error; + } + return undefined; + } finally { + lifecycle.clearController(controller); + if (source._analysisController === controller) { + source._analysisController = undefined; + } + } +} + +export function publishScopedRefreshGraphData( + source: GraphViewProviderRefreshMethodsSource, + graphData: IGraphData, +): void { + source._rawGraphData = graphData; + source._updateViewContext(); + source._applyViewTransform(); + source._computeMergedGroups(); + source._sendGroupsUpdated(); + source._sendMessage({ type: 'GRAPH_DATA_UPDATED', payload: source._graphData }); + source._sendDepthState(); + source._sendGraphControls?.(); + source._sendPluginStatuses(); + source._sendDecorations(); + source._analyzer?.registry.notifyGraphRebuild(source._graphData, source._disabledPlugins); +} + +export function publishGraphDataIfPresent( + source: GraphViewProviderRefreshMethodsSource, + graphData: IGraphData | undefined, + reason: RefreshStateReason, +): void { + if (!graphData) { + return; + } + + publishScopedRefreshGraphData(source, graphData); + sendRefreshState(source, reason); +} + +function isScopedRefreshStale( + source: GraphViewProviderRefreshMethodsSource, + signal: AbortSignal, + requestId: number, +): boolean { + return signal.aborted || source._analysisRequestId !== requestId; +} diff --git a/packages/extension/src/extension/graphView/provider/refresh/scoped/methods.ts b/packages/extension/src/extension/graphView/provider/refresh/scoped/methods.ts new file mode 100644 index 000000000..2f3702088 --- /dev/null +++ b/packages/extension/src/extension/graphView/provider/refresh/scoped/methods.ts @@ -0,0 +1,103 @@ +import type { + GraphViewProviderRefreshMethodsSource, + RefreshCoordinatorState, + ScopedRefreshLifecycle, +} from '../contracts'; +import { prepareRefreshInputs } from '../coordinator'; +import { + publishGraphDataIfPresent, + runScopedRefreshRequest, +} from './lifecycle'; + +export function createRefreshAnalysisScopeMethod( + source: GraphViewProviderRefreshMethodsSource, + state: RefreshCoordinatorState, + refresh: () => Promise, + scopedRefreshLifecycle: ScopedRefreshLifecycle, +): () => Promise { + return async (): Promise => { + if (state.indexRefreshPromise) { + await state.indexRefreshPromise; + } + + prepareRefreshInputs(source); + if (!source._analyzer?.hasIndex() || !source._analyzer.refreshAnalysisScope) { + await refresh(); + return; + } + + const graphData = await runScopedRefreshRequest( + source, + (signal, onProgress) => source._analyzer!.refreshAnalysisScope!( + source._filterPatterns, + source._disabledPlugins, + signal, + onProgress, + ), + scopedRefreshLifecycle, + ); + publishGraphDataIfPresent(source, graphData, 'analysisScope'); + }; +} + +export function createRefreshGitignoreMetadataMethod( + source: GraphViewProviderRefreshMethodsSource, + state: RefreshCoordinatorState, + refreshIndex: () => Promise, + scopedRefreshLifecycle: ScopedRefreshLifecycle, +): () => Promise { + return async (): Promise => { + if (state.indexRefreshPromise) { + await state.indexRefreshPromise; + } + + prepareRefreshInputs(source); + if (!source._analyzer?.refreshGitignoreMetadata) { + await refreshIndex(); + return; + } + + const graphData = await runScopedRefreshRequest( + source, + signal => source._analyzer!.refreshGitignoreMetadata!( + source._filterPatterns, + source._disabledPlugins, + signal, + ), + scopedRefreshLifecycle, + ); + publishGraphDataIfPresent(source, graphData, 'gitignoreMetadata'); + }; +} + +export function createRefreshPluginFilesMethod( + source: GraphViewProviderRefreshMethodsSource, + state: RefreshCoordinatorState, + refresh: () => Promise, + scopedRefreshLifecycle: ScopedRefreshLifecycle, +): (pluginIds: readonly string[]) => Promise { + return async (pluginIds: readonly string[]): Promise => { + if (state.indexRefreshPromise) { + await state.indexRefreshPromise; + } + + prepareRefreshInputs(source); + if (!source._analyzer?.refreshPluginFiles) { + await refresh(); + return; + } + + const graphData = await runScopedRefreshRequest( + source, + (signal, onProgress) => source._analyzer!.refreshPluginFiles!( + pluginIds, + source._filterPatterns, + source._disabledPlugins, + signal, + onProgress, + ), + scopedRefreshLifecycle, + ); + publishGraphDataIfPresent(source, graphData, 'pluginFiles'); + }; +} From 5f8df779cb0008c10363bbb4d4ef5b94b4fd0839 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 15:11:07 -0700 Subject: [PATCH 110/192] refactor: split graph data message handler mutation sites --- .../store/messageHandlers/graphData.ts | 167 ++---------------- .../graphDataMessage/bootstrap.ts | 16 ++ .../graphDataMessage/contracts.ts | 17 ++ .../graphDataMessage/duplicate.ts | 35 ++++ .../graphDataMessage/metricUpdates.ts | 55 ++++++ .../graphDataMessage/metrics.ts | 51 ++++++ .../graphDataMessage/payload.ts | 30 ++++ 7 files changed, 217 insertions(+), 154 deletions(-) create mode 100644 packages/extension/src/webview/store/messageHandlers/graphDataMessage/bootstrap.ts create mode 100644 packages/extension/src/webview/store/messageHandlers/graphDataMessage/contracts.ts create mode 100644 packages/extension/src/webview/store/messageHandlers/graphDataMessage/duplicate.ts create mode 100644 packages/extension/src/webview/store/messageHandlers/graphDataMessage/metricUpdates.ts create mode 100644 packages/extension/src/webview/store/messageHandlers/graphDataMessage/metrics.ts create mode 100644 packages/extension/src/webview/store/messageHandlers/graphDataMessage/payload.ts diff --git a/packages/extension/src/webview/store/messageHandlers/graphData.ts b/packages/extension/src/webview/store/messageHandlers/graphData.ts index 5ae48e555..e00b9771e 100644 --- a/packages/extension/src/webview/store/messageHandlers/graphData.ts +++ b/packages/extension/src/webview/store/messageHandlers/graphData.ts @@ -1,171 +1,30 @@ -import type { IGraphData } from '../../../shared/graph/contracts'; -import type { ExtensionToWebviewMessage } from '../../../shared/protocol/extensionToWebview'; -import type { NodeSizeMode } from '../../../shared/settings/modes'; import type { IHandlerContext, PartialState } from '../messageTypes'; - -type GraphNodeMetricsUpdateMessage = Extract; -type GraphNodeMetricsUpdate = GraphNodeMetricsUpdateMessage['payload']['nodes'][number]; - -function areGraphDataPayloadsEqual(left: IGraphData, right: IGraphData): boolean { - if (left.nodes.length !== right.nodes.length || left.edges.length !== right.edges.length) { - return false; - } - - try { - return JSON.stringify(left) === JSON.stringify(right); - } catch { - return false; - } -} - -function shouldSkipDuplicateGraphData( - state: ReturnType>, - payload: IGraphData, -): boolean { - if (!state.graphData || state.graphIsIndexing || !areGraphDataPayloadsEqual(state.graphData, payload)) { - return false; - } - - return ( - ( - state.bootstrapComplete - && !state.awaitingInitialBootstrap - && !state.isLoading - ) - || ( - state.awaitingInitialBootstrap - && !state.bootstrapComplete - ) - ); -} - -function nodeSizeModeUsesNodeMetrics(mode: NodeSizeMode): boolean { - return mode === 'file-size' || mode === 'churn'; -} - -function nodeMetricsDiffer( - node: IGraphData['nodes'][number], - update: GraphNodeMetricsUpdate, -): boolean { - return node.fileSize !== update.fileSize || node.churn !== update.churn; -} - -function applyMetricUpdatesInPlace( - graphData: IGraphData, - updatesById: ReadonlyMap, -): boolean { - let changed = false; - - for (const node of graphData.nodes) { - const update = updatesById.get(node.id); - if (!update || !nodeMetricsDiffer(node, update)) { - continue; - } - - node.fileSize = update.fileSize; - node.churn = update.churn; - changed = true; - } - - return changed; -} +import { handleAppBootstrapComplete as handleAppBootstrapCompleteImpl } from './graphDataMessage/bootstrap'; +import type { + AppBootstrapCompleteMessage, + GraphDataUpdatedMessage, + GraphNodeMetricsUpdateMessage, +} from './graphDataMessage/contracts'; +import { handleGraphNodeMetricsUpdated as handleGraphNodeMetricsUpdatedImpl } from './graphDataMessage/metrics'; +import { handleGraphDataUpdated as handleGraphDataUpdatedImpl } from './graphDataMessage/payload'; export function handleGraphDataUpdated( - message: Extract, + message: GraphDataUpdatedMessage, ctx?: Pick, ): PartialState | void { - const state = ctx?.getState(); - if (state && shouldSkipDuplicateGraphData(state, message.payload)) { - return undefined; - } - - const waitingForInitialBootstrap = Boolean( - state?.awaitingInitialBootstrap - && !state.bootstrapComplete, - ); - const initialBootstrapFinished = Boolean( - state?.awaitingInitialBootstrap - && state.bootstrapComplete - ); - - return { - graphData: message.payload, - ...(initialBootstrapFinished ? { awaitingInitialBootstrap: false } : {}), - isLoading: waitingForInitialBootstrap, - graphIsIndexing: false, - graphIndexProgress: null, - }; + return handleGraphDataUpdatedImpl(message, ctx); } export function handleGraphNodeMetricsUpdated( message: GraphNodeMetricsUpdateMessage, ctx?: Pick, ): PartialState | void { - const state = ctx?.getState(); - if (!state?.graphData) { - return undefined; - } - - const updatesById = new Map(message.payload.nodes.map(node => [node.id, node])); - const waitingForInitialBootstrap = Boolean( - state.awaitingInitialBootstrap - && !state.bootstrapComplete, - ); - - if (!nodeSizeModeUsesNodeMetrics(state.nodeSizeMode)) { - // Metrics do not affect the current visual graph, so keep graphData referentially stable. - applyMetricUpdatesInPlace(state.graphData, updatesById); - - return { - isLoading: waitingForInitialBootstrap, - graphIsIndexing: false, - graphIndexProgress: null, - }; - } - - let changed = false; - const nodes = state.graphData.nodes.map((node) => { - const update = updatesById.get(node.id); - if (!update || !nodeMetricsDiffer(node, update)) { - return node; - } - - changed = true; - return { - ...node, - fileSize: update.fileSize, - churn: update.churn, - }; - }); - - if (!changed) { - return { - graphIsIndexing: false, - graphIndexProgress: null, - }; - } - - return { - graphData: { - ...state.graphData, - nodes, - }, - isLoading: waitingForInitialBootstrap, - graphIsIndexing: false, - graphIndexProgress: null, - }; + return handleGraphNodeMetricsUpdatedImpl(message, ctx); } export function handleAppBootstrapComplete( - _message: Extract, + message: AppBootstrapCompleteMessage, ctx: Pick, ): PartialState { - const state = ctx.getState(); - const graphReady = state.graphData !== null; - - return { - bootstrapComplete: true, - awaitingInitialBootstrap: graphReady ? false : state.awaitingInitialBootstrap, - isLoading: graphReady ? false : state.isLoading, - }; + return handleAppBootstrapCompleteImpl(message, ctx); } diff --git a/packages/extension/src/webview/store/messageHandlers/graphDataMessage/bootstrap.ts b/packages/extension/src/webview/store/messageHandlers/graphDataMessage/bootstrap.ts new file mode 100644 index 000000000..627615ff0 --- /dev/null +++ b/packages/extension/src/webview/store/messageHandlers/graphDataMessage/bootstrap.ts @@ -0,0 +1,16 @@ +import type { IHandlerContext, PartialState } from '../../messageTypes'; +import type { AppBootstrapCompleteMessage } from './contracts'; + +export function handleAppBootstrapComplete( + _message: AppBootstrapCompleteMessage, + ctx: Pick, +): PartialState { + const state = ctx.getState(); + const graphReady = state.graphData !== null; + + return { + bootstrapComplete: true, + awaitingInitialBootstrap: graphReady ? false : state.awaitingInitialBootstrap, + isLoading: graphReady ? false : state.isLoading, + }; +} diff --git a/packages/extension/src/webview/store/messageHandlers/graphDataMessage/contracts.ts b/packages/extension/src/webview/store/messageHandlers/graphDataMessage/contracts.ts new file mode 100644 index 000000000..db251358d --- /dev/null +++ b/packages/extension/src/webview/store/messageHandlers/graphDataMessage/contracts.ts @@ -0,0 +1,17 @@ +import type { IGraphData } from '../../../../shared/graph/contracts'; +import type { ExtensionToWebviewMessage } from '../../../../shared/protocol/extensionToWebview'; + +export type GraphDataUpdatedMessage = Extract< + ExtensionToWebviewMessage, + { type: 'GRAPH_DATA_UPDATED' } +>; +export type GraphNodeMetricsUpdateMessage = Extract< + ExtensionToWebviewMessage, + { type: 'GRAPH_NODE_METRICS_UPDATED' } +>; +export type GraphNodeMetricsUpdate = GraphNodeMetricsUpdateMessage['payload']['nodes'][number]; +export type AppBootstrapCompleteMessage = Extract< + ExtensionToWebviewMessage, + { type: 'APP_BOOTSTRAP_COMPLETE' } +>; +export type GraphNode = IGraphData['nodes'][number]; diff --git a/packages/extension/src/webview/store/messageHandlers/graphDataMessage/duplicate.ts b/packages/extension/src/webview/store/messageHandlers/graphDataMessage/duplicate.ts new file mode 100644 index 000000000..320977d11 --- /dev/null +++ b/packages/extension/src/webview/store/messageHandlers/graphDataMessage/duplicate.ts @@ -0,0 +1,35 @@ +import type { IGraphData } from '../../../../shared/graph/contracts'; +import type { IHandlerContext } from '../../messageTypes'; + +export function shouldSkipDuplicateGraphData( + state: ReturnType>, + payload: IGraphData, +): boolean { + if (!state.graphData || state.graphIsIndexing || !areGraphDataPayloadsEqual(state.graphData, payload)) { + return false; + } + + return ( + ( + state.bootstrapComplete + && !state.awaitingInitialBootstrap + && !state.isLoading + ) + || ( + state.awaitingInitialBootstrap + && !state.bootstrapComplete + ) + ); +} + +function areGraphDataPayloadsEqual(left: IGraphData, right: IGraphData): boolean { + if (left.nodes.length !== right.nodes.length || left.edges.length !== right.edges.length) { + return false; + } + + try { + return JSON.stringify(left) === JSON.stringify(right); + } catch { + return false; + } +} diff --git a/packages/extension/src/webview/store/messageHandlers/graphDataMessage/metricUpdates.ts b/packages/extension/src/webview/store/messageHandlers/graphDataMessage/metricUpdates.ts new file mode 100644 index 000000000..718c1a5ea --- /dev/null +++ b/packages/extension/src/webview/store/messageHandlers/graphDataMessage/metricUpdates.ts @@ -0,0 +1,55 @@ +import type { NodeSizeMode } from '../../../../shared/settings/modes'; +import type { + GraphNode, + GraphNodeMetricsUpdate, +} from './contracts'; + +export function nodeSizeModeUsesNodeMetrics(mode: NodeSizeMode): boolean { + return mode === 'file-size' || mode === 'churn'; +} + +export function applyMetricUpdatesInPlace( + graphData: { nodes: GraphNode[] }, + updatesById: ReadonlyMap, +): boolean { + let changed = false; + + for (const node of graphData.nodes) { + const update = updatesById.get(node.id); + if (!update || !nodeMetricsDiffer(node, update)) { + continue; + } + + node.fileSize = update.fileSize; + node.churn = update.churn; + changed = true; + } + + return changed; +} + +export function applyMetricUpdates( + nodes: readonly GraphNode[], + updatesById: ReadonlyMap, +): { changed: boolean; nodes: GraphNode[] } { + let changed = false; + const nextNodes = nodes.map((node) => { + const update = updatesById.get(node.id); + if (!update || !nodeMetricsDiffer(node, update)) { + return node; + } + + changed = true; + return { + ...node, + fileSize: update.fileSize, + churn: update.churn, + }; + }); + + return { changed, nodes: nextNodes }; +} + +function nodeMetricsDiffer(node: GraphNode, update: GraphNodeMetricsUpdate): boolean { + return node.fileSize !== update.fileSize || node.churn !== update.churn; +} diff --git a/packages/extension/src/webview/store/messageHandlers/graphDataMessage/metrics.ts b/packages/extension/src/webview/store/messageHandlers/graphDataMessage/metrics.ts new file mode 100644 index 000000000..ff72f8daf --- /dev/null +++ b/packages/extension/src/webview/store/messageHandlers/graphDataMessage/metrics.ts @@ -0,0 +1,51 @@ +import type { IHandlerContext, PartialState } from '../../messageTypes'; +import type { GraphNodeMetricsUpdateMessage } from './contracts'; +import { + applyMetricUpdates, + applyMetricUpdatesInPlace, + nodeSizeModeUsesNodeMetrics, +} from './metricUpdates'; + +export function handleGraphNodeMetricsUpdated( + message: GraphNodeMetricsUpdateMessage, + ctx?: Pick, +): PartialState | void { + const state = ctx?.getState(); + if (!state?.graphData) { + return undefined; + } + + const updatesById = new Map(message.payload.nodes.map(node => [node.id, node])); + const waitingForInitialBootstrap = Boolean( + state.awaitingInitialBootstrap + && !state.bootstrapComplete, + ); + + if (!nodeSizeModeUsesNodeMetrics(state.nodeSizeMode)) { + applyMetricUpdatesInPlace(state.graphData, updatesById); + + return { + isLoading: waitingForInitialBootstrap, + graphIsIndexing: false, + graphIndexProgress: null, + }; + } + + const nextNodes = applyMetricUpdates(state.graphData.nodes, updatesById); + if (!nextNodes.changed) { + return { + graphIsIndexing: false, + graphIndexProgress: null, + }; + } + + return { + graphData: { + ...state.graphData, + nodes: nextNodes.nodes, + }, + isLoading: waitingForInitialBootstrap, + graphIsIndexing: false, + graphIndexProgress: null, + }; +} diff --git a/packages/extension/src/webview/store/messageHandlers/graphDataMessage/payload.ts b/packages/extension/src/webview/store/messageHandlers/graphDataMessage/payload.ts new file mode 100644 index 000000000..eb2ffe0f5 --- /dev/null +++ b/packages/extension/src/webview/store/messageHandlers/graphDataMessage/payload.ts @@ -0,0 +1,30 @@ +import type { PartialState, IHandlerContext } from '../../messageTypes'; +import type { GraphDataUpdatedMessage } from './contracts'; +import { shouldSkipDuplicateGraphData } from './duplicate'; + +export function handleGraphDataUpdated( + message: GraphDataUpdatedMessage, + ctx?: Pick, +): PartialState | void { + const state = ctx?.getState(); + if (state && shouldSkipDuplicateGraphData(state, message.payload)) { + return undefined; + } + + const waitingForInitialBootstrap = Boolean( + state?.awaitingInitialBootstrap + && !state.bootstrapComplete, + ); + const initialBootstrapFinished = Boolean( + state?.awaitingInitialBootstrap + && state.bootstrapComplete + ); + + return { + graphData: message.payload, + ...(initialBootstrapFinished ? { awaitingInitialBootstrap: false } : {}), + isLoading: waitingForInitialBootstrap, + graphIsIndexing: false, + graphIndexProgress: null, + }; +} From fb7fedcf4cdef3203e4a037aa5d4fa3ec84d8782 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 15:14:34 -0700 Subject: [PATCH 111/192] refactor: split webview message listener mutation sites --- .../graphView/webview/messages/listener.ts | 177 +----------------- .../messages/webviewListener/contracts.ts | 36 ++++ .../messages/webviewListener/handler.ts | 49 +++++ .../webview/messages/webviewListener/ready.ts | 52 +++++ .../webviewListener/readyDuplicate.ts | 25 +++ .../messages/webviewListener/readyState.ts | 17 ++ .../messages/webviewListener/results.ts | 34 ++++ 7 files changed, 216 insertions(+), 174 deletions(-) create mode 100644 packages/extension/src/extension/graphView/webview/messages/webviewListener/contracts.ts create mode 100644 packages/extension/src/extension/graphView/webview/messages/webviewListener/handler.ts create mode 100644 packages/extension/src/extension/graphView/webview/messages/webviewListener/ready.ts create mode 100644 packages/extension/src/extension/graphView/webview/messages/webviewListener/readyDuplicate.ts create mode 100644 packages/extension/src/extension/graphView/webview/messages/webviewListener/readyState.ts create mode 100644 packages/extension/src/extension/graphView/webview/messages/webviewListener/results.ts diff --git a/packages/extension/src/extension/graphView/webview/messages/listener.ts b/packages/extension/src/extension/graphView/webview/messages/listener.ts index 9506b58ab..c521f0ca2 100644 --- a/packages/extension/src/extension/graphView/webview/messages/listener.ts +++ b/packages/extension/src/extension/graphView/webview/messages/listener.ts @@ -1,182 +1,11 @@ import type * as vscode from 'vscode'; -import type { WebviewToExtensionMessage } from '../../../../shared/protocol/webviewToExtension'; -import type { IGroup } from '../../../../shared/settings/groups'; -import { - dispatchGraphViewPluginMessage, - type GraphViewPluginMessageContext, -} from '../dispatch/plugin'; -import { - dispatchGraphViewPrimaryMessage, - type GraphViewPrimaryMessageContext, -} from '../dispatch/primary'; -import { replayDuplicateWebviewReady } from './ready'; +import type { GraphViewMessageListenerContext } from './webviewListener/contracts'; +import { createGraphViewWebviewMessageHandler } from './webviewListener/handler'; -export interface GraphViewMessageListenerContext - extends GraphViewPrimaryMessageContext, - GraphViewPluginMessageContext { - reprocessPluginFiles(pluginIds: readonly string[]): Promise; - setUserGroups(groups: IGroup[]): void; - setFilterPatterns(patterns: string[]): void; - setWebviewReadyNotified(nextValue: boolean): void; -} +export type { GraphViewMessageListenerContext } from './webviewListener/contracts'; const webviewMessageListenerDisposables = new WeakMap(); -type GraphViewPrimaryMessageResult = Awaited>; -type GraphViewPluginMessageResult = Awaited>; -type WebviewReadyMessage = Extract; - -interface WebviewReadyDelivery { - pageId?: string; - postedAt?: number; -} - -interface WebviewReadyTracking { - completedAt?: number; - handled: boolean; - pageId?: string; -} - -function getWebviewReadyDelivery(message: WebviewReadyMessage): WebviewReadyDelivery { - const payload = (message as { payload?: unknown }).payload; - if (!payload || typeof payload !== 'object') { - return {}; - } - - const pageId = (payload as { pageId?: unknown }).pageId; - const postedAt = (payload as { postedAt?: unknown }).postedAt; - return { - ...(typeof pageId === 'string' && pageId.length > 0 ? { pageId } : {}), - ...(typeof postedAt === 'number' && Number.isFinite(postedAt) ? { postedAt } : {}), - }; -} - -function applyGraphViewPrimaryMessageResult( - primaryResult: GraphViewPrimaryMessageResult, - context: GraphViewMessageListenerContext, -): boolean { - if (!primaryResult.handled) { - return false; - } - - if (primaryResult.userGroups !== undefined) { - context.setUserGroups(primaryResult.userGroups); - context.recomputeGroups(); - context.sendGroupsUpdated(); - } - if (primaryResult.filterPatterns !== undefined) { - context.setFilterPatterns(primaryResult.filterPatterns); - } - - return true; -} - -function applyGraphViewPluginMessageResult( - pluginResult: GraphViewPluginMessageResult, - context: GraphViewMessageListenerContext, -): void { - if (pluginResult.handled && pluginResult.readyNotified !== undefined) { - context.setWebviewReadyNotified(pluginResult.readyNotified); - } -} - -function createReadyState(context: GraphViewMessageListenerContext) { - return { - maxFiles: context.getMaxFiles(), - verboseDiagnostics: context.getConfig('verboseDiagnostics', false), - playbackSpeed: context.getPlaybackSpeed(), - depthMode: context.getDepthMode?.() ?? false, - dagMode: context.getDagMode(), - nodeSizeMode: context.getNodeSizeMode(), - focusedFile: context.getFocusedFile(), - hasWorkspace: context.hasWorkspace(), - firstAnalysis: context.isFirstAnalysis(), - readyNotified: context.isWebviewReadyNotified(), - }; -} - -function isSameReadyPage(delivery: WebviewReadyDelivery, tracking: WebviewReadyTracking): boolean { - return delivery.pageId !== undefined && delivery.pageId === tracking.pageId; -} - -function wasReadyPostedBeforeBootstrapCompleted( - delivery: WebviewReadyDelivery, - tracking: WebviewReadyTracking, -): boolean { - return delivery.postedAt !== undefined - && tracking.completedAt !== undefined - && delivery.postedAt <= tracking.completedAt; -} - -function shouldIgnoreDuplicateReady( - delivery: WebviewReadyDelivery, - tracking: WebviewReadyTracking, -): boolean { - return isSameReadyPage(delivery, tracking) - || wasReadyPostedBeforeBootstrapCompleted(delivery, tracking); -} - -async function handleWebviewReadyMessage( - context: GraphViewMessageListenerContext, - delivery: WebviewReadyDelivery, - tracking: WebviewReadyTracking, -): Promise { - if (!tracking.handled) { - tracking.handled = true; - tracking.pageId = delivery.pageId; - return false; - } - - if (shouldIgnoreDuplicateReady(delivery, tracking)) { - return true; - } - - tracking.pageId = delivery.pageId; - await replayDuplicateWebviewReady(createReadyState(context), context); - return true; -} - -function markWebviewReadyCompleted( - tracking: WebviewReadyTracking, - isWebviewReadyMessage: boolean, -): void { - if (isWebviewReadyMessage) { - tracking.completedAt = Date.now(); - } -} - -function createGraphViewWebviewMessageHandler( - webview: vscode.Webview, - context: GraphViewMessageListenerContext, -): (message: WebviewToExtensionMessage) => Promise { - const webviewReadyTracking: WebviewReadyTracking = { handled: false }; - - return async function handleGraphViewWebviewMessage(message: WebviewToExtensionMessage): Promise { - const isWebviewReadyMessage = message.type === 'WEBVIEW_READY'; - if (isWebviewReadyMessage) { - const delivery = getWebviewReadyDelivery(message); - if (await handleWebviewReadyMessage(context, delivery, webviewReadyTracking)) { - return; - } - } - - const primaryResult = await dispatchGraphViewPrimaryMessage(message, { - ...context, - asWebviewUri: uri => webview.asWebviewUri(uri), - }); - if (applyGraphViewPrimaryMessageResult(primaryResult, context)) { - markWebviewReadyCompleted(webviewReadyTracking, isWebviewReadyMessage); - return; - } - - const pluginResult = await dispatchGraphViewPluginMessage(message, context); - applyGraphViewPluginMessageResult(pluginResult, context); - if (isWebviewReadyMessage && pluginResult.handled) { - markWebviewReadyCompleted(webviewReadyTracking, isWebviewReadyMessage); - } - }; -} - export function setGraphViewWebviewMessageListener( webview: vscode.Webview, context: GraphViewMessageListenerContext, diff --git a/packages/extension/src/extension/graphView/webview/messages/webviewListener/contracts.ts b/packages/extension/src/extension/graphView/webview/messages/webviewListener/contracts.ts new file mode 100644 index 000000000..b00c643c2 --- /dev/null +++ b/packages/extension/src/extension/graphView/webview/messages/webviewListener/contracts.ts @@ -0,0 +1,36 @@ +import type { WebviewToExtensionMessage } from '../../../../../shared/protocol/webviewToExtension'; +import type { IGroup } from '../../../../../shared/settings/groups'; +import type { + dispatchGraphViewPluginMessage, + GraphViewPluginMessageContext, +} from '../../dispatch/plugin'; +import type { + dispatchGraphViewPrimaryMessage, + GraphViewPrimaryMessageContext, +} from '../../dispatch/primary'; + +export interface GraphViewMessageListenerContext + extends GraphViewPrimaryMessageContext, + GraphViewPluginMessageContext { + reprocessPluginFiles(pluginIds: readonly string[]): Promise; + setUserGroups(groups: IGroup[]): void; + setFilterPatterns(patterns: string[]): void; + setWebviewReadyNotified(nextValue: boolean): void; +} + +export type GraphViewPrimaryMessageResult = + Awaited>; +export type GraphViewPluginMessageResult = + Awaited>; +export type WebviewReadyMessage = Extract; + +export interface WebviewReadyDelivery { + pageId?: string; + postedAt?: number; +} + +export interface WebviewReadyTracking { + completedAt?: number; + handled: boolean; + pageId?: string; +} diff --git a/packages/extension/src/extension/graphView/webview/messages/webviewListener/handler.ts b/packages/extension/src/extension/graphView/webview/messages/webviewListener/handler.ts new file mode 100644 index 000000000..34e99730f --- /dev/null +++ b/packages/extension/src/extension/graphView/webview/messages/webviewListener/handler.ts @@ -0,0 +1,49 @@ +import type * as vscode from 'vscode'; +import type { WebviewToExtensionMessage } from '../../../../../shared/protocol/webviewToExtension'; +import { dispatchGraphViewPluginMessage } from '../../dispatch/plugin'; +import { dispatchGraphViewPrimaryMessage } from '../../dispatch/primary'; +import type { + GraphViewMessageListenerContext, + WebviewReadyTracking, +} from './contracts'; +import { + getWebviewReadyDelivery, + handleWebviewReadyMessage, + markWebviewReadyCompleted, +} from './ready'; +import { + applyGraphViewPluginMessageResult, + applyGraphViewPrimaryMessageResult, +} from './results'; + +export function createGraphViewWebviewMessageHandler( + webview: vscode.Webview, + context: GraphViewMessageListenerContext, +): (message: WebviewToExtensionMessage) => Promise { + const webviewReadyTracking: WebviewReadyTracking = { handled: false }; + + return async function handleGraphViewWebviewMessage(message: WebviewToExtensionMessage): Promise { + const isWebviewReadyMessage = message.type === 'WEBVIEW_READY'; + if (isWebviewReadyMessage) { + const delivery = getWebviewReadyDelivery(message); + if (await handleWebviewReadyMessage(context, delivery, webviewReadyTracking)) { + return; + } + } + + const primaryResult = await dispatchGraphViewPrimaryMessage(message, { + ...context, + asWebviewUri: uri => webview.asWebviewUri(uri), + }); + if (applyGraphViewPrimaryMessageResult(primaryResult, context)) { + markWebviewReadyCompleted(webviewReadyTracking, isWebviewReadyMessage); + return; + } + + const pluginResult = await dispatchGraphViewPluginMessage(message, context); + applyGraphViewPluginMessageResult(pluginResult, context); + if (isWebviewReadyMessage && pluginResult.handled) { + markWebviewReadyCompleted(webviewReadyTracking, isWebviewReadyMessage); + } + }; +} diff --git a/packages/extension/src/extension/graphView/webview/messages/webviewListener/ready.ts b/packages/extension/src/extension/graphView/webview/messages/webviewListener/ready.ts new file mode 100644 index 000000000..32c80b98a --- /dev/null +++ b/packages/extension/src/extension/graphView/webview/messages/webviewListener/ready.ts @@ -0,0 +1,52 @@ +import { replayDuplicateWebviewReady } from '../ready'; +import type { + GraphViewMessageListenerContext, + WebviewReadyDelivery, + WebviewReadyMessage, + WebviewReadyTracking, +} from './contracts'; +import { shouldIgnoreDuplicateReady } from './readyDuplicate'; +import { createReadyState } from './readyState'; + +export function getWebviewReadyDelivery(message: WebviewReadyMessage): WebviewReadyDelivery { + const payload = (message as { payload?: unknown }).payload; + if (!payload || typeof payload !== 'object') { + return {}; + } + + const pageId = (payload as { pageId?: unknown }).pageId; + const postedAt = (payload as { postedAt?: unknown }).postedAt; + return { + ...(typeof pageId === 'string' && pageId.length > 0 ? { pageId } : {}), + ...(typeof postedAt === 'number' && Number.isFinite(postedAt) ? { postedAt } : {}), + }; +} + +export async function handleWebviewReadyMessage( + context: GraphViewMessageListenerContext, + delivery: WebviewReadyDelivery, + tracking: WebviewReadyTracking, +): Promise { + if (!tracking.handled) { + tracking.handled = true; + tracking.pageId = delivery.pageId; + return false; + } + + if (shouldIgnoreDuplicateReady(delivery, tracking)) { + return true; + } + + tracking.pageId = delivery.pageId; + await replayDuplicateWebviewReady(createReadyState(context), context); + return true; +} + +export function markWebviewReadyCompleted( + tracking: WebviewReadyTracking, + isWebviewReadyMessage: boolean, +): void { + if (isWebviewReadyMessage) { + tracking.completedAt = Date.now(); + } +} diff --git a/packages/extension/src/extension/graphView/webview/messages/webviewListener/readyDuplicate.ts b/packages/extension/src/extension/graphView/webview/messages/webviewListener/readyDuplicate.ts new file mode 100644 index 000000000..e678f78aa --- /dev/null +++ b/packages/extension/src/extension/graphView/webview/messages/webviewListener/readyDuplicate.ts @@ -0,0 +1,25 @@ +import type { + WebviewReadyDelivery, + WebviewReadyTracking, +} from './contracts'; + +export function shouldIgnoreDuplicateReady( + delivery: WebviewReadyDelivery, + tracking: WebviewReadyTracking, +): boolean { + return isSameReadyPage(delivery, tracking) + || wasReadyPostedBeforeBootstrapCompleted(delivery, tracking); +} + +function isSameReadyPage(delivery: WebviewReadyDelivery, tracking: WebviewReadyTracking): boolean { + return delivery.pageId !== undefined && delivery.pageId === tracking.pageId; +} + +function wasReadyPostedBeforeBootstrapCompleted( + delivery: WebviewReadyDelivery, + tracking: WebviewReadyTracking, +): boolean { + return delivery.postedAt !== undefined + && tracking.completedAt !== undefined + && delivery.postedAt <= tracking.completedAt; +} diff --git a/packages/extension/src/extension/graphView/webview/messages/webviewListener/readyState.ts b/packages/extension/src/extension/graphView/webview/messages/webviewListener/readyState.ts new file mode 100644 index 000000000..03f1ef266 --- /dev/null +++ b/packages/extension/src/extension/graphView/webview/messages/webviewListener/readyState.ts @@ -0,0 +1,17 @@ +import type { GraphViewReadyState } from '../webviewReady/contracts'; +import type { GraphViewMessageListenerContext } from './contracts'; + +export function createReadyState(context: GraphViewMessageListenerContext): GraphViewReadyState { + return { + maxFiles: context.getMaxFiles(), + verboseDiagnostics: context.getConfig('verboseDiagnostics', false), + playbackSpeed: context.getPlaybackSpeed(), + depthMode: context.getDepthMode?.() ?? false, + dagMode: context.getDagMode(), + nodeSizeMode: context.getNodeSizeMode(), + focusedFile: context.getFocusedFile(), + hasWorkspace: context.hasWorkspace(), + firstAnalysis: context.isFirstAnalysis(), + readyNotified: context.isWebviewReadyNotified(), + }; +} diff --git a/packages/extension/src/extension/graphView/webview/messages/webviewListener/results.ts b/packages/extension/src/extension/graphView/webview/messages/webviewListener/results.ts new file mode 100644 index 000000000..1be1cb319 --- /dev/null +++ b/packages/extension/src/extension/graphView/webview/messages/webviewListener/results.ts @@ -0,0 +1,34 @@ +import type { + GraphViewMessageListenerContext, + GraphViewPluginMessageResult, + GraphViewPrimaryMessageResult, +} from './contracts'; + +export function applyGraphViewPrimaryMessageResult( + primaryResult: GraphViewPrimaryMessageResult, + context: GraphViewMessageListenerContext, +): boolean { + if (!primaryResult.handled) { + return false; + } + + if (primaryResult.userGroups !== undefined) { + context.setUserGroups(primaryResult.userGroups); + context.recomputeGroups(); + context.sendGroupsUpdated(); + } + if (primaryResult.filterPatterns !== undefined) { + context.setFilterPatterns(primaryResult.filterPatterns); + } + + return true; +} + +export function applyGraphViewPluginMessageResult( + pluginResult: GraphViewPluginMessageResult, + context: GraphViewMessageListenerContext, +): void { + if (pluginResult.handled && pluginResult.readyNotified !== undefined) { + context.setWebviewReadyNotified(pluginResult.readyNotified); + } +} From d238000db6a288ad2368a084e714e5f7e3dcc26e Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 15:20:49 -0700 Subject: [PATCH 112/192] refactor: split visible graph scope mutation sites --- .../src/shared/visibleGraph/scope.ts | 181 +----------------- .../scope/edgeEndpointProjection.ts | 16 ++ .../visibleGraph/scope/edgePreference.ts | 37 ++++ .../visibleGraph/scope/edgeProjection.ts | 51 +++++ .../visibleGraph/scope/edgeSelection.ts | 63 ++++++ .../src/shared/visibleGraph/scope/edges.ts | 16 ++ 6 files changed, 186 insertions(+), 178 deletions(-) create mode 100644 packages/extension/src/shared/visibleGraph/scope/edgeEndpointProjection.ts create mode 100644 packages/extension/src/shared/visibleGraph/scope/edgePreference.ts create mode 100644 packages/extension/src/shared/visibleGraph/scope/edgeProjection.ts create mode 100644 packages/extension/src/shared/visibleGraph/scope/edgeSelection.ts create mode 100644 packages/extension/src/shared/visibleGraph/scope/edges.ts diff --git a/packages/extension/src/shared/visibleGraph/scope.ts b/packages/extension/src/shared/visibleGraph/scope.ts index e4fc7edde..04655877f 100644 --- a/packages/extension/src/shared/visibleGraph/scope.ts +++ b/packages/extension/src/shared/visibleGraph/scope.ts @@ -1,181 +1,10 @@ import type { IGraphData } from '../graph/contracts'; import type { VisibleGraphScopeConfig } from './contracts'; -import { filterEdgesToNodes, getDisabledTypes } from './model'; +import { getDisabledTypes } from './model'; import { getScopedSymbolDefinitions } from './scope/definitions'; +import { resolveScopedEdges } from './scope/edges'; import { nodeMatchesScope } from './scope/nodes'; -function getEdgeContainingFileKey( - edge: IGraphData['edges'][number], - nodeById: ReadonlyMap, -): string { - const fromNode = nodeById.get(edge.from); - const toNode = nodeById.get(edge.to); - const fromFile = fromNode?.symbol?.filePath ?? edge.from; - const toFile = toNode?.symbol?.filePath ?? edge.to; - - return `${edge.kind}\0${fromFile}\0${toFile}`; -} - -interface ScopedEdgeCandidate { - edge: IGraphData['edges'][number]; - endpointPreference?: number; - key?: string; -} - -function rememberBestEndpointPreference( - bestEndpointPreferenceByKey: Map, - key: string, - endpointPreference: number, -): void { - const currentEndpointPreference = bestEndpointPreferenceByKey.get(key); - bestEndpointPreferenceByKey.set( - key, - currentEndpointPreference === undefined - ? endpointPreference - : Math.max(currentEndpointPreference, endpointPreference), - ); -} - -function createScopedEdgeCandidate( - edge: IGraphData['edges'][number], - nodeById: ReadonlyMap, - bestEndpointPreferenceByKey: Map, -): ScopedEdgeCandidate { - if (edge.kind === 'contains') { - return { edge }; - } - - const key = getEdgeContainingFileKey(edge, nodeById); - const endpointPreference = getEndpointPreference(edge, nodeById); - rememberBestEndpointPreference(bestEndpointPreferenceByKey, key, endpointPreference); - return { edge, endpointPreference, key }; -} - -function shouldKeepScopedEdgeCandidate( - candidate: ScopedEdgeCandidate, - bestEndpointPreferenceByKey: ReadonlyMap, -): boolean { - return !candidate.key - || candidate.endpointPreference === (bestEndpointPreferenceByKey.get(candidate.key) ?? candidate.endpointPreference); -} - -function keepMostSpecificUniqueEdges( - nodes: IGraphData['nodes'], - edges: IGraphData['edges'], -): IGraphData['edges'] { - const nodeById = new Map(nodes.map((node) => [node.id, node])); - const bestEndpointPreferenceByKey = new Map(); - const candidates: ScopedEdgeCandidate[] = []; - - for (const edge of edges) { - candidates.push(createScopedEdgeCandidate(edge, nodeById, bestEndpointPreferenceByKey)); - } - - const seenEdgeIds = new Set(); - const uniqueEdges: IGraphData['edges'] = []; - - for (const candidate of candidates) { - if (!shouldKeepScopedEdgeCandidate(candidate, bestEndpointPreferenceByKey)) { - continue; - } - - if (seenEdgeIds.has(candidate.edge.id)) { - continue; - } - - seenEdgeIds.add(candidate.edge.id); - uniqueEdges.push(candidate.edge); - } - - return uniqueEdges; -} - -function getEndpointPreference( - edge: IGraphData['edges'][number], - nodeById: ReadonlyMap, -): number { - const fromNode = nodeById.get(edge.from); - const toNode = nodeById.get(edge.to); - const endpointSpecificity = Number(Boolean(fromNode?.symbol)) + Number(Boolean(toNode?.symbol)); - if (edge.kind === 'type-import') { - return -endpointSpecificity; - } - - return endpointSpecificity; -} - -function getEdgeKindSuffix(edge: IGraphData['edges'][number]): string { - const marker = edge.id.lastIndexOf('#'); - return marker >= 0 ? edge.id.slice(marker) : `#${edge.kind}`; -} - -function projectEdgeToVisibleNodes( - edge: IGraphData['edges'][number], - allNodeById: ReadonlyMap, - visibleNodeIds: ReadonlySet, -): IGraphData['edges'][number] | undefined { - if (edge.kind === 'contains') { - return edge; - } - - const from = projectEndpointToVisibleNode(edge.from, allNodeById, visibleNodeIds); - const to = projectEndpointToVisibleNode(edge.to, allNodeById, visibleNodeIds); - if (!from || !to) { - return undefined; - } - - if (from === edge.from && to === edge.to) { - return edge; - } - - if (from === to) { - return undefined; - } - - return { - ...edge, - id: `${from}->${to}${getEdgeKindSuffix(edge)}`, - from, - to, - }; -} - -function projectEndpointToVisibleNode( - nodeId: string, - allNodeById: ReadonlyMap, - visibleNodeIds: ReadonlySet, -): string | undefined { - if (visibleNodeIds.has(nodeId)) { - return nodeId; - } - - const containingFilePath = allNodeById.get(nodeId)?.symbol?.filePath; - if (containingFilePath && visibleNodeIds.has(containingFilePath)) { - return containingFilePath; - } - - return undefined; -} - -function projectEdgesToVisibleNodes( - edges: IGraphData['edges'], - graphNodes: IGraphData['nodes'], - visibleNodes: IGraphData['nodes'], -): IGraphData['edges'] { - const allNodeById = new Map(graphNodes.map((node) => [node.id, node])); - const visibleNodeIds = new Set(visibleNodes.map((node) => node.id)); - const projectedEdges: IGraphData['edges'] = []; - - for (const edge of edges) { - const projectedEdge = projectEdgeToVisibleNodes(edge, allNodeById, visibleNodeIds); - if (projectedEdge) { - projectedEdges.push(projectedEdge); - } - } - - return projectedEdges; -} - export function applyGraphScope( graphData: IGraphData, scope: VisibleGraphScopeConfig, @@ -189,12 +18,8 @@ export function applyGraphScope( scopedSymbolDefinitions, )); const scopedEdges = graphData.edges.filter((edge) => !disabledEdgeTypes.has(edge.kind)); - const edges = keepMostSpecificUniqueEdges( - nodes, - projectEdgesToVisibleNodes(scopedEdges, graphData.nodes, nodes), - ); return { nodes, - edges: filterEdgesToNodes(edges, nodes), + edges: resolveScopedEdges(nodes, graphData.nodes, scopedEdges), }; } diff --git a/packages/extension/src/shared/visibleGraph/scope/edgeEndpointProjection.ts b/packages/extension/src/shared/visibleGraph/scope/edgeEndpointProjection.ts new file mode 100644 index 000000000..2792c3962 --- /dev/null +++ b/packages/extension/src/shared/visibleGraph/scope/edgeEndpointProjection.ts @@ -0,0 +1,16 @@ +import type { IGraphData } from '../../graph/contracts'; + +export function getVisibleEdgeEndpoint( + nodeId: string, + allNodeById: ReadonlyMap, + visibleNodeIds: ReadonlySet, +): string | undefined { + if (visibleNodeIds.has(nodeId)) { + return nodeId; + } + + const containingFilePath = allNodeById.get(nodeId)?.symbol?.filePath; + return containingFilePath && visibleNodeIds.has(containingFilePath) + ? containingFilePath + : undefined; +} diff --git a/packages/extension/src/shared/visibleGraph/scope/edgePreference.ts b/packages/extension/src/shared/visibleGraph/scope/edgePreference.ts new file mode 100644 index 000000000..5549fb370 --- /dev/null +++ b/packages/extension/src/shared/visibleGraph/scope/edgePreference.ts @@ -0,0 +1,37 @@ +import type { IGraphData } from '../../graph/contracts'; + +export function getEdgeContainingFileKey( + edge: IGraphData['edges'][number], + nodeById: ReadonlyMap, +): string { + const fromNode = nodeById.get(edge.from); + const toNode = nodeById.get(edge.to); + const fromFile = fromNode?.symbol?.filePath ?? edge.from; + const toFile = toNode?.symbol?.filePath ?? edge.to; + + return `${edge.kind}\0${fromFile}\0${toFile}`; +} + +export function getEndpointPreference( + edge: IGraphData['edges'][number], + nodeById: ReadonlyMap, +): number { + const fromNode = nodeById.get(edge.from); + const toNode = nodeById.get(edge.to); + const endpointSpecificity = Number(Boolean(fromNode?.symbol)) + Number(Boolean(toNode?.symbol)); + return edge.kind === 'type-import' ? -endpointSpecificity : endpointSpecificity; +} + +export function rememberBestEndpointPreference( + bestEndpointPreferenceByKey: Map, + key: string, + endpointPreference: number, +): void { + const currentEndpointPreference = bestEndpointPreferenceByKey.get(key); + bestEndpointPreferenceByKey.set( + key, + currentEndpointPreference === undefined + ? endpointPreference + : Math.max(currentEndpointPreference, endpointPreference), + ); +} diff --git a/packages/extension/src/shared/visibleGraph/scope/edgeProjection.ts b/packages/extension/src/shared/visibleGraph/scope/edgeProjection.ts new file mode 100644 index 000000000..d9ad46414 --- /dev/null +++ b/packages/extension/src/shared/visibleGraph/scope/edgeProjection.ts @@ -0,0 +1,51 @@ +import type { IGraphData } from '../../graph/contracts'; +import { getVisibleEdgeEndpoint } from './edgeEndpointProjection'; + +export function projectEdgesToVisibleNodes( + edges: IGraphData['edges'], + graphNodes: IGraphData['nodes'], + visibleNodes: IGraphData['nodes'], +): IGraphData['edges'] { + const allNodeById = new Map(graphNodes.map((node) => [node.id, node])); + const visibleNodeIds = new Set(visibleNodes.map((node) => node.id)); + const projectedEdges: IGraphData['edges'] = []; + + for (const edge of edges) { + const projectedEdge = projectEdgeToVisibleNodes(edge, allNodeById, visibleNodeIds); + if (projectedEdge) { + projectedEdges.push(projectedEdge); + } + } + + return projectedEdges; +} + +function projectEdgeToVisibleNodes( + edge: IGraphData['edges'][number], + allNodeById: ReadonlyMap, + visibleNodeIds: ReadonlySet, +): IGraphData['edges'][number] | undefined { + if (edge.kind === 'contains') { + return edge; + } + + const from = getVisibleEdgeEndpoint(edge.from, allNodeById, visibleNodeIds); + const to = getVisibleEdgeEndpoint(edge.to, allNodeById, visibleNodeIds); + if (!from || !to || from === to) { + return undefined; + } + + return from === edge.from && to === edge.to + ? edge + : { + ...edge, + id: `${from}->${to}${getEdgeKindSuffix(edge)}`, + from, + to, + }; +} + +function getEdgeKindSuffix(edge: IGraphData['edges'][number]): string { + const marker = edge.id.lastIndexOf('#'); + return marker >= 0 ? edge.id.slice(marker) : `#${edge.kind}`; +} diff --git a/packages/extension/src/shared/visibleGraph/scope/edgeSelection.ts b/packages/extension/src/shared/visibleGraph/scope/edgeSelection.ts new file mode 100644 index 000000000..76140813a --- /dev/null +++ b/packages/extension/src/shared/visibleGraph/scope/edgeSelection.ts @@ -0,0 +1,63 @@ +import type { IGraphData } from '../../graph/contracts'; +import { + getEdgeContainingFileKey, + getEndpointPreference, + rememberBestEndpointPreference, +} from './edgePreference'; + +interface ScopedEdgeCandidate { + edge: IGraphData['edges'][number]; + endpointPreference?: number; + key?: string; +} + +export function keepMostSpecificUniqueEdges( + nodes: IGraphData['nodes'], + edges: IGraphData['edges'], +): IGraphData['edges'] { + const nodeById = new Map(nodes.map((node) => [node.id, node])); + const bestEndpointPreferenceByKey = new Map(); + const candidates = edges.map(edge => + createScopedEdgeCandidate(edge, nodeById, bestEndpointPreferenceByKey), + ); + const seenEdgeIds = new Set(); + const uniqueEdges: IGraphData['edges'] = []; + + for (const candidate of candidates) { + if (!shouldKeepScopedEdgeCandidate(candidate, bestEndpointPreferenceByKey)) { + continue; + } + + if (seenEdgeIds.has(candidate.edge.id)) { + continue; + } + + seenEdgeIds.add(candidate.edge.id); + uniqueEdges.push(candidate.edge); + } + + return uniqueEdges; +} + +function createScopedEdgeCandidate( + edge: IGraphData['edges'][number], + nodeById: ReadonlyMap, + bestEndpointPreferenceByKey: Map, +): ScopedEdgeCandidate { + if (edge.kind === 'contains') { + return { edge }; + } + + const key = getEdgeContainingFileKey(edge, nodeById); + const endpointPreference = getEndpointPreference(edge, nodeById); + rememberBestEndpointPreference(bestEndpointPreferenceByKey, key, endpointPreference); + return { edge, endpointPreference, key }; +} + +function shouldKeepScopedEdgeCandidate( + candidate: ScopedEdgeCandidate, + bestEndpointPreferenceByKey: ReadonlyMap, +): boolean { + return !candidate.key + || candidate.endpointPreference === (bestEndpointPreferenceByKey.get(candidate.key) ?? candidate.endpointPreference); +} diff --git a/packages/extension/src/shared/visibleGraph/scope/edges.ts b/packages/extension/src/shared/visibleGraph/scope/edges.ts new file mode 100644 index 000000000..b51786658 --- /dev/null +++ b/packages/extension/src/shared/visibleGraph/scope/edges.ts @@ -0,0 +1,16 @@ +import type { IGraphData } from '../../graph/contracts'; +import { filterEdgesToNodes } from '../model'; +import { projectEdgesToVisibleNodes } from './edgeProjection'; +import { keepMostSpecificUniqueEdges } from './edgeSelection'; + +export function resolveScopedEdges( + nodes: IGraphData['nodes'], + graphNodes: IGraphData['nodes'], + scopedEdges: IGraphData['edges'], +): IGraphData['edges'] { + const edges = keepMostSpecificUniqueEdges( + nodes, + projectEdgesToVisibleNodes(scopedEdges, graphNodes, nodes), + ); + return filterEdgesToNodes(edges, nodes); +} From 03bee2f74a179efbee70490dc702a99621b956cc Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 15:24:17 -0700 Subject: [PATCH 113/192] refactor: split filtered graph hook mutation sites --- .../webview/search/filteredGraph/cacheKeys.ts | 58 ++++ .../search/filteredGraph/coloredResult.ts | 32 ++ .../search/filteredGraph/referenceCache.ts | 69 +++++ .../search/filteredGraph/styledResult.ts | 38 +++ .../search/filteredGraph/visibleCache.ts | 37 +++ .../search/filteredGraph/visibleResult.ts | 62 ++++ .../src/webview/search/useFilteredGraph.ts | 283 +----------------- 7 files changed, 303 insertions(+), 276 deletions(-) create mode 100644 packages/extension/src/webview/search/filteredGraph/cacheKeys.ts create mode 100644 packages/extension/src/webview/search/filteredGraph/coloredResult.ts create mode 100644 packages/extension/src/webview/search/filteredGraph/referenceCache.ts create mode 100644 packages/extension/src/webview/search/filteredGraph/styledResult.ts create mode 100644 packages/extension/src/webview/search/filteredGraph/visibleCache.ts create mode 100644 packages/extension/src/webview/search/filteredGraph/visibleResult.ts diff --git a/packages/extension/src/webview/search/filteredGraph/cacheKeys.ts b/packages/extension/src/webview/search/filteredGraph/cacheKeys.ts new file mode 100644 index 000000000..b6a0d3037 --- /dev/null +++ b/packages/extension/src/webview/search/filteredGraph/cacheKeys.ts @@ -0,0 +1,58 @@ +import type { SearchOptions } from '../../components/searchBar/field/model'; +import type { + IGraphEdgeTypeDefinition, + IGraphNodeTypeDefinition, +} from '../../../shared/graphControls/contracts'; +import type { IGroup } from '../../../shared/settings/groups'; + +function sortedRecordEntries(record: Record): [string, TValue][] { + return Object.entries(record).sort(([left], [right]) => left.localeCompare(right)); +} + +export function createStyledGraphCacheKey({ + edgeTypes, + nodeColors, +}: { + edgeTypes: IGraphEdgeTypeDefinition[]; + nodeColors: Record; +}): string { + return JSON.stringify({ + edgeTypes: edgeTypes.map(({ defaultColor, id }) => [id, defaultColor]), + nodeColors: sortedRecordEntries(nodeColors), + }); +} + +export function createLegendGraphCacheKey(legends: IGroup[]): string { + return JSON.stringify(legends); +} + +export function createVisibleGraphCacheKey({ + edgeTypes, + edgeVisibility, + filterPatterns, + nodeTypes, + nodeVisibility, + searchOptions, + searchQuery, + showOrphans, +}: { + edgeTypes: IGraphEdgeTypeDefinition[]; + edgeVisibility: Record; + filterPatterns: readonly string[]; + nodeTypes: IGraphNodeTypeDefinition[]; + nodeVisibility: Record; + searchOptions: SearchOptions; + searchQuery: string; + showOrphans: boolean; +}): string { + return JSON.stringify({ + edgeTypes: edgeTypes.map(({ defaultVisible, id }) => [id, defaultVisible]), + edgeVisibility: sortedRecordEntries(edgeVisibility), + filterPatterns, + nodeTypes: nodeTypes.map(({ defaultVisible, id }) => [id, defaultVisible]), + nodeVisibility: sortedRecordEntries(nodeVisibility), + searchOptions, + searchQuery, + showOrphans, + }); +} diff --git a/packages/extension/src/webview/search/filteredGraph/coloredResult.ts b/packages/extension/src/webview/search/filteredGraph/coloredResult.ts new file mode 100644 index 000000000..a24cfc279 --- /dev/null +++ b/packages/extension/src/webview/search/filteredGraph/coloredResult.ts @@ -0,0 +1,32 @@ +import type { IGraphData } from '../../../shared/graph/contracts'; +import type { IGroup } from '../../../shared/settings/groups'; +import { applyLegendRules } from '../filtering/rules'; +import { cacheReferenceResult, getReferenceResult } from './referenceCache'; +import type { ReferenceResultCache } from './referenceCache'; + +export function getColoredGraphResult({ + cache, + filteredData, + key, + legends, +}: { + cache: ReferenceResultCache; + filteredData: IGraphData | null; + key: string; + legends: IGroup[]; +}): IGraphData | null { + if (!filteredData) { + return null; + } + + const cached = getReferenceResult(cache, filteredData, key); + if (cached) { + return cached; + } + + const result = applyLegendRules(filteredData, legends); + if (result) { + cacheReferenceResult(cache, filteredData, key, result); + } + return result; +} diff --git a/packages/extension/src/webview/search/filteredGraph/referenceCache.ts b/packages/extension/src/webview/search/filteredGraph/referenceCache.ts new file mode 100644 index 000000000..92c28e110 --- /dev/null +++ b/packages/extension/src/webview/search/filteredGraph/referenceCache.ts @@ -0,0 +1,69 @@ +const REFERENCE_RESULT_CACHE_LIMIT = 6; + +export interface ReferenceResultCache { + entries: Map; + nextReferenceId: number; + referenceIds: WeakMap; +} + +export function createReferenceResultCache(): ReferenceResultCache { + return { + entries: new Map(), + nextReferenceId: 1, + referenceIds: new WeakMap(), + }; +} + +export function getReferenceResult( + cache: ReferenceResultCache, + reference: object, + key: string, +): TValue | undefined { + return cache.entries.get(getReferenceResultCacheKey(cache, reference, key)); +} + +export function cacheReferenceResult( + cache: ReferenceResultCache, + reference: object, + key: string, + result: TValue, +): void { + const cacheKey = getReferenceResultCacheKey(cache, reference, key); + if (cache.entries.has(cacheKey)) { + cache.entries.delete(cacheKey); + } + + cache.entries.set(cacheKey, result); + + while (cache.entries.size > REFERENCE_RESULT_CACHE_LIMIT) { + const oldestKey = cache.entries.keys().next().value; + if (!oldestKey) { + return; + } + + cache.entries.delete(oldestKey); + } +} + +function getReferenceResultCacheKey( + cache: ReferenceResultCache, + reference: object, + key: string, +): string { + return `${getReferenceId(cache, reference)}:${key}`; +} + +function getReferenceId( + cache: ReferenceResultCache, + reference: object, +): number { + const existing = cache.referenceIds.get(reference); + if (existing !== undefined) { + return existing; + } + + const id = cache.nextReferenceId; + cache.nextReferenceId += 1; + cache.referenceIds.set(reference, id); + return id; +} diff --git a/packages/extension/src/webview/search/filteredGraph/styledResult.ts b/packages/extension/src/webview/search/filteredGraph/styledResult.ts new file mode 100644 index 000000000..50111cf87 --- /dev/null +++ b/packages/extension/src/webview/search/filteredGraph/styledResult.ts @@ -0,0 +1,38 @@ +import type { IGraphData } from '../../../shared/graph/contracts'; +import type { IGraphEdgeTypeDefinition } from '../../../shared/graphControls/contracts'; +import { applyEdgeTypeDefaultColors } from '../../graphControls/filtering/edges'; +import { applyNodeTypeColors, withResolvedNodeTypes } from '../../graphControls/filtering/nodes'; +import { withSharedEdgeTypeAliases } from '../visibleGraphConfig'; +import { cacheReferenceResult, getReferenceResult } from './referenceCache'; +import type { ReferenceResultCache } from './referenceCache'; + +export function getStyledGraphResult({ + cache, + edgeTypes, + graph, + key, + nodeColors, +}: { + cache: ReferenceResultCache; + edgeTypes: IGraphEdgeTypeDefinition[]; + graph: IGraphData | null; + key: string; + nodeColors: Record; +}): IGraphData | null { + if (!graph) { + return null; + } + + const cached = getReferenceResult(cache, graph, key); + if (cached) { + return cached; + } + + const edgeTypesForStyling = withSharedEdgeTypeAliases(edgeTypes); + const result = { + nodes: applyNodeTypeColors(withResolvedNodeTypes(graph.nodes), nodeColors), + edges: applyEdgeTypeDefaultColors(graph.edges, edgeTypesForStyling), + }; + cacheReferenceResult(cache, graph, key, result); + return result; +} diff --git a/packages/extension/src/webview/search/filteredGraph/visibleCache.ts b/packages/extension/src/webview/search/filteredGraph/visibleCache.ts new file mode 100644 index 000000000..870f3906b --- /dev/null +++ b/packages/extension/src/webview/search/filteredGraph/visibleCache.ts @@ -0,0 +1,37 @@ +import type { VisibleGraphResult } from '../../../shared/visibleGraph'; +import type { IGraphData } from '../../../shared/graph/contracts'; + +const VISIBLE_GRAPH_CACHE_LIMIT = 6; + +export interface VisibleGraphCache { + entries: Map; + graphData: IGraphData | null | undefined; +} + +export function createVisibleGraphCache(): VisibleGraphCache { + return { + entries: new Map(), + graphData: undefined, + }; +} + +export function cacheVisibleGraphResult( + cache: VisibleGraphCache, + key: string, + result: VisibleGraphResult, +): void { + if (cache.entries.has(key)) { + cache.entries.delete(key); + } + + cache.entries.set(key, result); + + while (cache.entries.size > VISIBLE_GRAPH_CACHE_LIMIT) { + const oldestKey = cache.entries.keys().next().value; + if (!oldestKey) { + return; + } + + cache.entries.delete(oldestKey); + } +} diff --git a/packages/extension/src/webview/search/filteredGraph/visibleResult.ts b/packages/extension/src/webview/search/filteredGraph/visibleResult.ts new file mode 100644 index 000000000..2970fcd27 --- /dev/null +++ b/packages/extension/src/webview/search/filteredGraph/visibleResult.ts @@ -0,0 +1,62 @@ +import type { SearchOptions } from '../../components/searchBar/field/model'; +import { deriveVisibleGraph } from '../../../shared/visibleGraph'; +import type { VisibleGraphResult } from '../../../shared/visibleGraph'; +import type { IGraphData } from '../../../shared/graph/contracts'; +import type { + IGraphEdgeTypeDefinition, + IGraphNodeTypeDefinition, +} from '../../../shared/graphControls/contracts'; +import { buildVisibleGraphConfig } from '../visibleGraphConfig'; +import { cacheVisibleGraphResult } from './visibleCache'; +import type { VisibleGraphCache } from './visibleCache'; + +export function getVisibleGraphResult({ + cache, + edgeTypes, + edgeVisibility, + filterPatterns, + graphData, + key, + nodeTypes, + nodeVisibility, + searchOptions, + searchQuery, + showOrphans, +}: { + cache: VisibleGraphCache; + edgeTypes: IGraphEdgeTypeDefinition[]; + edgeVisibility: Record; + filterPatterns: readonly string[]; + graphData: IGraphData | null; + key: string; + nodeTypes: IGraphNodeTypeDefinition[]; + nodeVisibility: Record; + searchOptions: SearchOptions; + searchQuery: string; + showOrphans: boolean; +}): VisibleGraphResult { + if (cache.graphData !== graphData) { + cache.graphData = graphData; + cache.entries.clear(); + } + + const cached = cache.entries.get(key); + if (cached) { + cache.entries.delete(key); + cache.entries.set(key, cached); + return cached; + } + + const result = deriveVisibleGraph(graphData, buildVisibleGraphConfig({ + edgeTypes, + edgeVisibility, + filterPatterns, + nodeTypes, + nodeVisibility, + searchOptions, + searchQuery, + showOrphans, + })); + cacheVisibleGraphResult(cache, key, result); + return result; +} diff --git a/packages/extension/src/webview/search/useFilteredGraph.ts b/packages/extension/src/webview/search/useFilteredGraph.ts index dfbf7a9d2..2f4025ea5 100644 --- a/packages/extension/src/webview/search/useFilteredGraph.ts +++ b/packages/extension/src/webview/search/useFilteredGraph.ts @@ -6,9 +6,6 @@ import { useMemo, useRef } from 'react'; import type { SearchOptions } from '../components/searchBar/field/model'; -import { applyLegendRules } from './filtering/rules'; -import { deriveVisibleGraph } from '../../shared/visibleGraph'; -import type { VisibleGraphResult } from '../../shared/visibleGraph'; import type { IGraphData } from '../../shared/graph/contracts'; import type { IGraphEdgeTypeDefinition, @@ -16,15 +13,13 @@ import type { } from '../../shared/graphControls/contracts'; import type { IGroup } from '../../shared/settings/groups'; import type { EdgeDecorationPayload } from '../../shared/plugins/decorations'; -import { - applyEdgeTypeDefaultColors, - filterVisibleEdgeDecorations, -} from '../graphControls/filtering/edges'; -import { applyNodeTypeColors, withResolvedNodeTypes } from '../graphControls/filtering/nodes'; -import { - buildVisibleGraphConfig, - withSharedEdgeTypeAliases, -} from './visibleGraphConfig'; +import { filterVisibleEdgeDecorations } from '../graphControls/filtering/edges'; +import { createLegendGraphCacheKey, createStyledGraphCacheKey, createVisibleGraphCacheKey } from './filteredGraph/cacheKeys'; +import { getColoredGraphResult } from './filteredGraph/coloredResult'; +import { createReferenceResultCache } from './filteredGraph/referenceCache'; +import { getStyledGraphResult } from './filteredGraph/styledResult'; +import { createVisibleGraphCache } from './filteredGraph/visibleCache'; +import { getVisibleGraphResult } from './filteredGraph/visibleResult'; export interface IFilteredGraph { /** Graph after node/edge search filtering (null when no graph data). */ @@ -37,270 +32,6 @@ export interface IFilteredGraph { regexError: string | null; } -const VISIBLE_GRAPH_CACHE_LIMIT = 6; - -interface VisibleGraphCache { - entries: Map; - graphData: IGraphData | null | undefined; -} - -interface ReferenceResultCache { - entries: Map; - nextReferenceId: number; - referenceIds: WeakMap; -} - -function createVisibleGraphCache(): VisibleGraphCache { - return { - entries: new Map(), - graphData: undefined, - }; -} - -function createReferenceResultCache(): ReferenceResultCache { - return { - entries: new Map(), - nextReferenceId: 1, - referenceIds: new WeakMap(), - }; -} - -function sortedRecordEntries(record: Record): [string, TValue][] { - return Object.entries(record).sort(([left], [right]) => left.localeCompare(right)); -} - -function createStyledGraphCacheKey({ - edgeTypes, - nodeColors, -}: { - edgeTypes: IGraphEdgeTypeDefinition[]; - nodeColors: Record; -}): string { - return JSON.stringify({ - edgeTypes: edgeTypes.map(({ defaultColor, id }) => [id, defaultColor]), - nodeColors: sortedRecordEntries(nodeColors), - }); -} - -function createLegendGraphCacheKey(legends: IGroup[]): string { - return JSON.stringify(legends); -} - -function createVisibleGraphCacheKey({ - edgeTypes, - edgeVisibility, - filterPatterns, - nodeTypes, - nodeVisibility, - searchOptions, - searchQuery, - showOrphans, -}: { - edgeTypes: IGraphEdgeTypeDefinition[]; - edgeVisibility: Record; - filterPatterns: readonly string[]; - nodeTypes: IGraphNodeTypeDefinition[]; - nodeVisibility: Record; - searchOptions: SearchOptions; - searchQuery: string; - showOrphans: boolean; -}): string { - return JSON.stringify({ - edgeTypes: edgeTypes.map(({ defaultVisible, id }) => [id, defaultVisible]), - edgeVisibility: sortedRecordEntries(edgeVisibility), - filterPatterns, - nodeTypes: nodeTypes.map(({ defaultVisible, id }) => [id, defaultVisible]), - nodeVisibility: sortedRecordEntries(nodeVisibility), - searchOptions, - searchQuery, - showOrphans, - }); -} - -function cacheVisibleGraphResult( - cache: VisibleGraphCache, - key: string, - result: VisibleGraphResult, -): void { - if (cache.entries.has(key)) { - cache.entries.delete(key); - } - - cache.entries.set(key, result); - - while (cache.entries.size > VISIBLE_GRAPH_CACHE_LIMIT) { - const oldestKey = cache.entries.keys().next().value; - if (!oldestKey) { - return; - } - - cache.entries.delete(oldestKey); - } -} - -function getReferenceId( - cache: ReferenceResultCache, - reference: object, -): number { - const existing = cache.referenceIds.get(reference); - if (existing !== undefined) { - return existing; - } - - const id = cache.nextReferenceId; - cache.nextReferenceId += 1; - cache.referenceIds.set(reference, id); - return id; -} - -function getReferenceResultCacheKey( - cache: ReferenceResultCache, - reference: object, - key: string, -): string { - return `${getReferenceId(cache, reference)}:${key}`; -} - -function getReferenceResult( - cache: ReferenceResultCache, - reference: object, - key: string, -): TValue | undefined { - return cache.entries.get(getReferenceResultCacheKey(cache, reference, key)); -} - -function cacheReferenceResult( - cache: ReferenceResultCache, - reference: object, - key: string, - result: TValue, -): void { - const cacheKey = getReferenceResultCacheKey(cache, reference, key); - if (cache.entries.has(cacheKey)) { - cache.entries.delete(cacheKey); - } - - cache.entries.set(cacheKey, result); - - while (cache.entries.size > VISIBLE_GRAPH_CACHE_LIMIT) { - const oldestKey = cache.entries.keys().next().value; - if (!oldestKey) { - return; - } - - cache.entries.delete(oldestKey); - } -} - -function getVisibleGraphResult({ - cache, - edgeTypes, - edgeVisibility, - filterPatterns, - graphData, - key, - nodeTypes, - nodeVisibility, - searchOptions, - searchQuery, - showOrphans, -}: { - cache: VisibleGraphCache; - edgeTypes: IGraphEdgeTypeDefinition[]; - edgeVisibility: Record; - filterPatterns: readonly string[]; - graphData: IGraphData | null; - key: string; - nodeTypes: IGraphNodeTypeDefinition[]; - nodeVisibility: Record; - searchOptions: SearchOptions; - searchQuery: string; - showOrphans: boolean; -}): VisibleGraphResult { - if (cache.graphData !== graphData) { - cache.graphData = graphData; - cache.entries.clear(); - } - - const cached = cache.entries.get(key); - if (cached) { - cache.entries.delete(key); - cache.entries.set(key, cached); - return cached; - } - - const result = deriveVisibleGraph(graphData, buildVisibleGraphConfig({ - edgeTypes, - edgeVisibility, - filterPatterns, - nodeTypes, - nodeVisibility, - searchOptions, - searchQuery, - showOrphans, - })); - cacheVisibleGraphResult(cache, key, result); - return result; -} - -function getStyledGraphResult({ - cache, - edgeTypes, - graph, - key, - nodeColors, -}: { - cache: ReferenceResultCache; - edgeTypes: IGraphEdgeTypeDefinition[]; - graph: IGraphData | null; - key: string; - nodeColors: Record; -}): IGraphData | null { - if (!graph) { - return null; - } - - const cached = getReferenceResult(cache, graph, key); - if (cached) { - return cached; - } - - const edgeTypesForStyling = withSharedEdgeTypeAliases(edgeTypes); - const result = { - nodes: applyNodeTypeColors(withResolvedNodeTypes(graph.nodes), nodeColors), - edges: applyEdgeTypeDefaultColors(graph.edges, edgeTypesForStyling), - }; - cacheReferenceResult(cache, graph, key, result); - return result; -} - -function getColoredGraphResult({ - cache, - filteredData, - key, - legends, -}: { - cache: ReferenceResultCache; - filteredData: IGraphData | null; - key: string; - legends: IGroup[]; -}): IGraphData | null { - if (!filteredData) { - return null; - } - - const cached = getReferenceResult(cache, filteredData, key); - if (cached) { - return cached; - } - - const result = applyLegendRules(filteredData, legends); - if (result) { - cacheReferenceResult(cache, filteredData, key, result); - } - return result; -} - /** * Derives the filtered + colored graph data. * Both memos recompute only when their specific inputs change. From 7aa378ca905b32793146622570badab8ed34f003 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 15:28:10 -0700 Subject: [PATCH 114/192] refactor: split typescript alias config mutation sites --- .../src/aliasImport/compilerOptions.ts | 154 +----------------- .../src/aliasImport/config/cache.ts | 45 +++++ .../src/aliasImport/config/discovery.ts | 22 +++ .../src/aliasImport/config/parseHost.ts | 21 +++ .../src/aliasImport/config/pathMappings.ts | 30 ++++ .../src/aliasImport/config/stamps.ts | 40 +++++ 6 files changed, 163 insertions(+), 149 deletions(-) create mode 100644 packages/plugin-typescript/src/aliasImport/config/cache.ts create mode 100644 packages/plugin-typescript/src/aliasImport/config/discovery.ts create mode 100644 packages/plugin-typescript/src/aliasImport/config/parseHost.ts create mode 100644 packages/plugin-typescript/src/aliasImport/config/pathMappings.ts create mode 100644 packages/plugin-typescript/src/aliasImport/config/stamps.ts diff --git a/packages/plugin-typescript/src/aliasImport/compilerOptions.ts b/packages/plugin-typescript/src/aliasImport/compilerOptions.ts index fc691336d..dba454e08 100644 --- a/packages/plugin-typescript/src/aliasImport/compilerOptions.ts +++ b/packages/plugin-typescript/src/aliasImport/compilerOptions.ts @@ -1,26 +1,14 @@ -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import ts from 'typescript'; -import { comparePathMappingSpecificity, type TypeScriptPathMapping } from './pathMapping'; +import type { TypeScriptPathMapping } from './pathMapping'; +import { clearCompilerOptionsCache, readCompilerOptions } from './config/cache'; +import { findNearestTypeScriptConfig } from './config/discovery'; +import { createPathMappings } from './config/pathMappings'; export type TypeScriptAliasConfig = { paths: TypeScriptPathMapping[]; }; -type CompilerOptionsCacheEntry = { - configFileStamps: Map; - parsed: ts.ParsedCommandLine | null; -}; - -type FileStamp = { - mtimeMs: number; - size: number; -} | null; - -const compilerOptionsCache = new Map(); - export function clearTypeScriptAliasConfigCache(): void { - compilerOptionsCache.clear(); + clearCompilerOptionsCache(); } export function readTypeScriptAliasConfig(filePath: string, workspaceRoot: string): TypeScriptAliasConfig | null { @@ -38,135 +26,3 @@ export function readTypeScriptAliasConfig(filePath: string, workspaceRoot: strin paths: createPathMappings(parsed.options.paths, parsed.options, tsconfigPath, workspaceRoot), }; } - -function findNearestTypeScriptConfig(filePath: string, workspaceRoot: string): string | null { - const realWorkspaceRoot = fs.realpathSync.native(workspaceRoot); - let currentDirectory = fs.realpathSync.native(path.dirname(filePath)); - - while (currentDirectory === realWorkspaceRoot || currentDirectory.startsWith(`${realWorkspaceRoot}${path.sep}`)) { - const tsconfigPath = path.join(currentDirectory, 'tsconfig.json'); - if (fs.existsSync(tsconfigPath)) { - return tsconfigPath; - } - - const parentDirectory = path.dirname(currentDirectory); - if (parentDirectory === currentDirectory) { - return null; - } - currentDirectory = parentDirectory; - } - - return null; -} - -function readCompilerOptions(tsconfigPath: string): ts.ParsedCommandLine | null { - const cached = compilerOptionsCache.get(tsconfigPath); - if (cached && isCompilerOptionsCacheEntryFresh(cached)) { - return cached.parsed; - } - - const configFilePaths = new Set([normalizeConfigFilePath(tsconfigPath)]); - const readResult = ts.readConfigFile(tsconfigPath, fileName => { - configFilePaths.add(normalizeConfigFilePath(fileName)); - return ts.sys.readFile(fileName); - }); - const parsed = readResult.error - ? null - : ts.parseJsonConfigFileContent( - readResult.config, - createCompilerOptionsParseHost(configFilePaths), - path.dirname(tsconfigPath), - undefined, - tsconfigPath, - ); - - compilerOptionsCache.set(tsconfigPath, { - configFileStamps: createConfigFileStamps(configFilePaths), - parsed, - }); - - if (!parsed) { - return null; - } - - return parsed; -} - -function normalizeConfigFilePath(filePath: string): string { - return path.resolve(filePath); -} - -function getFileStamp(filePath: string): FileStamp { - try { - const stat = fs.statSync(filePath); - return { - mtimeMs: stat.mtimeMs, - size: stat.size, - }; - } catch { - return null; - } -} - -function areFileStampsEqual(left: FileStamp, right: FileStamp): boolean { - if (left === null || right === null) { - return left === right; - } - - return left.mtimeMs === right.mtimeMs && left.size === right.size; -} - -function createConfigFileStamps(filePaths: ReadonlySet): Map { - return new Map([...filePaths].map(filePath => [filePath, getFileStamp(filePath)])); -} - -function isCompilerOptionsCacheEntryFresh(entry: CompilerOptionsCacheEntry): boolean { - for (const [filePath, stamp] of entry.configFileStamps) { - if (!areFileStampsEqual(getFileStamp(filePath), stamp)) { - return false; - } - } - - return true; -} - -function createCompilerOptionsParseHost(configFilePaths: Set): ts.ParseConfigHost { - return { - directoryExists: directoryName => ts.sys.directoryExists?.(directoryName) ?? false, - fileExists: fileName => ts.sys.fileExists(fileName), - getCurrentDirectory: () => ts.sys.getCurrentDirectory(), - readDirectory: () => [], - readFile: fileName => { - configFilePaths.add(normalizeConfigFilePath(fileName)); - return ts.sys.readFile(fileName); - }, - realpath: pathName => ts.sys.realpath?.(pathName) ?? pathName, - useCaseSensitiveFileNames: ts.sys.useCaseSensitiveFileNames, - }; -} - -function createPathMappings( - paths: ts.MapLike, - options: ts.CompilerOptions, - tsconfigPath: string, - workspaceRoot: string, -): TypeScriptPathMapping[] { - const pathsBasePath = typeof options.pathsBasePath === 'string' - ? options.pathsBasePath - : undefined; - const baseUrl = toWorkspacePath(options.baseUrl ?? pathsBasePath ?? path.dirname(tsconfigPath), workspaceRoot); - return Object.entries(paths) - .map(([key, targets]) => ({ - baseUrl, - key, - targets, - })) - .sort(comparePathMappingSpecificity); -} - -function toWorkspacePath(candidate: string, workspaceRoot: string): string { - const realWorkspaceRoot = fs.realpathSync.native(workspaceRoot); - return candidate === realWorkspaceRoot || candidate.startsWith(`${realWorkspaceRoot}${path.sep}`) - ? path.join(workspaceRoot, path.relative(realWorkspaceRoot, candidate)) - : candidate; -} diff --git a/packages/plugin-typescript/src/aliasImport/config/cache.ts b/packages/plugin-typescript/src/aliasImport/config/cache.ts new file mode 100644 index 000000000..7f7af81de --- /dev/null +++ b/packages/plugin-typescript/src/aliasImport/config/cache.ts @@ -0,0 +1,45 @@ +import * as path from 'node:path'; +import ts from 'typescript'; +import { createCompilerOptionsParseHost, normalizeConfigFilePath } from './parseHost'; +import { areConfigFileStampsFresh, createConfigFileStamps } from './stamps'; +import type { FileStamp } from './stamps'; + +type CompilerOptionsCacheEntry = { + configFileStamps: Map; + parsed: ts.ParsedCommandLine | null; +}; + +const compilerOptionsCache = new Map(); + +export function clearCompilerOptionsCache(): void { + compilerOptionsCache.clear(); +} + +export function readCompilerOptions(tsconfigPath: string): ts.ParsedCommandLine | null { + const cached = compilerOptionsCache.get(tsconfigPath); + if (cached && areConfigFileStampsFresh(cached.configFileStamps)) { + return cached.parsed; + } + + const configFilePaths = new Set([normalizeConfigFilePath(tsconfigPath)]); + const readResult = ts.readConfigFile(tsconfigPath, fileName => { + configFilePaths.add(normalizeConfigFilePath(fileName)); + return ts.sys.readFile(fileName); + }); + const parsed = readResult.error + ? null + : ts.parseJsonConfigFileContent( + readResult.config, + createCompilerOptionsParseHost(configFilePaths), + path.dirname(tsconfigPath), + undefined, + tsconfigPath, + ); + + compilerOptionsCache.set(tsconfigPath, { + configFileStamps: createConfigFileStamps(configFilePaths), + parsed, + }); + + return parsed ?? null; +} diff --git a/packages/plugin-typescript/src/aliasImport/config/discovery.ts b/packages/plugin-typescript/src/aliasImport/config/discovery.ts new file mode 100644 index 000000000..2e81d13ff --- /dev/null +++ b/packages/plugin-typescript/src/aliasImport/config/discovery.ts @@ -0,0 +1,22 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +export function findNearestTypeScriptConfig(filePath: string, workspaceRoot: string): string | null { + const realWorkspaceRoot = fs.realpathSync.native(workspaceRoot); + let currentDirectory = fs.realpathSync.native(path.dirname(filePath)); + + while (currentDirectory === realWorkspaceRoot || currentDirectory.startsWith(`${realWorkspaceRoot}${path.sep}`)) { + const tsconfigPath = path.join(currentDirectory, 'tsconfig.json'); + if (fs.existsSync(tsconfigPath)) { + return tsconfigPath; + } + + const parentDirectory = path.dirname(currentDirectory); + if (parentDirectory === currentDirectory) { + return null; + } + currentDirectory = parentDirectory; + } + + return null; +} diff --git a/packages/plugin-typescript/src/aliasImport/config/parseHost.ts b/packages/plugin-typescript/src/aliasImport/config/parseHost.ts new file mode 100644 index 000000000..6cad4f2a3 --- /dev/null +++ b/packages/plugin-typescript/src/aliasImport/config/parseHost.ts @@ -0,0 +1,21 @@ +import * as path from 'node:path'; +import ts from 'typescript'; + +export function normalizeConfigFilePath(filePath: string): string { + return path.resolve(filePath); +} + +export function createCompilerOptionsParseHost(configFilePaths: Set): ts.ParseConfigHost { + return { + directoryExists: directoryName => ts.sys.directoryExists?.(directoryName) ?? false, + fileExists: fileName => ts.sys.fileExists(fileName), + getCurrentDirectory: () => ts.sys.getCurrentDirectory(), + readDirectory: () => [], + readFile: fileName => { + configFilePaths.add(normalizeConfigFilePath(fileName)); + return ts.sys.readFile(fileName); + }, + realpath: pathName => ts.sys.realpath?.(pathName) ?? pathName, + useCaseSensitiveFileNames: ts.sys.useCaseSensitiveFileNames, + }; +} diff --git a/packages/plugin-typescript/src/aliasImport/config/pathMappings.ts b/packages/plugin-typescript/src/aliasImport/config/pathMappings.ts new file mode 100644 index 000000000..9661faf9b --- /dev/null +++ b/packages/plugin-typescript/src/aliasImport/config/pathMappings.ts @@ -0,0 +1,30 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import ts from 'typescript'; +import { comparePathMappingSpecificity, type TypeScriptPathMapping } from '../pathMapping'; + +export function createPathMappings( + paths: ts.MapLike, + options: ts.CompilerOptions, + tsconfigPath: string, + workspaceRoot: string, +): TypeScriptPathMapping[] { + const pathsBasePath = typeof options.pathsBasePath === 'string' + ? options.pathsBasePath + : undefined; + const baseUrl = toWorkspacePath(options.baseUrl ?? pathsBasePath ?? path.dirname(tsconfigPath), workspaceRoot); + return Object.entries(paths) + .map(([key, targets]) => ({ + baseUrl, + key, + targets, + })) + .sort(comparePathMappingSpecificity); +} + +function toWorkspacePath(candidate: string, workspaceRoot: string): string { + const realWorkspaceRoot = fs.realpathSync.native(workspaceRoot); + return candidate === realWorkspaceRoot || candidate.startsWith(`${realWorkspaceRoot}${path.sep}`) + ? path.join(workspaceRoot, path.relative(realWorkspaceRoot, candidate)) + : candidate; +} diff --git a/packages/plugin-typescript/src/aliasImport/config/stamps.ts b/packages/plugin-typescript/src/aliasImport/config/stamps.ts new file mode 100644 index 000000000..6271f1072 --- /dev/null +++ b/packages/plugin-typescript/src/aliasImport/config/stamps.ts @@ -0,0 +1,40 @@ +import * as fs from 'node:fs'; + +export type FileStamp = { + mtimeMs: number; + size: number; +} | null; + +export function createConfigFileStamps(filePaths: ReadonlySet): Map { + return new Map([...filePaths].map(filePath => [filePath, getFileStamp(filePath)])); +} + +export function areConfigFileStampsFresh(stamps: ReadonlyMap): boolean { + for (const [filePath, stamp] of stamps) { + if (!areFileStampsEqual(getFileStamp(filePath), stamp)) { + return false; + } + } + + return true; +} + +function getFileStamp(filePath: string): FileStamp { + try { + const stat = fs.statSync(filePath); + return { + mtimeMs: stat.mtimeMs, + size: stat.size, + }; + } catch { + return null; + } +} + +function areFileStampsEqual(left: FileStamp, right: FileStamp): boolean { + if (left === null || right === null) { + return left === right; + } + + return left.mtimeMs === right.mtimeMs && left.size === right.size; +} From 849d606c7b39aa1e3d070f55ef82720b4cfe3e33 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 15:30:18 -0700 Subject: [PATCH 115/192] refactor: split graph viewport shell mutation sites --- .../components/graph/viewport/shell.tsx | 98 +------------------ .../viewport/shell/accessibilityItems.ts | 70 +++++++++++++ .../viewport/shell/pluginViewportState.ts | 32 ++++++ 3 files changed, 105 insertions(+), 95 deletions(-) create mode 100644 packages/extension/src/webview/components/graph/viewport/shell/accessibilityItems.ts create mode 100644 packages/extension/src/webview/components/graph/viewport/shell/pluginViewportState.ts diff --git a/packages/extension/src/webview/components/graph/viewport/shell.tsx b/packages/extension/src/webview/components/graph/viewport/shell.tsx index 7150c32cf..f53561462 100644 --- a/packages/extension/src/webview/components/graph/viewport/shell.tsx +++ b/packages/extension/src/webview/components/graph/viewport/shell.tsx @@ -3,10 +3,7 @@ import { useEffect, useRef, useState, - type Dispatch, - type MutableRefObject, type ReactElement, - type SetStateAction, } from 'react'; import type { CoreGraphViewContributionSet } from '@codegraphy-dev/core'; import type { ThemeKind } from '../../../theme/useTheme'; @@ -24,16 +21,13 @@ import { publishGraphViewportScale as publishGraphViewportScaleChange } from './ import { buildRenderingRuntimeOptions } from './shell/runtimeOptions'; import { useGraphViewportModelOptions } from './shell/modelOptions'; import { createGraphViewportSurfaceProps } from './shell/surfaceProps'; +import { publishCurrentGraphAccessibilityItems } from './shell/accessibilityItems'; +import { publishPluginGraphViewViewportState } from './shell/pluginViewportState'; +import type { GraphViewport2dControls } from './shell/viewportState'; import { - createGraphViewViewportState, - type GraphViewport2dControls, -} from './shell/viewportState'; -import { - createGraphAccessibilityItems, type GraphAccessibilityItems, type GraphScreenProjector, } from './accessibility'; -import type { FGLink, FGNode } from '../model/build'; export interface GraphViewportShellProps { appearance?: GraphAppearance; @@ -48,92 +42,6 @@ export interface GraphViewportShellProps { viewState: GraphViewStoreState; } -function createGraphAccessibilitySignature( - nodes: readonly FGNode[], - links: readonly FGLink[], -): string { - const nodeSignature = nodes - .map(node => `${node.id}:${node.size}:${Number.isFinite(node.x) && Number.isFinite(node.y) ? 'ready' : 'pending'}`) - .join('|'); - const linkSignature = links - .map(link => `${link.id}:${resolveLinkEndpoint(link.source)}:${resolveLinkEndpoint(link.target)}`) - .join('|'); - - return `${nodeSignature}::${linkSignature}`; -} - -function resolveLinkEndpoint(endpoint: string | FGNode): string { - return typeof endpoint === 'string' ? endpoint : endpoint.id; -} - -function areGraphAccessibilityNodePositionsReady(nodes: readonly FGNode[]): boolean { - return nodes.every(node => Number.isFinite(node.x) && Number.isFinite(node.y)); -} - -function publishPluginGraphViewViewportState({ - globalScale, - graph, - graphMode, - nodes, - pluginHost, - timelineActive, -}: { - globalScale: number; - graph: GraphViewport2dControls | undefined; - graphMode: GraphViewStoreState['graphMode']; - nodes: readonly FGNode[]; - pluginHost: WebviewPluginHost | undefined; - timelineActive: boolean; -}): void { - if (!pluginHost || pluginHost.hasGraphViewViewportConsumers?.() === false) { - return; - } - - pluginHost.setGraphViewViewportState(createGraphViewViewportState({ - globalScale, - graph, - graphMode, - nodes: [...nodes], - timelineActive, - })); -} - -function publishCurrentGraphAccessibilityItems({ - accessibilityDirtyRef, - graph, - graphMode, - lastAccessibilitySignatureRef, - links, - nodes, - setAccessibilityItems, -}: { - accessibilityDirtyRef: MutableRefObject; - graph: GraphScreenProjector | undefined; - graphMode: GraphViewStoreState['graphMode']; - lastAccessibilitySignatureRef: MutableRefObject; - links: readonly FGLink[]; - nodes: readonly FGNode[]; - setAccessibilityItems: Dispatch>; -}): void { - if (graphMode !== '2d' || !accessibilityDirtyRef.current) { - return; - } - - if (!areGraphAccessibilityNodePositionsReady(nodes)) { - return; - } - - const signature = createGraphAccessibilitySignature(nodes, links); - if (signature === lastAccessibilitySignatureRef.current) { - accessibilityDirtyRef.current = false; - return; - } - - lastAccessibilitySignatureRef.current = signature; - setAccessibilityItems(createGraphAccessibilityItems(nodes, links, graph)); - accessibilityDirtyRef.current = false; -} - export function GraphViewportShell({ appearance, callbacks, diff --git a/packages/extension/src/webview/components/graph/viewport/shell/accessibilityItems.ts b/packages/extension/src/webview/components/graph/viewport/shell/accessibilityItems.ts new file mode 100644 index 000000000..b1af41674 --- /dev/null +++ b/packages/extension/src/webview/components/graph/viewport/shell/accessibilityItems.ts @@ -0,0 +1,70 @@ +import type { + Dispatch, + MutableRefObject, + SetStateAction, +} from 'react'; +import type { GraphViewStoreState } from '../../view/store'; +import { + createGraphAccessibilityItems, + type GraphAccessibilityItems, + type GraphScreenProjector, +} from '../accessibility'; +import type { FGLink, FGNode } from '../../model/build'; + +export function publishCurrentGraphAccessibilityItems({ + accessibilityDirtyRef, + graph, + graphMode, + lastAccessibilitySignatureRef, + links, + nodes, + setAccessibilityItems, +}: { + accessibilityDirtyRef: MutableRefObject; + graph: GraphScreenProjector | undefined; + graphMode: GraphViewStoreState['graphMode']; + lastAccessibilitySignatureRef: MutableRefObject; + links: readonly FGLink[]; + nodes: readonly FGNode[]; + setAccessibilityItems: Dispatch>; +}): void { + if (graphMode !== '2d' || !accessibilityDirtyRef.current) { + return; + } + + if (!areGraphAccessibilityNodePositionsReady(nodes)) { + return; + } + + const signature = createGraphAccessibilitySignature(nodes, links); + if (signature === lastAccessibilitySignatureRef.current) { + accessibilityDirtyRef.current = false; + return; + } + + lastAccessibilitySignatureRef.current = signature; + setAccessibilityItems(createGraphAccessibilityItems(nodes, links, graph)); + accessibilityDirtyRef.current = false; +} + +function createGraphAccessibilitySignature( + nodes: readonly FGNode[], + links: readonly FGLink[], +): string { + const nodeSignature = nodes + .map(node => `${node.id}:${node.size}:${Number.isFinite(node.x) && Number.isFinite(node.y) ? 'ready' : 'pending'}`) + .join('|'); + const linkSignature = links + .map(link => `${link.id}:${resolveLinkEndpoint(link.source)}:${resolveLinkEndpoint(link.target)}`) + .join('|'); + + return `${nodeSignature}::${linkSignature}`; +} + +function resolveLinkEndpoint(endpoint: string | FGNode): string { + return typeof endpoint === 'string' ? endpoint : endpoint.id; +} + +function areGraphAccessibilityNodePositionsReady(nodes: readonly FGNode[]): boolean { + return nodes.every(node => Number.isFinite(node.x) && Number.isFinite(node.y)); +} diff --git a/packages/extension/src/webview/components/graph/viewport/shell/pluginViewportState.ts b/packages/extension/src/webview/components/graph/viewport/shell/pluginViewportState.ts new file mode 100644 index 000000000..48a2773fa --- /dev/null +++ b/packages/extension/src/webview/components/graph/viewport/shell/pluginViewportState.ts @@ -0,0 +1,32 @@ +import type { WebviewPluginHost } from '../../../../pluginHost/manager'; +import type { GraphViewStoreState } from '../../view/store'; +import type { FGNode } from '../../model/build'; +import { createGraphViewViewportState, type GraphViewport2dControls } from './viewportState'; + +export function publishPluginGraphViewViewportState({ + globalScale, + graph, + graphMode, + nodes, + pluginHost, + timelineActive, +}: { + globalScale: number; + graph: GraphViewport2dControls | undefined; + graphMode: GraphViewStoreState['graphMode']; + nodes: readonly FGNode[]; + pluginHost: WebviewPluginHost | undefined; + timelineActive: boolean; +}): void { + if (!pluginHost || pluginHost.hasGraphViewViewportConsumers?.() === false) { + return; + } + + pluginHost.setGraphViewViewportState(createGraphViewViewportState({ + globalScale, + graph, + graphMode, + nodes: [...nodes], + timelineActive, + })); +} From 496e152d8a9ce701e9911975e42ce85e7a1290b7 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 15:33:03 -0700 Subject: [PATCH 116/192] refactor: split discovery path matching mutation sites --- .../core/src/discovery/defaultExcludedPath.ts | 33 +++++++++ packages/core/src/discovery/knownDirectory.ts | 12 +++ packages/core/src/discovery/pathExclusions.ts | 17 +++++ packages/core/src/discovery/pathMatching.ts | 74 ++----------------- .../core/src/discovery/pathNormalization.ts | 3 + 5 files changed, 70 insertions(+), 69 deletions(-) create mode 100644 packages/core/src/discovery/defaultExcludedPath.ts create mode 100644 packages/core/src/discovery/knownDirectory.ts create mode 100644 packages/core/src/discovery/pathExclusions.ts create mode 100644 packages/core/src/discovery/pathNormalization.ts diff --git a/packages/core/src/discovery/defaultExcludedPath.ts b/packages/core/src/discovery/defaultExcludedPath.ts new file mode 100644 index 000000000..ca4ceccf5 --- /dev/null +++ b/packages/core/src/discovery/defaultExcludedPath.ts @@ -0,0 +1,33 @@ +import { normalizeDiscoveryPath } from './pathNormalization.js'; + +const DEFAULT_EXCLUDE_SEGMENTS = new Set([ + 'node_modules', + 'dist', + 'build', + 'out', + '.git', + '.codegraphy', + '.turbo', + '.worktrees', + 'coverage', +]); + +const DEFAULT_EXCLUDE_BASENAMES = new Set([ + '.DS_Store', +]); + +const DEFAULT_EXCLUDE_SUFFIXES = [ + '.min.js', + '.bundle.js', + '.map', +]; + +export function isDefaultExcludedPath(relativePath: string): boolean { + const normalizedPath = normalizeDiscoveryPath(relativePath); + const segments = normalizedPath.split('/').filter(Boolean); + const basename = segments.at(-1) ?? normalizedPath; + + return segments.some(segment => DEFAULT_EXCLUDE_SEGMENTS.has(segment)) + || DEFAULT_EXCLUDE_BASENAMES.has(basename) + || DEFAULT_EXCLUDE_SUFFIXES.some(suffix => basename.endsWith(suffix)); +} diff --git a/packages/core/src/discovery/knownDirectory.ts b/packages/core/src/discovery/knownDirectory.ts new file mode 100644 index 000000000..e1b4572cc --- /dev/null +++ b/packages/core/src/discovery/knownDirectory.ts @@ -0,0 +1,12 @@ +import { normalizeDiscoveryPath } from './pathNormalization.js'; + +export function shouldSkipKnownDirectory(relativePath: string): boolean { + const normalizedRelative = normalizeDiscoveryPath(relativePath); + + return normalizedRelative === 'node_modules' + || normalizedRelative === '.git' + || normalizedRelative === '.codegraphy' + || normalizedRelative.startsWith('node_modules/') + || normalizedRelative.startsWith('.git/') + || normalizedRelative.startsWith('.codegraphy/'); +} diff --git a/packages/core/src/discovery/pathExclusions.ts b/packages/core/src/discovery/pathExclusions.ts new file mode 100644 index 000000000..20d9b2681 --- /dev/null +++ b/packages/core/src/discovery/pathExclusions.ts @@ -0,0 +1,17 @@ +export const DEFAULT_EXCLUDE = [ + '**/node_modules/**', + '**/dist/**', + '**/build/**', + '**/out/**', + '**/.git/**', + '**/.codegraphy/**', + '**/.turbo', + '**/.turbo/**', + '**/.worktrees', + '**/.worktrees/**', + '**/coverage/**', + '**/.DS_Store', + '**/*.min.js', + '**/*.bundle.js', + '**/*.map', +]; diff --git a/packages/core/src/discovery/pathMatching.ts b/packages/core/src/discovery/pathMatching.ts index 834017b3e..68f44ad38 100644 --- a/packages/core/src/discovery/pathMatching.ts +++ b/packages/core/src/discovery/pathMatching.ts @@ -1,63 +1,10 @@ -/** - * @fileoverview Path normalization and pattern matching helpers for discovery. - * @module core/discovery/pathMatching - */ - import { minimatch } from 'minimatch'; +import { normalizeDiscoveryPath } from './pathNormalization.js'; -export const DEFAULT_EXCLUDE = [ - '**/node_modules/**', - '**/dist/**', - '**/build/**', - '**/out/**', - '**/.git/**', - '**/.codegraphy/**', - '**/.turbo', - '**/.turbo/**', - '**/.worktrees', - '**/.worktrees/**', - '**/coverage/**', - '**/.DS_Store', - '**/*.min.js', - '**/*.bundle.js', - '**/*.map', -]; - -const DEFAULT_EXCLUDE_SEGMENTS = new Set([ - 'node_modules', - 'dist', - 'build', - 'out', - '.git', - '.codegraphy', - '.turbo', - '.worktrees', - 'coverage', -]); - -const DEFAULT_EXCLUDE_BASENAMES = new Set([ - '.DS_Store', -]); - -const DEFAULT_EXCLUDE_SUFFIXES = [ - '.min.js', - '.bundle.js', - '.map', -]; - -export function normalizeDiscoveryPath(relativePath: string): string { - return relativePath.replace(/\\/g, '/'); -} - -export function isDefaultExcludedPath(relativePath: string): boolean { - const normalizedPath = normalizeDiscoveryPath(relativePath); - const segments = normalizedPath.split('/').filter(Boolean); - const basename = segments.at(-1) ?? normalizedPath; - - return segments.some(segment => DEFAULT_EXCLUDE_SEGMENTS.has(segment)) - || DEFAULT_EXCLUDE_BASENAMES.has(basename) - || DEFAULT_EXCLUDE_SUFFIXES.some(suffix => basename.endsWith(suffix)); -} +export { DEFAULT_EXCLUDE } from './pathExclusions.js'; +export { isDefaultExcludedPath } from './defaultExcludedPath.js'; +export { normalizeDiscoveryPath } from './pathNormalization.js'; +export { shouldSkipKnownDirectory } from './knownDirectory.js'; export function matchesAnyPattern(relativePath: string, patterns: readonly string[]): boolean { const normalizedPath = normalizeDiscoveryPath(relativePath); @@ -66,14 +13,3 @@ export function matchesAnyPattern(relativePath: string, patterns: readonly strin minimatch(normalizedPath, pattern, { dot: true, matchBase: true }) ); } - -export function shouldSkipKnownDirectory(relativePath: string): boolean { - const normalizedRelative = normalizeDiscoveryPath(relativePath); - - return normalizedRelative === 'node_modules' - || normalizedRelative === '.git' - || normalizedRelative === '.codegraphy' - || normalizedRelative.startsWith('node_modules/') - || normalizedRelative.startsWith('.git/') - || normalizedRelative.startsWith('.codegraphy/'); -} diff --git a/packages/core/src/discovery/pathNormalization.ts b/packages/core/src/discovery/pathNormalization.ts new file mode 100644 index 000000000..61f7c5595 --- /dev/null +++ b/packages/core/src/discovery/pathNormalization.ts @@ -0,0 +1,3 @@ +export function normalizeDiscoveryPath(relativePath: string): string { + return relativePath.replace(/\\/g, '/'); +} From 814506cbf554bff1cb40b073afe97e8e50f6a559 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 15:35:13 -0700 Subject: [PATCH 117/192] refactor: split workspace refresh operation mutation sites --- .../workspaceFiles/refresh/operations.ts | 90 +++---------------- .../extension/workspaceFiles/refresh/paths.ts | 33 +++++++ .../workspaceFiles/refresh/recentSaves.ts | 36 ++++++++ .../workspaceFiles/refresh/renameEvents.ts | 20 +++++ 4 files changed, 102 insertions(+), 77 deletions(-) create mode 100644 packages/extension/src/extension/workspaceFiles/refresh/paths.ts create mode 100644 packages/extension/src/extension/workspaceFiles/refresh/recentSaves.ts create mode 100644 packages/extension/src/extension/workspaceFiles/refresh/renameEvents.ts diff --git a/packages/extension/src/extension/workspaceFiles/refresh/operations.ts b/packages/extension/src/extension/workspaceFiles/refresh/operations.ts index 9690b351a..cd32d9942 100644 --- a/packages/extension/src/extension/workspaceFiles/refresh/operations.ts +++ b/packages/extension/src/extension/workspaceFiles/refresh/operations.ts @@ -5,53 +5,23 @@ import { shouldIgnoreWorkspaceFileWatcherRefresh, } from '../ignore'; import { scheduleWorkspaceRefresh } from './scheduler'; +import { + emitWorkspaceRenameEvents, + getRenameFilePaths, +} from './renameEvents'; +import { + consumeRecentSavedDocumentPath, + rememberRecentSavedDocumentPath, +} from './recentSaves'; +import { + isGitignorePath, + refreshWorkspacePaths, +} from './paths'; type WorkspaceRenameFiles = vscode.FileRenameEvent['files']; type WorkspaceFileEventName = 'workspace:fileCreated' | 'workspace:fileDeleted'; const WORKSPACE_CONTENT_CHANGE_REFRESH_DELAY_MS = 32; -const WORKSPACE_FILE_OPERATION_REFRESH_DELAY_MS = 500; -const RECENT_SAVE_WATCHER_SUPPRESSION_MS = 1000; -const recentSavedDocumentPaths = new Map(); - -function normalizeFileWatcherPath(filePath: string): string { - return filePath.replace(/\\/g, '/'); -} - -function pruneRecentSavedDocumentPaths(now: number): void { - for (const [filePath, expiresAt] of recentSavedDocumentPaths) { - if (expiresAt < now) { - recentSavedDocumentPaths.delete(filePath); - } - } -} - -function isGitignorePath(filePath: string): boolean { - const normalizedPath = normalizeFileWatcherPath(filePath); - return normalizedPath.endsWith('/.gitignore') || normalizedPath === '.gitignore'; -} - -function includesGitignorePath(filePaths: readonly string[]): boolean { - return filePaths.some(isGitignorePath); -} - -function refreshWorkspacePaths( - provider: GraphViewProvider, - logMessage: string, - filePaths: readonly string[], -): string[] { - const refreshPaths = filePaths.filter(filePath => - !shouldIgnoreWorkspaceFileWatcherRefresh(filePath), - ); - - if (refreshPaths.length > 0) { - scheduleWorkspaceRefresh(provider, logMessage, refreshPaths, WORKSPACE_FILE_OPERATION_REFRESH_DELAY_MS, { - gitignoreRefresh: includesGitignorePath(refreshPaths), - }); - } - - return refreshPaths; -} export function refreshWorkspaceSavedDocument( provider: GraphViewProvider, @@ -61,12 +31,7 @@ export function refreshWorkspaceSavedDocument( return; } - const now = Date.now(); - pruneRecentSavedDocumentPaths(now); - recentSavedDocumentPaths.set( - normalizeFileWatcherPath(document.uri.fsPath), - now + RECENT_SAVE_WATCHER_SUPPRESSION_MS, - ); + rememberRecentSavedDocumentPath(document.uri.fsPath); refreshWorkspaceChangedPath( provider, '[CodeGraphy] File saved, refreshing graph', @@ -74,19 +39,6 @@ export function refreshWorkspaceSavedDocument( ); } -function consumeRecentSavedDocumentPath(filePath: string): boolean { - const now = Date.now(); - pruneRecentSavedDocumentPaths(now); - const normalizedPath = normalizeFileWatcherPath(filePath); - const expiresAt = recentSavedDocumentPaths.get(normalizedPath); - if (expiresAt === undefined) { - return false; - } - - recentSavedDocumentPaths.delete(normalizedPath); - return now <= expiresAt; -} - export function refreshWorkspaceChangedFileWatcherPath( provider: GraphViewProvider, logMessage: string, @@ -135,22 +87,6 @@ export function refreshWorkspaceFileOperation( } } -function getRenameFilePaths(files: WorkspaceRenameFiles): string[] { - return files.flatMap(file => [file.oldUri.fsPath, file.newUri.fsPath]); -} - -function emitWorkspaceRenameEvents( - provider: GraphViewProvider, - files: WorkspaceRenameFiles, -): void { - for (const file of files) { - provider.emitEvent('workspace:fileRenamed', { - oldPath: file.oldUri.fsPath, - newPath: file.newUri.fsPath, - }); - } -} - export function refreshWorkspaceRenameOperation( provider: GraphViewProvider, files: WorkspaceRenameFiles, diff --git a/packages/extension/src/extension/workspaceFiles/refresh/paths.ts b/packages/extension/src/extension/workspaceFiles/refresh/paths.ts new file mode 100644 index 000000000..345bfe87b --- /dev/null +++ b/packages/extension/src/extension/workspaceFiles/refresh/paths.ts @@ -0,0 +1,33 @@ +import type { GraphViewProvider } from '../../graphViewProvider'; +import { shouldIgnoreWorkspaceFileWatcherRefresh } from '../ignore'; +import { scheduleWorkspaceRefresh } from './scheduler'; +import { normalizeFileWatcherPath } from './recentSaves'; + +const WORKSPACE_FILE_OPERATION_REFRESH_DELAY_MS = 500; + +export function refreshWorkspacePaths( + provider: GraphViewProvider, + logMessage: string, + filePaths: readonly string[], +): string[] { + const refreshPaths = filePaths.filter(filePath => + !shouldIgnoreWorkspaceFileWatcherRefresh(filePath), + ); + + if (refreshPaths.length > 0) { + scheduleWorkspaceRefresh(provider, logMessage, refreshPaths, WORKSPACE_FILE_OPERATION_REFRESH_DELAY_MS, { + gitignoreRefresh: includesGitignorePath(refreshPaths), + }); + } + + return refreshPaths; +} + +export function isGitignorePath(filePath: string): boolean { + const normalizedPath = normalizeFileWatcherPath(filePath); + return normalizedPath.endsWith('/.gitignore') || normalizedPath === '.gitignore'; +} + +function includesGitignorePath(filePaths: readonly string[]): boolean { + return filePaths.some(isGitignorePath); +} diff --git a/packages/extension/src/extension/workspaceFiles/refresh/recentSaves.ts b/packages/extension/src/extension/workspaceFiles/refresh/recentSaves.ts new file mode 100644 index 000000000..aecb4858a --- /dev/null +++ b/packages/extension/src/extension/workspaceFiles/refresh/recentSaves.ts @@ -0,0 +1,36 @@ +const RECENT_SAVE_WATCHER_SUPPRESSION_MS = 1000; +const recentSavedDocumentPaths = new Map(); + +export function rememberRecentSavedDocumentPath(filePath: string): void { + const now = Date.now(); + pruneRecentSavedDocumentPaths(now); + recentSavedDocumentPaths.set( + normalizeFileWatcherPath(filePath), + now + RECENT_SAVE_WATCHER_SUPPRESSION_MS, + ); +} + +export function consumeRecentSavedDocumentPath(filePath: string): boolean { + const now = Date.now(); + pruneRecentSavedDocumentPaths(now); + const normalizedPath = normalizeFileWatcherPath(filePath); + const expiresAt = recentSavedDocumentPaths.get(normalizedPath); + if (expiresAt === undefined) { + return false; + } + + recentSavedDocumentPaths.delete(normalizedPath); + return now <= expiresAt; +} + +export function normalizeFileWatcherPath(filePath: string): string { + return filePath.replace(/\\/g, '/'); +} + +function pruneRecentSavedDocumentPaths(now: number): void { + for (const [filePath, expiresAt] of recentSavedDocumentPaths) { + if (expiresAt < now) { + recentSavedDocumentPaths.delete(filePath); + } + } +} diff --git a/packages/extension/src/extension/workspaceFiles/refresh/renameEvents.ts b/packages/extension/src/extension/workspaceFiles/refresh/renameEvents.ts new file mode 100644 index 000000000..1fc5928a9 --- /dev/null +++ b/packages/extension/src/extension/workspaceFiles/refresh/renameEvents.ts @@ -0,0 +1,20 @@ +import * as vscode from 'vscode'; +import type { GraphViewProvider } from '../../graphViewProvider'; + +type WorkspaceRenameFiles = vscode.FileRenameEvent['files']; + +export function getRenameFilePaths(files: WorkspaceRenameFiles): string[] { + return files.flatMap(file => [file.oldUri.fsPath, file.newUri.fsPath]); +} + +export function emitWorkspaceRenameEvents( + provider: GraphViewProvider, + files: WorkspaceRenameFiles, +): void { + for (const file of files) { + provider.emitEvent('workspace:fileRenamed', { + oldPath: file.oldUri.fsPath, + newPath: file.newUri.fsPath, + }); + } +} From 71a20a655ae880515188b91479fa9b8372ed9046 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 15:37:50 -0700 Subject: [PATCH 118/192] refactor: split godot text resource symbol mutation sites --- .../src/plugin/symbol/textResource/factory.ts | 31 ++++++ .../src/plugin/symbol/textResource/names.ts | 9 ++ .../src/plugin/symbol/textResource/scene.ts | 45 ++++++++ .../plugin/symbol/textResource/standalone.ts | 23 ++++ .../src/plugin/symbol/textResourceSymbols.ts | 100 +----------------- 5 files changed, 110 insertions(+), 98 deletions(-) create mode 100644 packages/plugin-godot/src/plugin/symbol/textResource/factory.ts create mode 100644 packages/plugin-godot/src/plugin/symbol/textResource/names.ts create mode 100644 packages/plugin-godot/src/plugin/symbol/textResource/scene.ts create mode 100644 packages/plugin-godot/src/plugin/symbol/textResource/standalone.ts diff --git a/packages/plugin-godot/src/plugin/symbol/textResource/factory.ts b/packages/plugin-godot/src/plugin/symbol/textResource/factory.ts new file mode 100644 index 000000000..712d1897e --- /dev/null +++ b/packages/plugin-godot/src/plugin/symbol/textResource/factory.ts @@ -0,0 +1,31 @@ +import type { IAnalysisSymbol } from '@codegraphy-dev/plugin-api'; +import { + GODOT_SYMBOL_SOURCE, + GODOT_TEXT_RESOURCE_LANGUAGE, +} from '../vocabulary'; + +export function createTextResourceSymbol( + relativeFilePath: string, + filePath: string, + name: string, + kind: string, + line: number, + pluginKind: string, +): IAnalysisSymbol { + return { + id: `${relativeFilePath}#${name}:${kind}:${line}`, + name, + kind, + filePath, + range: { + startLine: line, + startColumn: 1, + endLine: line, + }, + metadata: { + language: GODOT_TEXT_RESOURCE_LANGUAGE, + source: GODOT_SYMBOL_SOURCE, + pluginKind, + }, + }; +} diff --git a/packages/plugin-godot/src/plugin/symbol/textResource/names.ts b/packages/plugin-godot/src/plugin/symbol/textResource/names.ts new file mode 100644 index 000000000..4d38cae17 --- /dev/null +++ b/packages/plugin-godot/src/plugin/symbol/textResource/names.ts @@ -0,0 +1,9 @@ +import * as path from 'path'; + +export function toPascalName(relativeFilePath: string): string { + return path.parse(relativeFilePath).name + .split(/[^A-Za-z0-9]+/) + .filter(Boolean) + .map(part => part.charAt(0).toUpperCase() + part.slice(1)) + .join(''); +} diff --git a/packages/plugin-godot/src/plugin/symbol/textResource/scene.ts b/packages/plugin-godot/src/plugin/symbol/textResource/scene.ts new file mode 100644 index 000000000..ef3567a5a --- /dev/null +++ b/packages/plugin-godot/src/plugin/symbol/textResource/scene.ts @@ -0,0 +1,45 @@ +import type { IAnalysisSymbol } from '@codegraphy-dev/plugin-api'; +import type { GodotTextResourceTag } from '../../../textResource/types'; +import { GODOT_SYMBOL_PLUGIN_KIND } from '../vocabulary'; +import { toPascalName } from './names'; +import { createTextResourceSymbol } from './factory'; + +export function extractSceneSymbols( + filePath: string, + relativeFilePath: string, + tags: readonly GodotTextResourceTag[], +): IAnalysisSymbol[] { + const symbols: IAnalysisSymbol[] = []; + const rootNode = readRootSceneNode(tags); + const sceneName = rootNode?.fields.name ?? toPascalName(relativeFilePath); + + symbols.push(createTextResourceSymbol( + relativeFilePath, + filePath, + sceneName, + 'scene', + rootNode?.line ?? 1, + GODOT_SYMBOL_PLUGIN_KIND.scene, + )); + + for (const tag of tags) { + if (tag.name !== 'node' || !tag.fields.name) { + continue; + } + + symbols.push(createTextResourceSymbol( + relativeFilePath, + filePath, + tag.fields.name, + 'scene-node', + tag.line, + GODOT_SYMBOL_PLUGIN_KIND.sceneNode, + )); + } + + return symbols; +} + +function readRootSceneNode(tags: readonly GodotTextResourceTag[]): GodotTextResourceTag | undefined { + return tags.find(tag => tag.name === 'node' && !tag.fields.parent && tag.fields.name); +} diff --git a/packages/plugin-godot/src/plugin/symbol/textResource/standalone.ts b/packages/plugin-godot/src/plugin/symbol/textResource/standalone.ts new file mode 100644 index 000000000..f1c681274 --- /dev/null +++ b/packages/plugin-godot/src/plugin/symbol/textResource/standalone.ts @@ -0,0 +1,23 @@ +import type { IAnalysisSymbol } from '@codegraphy-dev/plugin-api'; +import type { GodotTextResourceTag } from '../../../textResource/types'; +import { GODOT_SYMBOL_PLUGIN_KIND } from '../vocabulary'; +import { toPascalName } from './names'; +import { createTextResourceSymbol } from './factory'; + +export function extractResourceSymbols( + filePath: string, + relativeFilePath: string, + tags: readonly GodotTextResourceTag[], +): IAnalysisSymbol[] { + const resourceTag = tags.find(tag => tag.name === 'gd_resource'); + return [ + createTextResourceSymbol( + relativeFilePath, + filePath, + toPascalName(relativeFilePath), + 'resource', + resourceTag?.line ?? 1, + GODOT_SYMBOL_PLUGIN_KIND.resource, + ), + ]; +} diff --git a/packages/plugin-godot/src/plugin/symbol/textResourceSymbols.ts b/packages/plugin-godot/src/plugin/symbol/textResourceSymbols.ts index 43d48ee0f..07165b933 100644 --- a/packages/plugin-godot/src/plugin/symbol/textResourceSymbols.ts +++ b/packages/plugin-godot/src/plugin/symbol/textResourceSymbols.ts @@ -1,108 +1,12 @@ import * as path from 'path'; import type { IAnalysisSymbol } from '@codegraphy-dev/plugin-api'; import { parseGodotTextResourceDocument } from '../../textResource/parser'; -import type { GodotTextResourceTag } from '../../textResource/types'; -import { - GODOT_SYMBOL_PLUGIN_KIND, - GODOT_SYMBOL_SOURCE, - GODOT_TEXT_RESOURCE_LANGUAGE, -} from './vocabulary'; +import { extractResourceSymbols } from './textResource/standalone'; +import { extractSceneSymbols } from './textResource/scene'; const SCENE_EXTENSIONS = new Set(['.tscn']); const RESOURCE_EXTENSIONS = new Set(['.tres']); -function toPascalName(relativeFilePath: string): string { - return path.parse(relativeFilePath).name - .split(/[^A-Za-z0-9]+/) - .filter(Boolean) - .map(part => part.charAt(0).toUpperCase() + part.slice(1)) - .join(''); -} - -function createTextResourceSymbol( - relativeFilePath: string, - filePath: string, - name: string, - kind: string, - line: number, - pluginKind: string, -): IAnalysisSymbol { - return { - id: `${relativeFilePath}#${name}:${kind}:${line}`, - name, - kind, - filePath, - range: { - startLine: line, - startColumn: 1, - endLine: line, - }, - metadata: { - language: GODOT_TEXT_RESOURCE_LANGUAGE, - source: GODOT_SYMBOL_SOURCE, - pluginKind, - }, - }; -} - -function readRootSceneNode(tags: readonly GodotTextResourceTag[]): GodotTextResourceTag | undefined { - return tags.find(tag => tag.name === 'node' && !tag.fields.parent && tag.fields.name); -} - -function extractSceneSymbols( - filePath: string, - relativeFilePath: string, - tags: readonly GodotTextResourceTag[], -): IAnalysisSymbol[] { - const symbols: IAnalysisSymbol[] = []; - const rootNode = readRootSceneNode(tags); - const sceneName = rootNode?.fields.name ?? toPascalName(relativeFilePath); - - symbols.push(createTextResourceSymbol( - relativeFilePath, - filePath, - sceneName, - 'scene', - rootNode?.line ?? 1, - GODOT_SYMBOL_PLUGIN_KIND.scene, - )); - - for (const tag of tags) { - if (tag.name !== 'node' || !tag.fields.name) { - continue; - } - - symbols.push(createTextResourceSymbol( - relativeFilePath, - filePath, - tag.fields.name, - 'scene-node', - tag.line, - GODOT_SYMBOL_PLUGIN_KIND.sceneNode, - )); - } - - return symbols; -} - -function extractResourceSymbols( - filePath: string, - relativeFilePath: string, - tags: readonly GodotTextResourceTag[], -): IAnalysisSymbol[] { - const resourceTag = tags.find(tag => tag.name === 'gd_resource'); - return [ - createTextResourceSymbol( - relativeFilePath, - filePath, - toPascalName(relativeFilePath), - 'resource', - resourceTag?.line ?? 1, - GODOT_SYMBOL_PLUGIN_KIND.resource, - ), - ]; -} - export function extractTextResourceSymbols( content: string, filePath: string, From 94d0f834748ab26789c175290ab71dc4ba8c6836 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 15:41:08 -0700 Subject: [PATCH 119/192] refactor: split full index analysis mutation sites --- .../graphView/provider/analysis/fullIndex.ts | 44 +++++++------------ .../provider/analysis/fullIndex/background.ts | 35 +++++++++++++++ .../analysis/fullIndex/cacheReplay.ts | 13 ++++++ 3 files changed, 63 insertions(+), 29 deletions(-) create mode 100644 packages/extension/src/extension/graphView/provider/analysis/fullIndex/background.ts create mode 100644 packages/extension/src/extension/graphView/provider/analysis/fullIndex/cacheReplay.ts diff --git a/packages/extension/src/extension/graphView/provider/analysis/fullIndex.ts b/packages/extension/src/extension/graphView/provider/analysis/fullIndex.ts index 1efa9e1b4..b8ca16aac 100644 --- a/packages/extension/src/extension/graphView/provider/analysis/fullIndex.ts +++ b/packages/extension/src/extension/graphView/provider/analysis/fullIndex.ts @@ -1,14 +1,9 @@ -interface FullIndexAnalysisLogger { - logError(message: string, error: unknown): void; -} +import { scheduleFullIndexBackgroundAnalysis } from './fullIndex/background'; -interface ReplayableCacheAnalyzer { - getIndexStatus?(): { freshness: string }; - loadCachedGraph?: unknown; -} +export { canReplayStaleCache } from './fullIndex/cacheReplay'; -interface ReplayableCacheSource { - _analyzer?: ReplayableCacheAnalyzer; +interface FullIndexAnalysisLogger { + logError(message: string, error: unknown): void; } export interface FullIndexAnalysisCoordinator { @@ -22,7 +17,7 @@ export interface FullIndexAnalysisCoordinator { waitForForegroundFullIndexAnalysis(): Promise; } -type FullIndexAnalysisKind = 'background' | 'foreground'; +export type FullIndexAnalysisKind = 'background' | 'foreground'; class FullIndexAnalysisCoordinatorState implements FullIndexAnalysisCoordinator { private _fullIndexAnalysisPromise: Promise | undefined; @@ -94,20 +89,16 @@ class FullIndexAnalysisCoordinatorState implements FullIndexAnalysisCoordinator runAnalysis: () => Promise, shouldStart: () => boolean = () => true, ): void { - if (this._scheduledBackgroundAnalysis !== undefined || this._fullIndexAnalysisPromise) { - return; - } - - this._scheduledBackgroundAnalysis = setTimeout(() => { - this._scheduledBackgroundAnalysis = undefined; - if (!shouldStart()) { - return; - } - - void this.runFullIndexAnalysis(runAnalysis, 'background').catch(error => { - this._dependencies.logError('[CodeGraphy] Background cache sync failed:', error); - }); - }, 0); + scheduleFullIndexBackgroundAnalysis({ + fullIndexAnalysisPromise: this._fullIndexAnalysisPromise, + logError: (message, error) => this._dependencies.logError(message, error), + runFullIndexAnalysis: (nextRunAnalysis, kind) => + this.runFullIndexAnalysis(nextRunAnalysis, kind), + scheduledBackgroundAnalysis: this._scheduledBackgroundAnalysis, + setScheduledBackgroundAnalysis: scheduledBackgroundAnalysis => { + this._scheduledBackgroundAnalysis = scheduledBackgroundAnalysis; + }, + }, runAnalysis, shouldStart); } async runAfterFullIndexAnalysis( @@ -124,8 +115,3 @@ export function createFullIndexAnalysisCoordinator( ): FullIndexAnalysisCoordinator { return new FullIndexAnalysisCoordinatorState(dependencies); } - -export function canReplayStaleCache(source: ReplayableCacheSource): boolean { - return source._analyzer?.getIndexStatus?.().freshness === 'stale' - && typeof source._analyzer.loadCachedGraph === 'function'; -} diff --git a/packages/extension/src/extension/graphView/provider/analysis/fullIndex/background.ts b/packages/extension/src/extension/graphView/provider/analysis/fullIndex/background.ts new file mode 100644 index 000000000..9a924f66f --- /dev/null +++ b/packages/extension/src/extension/graphView/provider/analysis/fullIndex/background.ts @@ -0,0 +1,35 @@ +import type { FullIndexAnalysisKind } from '../fullIndex'; + +interface FullIndexBackgroundScheduleState { + fullIndexAnalysisPromise: Promise | undefined; + logError(message: string, error: unknown): void; + runFullIndexAnalysis( + runAnalysis: () => Promise, + kind: FullIndexAnalysisKind, + ): Promise; + scheduledBackgroundAnalysis: ReturnType | undefined; + setScheduledBackgroundAnalysis( + scheduledBackgroundAnalysis: ReturnType | undefined, + ): void; +} + +export function scheduleFullIndexBackgroundAnalysis( + state: FullIndexBackgroundScheduleState, + runAnalysis: () => Promise, + shouldStart: () => boolean, +): void { + if (state.scheduledBackgroundAnalysis !== undefined || state.fullIndexAnalysisPromise) { + return; + } + + state.setScheduledBackgroundAnalysis(setTimeout(() => { + state.setScheduledBackgroundAnalysis(undefined); + if (!shouldStart()) { + return; + } + + void state.runFullIndexAnalysis(runAnalysis, 'background').catch(error => { + state.logError('[CodeGraphy] Background cache sync failed:', error); + }); + }, 0)); +} diff --git a/packages/extension/src/extension/graphView/provider/analysis/fullIndex/cacheReplay.ts b/packages/extension/src/extension/graphView/provider/analysis/fullIndex/cacheReplay.ts new file mode 100644 index 000000000..135eab66b --- /dev/null +++ b/packages/extension/src/extension/graphView/provider/analysis/fullIndex/cacheReplay.ts @@ -0,0 +1,13 @@ +interface ReplayableCacheAnalyzer { + getIndexStatus?(): { freshness: string }; + loadCachedGraph?: unknown; +} + +interface ReplayableCacheSource { + _analyzer?: ReplayableCacheAnalyzer; +} + +export function canReplayStaleCache(source: ReplayableCacheSource): boolean { + return source._analyzer?.getIndexStatus?.().freshness === 'stale' + && typeof source._analyzer.loadCachedGraph === 'function'; +} From 76823a9f546a0ff61a73965b99975ebfe3283771 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 15:45:49 -0700 Subject: [PATCH 120/192] refactor: split graph provider analysis mutation sites --- .../graphView/provider/analysis/handlers.ts | 32 +++----- .../provider/analysis/handlers/messages.ts | 42 ++++++++++ .../graphView/provider/analysis/methods.ts | 76 +++---------------- .../provider/analysis/methods/dependencies.ts | 66 ++++++++++++++++ 4 files changed, 128 insertions(+), 88 deletions(-) create mode 100644 packages/extension/src/extension/graphView/provider/analysis/handlers/messages.ts create mode 100644 packages/extension/src/extension/graphView/provider/analysis/methods/dependencies.ts diff --git a/packages/extension/src/extension/graphView/provider/analysis/handlers.ts b/packages/extension/src/extension/graphView/provider/analysis/handlers.ts index 4c07d9c3d..164d70b09 100644 --- a/packages/extension/src/extension/graphView/provider/analysis/handlers.ts +++ b/packages/extension/src/extension/graphView/provider/analysis/handlers.ts @@ -3,7 +3,11 @@ import type { GraphViewProviderAnalysisHandlers, GraphViewProviderAnalysisRequestHandlers, } from '../../analysis/lifecycle'; -import { sendGraphControlsUpdated } from '../../controls/send'; +import { + sendGraphDataUpdated, + sendGraphIndexStatusUpdated, + sendGraphNodeMetricsUpdated, +} from './handlers/messages'; import type { GraphViewProviderAnalysisMethodDependencies, GraphViewProviderAnalysisMethodsSource, @@ -36,22 +40,8 @@ export function createGraphViewProviderAnalysisHandlers( }, getRawGraphData: () => source._rawGraphData, getGraphData: () => source._graphData, - sendGraphDataUpdated: graphData => { - sendGraphControlsUpdated( - source._rawGraphData, - source._analyzer, - message => source._sendMessage(message), - undefined, - source._disabledPlugins, - ); - source._sendMessage({ type: 'GRAPH_DATA_UPDATED', payload: graphData }); - }, - sendGraphNodeMetricsUpdated: updates => { - source._sendMessage({ - type: 'GRAPH_NODE_METRICS_UPDATED', - payload: { nodes: updates }, - }); - }, + sendGraphDataUpdated: graphData => sendGraphDataUpdated(source, graphData), + sendGraphNodeMetricsUpdated: updates => sendGraphNodeMetricsUpdated(source, updates), sendDepthState: () => source._sendDepthState(), computeMergedGroups: () => source._computeMergedGroups(), sendGroupsUpdated: () => source._sendGroupsUpdated(), @@ -60,12 +50,8 @@ export function createGraphViewProviderAnalysisHandlers( sendPluginStatuses: () => source._sendPluginStatuses(), sendDecorations: () => source._sendDecorations(), sendContextMenuItems: () => source._sendContextMenuItems(), - sendGraphIndexStatusUpdated: (hasIndex, freshness, detail) => { - source._sendMessage({ - type: 'GRAPH_INDEX_STATUS_UPDATED', - payload: { hasIndex, freshness, detail }, - }); - }, + sendGraphIndexStatusUpdated: (hasIndex, freshness, detail) => + sendGraphIndexStatusUpdated(source, hasIndex, freshness, detail), sendIndexProgress: progress => { source._sendMessage({ type: 'GRAPH_INDEX_PROGRESS', payload: progress }); }, diff --git a/packages/extension/src/extension/graphView/provider/analysis/handlers/messages.ts b/packages/extension/src/extension/graphView/provider/analysis/handlers/messages.ts new file mode 100644 index 000000000..b58c66170 --- /dev/null +++ b/packages/extension/src/extension/graphView/provider/analysis/handlers/messages.ts @@ -0,0 +1,42 @@ +import type { IGraphData } from '../../../../../shared/graph/contracts'; +import type { ExtensionToWebviewMessage } from '../../../../../shared/protocol/extensionToWebview'; +import type { GraphViewProviderAnalysisHandlers } from '../../../analysis/lifecycle'; +import { sendGraphControlsUpdated } from '../../../controls/send'; +import type { GraphViewProviderAnalysisMethodsSource } from '../methods'; + +type GraphNodeMetricUpdates = Parameters>[0]; +type GraphIndexStatusUpdated = GraphViewProviderAnalysisHandlers['sendGraphIndexStatusUpdated']; + +export function sendGraphDataUpdated( + source: GraphViewProviderAnalysisMethodsSource, + graphData: IGraphData, +): void { + sendGraphControlsUpdated( + source._rawGraphData, + source._analyzer, + (message: ExtensionToWebviewMessage) => source._sendMessage(message), + undefined, + source._disabledPlugins, + ); + source._sendMessage({ type: 'GRAPH_DATA_UPDATED', payload: graphData }); +} + +export function sendGraphNodeMetricsUpdated( + source: GraphViewProviderAnalysisMethodsSource, + updates: GraphNodeMetricUpdates, +): void { + source._sendMessage({ + type: 'GRAPH_NODE_METRICS_UPDATED', + payload: { nodes: updates }, + }); +} + +export const sendGraphIndexStatusUpdated: ( + source: GraphViewProviderAnalysisMethodsSource, + ...args: Parameters +) => void = (source, hasIndex, freshness, detail) => { + source._sendMessage({ + type: 'GRAPH_INDEX_STATUS_UPDATED', + payload: { hasIndex, freshness, detail }, + }); +}; diff --git a/packages/extension/src/extension/graphView/provider/analysis/methods.ts b/packages/extension/src/extension/graphView/provider/analysis/methods.ts index 73a1e12d0..b3c06852a 100644 --- a/packages/extension/src/extension/graphView/provider/analysis/methods.ts +++ b/packages/extension/src/extension/graphView/provider/analysis/methods.ts @@ -1,19 +1,6 @@ -import * as vscode from 'vscode'; import type { IGraphData } from '../../../../shared/graph/contracts'; import type { ExtensionToWebviewMessage } from '../../../../shared/protocol/extensionToWebview'; -import { - executeGraphViewProviderAnalysis, - isGraphViewAbortError, - isGraphViewAnalysisStale, - markGraphViewWorkspaceReady, - runGraphViewProviderAnalysisRequest, - type GraphViewProviderAnalysisHandlers, - type GraphViewProviderAnalysisRequestHandlers, - type GraphViewProviderAnalysisState, -} from '../../analysis/lifecycle'; -import type { DiagnosticEventInput } from '@codegraphy-dev/core'; -import { getCodeGraphyConfiguration } from '../../../repoSettings/current'; -import { createExtensionDiagnosticLogger } from '../../../diagnostics/logger'; +import type { GraphViewProviderAnalysisState } from '../../analysis/lifecycle'; import { createGraphViewProviderAnalysisDelegates } from './delegates'; import { createGraphViewProviderWorkspaceReadyState, @@ -25,8 +12,17 @@ import { canReplayStaleCache, createFullIndexAnalysisCoordinator, } from './fullIndex'; +import { + createDefaultGraphViewProviderAnalysisMethodDependencies, + type GraphViewProviderAnalysisMethodDependencies, +} from './methods/dependencies'; -interface GraphViewProviderWorkspaceReadyRegistryLike { +export { + createDefaultGraphViewProviderAnalysisMethodDependencies, + type GraphViewProviderAnalysisMethodDependencies, +} from './methods/dependencies'; + +export interface GraphViewProviderWorkspaceReadyRegistryLike { notifyWorkspaceReady( graphData: IGraphData, disabledPlugins?: ReadonlySet, @@ -88,56 +84,6 @@ export interface GraphViewProviderAnalysisMethods { _isAbortError(error: unknown): boolean; } -export interface GraphViewProviderAnalysisMethodDependencies { - runAnalysisRequest: ( - state: GraphViewProviderAnalysisState, - handlers: GraphViewProviderAnalysisRequestHandlers, - ) => Promise; - executeAnalysis: ( - signal: AbortSignal, - requestId: number, - state: GraphViewProviderAnalysisState, - handlers: GraphViewProviderAnalysisHandlers, - ) => Promise; - markWorkspaceReady: ( - state: { - firstAnalysis: boolean; - resolveFirstWorkspaceReady: (() => void) | undefined; - }, - registry: GraphViewProviderWorkspaceReadyRegistryLike | undefined, - graphData: IGraphData, - disabledPlugins?: ReadonlySet, - ) => void; - isAnalysisStale: ( - signal: AbortSignal, - requestId: number, - currentRequestId: number, - ) => boolean; - isAbortError(error: unknown): boolean; - hasWorkspace(): boolean; - logError(message: string, error: unknown): void; - emitDiagnostic?(input: DiagnosticEventInput): void; -} - -export function createDefaultGraphViewProviderAnalysisMethodDependencies(): GraphViewProviderAnalysisMethodDependencies { - const diagnostics = createExtensionDiagnosticLogger({ - isEnabled: () => getCodeGraphyConfiguration().get('verboseDiagnostics', false), - }); - - return { - runAnalysisRequest: runGraphViewProviderAnalysisRequest, - executeAnalysis: executeGraphViewProviderAnalysis, - markWorkspaceReady: markGraphViewWorkspaceReady, - isAnalysisStale: isGraphViewAnalysisStale, - isAbortError: isGraphViewAbortError, - hasWorkspace: () => (vscode.workspace.workspaceFolders?.length ?? 0) > 0, - logError: (message, error) => { - console.error(message, error); - }, - emitDiagnostic: input => diagnostics.emit(input), - }; -} - export function createGraphViewProviderAnalysisMethods( source: GraphViewProviderAnalysisMethodsSource, dependencies: GraphViewProviderAnalysisMethodDependencies = diff --git a/packages/extension/src/extension/graphView/provider/analysis/methods/dependencies.ts b/packages/extension/src/extension/graphView/provider/analysis/methods/dependencies.ts new file mode 100644 index 000000000..a7d725adf --- /dev/null +++ b/packages/extension/src/extension/graphView/provider/analysis/methods/dependencies.ts @@ -0,0 +1,66 @@ +import * as vscode from 'vscode'; +import type { DiagnosticEventInput } from '@codegraphy-dev/core'; +import type { IGraphData } from '../../../../../shared/graph/contracts'; +import { + executeGraphViewProviderAnalysis, + isGraphViewAbortError, + isGraphViewAnalysisStale, + markGraphViewWorkspaceReady, + runGraphViewProviderAnalysisRequest, + type GraphViewProviderAnalysisHandlers, + type GraphViewProviderAnalysisRequestHandlers, + type GraphViewProviderAnalysisState, +} from '../../../analysis/lifecycle'; +import { createExtensionDiagnosticLogger } from '../../../../diagnostics/logger'; +import { getCodeGraphyConfiguration } from '../../../../repoSettings/current'; +import type { GraphViewProviderWorkspaceReadyRegistryLike } from '../methods'; + +export interface GraphViewProviderAnalysisMethodDependencies { + runAnalysisRequest: ( + state: GraphViewProviderAnalysisState, + handlers: GraphViewProviderAnalysisRequestHandlers, + ) => Promise; + executeAnalysis: ( + signal: AbortSignal, + requestId: number, + state: GraphViewProviderAnalysisState, + handlers: GraphViewProviderAnalysisHandlers, + ) => Promise; + markWorkspaceReady: ( + state: { + firstAnalysis: boolean; + resolveFirstWorkspaceReady: (() => void) | undefined; + }, + registry: GraphViewProviderWorkspaceReadyRegistryLike | undefined, + graphData: IGraphData, + disabledPlugins?: ReadonlySet, + ) => void; + isAnalysisStale: ( + signal: AbortSignal, + requestId: number, + currentRequestId: number, + ) => boolean; + isAbortError(error: unknown): boolean; + hasWorkspace(): boolean; + logError(message: string, error: unknown): void; + emitDiagnostic?(input: DiagnosticEventInput): void; +} + +export function createDefaultGraphViewProviderAnalysisMethodDependencies(): GraphViewProviderAnalysisMethodDependencies { + const diagnostics = createExtensionDiagnosticLogger({ + isEnabled: () => getCodeGraphyConfiguration().get('verboseDiagnostics', false), + }); + + return { + runAnalysisRequest: runGraphViewProviderAnalysisRequest, + executeAnalysis: executeGraphViewProviderAnalysis, + markWorkspaceReady: markGraphViewWorkspaceReady, + isAnalysisStale: isGraphViewAnalysisStale, + isAbortError: isGraphViewAbortError, + hasWorkspace: () => (vscode.workspace.workspaceFolders?.length ?? 0) > 0, + logError: (message, error) => { + console.error(message, error); + }, + emitDiagnostic: input => diagnostics.emit(input), + }; +} From 999aee6bf7098c591c8f341b8c1098e32802cd60 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 15:48:11 -0700 Subject: [PATCH 121/192] refactor: split cached discovery mutation sites --- .../pipeline/service/cache/cachedDiscovery.ts | 53 ++----------------- .../cache/cachedDiscovery/gitignore.ts | 51 ++++++++++++++++++ 2 files changed, 54 insertions(+), 50 deletions(-) create mode 100644 packages/extension/src/extension/pipeline/service/cache/cachedDiscovery/gitignore.ts diff --git a/packages/extension/src/extension/pipeline/service/cache/cachedDiscovery.ts b/packages/extension/src/extension/pipeline/service/cache/cachedDiscovery.ts index 7c0620411..37276b05a 100644 --- a/packages/extension/src/extension/pipeline/service/cache/cachedDiscovery.ts +++ b/packages/extension/src/extension/pipeline/service/cache/cachedDiscovery.ts @@ -1,6 +1,8 @@ -import { spawnSync } from 'node:child_process'; import * as path from 'node:path'; import type { IDiscoveredFile } from '@codegraphy-dev/core'; +import { collectCachedGitIgnoredPaths } from './cachedDiscovery/gitignore'; + +export { collectCachedGitIgnoredPaths } from './cachedDiscovery/gitignore'; export interface CachedWorkspaceDiscoveryState { directories: string[]; @@ -34,55 +36,6 @@ export function collectCachedDirectoryPaths(filePaths: readonly string[]): strin return [...directories].sort(); } -function toGitPath(relativePath: string): string { - return relativePath.split(path.sep).join('/'); -} - -function createCachedGitPathLookup(relativePaths: readonly string[]): Map { - return new Map(relativePaths.map(relativePath => [toGitPath(relativePath), relativePath])); -} - -function createGitCheckIgnoreInput(pathsByGitPath: ReadonlyMap): string { - return `${[...pathsByGitPath.keys()].join('\n')}\n`; -} - -function didGitCheckIgnoreFail(result: ReturnType): boolean { - return Boolean(result.error) || (result.status !== 0 && result.status !== 1); -} - -function readGitIgnoredCachedPaths( - stdout: string, - pathsByGitPath: ReadonlyMap, -): string[] { - return stdout - .split(/\r?\n/) - .filter(Boolean) - .map(gitPath => pathsByGitPath.get(gitPath) ?? gitPath); -} - -export function collectCachedGitIgnoredPaths( - workspaceRoot: string, - relativePaths: readonly string[], - respectGitignore: boolean, -): string[] { - if (!respectGitignore || relativePaths.length === 0) { - return []; - } - - const pathsByGitPath = createCachedGitPathLookup(relativePaths); - - const result = spawnSync('git', ['-C', workspaceRoot, 'check-ignore', '--stdin'], { - encoding: 'utf8', - input: createGitCheckIgnoreInput(pathsByGitPath), - }); - - if (didGitCheckIgnoreFail(result)) { - return []; - } - - return readGitIgnoredCachedPaths(result.stdout, pathsByGitPath); -} - export function createCachedWorkspaceDiscoveryState( workspaceRoot: string, filePaths: readonly string[], diff --git a/packages/extension/src/extension/pipeline/service/cache/cachedDiscovery/gitignore.ts b/packages/extension/src/extension/pipeline/service/cache/cachedDiscovery/gitignore.ts new file mode 100644 index 000000000..3ee602c17 --- /dev/null +++ b/packages/extension/src/extension/pipeline/service/cache/cachedDiscovery/gitignore.ts @@ -0,0 +1,51 @@ +import { spawnSync } from 'node:child_process'; +import * as path from 'node:path'; + +function toGitPath(relativePath: string): string { + return relativePath.split(path.sep).join('/'); +} + +function createCachedGitPathLookup(relativePaths: readonly string[]): Map { + return new Map(relativePaths.map(relativePath => [toGitPath(relativePath), relativePath])); +} + +function createGitCheckIgnoreInput(pathsByGitPath: ReadonlyMap): string { + return `${[...pathsByGitPath.keys()].join('\n')}\n`; +} + +function didGitCheckIgnoreFail(result: ReturnType): boolean { + return Boolean(result.error) || (result.status !== 0 && result.status !== 1); +} + +function readGitIgnoredCachedPaths( + stdout: string, + pathsByGitPath: ReadonlyMap, +): string[] { + return stdout + .split(/\r?\n/) + .filter(Boolean) + .map(gitPath => pathsByGitPath.get(gitPath) ?? gitPath); +} + +export function collectCachedGitIgnoredPaths( + workspaceRoot: string, + relativePaths: readonly string[], + respectGitignore: boolean, +): string[] { + if (!respectGitignore || relativePaths.length === 0) { + return []; + } + + const pathsByGitPath = createCachedGitPathLookup(relativePaths); + + const result = spawnSync('git', ['-C', workspaceRoot, 'check-ignore', '--stdin'], { + encoding: 'utf8', + input: createGitCheckIgnoreInput(pathsByGitPath), + }); + + if (didGitCheckIgnoreFail(result)) { + return []; + } + + return readGitIgnoredCachedPaths(result.stdout, pathsByGitPath); +} From 42a8151bda5ed789df4c8ee61e0bfa5513719a45 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 15:53:02 -0700 Subject: [PATCH 122/192] refactor: split core mutation-heavy modules --- packages/core/src/globMatch.ts | 41 +--------------- packages/core/src/globRegex.ts | 40 +++++++++++++++ packages/core/src/graph/edgeTargetCache.ts | 48 ++++++++++++++++++ packages/core/src/graph/edges.ts | 49 ++----------------- .../src/graphCache/database/io/connection.ts | 31 +----------- .../core/src/graphCache/database/io/schema.ts | 32 ++++++++++++ packages/core/src/indexing/workspace.ts | 44 +---------------- .../core/src/indexing/workspace/timing.ts | 44 +++++++++++++++++ .../src/plugins/lifecycle/notify/analysis.ts | 30 +----------- .../src/plugins/lifecycle/notify/errors.ts | 3 ++ .../src/plugins/lifecycle/notify/files.ts | 25 ++++++++++ packages/core/src/visibleGraph/filter.ts | 44 +++-------------- .../core/src/visibleGraph/filterPatterns.ts | 44 +++++++++++++++++ 13 files changed, 253 insertions(+), 222 deletions(-) create mode 100644 packages/core/src/globRegex.ts create mode 100644 packages/core/src/graph/edgeTargetCache.ts create mode 100644 packages/core/src/graphCache/database/io/schema.ts create mode 100644 packages/core/src/indexing/workspace/timing.ts create mode 100644 packages/core/src/plugins/lifecycle/notify/errors.ts create mode 100644 packages/core/src/plugins/lifecycle/notify/files.ts create mode 100644 packages/core/src/visibleGraph/filterPatterns.ts diff --git a/packages/core/src/globMatch.ts b/packages/core/src/globMatch.ts index d9a1b0a08..ab9a217bd 100644 --- a/packages/core/src/globMatch.ts +++ b/packages/core/src/globMatch.ts @@ -1,43 +1,6 @@ -/** - * Convert a simple glob pattern to a RegExp. - * - * Rules: - * - `**` matches any path segments, including nested `/` - * - `*` matches anything except `/` - * - regex metacharacters are escaped - * - * Patterns are matched against the basename or path suffix, so `src/*` - * works anywhere in the tree while still keeping `*` and `**` semantics. - */ -export function globToRegex(pattern: string): RegExp { - let body = ''; - for (let index = 0; index < pattern.length; index += 1) { - const character = pattern[index]; - const nextCharacter = pattern[index + 1]; - const afterNextCharacter = pattern[index + 2]; +import { globToRegex } from './globRegex.js'; - if (character === '*' && nextCharacter === '*' && afterNextCharacter === '/') { - body += '(?:.*/)?'; - index += 2; - continue; - } - - if (character === '*' && nextCharacter === '*') { - body += '.*'; - index += 1; - continue; - } - - if (character === '*') { - body += '[^/]*'; - continue; - } - - body += character.replace(/([.+^${}()|[\]\\])/g, '\\$1'); - } - - return new RegExp(`(?:^|/)${body}$`); -} +export { globToRegex } from './globRegex.js'; export function createGlobMatcher(pattern: string): (filePath: string) => boolean { const regex = globToRegex(pattern); diff --git a/packages/core/src/globRegex.ts b/packages/core/src/globRegex.ts new file mode 100644 index 000000000..b1d31ef78 --- /dev/null +++ b/packages/core/src/globRegex.ts @@ -0,0 +1,40 @@ +/** + * Convert a simple glob pattern to a RegExp. + * + * Rules: + * - `**` matches any path segments, including nested `/` + * - `*` matches anything except `/` + * - regex metacharacters are escaped + * + * Patterns are matched against the basename or path suffix, so `src/*` + * works anywhere in the tree while still keeping `*` and `**` semantics. + */ +export function globToRegex(pattern: string): RegExp { + let body = ''; + for (let index = 0; index < pattern.length; index += 1) { + const character = pattern[index]; + const nextCharacter = pattern[index + 1]; + const afterNextCharacter = pattern[index + 2]; + + if (character === '*' && nextCharacter === '*' && afterNextCharacter === '/') { + body += '(?:.*/)?'; + index += 2; + continue; + } + + if (character === '*' && nextCharacter === '*') { + body += '.*'; + index += 1; + continue; + } + + if (character === '*') { + body += '[^/]*'; + continue; + } + + body += character.replace(/([.+^${}()|[\]\\])/g, '\\$1'); + } + + return new RegExp(`(?:^|/)${body}$`); +} diff --git a/packages/core/src/graph/edgeTargetCache.ts b/packages/core/src/graph/edgeTargetCache.ts new file mode 100644 index 000000000..e4e521ffc --- /dev/null +++ b/packages/core/src/graph/edgeTargetCache.ts @@ -0,0 +1,48 @@ +import type { IPlugin } from '@codegraphy-dev/plugin-api'; +import type { IProjectedConnection } from '../analysis/projectedConnection.js'; +import { getConnectionTargetId } from './edgeTargets.js'; + +export type ConnectionTargetResolver = typeof getConnectionTargetId; + +export function createCachedConnectionTargetResolver( + resolveConnectionTargetId: ConnectionTargetResolver, + fileConnections: ReadonlyMap, + workspaceRoot: string, +): (plugin: IPlugin | undefined, connection: IProjectedConnection) => string | null { + const targetIdByKey = new Map(); + + return (plugin, connection) => { + const cacheKey = createTargetCacheKey(plugin, connection); + if (cacheKey && targetIdByKey.has(cacheKey)) { + return targetIdByKey.get(cacheKey) ?? null; + } + + const targetId = resolveConnectionTargetId( + plugin, + connection, + fileConnections, + workspaceRoot, + ); + + if (cacheKey) { + targetIdByKey.set(cacheKey, targetId); + } + + return targetId; + }; +} + +function createTargetCacheKey( + plugin: IPlugin | undefined, + connection: IProjectedConnection, +): string | undefined { + if (connection.resolvedPath) { + return `${plugin?.id ?? ''}\0resolved\0${connection.resolvedPath}`; + } + + if (connection.specifier) { + return `${plugin?.id ?? ''}\0specifier\0${connection.specifier}`; + } + + return undefined; +} diff --git a/packages/core/src/graph/edges.ts b/packages/core/src/graph/edges.ts index 1540c7a9a..78714c6fe 100644 --- a/packages/core/src/graph/edges.ts +++ b/packages/core/src/graph/edges.ts @@ -10,8 +10,10 @@ import type { IGraphEdge } from './contracts'; import { createGraphEdgeId } from './edgeIdentity'; import { createEdgeSource } from './edgeSources'; import { getConnectionTargetId } from './edgeTargets'; - -type ConnectionTargetResolver = typeof getConnectionTargetId; +import { + createCachedConnectionTargetResolver, + type ConnectionTargetResolver, +} from './edgeTargetCache.js'; export interface IWorkspaceGraphEdgesOptions { disabledPlugins: ReadonlySet; @@ -94,49 +96,6 @@ function appendConnectionEdge( options.edgeMap.set(edgeId, edge); } -function createTargetCacheKey( - plugin: IPlugin | undefined, - connection: IProjectedConnection, -): string | undefined { - if (connection.resolvedPath) { - return `${plugin?.id ?? ''}\0resolved\0${connection.resolvedPath}`; - } - - if (connection.specifier) { - return `${plugin?.id ?? ''}\0specifier\0${connection.specifier}`; - } - - return undefined; -} - -function createCachedConnectionTargetResolver( - resolveConnectionTargetId: ConnectionTargetResolver, - fileConnections: ReadonlyMap, - workspaceRoot: string, -): (plugin: IPlugin | undefined, connection: IProjectedConnection) => string | null { - const targetIdByKey = new Map(); - - return (plugin, connection) => { - const cacheKey = createTargetCacheKey(plugin, connection); - if (cacheKey && targetIdByKey.has(cacheKey)) { - return targetIdByKey.get(cacheKey) ?? null; - } - - const targetId = resolveConnectionTargetId( - plugin, - connection, - fileConnections, - workspaceRoot, - ); - - if (cacheKey) { - targetIdByKey.set(cacheKey, targetId); - } - - return targetId; - }; -} - export function buildWorkspaceGraphEdges( options: IWorkspaceGraphEdgesOptions, ): IWorkspaceGraphEdgeBuildResult { diff --git a/packages/core/src/graphCache/database/io/connection.ts b/packages/core/src/graphCache/database/io/connection.ts index 04051df6d..f3d89edf7 100644 --- a/packages/core/src/graphCache/database/io/connection.ts +++ b/packages/core/src/graphCache/database/io/connection.ts @@ -1,6 +1,7 @@ import { Connection, Database } from '@ladybugdb/core'; import type * as lb from '@ladybugdb/core'; import type { FileAnalysisRow } from '../records/contracts'; +import { ensureSchema, ensureSchemaAsync } from './schema.js'; interface LadybugQueryResultLike { getAll?(): Promise; @@ -104,36 +105,6 @@ export async function readRowsAsync(connection: lb.Connection, statement: string } } -function ensureSchema(connection: lb.Connection): void { - runStatementSync( - connection, - 'CREATE NODE TABLE IF NOT EXISTS FileAnalysis(filePath STRING PRIMARY KEY, mtime INT64, size INT64, analysis STRING)', - ); - runStatementSync( - connection, - 'CREATE NODE TABLE IF NOT EXISTS Symbol(symbolId STRING PRIMARY KEY, filePath STRING, name STRING, kind STRING, signature STRING, rangeJson STRING, metadataJson STRING)', - ); - runStatementSync( - connection, - 'CREATE NODE TABLE IF NOT EXISTS Relation(relationId STRING PRIMARY KEY, filePath STRING, kind STRING, pluginId STRING, sourceId STRING, fromFilePath STRING, toFilePath STRING, fromNodeId STRING, toNodeId STRING, fromSymbolId STRING, toSymbolId STRING, specifier STRING, relationType STRING, variant STRING, resolvedPath STRING, metadataJson STRING)', - ); -} - -async function ensureSchemaAsync(connection: lb.Connection): Promise { - await runStatementAsync( - connection, - 'CREATE NODE TABLE IF NOT EXISTS FileAnalysis(filePath STRING PRIMARY KEY, mtime INT64, size INT64, analysis STRING)', - ); - await runStatementAsync( - connection, - 'CREATE NODE TABLE IF NOT EXISTS Symbol(symbolId STRING PRIMARY KEY, filePath STRING, name STRING, kind STRING, signature STRING, rangeJson STRING, metadataJson STRING)', - ); - await runStatementAsync( - connection, - 'CREATE NODE TABLE IF NOT EXISTS Relation(relationId STRING PRIMARY KEY, filePath STRING, kind STRING, pluginId STRING, sourceId STRING, fromFilePath STRING, toFilePath STRING, fromNodeId STRING, toNodeId STRING, fromSymbolId STRING, toSymbolId STRING, specifier STRING, relationType STRING, variant STRING, resolvedPath STRING, metadataJson STRING)', - ); -} - export function withConnection( databasePath: string, callback: (connection: lb.Connection) => T, diff --git a/packages/core/src/graphCache/database/io/schema.ts b/packages/core/src/graphCache/database/io/schema.ts new file mode 100644 index 000000000..4fe7b9638 --- /dev/null +++ b/packages/core/src/graphCache/database/io/schema.ts @@ -0,0 +1,32 @@ +import type * as lb from '@ladybugdb/core'; +import { runStatementAsync, runStatementSync } from './connection.js'; + +export function ensureSchema(connection: lb.Connection): void { + runStatementSync( + connection, + 'CREATE NODE TABLE IF NOT EXISTS FileAnalysis(filePath STRING PRIMARY KEY, mtime INT64, size INT64, analysis STRING)', + ); + runStatementSync( + connection, + 'CREATE NODE TABLE IF NOT EXISTS Symbol(symbolId STRING PRIMARY KEY, filePath STRING, name STRING, kind STRING, signature STRING, rangeJson STRING, metadataJson STRING)', + ); + runStatementSync( + connection, + 'CREATE NODE TABLE IF NOT EXISTS Relation(relationId STRING PRIMARY KEY, filePath STRING, kind STRING, pluginId STRING, sourceId STRING, fromFilePath STRING, toFilePath STRING, fromNodeId STRING, toNodeId STRING, fromSymbolId STRING, toSymbolId STRING, specifier STRING, relationType STRING, variant STRING, resolvedPath STRING, metadataJson STRING)', + ); +} + +export async function ensureSchemaAsync(connection: lb.Connection): Promise { + await runStatementAsync( + connection, + 'CREATE NODE TABLE IF NOT EXISTS FileAnalysis(filePath STRING PRIMARY KEY, mtime INT64, size INT64, analysis STRING)', + ); + await runStatementAsync( + connection, + 'CREATE NODE TABLE IF NOT EXISTS Symbol(symbolId STRING PRIMARY KEY, filePath STRING, name STRING, kind STRING, signature STRING, rangeJson STRING, metadataJson STRING)', + ); + await runStatementAsync( + connection, + 'CREATE NODE TABLE IF NOT EXISTS Relation(relationId STRING PRIMARY KEY, filePath STRING, kind STRING, pluginId STRING, sourceId STRING, fromFilePath STRING, toFilePath STRING, fromNodeId STRING, toNodeId STRING, fromSymbolId STRING, toSymbolId STRING, specifier STRING, relationType STRING, variant STRING, resolvedPath STRING, metadataJson STRING)', + ); +} diff --git a/packages/core/src/indexing/workspace.ts b/packages/core/src/indexing/workspace.ts index 76ef63e32..8816e087e 100644 --- a/packages/core/src/indexing/workspace.ts +++ b/packages/core/src/indexing/workspace.ts @@ -1,6 +1,4 @@ -import { performance } from 'node:perf_hooks'; import { createEmptyWorkspaceAnalysisCache } from '../analysis/cache'; -import { createDiagnosticEvent } from '../diagnostics/events'; import { FileDiscovery } from '../discovery/file/service'; import { buildWorkspacePipelineGraphFromAnalysis } from '../graph/build'; import { saveWorkspaceAnalysisDatabaseCache } from '../graphCache/database/storage'; @@ -12,6 +10,7 @@ import { discoverWorkspaceIndexFiles } from './discovery'; import { persistWorkspaceIndexMetadata } from './metadata'; import { createWorkspaceIndexRegistry } from './registry'; import { createEffectiveIndexSettings } from './settings'; +import { timeIndexPhase, timeIndexPhaseSync } from './workspace/timing.js'; export { createCodeGraphyWorkspaceEngine, type CodeGraphyWorkspaceEngine, @@ -33,47 +32,6 @@ export type { IndexCodeGraphyWorkspaceResult, } from './contracts'; -function emitIndexPhaseCompleted( - options: IndexCodeGraphyWorkspaceOptions, - phase: string, - durationMs: number, - context: Record = {}, -): void { - options.diagnostics?.emit(createDiagnosticEvent({ - area: 'indexing', - event: 'phase-completed', - context: { - phase, - durationMs: Math.round(durationMs), - ...context, - }, - })); -} - -async function timeIndexPhase( - options: IndexCodeGraphyWorkspaceOptions, - phase: string, - run: () => Promise, - createContext: (result: T) => Record = () => ({}), -): Promise { - const startedAt = performance.now(); - const result = await run(); - emitIndexPhaseCompleted(options, phase, performance.now() - startedAt, createContext(result)); - return result; -} - -function timeIndexPhaseSync( - options: IndexCodeGraphyWorkspaceOptions, - phase: string, - run: () => T, - createContext: (result: T) => Record = () => ({}), -): T { - const startedAt = performance.now(); - const result = run(); - emitIndexPhaseCompleted(options, phase, performance.now() - startedAt, createContext(result)); - return result; -} - export async function indexCodeGraphyWorkspace( options: IndexCodeGraphyWorkspaceOptions, ): Promise { diff --git a/packages/core/src/indexing/workspace/timing.ts b/packages/core/src/indexing/workspace/timing.ts new file mode 100644 index 000000000..3a3f79903 --- /dev/null +++ b/packages/core/src/indexing/workspace/timing.ts @@ -0,0 +1,44 @@ +import { performance } from 'node:perf_hooks'; +import { createDiagnosticEvent } from '../../diagnostics/events.js'; +import type { IndexCodeGraphyWorkspaceOptions } from '../contracts.js'; + +function emitIndexPhaseCompleted( + options: IndexCodeGraphyWorkspaceOptions, + phase: string, + durationMs: number, + context: Record = {}, +): void { + options.diagnostics?.emit(createDiagnosticEvent({ + area: 'indexing', + event: 'phase-completed', + context: { + phase, + durationMs: Math.round(durationMs), + ...context, + }, + })); +} + +export async function timeIndexPhase( + options: IndexCodeGraphyWorkspaceOptions, + phase: string, + run: () => Promise, + createContext: (result: T) => Record = () => ({}), +): Promise { + const startedAt = performance.now(); + const result = await run(); + emitIndexPhaseCompleted(options, phase, performance.now() - startedAt, createContext(result)); + return result; +} + +export function timeIndexPhaseSync( + options: IndexCodeGraphyWorkspaceOptions, + phase: string, + run: () => T, + createContext: (result: T) => Record = () => ({}), +): T { + const startedAt = performance.now(); + const result = run(); + emitIndexPhaseCompleted(options, phase, performance.now() - startedAt, createContext(result)); + return result; +} diff --git a/packages/core/src/plugins/lifecycle/notify/analysis.ts b/packages/core/src/plugins/lifecycle/notify/analysis.ts index f12c01895..5292545d8 100644 --- a/packages/core/src/plugins/lifecycle/notify/analysis.ts +++ b/packages/core/src/plugins/lifecycle/notify/analysis.ts @@ -1,39 +1,13 @@ import type { IPluginAnalysisContext } from '@codegraphy-dev/plugin-api'; import type { IGraphData } from '../../../graph/contracts'; import type { ILifecyclePluginInfo } from '../contracts'; +import { logLifecycleError } from './errors.js'; +import { getPluginFiles, type AnalyzeFile } from './files.js'; import { createWorkspacePluginAnalysisContext, withWorkspacePluginAnalysisOptions, } from '../../context/workspace'; -type AnalyzeFile = { - absolutePath: string; - relativePath: string; - content: string; -}; - -function pluginMatchesFile(info: ILifecyclePluginInfo, relativePath: string): boolean { - if (info.plugin.supportedExtensions.includes('*')) { - return true; - } - - const lowercasePath = relativePath.toLowerCase(); - return info.plugin.supportedExtensions.some((extension) => - lowercasePath.endsWith(extension.toLowerCase()), - ); -} - -function getPluginFiles( - info: ILifecyclePluginInfo, - files: AnalyzeFile[], -): AnalyzeFile[] { - return files.filter((file) => pluginMatchesFile(info, file.relativePath)); -} - -function logLifecycleError(hook: string, pluginId: string, error: unknown): void { - console.error(`[CodeGraphy] Error in ${hook} for ${pluginId}:`, error); -} - export async function notifyPreAnalyze( plugins: Map, files: AnalyzeFile[], diff --git a/packages/core/src/plugins/lifecycle/notify/errors.ts b/packages/core/src/plugins/lifecycle/notify/errors.ts new file mode 100644 index 000000000..1c2603907 --- /dev/null +++ b/packages/core/src/plugins/lifecycle/notify/errors.ts @@ -0,0 +1,3 @@ +export function logLifecycleError(hook: string, pluginId: string, error: unknown): void { + console.error(`[CodeGraphy] Error in ${hook} for ${pluginId}:`, error); +} diff --git a/packages/core/src/plugins/lifecycle/notify/files.ts b/packages/core/src/plugins/lifecycle/notify/files.ts new file mode 100644 index 000000000..08637e981 --- /dev/null +++ b/packages/core/src/plugins/lifecycle/notify/files.ts @@ -0,0 +1,25 @@ +import type { ILifecyclePluginInfo } from '../contracts.js'; + +export type AnalyzeFile = { + absolutePath: string; + relativePath: string; + content: string; +}; + +export function getPluginFiles( + info: ILifecyclePluginInfo, + files: AnalyzeFile[], +): AnalyzeFile[] { + return files.filter((file) => pluginMatchesFile(info, file.relativePath)); +} + +function pluginMatchesFile(info: ILifecyclePluginInfo, relativePath: string): boolean { + if (info.plugin.supportedExtensions.includes('*')) { + return true; + } + + const lowercasePath = relativePath.toLowerCase(); + return info.plugin.supportedExtensions.some((extension) => + lowercasePath.endsWith(extension.toLowerCase()), + ); +} diff --git a/packages/core/src/visibleGraph/filter.ts b/packages/core/src/visibleGraph/filter.ts index 0a57aaa77..cc04e607b 100644 --- a/packages/core/src/visibleGraph/filter.ts +++ b/packages/core/src/visibleGraph/filter.ts @@ -1,40 +1,12 @@ import type { IGraphData } from '../graph/contracts'; -import { createGlobMatcher } from '../globMatch'; import type { VisibleGraphFilterConfig } from './contracts'; import { filterEdgesToNodes } from './model'; - -type GlobMatcher = ReturnType; -interface CompiledFilterPattern { - matches: GlobMatcher; - pattern: string; -} - -function nodeMatchesPattern(node: IGraphData['nodes'][number], matches: GlobMatcher): boolean { - return matches(node.id) - || (node.symbol?.filePath ? matches(node.symbol.filePath) : false); -} - -function edgeMatchesPattern(edge: IGraphData['edges'][number], matches: GlobMatcher): boolean { - return ( - matches(edge.id) - || matches(edge.kind) - || matches(`${edge.from}->${edge.to}`) - || matches(`${edge.from}->${edge.to}#${edge.kind}`) - ); -} - -function canFilterEdgeDirectly(pattern: string): boolean { - return pattern.includes('->') - || pattern.includes('#') - || (!pattern.includes('*') && !pattern.includes('/')); -} - -function compileFilterPatterns(patterns: readonly string[]): CompiledFilterPattern[] { - return patterns.map(pattern => ({ - matches: createGlobMatcher(pattern), - pattern, - })); -} +import { + compileFilterPatterns, + edgeMatchesPattern, + getDirectEdgePatternMatchers, + nodeMatchesPattern, +} from './filterPatterns.js'; export function applyFilterPatterns( graphData: IGraphData, @@ -49,9 +21,7 @@ export function applyFilterPatterns( (node) => !compiledPatterns.some(({ matches }) => nodeMatchesPattern(node, matches)), ); const nodeFilteredEdges = filterEdgesToNodes(graphData.edges, nodes); - const edgePatternMatchers = compiledPatterns - .filter(({ pattern }) => canFilterEdgeDirectly(pattern)) - .map(({ matches }) => matches); + const edgePatternMatchers = getDirectEdgePatternMatchers(compiledPatterns); if (edgePatternMatchers.length === 0) { return { nodes, edges: nodeFilteredEdges }; } diff --git a/packages/core/src/visibleGraph/filterPatterns.ts b/packages/core/src/visibleGraph/filterPatterns.ts new file mode 100644 index 000000000..6ac831414 --- /dev/null +++ b/packages/core/src/visibleGraph/filterPatterns.ts @@ -0,0 +1,44 @@ +import type { IGraphData } from '../graph/contracts.js'; +import { createGlobMatcher } from '../globMatch.js'; + +type GlobMatcher = ReturnType; + +export interface CompiledFilterPattern { + matches: GlobMatcher; + pattern: string; +} + +export function compileFilterPatterns(patterns: readonly string[]): CompiledFilterPattern[] { + return patterns.map(pattern => ({ + matches: createGlobMatcher(pattern), + pattern, + })); +} + +export function getDirectEdgePatternMatchers( + patterns: readonly CompiledFilterPattern[], +): GlobMatcher[] { + return patterns + .filter(({ pattern }) => canFilterEdgeDirectly(pattern)) + .map(({ matches }) => matches); +} + +export function nodeMatchesPattern(node: IGraphData['nodes'][number], matches: GlobMatcher): boolean { + return matches(node.id) + || (node.symbol?.filePath ? matches(node.symbol.filePath) : false); +} + +export function edgeMatchesPattern(edge: IGraphData['edges'][number], matches: GlobMatcher): boolean { + return ( + matches(edge.id) + || matches(edge.kind) + || matches(`${edge.from}->${edge.to}`) + || matches(`${edge.from}->${edge.to}#${edge.kind}`) + ); +} + +function canFilterEdgeDirectly(pattern: string): boolean { + return pattern.includes('->') + || pattern.includes('#') + || (!pattern.includes('*') && !pattern.includes('/')); +} From 697111a85a4c10f7cac7967bf03983604553405a Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 15:58:55 -0700 Subject: [PATCH 123/192] refactor: split remaining extension mutation sites --- .../graphView/analysis/execution/progress.ts | 43 ++------------ .../analysis/execution/progress/coalescer.ts | 36 ++++++++++++ .../analysis/execution/progress/modes.ts | 5 ++ .../defaults/materialTheme/extensionMatch.ts | 38 ++---------- .../extensionMatch/candidates.ts | 35 +++++++++++ .../graphView/provider/webview/resolve.ts | 58 ++----------------- .../provider/webview/resolve/views.ts | 50 ++++++++++++++++ .../src/webview/app/shell/messageListener.ts | 18 +----- .../app/shell/messageListener/pluginData.ts | 18 ++++++ .../surface/view/threeDimensional.tsx | 34 ++--------- .../view/threeDimensional/deferredMount.ts | 30 ++++++++++ .../store/messageHandlers/graphControls.ts | 21 +------ .../messageHandlers/graphControls/patch.ts | 34 +++++++++++ 13 files changed, 229 insertions(+), 191 deletions(-) create mode 100644 packages/extension/src/extension/graphView/analysis/execution/progress/coalescer.ts create mode 100644 packages/extension/src/extension/graphView/analysis/execution/progress/modes.ts create mode 100644 packages/extension/src/extension/graphView/groups/defaults/materialTheme/extensionMatch/candidates.ts create mode 100644 packages/extension/src/extension/graphView/provider/webview/resolve/views.ts create mode 100644 packages/extension/src/webview/app/shell/messageListener/pluginData.ts create mode 100644 packages/extension/src/webview/components/graph/rendering/surface/view/threeDimensional/deferredMount.ts create mode 100644 packages/extension/src/webview/store/messageHandlers/graphControls/patch.ts diff --git a/packages/extension/src/extension/graphView/analysis/execution/progress.ts b/packages/extension/src/extension/graphView/analysis/execution/progress.ts index d9b2f030b..82ffcd228 100644 --- a/packages/extension/src/extension/graphView/analysis/execution/progress.ts +++ b/packages/extension/src/extension/graphView/analysis/execution/progress.ts @@ -3,6 +3,10 @@ import type { GraphViewAnalysisMode, GraphViewIndexingProgress, } from '../execution'; +import { createGraphViewIndexProgressCoalescer } from './progress/coalescer'; +import { supportsInitialProgress } from './progress/modes'; + +export { createGraphViewIndexProgressCoalescer } from './progress/coalescer'; const ANALYSIS_PHASE_BY_MODE: Record = { analyze: 'Indexing Workspace', @@ -11,12 +15,6 @@ const ANALYSIS_PHASE_BY_MODE: Record = { refresh: 'Refreshing Index', incremental: 'Applying Changes', }; -const MAX_PROGRESS_BUCKETS_PER_PHASE = 20; - -function supportsInitialProgress(mode: GraphViewAnalysisMode): boolean { - return mode === 'index' || mode === 'refresh' || mode === 'incremental'; -} - export function createGraphViewAnalysisProgressForwarder( mode: GraphViewAnalysisMode, handlers: GraphViewAnalysisExecutionHandlers, @@ -34,39 +32,6 @@ export function createGraphViewAnalysisProgressForwarder( }; } -export function createGraphViewIndexProgressCoalescer( - sendProgress: (progress: TProgress) => void, -): (progress: TProgress) => void { - let lastPhase: string | undefined; - let lastTotal: number | undefined; - let lastBucket: number | undefined; - - return (progress) => { - const bucket = getGraphViewIndexProgressBucket(progress); - if ( - progress.phase === lastPhase - && progress.total === lastTotal - && bucket === lastBucket - ) { - return; - } - - lastPhase = progress.phase; - lastTotal = progress.total; - lastBucket = bucket; - sendProgress(progress); - }; -} - -function getGraphViewIndexProgressBucket(progress: GraphViewIndexingProgress): number { - if (progress.total <= MAX_PROGRESS_BUCKETS_PER_PHASE) { - return progress.current; - } - - const clampedCurrent = Math.max(0, Math.min(progress.current, progress.total)); - return Math.floor((clampedCurrent * MAX_PROGRESS_BUCKETS_PER_PHASE) / progress.total); -} - export function sendInitialGraphViewAnalysisProgress( mode: GraphViewAnalysisMode, handlers: GraphViewAnalysisExecutionHandlers, diff --git a/packages/extension/src/extension/graphView/analysis/execution/progress/coalescer.ts b/packages/extension/src/extension/graphView/analysis/execution/progress/coalescer.ts new file mode 100644 index 000000000..c4cf9db14 --- /dev/null +++ b/packages/extension/src/extension/graphView/analysis/execution/progress/coalescer.ts @@ -0,0 +1,36 @@ +import type { GraphViewIndexingProgress } from '../../execution'; + +const MAX_PROGRESS_BUCKETS_PER_PHASE = 20; + +export function createGraphViewIndexProgressCoalescer( + sendProgress: (progress: TProgress) => void, +): (progress: TProgress) => void { + let lastPhase: string | undefined; + let lastTotal: number | undefined; + let lastBucket: number | undefined; + + return (progress) => { + const bucket = getGraphViewIndexProgressBucket(progress); + if ( + progress.phase === lastPhase + && progress.total === lastTotal + && bucket === lastBucket + ) { + return; + } + + lastPhase = progress.phase; + lastTotal = progress.total; + lastBucket = bucket; + sendProgress(progress); + }; +} + +function getGraphViewIndexProgressBucket(progress: GraphViewIndexingProgress): number { + if (progress.total <= MAX_PROGRESS_BUCKETS_PER_PHASE) { + return progress.current; + } + + const clampedCurrent = Math.max(0, Math.min(progress.current, progress.total)); + return Math.floor((clampedCurrent * MAX_PROGRESS_BUCKETS_PER_PHASE) / progress.total); +} diff --git a/packages/extension/src/extension/graphView/analysis/execution/progress/modes.ts b/packages/extension/src/extension/graphView/analysis/execution/progress/modes.ts new file mode 100644 index 000000000..71adfb7b9 --- /dev/null +++ b/packages/extension/src/extension/graphView/analysis/execution/progress/modes.ts @@ -0,0 +1,5 @@ +import type { GraphViewAnalysisMode } from '../../execution'; + +export function supportsInitialProgress(mode: GraphViewAnalysisMode): boolean { + return mode === 'index' || mode === 'refresh' || mode === 'incremental'; +} diff --git a/packages/extension/src/extension/graphView/groups/defaults/materialTheme/extensionMatch.ts b/packages/extension/src/extension/graphView/groups/defaults/materialTheme/extensionMatch.ts index 5b71f2aeb..da1ec9e48 100644 --- a/packages/extension/src/extension/graphView/groups/defaults/materialTheme/extensionMatch.ts +++ b/packages/extension/src/extension/graphView/groups/defaults/materialTheme/extensionMatch.ts @@ -1,4 +1,8 @@ import type { MaterialMatch } from './model'; +import { + createExtensionMatch, + getExtensionCandidates, +} from './extensionMatch/candidates'; export interface MaterialExtensionMatcher { iconNameByLowerExtension: Map; @@ -50,37 +54,3 @@ export function findLongestExtensionMatchWithMatcher( return bestMatch; } - -function getExtensionCandidates(lowerBaseName: string): string[] { - const candidates = [lowerBaseName]; - for (let index = lowerBaseName.indexOf('.'); index >= 0; index = lowerBaseName.indexOf('.', index + 1)) { - const extension = lowerBaseName.slice(index + 1); - if (extension) { - candidates.push(extension); - } - } - - return candidates; -} - -function createExtensionMatch( - baseName: string, - lowerBaseName: string, - extension: string, - iconName: string, -): MaterialMatch | undefined { - const lowerExtension = extension.toLowerCase(); - if (!matchesExtension(lowerBaseName, lowerExtension)) { - return undefined; - } - - return { - iconName, - key: lowerBaseName === lowerExtension ? baseName : baseName.slice(-extension.length), - kind: 'fileExtension', - }; -} - -function matchesExtension(lowerBaseName: string, lowerExtension: string): boolean { - return lowerBaseName === lowerExtension || lowerBaseName.endsWith(`.${lowerExtension}`); -} diff --git a/packages/extension/src/extension/graphView/groups/defaults/materialTheme/extensionMatch/candidates.ts b/packages/extension/src/extension/graphView/groups/defaults/materialTheme/extensionMatch/candidates.ts new file mode 100644 index 000000000..2ee03c6be --- /dev/null +++ b/packages/extension/src/extension/graphView/groups/defaults/materialTheme/extensionMatch/candidates.ts @@ -0,0 +1,35 @@ +import type { MaterialMatch } from '../model'; + +export function getExtensionCandidates(lowerBaseName: string): string[] { + const candidates = [lowerBaseName]; + for (let index = lowerBaseName.indexOf('.'); index >= 0; index = lowerBaseName.indexOf('.', index + 1)) { + const extension = lowerBaseName.slice(index + 1); + if (extension) { + candidates.push(extension); + } + } + + return candidates; +} + +export function createExtensionMatch( + baseName: string, + lowerBaseName: string, + extension: string, + iconName: string, +): MaterialMatch | undefined { + const lowerExtension = extension.toLowerCase(); + if (!matchesExtension(lowerBaseName, lowerExtension)) { + return undefined; + } + + return { + iconName, + key: lowerBaseName === lowerExtension ? baseName : baseName.slice(-extension.length), + kind: 'fileExtension', + }; +} + +function matchesExtension(lowerBaseName: string, lowerExtension: string): boolean { + return lowerBaseName === lowerExtension || lowerBaseName.endsWith(`.${lowerExtension}`); +} diff --git a/packages/extension/src/extension/graphView/provider/webview/resolve.ts b/packages/extension/src/extension/graphView/provider/webview/resolve.ts index 22eb417f9..87348ecbd 100644 --- a/packages/extension/src/extension/graphView/provider/webview/resolve.ts +++ b/packages/extension/src/extension/graphView/provider/webview/resolve.ts @@ -1,7 +1,12 @@ import type * as vscode from 'vscode'; -import type { CodeGraphyWebviewKind } from '../../webview/html'; import type { GraphViewProviderWebviewMethodDependencies } from './defaultDependencies'; import type { GraphViewProviderSidebarViewSource } from './sidebarViews'; +import { + assignResolvedWebviewView, + clearResolvedWebviewView, + getWebviewKind, + maybeFlushPendingWorkspaceRefresh, +} from './resolve/views'; export interface GraphViewProviderWebviewResolveSource extends GraphViewProviderSidebarViewSource { _extensionUri: vscode.Uri; @@ -9,57 +14,6 @@ export interface GraphViewProviderWebviewResolveSource extends GraphViewProvider flushPendingWorkspaceRefresh?(): void; } -function isTimelineWebviewView(webviewView: vscode.WebviewView): boolean { - return webviewView.viewType === 'codegraphy.timelineView'; -} - -function getWebviewKind(webviewView: vscode.WebviewView): CodeGraphyWebviewKind { - if (isTimelineWebviewView(webviewView)) { - return 'timeline'; - } - - return 'graph'; -} - -function assignResolvedWebviewView( - source: GraphViewProviderWebviewResolveSource, - webviewView: vscode.WebviewView, - viewKind: CodeGraphyWebviewKind, - workspaceTitle: string | undefined, -): void { - if (viewKind === 'timeline') { - source._timelineView = webviewView; - return; - } - - webviewView.title = workspaceTitle ?? 'Graph'; - source._view = webviewView; -} - -function clearResolvedWebviewView( - source: GraphViewProviderWebviewResolveSource, - webviewView: vscode.WebviewView, - viewKind: CodeGraphyWebviewKind, -): void { - if (viewKind === 'timeline' && source._timelineView === webviewView) { - source._timelineView = undefined; - } - - if (viewKind === 'graph' && source._view === webviewView) { - source._view = undefined; - } -} - -function maybeFlushPendingWorkspaceRefresh( - source: GraphViewProviderWebviewResolveSource, - webviewView: vscode.WebviewView, - viewKind: CodeGraphyWebviewKind, -): void { - if (viewKind === 'graph' && webviewView.visible) { - source.flushPendingWorkspaceRefresh?.(); - } -} - export function resolveGraphViewProviderWebviewView( source: GraphViewProviderWebviewResolveSource, dependencies: Pick< diff --git a/packages/extension/src/extension/graphView/provider/webview/resolve/views.ts b/packages/extension/src/extension/graphView/provider/webview/resolve/views.ts new file mode 100644 index 000000000..98f5fd0f5 --- /dev/null +++ b/packages/extension/src/extension/graphView/provider/webview/resolve/views.ts @@ -0,0 +1,50 @@ +import type * as vscode from 'vscode'; +import type { CodeGraphyWebviewKind } from '../../../webview/html'; +import type { GraphViewProviderWebviewResolveSource } from '../resolve'; + +export function getWebviewKind(webviewView: vscode.WebviewView): CodeGraphyWebviewKind { + return isTimelineWebviewView(webviewView) ? 'timeline' : 'graph'; +} + +export function assignResolvedWebviewView( + source: GraphViewProviderWebviewResolveSource, + webviewView: vscode.WebviewView, + viewKind: CodeGraphyWebviewKind, + workspaceTitle: string | undefined, +): void { + if (viewKind === 'timeline') { + source._timelineView = webviewView; + return; + } + + webviewView.title = workspaceTitle ?? 'Graph'; + source._view = webviewView; +} + +export function clearResolvedWebviewView( + source: GraphViewProviderWebviewResolveSource, + webviewView: vscode.WebviewView, + viewKind: CodeGraphyWebviewKind, +): void { + if (viewKind === 'timeline' && source._timelineView === webviewView) { + source._timelineView = undefined; + } + + if (viewKind === 'graph' && source._view === webviewView) { + source._view = undefined; + } +} + +export function maybeFlushPendingWorkspaceRefresh( + source: GraphViewProviderWebviewResolveSource, + webviewView: vscode.WebviewView, + viewKind: CodeGraphyWebviewKind, +): void { + if (viewKind === 'graph' && webviewView.visible) { + source.flushPendingWorkspaceRefresh?.(); + } +} + +function isTimelineWebviewView(webviewView: vscode.WebviewView): boolean { + return webviewView.viewType === 'codegraphy.timelineView'; +} diff --git a/packages/extension/src/webview/app/shell/messageListener.ts b/packages/extension/src/webview/app/shell/messageListener.ts index beda660e5..6154c220e 100644 --- a/packages/extension/src/webview/app/shell/messageListener.ts +++ b/packages/extension/src/webview/app/shell/messageListener.ts @@ -9,6 +9,7 @@ import { parsePluginScopedMessage } from './messages'; import type { WebviewPluginHost } from '../../pluginHost/manager'; import { handlePluginInjectMessage } from './messageListener/pluginInjection'; import { removeDisabledPluginRegistrations } from './messageListener/pluginRegistrations'; +import { handlePluginDataUpdatedMessage } from './messageListener/pluginData'; import { postWebviewReadyOnce, resetWebviewReadyPosted } from './messageListener/ready'; import { handleCssSnippetsUpdatedMessage } from './messageListener/cssSnippets'; @@ -29,23 +30,6 @@ export interface InjectAssetsParams { export type ResetPluginAssets = (pluginId: string) => void; export type UpdatePluginData = (pluginId: string, data: unknown) => void; -function handlePluginDataUpdatedMessage( - raw: { type?: unknown; payload?: unknown }, - updatePluginData: UpdatePluginData, -): boolean { - if (raw.type !== 'PLUGIN_DATA_UPDATED' || !raw.payload || typeof raw.payload !== 'object') { - return false; - } - - const payload = raw.payload as { pluginId?: unknown; data?: unknown }; - if (typeof payload.pluginId !== 'string' || payload.pluginId.length === 0) { - return false; - } - - updatePluginData(payload.pluginId, payload.data); - return true; -} - /** * Create the message event handler for the App's window listener. */ diff --git a/packages/extension/src/webview/app/shell/messageListener/pluginData.ts b/packages/extension/src/webview/app/shell/messageListener/pluginData.ts new file mode 100644 index 000000000..2a5502549 --- /dev/null +++ b/packages/extension/src/webview/app/shell/messageListener/pluginData.ts @@ -0,0 +1,18 @@ +import type { UpdatePluginData } from '../messageListener'; + +export function handlePluginDataUpdatedMessage( + raw: { type?: unknown; payload?: unknown }, + updatePluginData: UpdatePluginData, +): boolean { + if (raw.type !== 'PLUGIN_DATA_UPDATED' || !raw.payload || typeof raw.payload !== 'object') { + return false; + } + + const payload = raw.payload as { pluginId?: unknown; data?: unknown }; + if (typeof payload.pluginId !== 'string' || payload.pluginId.length === 0) { + return false; + } + + updatePluginData(payload.pluginId, payload.data); + return true; +} diff --git a/packages/extension/src/webview/components/graph/rendering/surface/view/threeDimensional.tsx b/packages/extension/src/webview/components/graph/rendering/surface/view/threeDimensional.tsx index 649722af8..70dc4ad9d 100644 --- a/packages/extension/src/webview/components/graph/rendering/surface/view/threeDimensional.tsx +++ b/packages/extension/src/webview/components/graph/rendering/surface/view/threeDimensional.tsx @@ -1,5 +1,5 @@ import '../../../../../three/runtime'; -import { useEffect, useState, type MutableRefObject, type ReactElement } from 'react'; +import { type MutableRefObject, type ReactElement } from 'react'; import ForceGraph3D from 'react-force-graph-3d'; import type { ForceGraphMethods as FG3DMethods, @@ -13,6 +13,9 @@ import { createNodeThreeObject, type NodeThreeObjectDependencies, } from '../../nodes/canvas3d'; +import { useDeferredSurface3dMount } from './threeDimensional/deferredMount'; + +export { useDeferredSurface3dMount } from './threeDimensional/deferredMount'; type ForceGraph3DRef = MutableRefObject | undefined>; type Surface3dMeasurementKey = 'measured' | 'unmeasured'; @@ -44,35 +47,6 @@ export function getSurface3dMeasurementKey( : 'measured'; } -export function useDeferredSurface3dMount(enabled: boolean): boolean { - const [isMounted, setIsMounted] = useState(!enabled); - - useEffect(() => { - if (!enabled) { - setIsMounted(true); - return; - } - - setIsMounted(false); - - let firstFrame: number | null = null; - let secondFrame: number | null = null; - - firstFrame = requestAnimationFrame(() => { - secondFrame = requestAnimationFrame(() => { - setIsMounted(true); - }); - }); - - return () => { - if (firstFrame !== null) cancelAnimationFrame(firstFrame); - if (secondFrame !== null) cancelAnimationFrame(secondFrame); - }; - }, [enabled]); - - return isMounted; -} - export function Surface3d({ backgroundColor, directionMode, diff --git a/packages/extension/src/webview/components/graph/rendering/surface/view/threeDimensional/deferredMount.ts b/packages/extension/src/webview/components/graph/rendering/surface/view/threeDimensional/deferredMount.ts new file mode 100644 index 000000000..40eb63296 --- /dev/null +++ b/packages/extension/src/webview/components/graph/rendering/surface/view/threeDimensional/deferredMount.ts @@ -0,0 +1,30 @@ +import { useEffect, useState } from 'react'; + +export function useDeferredSurface3dMount(enabled: boolean): boolean { + const [isMounted, setIsMounted] = useState(!enabled); + + useEffect(() => { + if (!enabled) { + setIsMounted(true); + return; + } + + setIsMounted(false); + + let firstFrame: number | null = null; + let secondFrame: number | null = null; + + firstFrame = requestAnimationFrame(() => { + secondFrame = requestAnimationFrame(() => { + setIsMounted(true); + }); + }); + + return () => { + if (firstFrame !== null) cancelAnimationFrame(firstFrame); + if (secondFrame !== null) cancelAnimationFrame(secondFrame); + }; + }, [enabled]); + + return isMounted; +} diff --git a/packages/extension/src/webview/store/messageHandlers/graphControls.ts b/packages/extension/src/webview/store/messageHandlers/graphControls.ts index 9abc9b1eb..22a07a2c6 100644 --- a/packages/extension/src/webview/store/messageHandlers/graphControls.ts +++ b/packages/extension/src/webview/store/messageHandlers/graphControls.ts @@ -1,6 +1,6 @@ import type { ExtensionToWebviewMessage } from '../../../shared/protocol/extensionToWebview'; import type { IHandlerContext, PartialState } from '../messageTypes'; -import { arePlainValuesEqual } from './equality/compare'; +import { createGraphControlsStatePatch } from './graphControls/patch'; export function handleGraphIndexStatusUpdated( message: Extract, @@ -27,17 +27,6 @@ export function handleGraphIndexProgress( }; } -function assignChangedGraphControl( - next: PartialState, - key: K, - currentValue: PartialState[K], - nextValue: PartialState[K], -): void { - if (!arePlainValuesEqual(currentValue, nextValue)) { - next[key] = nextValue; - } -} - export function handleGraphControlsUpdated( message: Extract, ctx?: Pick, @@ -53,13 +42,7 @@ export function handleGraphControlsUpdated( }; } - const next: PartialState = {}; - - assignChangedGraphControl(next, 'graphNodeTypes', state.graphNodeTypes, message.payload.nodeTypes); - assignChangedGraphControl(next, 'graphEdgeTypes', state.graphEdgeTypes, message.payload.edgeTypes); - assignChangedGraphControl(next, 'nodeColors', state.nodeColors, message.payload.nodeColors); - assignChangedGraphControl(next, 'nodeVisibility', state.nodeVisibility, message.payload.nodeVisibility); - assignChangedGraphControl(next, 'edgeVisibility', state.edgeVisibility, message.payload.edgeVisibility); + const next = createGraphControlsStatePatch(state, message.payload); return Object.keys(next).length > 0 ? next : undefined; } diff --git a/packages/extension/src/webview/store/messageHandlers/graphControls/patch.ts b/packages/extension/src/webview/store/messageHandlers/graphControls/patch.ts new file mode 100644 index 000000000..ad58f7677 --- /dev/null +++ b/packages/extension/src/webview/store/messageHandlers/graphControls/patch.ts @@ -0,0 +1,34 @@ +import type { ExtensionToWebviewMessage } from '../../../../shared/protocol/extensionToWebview'; +import type { PartialState } from '../../messageTypes'; +import { arePlainValuesEqual } from '../equality/compare'; + +type GraphControlsPayload = Extract< + ExtensionToWebviewMessage, + { type: 'GRAPH_CONTROLS_UPDATED' } +>['payload']; + +export function createGraphControlsStatePatch( + state: PartialState, + payload: GraphControlsPayload, +): PartialState { + const next: PartialState = {}; + + assignChangedGraphControl(next, 'graphNodeTypes', state.graphNodeTypes, payload.nodeTypes); + assignChangedGraphControl(next, 'graphEdgeTypes', state.graphEdgeTypes, payload.edgeTypes); + assignChangedGraphControl(next, 'nodeColors', state.nodeColors, payload.nodeColors); + assignChangedGraphControl(next, 'nodeVisibility', state.nodeVisibility, payload.nodeVisibility); + assignChangedGraphControl(next, 'edgeVisibility', state.edgeVisibility, payload.edgeVisibility); + + return next; +} + +function assignChangedGraphControl( + next: PartialState, + key: K, + currentValue: PartialState[K], + nextValue: PartialState[K], +): void { + if (!arePlainValuesEqual(currentValue, nextValue)) { + next[key] = nextValue; + } +} From b863e83bb6d47891764782c2633edb9b9c8c056f Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 16:24:01 -0700 Subject: [PATCH 124/192] fix: preserve file self call edges in scoped graph --- .../visibleGraph/scope/edgeProjection.ts | 24 ++++++++----- .../tests/shared/visibleGraph/scope.test.ts | 34 +++++++++++++++++++ 2 files changed, 49 insertions(+), 9 deletions(-) diff --git a/packages/extension/src/shared/visibleGraph/scope/edgeProjection.ts b/packages/extension/src/shared/visibleGraph/scope/edgeProjection.ts index d9ad46414..c07ec00ff 100644 --- a/packages/extension/src/shared/visibleGraph/scope/edgeProjection.ts +++ b/packages/extension/src/shared/visibleGraph/scope/edgeProjection.ts @@ -31,18 +31,24 @@ function projectEdgeToVisibleNodes( const from = getVisibleEdgeEndpoint(edge.from, allNodeById, visibleNodeIds); const to = getVisibleEdgeEndpoint(edge.to, allNodeById, visibleNodeIds); - if (!from || !to || from === to) { + if (!from || !to) { return undefined; } - return from === edge.from && to === edge.to - ? edge - : { - ...edge, - id: `${from}->${to}${getEdgeKindSuffix(edge)}`, - from, - to, - }; + if (from === edge.from && to === edge.to) { + return edge; + } + + if (from === to) { + return undefined; + } + + return { + ...edge, + id: `${from}->${to}${getEdgeKindSuffix(edge)}`, + from, + to, + }; } function getEdgeKindSuffix(edge: IGraphData['edges'][number]): string { diff --git a/packages/extension/tests/shared/visibleGraph/scope.test.ts b/packages/extension/tests/shared/visibleGraph/scope.test.ts index a5daff7bd..4eeb8344a 100644 --- a/packages/extension/tests/shared/visibleGraph/scope.test.ts +++ b/packages/extension/tests/shared/visibleGraph/scope.test.ts @@ -425,5 +425,39 @@ describe('shared/visibleGraph/scope', () => { }); }); + it('keeps file-level self edges while dropping symbol edges projected onto the same file', () => { + const result = applyGraphScope( + { + nodes: [ + node('src/runner.dart'), + symbolNode('src/runner.dart#Runner:class', { + id: 'src/runner.dart#Runner:class', + name: 'Runner', + kind: 'class', + filePath: 'src/runner.dart', + }), + symbolNode('src/runner.dart#run:method', { + id: 'src/runner.dart#run:method', + name: 'run', + kind: 'method', + filePath: 'src/runner.dart', + }), + ], + edges: [ + edge('src/runner.dart', 'src/runner.dart', 'call'), + edge('src/runner.dart#run:method', 'src/runner.dart#Runner:class', 'call'), + ], + }, + { + nodes: [{ type: 'file', enabled: true }], + edges: [{ type: 'call', enabled: true }], + }, + ); + + expect(ids(result)).toEqual({ + nodes: ['src/runner.dart'], + edges: ['src/runner.dart->src/runner.dart#call'], + }); + }); }); From 2d4d5124b3d097787ec0e21149780339dce7bbc4 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 16:34:38 -0700 Subject: [PATCH 125/192] test: kill scoped edge projection mutants --- .../visibleGraph/scope/edgeProjection.test.ts | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 packages/extension/tests/shared/visibleGraph/scope/edgeProjection.test.ts diff --git a/packages/extension/tests/shared/visibleGraph/scope/edgeProjection.test.ts b/packages/extension/tests/shared/visibleGraph/scope/edgeProjection.test.ts new file mode 100644 index 000000000..f6249aabc --- /dev/null +++ b/packages/extension/tests/shared/visibleGraph/scope/edgeProjection.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from 'vitest'; +import type { IGraphEdge } from '../../../../src/shared/graph/contracts'; +import { projectEdgesToVisibleNodes } from '../../../../src/shared/visibleGraph/scope/edgeProjection'; +import { edge, ids, node, symbolNode } from './fixture'; + +describe('shared/visibleGraph/scope/edgeProjection', () => { + it('drops edges when either endpoint cannot be projected to a visible node', () => { + const result = projectEdgesToVisibleNodes( + [ + edge('src/app.ts', 'src/missing.ts#missing:function', 'call'), + ], + [ + node('src/app.ts'), + ], + [ + node('src/app.ts'), + ], + ); + + expect(ids({ nodes: [], edges: result })).toEqual({ + nodes: [], + edges: [], + }); + }); + + it('keeps already visible edges unchanged even when they are file-level self edges', () => { + const visibleSelfEdge: IGraphEdge = { + ...edge('src/runner.dart', 'src/runner.dart', 'call'), + id: 'stable-self-call-id', + }; + + const result = projectEdgesToVisibleNodes( + [ + visibleSelfEdge, + ], + [ + node('src/runner.dart'), + ], + [ + node('src/runner.dart'), + ], + ); + + expect(result).toEqual([visibleSelfEdge]); + }); + + it('projects hidden symbol endpoints to their visible containing files', () => { + const result = projectEdgesToVisibleNodes( + [ + edge('src/app.ts#main:function', 'src/logger.ts', 'call'), + ], + [ + node('src/app.ts'), + node('src/logger.ts'), + symbolNode('src/app.ts#main:function', { + id: 'src/app.ts#main:function', + name: 'main', + kind: 'function', + filePath: 'src/app.ts', + }), + ], + [ + node('src/app.ts'), + node('src/logger.ts'), + ], + ); + + expect(ids({ nodes: [], edges: result })).toEqual({ + nodes: [], + edges: ['src/app.ts->src/logger.ts#call'], + }); + }); + + it('drops hidden symbol edges projected onto the same visible file', () => { + const result = projectEdgesToVisibleNodes( + [ + edge('src/runner.dart#run:method', 'src/runner.dart#Runner:class', 'call'), + ], + [ + node('src/runner.dart'), + symbolNode('src/runner.dart#run:method', { + id: 'src/runner.dart#run:method', + name: 'run', + kind: 'method', + filePath: 'src/runner.dart', + }), + symbolNode('src/runner.dart#Runner:class', { + id: 'src/runner.dart#Runner:class', + name: 'Runner', + kind: 'class', + filePath: 'src/runner.dart', + }), + ], + [ + node('src/runner.dart'), + ], + ); + + expect(ids({ nodes: [], edges: result })).toEqual({ + nodes: [], + edges: [], + }); + }); + + it('uses the edge kind as the projected id suffix when the source id has no suffix marker', () => { + const result = projectEdgesToVisibleNodes( + [ + { + ...edge('src/app.ts#main:function', 'src/logger.ts', 'call'), + id: 'stable-call-id', + }, + ], + [ + node('src/app.ts'), + node('src/logger.ts'), + symbolNode('src/app.ts#main:function', { + id: 'src/app.ts#main:function', + name: 'main', + kind: 'function', + filePath: 'src/app.ts', + }), + ], + [ + node('src/app.ts'), + node('src/logger.ts'), + ], + ); + + expect(ids({ nodes: [], edges: result })).toEqual({ + nodes: [], + edges: ['src/app.ts->src/logger.ts#call'], + }); + }); + + it('keeps source id suffixes that start at the first character', () => { + const result = projectEdgesToVisibleNodes( + [ + { + ...edge('src/app.ts#main:function', 'src/logger.ts', 'call'), + id: '#legacy-call', + }, + ], + [ + node('src/app.ts'), + node('src/logger.ts'), + symbolNode('src/app.ts#main:function', { + id: 'src/app.ts#main:function', + name: 'main', + kind: 'function', + filePath: 'src/app.ts', + }), + ], + [ + node('src/app.ts'), + node('src/logger.ts'), + ], + ); + + expect(ids({ nodes: [], edges: result })).toEqual({ + nodes: [], + edges: ['src/app.ts->src/logger.ts#legacy-call'], + }); + }); +}); From 8ae9981705e659d0626196b4a2a193dc92f5781c Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 16:36:58 -0700 Subject: [PATCH 126/192] test: kill edge endpoint projection mutants --- .../scope/edgeEndpointProjection.test.ts | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 packages/extension/tests/shared/visibleGraph/scope/edgeEndpointProjection.test.ts diff --git a/packages/extension/tests/shared/visibleGraph/scope/edgeEndpointProjection.test.ts b/packages/extension/tests/shared/visibleGraph/scope/edgeEndpointProjection.test.ts new file mode 100644 index 000000000..707d8cb96 --- /dev/null +++ b/packages/extension/tests/shared/visibleGraph/scope/edgeEndpointProjection.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { getVisibleEdgeEndpoint } from '../../../../src/shared/visibleGraph/scope/edgeEndpointProjection'; +import { node, symbolNode } from './fixture'; + +describe('shared/visibleGraph/scope/edgeEndpointProjection', () => { + it('returns visible node ids without projection', () => { + expect(getVisibleEdgeEndpoint( + 'src/app.ts', + new Map([ + ['src/app.ts', node('src/app.ts')], + ]), + new Set(['src/app.ts']), + )).toBe('src/app.ts'); + }); + + it('projects hidden symbol ids to their visible containing file', () => { + expect(getVisibleEdgeEndpoint( + 'src/app.ts#main:function', + new Map([ + ['src/app.ts', node('src/app.ts')], + ['src/app.ts#main:function', symbolNode('src/app.ts#main:function', { + id: 'src/app.ts#main:function', + name: 'main', + kind: 'function', + filePath: 'src/app.ts', + })], + ]), + new Set(['src/app.ts']), + )).toBe('src/app.ts'); + }); + + it('does not project known symbols when their containing file is hidden', () => { + expect(getVisibleEdgeEndpoint( + 'src/app.ts#main:function', + new Map([ + ['src/app.ts', node('src/app.ts')], + ['src/app.ts#main:function', symbolNode('src/app.ts#main:function', { + id: 'src/app.ts#main:function', + name: 'main', + kind: 'function', + filePath: 'src/app.ts', + })], + ]), + new Set(['src/other.ts']), + )).toBeUndefined(); + }); +}); From aa4a2243e49fb3dd79bdaad4fa246602fcc8d978 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 16:39:23 -0700 Subject: [PATCH 127/192] test: kill edge preference mutants --- .../visibleGraph/scope/edgePreference.test.ts | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 packages/extension/tests/shared/visibleGraph/scope/edgePreference.test.ts diff --git a/packages/extension/tests/shared/visibleGraph/scope/edgePreference.test.ts b/packages/extension/tests/shared/visibleGraph/scope/edgePreference.test.ts new file mode 100644 index 000000000..329b5228b --- /dev/null +++ b/packages/extension/tests/shared/visibleGraph/scope/edgePreference.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import { + getEdgeContainingFileKey, + getEndpointPreference, + rememberBestEndpointPreference, +} from '../../../../src/shared/visibleGraph/scope/edgePreference'; +import { edge, node, symbolNode } from './fixture'; + +describe('shared/visibleGraph/scope/edgePreference', () => { + it('builds grouping keys from containing files for symbol endpoints', () => { + expect(getEdgeContainingFileKey( + edge('src/app.ts#main:function', 'src/logger.ts#write:function', 'call'), + new Map([ + ['src/app.ts#main:function', symbolNode('src/app.ts#main:function', { + id: 'src/app.ts#main:function', + name: 'main', + kind: 'function', + filePath: 'src/app.ts', + })], + ['src/logger.ts#write:function', symbolNode('src/logger.ts#write:function', { + id: 'src/logger.ts#write:function', + name: 'write', + kind: 'function', + filePath: 'src/logger.ts', + })], + ]), + )).toBe('call\0src/app.ts\0src/logger.ts'); + }); + + it('falls back to raw endpoint ids when nodes are missing from the lookup', () => { + expect(getEdgeContainingFileKey( + edge('src/missing-from.ts#main:function', 'src/missing-to.ts#write:function', 'call'), + new Map(), + )).toBe('call\0src/missing-from.ts#main:function\0src/missing-to.ts#write:function'); + }); + + it('prefers edges with more symbol endpoints except type imports', () => { + const nodeById = new Map([ + ['src/app.ts', node('src/app.ts')], + ['src/logger.ts#write:function', symbolNode('src/logger.ts#write:function', { + id: 'src/logger.ts#write:function', + name: 'write', + kind: 'function', + filePath: 'src/logger.ts', + })], + ]); + + expect(getEndpointPreference(edge('src/app.ts', 'src/logger.ts#write:function', 'call'), nodeById)).toBe(1); + expect(getEndpointPreference(edge('src/app.ts', 'src/logger.ts#write:function', 'type-import'), nodeById)).toBe(-1); + }); + + it('treats missing endpoint nodes as file-level preference', () => { + expect(getEndpointPreference( + edge('src/missing-from.ts#main:function', 'src/missing-to.ts#write:function', 'call'), + new Map(), + )).toBe(0); + }); + + it('remembers the highest endpoint preference for each grouping key', () => { + const preferences = new Map(); + + rememberBestEndpointPreference(preferences, 'call\0src/app.ts\0src/logger.ts', 1); + rememberBestEndpointPreference(preferences, 'call\0src/app.ts\0src/logger.ts', 0); + rememberBestEndpointPreference(preferences, 'call\0src/app.ts\0src/logger.ts', 2); + + expect(preferences.get('call\0src/app.ts\0src/logger.ts')).toBe(2); + }); +}); From c20d2006a38ef0198fb2731260067a3e8d22d35d Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 16:42:32 -0700 Subject: [PATCH 128/192] test: kill edge selection mutants --- .../visibleGraph/scope/edgeSelection.test.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 packages/extension/tests/shared/visibleGraph/scope/edgeSelection.test.ts diff --git a/packages/extension/tests/shared/visibleGraph/scope/edgeSelection.test.ts b/packages/extension/tests/shared/visibleGraph/scope/edgeSelection.test.ts new file mode 100644 index 000000000..cc47c3ba4 --- /dev/null +++ b/packages/extension/tests/shared/visibleGraph/scope/edgeSelection.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { keepMostSpecificUniqueEdges } from '../../../../src/shared/visibleGraph/scope/edgeSelection'; +import { edge, ids, node } from './fixture'; + +describe('shared/visibleGraph/scope/edgeSelection', () => { + it('returns an empty edge list when there are no candidates', () => { + expect(keepMostSpecificUniqueEdges([], [])).toEqual([]); + }); + + it('keeps contains edges without endpoint preference filtering', () => { + const result = keepMostSpecificUniqueEdges( + [ + node('src/app.ts'), + node('src/app.ts#main:function', 'symbol'), + ], + [ + edge('src/app.ts', 'src/app.ts#main:function', 'contains'), + ], + ); + + expect(ids({ nodes: [], edges: result })).toEqual({ + nodes: [], + edges: ['src/app.ts->src/app.ts#main:function#contains'], + }); + }); +}); From bafb4ff82f1830abf8cfe582dd0dee1ee56c6be6 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 16:48:49 -0700 Subject: [PATCH 129/192] test: kill scoped node visibility mutants --- .../shared/visibleGraph/scope/nodes.test.ts | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 packages/extension/tests/shared/visibleGraph/scope/nodes.test.ts diff --git a/packages/extension/tests/shared/visibleGraph/scope/nodes.test.ts b/packages/extension/tests/shared/visibleGraph/scope/nodes.test.ts new file mode 100644 index 000000000..76f80c184 --- /dev/null +++ b/packages/extension/tests/shared/visibleGraph/scope/nodes.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest'; +import type { IGraphNodeTypeDefinition } from '../../../../src/shared/graphControls/contracts'; +import type { ScopedSymbolDefinition } from '../../../../src/shared/visibleGraph/scope/definitions'; +import { nodeMatchesScope } from '../../../../src/shared/visibleGraph/scope/nodes'; +import { node, symbolNode } from './fixture'; + +describe('shared/visibleGraph/scope/nodes', () => { + it('hides core child node types when their direct parent is disabled', () => { + expect(nodeMatchesScope( + node('src/app.ts#main:function', 'symbol:function'), + new Set(['symbol']), + [], + )).toBe(false); + }); + + it('hides core child node types when a grandparent is disabled', () => { + expect(nodeMatchesScope( + node('src/app.ts#count:variable', 'variable:plain'), + new Set(['symbol']), + [], + )).toBe(false); + }); + + it('does not throw for unknown node types without a registered parent', () => { + expect(nodeMatchesScope( + node('src/app.ts#custom', 'plugin:custom-node'), + new Set(), + [], + )).toBe(true); + }); + + it('hides scoped symbols when their scoped parent row is disabled', () => { + expect(nodeMatchesScope( + symbolNode('src/app.ts#counter:local', { + id: 'src/app.ts#counter:local', + name: 'counter', + kind: 'local', + filePath: 'src/app.ts', + }, 'file'), + new Set(['variable']), + [ + createScopedDefinition({ + id: 'symbol:local', + parentId: 'variable', + matchSymbolKinds: ['local'], + enabled: true, + }), + ], + )).toBe(false); + }); + + it('does not throw for scoped symbol parents outside the core hierarchy', () => { + expect(nodeMatchesScope( + symbolNode('src/app.ts#main:function', { + id: 'src/app.ts#main:function', + name: 'main', + kind: 'function', + filePath: 'src/app.ts', + }, 'file'), + new Set(), + [ + createScopedDefinition({ + id: 'plugin:custom:function', + parentId: 'plugin:custom-parent', + matchSymbolKinds: ['function'], + enabled: true, + }), + ], + )).toBe(true); + }); +}); + +function createScopedDefinition(input: { + id: string; + parentId: string; + matchSymbolKinds: string[]; + enabled: boolean; +}): ScopedSymbolDefinition { + return { + definition: createNodeTypeDefinition(input), + enabled: input.enabled, + specificity: 1, + }; +} + +function createNodeTypeDefinition(input: { + id: string; + parentId: string; + matchSymbolKinds: string[]; +}): IGraphNodeTypeDefinition { + return { + id: input.id, + label: input.id, + defaultColor: '#111111', + defaultVisible: false, + parentId: input.parentId, + matchSymbolKinds: input.matchSymbolKinds, + }; +} From 7319761bea3b882b10448fc3257d199ced05cb4b Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 16:53:21 -0700 Subject: [PATCH 130/192] test: kill scoped symbol matcher mutants --- .../visibleGraph/scope/symbolMatch.test.ts | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 packages/extension/tests/shared/visibleGraph/scope/symbolMatch.test.ts diff --git a/packages/extension/tests/shared/visibleGraph/scope/symbolMatch.test.ts b/packages/extension/tests/shared/visibleGraph/scope/symbolMatch.test.ts new file mode 100644 index 000000000..1956dceb6 --- /dev/null +++ b/packages/extension/tests/shared/visibleGraph/scope/symbolMatch.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from 'vitest'; +import type { IGraphNodeTypeDefinition } from '../../../../src/shared/graphControls/contracts'; +import type { ScopedSymbolDefinition } from '../../../../src/shared/visibleGraph/scope/definitions'; +import { symbolMatchesScopedDefinition } from '../../../../src/shared/visibleGraph/scope/symbolMatch'; +import { node, symbolNode } from './fixture'; + +describe('shared/visibleGraph/scope/symbolMatch', () => { + it('does not match nodes without symbol metadata', () => { + expect(symbolMatchesScopedDefinition( + node('src/app.ts'), + createDefinition({ id: 'symbol:function', matchSymbolKinds: ['function'] }), + )).toBe(false); + }); + + it('matches any symbol kind when a definition has no symbol-kind constraint', () => { + expect(symbolMatchesScopedDefinition( + symbolNode('src/app.ts#main:function', { + id: 'src/app.ts#main:function', + name: 'main', + kind: 'function', + filePath: 'src/app.ts', + }), + createDefinition({ id: 'plugin:custom:any-symbol' }), + )).toBe(true); + }); + + it('uses raw glob matching for uncompiled file path constraints', () => { + const definition = createDefinition({ + id: 'symbol:function', + matchSymbolKinds: ['function'], + matchSymbolFilePath: 'src/**/*.ts', + }); + + expect(symbolMatchesScopedDefinition( + symbolNode('src/app.ts#main:function', { + id: 'src/app.ts#main:function', + name: 'main', + kind: 'function', + filePath: 'src/app.ts', + }), + definition, + )).toBe(true); + expect(symbolMatchesScopedDefinition( + symbolNode('test/app.ts#main:function', { + id: 'test/app.ts#main:function', + name: 'main', + kind: 'function', + filePath: 'test/app.ts', + }), + definition, + )).toBe(false); + }); + + it('uses compiled file path matchers when available', () => { + const definition = createDefinition({ + id: 'symbol:function', + matchSymbolKinds: ['function'], + matchSymbolFilePath: 'src/**/*.ts', + }); + const scopedDefinition: ScopedSymbolDefinition = { + definition, + enabled: true, + specificity: 1, + symbolFilePathMatches: filePath => filePath.endsWith('.generated.ts'), + }; + + expect(symbolMatchesScopedDefinition( + symbolNode('test/app.generated.ts#main:function', { + id: 'test/app.generated.ts#main:function', + name: 'main', + kind: 'function', + filePath: 'test/app.generated.ts', + }), + scopedDefinition, + )).toBe(true); + expect(symbolMatchesScopedDefinition( + symbolNode('src/app.ts#main:function', { + id: 'src/app.ts#main:function', + name: 'main', + kind: 'function', + filePath: 'src/app.ts', + }), + scopedDefinition, + )).toBe(false); + }); + + it('ignores compiled matcher fields on plain definitions', () => { + const definition = { + ...createDefinition({ + id: 'symbol:function', + matchSymbolKinds: ['function'], + matchSymbolFilePath: 'src/**/*.ts', + }), + symbolFilePathMatches: () => true, + }; + + expect(symbolMatchesScopedDefinition( + symbolNode('test/app.ts#main:function', { + id: 'test/app.ts#main:function', + name: 'main', + kind: 'function', + filePath: 'test/app.ts', + }), + definition, + )).toBe(false); + }); +}); + +function createDefinition(input: { + id: string; + matchSymbolKinds?: string[]; + matchSymbolFilePath?: string; +}): IGraphNodeTypeDefinition { + return { + id: input.id, + label: input.id, + defaultColor: '#111111', + defaultVisible: false, + ...(input.matchSymbolKinds ? { matchSymbolKinds: input.matchSymbolKinds } : {}), + ...(input.matchSymbolFilePath ? { matchSymbolFilePath: input.matchSymbolFilePath } : {}), + }; +} From e149d530fe4e7a6862350ae04cff951f3f403cf4 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 17:00:20 -0700 Subject: [PATCH 131/192] test: kill refresh run mutants --- .../provider/refresh/requests/methods.ts | 6 +-- .../graphView/provider/refresh/run.ts | 9 ---- .../provider/refresh/scoped/lifecycle.ts | 5 +- .../provider/refresh/scoped/methods.ts | 6 +-- .../graphView/provider/refresh/run.test.ts | 52 ++++++++++++++++--- 5 files changed, 54 insertions(+), 24 deletions(-) diff --git a/packages/extension/src/extension/graphView/provider/refresh/requests/methods.ts b/packages/extension/src/extension/graphView/provider/refresh/requests/methods.ts index 1295b97f9..511571cf7 100644 --- a/packages/extension/src/extension/graphView/provider/refresh/requests/methods.ts +++ b/packages/extension/src/extension/graphView/provider/refresh/requests/methods.ts @@ -25,7 +25,7 @@ export function createRefreshMethod( prepareRefreshInputs(source); await runPrimaryRefresh(source); - sendRefreshState(source, 'refresh'); + sendRefreshState(source); }; } @@ -73,7 +73,7 @@ export function createRefreshChangedFilesMethod( } const refreshMode = await runChangedFileRefresh(source, filePaths); if (refreshMode !== 'incremental') { - sendRefreshState(source, 'changedFiles'); + sendRefreshState(source); } }; } @@ -83,5 +83,5 @@ async function runIndexRefreshWithInputs( ): Promise { prepareRefreshInputs(source); await runIndexRefresh(source); - sendRefreshState(source, 'refreshIndex'); + sendRefreshState(source); } diff --git a/packages/extension/src/extension/graphView/provider/refresh/run.ts b/packages/extension/src/extension/graphView/provider/refresh/run.ts index 86e799d51..5e0edb0b3 100644 --- a/packages/extension/src/extension/graphView/provider/refresh/run.ts +++ b/packages/extension/src/extension/graphView/provider/refresh/run.ts @@ -1,19 +1,10 @@ import type { IGraphData } from '../../../../shared/graph/contracts'; import type { GraphViewProviderRefreshMethodsSource } from './contracts'; -export type RefreshStateReason = - | 'analysisScope' - | 'changedFiles' - | 'direct' - | 'gitignoreMetadata' - | 'pluginFiles' - | 'refresh' - | 'refreshIndex'; export type ChangedFileRefreshMode = 'analysis' | 'incremental' | 'primary'; export function sendRefreshState( source: GraphViewProviderRefreshMethodsSource, - _reason: RefreshStateReason = 'direct', ): void { source._sendAllSettings(); source._sendGraphControls?.(); diff --git a/packages/extension/src/extension/graphView/provider/refresh/scoped/lifecycle.ts b/packages/extension/src/extension/graphView/provider/refresh/scoped/lifecycle.ts index 210d766ef..f16b3af0a 100644 --- a/packages/extension/src/extension/graphView/provider/refresh/scoped/lifecycle.ts +++ b/packages/extension/src/extension/graphView/provider/refresh/scoped/lifecycle.ts @@ -5,7 +5,7 @@ import type { GraphViewScopedRefreshProgress, ScopedRefreshLifecycle, } from '../contracts'; -import { sendRefreshState, type RefreshStateReason } from '../run'; +import { sendRefreshState } from '../run'; export function createScopedRefreshLifecycle(): ScopedRefreshLifecycle { let scopedRefreshController: AbortController | undefined; @@ -81,14 +81,13 @@ export function publishScopedRefreshGraphData( export function publishGraphDataIfPresent( source: GraphViewProviderRefreshMethodsSource, graphData: IGraphData | undefined, - reason: RefreshStateReason, ): void { if (!graphData) { return; } publishScopedRefreshGraphData(source, graphData); - sendRefreshState(source, reason); + sendRefreshState(source); } function isScopedRefreshStale( diff --git a/packages/extension/src/extension/graphView/provider/refresh/scoped/methods.ts b/packages/extension/src/extension/graphView/provider/refresh/scoped/methods.ts index 2f3702088..a34aa97f5 100644 --- a/packages/extension/src/extension/graphView/provider/refresh/scoped/methods.ts +++ b/packages/extension/src/extension/graphView/provider/refresh/scoped/methods.ts @@ -36,7 +36,7 @@ export function createRefreshAnalysisScopeMethod( ), scopedRefreshLifecycle, ); - publishGraphDataIfPresent(source, graphData, 'analysisScope'); + publishGraphDataIfPresent(source, graphData); }; } @@ -66,7 +66,7 @@ export function createRefreshGitignoreMetadataMethod( ), scopedRefreshLifecycle, ); - publishGraphDataIfPresent(source, graphData, 'gitignoreMetadata'); + publishGraphDataIfPresent(source, graphData); }; } @@ -98,6 +98,6 @@ export function createRefreshPluginFilesMethod( ), scopedRefreshLifecycle, ); - publishGraphDataIfPresent(source, graphData, 'pluginFiles'); + publishGraphDataIfPresent(source, graphData); }; } diff --git a/packages/extension/tests/extension/graphView/provider/refresh/run.test.ts b/packages/extension/tests/extension/graphView/provider/refresh/run.test.ts index ff1a8868f..cf609a6e2 100644 --- a/packages/extension/tests/extension/graphView/provider/refresh/run.test.ts +++ b/packages/extension/tests/extension/graphView/provider/refresh/run.test.ts @@ -27,7 +27,7 @@ describe('graphView/provider/refresh/run', () => { it('sends refresh state even when graph controls are unavailable', () => { const source = createSource({ _sendGraphControls: undefined }); - expect(() => sendRefreshState(source as never, 'refresh')).not.toThrow(); + expect(() => sendRefreshState(source as never)).not.toThrow(); expect(source._sendAllSettings).toHaveBeenCalledOnce(); expect(source._sendFavorites).not.toHaveBeenCalled(); }); @@ -63,8 +63,9 @@ describe('graphView/provider/refresh/run', () => { _loadAndSendData: vi.fn(async () => undefined), }); - await runChangedFileRefresh(source as never, ['src/app.ts']); + const refreshMode = await runChangedFileRefresh(source as never, ['src/app.ts']); + expect(refreshMode).toBe('primary'); expect(source._loadAndSendData).toHaveBeenCalledOnce(); expect(source._incrementalAnalyzeAndSendData).not.toHaveBeenCalled(); }); @@ -75,8 +76,9 @@ describe('graphView/provider/refresh/run', () => { _loadAndSendData: vi.fn(async () => undefined), }); - await runChangedFileRefresh(source as never, ['src/app.ts']); + const refreshMode = await runChangedFileRefresh(source as never, ['src/app.ts']); + expect(refreshMode).toBe('primary'); expect(source._loadAndSendData).toHaveBeenCalledOnce(); expect(source._incrementalAnalyzeAndSendData).not.toHaveBeenCalled(); }); @@ -84,8 +86,9 @@ describe('graphView/provider/refresh/run', () => { it('uses incremental refresh when an indexed analyzer is available', async () => { const source = createSource(); - await runChangedFileRefresh(source as never, ['src/app.ts']); + const refreshMode = await runChangedFileRefresh(source as never, ['src/app.ts']); + expect(refreshMode).toBe('incremental'); expect(source._incrementalAnalyzeAndSendData).toHaveBeenCalledWith(['src/app.ts']); expect(source._analyzeAndSendData).not.toHaveBeenCalled(); expect(source._loadAndSendData).not.toHaveBeenCalled(); @@ -100,8 +103,44 @@ describe('graphView/provider/refresh/run', () => { }, }); - await runChangedFileRefresh(source as never, ['src/app.ts']); + const refreshMode = await runChangedFileRefresh(source as never, ['src/app.ts']); + expect(refreshMode).toBe('incremental'); + expect(source._incrementalAnalyzeAndSendData).toHaveBeenCalledWith(['src/app.ts']); + expect(source._loadAndSendData).not.toHaveBeenCalled(); + expect(source._analyzeAndSendData).not.toHaveBeenCalled(); + }); + + it('uses incremental refresh for an edge-only loaded graph while index metadata is unavailable', async () => { + const source = createSource({ + _analyzer: { hasIndex: vi.fn(() => false) }, + _rawGraphData: { + nodes: [], + edges: [{ from: 'src/app.ts', to: 'src/dep.ts' }], + }, + }); + + const refreshMode = await runChangedFileRefresh(source as never, ['src/app.ts']); + + expect(refreshMode).toBe('incremental'); + expect(source._incrementalAnalyzeAndSendData).toHaveBeenCalledWith(['src/app.ts']); + expect(source._loadAndSendData).not.toHaveBeenCalled(); + expect(source._analyzeAndSendData).not.toHaveBeenCalled(); + }); + + it('uses visible graph data when raw graph data has not loaded yet', async () => { + const source = createSource({ + _analyzer: { hasIndex: vi.fn(() => false) }, + _rawGraphData: undefined, + _graphData: { + nodes: [{ id: 'src/app.ts' }], + edges: [], + }, + }); + + const refreshMode = await runChangedFileRefresh(source as never, ['src/app.ts']); + + expect(refreshMode).toBe('incremental'); expect(source._incrementalAnalyzeAndSendData).toHaveBeenCalledWith(['src/app.ts']); expect(source._loadAndSendData).not.toHaveBeenCalled(); expect(source._analyzeAndSendData).not.toHaveBeenCalled(); @@ -112,8 +151,9 @@ describe('graphView/provider/refresh/run', () => { _incrementalAnalyzeAndSendData: undefined, }); - await runChangedFileRefresh(source as never, ['src/app.ts']); + const refreshMode = await runChangedFileRefresh(source as never, ['src/app.ts']); + expect(refreshMode).toBe('analysis'); expect(source._analyzeAndSendData).toHaveBeenCalledOnce(); }); }); From b96c68376a76706f0a7d7d79c6f59e3c18662df4 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 17:07:39 -0700 Subject: [PATCH 132/192] test: kill edge target cache mutants --- packages/core/src/graph/edgeTargetCache.ts | 28 ++- .../core/tests/graph/edgeTargetCache.test.ts | 159 ++++++++++++++++++ 2 files changed, 178 insertions(+), 9 deletions(-) create mode 100644 packages/core/tests/graph/edgeTargetCache.test.ts diff --git a/packages/core/src/graph/edgeTargetCache.ts b/packages/core/src/graph/edgeTargetCache.ts index e4e521ffc..5ce59fe15 100644 --- a/packages/core/src/graph/edgeTargetCache.ts +++ b/packages/core/src/graph/edgeTargetCache.ts @@ -9,11 +9,22 @@ export function createCachedConnectionTargetResolver( fileConnections: ReadonlyMap, workspaceRoot: string, ): (plugin: IPlugin | undefined, connection: IProjectedConnection) => string | null { - const targetIdByKey = new Map(); + const targetIdByPlugin = new Map>(); return (plugin, connection) => { - const cacheKey = createTargetCacheKey(plugin, connection); - if (cacheKey && targetIdByKey.has(cacheKey)) { + const cacheKey = createTargetCacheKey(connection); + if (!cacheKey) { + return resolveConnectionTargetId( + plugin, + connection, + fileConnections, + workspaceRoot, + ); + } + + const pluginKey = plugin?.id; + const targetIdByKey = targetIdByPlugin.get(pluginKey); + if (targetIdByKey?.has(cacheKey)) { return targetIdByKey.get(cacheKey) ?? null; } @@ -24,24 +35,23 @@ export function createCachedConnectionTargetResolver( workspaceRoot, ); - if (cacheKey) { - targetIdByKey.set(cacheKey, targetId); - } + const pluginTargetIds = targetIdByKey ?? new Map(); + targetIdByPlugin.set(pluginKey, pluginTargetIds); + pluginTargetIds.set(cacheKey, targetId); return targetId; }; } function createTargetCacheKey( - plugin: IPlugin | undefined, connection: IProjectedConnection, ): string | undefined { if (connection.resolvedPath) { - return `${plugin?.id ?? ''}\0resolved\0${connection.resolvedPath}`; + return `resolved\0${connection.resolvedPath}`; } if (connection.specifier) { - return `${plugin?.id ?? ''}\0specifier\0${connection.specifier}`; + return `specifier\0${connection.specifier}`; } return undefined; diff --git a/packages/core/tests/graph/edgeTargetCache.test.ts b/packages/core/tests/graph/edgeTargetCache.test.ts new file mode 100644 index 000000000..8a203bac1 --- /dev/null +++ b/packages/core/tests/graph/edgeTargetCache.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { IPlugin } from '@codegraphy-dev/plugin-api'; +import type { IProjectedConnection } from '../../src/analysis/projectedConnection'; +import { + createCachedConnectionTargetResolver, + type ConnectionTargetResolver, +} from '../../src/graph/edgeTargetCache'; + +function createPlugin(id: string): IPlugin { + return { + id, + name: id, + version: '1.0.0', + apiVersion: '^3.0.0', + supportedExtensions: ['.ts'], + analyzeFile: vi.fn(async (filePath: string) => ({ filePath, relations: [] })), + } as IPlugin; +} + +function createConnection( + overrides: Partial = {}, +): IProjectedConnection { + return { + kind: 'import', + resolvedPath: '/workspace/src/target.ts', + sourceId: 'import', + specifier: './target', + ...overrides, + }; +} + +describe('core/graph/edgeTargetCache', () => { + it('reuses resolved-path targets for the same plugin and connection target', () => { + const fileConnections = new Map(); + const resolveConnectionTargetId = vi.fn(() => 'src/target.ts'); + const resolveCachedTarget = createCachedConnectionTargetResolver( + resolveConnectionTargetId, + fileConnections, + '/workspace', + ); + const plugin = createPlugin('plugin.typescript'); + const connection = createConnection(); + + expect(resolveCachedTarget(plugin, connection)).toBe('src/target.ts'); + expect(resolveCachedTarget(plugin, { ...connection, specifier: './renamed' })).toBe('src/target.ts'); + expect(resolveConnectionTargetId).toHaveBeenCalledOnce(); + expect(resolveConnectionTargetId).toHaveBeenCalledWith( + plugin, + connection, + fileConnections, + '/workspace', + ); + }); + + it('keeps resolved-path cache entries separate by plugin id', () => { + const resolveConnectionTargetId = vi + .fn() + .mockReturnValueOnce('src/typescript.ts') + .mockReturnValueOnce('src/vue.ts'); + const resolveCachedTarget = createCachedConnectionTargetResolver( + resolveConnectionTargetId, + new Map(), + '/workspace', + ); + const connection = createConnection(); + + expect(resolveCachedTarget(createPlugin('plugin.typescript'), connection)).toBe('src/typescript.ts'); + expect(resolveCachedTarget(createPlugin('plugin.vue'), connection)).toBe('src/vue.ts'); + expect(resolveConnectionTargetId).toHaveBeenCalledTimes(2); + }); + + it('caches null resolved-path targets', () => { + const resolveConnectionTargetId = vi.fn(() => null); + const resolveCachedTarget = createCachedConnectionTargetResolver( + resolveConnectionTargetId, + new Map(), + '/workspace', + ); + const connection = createConnection(); + + expect(resolveCachedTarget(undefined, connection)).toBeNull(); + expect(resolveCachedTarget(undefined, connection)).toBeNull(); + expect(resolveConnectionTargetId).toHaveBeenCalledOnce(); + }); + + it('uses the specifier as a cache key when no resolved path exists', () => { + const resolveConnectionTargetId = vi.fn(() => 'pkg:react'); + const resolveCachedTarget = createCachedConnectionTargetResolver( + resolveConnectionTargetId, + new Map(), + '/workspace', + ); + const connection = createConnection({ + resolvedPath: null, + specifier: 'react', + }); + + expect(resolveCachedTarget(createPlugin('plugin.typescript'), connection)).toBe('pkg:react'); + expect(resolveCachedTarget(createPlugin('plugin.typescript'), connection)).toBe('pkg:react'); + expect(resolveConnectionTargetId).toHaveBeenCalledOnce(); + }); + + it('keeps specifier cache entries separate by plugin id', () => { + const resolveConnectionTargetId = vi + .fn() + .mockReturnValueOnce('pkg:typescript-react') + .mockReturnValueOnce('pkg:vue-react'); + const resolveCachedTarget = createCachedConnectionTargetResolver( + resolveConnectionTargetId, + new Map(), + '/workspace', + ); + const connection = createConnection({ + resolvedPath: null, + specifier: 'react', + }); + + expect(resolveCachedTarget(createPlugin('plugin.typescript'), connection)).toBe('pkg:typescript-react'); + expect(resolveCachedTarget(createPlugin('plugin.vue'), connection)).toBe('pkg:vue-react'); + expect(resolveConnectionTargetId).toHaveBeenCalledTimes(2); + }); + + it('caches specifier targets without a plugin', () => { + const resolveConnectionTargetId = vi.fn(() => 'pkg:react'); + const resolveCachedTarget = createCachedConnectionTargetResolver( + resolveConnectionTargetId, + new Map(), + '/workspace', + ); + const connection = createConnection({ + resolvedPath: null, + specifier: 'react', + }); + + expect(resolveCachedTarget(undefined, connection)).toBe('pkg:react'); + expect(resolveCachedTarget(undefined, connection)).toBe('pkg:react'); + expect(resolveConnectionTargetId).toHaveBeenCalledOnce(); + }); + + it('does not cache connections without a resolved path or specifier', () => { + const resolveConnectionTargetId = vi + .fn() + .mockReturnValueOnce(null) + .mockReturnValueOnce('dynamic-target'); + const resolveCachedTarget = createCachedConnectionTargetResolver( + resolveConnectionTargetId, + new Map(), + '/workspace', + ); + const connection = createConnection({ + resolvedPath: null, + specifier: '', + }); + + expect(resolveCachedTarget(undefined, connection)).toBeNull(); + expect(resolveCachedTarget(undefined, connection)).toBe('dynamic-target'); + expect(resolveConnectionTargetId).toHaveBeenCalledTimes(2); + }); +}); From 7a7e7e424c0e5dbc6c909df93a2832317adc00e8 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 17:16:35 -0700 Subject: [PATCH 133/192] test: kill changed-file refresh mutants --- .../refresh/modes/changedFiles.test.ts | 262 ++++++++++++++++++ 1 file changed, 262 insertions(+) create mode 100644 packages/core/tests/indexing/refresh/modes/changedFiles.test.ts diff --git a/packages/core/tests/indexing/refresh/modes/changedFiles.test.ts b/packages/core/tests/indexing/refresh/modes/changedFiles.test.ts new file mode 100644 index 000000000..4cd9940db --- /dev/null +++ b/packages/core/tests/indexing/refresh/modes/changedFiles.test.ts @@ -0,0 +1,262 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { IFileAnalysisResult } from '@codegraphy-dev/plugin-api'; +import type { IDiscoveredFile } from '../../../../src/discovery/contracts'; +import type { IGraphData } from '../../../../src/graph/contracts'; +import { refreshWorkspaceIndexChangedFiles } from '../../../../src/indexing/refresh'; +import { + createDiscoveredFile, + createFileAnalysis, + createGraphNode, + createSource, + refreshOptions, +} from '../fixture'; + +describe('indexing/refresh/modes/changedFiles', () => { + it('records discovery state, invalidates changed files, and forwards incremental progress', async () => { + const onProgress = vi.fn(); + const discoveredFiles = [ + createDiscoveredFile('src/app.ts'), + createDiscoveredFile('src/generated.ts'), + ]; + const source = createSource({ + _analyzeFiles: vi.fn(async (files: IDiscoveredFile[], _workspaceRoot, onFileProgress) => { + onFileProgress?.({ + current: 1, + filePath: '/workspace/src/app.ts', + total: files.length, + }); + return createAnalysisResult(files.map(file => file.relativePath)); + }), + }); + + await refreshWorkspaceIndexChangedFiles(source, refreshOptions({ + discoveredDirectories: ['src', 'generated'], + discoveredFiles, + notifyFilesChanged: vi.fn(async () => ({ + additionalFilePaths: ['src/generated.ts'], + requiresFullRefresh: false, + })), + onProgress, + })); + + expect(source._lastDiscoveredDirectories).toEqual(['src', 'generated']); + expect(source._lastDiscoveredFiles).toBe(discoveredFiles); + expect(source.invalidateWorkspaceFiles).toHaveBeenCalledWith([ + '/workspace/src/app.ts', + '/workspace/src/generated.ts', + ]); + expect(onProgress).toHaveBeenNthCalledWith(1, { + phase: 'Applying Changes', + current: 0, + total: 2, + }); + expect(onProgress).toHaveBeenNthCalledWith(2, { + phase: 'Applying Changes', + current: 1, + total: 2, + }); + }); + + it('rebuilds from retained analysis without analyzing when no files remain to refresh', async () => { + const graph: IGraphData = { + nodes: [createGraphNode('src/app.ts')], + edges: [], + }; + const persistCache = vi.fn(); + const source = createSource({ + _buildGraphDataFromAnalysis: vi.fn(() => graph), + _lastFileAnalysis: new Map([ + ['src/app.ts', createFileAnalysis('/workspace/src/app.ts')], + ]), + _lastFileConnections: new Map([ + ['src/app.ts', []], + ]), + }); + + await expect(refreshWorkspaceIndexChangedFiles(source, refreshOptions({ + discoveredDirectories: undefined, + discoveredFiles: [createDiscoveredFile('src/app.ts')], + filePaths: ['/outside/src/app.ts'], + persistCache, + }))).resolves.toBe(graph); + + expect(source._lastDiscoveredDirectories).toEqual([]); + expect(source._analyzeFiles).not.toHaveBeenCalled(); + expect(source.invalidateWorkspaceFiles).not.toHaveBeenCalled(); + expect(persistCache).not.toHaveBeenCalled(); + }); + + it('does not require an incremental progress callback', async () => { + const source = createSource({ + _analyzeFiles: vi.fn(async (files: IDiscoveredFile[], _workspaceRoot, onFileProgress) => { + onFileProgress?.({ + current: 1, + filePath: '/workspace/src/app.ts', + total: files.length, + }); + return createAnalysisResult(files.map(file => file.relativePath)); + }), + }); + + await expect(refreshWorkspaceIndexChangedFiles(source, refreshOptions({ + onProgress: undefined, + }))).resolves.toBeDefined(); + expect(source._analyzeFiles).toHaveBeenCalledOnce(); + }); + + it('labels fallback full-analysis progress as applying changes when no phase is provided', async () => { + const graph: IGraphData = { nodes: [], edges: [] }; + const onProgress = vi.fn(); + const source = createSource({ + analyze: vi.fn(async (_filterPatterns, _disabledPlugins, _signal, reportProgress) => { + reportProgress?.({ phase: '', current: 1, total: 2 }); + reportProgress?.({ phase: 'Scanning', current: 2, total: 2 }); + return graph; + }), + }); + + await expect(refreshWorkspaceIndexChangedFiles(source, refreshOptions({ + filePaths: ['/workspace/src/deleted.ts'], + onProgress, + }))).resolves.toBe(graph); + + expect(onProgress).toHaveBeenNthCalledWith(1, { + phase: 'Applying Changes', + current: 1, + total: 2, + }); + expect(onProgress).toHaveBeenNthCalledWith(2, { + phase: 'Scanning', + current: 2, + total: 2, + }); + }); + + it('does not require a fallback full-analysis progress callback', async () => { + const graph: IGraphData = { nodes: [], edges: [] }; + const source = createSource({ + analyze: vi.fn(async (_filterPatterns, _disabledPlugins, _signal, reportProgress) => { + reportProgress?.({ phase: '', current: 1, total: 1 }); + return graph; + }), + }); + + await expect(refreshWorkspaceIndexChangedFiles(source, refreshOptions({ + filePaths: ['/workspace/src/deleted.ts'], + onProgress: undefined, + }))).resolves.toBe(graph); + }); + + it('waits for metric-only metadata persistence when it is not deferred', async () => { + const graph: IGraphData = { + nodes: [createGraphNode('src/app.ts')], + edges: [], + }; + const source = createMetricOnlyPatchSource(graph); + let resolvePersistence: () => void = () => undefined; + const persistIndexMetadata = vi.fn(() => new Promise(resolve => { + resolvePersistence = resolve; + })); + + const refreshPromise = refreshWorkspaceIndexChangedFiles(source, refreshOptions({ + persistIndexMetadata, + })); + const onSettled = vi.fn(); + void refreshPromise.then(onSettled); + await flushMicrotasks(); + + expect(onSettled).not.toHaveBeenCalled(); + expect(persistIndexMetadata).toHaveBeenCalledOnce(); + + resolvePersistence(); + await expect(refreshPromise).resolves.toBe(graph); + expect(onSettled).toHaveBeenCalledWith(graph); + }); + + it('reports deferred metric-only metadata persistence errors without blocking graph data', async () => { + const graph: IGraphData = { + nodes: [createGraphNode('src/app.ts')], + edges: [], + }; + const source = createMetricOnlyPatchSource(graph); + const error = new Error('metadata write failed'); + const onDeferredIndexMetadataError = vi.fn(); + + await expect(refreshWorkspaceIndexChangedFiles(source, refreshOptions({ + deferMetricOnlyIndexMetadata: true, + onDeferredIndexMetadataError, + persistIndexMetadata: vi.fn(() => Promise.reject(error)), + }))).resolves.toBe(graph); + await Promise.resolve(); + + expect(onDeferredIndexMetadataError).toHaveBeenCalledWith(error); + }); + + it('does not require a deferred metadata error callback', async () => { + const graph: IGraphData = { + nodes: [createGraphNode('src/app.ts')], + edges: [], + }; + const source = createMetricOnlyPatchSource(graph); + let caughtError: unknown; + + await expect(refreshWorkspaceIndexChangedFiles(source, refreshOptions({ + deferMetricOnlyIndexMetadata: true, + onDeferredIndexMetadataError: undefined, + persistIndexMetadata: vi.fn(() => createCapturedRejection(new Error('metadata write failed'), error => { + caughtError = error; + })), + }))).resolves.toBe(graph); + + expect(caughtError).toBeUndefined(); + }); +}); + +function createAnalysisResult(relativePaths: string[]) { + return { + cacheHits: 0, + cacheMisses: relativePaths.length, + fileAnalysis: new Map( + relativePaths.map(relativePath => [ + relativePath, + createFileAnalysis(`/workspace/${relativePath}`), + ]), + ), + fileConnections: new Map(relativePaths.map(relativePath => [relativePath, []])), + }; +} + +function createMetricOnlyPatchSource(graph: IGraphData) { + return createSource({ + _lastFileAnalysis: new Map([ + ['src/app.ts', createFileAnalysis('/workspace/src/app.ts')], + ]), + _lastFileConnections: new Map([ + ['src/app.ts', []], + ]), + _patchGraphDataNodeMetrics: vi.fn(() => graph), + }); +} + +async function flushMicrotasks(): Promise { + for (let index = 0; index < 5; index += 1) { + await Promise.resolve(); + } +} + +function createCapturedRejection( + error: Error, + onCaughtError: (error: unknown) => void, +): Promise { + return { + catch(onRejected?: (error: unknown) => unknown) { + try { + onRejected?.(error); + } catch (caughtError) { + onCaughtError(caughtError); + } + return Promise.resolve(); + }, + } as Promise; +} From 143eac794924d4039baeaa22465d3b2bf1c403c3 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 17:21:16 -0700 Subject: [PATCH 134/192] test: kill refresh graph mutants --- packages/core/src/indexing/refresh/graph.ts | 4 - .../core/tests/indexing/refresh/graph.test.ts | 123 ++++++++++++++++++ 2 files changed, 123 insertions(+), 4 deletions(-) create mode 100644 packages/core/tests/indexing/refresh/graph.test.ts diff --git a/packages/core/src/indexing/refresh/graph.ts b/packages/core/src/indexing/refresh/graph.ts index ebc5de17a..ba65e35f4 100644 --- a/packages/core/src/indexing/refresh/graph.ts +++ b/packages/core/src/indexing/refresh/graph.ts @@ -59,10 +59,6 @@ function workspaceIndexAnalysisCoversConnections( fileConnections: ReadonlyMap, workspaceRoot: string, ): boolean { - if (fileConnections.size === 0) { - return true; - } - const analysisFilePaths = new Set( [...fileAnalysis.keys()].map(filePath => toRepoRelativeGraphPath(filePath, workspaceRoot), diff --git a/packages/core/tests/indexing/refresh/graph.test.ts b/packages/core/tests/indexing/refresh/graph.test.ts new file mode 100644 index 000000000..ffbb434a5 --- /dev/null +++ b/packages/core/tests/indexing/refresh/graph.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { IGraphData, IGraphEdge } from '../../../src/graph/contracts'; +import { buildWorkspaceIndexGraphFromRefreshState } from '../../../src/indexing/refresh/graph'; +import { + createFileAnalysis, + createGraphNode, + createSource, +} from './fixture'; + +describe('indexing/refresh/graph', () => { + it('uses the analysis graph directly when there are no retained file connections', () => { + const analysisGraph: IGraphData = { + nodes: [createGraphNode('src/app.ts')], + edges: [], + }; + const source = createSource({ + _buildGraphData: vi.fn(() => ({ + nodes: [createGraphNode('fallback')], + edges: [], + })), + _buildGraphDataFromAnalysis: vi.fn(() => analysisGraph), + _lastFileAnalysis: new Map([ + ['src/app.ts', createFileAnalysis('/workspace/src/app.ts')], + ]), + _lastFileConnections: new Map(), + }); + + expect(buildWorkspaceIndexGraphFromRefreshState( + source, + '/workspace', + new Set(), + )).toBe(analysisGraph); + expect(source._buildGraphData).not.toHaveBeenCalled(); + expect(source._lastGraphData).toBe(analysisGraph); + }); + + it('merges fallback graph data without duplicating nodes or edges', () => { + const duplicateExplicitEdge = createEdge('src/app.ts', 'src/dep.ts', 'import', 'edge:app-dep'); + const duplicateDerivedEdge = createEdge('src/app.ts', 'src/implicit.ts', 'import', undefined); + const uniqueDerivedEdge = createEdge('src/app.ts', 'src/fallback.ts', 'import', undefined); + const uniqueExplicitEdge = createEdge('src/app.ts', 'src/extra.ts', 'call', 'edge:app-extra'); + const analysisGraph: IGraphData = { + nodes: [ + createGraphNode('src/app.ts'), + createGraphNode('src/dep.ts'), + ], + edges: [ + duplicateExplicitEdge, + duplicateDerivedEdge, + ], + }; + const fallbackGraph: IGraphData = { + nodes: [ + createGraphNode('src/app.ts'), + createGraphNode('src/fallback.ts'), + ], + edges: [ + createEdge('src/app.ts', 'src/dep.ts', 'import', 'edge:app-dep'), + createEdge('src/app.ts', 'src/implicit.ts', 'import', undefined), + uniqueDerivedEdge, + uniqueExplicitEdge, + ], + }; + const source = createSource({ + _buildGraphData: vi.fn(() => fallbackGraph), + _buildGraphDataFromAnalysis: vi.fn(() => analysisGraph), + _lastFileAnalysis: new Map([ + ['src/app.ts', createFileAnalysis('/workspace/src/app.ts')], + ]), + _lastFileConnections: new Map([ + ['src/app.ts', []], + ['src/fallback.ts', []], + ]), + }); + + expect(buildWorkspaceIndexGraphFromRefreshState( + source, + '/workspace', + new Set(), + )).toEqual({ + nodes: [ + createGraphNode('src/app.ts'), + createGraphNode('src/dep.ts'), + createGraphNode('src/fallback.ts'), + ], + edges: [ + duplicateExplicitEdge, + duplicateDerivedEdge, + uniqueDerivedEdge, + uniqueExplicitEdge, + ], + }); + expect(source._lastGraphData).toEqual({ + nodes: [ + createGraphNode('src/app.ts'), + createGraphNode('src/dep.ts'), + createGraphNode('src/fallback.ts'), + ], + edges: [ + duplicateExplicitEdge, + duplicateDerivedEdge, + uniqueDerivedEdge, + uniqueExplicitEdge, + ], + }); + }); +}); + +function createEdge( + from: string, + to: string, + kind: IGraphEdge['kind'], + id: string | undefined, +): IGraphEdge { + return { + id: id as string, + from, + to, + kind, + sources: [], + }; +} From ec8cd31be317645520c81437bba91eaa832e5876 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 17:26:51 -0700 Subject: [PATCH 135/192] test: kill refresh mode mutants --- .../src/indexing/refresh/modes/pluginFiles.ts | 9 - .../refresh/modes/analysisScope.test.ts | 105 ++++++++++++ .../refresh/modes/pluginFiles.test.ts | 156 ++++++++++++++++++ 3 files changed, 261 insertions(+), 9 deletions(-) create mode 100644 packages/core/tests/indexing/refresh/modes/analysisScope.test.ts create mode 100644 packages/core/tests/indexing/refresh/modes/pluginFiles.test.ts diff --git a/packages/core/src/indexing/refresh/modes/pluginFiles.ts b/packages/core/src/indexing/refresh/modes/pluginFiles.ts index 8796614f4..c9707b414 100644 --- a/packages/core/src/indexing/refresh/modes/pluginFiles.ts +++ b/packages/core/src/indexing/refresh/modes/pluginFiles.ts @@ -26,15 +26,6 @@ export async function refreshWorkspaceIndexPluginFiles( dependencies.pluginIds, ); const registeredPluginIds = pluginInfos.map(({ plugin }) => plugin.id); - if (pluginInfos.length === 0) { - const graphData = buildWorkspaceIndexGraphFromRefreshState( - source, - dependencies.workspaceRoot, - dependencies.disabledPlugins, - ); - await dependencies.persistIndexMetadata(); - return graphData; - } const pluginFiles = selectWorkspaceIndexPluginFiles(pluginInfos, dependencies.discoveredFiles); if (pluginFiles.length > 0) { diff --git a/packages/core/tests/indexing/refresh/modes/analysisScope.test.ts b/packages/core/tests/indexing/refresh/modes/analysisScope.test.ts new file mode 100644 index 000000000..3f0602544 --- /dev/null +++ b/packages/core/tests/indexing/refresh/modes/analysisScope.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { IFileAnalysisResult } from '@codegraphy-dev/plugin-api'; +import type { IDiscoveredFile } from '../../../../src/discovery/contracts'; +import { refreshWorkspaceIndexAnalysisScope } from '../../../../src/indexing/refresh'; +import { + createDiscoveredFile, + createFileAnalysis, + createSource, +} from '../fixture'; + +describe('indexing/refresh/modes/analysisScope', () => { + it('records discovery state and forwards analysis progress as scope progress', async () => { + const onProgress = vi.fn(); + const persistCache = vi.fn(); + const persistIndexMetadata = vi.fn(); + const discoveredFiles = [ + createDiscoveredFile('src/app.ts'), + createDiscoveredFile('src/dep.ts'), + ]; + const source = createSource({ + _analyzeFiles: vi.fn(async (files: IDiscoveredFile[], _workspaceRoot, onFileProgress) => { + onFileProgress?.({ + current: 1, + filePath: '/workspace/src/app.ts', + total: files.length, + }); + return createAnalysisResult(files); + }), + }); + + await refreshWorkspaceIndexAnalysisScope(source, { + disabledPlugins: new Set(), + discoveredDirectories: ['src'], + discoveredFiles, + onProgress, + persistCache, + persistIndexMetadata, + signal: undefined, + workspaceRoot: '/workspace', + }); + + expect(source._lastDiscoveredDirectories).toEqual(['src']); + expect(source._lastDiscoveredFiles).toEqual(discoveredFiles); + expect(source._analyzeFiles).toHaveBeenCalledWith( + discoveredFiles, + '/workspace', + expect.any(Function), + undefined, + undefined, + new Set(), + ); + expect(onProgress).toHaveBeenNthCalledWith(1, { + phase: 'Applying Scope', + current: 0, + total: 2, + }); + expect(onProgress).toHaveBeenNthCalledWith(2, { + phase: 'Applying Scope', + current: 1, + total: 2, + }); + expect(persistCache).toHaveBeenCalledOnce(); + expect(persistIndexMetadata).toHaveBeenCalledOnce(); + }); + + it('does not require a scope progress callback', async () => { + const discoveredFiles = [createDiscoveredFile('src/app.ts')]; + const source = createSource({ + _analyzeFiles: vi.fn(async (files: IDiscoveredFile[], _workspaceRoot, onFileProgress) => { + onFileProgress?.({ + current: 1, + filePath: '/workspace/src/app.ts', + total: files.length, + }); + return createAnalysisResult(files); + }), + }); + + await expect(refreshWorkspaceIndexAnalysisScope(source, { + disabledPlugins: new Set(), + discoveredFiles, + onProgress: undefined, + persistCache: vi.fn(), + persistIndexMetadata: vi.fn(), + signal: undefined, + workspaceRoot: '/workspace', + })).resolves.toBeDefined(); + expect(source._analyzeFiles).toHaveBeenCalledOnce(); + }); +}); + +function createAnalysisResult(files: IDiscoveredFile[]) { + return { + cacheHits: 0, + cacheMisses: files.length, + fileAnalysis: new Map( + files.map(file => [ + file.relativePath, + createFileAnalysis(file.absolutePath), + ]), + ), + fileConnections: new Map(files.map(file => [file.relativePath, []])), + }; +} diff --git a/packages/core/tests/indexing/refresh/modes/pluginFiles.test.ts b/packages/core/tests/indexing/refresh/modes/pluginFiles.test.ts new file mode 100644 index 000000000..60cde24ce --- /dev/null +++ b/packages/core/tests/indexing/refresh/modes/pluginFiles.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { IFileAnalysisResult } from '@codegraphy-dev/plugin-api'; +import type { IDiscoveredFile } from '../../../../src/discovery/contracts'; +import { refreshWorkspaceIndexPluginFiles } from '../../../../src/indexing/refresh'; +import { + createDiscoveredFile, + createFileAnalysis, + createSource, +} from '../fixture'; + +describe('indexing/refresh/modes/pluginFiles', () => { + it('rebuilds from retained state when no requested plugins are registered', async () => { + const persistIndexMetadata = vi.fn(); + const source = createSource(); + + await refreshWorkspaceIndexPluginFiles(source, { + disabledPlugins: new Set(), + discoveredFiles: [createDiscoveredFile('src/app.ts')], + onProgress: vi.fn(), + persistCache: vi.fn(), + persistIndexMetadata, + pluginIds: ['codegraphy.missing'], + pluginInfos: [createPluginInfo('codegraphy.typescript', ['.ts'])], + signal: undefined, + workspaceRoot: '/workspace', + }); + + expect(source._analyzeFiles).not.toHaveBeenCalled(); + expect(persistIndexMetadata).toHaveBeenCalledOnce(); + }); + + it('skips analysis and progress when registered plugins have no matching files', async () => { + const onProgress = vi.fn(); + const persistCache = vi.fn(); + const source = createSource(); + + await refreshWorkspaceIndexPluginFiles(source, { + disabledPlugins: new Set(), + discoveredFiles: [createDiscoveredFile('src/app.ts')], + onProgress, + persistCache, + persistIndexMetadata: vi.fn(), + pluginIds: ['codegraphy.python'], + pluginInfos: [createPluginInfo('codegraphy.python', ['.py'])], + signal: undefined, + workspaceRoot: '/workspace', + }); + + expect(source._analyzeFiles).not.toHaveBeenCalled(); + expect(onProgress).not.toHaveBeenCalled(); + expect(persistCache).not.toHaveBeenCalled(); + }); + + it('analyzes matching plugin files and forwards plugin progress', async () => { + const onProgress = vi.fn(); + const persistCache = vi.fn(); + const persistIndexMetadata = vi.fn(); + const discoveredFiles = [ + createDiscoveredFile('README.md'), + createDiscoveredFile('src/app.ts'), + ]; + const source = createSource({ + _analyzeFiles: vi.fn(async (files: IDiscoveredFile[], _workspaceRoot, onFileProgress) => { + onFileProgress?.({ + current: 1, + filePath: '/workspace/src/app.ts', + total: files.length, + }); + return createAnalysisResult(files); + }), + }); + + await refreshWorkspaceIndexPluginFiles(source, { + disabledPlugins: new Set(), + discoveredFiles, + onProgress, + persistCache, + persistIndexMetadata, + pluginIds: ['codegraphy.typescript'], + pluginInfos: [createPluginInfo('codegraphy.typescript', ['.ts'])], + signal: undefined, + workspaceRoot: '/workspace', + }); + + expect(source._analyzeFiles).toHaveBeenCalledWith( + [createDiscoveredFile('src/app.ts')], + '/workspace', + expect.any(Function), + undefined, + ['codegraphy.typescript'], + new Set(), + ); + expect(onProgress).toHaveBeenNthCalledWith(1, { + phase: 'Applying Plugin', + current: 0, + total: 1, + }); + expect(onProgress).toHaveBeenNthCalledWith(2, { + phase: 'Applying Plugin', + current: 1, + total: 1, + }); + expect(persistCache).toHaveBeenCalledOnce(); + expect(persistIndexMetadata).toHaveBeenCalledOnce(); + }); + + it('does not require a plugin progress callback', async () => { + const source = createSource({ + _analyzeFiles: vi.fn(async (files: IDiscoveredFile[], _workspaceRoot, onFileProgress) => { + onFileProgress?.({ + current: 1, + filePath: '/workspace/src/app.ts', + total: files.length, + }); + return createAnalysisResult(files); + }), + }); + + await expect(refreshWorkspaceIndexPluginFiles(source, { + disabledPlugins: new Set(), + discoveredFiles: [createDiscoveredFile('src/app.ts')], + onProgress: undefined, + persistCache: vi.fn(), + persistIndexMetadata: vi.fn(), + pluginIds: ['codegraphy.typescript'], + pluginInfos: [createPluginInfo('codegraphy.typescript', ['.ts'])], + signal: undefined, + workspaceRoot: '/workspace', + })).resolves.toBeDefined(); + expect(source._analyzeFiles).toHaveBeenCalledOnce(); + }); +}); + +function createPluginInfo(id: string, supportedExtensions: readonly string[]) { + return { + plugin: { + id, + supportedExtensions, + }, + }; +} + +function createAnalysisResult(files: IDiscoveredFile[]) { + return { + cacheHits: 0, + cacheMisses: files.length, + fileAnalysis: new Map( + files.map(file => [ + file.relativePath, + createFileAnalysis(file.absolutePath), + ]), + ), + fileConnections: new Map(files.map(file => [file.relativePath, []])), + }; +} From 357d9b4a7058bd82e2f8a4e3dac15a80b5635f9e Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 17:32:10 -0700 Subject: [PATCH 136/192] test: kill refresh snapshot mutants --- .../indexing/refresh/snapshot/capture.test.ts | 142 ++++++++++++++++++ .../refresh/snapshot/eligibility.test.ts | 46 ++++++ .../refresh/snapshot/serialization.test.ts | 76 ++++++++++ 3 files changed, 264 insertions(+) create mode 100644 packages/core/tests/indexing/refresh/snapshot/capture.test.ts create mode 100644 packages/core/tests/indexing/refresh/snapshot/eligibility.test.ts create mode 100644 packages/core/tests/indexing/refresh/snapshot/serialization.test.ts diff --git a/packages/core/tests/indexing/refresh/snapshot/capture.test.ts b/packages/core/tests/indexing/refresh/snapshot/capture.test.ts new file mode 100644 index 000000000..1f09023c8 --- /dev/null +++ b/packages/core/tests/indexing/refresh/snapshot/capture.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { IWorkspaceFileAnalysisResult } from '../../../../src/analysis/fileAnalysis'; +import type { IProjectedConnection } from '../../../../src/analysis/projectedConnection'; +import { + canPatchWorkspaceIndexRefreshGraphData, + captureWorkspaceIndexRefreshGraphSnapshot, +} from '../../../../src/indexing/refresh/snapshot/capture'; +import { + createDiscoveredFile, + createFileAnalysis, + createSource, +} from '../fixture'; + +describe('indexing/refresh/snapshot/capture', () => { + it('does not capture when metric patching is unavailable', () => { + const source = createSource({ + _lastFileAnalysis: new Map([ + ['src/app.ts', createFileAnalysis('/workspace/src/app.ts')], + ]), + }); + + expect(captureWorkspaceIndexRefreshGraphSnapshot(source, [ + createDiscoveredFile('src/app.ts'), + ])).toBeUndefined(); + }); + + it('does not capture when any requested file is missing previous analysis', () => { + const source = createSource({ + _lastFileAnalysis: new Map([ + ['src/app.ts', createFileAnalysis('/workspace/src/app.ts')], + ]), + _patchGraphDataNodeMetrics: vi.fn(), + }); + + expect(captureWorkspaceIndexRefreshGraphSnapshot(source, [ + createDiscoveredFile('src/app.ts'), + createDiscoveredFile('src/missing.ts'), + ])).toBeUndefined(); + }); + + it('cannot patch graph data without a captured snapshot', () => { + expect(canPatchWorkspaceIndexRefreshGraphData( + undefined, + createAnalysisResult(['src/app.ts']), + [createDiscoveredFile('src/app.ts')], + )).toBe(false); + }); + + it('requires updated analysis for every captured file', () => { + const files = [ + createDiscoveredFile('src/app.ts'), + createDiscoveredFile('src/dep.ts'), + ]; + const snapshot = captureWorkspaceIndexRefreshGraphSnapshot( + createSnapshotSource(['src/app.ts', 'src/dep.ts']), + files, + ); + + expect(canPatchWorkspaceIndexRefreshGraphData( + snapshot, + createAnalysisResult(['src/app.ts']), + files, + )).toBe(false); + }); + + it('does not patch graph data when file connections change', () => { + const file = createDiscoveredFile('src/app.ts'); + const snapshot = captureWorkspaceIndexRefreshGraphSnapshot( + createSnapshotSource(['src/app.ts'], new Map([ + ['src/app.ts', [createConnection('./dep', '/workspace/src/dep.ts')]], + ])), + [file], + ); + + expect(canPatchWorkspaceIndexRefreshGraphData( + snapshot, + createAnalysisResult(['src/app.ts'], new Map([ + ['src/app.ts', [createConnection('./next', '/workspace/src/next.ts')]], + ])), + [file], + )).toBe(false); + }); + + it('patches graph data when captured analysis and connections still match', () => { + const file = createDiscoveredFile('src/app.ts'); + const connections = new Map([ + ['src/app.ts', [createConnection('./dep', '/workspace/src/dep.ts')]], + ]); + const snapshot = captureWorkspaceIndexRefreshGraphSnapshot( + createSnapshotSource(['src/app.ts'], connections), + [file], + ); + + expect(canPatchWorkspaceIndexRefreshGraphData( + snapshot, + createAnalysisResult(['src/app.ts'], connections), + [file], + )).toBe(true); + }); +}); + +function createSnapshotSource( + relativePaths: string[], + fileConnections = new Map(), +) { + return createSource({ + _lastFileAnalysis: new Map(relativePaths.map(relativePath => [ + relativePath, + createFileAnalysis(`/workspace/${relativePath}`), + ])), + _lastFileConnections: fileConnections, + _patchGraphDataNodeMetrics: vi.fn(), + }); +} + +function createAnalysisResult( + relativePaths: string[], + fileConnections = new Map(), +): IWorkspaceFileAnalysisResult { + return { + cacheHits: 0, + cacheMisses: relativePaths.length, + fileAnalysis: new Map(relativePaths.map(relativePath => [ + relativePath, + createFileAnalysis(`/workspace/${relativePath}`), + ])), + fileConnections, + }; +} + +function createConnection( + specifier: string, + resolvedPath: string, +): IProjectedConnection { + return { + kind: 'import', + resolvedPath, + sourceId: 'import', + specifier, + }; +} diff --git a/packages/core/tests/indexing/refresh/snapshot/eligibility.test.ts b/packages/core/tests/indexing/refresh/snapshot/eligibility.test.ts new file mode 100644 index 000000000..cb243ff04 --- /dev/null +++ b/packages/core/tests/indexing/refresh/snapshot/eligibility.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { canCaptureWorkspaceIndexRefreshGraphSnapshot } from '../../../../src/indexing/refresh/snapshot/eligibility'; +import { createGraphNode, createSource } from '../fixture'; + +describe('indexing/refresh/snapshot/eligibility', () => { + it('requires the metric patch helper', () => { + expect(canCaptureWorkspaceIndexRefreshGraphSnapshot(createSource())).toBe(false); + }); + + it('does not capture an empty graph', () => { + const source = createSource({ + _lastGraphData: { nodes: [], edges: [] }, + _patchGraphDataNodeMetrics: vi.fn(), + }); + + expect(canCaptureWorkspaceIndexRefreshGraphSnapshot(source)).toBe(false); + }); + + it('can capture a graph with nodes', () => { + const source = createSource({ + _lastGraphData: { nodes: [createGraphNode('src/app.ts')], edges: [] }, + _patchGraphDataNodeMetrics: vi.fn(), + }); + + expect(canCaptureWorkspaceIndexRefreshGraphSnapshot(source)).toBe(true); + }); + + it('can capture an edge-only graph', () => { + const source = createSource({ + _lastGraphData: { + nodes: [], + edges: [{ + id: 'src/app.ts->src/dep.ts#import', + from: 'src/app.ts', + to: 'src/dep.ts', + kind: 'import', + sources: [], + }], + }, + _patchGraphDataNodeMetrics: vi.fn(), + }); + + expect(canCaptureWorkspaceIndexRefreshGraphSnapshot(source)).toBe(true); + }); +}); diff --git a/packages/core/tests/indexing/refresh/snapshot/serialization.test.ts b/packages/core/tests/indexing/refresh/snapshot/serialization.test.ts new file mode 100644 index 000000000..01d219f68 --- /dev/null +++ b/packages/core/tests/indexing/refresh/snapshot/serialization.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest'; + +import type { IFileAnalysisResult } from '@codegraphy-dev/plugin-api'; +import type { IProjectedConnection } from '../../../../src/analysis/projectedConnection'; +import { + serializeWorkspaceIndexConnections, + serializeWorkspaceIndexGraphAnalysis, +} from '../../../../src/indexing/refresh/snapshot/serialization'; + +describe('indexing/refresh/snapshot/serialization', () => { + it('serializes missing analysis collections as empty lists', () => { + expect(serializeWorkspaceIndexGraphAnalysis({ + filePath: '/workspace/src/app.ts', + })).toBe(JSON.stringify({ + edgeTypes: [], + filePath: '/workspace/src/app.ts', + nodeTypes: [], + nodes: [], + relations: [], + symbols: [], + })); + }); + + it('preserves non-empty analysis collections', () => { + const analysis: IFileAnalysisResult = { + filePath: '/workspace/src/app.ts', + edgeTypes: [{ + id: 'import', + label: 'Import', + defaultColor: '#ffffff', + defaultVisible: true, + }], + nodeTypes: [{ + id: 'file', + label: 'File', + defaultColor: '#ffffff', + defaultVisible: true, + }], + nodes: [{ + id: 'src/app.ts#App', + nodeType: 'symbol', + label: 'App', + filePath: '/workspace/src/app.ts', + }], + relations: [{ + kind: 'import', + sourceId: 'import', + fromFilePath: '/workspace/src/app.ts', + toFilePath: '/workspace/src/dep.ts', + }], + symbols: [{ + id: 'src/app.ts#App', + name: 'App', + kind: 'class', + filePath: '/workspace/src/app.ts', + }], + }; + + expect(JSON.parse(serializeWorkspaceIndexGraphAnalysis(analysis))).toEqual(analysis); + }); + + it('serializes missing connections as an empty list', () => { + expect(serializeWorkspaceIndexConnections(undefined)).toBe('[]'); + }); + + it('preserves non-empty connections', () => { + const connections: IProjectedConnection[] = [{ + kind: 'import', + resolvedPath: '/workspace/src/dep.ts', + sourceId: 'import', + specifier: './dep', + }]; + + expect(JSON.parse(serializeWorkspaceIndexConnections(connections))).toEqual(connections); + }); +}); From df160c7886467e5d61cd37c67ed369f23ae4fea6 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 17:35:02 -0700 Subject: [PATCH 137/192] test: kill refresh state mutants --- .../core/tests/indexing/refresh/state.test.ts | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 packages/core/tests/indexing/refresh/state.test.ts diff --git a/packages/core/tests/indexing/refresh/state.test.ts b/packages/core/tests/indexing/refresh/state.test.ts new file mode 100644 index 000000000..e9c175dd7 --- /dev/null +++ b/packages/core/tests/indexing/refresh/state.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest'; + +import type { IWorkspaceFileAnalysisResult } from '../../../src/analysis/fileAnalysis'; +import type { IProjectedConnection } from '../../../src/analysis/projectedConnection'; +import { + applyWorkspaceIndexAnalysisResult, + retainWorkspaceIndexDiscoveredFileConnections, + updateWorkspaceIndexDiscoveryState, +} from '../../../src/indexing/refresh/state'; +import { + createDiscoveredFile, + createFileAnalysis, + createSource, +} from './fixture'; + +describe('indexing/refresh/state', () => { + it('applies analysis and connection results to the refresh source', () => { + const source = createSource(); + const connection = createConnection('./dep'); + const analysisResult: IWorkspaceFileAnalysisResult = { + cacheHits: 0, + cacheMisses: 1, + fileAnalysis: new Map([ + ['src/app.ts', createFileAnalysis('/workspace/src/app.ts')], + ]), + fileConnections: new Map([ + ['src/app.ts', [connection]], + ]), + }; + + applyWorkspaceIndexAnalysisResult(source, analysisResult); + + expect(source._lastFileAnalysis.get('src/app.ts')).toEqual( + createFileAnalysis('/workspace/src/app.ts'), + ); + expect(source._lastFileConnections.get('src/app.ts')).toEqual([connection]); + }); + + it('updates discovery state and defaults missing directories to an empty list', () => { + const source = createSource(); + const discoveredFiles = [createDiscoveredFile('src/app.ts')]; + + updateWorkspaceIndexDiscoveryState(source, { + discoveredDirectories: undefined, + discoveredFiles, + workspaceRoot: '/workspace-next', + }); + + expect(source._lastDiscoveredDirectories).toEqual([]); + expect(source._lastDiscoveredFiles).toEqual(discoveredFiles); + expect(source._lastDiscoveredFiles).not.toBe(discoveredFiles); + expect(source._lastWorkspaceRoot).toBe('/workspace-next'); + }); + + it('retains existing file connections and initializes missing discovered files', () => { + const source = createSource({ + _lastFileConnections: new Map([ + ['src/app.ts', [createConnection('./dep')]], + ]), + }); + + retainWorkspaceIndexDiscoveredFileConnections(source, [ + createDiscoveredFile('src/app.ts'), + createDiscoveredFile('src/missing.ts'), + ]); + + expect(source._lastFileConnections.get('src/app.ts')).toEqual([ + createConnection('./dep'), + ]); + expect(source._lastFileConnections.get('src/missing.ts')).toEqual([]); + }); +}); + +function createConnection(specifier: string): IProjectedConnection { + return { + kind: 'import', + resolvedPath: `/workspace/src/${specifier.slice(2)}.ts`, + sourceId: 'import', + specifier, + }; +} From c5261adfbb2f7a7077ed7323281205a6bc810c22 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 17:38:09 -0700 Subject: [PATCH 138/192] test: kill graph cache write mutants --- .../graphCache/database/query/write.test.ts | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/packages/core/tests/graphCache/database/query/write.test.ts b/packages/core/tests/graphCache/database/query/write.test.ts index f92e2efec..a6367c25e 100644 --- a/packages/core/tests/graphCache/database/query/write.test.ts +++ b/packages/core/tests/graphCache/database/query/write.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it, vi } from 'vitest'; import { createWorkspaceAnalysisCacheWriter, + createWorkspaceAnalysisCacheWriterAsync, persistAnalysisEntry, + persistAnalysisEntryAsync, sortedCacheEntries, type WorkspaceAnalysisCacheWriter, } from '../../../../src/graphCache/database/query/write'; @@ -35,6 +37,21 @@ describe('graphCache/database/writeStatements', () => { expect(prepareStatementSyncSpy).toHaveBeenNthCalledWith(1, {}, expect.stringContaining('filePath: $filePath')); }); + it('prepares the async canonical file analysis write statement once per cache write session', async () => { + const fileStatement = {}; + const prepareStatementAsyncSpy = vi + .spyOn(cacheConnectionModule, 'prepareStatementAsync') + .mockResolvedValueOnce(fileStatement as never); + + await expect(createWorkspaceAnalysisCacheWriterAsync({} as never)).resolves.toEqual({ + connection: {}, + fileAnalysisStatement: fileStatement, + }); + + expect(prepareStatementAsyncSpy).toHaveBeenCalledTimes(1); + expect(prepareStatementAsyncSpy).toHaveBeenNthCalledWith(1, {}, expect.stringContaining('filePath: $filePath')); + }); + it('persists one canonical file analysis row through a prepared statement', () => { const executeStatementSyncSpy = vi .spyOn(cacheConnectionModule, 'executeStatementSync') @@ -108,4 +125,39 @@ describe('graphCache/database/writeStatements', () => { analysis: JSON.stringify({}), }); }); + + it('persists one canonical file analysis row asynchronously before yielding', async () => { + const sequence: string[] = []; + const executeStatementAsyncSpy = vi + .spyOn(cacheConnectionModule, 'executeStatementAsync') + .mockImplementation(async () => { + sequence.push('execute'); + }); + const afterStatement = vi.fn(async () => { + sequence.push('yield'); + }); + const writer = { + connection: {} as never, + fileAnalysisStatement: { kind: 'file' } as never, + } satisfies WorkspaceAnalysisCacheWriter; + + await persistAnalysisEntryAsync( + writer, + '/workspace/src/app.ts', + { + analysis: {}, + } as never, + afterStatement, + ); + + expect(executeStatementAsyncSpy).toHaveBeenCalledTimes(1); + expect(executeStatementAsyncSpy).toHaveBeenNthCalledWith(1, {}, { kind: 'file' }, { + filePath: '/workspace/src/app.ts', + mtime: 0, + size: 0, + analysis: JSON.stringify({}), + }); + expect(afterStatement).toHaveBeenCalledOnce(); + expect(sequence).toEqual(['execute', 'yield']); + }); }); From 076580777830183cb2edf8690966f000c19a7550 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 17:54:19 -0700 Subject: [PATCH 139/192] test: kill graph cache save mutants --- .../tests/graphCache/database/io/save.test.ts | 259 ++++++++++++++++++ packages/core/vitest.config.ts | 24 +- 2 files changed, 282 insertions(+), 1 deletion(-) create mode 100644 packages/core/tests/graphCache/database/io/save.test.ts diff --git a/packages/core/tests/graphCache/database/io/save.test.ts b/packages/core/tests/graphCache/database/io/save.test.ts new file mode 100644 index 000000000..ccaa71a3b --- /dev/null +++ b/packages/core/tests/graphCache/database/io/save.test.ts @@ -0,0 +1,259 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as fs from 'node:fs'; +import { setImmediate as waitForImmediate } from 'node:timers/promises'; +import { + clearWorkspaceAnalysisDatabaseCache, + saveWorkspaceAnalysisDatabaseCache, +} from '../../../../src/graphCache/database/io/save'; +import { saveWorkspaceAnalysisDatabaseCacheAsync } from '../../../../src/graphCache/database/io/saveAsync'; +import * as connectionModule from '../../../../src/graphCache/database/io/connection'; +import * as pathsModule from '../../../../src/graphCache/database/io/paths'; +import * as temporaryModule from '../../../../src/graphCache/database/io/temporary'; +import * as writeModule from '../../../../src/graphCache/database/query/write'; + +const timerPromisesMock = vi.hoisted(() => ({ + setImmediate: vi.fn(async () => undefined), +})); + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + existsSync: vi.fn(), + }; +}); + +vi.mock('node:timers/promises', () => ({ + ...timerPromisesMock, + default: timerPromisesMock, +})); + +vi.mock('../../../../src/graphCache/database/io/connection', () => ({ + runStatementAsync: vi.fn(async () => undefined), + runStatementSync: vi.fn(), + withConnection: vi.fn(), + withConnectionAsync: vi.fn(), +})); + +vi.mock('../../../../src/graphCache/database/io/paths', () => ({ + ensureDatabaseDirectory: vi.fn(), + getWorkspaceAnalysisDatabasePath: vi.fn(), +})); + +vi.mock('../../../../src/graphCache/database/io/temporary', () => ({ + cleanupTemporaryDatabase: vi.fn(), + createTemporaryDatabasePath: vi.fn(), + replaceDatabaseCache: vi.fn(), +})); + +vi.mock('../../../../src/graphCache/database/query/write', () => ({ + createWorkspaceAnalysisCacheWriter: vi.fn(), + createWorkspaceAnalysisCacheWriterAsync: vi.fn(), + persistAnalysisEntry: vi.fn(), + persistAnalysisEntryAsync: vi.fn(), + sortedCacheEntries: vi.fn(), +})); + +const cache = { + version: '1', + files: { + 'src/b.ts': { mtime: 2, size: 20, analysis: {} }, + 'src/a.ts': { mtime: 1, size: 10, analysis: {} }, + }, +} as never; + +describe('graphCache/database/io/save', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(pathsModule.getWorkspaceAnalysisDatabasePath) + .mockReturnValue('/workspace/.codegraphy/graph.lbug'); + vi.mocked(temporaryModule.createTemporaryDatabasePath) + .mockReturnValue('/workspace/.codegraphy/graph.lbug.tmp'); + vi.mocked(writeModule.sortedCacheEntries).mockReturnValue([ + ['src/a.ts', { mtime: 1, size: 10, analysis: {} }], + ['src/b.ts', { mtime: 2, size: 20, analysis: {} }], + ] as never); + vi.mocked(writeModule.createWorkspaceAnalysisCacheWriter) + .mockReturnValue({ connection: 'connection', fileAnalysisStatement: 'statement' } as never); + vi.mocked(writeModule.createWorkspaceAnalysisCacheWriterAsync) + .mockResolvedValue({ connection: 'connection', fileAnalysisStatement: 'statement' } as never); + vi.mocked(connectionModule.withConnection).mockImplementation((_databasePath, callback) => + callback('connection' as never)); + vi.mocked(connectionModule.withConnectionAsync).mockImplementation(async (_databasePath, callback) => + callback('connection' as never)); + vi.mocked(writeModule.persistAnalysisEntryAsync).mockImplementation(async ( + _writer, + _filePath, + _entry, + afterStatement, + ) => { + await afterStatement(); + }); + }); + + it('writes a temporary database, replaces the cache, and persists sorted entries', () => { + saveWorkspaceAnalysisDatabaseCache('/workspace', cache); + + expect(pathsModule.ensureDatabaseDirectory).toHaveBeenCalledWith('/workspace'); + expect(connectionModule.withConnection).toHaveBeenCalledWith( + '/workspace/.codegraphy/graph.lbug.tmp', + expect.any(Function), + ); + expect(connectionModule.runStatementSync).toHaveBeenNthCalledWith( + 1, + 'connection', + 'MATCH (entry:FileAnalysis) DELETE entry', + ); + expect(connectionModule.runStatementSync).toHaveBeenNthCalledWith( + 2, + 'connection', + 'MATCH (entry:Symbol) DELETE entry', + ); + expect(connectionModule.runStatementSync).toHaveBeenNthCalledWith( + 3, + 'connection', + 'MATCH (entry:Relation) DELETE entry', + ); + expect(writeModule.persistAnalysisEntry).toHaveBeenNthCalledWith( + 1, + { connection: 'connection', fileAnalysisStatement: 'statement' }, + 'src/a.ts', + { mtime: 1, size: 10, analysis: {} }, + ); + expect(writeModule.persistAnalysisEntry).toHaveBeenNthCalledWith( + 2, + { connection: 'connection', fileAnalysisStatement: 'statement' }, + 'src/b.ts', + { mtime: 2, size: 20, analysis: {} }, + ); + expect(temporaryModule.replaceDatabaseCache).toHaveBeenCalledWith( + '/workspace/.codegraphy/graph.lbug.tmp', + '/workspace/.codegraphy/graph.lbug', + ); + expect(temporaryModule.cleanupTemporaryDatabase).not.toHaveBeenCalled(); + }); + + it('does not write when the database directory cannot be created', () => { + vi.mocked(fs.existsSync).mockReturnValueOnce(false); + + saveWorkspaceAnalysisDatabaseCache('/workspace', cache); + + expect(connectionModule.withConnection).not.toHaveBeenCalled(); + expect(temporaryModule.createTemporaryDatabasePath).not.toHaveBeenCalled(); + expect(temporaryModule.replaceDatabaseCache).not.toHaveBeenCalled(); + }); + + it('cleans up the temporary database when saving fails', () => { + vi.mocked(connectionModule.withConnection).mockImplementationOnce(() => { + throw new Error('write failed'); + }); + + expect(() => saveWorkspaceAnalysisDatabaseCache('/workspace', cache)).toThrow('write failed'); + expect(temporaryModule.cleanupTemporaryDatabase).toHaveBeenCalledWith('/workspace/.codegraphy/graph.lbug.tmp'); + expect(temporaryModule.replaceDatabaseCache).not.toHaveBeenCalled(); + }); + + it('clears existing database rows from every cache table', () => { + clearWorkspaceAnalysisDatabaseCache('/workspace'); + + expect(connectionModule.withConnection).toHaveBeenCalledWith( + '/workspace/.codegraphy/graph.lbug', + expect.any(Function), + ); + expect(connectionModule.runStatementSync).toHaveBeenNthCalledWith( + 1, + 'connection', + 'MATCH (entry:FileAnalysis) DELETE entry', + ); + expect(connectionModule.runStatementSync).toHaveBeenNthCalledWith( + 2, + 'connection', + 'MATCH (entry:Symbol) DELETE entry', + ); + expect(connectionModule.runStatementSync).toHaveBeenNthCalledWith( + 3, + 'connection', + 'MATCH (entry:Relation) DELETE entry', + ); + }); + + it('does not clear a missing database', () => { + vi.mocked(fs.existsSync).mockReturnValueOnce(false); + + clearWorkspaceAnalysisDatabaseCache('/workspace'); + + expect(connectionModule.withConnection).not.toHaveBeenCalled(); + expect(connectionModule.runStatementSync).not.toHaveBeenCalled(); + }); + + it('writes the async cache with progress and cooperative yielding', async () => { + const onProgress = vi.fn(); + + await saveWorkspaceAnalysisDatabaseCacheAsync('/workspace', cache, { + onProgress, + yieldEvery: 1, + }); + + expect(connectionModule.runStatementAsync).toHaveBeenNthCalledWith( + 1, + 'connection', + 'MATCH (entry:FileAnalysis) DELETE entry', + ); + expect(connectionModule.runStatementAsync).toHaveBeenNthCalledWith( + 2, + 'connection', + 'MATCH (entry:Symbol) DELETE entry', + ); + expect(connectionModule.runStatementAsync).toHaveBeenNthCalledWith( + 3, + 'connection', + 'MATCH (entry:Relation) DELETE entry', + ); + expect(writeModule.persistAnalysisEntryAsync).toHaveBeenCalledTimes(2); + expect(waitForImmediate).toHaveBeenCalledTimes(2); + expect(onProgress).toHaveBeenNthCalledWith(1, { current: 0, total: 2 }); + expect(onProgress).toHaveBeenNthCalledWith(2, { current: 1, total: 2 }); + expect(onProgress).toHaveBeenNthCalledWith(3, { current: 2, total: 2 }); + expect(temporaryModule.replaceDatabaseCache).toHaveBeenCalledWith( + '/workspace/.codegraphy/graph.lbug.tmp', + '/workspace/.codegraphy/graph.lbug', + ); + }); + + it('does not write the async cache when the database directory cannot be created', async () => { + vi.mocked(fs.existsSync).mockReturnValueOnce(false); + + await saveWorkspaceAnalysisDatabaseCacheAsync('/workspace', cache); + + expect(connectionModule.withConnectionAsync).not.toHaveBeenCalled(); + expect(temporaryModule.createTemporaryDatabasePath).not.toHaveBeenCalled(); + expect(temporaryModule.replaceDatabaseCache).not.toHaveBeenCalled(); + }); + + it('waits for the async yield interval before yielding', async () => { + await saveWorkspaceAnalysisDatabaseCacheAsync('/workspace', cache, { + yieldEvery: 2, + }); + + expect(waitForImmediate).toHaveBeenCalledTimes(1); + }); + + it('does not require async progress callbacks or positive yield intervals', async () => { + await saveWorkspaceAnalysisDatabaseCacheAsync('/workspace', cache, { + yieldEvery: 0, + }); + + expect(waitForImmediate).not.toHaveBeenCalled(); + expect(writeModule.persistAnalysisEntryAsync).toHaveBeenCalledTimes(2); + }); + + it('cleans up the temporary database when async saving fails', async () => { + vi.mocked(connectionModule.withConnectionAsync).mockRejectedValueOnce(new Error('async write failed')); + + await expect(saveWorkspaceAnalysisDatabaseCacheAsync('/workspace', cache)) + .rejects.toThrow('async write failed'); + expect(temporaryModule.cleanupTemporaryDatabase).toHaveBeenCalledWith('/workspace/.codegraphy/graph.lbug.tmp'); + expect(temporaryModule.replaceDatabaseCache).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts index d09dd6fcc..fe2e68433 100644 --- a/packages/core/vitest.config.ts +++ b/packages/core/vitest.config.ts @@ -1,6 +1,28 @@ import { resolve } from 'node:path'; import { defineConfig } from 'vitest/config'; +const explicitTestIncludes = process.env.QUALITY_TOOLS_VITEST_INCLUDE_JSON + ?? process.env.CODEGRAPHY_VITEST_INCLUDE_JSON; + +function packageRelativeInclude(includePattern: string): string { + const negated = includePattern.startsWith('!'); + const pattern = negated ? includePattern.slice(1) : includePattern; + const packagePrefix = 'packages/core/'; + const relativePattern = pattern.startsWith(packagePrefix) + ? pattern.slice(packagePrefix.length) + : pattern; + + return negated ? `!${relativePattern}` : relativePattern; +} + +function resolveTestIncludes(): string[] { + if (!explicitTestIncludes) { + return ['tests/**/*.test.ts']; + } + + return (JSON.parse(explicitTestIncludes) as string[]).map(packageRelativeInclude); +} + export default defineConfig({ resolve: { alias: { @@ -10,7 +32,7 @@ export default defineConfig({ }, test: { environment: 'node', - include: ['tests/**/*.test.ts'], + include: resolveTestIncludes(), coverage: { provider: 'istanbul', reporter: ['text', 'html', 'json'], From 4059084aa677d765bd548c5bbbeb1621be36976d Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 17:59:52 -0700 Subject: [PATCH 140/192] test: kill analysis facade mutants --- .../pipeline/service/analysisFacade.test.ts | 200 ++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 packages/extension/tests/extension/pipeline/service/analysisFacade.test.ts diff --git a/packages/extension/tests/extension/pipeline/service/analysisFacade.test.ts b/packages/extension/tests/extension/pipeline/service/analysisFacade.test.ts new file mode 100644 index 000000000..5bac5c8e1 --- /dev/null +++ b/packages/extension/tests/extension/pipeline/service/analysisFacade.test.ts @@ -0,0 +1,200 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { FileDiscovery } from '@codegraphy-dev/core'; +import type { PluginRegistry } from '../../../../src/core/plugins/registry/manager'; +import type { Configuration } from '../../../../src/extension/config/reader'; +import type { IWorkspaceAnalysisCache } from '../../../../src/extension/pipeline/cache'; +import { WorkspacePipelineAnalysisFacade } from '../../../../src/extension/pipeline/service/analysisFacade'; +import { + analyzeWorkspacePipeline, + rebuildWorkspacePipelineGraph, +} from '../../../../src/extension/pipeline/service/runtime/run'; + +vi.mock('../../../../src/extension/pipeline/service/runtime/run', () => ({ + analyzeWorkspacePipeline: vi.fn(), + rebuildWorkspacePipelineGraph: vi.fn(), +})); + +vi.mock('vscode', () => ({ + workspace: { + workspaceFolders: [{ uri: { fsPath: '/workspace' } }], + getConfiguration: vi.fn(() => ({ + get: vi.fn(), + update: vi.fn(), + inspect: vi.fn(), + })), + }, +})); + +class TestAnalysisFacade extends WorkspacePipelineAnalysisFacade { + readonly getWorkspaceRoot = vi.fn<() => string | undefined>(() => '/workspace'); + readonly effectiveCustomFilterPatterns = vi.fn((patterns: string[]) => + patterns.map(pattern => `effective:${pattern}`), + ); + readonly persistIndexMetadata = vi.fn(async () => undefined); + + constructor() { + super({ + subscriptions: [], + workspaceState: { + get: vi.fn(), + update: vi.fn(), + }, + } as never); + this._cache = { + files: { + 'src/stale.ts': { analysis: {}, mtime: 1, size: 10 }, + }, + } as unknown as IWorkspaceAnalysisCache; + } + + _config = { + get: vi.fn((_key: string, defaultValue: unknown) => defaultValue), + getAll: vi.fn(() => ({ showOrphans: true, respectGitignore: true })), + } as unknown as Configuration; + + _discovery = { kind: 'discovery' } as unknown as FileDiscovery; + _registry = { + list: vi.fn(() => []), + } as unknown as PluginRegistry; + + public override get _cache(): IWorkspaceAnalysisCache { + return super._cache; + } + + public override set _cache(cache: IWorkspaceAnalysisCache) { + super._cache = cache; + } + + protected override _getWorkspaceRoot(): string | undefined { + return this.getWorkspaceRoot(); + } + + protected override _getEffectiveCustomFilterPatterns(filterPatterns: string[]): string[] { + return this.effectiveCustomFilterPatterns(filterPatterns); + } + + protected override async _persistIndexMetadata(): Promise { + await this.persistIndexMetadata(); + } +} + +describe('extension/pipeline/service/analysisFacade', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(analyzeWorkspacePipeline).mockResolvedValue({ + nodes: [{ id: 'analysis', label: 'Analysis', color: '#111111' }], + edges: [], + }); + vi.mocked(rebuildWorkspacePipelineGraph).mockReturnValue({ + nodes: [{ id: 'rebuild', label: 'Rebuild', color: '#222222' }], + edges: [], + }); + }); + + it('delegates analysis with effective filters and index metadata persistence', async () => { + const facade = new TestAnalysisFacade(); + const disabledPlugins = new Set(['plugin.disabled']); + const signal = new AbortController().signal; + const onProgress = vi.fn(); + + await expect( + facade.analyze(['src/**'], disabledPlugins, signal, onProgress), + ).resolves.toEqual({ + nodes: [{ id: 'analysis', label: 'Analysis', color: '#111111' }], + edges: [], + }); + + expect(analyzeWorkspacePipeline).toHaveBeenCalledWith( + facade, + facade._cache, + facade._config, + facade._discovery, + expect.any(Function), + ['effective:src/**'], + disabledPlugins, + onProgress, + signal, + expect.any(Function), + ); + expect(vi.mocked(analyzeWorkspacePipeline).mock.calls[0][4]()).toBe('/workspace'); + await vi.mocked(analyzeWorkspacePipeline).mock.calls[0][9](); + expect(facade.persistIndexMetadata).toHaveBeenCalledOnce(); + + await facade.analyze(); + expect(facade.effectiveCustomFilterPatterns).toHaveBeenLastCalledWith([]); + }); + + it('delegates graph rebuilding with disabled plugins and orphan visibility', () => { + const facade = new TestAnalysisFacade(); + const disabledPlugins = new Set(['plugin.disabled']); + + expect(facade.rebuildGraph(disabledPlugins, false)).toEqual({ + nodes: [{ id: 'rebuild', label: 'Rebuild', color: '#222222' }], + edges: [], + }); + expect(rebuildWorkspacePipelineGraph).toHaveBeenCalledWith( + facade, + disabledPlugins, + false, + ); + }); + + it('refreshes from an empty cache and forwards progress with fallback phases', async () => { + const facade = new TestAnalysisFacade(); + const disabledPlugins = new Set(['plugin.disabled']); + const signal = new AbortController().signal; + const onProgress = vi.fn(); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + const cacheBeforeRefresh = facade._cache; + const analyzeSpy = vi + .spyOn(facade, 'analyze') + .mockImplementation(async (_filters, _disabledPlugins, _signal, reportProgress) => { + reportProgress?.({ phase: '', current: 1, total: 2 }); + reportProgress?.({ phase: 'Analyzing', current: 2, total: 2 }); + return { + nodes: [{ id: 'refresh', label: 'Refresh', color: '#333333' }], + edges: [], + }; + }); + + await expect( + facade.refreshIndex(undefined, disabledPlugins, signal, onProgress), + ).resolves.toEqual({ + nodes: [{ id: 'refresh', label: 'Refresh', color: '#333333' }], + edges: [], + }); + + expect(facade._cache).not.toBe(cacheBeforeRefresh); + expect(facade._cache.files).toEqual({}); + expect(logSpy).toHaveBeenCalledWith('[CodeGraphy] Cache cleared'); + expect(analyzeSpy).toHaveBeenCalledWith( + [], + disabledPlugins, + signal, + expect.any(Function), + ); + expect(onProgress).toHaveBeenNthCalledWith(1, { + phase: 'Refreshing Index', + current: 1, + total: 2, + }); + expect(onProgress).toHaveBeenNthCalledWith(2, { + phase: 'Analyzing', + current: 2, + total: 2, + }); + + await expect( + facade.refreshIndex(['src/**'], disabledPlugins, signal), + ).resolves.toEqual({ + nodes: [{ id: 'refresh', label: 'Refresh', color: '#333333' }], + edges: [], + }); + expect(analyzeSpy).toHaveBeenLastCalledWith( + ['src/**'], + disabledPlugins, + signal, + expect.any(Function), + ); + }); +}); From 27b5b5af5731b6a3ace3caa913518db201e910c0 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 18:12:04 -0700 Subject: [PATCH 141/192] test: kill cached graph mutants --- .../extension/pipeline/service/cachedGraph.ts | 14 +- .../pipeline/service/cachedGraph.test.ts | 300 ++++++++++++++++++ 2 files changed, 305 insertions(+), 9 deletions(-) create mode 100644 packages/extension/tests/extension/pipeline/service/cachedGraph.test.ts diff --git a/packages/extension/src/extension/pipeline/service/cachedGraph.ts b/packages/extension/src/extension/pipeline/service/cachedGraph.ts index 6c9a69bd4..9e2dd4aa5 100644 --- a/packages/extension/src/extension/pipeline/service/cachedGraph.ts +++ b/packages/extension/src/extension/pipeline/service/cachedGraph.ts @@ -22,7 +22,7 @@ export interface WorkspacePipelineCachedGraphLoadOptions { export abstract class WorkspacePipelineCachedGraphFacade extends WorkspacePipelineAnalysisFacade { async loadCachedGraph( - _filterPatterns: string[] = [], + _filterPatterns?: string[], disabledPlugins: Set = new Set(), signal?: AbortSignal, options: WorkspacePipelineCachedGraphLoadOptions = {}, @@ -102,15 +102,11 @@ export abstract class WorkspacePipelineCachedGraphFacade extends WorkspacePipeli } void warmCachedGraphAnalysisFile(input, this._discovery, this._registry).catch(error => { - const status = isWorkspaceAnalysisAbortError(error) - ? 'aborted' - : isMissingFileError(error) - ? 'skipped' - : 'failed'; - - if (status === 'failed') { - console.warn('[CodeGraphy] Failed to warm cached graph analysis.', error); + if (isWorkspaceAnalysisAbortError(error) || isMissingFileError(error)) { + return; } + + console.warn('[CodeGraphy] Failed to warm cached graph analysis.', error); }); } } diff --git a/packages/extension/tests/extension/pipeline/service/cachedGraph.test.ts b/packages/extension/tests/extension/pipeline/service/cachedGraph.test.ts new file mode 100644 index 000000000..a1f669dce --- /dev/null +++ b/packages/extension/tests/extension/pipeline/service/cachedGraph.test.ts @@ -0,0 +1,300 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + projectFileAnalysisConnections, + throwIfWorkspaceAnalysisAborted, + type FileDiscovery, + type IDiscoveredFile, +} from '@codegraphy-dev/core'; +import type { PluginRegistry } from '../../../../src/core/plugins/registry/manager'; +import type { Configuration } from '../../../../src/extension/config/reader'; +import { WorkspacePipelineCachedGraphFacade } from '../../../../src/extension/pipeline/service/cachedGraph'; +import { createCachedWorkspaceDiscoveryState } from '../../../../src/extension/pipeline/service/cache/cachedDiscovery'; +import { + isMissingFileError, + isWorkspaceAnalysisAbortError, +} from '../../../../src/extension/pipeline/service/cachedGraphWarmup/errors'; +import { warmCachedGraphAnalysisFile } from '../../../../src/extension/pipeline/service/cachedGraphWarmup/execution'; +import { createCachedGraphAnalysisWarmupInput } from '../../../../src/extension/pipeline/service/cachedGraphWarmup/input'; + +vi.mock('@codegraphy-dev/core', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + projectFileAnalysisConnections: vi.fn(), + throwIfWorkspaceAnalysisAborted: vi.fn(), + }; +}); + +vi.mock('../../../../src/extension/pipeline/service/cache/cachedDiscovery', () => ({ + createCachedWorkspaceDiscoveryState: vi.fn(), +})); +vi.mock('../../../../src/extension/pipeline/service/cachedGraphWarmup/errors', () => ({ + isMissingFileError: vi.fn(), + isWorkspaceAnalysisAbortError: vi.fn(), +})); +vi.mock('../../../../src/extension/pipeline/service/cachedGraphWarmup/execution', () => ({ + warmCachedGraphAnalysisFile: vi.fn(), +})); +vi.mock('../../../../src/extension/pipeline/service/cachedGraphWarmup/input', () => ({ + createCachedGraphAnalysisWarmupInput: vi.fn(), +})); + +vi.mock('vscode', () => ({ + workspace: { + workspaceFolders: [{ uri: { fsPath: '/workspace' } }], + getConfiguration: vi.fn(() => ({ get: vi.fn(), inspect: vi.fn(), update: vi.fn() })), + }, +})); + +const cachedAnalysis = { filePath: '/workspace/src/cached.ts', imports: [], relations: [], symbols: [] }; +const cachedFiles: IDiscoveredFile[] = [{ + absolutePath: '/workspace/src/cached.ts', + relativePath: 'src/cached.ts', +}] as never; + +class TestCachedGraphFacade extends WorkspacePipelineCachedGraphFacade { + readonly getWorkspaceRoot = vi.fn<() => string | undefined>(() => '/workspace'); + readonly hydrateCacheFromGraphCache = vi.fn(async () => undefined); + readonly activeAnalysisPluginIds = vi.fn(( + _pluginIds: readonly string[] | undefined, _disabledPlugins: ReadonlySet, + ) => ['plugin.active']); + readonly buildGraphDataFromAnalysis = vi.fn(( + _fileAnalysis: Map, _workspaceRoot: string, _showOrphans: boolean, _disabledPlugins: Set, + ) => ({ nodes: [{ id: 'graph', label: 'Graph', color: '#333333' }], edges: [] })); + + constructor() { + super({ + subscriptions: [], + workspaceState: { + get: vi.fn(), + update: vi.fn(), + }, + } as never); + this._cache = { + files: { + 'src/cached.ts': { analysis: cachedAnalysis, mtime: 1, size: 10 }, + }, + } as never; + } + + _config = { + get: vi.fn((key: string, defaultValue: unknown) => + key === 'nodeVisibility' ? { Symbol: true } : defaultValue, + ), + getAll: vi.fn(() => ({ respectGitignore: true, showOrphans: false })), + } as unknown as Configuration; + + _discovery = { kind: 'discovery' } as unknown as FileDiscovery; + _registry = { + list: vi.fn(() => []), + } as unknown as PluginRegistry; + + protected override _getWorkspaceRoot(): string | undefined { + return this.getWorkspaceRoot(); + } + + protected override async _hydrateCacheFromGraphCache(): Promise { + await this.hydrateCacheFromGraphCache(); + } + + protected override _getActiveAnalysisPluginIds( + pluginIds: readonly string[] | undefined, + disabledPlugins: ReadonlySet, + ): string[] { + return this.activeAnalysisPluginIds(pluginIds, disabledPlugins); + } + + protected override _buildGraphDataFromAnalysis( + fileAnalysis: Map, + workspaceRoot: string, + showOrphans: boolean, + disabledPlugins: Set, + ) { + return this.buildGraphDataFromAnalysis(fileAnalysis, workspaceRoot, showOrphans, disabledPlugins); + } +} + +interface CachedGraphState { + _lastDiscoveredDirectories: string[]; + _lastDiscoveredFiles: IDiscoveredFile[]; + _lastFileAnalysis: Map; + _lastFileConnections: Map; + _lastGitIgnoredPaths: string[]; + _lastWorkspaceRoot: string; +} + +function cachedGraphState(facade: TestCachedGraphFacade): CachedGraphState { + return facade as unknown as CachedGraphState; +} + +function setupCachedDiscovery(): Map { + const projectedConnections = new Map([['src/cached.ts', []]]); + + vi.mocked(projectFileAnalysisConnections).mockReturnValue(projectedConnections as never); + vi.mocked(createCachedWorkspaceDiscoveryState).mockReturnValue({ + directories: ['src'], + files: cachedFiles, + gitIgnoredPaths: ['dist/generated.ts'], + }); + vi.mocked(createCachedGraphAnalysisWarmupInput).mockReturnValue({ + file: cachedFiles[0], + } as never); + vi.mocked(warmCachedGraphAnalysisFile).mockResolvedValue(undefined); + + return projectedConnections; +} + +async function flushWarmupCatch(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +describe('extension/pipeline/service/cachedGraph', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(isMissingFileError).mockReturnValue(false); + vi.mocked(isWorkspaceAnalysisAbortError).mockReturnValue(false); + setupCachedDiscovery(); + }); + + it('returns an empty graph after hydration when no workspace is open', async () => { + const facade = new TestCachedGraphFacade(); + facade.getWorkspaceRoot.mockReturnValue(undefined); + + await expect(facade.loadCachedGraph()).resolves.toEqual({ nodes: [], edges: [] }); + + expect(facade.hydrateCacheFromGraphCache).toHaveBeenCalledOnce(); + expect(facade._config.getAll).not.toHaveBeenCalled(); + expect(createCachedWorkspaceDiscoveryState).not.toHaveBeenCalled(); + expect(warmCachedGraphAnalysisFile).not.toHaveBeenCalled(); + }); + + it('replays cached analysis into graph state and schedules warmup by default', async () => { + const facade = new TestCachedGraphFacade(); + const disabledPlugins = new Set(['plugin.disabled']); + const signal = new AbortController().signal; + const projectedConnections = setupCachedDiscovery(); + + await expect( + facade.loadCachedGraph(['ignored'], disabledPlugins, signal), + ).resolves.toEqual({ + nodes: [{ id: 'graph', label: 'Graph', color: '#333333' }], + edges: [], + }); + + expect(throwIfWorkspaceAnalysisAborted).toHaveBeenCalledWith(signal); + expect(createCachedWorkspaceDiscoveryState).toHaveBeenCalledWith( + '/workspace', + ['src/cached.ts'], + true, + ); + expect(projectFileAnalysisConnections).toHaveBeenCalledWith( + new Map([['src/cached.ts', cachedAnalysis]]), + '/workspace', + ); + const retainedState = cachedGraphState(facade); + expect(retainedState._lastDiscoveredFiles).toEqual(cachedFiles); + expect(retainedState._lastDiscoveredDirectories).toEqual(['src']); + expect(retainedState._lastGitIgnoredPaths).toEqual(['dist/generated.ts']); + expect(retainedState._lastFileAnalysis).toEqual(new Map([['src/cached.ts', cachedAnalysis]])); + expect(retainedState._lastFileConnections).toBe(projectedConnections); + expect(retainedState._lastWorkspaceRoot).toBe('/workspace'); + expect(facade.buildGraphDataFromAnalysis).toHaveBeenCalledWith( + new Map([['src/cached.ts', cachedAnalysis]]), + '/workspace', + false, + disabledPlugins, + ); + + expect(createCachedGraphAnalysisWarmupInput).toHaveBeenCalledWith({ + disabledPlugins, + files: cachedFiles, + getActiveAnalysisPluginIds: expect.any(Function), + nodeVisibility: { Symbol: true }, + registry: facade._registry, + signal, + workspaceRoot: '/workspace', + }); + const warmupInput = vi.mocked(createCachedGraphAnalysisWarmupInput).mock.calls[0][0]; + expect(warmupInput.getActiveAnalysisPluginIds(new Set(['disabled']))).toEqual(['plugin.active']); + expect(facade.activeAnalysisPluginIds).toHaveBeenCalledWith(undefined, new Set(['disabled'])); + expect(warmCachedGraphAnalysisFile).toHaveBeenCalledWith( + { file: cachedFiles[0] }, + facade._discovery, + facade._registry, + ); + }); + + it('honors gitignore and warmup replay options independently', async () => { + const facade = new TestCachedGraphFacade(); + + await facade.loadCachedGraph([], new Set(), undefined, { + includeCurrentGitignoreMetadata: false, + warmAnalysis: false, + }); + + expect(createCachedWorkspaceDiscoveryState).toHaveBeenLastCalledWith( + '/workspace', + ['src/cached.ts'], + false, + ); + expect(createCachedGraphAnalysisWarmupInput).not.toHaveBeenCalled(); + expect(warmCachedGraphAnalysisFile).not.toHaveBeenCalled(); + + vi.clearAllMocks(); + setupCachedDiscovery(); + vi.mocked(facade._config.getAll).mockReturnValueOnce({ + showOrphans: true, + respectGitignore: false, + } as never); + + await facade.loadCachedGraph(); + + expect(createCachedWorkspaceDiscoveryState).toHaveBeenLastCalledWith( + '/workspace', + ['src/cached.ts'], + false, + ); + expect(facade.buildGraphDataFromAnalysis).toHaveBeenLastCalledWith( + new Map([['src/cached.ts', cachedAnalysis]]), + '/workspace', + true, + new Set(), + ); + }); + + it('logs only unexpected cached analysis warmup failures', async () => { + const facade = new TestCachedGraphFacade(); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const abortError = new Error('aborted'); + const missingFileError = new Error('missing'); + const failedError = new Error('failed'); + + vi.mocked(warmCachedGraphAnalysisFile).mockRejectedValueOnce(abortError); + vi.mocked(isWorkspaceAnalysisAbortError).mockImplementation(error => error === abortError); + await facade.loadCachedGraph(); + await flushWarmupCatch(); + expect(warnSpy).not.toHaveBeenCalled(); + + vi.mocked(warmCachedGraphAnalysisFile).mockRejectedValueOnce(missingFileError); + vi.mocked(isWorkspaceAnalysisAbortError).mockReturnValue(false); + vi.mocked(isMissingFileError).mockImplementation(error => error === missingFileError); + await facade.loadCachedGraph(); + await flushWarmupCatch(); + expect(warnSpy).not.toHaveBeenCalled(); + + vi.mocked(warmCachedGraphAnalysisFile).mockRejectedValueOnce(failedError); + vi.mocked(isMissingFileError).mockReturnValue(false); + await facade.loadCachedGraph(); + await flushWarmupCatch(); + + expect(warnSpy).toHaveBeenCalledWith( + '[CodeGraphy] Failed to warm cached graph analysis.', + failedError, + ); + + vi.mocked(createCachedGraphAnalysisWarmupInput).mockReturnValueOnce(undefined); + await facade.loadCachedGraph(); + expect(warmCachedGraphAnalysisFile).toHaveBeenCalledTimes(3); + }); +}); From 388bdc05a9877e8a055ebe41982bb261d35c4b33 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 18:14:16 -0700 Subject: [PATCH 142/192] test: kill cached graph warmup error mutants --- .../service/cachedGraphWarmup/errors.test.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 packages/extension/tests/extension/pipeline/service/cachedGraphWarmup/errors.test.ts diff --git a/packages/extension/tests/extension/pipeline/service/cachedGraphWarmup/errors.test.ts b/packages/extension/tests/extension/pipeline/service/cachedGraphWarmup/errors.test.ts new file mode 100644 index 000000000..17099176e --- /dev/null +++ b/packages/extension/tests/extension/pipeline/service/cachedGraphWarmup/errors.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { + isMissingFileError, + isWorkspaceAnalysisAbortError, +} from '../../../../../src/extension/pipeline/service/cachedGraphWarmup/errors'; + +describe('extension/pipeline/service/cachedGraphWarmup/errors', () => { + it('detects only AbortError instances as workspace analysis aborts', () => { + const abortError = new Error('aborted'); + abortError.name = 'AbortError'; + + expect(isWorkspaceAnalysisAbortError(abortError)).toBe(true); + expect(isWorkspaceAnalysisAbortError(new Error('AbortError'))).toBe(false); + expect(isWorkspaceAnalysisAbortError({ name: 'AbortError' })).toBe(false); + }); + + it('detects only Error instances with ENOENT codes as missing files', () => { + const missingFileError = Object.assign(new Error('missing'), { code: 'ENOENT' }); + + expect(isMissingFileError(missingFileError)).toBe(true); + expect(isMissingFileError(Object.assign(new Error('missing'), { code: 'EACCES' }))).toBe(false); + expect(isMissingFileError({ code: 'ENOENT' })).toBe(false); + }); +}); From a236690aab2077de6791848c7ba8da2fa89237b3 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 18:17:53 -0700 Subject: [PATCH 143/192] test: kill cached graph warmup input mutants --- .../service/cachedGraphWarmup/input.test.ts | 127 ++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 packages/extension/tests/extension/pipeline/service/cachedGraphWarmup/input.test.ts diff --git a/packages/extension/tests/extension/pipeline/service/cachedGraphWarmup/input.test.ts b/packages/extension/tests/extension/pipeline/service/cachedGraphWarmup/input.test.ts new file mode 100644 index 000000000..480291f73 --- /dev/null +++ b/packages/extension/tests/extension/pipeline/service/cachedGraphWarmup/input.test.ts @@ -0,0 +1,127 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + createWorkspacePluginAnalysisContext, + SYMBOLS_ANALYSIS_CACHE_TIER, + type IDiscoveredFile, +} from '@codegraphy-dev/core'; +import { createWorkspacePipelineAnalysisCacheTiers } from '../../../../../src/extension/pipeline/service/cache/tiers'; +import { createCachedGraphAnalysisWarmupInput } from '../../../../../src/extension/pipeline/service/cachedGraphWarmup/input'; +import { selectCachedGraphAnalysisWarmupFile } from '../../../../../src/extension/pipeline/service/cachedGraphWarmup/selection'; +import type { CachedGraphAnalysisWarmupOptions } from '../../../../../src/extension/pipeline/service/cachedGraphWarmup/contracts'; + +vi.mock('@codegraphy-dev/core', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createWorkspacePluginAnalysisContext: vi.fn((workspaceRoot, options) => ({ + options, + workspaceRoot, + })), + }; +}); + +vi.mock('../../../../../src/extension/pipeline/service/cache/tiers', () => ({ + createWorkspacePipelineAnalysisCacheTiers: vi.fn(), +})); + +vi.mock('../../../../../src/extension/pipeline/service/cachedGraphWarmup/selection', () => ({ + selectCachedGraphAnalysisWarmupFile: vi.fn(), +})); + +const selectedFile = { + absolutePath: '/workspace/src/a.ts', + relativePath: 'src/a.ts', +} as IDiscoveredFile; + +function createOptions( + overrides: Partial = {}, +): CachedGraphAnalysisWarmupOptions { + return { + disabledPlugins: new Set(['plugin.disabled']), + files: [selectedFile], + getActiveAnalysisPluginIds: vi.fn(() => ['plugin.active']), + nodeVisibility: { Symbol: true }, + registry: { + analyzeFileResultForPlugins: vi.fn(), + supportsFile: vi.fn(() => true), + }, + signal: new AbortController().signal, + workspaceRoot: '/workspace', + ...overrides, + }; +} + +describe('extension/pipeline/service/cachedGraphWarmup/input', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(selectCachedGraphAnalysisWarmupFile).mockReturnValue(selectedFile); + vi.mocked(createWorkspacePipelineAnalysisCacheTiers).mockReturnValue({ + active: undefined, + } as never); + }); + + it('does not build input when the registry cannot analyze warmup files', () => { + const options = createOptions({ + registry: { supportsFile: vi.fn(() => true) }, + }); + + expect(createCachedGraphAnalysisWarmupInput(options)).toBeUndefined(); + expect(selectCachedGraphAnalysisWarmupFile).not.toHaveBeenCalled(); + }); + + it('does not build input when no warmup file can be selected', () => { + vi.mocked(selectCachedGraphAnalysisWarmupFile).mockReturnValue(undefined); + const options = createOptions(); + + expect(createCachedGraphAnalysisWarmupInput(options)).toBeUndefined(); + expect(selectCachedGraphAnalysisWarmupFile).toHaveBeenCalledWith( + options.registry, + [selectedFile], + ); + }); + + it('builds warmup input with a disabled-plugin snapshot and plugin analysis context', () => { + const options = createOptions(); + + const input = createCachedGraphAnalysisWarmupInput(options); + + expect(options.getActiveAnalysisPluginIds).toHaveBeenCalledWith(new Set(['plugin.disabled'])); + expect(createWorkspacePipelineAnalysisCacheTiers).toHaveBeenCalledWith( + { Symbol: true }, + ['plugin.active'], + ); + expect(createWorkspacePluginAnalysisContext).toHaveBeenCalledWith('/workspace', { + features: { symbols: true }, + }); + expect(input).toEqual({ + analysisContext: { + options: { features: { symbols: true } }, + workspaceRoot: '/workspace', + }, + disabledPluginSnapshot: new Set(['plugin.disabled']), + file: selectedFile, + pluginIds: ['plugin.active'], + signal: options.signal, + workspaceRoot: '/workspace', + }); + expect(input?.disabledPluginSnapshot).not.toBe(options.disabledPlugins); + }); + + it('enables symbols only when the active cache tiers include symbol analysis', () => { + vi.mocked(createWorkspacePipelineAnalysisCacheTiers).mockReturnValueOnce({ + active: ['baseline'], + } as never); + createCachedGraphAnalysisWarmupInput(createOptions()); + expect(createWorkspacePluginAnalysisContext).toHaveBeenLastCalledWith('/workspace', { + features: { symbols: false }, + }); + + vi.mocked(createWorkspacePipelineAnalysisCacheTiers).mockReturnValueOnce({ + active: [SYMBOLS_ANALYSIS_CACHE_TIER], + } as never); + createCachedGraphAnalysisWarmupInput(createOptions()); + expect(createWorkspacePluginAnalysisContext).toHaveBeenLastCalledWith('/workspace', { + features: { symbols: true }, + }); + }); +}); From 0f212c3b67f693c30c53eeb61bca14cc0f394d36 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 18:28:26 -0700 Subject: [PATCH 144/192] test: kill cached graph warmup helper mutants --- .../service/cachedGraphWarmup/ranking.ts | 15 +++-- .../service/cachedGraphWarmup/selection.ts | 4 -- .../cachedGraphWarmup/candidates.test.ts | 17 +++++ .../cachedGraphWarmup/execution.test.ts | 63 +++++++++++++++++++ .../service/cachedGraphWarmup/ranking.test.ts | 32 ++++++++++ .../cachedGraphWarmup/selection.test.ts | 41 ++++++++++++ .../service/cachedGraphWarmup/support.test.ts | 26 ++++++++ 7 files changed, 188 insertions(+), 10 deletions(-) create mode 100644 packages/extension/tests/extension/pipeline/service/cachedGraphWarmup/candidates.test.ts create mode 100644 packages/extension/tests/extension/pipeline/service/cachedGraphWarmup/execution.test.ts create mode 100644 packages/extension/tests/extension/pipeline/service/cachedGraphWarmup/ranking.test.ts create mode 100644 packages/extension/tests/extension/pipeline/service/cachedGraphWarmup/selection.test.ts create mode 100644 packages/extension/tests/extension/pipeline/service/cachedGraphWarmup/support.test.ts diff --git a/packages/extension/src/extension/pipeline/service/cachedGraphWarmup/ranking.ts b/packages/extension/src/extension/pipeline/service/cachedGraphWarmup/ranking.ts index 162025307..4650b50dc 100644 --- a/packages/extension/src/extension/pipeline/service/cachedGraphWarmup/ranking.ts +++ b/packages/extension/src/extension/pipeline/service/cachedGraphWarmup/ranking.ts @@ -6,10 +6,9 @@ export function selectMostRepresentedCachedGraphWarmupFile( const extensionStats = new Map(); - for (const [index, file] of files.entries()) { + for (const file of files) { const extension = file.extension; const stats = extensionStats.get(extension); if (stats) { @@ -20,11 +19,15 @@ export function selectMostRepresentedCachedGraphWarmupFile( extensionStats.set(extension, { count: 1, file, - firstIndex: index, }); } - return [...extensionStats.values()] - .sort((left, right) => right.count - left.count || left.firstIndex - right.firstIndex)[0] - ?.file; + let selected: { count: number; file: IDiscoveredFile } | undefined; + for (const stats of extensionStats.values()) { + if (!selected || stats.count > selected.count) { + selected = stats; + } + } + + return selected?.file; } diff --git a/packages/extension/src/extension/pipeline/service/cachedGraphWarmup/selection.ts b/packages/extension/src/extension/pipeline/service/cachedGraphWarmup/selection.ts index 3fb540010..fb05cf5ae 100644 --- a/packages/extension/src/extension/pipeline/service/cachedGraphWarmup/selection.ts +++ b/packages/extension/src/extension/pipeline/service/cachedGraphWarmup/selection.ts @@ -8,10 +8,6 @@ export function selectCachedGraphAnalysisWarmupFile( registry: CachedGraphWarmupRegistry, files: readonly IDiscoveredFile[], ): IDiscoveredFile | undefined { - if (typeof registry.supportsFile !== 'function') { - return files[0]; - } - const supportedFiles = getSupportedCachedGraphAnalysisWarmupFiles( registry, files.filter(isCachedGraphAnalysisWarmupCandidate), diff --git a/packages/extension/tests/extension/pipeline/service/cachedGraphWarmup/candidates.test.ts b/packages/extension/tests/extension/pipeline/service/cachedGraphWarmup/candidates.test.ts new file mode 100644 index 000000000..892184e77 --- /dev/null +++ b/packages/extension/tests/extension/pipeline/service/cachedGraphWarmup/candidates.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import type { IDiscoveredFile } from '@codegraphy-dev/core'; +import { isCachedGraphAnalysisWarmupCandidate } from '../../../../../src/extension/pipeline/service/cachedGraphWarmup/candidates'; + +function file(relativePath: string): IDiscoveredFile { + return { absolutePath: `/workspace/${relativePath}`, relativePath } as IDiscoveredFile; +} + +describe('extension/pipeline/service/cachedGraphWarmup/candidates', () => { + it('keeps source files and rejects generated or tool-owned paths', () => { + expect(isCachedGraphAnalysisWarmupCandidate(file('src/app.ts'))).toBe(true); + expect(isCachedGraphAnalysisWarmupCandidate(file('dist/app.js'))).toBe(false); + expect(isCachedGraphAnalysisWarmupCandidate(file('packages/core/.codegraphy/graph.lbug'))).toBe(false); + expect(isCachedGraphAnalysisWarmupCandidate(file('node_modules/pkg/index.js'))).toBe(false); + expect(isCachedGraphAnalysisWarmupCandidate(file('src\\coverage\\report.ts'))).toBe(false); + }); +}); diff --git a/packages/extension/tests/extension/pipeline/service/cachedGraphWarmup/execution.test.ts b/packages/extension/tests/extension/pipeline/service/cachedGraphWarmup/execution.test.ts new file mode 100644 index 000000000..bc6398247 --- /dev/null +++ b/packages/extension/tests/extension/pipeline/service/cachedGraphWarmup/execution.test.ts @@ -0,0 +1,63 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { throwIfWorkspaceAnalysisAborted, type IDiscoveredFile } from '@codegraphy-dev/core'; +import { warmCachedGraphAnalysisFile } from '../../../../../src/extension/pipeline/service/cachedGraphWarmup/execution'; +import type { CachedGraphAnalysisWarmupInput } from '../../../../../src/extension/pipeline/service/cachedGraphWarmup/contracts'; + +vi.mock('@codegraphy-dev/core', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + throwIfWorkspaceAnalysisAborted: vi.fn(), + }; +}); + +const file = { + absolutePath: '/workspace/src/a.ts', + relativePath: 'src/a.ts', +} as IDiscoveredFile; + +function createInput(): CachedGraphAnalysisWarmupInput { + return { + analysisContext: { workspaceRoot: '/workspace' } as never, + disabledPluginSnapshot: new Set(['plugin.disabled']), + file, + pluginIds: ['plugin.active'], + signal: new AbortController().signal, + workspaceRoot: '/workspace', + }; +} + +describe('extension/pipeline/service/cachedGraphWarmup/execution', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('does not read files when the registry cannot analyze warmup results', async () => { + const discovery = { readContent: vi.fn() }; + + await warmCachedGraphAnalysisFile(createInput(), discovery, {}); + + expect(discovery.readContent).not.toHaveBeenCalled(); + expect(throwIfWorkspaceAnalysisAborted).not.toHaveBeenCalled(); + }); + + it('reads content and analyzes the selected warmup file with abort checks', async () => { + const input = createInput(); + const discovery = { readContent: vi.fn(async () => 'content') }; + const analyzeFileResultForPlugins = vi.fn(async () => undefined); + + await warmCachedGraphAnalysisFile(input, discovery, { analyzeFileResultForPlugins }); + + expect(throwIfWorkspaceAnalysisAborted).toHaveBeenNthCalledWith(1, input.signal); + expect(discovery.readContent).toHaveBeenCalledWith(file); + expect(throwIfWorkspaceAnalysisAborted).toHaveBeenNthCalledWith(2, input.signal); + expect(analyzeFileResultForPlugins).toHaveBeenCalledWith( + '/workspace/src/a.ts', + 'content', + '/workspace', + ['plugin.active'], + input.analysisContext, + { disabledPlugins: new Set(['plugin.disabled']) }, + ); + }); +}); diff --git a/packages/extension/tests/extension/pipeline/service/cachedGraphWarmup/ranking.test.ts b/packages/extension/tests/extension/pipeline/service/cachedGraphWarmup/ranking.test.ts new file mode 100644 index 000000000..9b9e3dcd4 --- /dev/null +++ b/packages/extension/tests/extension/pipeline/service/cachedGraphWarmup/ranking.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import type { IDiscoveredFile } from '@codegraphy-dev/core'; +import { selectMostRepresentedCachedGraphWarmupFile } from '../../../../../src/extension/pipeline/service/cachedGraphWarmup/ranking'; + +function file(relativePath: string, extension: string): IDiscoveredFile { + return { absolutePath: `/workspace/${relativePath}`, extension, relativePath } as IDiscoveredFile; +} + +describe('extension/pipeline/service/cachedGraphWarmup/ranking', () => { + it('returns undefined when there are no supported files', () => { + expect(selectMostRepresentedCachedGraphWarmupFile([])).toBeUndefined(); + }); + + it('selects the first file from the most represented extension', () => { + const python = file('src/b.py', '.py'); + const firstTypeScript = file('src/a.ts', '.ts'); + const secondTypeScript = file('src/c.ts', '.ts'); + + expect(selectMostRepresentedCachedGraphWarmupFile([ + python, + firstTypeScript, + secondTypeScript, + ])).toBe(firstTypeScript); + }); + + it('uses the earliest represented extension as the tie breaker', () => { + const python = file('src/b.py', '.py'); + const typeScript = file('src/a.ts', '.ts'); + + expect(selectMostRepresentedCachedGraphWarmupFile([python, typeScript])).toBe(python); + }); +}); diff --git a/packages/extension/tests/extension/pipeline/service/cachedGraphWarmup/selection.test.ts b/packages/extension/tests/extension/pipeline/service/cachedGraphWarmup/selection.test.ts new file mode 100644 index 000000000..a558a32e6 --- /dev/null +++ b/packages/extension/tests/extension/pipeline/service/cachedGraphWarmup/selection.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { IDiscoveredFile } from '@codegraphy-dev/core'; +import { selectCachedGraphAnalysisWarmupFile } from '../../../../../src/extension/pipeline/service/cachedGraphWarmup/selection'; + +function file(relativePath: string, extension: string): IDiscoveredFile { + return { absolutePath: `/workspace/${relativePath}`, extension, relativePath } as IDiscoveredFile; +} + +describe('extension/pipeline/service/cachedGraphWarmup/selection', () => { + const generated = file('dist/generated.ts', '.ts'); + const firstTypeScript = file('src/a.ts', '.ts'); + const python = file('src/b.py', '.py'); + const secondTypeScript = file('src/c.ts', '.ts'); + + it('uses the first file when the registry has no support predicate', () => { + expect(selectCachedGraphAnalysisWarmupFile({}, [firstTypeScript, python])).toBe(firstTypeScript); + expect(selectCachedGraphAnalysisWarmupFile({}, [])).toBeUndefined(); + }); + + it('selects the most represented supported candidate outside generated folders', () => { + const supportsFile = vi.fn((filePath: string) => filePath.endsWith('.ts')); + + expect(selectCachedGraphAnalysisWarmupFile( + { supportsFile }, + [generated, firstTypeScript, python, secondTypeScript], + )).toBe(firstTypeScript); + expect(supportsFile).not.toHaveBeenCalledWith('/workspace/dist/generated.ts'); + }); + + it('falls back to supported generated files and then the first file', () => { + expect(selectCachedGraphAnalysisWarmupFile( + { supportsFile: filePath => filePath.includes('generated') }, + [generated, python], + )).toBe(generated); + + expect(selectCachedGraphAnalysisWarmupFile( + { supportsFile: () => false }, + [generated, python], + )).toBe(generated); + }); +}); diff --git a/packages/extension/tests/extension/pipeline/service/cachedGraphWarmup/support.test.ts b/packages/extension/tests/extension/pipeline/service/cachedGraphWarmup/support.test.ts new file mode 100644 index 000000000..fcd4dd907 --- /dev/null +++ b/packages/extension/tests/extension/pipeline/service/cachedGraphWarmup/support.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { IDiscoveredFile } from '@codegraphy-dev/core'; +import { getSupportedCachedGraphAnalysisWarmupFiles } from '../../../../../src/extension/pipeline/service/cachedGraphWarmup/support'; + +const files = [ + { absolutePath: '/workspace/src/a.ts', relativePath: 'src/a.ts' }, + { absolutePath: '/workspace/src/b.py', relativePath: 'src/b.py' }, +] as IDiscoveredFile[]; + +describe('extension/pipeline/service/cachedGraphWarmup/support', () => { + it('keeps files supported by absolute or relative path', () => { + const supportsFile = vi.fn((filePath: string) => + filePath === '/workspace/src/a.ts' || filePath === 'src/b.py', + ); + + expect(getSupportedCachedGraphAnalysisWarmupFiles({ supportsFile }, files)).toEqual(files); + expect(supportsFile).toHaveBeenCalledWith('/workspace/src/a.ts'); + expect(supportsFile).toHaveBeenCalledWith('/workspace/src/b.py'); + expect(supportsFile).toHaveBeenCalledWith('src/b.py'); + }); + + it('returns no files when the registry has no support predicate or rejects every path', () => { + expect(getSupportedCachedGraphAnalysisWarmupFiles({}, files)).toEqual([]); + expect(getSupportedCachedGraphAnalysisWarmupFiles({ supportsFile: () => false }, files)).toEqual([]); + }); +}); From 5f8d98d855a80fa783aca94de38964914a8bf403 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 18:35:44 -0700 Subject: [PATCH 145/192] test: kill graph discovery mutants --- .../pipeline/service/graphDiscovery.test.ts | 206 ++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 packages/extension/tests/extension/pipeline/service/graphDiscovery.test.ts diff --git a/packages/extension/tests/extension/pipeline/service/graphDiscovery.test.ts b/packages/extension/tests/extension/pipeline/service/graphDiscovery.test.ts new file mode 100644 index 000000000..396ed517d --- /dev/null +++ b/packages/extension/tests/extension/pipeline/service/graphDiscovery.test.ts @@ -0,0 +1,206 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as vscode from 'vscode'; +import type { FileDiscovery, IDiscoveredFile } from '@codegraphy-dev/core'; +import type { IProjectedConnection } from '../../../../src/core/plugins/types/contracts'; +import type { Configuration } from '../../../../src/extension/config/reader'; +import { WorkspacePipelineGraphDiscoveryFacade } from '../../../../src/extension/pipeline/service/graphDiscovery'; +import { + createWorkspacePipelineDiscoveryDependencies, + discoverWorkspacePipelineFilesWithWarnings, +} from '../../../../src/extension/pipeline/service/runtime/discovery'; +import type { IGraphData } from '../../../../src/shared/graph/contracts'; + +vi.mock('../../../../src/extension/pipeline/service/runtime/discovery', () => ({ + createWorkspacePipelineDiscoveryDependencies: vi.fn(), + discoverWorkspacePipelineFilesWithWarnings: vi.fn(), +})); + +vi.mock('vscode', () => ({ + workspace: { + workspaceFolders: [{ uri: { fsPath: '/workspace' } }], + getConfiguration: vi.fn(() => ({ + get: vi.fn(), + inspect: vi.fn(), + update: vi.fn(), + })), + createFileSystemWatcher: vi.fn(), + onDidChangeConfiguration: vi.fn(), + onDidSaveTextDocument: vi.fn(), + }, + window: { + showWarningMessage: vi.fn(), + }, +})); + +class TestGraphDiscoveryFacade extends WorkspacePipelineGraphDiscoveryFacade { + readonly buildGraphData = vi.fn(( + _fileConnections: Map, + _workspaceRoot: string, + _showOrphans: boolean, + _disabledPlugins: Set, + ): IGraphData => ({ + nodes: [{ id: 'graph', label: 'Graph', color: '#333333' }], + edges: [], + })); + readonly getWorkspaceRoot = vi.fn<() => string | undefined>(() => '/workspace'); + + _config = { + get: vi.fn((_key: string, defaultValue: unknown) => defaultValue), + getAll: vi.fn(() => ({ respectGitignore: true, showOrphans: true })), + } as unknown as Configuration; + + _discovery = { kind: 'discovery' } as unknown as FileDiscovery; + + get discoveredDirectories(): string[] { + return this._lastDiscoveredDirectories; + } + + get discoveredFiles(): IDiscoveredFile[] { + return this._lastDiscoveredFiles; + } + + get fileAnalysis(): ReadonlyMap { + return this._lastFileAnalysis; + } + + get fileConnections(): ReadonlyMap { + return this._lastFileConnections; + } + + get gitIgnoredPaths(): string[] { + return this._lastGitIgnoredPaths; + } + + get workspaceRoot(): string { + return this._lastWorkspaceRoot; + } + + constructor() { + super({ + subscriptions: [], + workspaceState: { + get: vi.fn(), + update: vi.fn(), + }, + } as never); + } + + protected override _buildGraphData( + fileConnections: Map, + workspaceRoot: string, + showOrphans: boolean, + disabledPlugins: Set = new Set(), + ): IGraphData { + return this.buildGraphData(fileConnections, workspaceRoot, showOrphans, disabledPlugins); + } + + protected override _getEffectiveCustomFilterPatterns(filterPatterns: string[]): string[] { + return filterPatterns.length > 0 ? [`custom:${filterPatterns.join(',')}`] : []; + } + + protected override _getEffectivePluginFilterPatterns(disabledPlugins: ReadonlySet): string[] { + return [...disabledPlugins].map(pluginId => `plugin:${pluginId}`); + } + + protected override _getWorkspaceRoot(): string | undefined { + return this.getWorkspaceRoot(); + } +} + +describe('extension/pipeline/service/graphDiscovery', () => { + const fileA = { + absolutePath: '/workspace/src/a.ts', + relativePath: 'src/a.ts', + } as IDiscoveredFile; + const fileB = { + absolutePath: '/workspace/src/b.ts', + relativePath: 'src/b.ts', + } as IDiscoveredFile; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(createWorkspacePipelineDiscoveryDependencies).mockReturnValue('discovery-deps' as never); + vi.mocked(discoverWorkspacePipelineFilesWithWarnings).mockResolvedValue({ + directories: ['src'], + files: [fileA, fileB], + gitIgnoredPaths: ['dist/generated.ts'], + } as never); + }); + + it('returns an empty graph without discovery when no workspace is open', async () => { + const facade = new TestGraphDiscoveryFacade(); + facade.getWorkspaceRoot.mockReturnValue(undefined); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + await expect(facade.discoverGraph()).resolves.toEqual({ nodes: [], edges: [] }); + + expect(log).toHaveBeenCalledWith('[CodeGraphy] No workspace folder open'); + expect(discoverWorkspacePipelineFilesWithWarnings).not.toHaveBeenCalled(); + expect(facade.buildGraphData).not.toHaveBeenCalled(); + }); + + it('discovers workspace files, stores discovery state, and builds the cold graph', async () => { + const facade = new TestGraphDiscoveryFacade(); + const disabledPlugins = new Set(['plugin.disabled']); + const signal = new AbortController().signal; + + await expect(facade.discoverGraph(['dist/**'], disabledPlugins, signal)).resolves.toEqual({ + nodes: [{ id: 'graph', label: 'Graph', color: '#333333' }], + edges: [], + }); + + expect(createWorkspacePipelineDiscoveryDependencies).toHaveBeenCalledWith(facade._discovery); + expect(discoverWorkspacePipelineFilesWithWarnings).toHaveBeenCalledWith( + 'discovery-deps', + '/workspace', + { respectGitignore: true, showOrphans: true }, + ['custom:dist/**'], + ['plugin:plugin.disabled'], + signal, + expect.any(Function), + ); + + const warn = vi.mocked(discoverWorkspacePipelineFilesWithWarnings).mock.calls[0][6]; + warn('Discovery warning'); + expect(vscode.window.showWarningMessage).toHaveBeenCalledWith('Discovery warning'); + + const expectedConnections = new Map([ + ['src/a.ts', []], + ['src/b.ts', []], + ]); + expect(facade.buildGraphData).toHaveBeenCalledWith( + expectedConnections, + '/workspace', + true, + disabledPlugins, + ); + expect(facade.discoveredDirectories).toEqual(['src']); + expect(facade.discoveredFiles).toEqual([fileA, fileB]); + expect(facade.fileAnalysis).toEqual(new Map()); + expect(facade.fileConnections).toEqual(expectedConnections); + expect(facade.gitIgnoredPaths).toEqual(['dist/generated.ts']); + expect(facade.workspaceRoot).toBe('/workspace'); + }); + + it('stores empty directory and gitignore lists when discovery omits them', async () => { + const facade = new TestGraphDiscoveryFacade(); + vi.mocked(discoverWorkspacePipelineFilesWithWarnings).mockResolvedValueOnce({ + files: [fileA], + } as never); + + await facade.discoverGraph(); + + expect(discoverWorkspacePipelineFilesWithWarnings).toHaveBeenCalledWith( + 'discovery-deps', + '/workspace', + { respectGitignore: true, showOrphans: true }, + [], + [], + undefined, + expect.any(Function), + ); + expect(facade.discoveredDirectories).toEqual([]); + expect(facade.gitIgnoredPaths).toEqual([]); + expect(facade.fileConnections).toEqual(new Map([['src/a.ts', []]])); + }); +}); From 72dba7b4f9a9e7bea2efe6d99a09a4cf03390773 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 18:41:16 -0700 Subject: [PATCH 146/192] test: kill index status mutants --- .../pipeline/service/indexStatus.test.ts | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 packages/extension/tests/extension/pipeline/service/indexStatus.test.ts diff --git a/packages/extension/tests/extension/pipeline/service/indexStatus.test.ts b/packages/extension/tests/extension/pipeline/service/indexStatus.test.ts new file mode 100644 index 000000000..7ab21246a --- /dev/null +++ b/packages/extension/tests/extension/pipeline/service/indexStatus.test.ts @@ -0,0 +1,71 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { readCodeGraphyWorkspaceStatus } from '@codegraphy-dev/core'; +import { getWorkspacePipelineIndexStatus } from '../../../../src/extension/pipeline/service/indexStatus'; + +vi.mock('@codegraphy-dev/core', () => ({ + readCodeGraphyWorkspaceStatus: vi.fn(), +})); + +const missingIndexStatus = { + freshness: 'missing', + detail: 'CodeGraphy index is missing. Index the workspace to build the graph.', +}; + +describe('extension/pipeline/service/indexStatus', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(readCodeGraphyWorkspaceStatus).mockReturnValue({ + detail: 'CodeGraphy index is fresh.', + state: 'fresh', + } as never); + }); + + it('reports a missing index without probing storage when no workspace is open', () => { + const hasIndex = vi.fn(() => true); + + expect(getWorkspacePipelineIndexStatus({ + hasIndex, + pluginSignature: 'plugins', + settingsSignature: 'settings', + workspaceRoot: undefined, + })).toEqual(missingIndexStatus); + expect(hasIndex).not.toHaveBeenCalled(); + expect(readCodeGraphyWorkspaceStatus).not.toHaveBeenCalled(); + }); + + it('reports a missing index without reading status when no index exists', () => { + const hasIndex = vi.fn(() => false); + + expect(getWorkspacePipelineIndexStatus({ + hasIndex, + pluginSignature: 'plugins', + settingsSignature: 'settings', + workspaceRoot: '/workspace', + })).toEqual(missingIndexStatus); + expect(hasIndex).toHaveBeenCalledOnce(); + expect(readCodeGraphyWorkspaceStatus).not.toHaveBeenCalled(); + }); + + it('reads and returns the workspace status with current signatures', () => { + const hasIndex = vi.fn(() => true); + vi.mocked(readCodeGraphyWorkspaceStatus).mockReturnValue({ + detail: 'Plugin signature changed.', + state: 'stale', + } as never); + + expect(getWorkspacePipelineIndexStatus({ + hasIndex, + pluginSignature: null, + settingsSignature: 'settings', + workspaceRoot: '/workspace', + })).toEqual({ + freshness: 'stale', + detail: 'Plugin signature changed.', + }); + expect(hasIndex).toHaveBeenCalledOnce(); + expect(readCodeGraphyWorkspaceStatus).toHaveBeenCalledWith('/workspace', { + pluginSignature: null, + settingsSignature: 'settings', + }); + }); +}); From 785a426806d7eaef746bf3a214ea465d939944d0 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 18:48:37 -0700 Subject: [PATCH 147/192] test: kill plugin facade mutants --- .../pipeline/service/pluginFacade.test.ts | 197 ++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 packages/extension/tests/extension/pipeline/service/pluginFacade.test.ts diff --git a/packages/extension/tests/extension/pipeline/service/pluginFacade.test.ts b/packages/extension/tests/extension/pipeline/service/pluginFacade.test.ts new file mode 100644 index 000000000..07132a2df --- /dev/null +++ b/packages/extension/tests/extension/pipeline/service/pluginFacade.test.ts @@ -0,0 +1,197 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { FileDiscovery } from '@codegraphy-dev/core'; +import type { PluginRegistry } from '../../../../src/core/plugins/registry/manager'; +import type { Configuration } from '../../../../src/extension/config/reader'; +import { hasWorkspacePipelineIndex } from '../../../../src/extension/pipeline/service/cache/index'; +import { getWorkspacePipelineIndexStatus } from '../../../../src/extension/pipeline/service/indexStatus'; +import { WorkspacePipelinePluginFacade } from '../../../../src/extension/pipeline/service/pluginFacade'; +import { + getEffectiveCustomFilterPatterns, + getEffectivePluginFilterPatterns, + getPipelinePluginFilterGroups, + getPipelinePluginFilterPatterns, + initializeWorkspacePipelinePlugins, + queueWorkspacePipelinePluginReload, + queueWorkspacePipelinePluginSync, +} from '../../../../src/extension/pipeline/service/pluginState'; + +vi.mock('../../../../src/extension/pipeline/service/cache/index', () => ({ + hasWorkspacePipelineIndex: vi.fn(), +})); + +vi.mock('../../../../src/extension/pipeline/service/indexStatus', () => ({ + getWorkspacePipelineIndexStatus: vi.fn(), +})); + +vi.mock('../../../../src/extension/pipeline/service/pluginState', () => ({ + getEffectiveCustomFilterPatterns: vi.fn(), + getEffectivePluginFilterPatterns: vi.fn(), + getPipelinePluginFilterGroups: vi.fn(), + getPipelinePluginFilterPatterns: vi.fn(), + initializeWorkspacePipelinePlugins: vi.fn(), + queueWorkspacePipelinePluginReload: vi.fn(), + queueWorkspacePipelinePluginSync: vi.fn(), +})); + +vi.mock('vscode', () => ({ + workspace: { + workspaceFolders: [{ uri: { fsPath: '/workspace' } }], + getConfiguration: vi.fn(() => ({ + get: vi.fn(), + inspect: vi.fn(), + update: vi.fn(), + })), + createFileSystemWatcher: vi.fn(), + onDidChangeConfiguration: vi.fn(), + onDidSaveTextDocument: vi.fn(), + }, +})); + +class TestPluginFacade extends WorkspacePipelinePluginFacade { + readonly getWorkspaceRoot = vi.fn<() => string | undefined>(() => '/workspace'); + + _config = { id: 'config' } as unknown as Configuration; + _discovery = { kind: 'discovery' } as unknown as FileDiscovery; + _registry = { id: 'registry' } as unknown as PluginRegistry; + + constructor() { + super({ + subscriptions: [], + workspaceState: { + get: vi.fn(), + update: vi.fn(), + }, + } as never); + } + + effectiveCustomFilterPatterns(filterPatterns: string[]): string[] { + return this._getEffectiveCustomFilterPatterns(filterPatterns); + } + + effectivePluginFilterPatterns(disabledPlugins: ReadonlySet): string[] { + return this._getEffectivePluginFilterPatterns(disabledPlugins); + } + + protected override _getPluginSignature(): string | null { + return 'plugin-signature'; + } + + protected override _getSettingsSignature(): string { + return 'settings-signature'; + } + + protected override _getWorkspaceRoot(): string | undefined { + return this.getWorkspaceRoot(); + } +} + +describe('extension/pipeline/service/pluginFacade', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getPipelinePluginFilterPatterns).mockReturnValue(['plugin-filter']); + vi.mocked(getPipelinePluginFilterGroups).mockReturnValue([{ + patterns: ['generated/**'], + pluginId: 'plugin.disabled', + pluginName: 'Disabled Plugin', + }]); + vi.mocked(getEffectiveCustomFilterPatterns).mockReturnValue(['custom-filter']); + vi.mocked(getEffectivePluginFilterPatterns).mockReturnValue(['effective-plugin-filter']); + vi.mocked(hasWorkspacePipelineIndex).mockReturnValue(true); + vi.mocked(getWorkspacePipelineIndexStatus).mockReturnValue({ + freshness: 'fresh', + detail: 'Index is fresh.', + }); + vi.mocked(queueWorkspacePipelinePluginReload).mockImplementation((_queue, _registry, reload) => { + const reloadResult = Promise.resolve(reload()).then(() => undefined); + return { + nextQueue: reloadResult, + reload: reloadResult, + }; + }); + vi.mocked(queueWorkspacePipelinePluginSync).mockImplementation((_queue, _registry, getWorkspaceRoot) => { + const sync = Promise.resolve(getWorkspaceRoot()).then(() => undefined); + return { + nextQueue: sync, + sync, + }; + }); + }); + + it('initializes and reloads workspace plugins through callback-based helpers', async () => { + const facade = new TestPluginFacade(); + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + await facade.initialize(); + + expect(initializeWorkspacePipelinePlugins).toHaveBeenCalledWith(facade._registry, expect.any(Function)); + expect(vi.mocked(initializeWorkspacePipelinePlugins).mock.calls[0][1]()).toBe('/workspace'); + expect(log).toHaveBeenCalledWith('[CodeGraphy] WorkspacePipeline initialized'); + + vi.mocked(initializeWorkspacePipelinePlugins).mockClear(); + await facade.reloadWorkspacePlugins(); + + expect(queueWorkspacePipelinePluginReload).toHaveBeenCalledWith( + expect.any(Promise), + facade._registry, + expect.any(Function), + ); + expect(initializeWorkspacePipelinePlugins).toHaveBeenCalledWith(facade._registry, expect.any(Function)); + }); + + it('syncs workspace plugins with the current workspace-root callback', async () => { + const facade = new TestPluginFacade(); + + await facade.syncWorkspacePlugins(); + + expect(queueWorkspacePipelinePluginSync).toHaveBeenCalledWith( + expect.any(Promise), + facade._registry, + expect.any(Function), + ); + expect(facade.getWorkspaceRoot).toHaveBeenCalledOnce(); + }); + + it('delegates plugin filters and effective filter resolution to plugin state helpers', () => { + const facade = new TestPluginFacade(); + const disabledPlugins = new Set(['plugin.disabled']); + + expect(facade.getPluginFilterPatterns(disabledPlugins)).toEqual(['plugin-filter']); + expect(getPipelinePluginFilterPatterns).toHaveBeenCalledWith(facade._registry, disabledPlugins); + + expect(facade.getPluginFilterGroups(disabledPlugins)).toEqual([{ + patterns: ['generated/**'], + pluginId: 'plugin.disabled', + pluginName: 'Disabled Plugin', + }]); + expect(getPipelinePluginFilterGroups).toHaveBeenCalledWith(facade._registry, disabledPlugins); + + expect(facade.effectiveCustomFilterPatterns(['dist/**'])).toEqual(['custom-filter']); + expect(getEffectiveCustomFilterPatterns).toHaveBeenCalledWith(facade._config, ['dist/**']); + + expect(facade.effectivePluginFilterPatterns(disabledPlugins)).toEqual(['effective-plugin-filter']); + expect(getEffectivePluginFilterPatterns).toHaveBeenCalledWith( + facade._registry, + facade._config, + disabledPlugins, + ); + }); + + it('delegates index checks and index status inputs through current facade state', () => { + const facade = new TestPluginFacade(); + + expect(facade.hasIndex()).toBe(true); + expect(hasWorkspacePipelineIndex).toHaveBeenCalledWith('/workspace'); + + expect(facade.getIndexStatus()).toEqual({ + freshness: 'fresh', + detail: 'Index is fresh.', + }); + + const statusInput = vi.mocked(getWorkspacePipelineIndexStatus).mock.calls[0][0]; + expect(statusInput.pluginSignature).toBe('plugin-signature'); + expect(statusInput.settingsSignature).toBe('settings-signature'); + expect(statusInput.workspaceRoot).toBe('/workspace'); + expect(statusInput.hasIndex()).toBe(true); + expect(hasWorkspacePipelineIndex).toHaveBeenLastCalledWith('/workspace'); + }); +}); From d9f0dd313e642eb9c1a92074b6b936f23466485c Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 18:55:54 -0700 Subject: [PATCH 148/192] test: kill plugin state mutants --- .../pipeline/service/pluginState.test.ts | 187 ++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 packages/extension/tests/extension/pipeline/service/pluginState.test.ts diff --git a/packages/extension/tests/extension/pipeline/service/pluginState.test.ts b/packages/extension/tests/extension/pipeline/service/pluginState.test.ts new file mode 100644 index 000000000..8106398bc --- /dev/null +++ b/packages/extension/tests/extension/pipeline/service/pluginState.test.ts @@ -0,0 +1,187 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + getEffectiveCustomFilterPatterns, + getEffectivePluginFilterPatterns, + getPipelinePluginFilterGroups, + getPipelinePluginFilterPatterns, + initializeWorkspacePipelinePlugins, + queueWorkspacePipelinePluginReload, + queueWorkspacePipelinePluginSync, +} from '../../../../src/extension/pipeline/service/pluginState'; +import { + getWorkspacePipelinePluginFilterGroups, + getWorkspacePipelinePluginFilterPatterns, + initializeWorkspacePipeline, + syncWorkspacePipelinePlugins, +} from '../../../../src/extension/pipeline/plugins/bootstrap'; + +vi.mock('../../../../src/extension/pipeline/plugins/bootstrap', () => ({ + getWorkspacePipelinePluginFilterGroups: vi.fn(), + getWorkspacePipelinePluginFilterPatterns: vi.fn(), + initializeWorkspacePipeline: vi.fn(), + syncWorkspacePipelinePlugins: vi.fn(), +})); + +function createDeferred(): { + promise: Promise; + resolve(): void; + reject(reason: unknown): void; +} { + let resolve!: () => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve; + reject = promiseReject; + }); + + return { promise, resolve, reject }; +} + +describe('extension/pipeline/service/pluginState', () => { + const registry = { + disposeAll: vi.fn(), + id: 'registry', + } as unknown as Parameters[0] & { + disposeAll: ReturnType; + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getWorkspacePipelinePluginFilterPatterns).mockReturnValue([ + 'generated/**', + 'vendor/**', + 'node_modules/**', + ]); + vi.mocked(getWorkspacePipelinePluginFilterGroups).mockReturnValue([{ + patterns: ['generated/**'], + pluginId: 'plugin.generated', + pluginName: 'Generated Plugin', + }]); + }); + + it('initializes workspace plugins with a live workspace-root callback', async () => { + const getWorkspaceRoot = vi.fn(() => '/workspace'); + + await initializeWorkspacePipelinePlugins(registry, getWorkspaceRoot); + + expect(initializeWorkspacePipeline).toHaveBeenCalledWith(registry, { + getWorkspaceRoot, + }); + expect(vi.mocked(initializeWorkspacePipeline).mock.calls[0][1].getWorkspaceRoot()).toBe('/workspace'); + }); + + it('queues plugin reload after existing work, disposes plugins, and initializes again', async () => { + const existingWork = createDeferred(); + const initialize = vi.fn(async () => undefined); + + const { nextQueue, reload } = queueWorkspacePipelinePluginReload( + existingWork.promise, + registry, + initialize, + ); + + await Promise.resolve(); + expect(registry.disposeAll).not.toHaveBeenCalled(); + expect(initialize).not.toHaveBeenCalled(); + + existingWork.resolve(); + await reload; + await nextQueue; + + expect(registry.disposeAll).toHaveBeenCalledOnce(); + expect(initialize).toHaveBeenCalledOnce(); + expect(registry.disposeAll.mock.invocationCallOrder[0]).toBeLessThan( + initialize.mock.invocationCallOrder[0], + ); + }); + + it('keeps the reload queue usable when a reload fails', async () => { + const initialize = vi.fn(async () => { + throw new Error('reload failed'); + }); + + const { nextQueue, reload } = queueWorkspacePipelinePluginReload( + Promise.resolve(), + registry, + initialize, + ); + + await expect(reload).rejects.toThrow('reload failed'); + await expect(nextQueue).resolves.toBeUndefined(); + }); + + it('queues workspace plugin sync with a live workspace-root callback', async () => { + const existingWork = createDeferred(); + const getWorkspaceRoot = vi.fn(() => '/workspace'); + vi.mocked(syncWorkspacePipelinePlugins).mockImplementation(async (_registry, options) => { + expect(options.getWorkspaceRoot()).toBe('/workspace'); + }); + + const { nextQueue, sync } = queueWorkspacePipelinePluginSync( + existingWork.promise, + registry, + getWorkspaceRoot, + ); + + await Promise.resolve(); + expect(syncWorkspacePipelinePlugins).not.toHaveBeenCalled(); + + existingWork.resolve(); + await sync; + await nextQueue; + + expect(syncWorkspacePipelinePlugins).toHaveBeenCalledWith(registry, { + getWorkspaceRoot, + }); + }); + + it('keeps the sync queue usable when a sync fails', async () => { + vi.mocked(syncWorkspacePipelinePlugins).mockRejectedValueOnce(new Error('sync failed')); + + const { nextQueue, sync } = queueWorkspacePipelinePluginSync( + Promise.resolve(), + registry, + () => '/workspace', + ); + + await expect(sync).rejects.toThrow('sync failed'); + await expect(nextQueue).resolves.toBeUndefined(); + }); + + it('delegates plugin filter patterns and groups to bootstrap helpers', () => { + const disabledPlugins = new Set(['plugin.disabled']); + + expect(getPipelinePluginFilterPatterns(registry, disabledPlugins)).toEqual([ + 'generated/**', + 'vendor/**', + 'node_modules/**', + ]); + expect(getWorkspacePipelinePluginFilterPatterns).toHaveBeenCalledWith(registry, disabledPlugins); + + expect(getPipelinePluginFilterGroups(registry, disabledPlugins)).toEqual([{ + patterns: ['generated/**'], + pluginId: 'plugin.generated', + pluginName: 'Generated Plugin', + }]); + expect(getWorkspacePipelinePluginFilterGroups).toHaveBeenCalledWith(registry, disabledPlugins); + }); + + it('removes disabled custom and plugin filter patterns', () => { + expect(getEffectiveCustomFilterPatterns( + { + disabledCustomFilterPatterns: ['vendor/**'], + disabledPluginFilterPatterns: [], + }, + ['generated/**', 'vendor/**', 'node_modules/**'], + )).toEqual(['generated/**', 'node_modules/**']); + + expect(getEffectivePluginFilterPatterns( + registry, + { + disabledCustomFilterPatterns: [], + disabledPluginFilterPatterns: ['vendor/**'], + }, + new Set(['plugin.disabled']), + )).toEqual(['generated/**', 'node_modules/**']); + }); +}); From 65176cb38af516e8f0264d67db81397dee355b10 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 19:12:18 -0700 Subject: [PATCH 149/192] test: kill refresh facade mutants --- .../pipeline/service/refreshFacade.test.ts | 88 ++++++++++++++++++- 1 file changed, 87 insertions(+), 1 deletion(-) diff --git a/packages/extension/tests/extension/pipeline/service/refreshFacade.test.ts b/packages/extension/tests/extension/pipeline/service/refreshFacade.test.ts index 47a5a9434..390b5eb05 100644 --- a/packages/extension/tests/extension/pipeline/service/refreshFacade.test.ts +++ b/packages/extension/tests/extension/pipeline/service/refreshFacade.test.ts @@ -9,6 +9,7 @@ import { import { refreshWorkspacePipelineAnalysisScope, refreshWorkspacePipelineChangedFiles, + refreshWorkspacePipelinePluginFiles, } from '../../../../src/extension/pipeline/service/runtime/refresh'; import { CACHE_VERSION, @@ -25,6 +26,7 @@ vi.mock('../../../../src/extension/pipeline/service/runtime/discovery', () => ({ vi.mock('../../../../src/extension/pipeline/service/runtime/refresh', () => ({ refreshWorkspacePipelineAnalysisScope: vi.fn(), refreshWorkspacePipelineChangedFiles: vi.fn(), + refreshWorkspacePipelinePluginFiles: vi.fn(), })); vi.mock('vscode', () => ({ @@ -174,6 +176,10 @@ describe('pipeline/service/refreshFacade', () => { nodes: [{ id: 'scope-refresh' }], edges: [], } as never); + vi.mocked(refreshWorkspacePipelinePluginFiles).mockResolvedValue({ + nodes: [{ id: 'plugin-refresh' }], + edges: [], + } as never); }); it('returns an empty graph immediately when no workspace root is available', async () => { @@ -466,6 +472,77 @@ describe('pipeline/service/refreshFacade', () => { ); }); + it('uses empty filters by default for analysis-scope refreshes', async () => { + const facade = new TestRefreshFacade(); + + await facade.refreshAnalysisScope(); + + expect(discoverWorkspacePipelineFilesWithWarnings).toHaveBeenCalledWith( + 'discovery-deps', + '/workspace', + { showOrphans: true, respectGitignore: true }, + [], + ['plugin-filter'], + undefined, + expect.any(Function), + ); + }); + + it('builds delegated discovery and refresh dependencies for plugin-file refreshes', async () => { + const facade = new TestRefreshFacade(); + const disabledPlugins = new Set(['plugin.disabled']); + const signal = new AbortController().signal; + const onProgress = vi.fn(); + + const result = await facade.refreshPluginFiles( + ['plugin.a'], + undefined, + disabledPlugins, + signal, + onProgress, + ); + + expect(result).toEqual({ nodes: [{ id: 'plugin-refresh' }], edges: [] }); + expect(facade._lastGitIgnoredPaths).toEqual(['example-python/app.py']); + expect(discoverWorkspacePipelineFilesWithWarnings).toHaveBeenCalledWith( + 'discovery-deps', + '/workspace', + { showOrphans: true, respectGitignore: true }, + [], + ['plugin-filter'], + signal, + expect.any(Function), + ); + + const [refreshSource, refreshDependencies] = vi.mocked(refreshWorkspacePipelinePluginFiles).mock.calls[0]; + expect(refreshDependencies.disabledPlugins).toBe(disabledPlugins); + expect(refreshDependencies.discoveredDirectories).toEqual([]); + expect(refreshDependencies.discoveredFiles).toEqual([ + { absolutePath: '/workspace/src/a.ts', relativePath: 'src/a.ts' }, + ]); + expect(refreshDependencies.onProgress).toBe(onProgress); + expect(refreshDependencies.pluginIds).toEqual(['plugin.a']); + expect(refreshDependencies.pluginInfos).toEqual([{ plugin: { id: 'plugin.a' } }]); + expect(refreshDependencies.signal).toBe(signal); + expect(refreshDependencies.workspaceRoot).toBe('/workspace'); + + refreshDependencies.persistCache(); + expect(facade._persistCache).toHaveBeenCalledOnce(); + + await refreshDependencies.persistIndexMetadata(); + expect(facade._persistIndexMetadata).toHaveBeenCalledOnce(); + + await refreshSource._analyzeFiles([], '/workspace', undefined, signal); + expect(facade._analyzeFiles).toHaveBeenCalledWith( + [], + '/workspace', + undefined, + signal, + undefined, + disabledPlugins, + ); + }); + it('rebuilds analysis scope from tier-complete cached analysis without reanalyzing files', async () => { const facade = new TestRefreshFacade(); const disabledPlugins = new Set(['plugin.disabled']); @@ -558,7 +635,7 @@ describe('pipeline/service/refreshFacade', () => { })) as never; await expect( - facade.refreshGitignoreMetadata(['dist/**'], disabledPlugins), + facade.refreshGitignoreMetadata(undefined, disabledPlugins), ).resolves.toEqual({ nodes: [{ color: '#64748B', @@ -571,6 +648,15 @@ describe('pipeline/service/refreshFacade', () => { expect(facade._analyzeFiles).not.toHaveBeenCalled(); expect(facade._lastGitIgnoredPaths).toEqual(['example-python/src/main.py']); + expect(discoverWorkspacePipelineFilesWithWarnings).toHaveBeenCalledWith( + 'discovery-deps', + '/workspace', + { showOrphans: true, respectGitignore: true }, + [], + ['plugin-filter'], + undefined, + expect.any(Function), + ); expect(facade._buildGraphDataFromAnalysis).toHaveBeenCalledWith( facade._lastFileAnalysis, '/workspace', From caab061352b989f4f6e51164ce766dac1d297dcf Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 19:14:50 -0700 Subject: [PATCH 150/192] test: kill cache index mutants --- .../tests/extension/pipeline/service/cache/index.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/extension/tests/extension/pipeline/service/cache/index.test.ts b/packages/extension/tests/extension/pipeline/service/cache/index.test.ts index 823b6b694..5a1f8e274 100644 --- a/packages/extension/tests/extension/pipeline/service/cache/index.test.ts +++ b/packages/extension/tests/extension/pipeline/service/cache/index.test.ts @@ -129,6 +129,7 @@ describe('pipeline/service/cache/index', () => { pluginSignature: 'next-plugin-signature', settingsSignature: 'next-settings-signature', }); + expect(writeCodeGraphyRepoMeta).not.toHaveBeenCalled(); expect(warn).not.toHaveBeenCalled(); }); From 8895ae03bfd7eb34ba502b261486827a6c8ccc4e Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 19:32:03 -0700 Subject: [PATCH 151/192] test: kill cached discovery mutants --- .../service/cache/cachedDiscovery.test.ts | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/extension/tests/extension/pipeline/service/cache/cachedDiscovery.test.ts b/packages/extension/tests/extension/pipeline/service/cache/cachedDiscovery.test.ts index 712aee4b9..9857a4c24 100644 --- a/packages/extension/tests/extension/pipeline/service/cache/cachedDiscovery.test.ts +++ b/packages/extension/tests/extension/pipeline/service/cache/cachedDiscovery.test.ts @@ -2,13 +2,19 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { spawnSync } from 'node:child_process'; import { collectCachedGitIgnoredPaths, + collectCachedDirectoryPaths, createCachedWorkspaceDiscoveryState, } from '../../../../../src/extension/pipeline/service/cache/cachedDiscovery'; -vi.mock('node:child_process', () => ({ +const childProcessMock = vi.hoisted(() => ({ spawnSync: vi.fn(), })); +vi.mock('node:child_process', () => ({ + ...childProcessMock, + default: childProcessMock, +})); + describe('pipeline/service/cache/cachedDiscovery', () => { beforeEach(() => { vi.mocked(spawnSync).mockReset(); @@ -45,6 +51,27 @@ describe('pipeline/service/cache/cachedDiscovery', () => { ], gitIgnoredPaths: [], }); + expect(spawnSync).toHaveBeenCalledWith( + 'git', + ['-C', '/workspace', 'check-ignore', '--stdin'], + { + encoding: 'utf8', + input: 'src\nsrc/nested\nsrc/nested/cached.ts\nREADME.md\n', + }, + ); + }); + + it('normalizes windows separators while deriving cached directory ancestry', () => { + expect( + collectCachedDirectoryPaths([ + 'src\\nested\\cached.ts', + 'src\\other\\child.ts', + ]), + ).toEqual([ + 'src', + 'src/nested', + 'src/other', + ]); }); it('collects current gitignore matches only for cached paths', () => { From 5b2c33acfb576f668e5333e1594212fa8be21cdf Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 19:35:48 -0700 Subject: [PATCH 152/192] test: kill cached gitignore mutants --- .../faster-graph-cache-and-filtering.md | 2 + .../cache/cachedDiscovery/gitignore.ts | 15 ++++- .../service/cache/cachedDiscovery.test.ts | 59 +++++++++++++++++++ 3 files changed, 73 insertions(+), 3 deletions(-) diff --git a/.changeset/faster-graph-cache-and-filtering.md b/.changeset/faster-graph-cache-and-filtering.md index fdd16e77c..37831df2c 100644 --- a/.changeset/faster-graph-cache-and-filtering.md +++ b/.changeset/faster-graph-cache-and-filtering.md @@ -6,3 +6,5 @@ Large CodeGraphy workspaces now index, save, and filter graph data much faster. On the CodeGraphy monorepo benchmark, cold indexing improved from 214.04s to 17.28s, Graph Cache saves improved from 122,757ms to 10,904ms, and the Graph Cache shrank from 64,638,976 bytes to 18,153,472 bytes. The same benchmark now projects the current Visible Graph in 12ms instead of 775ms. Folder-node projection improved from 1,369ms to 32ms, import-edge-off projection improved from 153ms to 7ms, and search projection improved from 781ms to 12ms. + +Graph Cache replay also normalizes cached path separators before checking gitignore rules, so ignored files stay filtered across platforms during warm starts. diff --git a/packages/extension/src/extension/pipeline/service/cache/cachedDiscovery/gitignore.ts b/packages/extension/src/extension/pipeline/service/cache/cachedDiscovery/gitignore.ts index 3ee602c17..32264715c 100644 --- a/packages/extension/src/extension/pipeline/service/cache/cachedDiscovery/gitignore.ts +++ b/packages/extension/src/extension/pipeline/service/cache/cachedDiscovery/gitignore.ts @@ -1,8 +1,7 @@ import { spawnSync } from 'node:child_process'; -import * as path from 'node:path'; function toGitPath(relativePath: string): string { - return relativePath.split(path.sep).join('/'); + return relativePath.split(/[\\/]/).join('/'); } function createCachedGitPathLookup(relativePaths: readonly string[]): Map { @@ -14,7 +13,17 @@ function createGitCheckIgnoreInput(pathsByGitPath: ReadonlyMap): } function didGitCheckIgnoreFail(result: ReturnType): boolean { - return Boolean(result.error) || (result.status !== 0 && result.status !== 1); + if (result.error) { + return true; + } + + switch (result.status) { + case 0: + case 1: + return false; + default: + return true; + } } function readGitIgnoredCachedPaths( diff --git a/packages/extension/tests/extension/pipeline/service/cache/cachedDiscovery.test.ts b/packages/extension/tests/extension/pipeline/service/cache/cachedDiscovery.test.ts index 9857a4c24..978fd01e4 100644 --- a/packages/extension/tests/extension/pipeline/service/cache/cachedDiscovery.test.ts +++ b/packages/extension/tests/extension/pipeline/service/cache/cachedDiscovery.test.ts @@ -98,8 +98,67 @@ describe('pipeline/service/cache/cachedDiscovery', () => { ); }); + it('maps git-normalized ignored paths back to cached relative paths', () => { + vi.mocked(spawnSync).mockReturnValue({ + error: undefined, + status: 0, + stdout: 'src/generated.ts\nexternal/generated.ts\n', + } as never); + + expect( + collectCachedGitIgnoredPaths( + '/workspace', + ['src\\generated.ts'], + true, + ), + ).toEqual(['src\\generated.ts', 'external/generated.ts']); + expect(spawnSync).toHaveBeenCalledWith( + 'git', + ['-C', '/workspace', 'check-ignore', '--stdin'], + { + encoding: 'utf8', + input: 'src/generated.ts\n', + }, + ); + }); + + it('returns no ignored paths when git check-ignore fails', () => { + vi.mocked(spawnSync).mockReturnValueOnce({ + error: undefined, + status: 2, + stdout: 'src/generated.ts\n', + } as never); + + expect( + collectCachedGitIgnoredPaths( + '/workspace', + ['src/generated.ts'], + true, + ), + ).toEqual([]); + + vi.mocked(spawnSync).mockReturnValueOnce({ + error: new Error('git failed'), + status: 0, + stdout: 'src/generated.ts\n', + } as never); + + expect( + collectCachedGitIgnoredPaths( + '/workspace', + ['src/generated.ts'], + true, + ), + ).toEqual([]); + }); + it('skips git when gitignore handling is disabled', () => { expect(collectCachedGitIgnoredPaths('/workspace', ['src/generated.ts'], false)).toEqual([]); expect(spawnSync).not.toHaveBeenCalled(); }); + + it('skips git when there are no cached paths to check', () => { + expect(collectCachedGitIgnoredPaths('/workspace', [], true)).toEqual([]); + expect(spawnSync).not.toHaveBeenCalled(); + }); }); From c874ba4ae9596aba088f74f3ac350f09bd5587a2 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 19:42:37 -0700 Subject: [PATCH 153/192] test: kill pipeline state mutants --- .../pipeline/service/base/state.test.ts | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) diff --git a/packages/extension/tests/extension/pipeline/service/base/state.test.ts b/packages/extension/tests/extension/pipeline/service/base/state.test.ts index 4ae532480..c9749d8c2 100644 --- a/packages/extension/tests/extension/pipeline/service/base/state.test.ts +++ b/packages/extension/tests/extension/pipeline/service/base/state.test.ts @@ -141,18 +141,122 @@ describe('extension/pipeline/service/stateBase', () => { }); }); + it('skips Graph Cache warming without a workspace root or when cache is already populated', async () => { + const stateWithoutRoot = new TestWorkspacePipelineState(createContext()) as TestWorkspacePipelineState & { + warmGraphCache(): Promise; + }; + + await stateWithoutRoot.warmGraphCache(); + expect(stateBaseHarness.loadWorkspaceAnalysisDatabaseCache).not.toHaveBeenCalled(); + + const stateWithCache = new TestWorkspacePipelineState(createContext(), '/workspace') as TestWorkspacePipelineState & { + _cache: unknown; + warmGraphCache(): Promise; + }; + stateWithCache._cache = { + version: '2.1.0', + files: { + 'src/current.ts': { + mtime: 2, + analysis: { filePath: '/workspace/src/current.ts', relations: [] }, + }, + }, + }; + + await stateWithCache.warmGraphCache(); + + expect(stateBaseHarness.loadWorkspaceAnalysisDatabaseCache).not.toHaveBeenCalled(); + }); + + it('does not overwrite cache populated while Graph Cache hydration is pending', async () => { + let resolveHydration!: (cache: unknown) => void; + stateBaseHarness.loadWorkspaceAnalysisDatabaseCache.mockReturnValueOnce( + new Promise(resolve => { + resolveHydration = resolve; + }), + ); + const state = new TestWorkspacePipelineState(createContext(), '/workspace') as TestWorkspacePipelineState & { + _cache: unknown; + warmGraphCache(): Promise; + }; + const populatedDuringHydration = { + version: '2.1.0', + files: { + 'src/current.ts': { + mtime: 2, + analysis: { filePath: '/workspace/src/current.ts', relations: [] }, + }, + }, + }; + + const warm = state.warmGraphCache(); + state._cache = populatedDuringHydration; + resolveHydration({ + version: '2.1.0', + files: { + 'src/stale.ts': { + mtime: 1, + analysis: { filePath: '/workspace/src/stale.ts', relations: [] }, + }, + }, + }); + await warm; + + expect(state._cache).toBe(populatedDuringHydration); + }); + + it('clears the shared hydration promise so empty cache warms can retry', async () => { + stateBaseHarness.loadWorkspaceAnalysisDatabaseCache.mockReturnValue({ + version: '2.1.0', + files: {}, + }); + const state = new TestWorkspacePipelineState(createContext(), '/workspace') as TestWorkspacePipelineState & { + warmGraphCache(): Promise; + }; + + await state.warmGraphCache(); + await state.warmGraphCache(); + + expect(stateBaseHarness.loadWorkspaceAnalysisDatabaseCache).toHaveBeenCalledTimes(2); + }); + it('stores retained indexing fields in the core engine state', () => { const state = new TestWorkspacePipelineState(createContext()) as TestWorkspacePipelineState & { _cache: { files: Record }; _engineState: { cache: unknown; fileAnalysis: unknown; + fileConnections: unknown; + discoveredFiles: unknown; + graph: unknown; workspaceRoot: string; }; _lastFileAnalysis: Map; + _lastFileConnections: Map; + _lastDiscoveredFiles: unknown[]; + _lastGraphData: unknown; _lastWorkspaceRoot: string; }; const fileAnalysis = new Map([['src/a.ts', { filePath: '/workspace/src/a.ts' }]]); + const fileConnections = new Map([[ + 'src/a.ts', + [{ + kind: 'import' as const, + sourceId: 'src/a.ts', + specifier: './b', + resolvedPath: '/workspace/src/b.ts', + }], + ]]); + const discoveredFiles = [{ + absolutePath: '/workspace/src/a.ts', + extension: '.ts', + name: 'a.ts', + relativePath: 'src/a.ts', + }]; + const graphData = { + nodes: [{ id: 'src/a.ts', label: 'a.ts', color: '#67E8F9', nodeType: 'file' }], + edges: [], + }; state._cache = { version: 'test', @@ -164,10 +268,18 @@ describe('extension/pipeline/service/stateBase', () => { }, }; state._lastFileAnalysis = fileAnalysis; + state._lastFileConnections = fileConnections; + state._lastDiscoveredFiles = discoveredFiles; + state._lastGraphData = graphData; state._lastWorkspaceRoot = '/workspace'; expect(state._engineState.cache).toBe(state._cache); expect(state._engineState.fileAnalysis).toBe(fileAnalysis); + expect(state._lastFileConnections).toBe(fileConnections); + expect(state._engineState.fileConnections).toBe(fileConnections); + expect(state._engineState.discoveredFiles).toBe(discoveredFiles); + expect(state._lastGraphData).toBe(graphData); + expect(state._engineState.graph).toBe(graphData); expect(state._engineState.workspaceRoot).toBe('/workspace'); }); }); From e52134312db66dfc7a69ae366d3c65d2d820bce1 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 19:47:30 -0700 Subject: [PATCH 154/192] test: kill pipeline internal mutants --- .../pipeline/service/base/internal.test.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/packages/extension/tests/extension/pipeline/service/base/internal.test.ts b/packages/extension/tests/extension/pipeline/service/base/internal.test.ts index 6c1869a47..e8aba0c81 100644 --- a/packages/extension/tests/extension/pipeline/service/base/internal.test.ts +++ b/packages/extension/tests/extension/pipeline/service/base/internal.test.ts @@ -26,6 +26,7 @@ import { createWorkspacePipelineSettingsSignature, readWorkspacePipelineCurrentCommitShaSync, } from '../../../../../src/extension/pipeline/service/cache/signatures'; +import { createWorkspacePipelineAnalysisCacheTiers } from '../../../../../src/extension/pipeline/service/cache/tiers'; import { preAnalyzeCoreTreeSitterFiles } from '@codegraphy-dev/core'; vi.mock('../../../../../src/extension/pipeline/serviceAdapters', () => ({ @@ -65,6 +66,10 @@ vi.mock('../../../../../src/extension/pipeline/service/cache/signatures', () => readWorkspacePipelineCurrentCommitShaSync: vi.fn(), })); +vi.mock('../../../../../src/extension/pipeline/service/cache/tiers', () => ({ + createWorkspacePipelineAnalysisCacheTiers: vi.fn(), +})); + vi.mock('@codegraphy-dev/core', async (importOriginal) => ({ ...(await importOriginal()), preAnalyzeCoreTreeSitterFiles: vi.fn(), @@ -225,6 +230,11 @@ describe('extension/pipeline/service/internalBase', () => { vi.clearAllMocks(); vi.mocked(createWorkspacePipelinePluginSignature).mockReturnValue('plugin-signature'); vi.mocked(createWorkspacePipelineSettingsSignature).mockReturnValue('settings-signature'); + vi.mocked(createWorkspacePipelineAnalysisCacheTiers).mockReturnValue({ + active: ['baseline', 'plugin:plugin.a'], + completed: ['baseline', 'plugin:plugin.a'], + required: ['baseline', 'plugin:plugin.a'], + }); vi.mocked(readWorkspacePipelineCurrentCommitSha).mockResolvedValue('async-commit-sha'); vi.mocked(readWorkspacePipelineCurrentCommitShaSync).mockReturnValue('commit-sha'); vi.mocked(readWorkspacePipelineFileStat).mockResolvedValue({ @@ -295,6 +305,15 @@ describe('extension/pipeline/service/internalBase', () => { const progress = vi.fn(); const disabledPlugins = new Set(['plugin.disabled']); source.setEventBus({ emit: vi.fn() } as never); + source._registry = { + ...source._registry, + list: vi.fn(() => [ + { plugin: { id: 'plugin.a' } }, + { plugin: { id: '' } }, + { plugin: { id: undefined } }, + { plugin: { id: 'plugin.disabled' } }, + ]), + } as never; const state = source as unknown as { _eventBus: unknown }; const getFileStat = vi .spyOn(source as unknown as { _getFileStat: (filePath: string) => Promise<{ mtime: number; size: number } | null> }, '_getFileStat') @@ -325,6 +344,10 @@ describe('extension/pipeline/service/internalBase', () => { ['plugin.a'], disabledPlugins, ); + expect(createWorkspacePipelineAnalysisCacheTiers).toHaveBeenCalledWith( + { file: true, symbol: false }, + ['plugin.a'], + ); await expect( vi.mocked(analyzeWorkspacePipelineDiscoveredFiles).mock.calls[0][4]('/workspace/src/a.ts'), ).resolves.toEqual({ mtime: 1, size: 2 }); @@ -484,6 +507,9 @@ describe('extension/pipeline/service/internalBase', () => { expect(getPluginSignature).toHaveBeenCalledOnce(); expect(dependencies.getSettingsSignature()).toBe('settings-signature'); expect(getSettingsSignature).toHaveBeenCalledOnce(); + expect(dependencies.getCurrentCommitSha).toBeDefined(); + expect(dependencies.getCurrentCommitSha?.()).toBe('commit-sha'); + expect(readWorkspacePipelineCurrentCommitShaSync).toHaveBeenCalledWith('/workspace'); dependencies.warn('failed to persist', new Error('boom')); expect(warnSpy).toHaveBeenCalledWith('failed to persist', expect.any(Error)); }); From 3b17e9d158da80f6bf0a084e58dc1ebe5a6e9e49 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 19:50:51 -0700 Subject: [PATCH 155/192] test: cover refresh context fallback graph --- .../pipeline/service/refresh/context.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 packages/extension/tests/extension/pipeline/service/refresh/context.test.ts diff --git a/packages/extension/tests/extension/pipeline/service/refresh/context.test.ts b/packages/extension/tests/extension/pipeline/service/refresh/context.test.ts new file mode 100644 index 000000000..261fb5b9e --- /dev/null +++ b/packages/extension/tests/extension/pipeline/service/refresh/context.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from 'vitest'; +import { EMPTY_REFRESH_GRAPH } from '../../../../../src/extension/pipeline/service/refresh/context'; + +describe('extension/pipeline/service/refresh/context', () => { + it('uses an empty graph shape for refresh fallbacks', () => { + expect(EMPTY_REFRESH_GRAPH).toEqual({ + nodes: [], + edges: [], + }); + }); +}); From 3aaa556b78e49db7899d966a7c48806cb0b1ea2b Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 19:57:01 -0700 Subject: [PATCH 156/192] test: kill refresh metrics mutants --- .../pipeline/service/refresh/metrics.test.ts | 212 ++++++++++++++++++ 1 file changed, 212 insertions(+) create mode 100644 packages/extension/tests/extension/pipeline/service/refresh/metrics.test.ts diff --git a/packages/extension/tests/extension/pipeline/service/refresh/metrics.test.ts b/packages/extension/tests/extension/pipeline/service/refresh/metrics.test.ts new file mode 100644 index 000000000..2955ac567 --- /dev/null +++ b/packages/extension/tests/extension/pipeline/service/refresh/metrics.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, it } from 'vitest'; +import type { IGraphData, IGraphNode } from '../../../../../src/shared/graph/contracts'; +import { patchGraphDataNodeMetrics } from '../../../../../src/extension/pipeline/service/refresh/metrics'; + +function createNode(overrides: Partial = {}): IGraphNode { + return { + id: 'src/a.ts', + label: 'a.ts', + color: '#67E8F9', + nodeType: 'file', + ...overrides, + }; +} + +function createGraph(nodes: IGraphNode[]): IGraphData { + return { + nodes, + edges: [], + }; +} + +describe('extension/pipeline/service/refresh/metrics', () => { + it('returns the same graph when there are no metric file paths', () => { + const graphData = { + get nodes(): IGraphNode[] { + throw new Error('nodes should not be read without metric file paths'); + }, + edges: [], + } as IGraphData; + + expect( + patchGraphDataNodeMetrics({ + churnCounts: {}, + filePaths: [], + fileSizes: {}, + graphData, + }), + ).toBe(graphData); + }); + + it('returns the same graph when metric paths do not match any node', () => { + const graphData = createGraph([createNode({ id: 'src/a.ts' })]); + + expect( + patchGraphDataNodeMetrics({ + churnCounts: { 'src/other.ts': 4 }, + filePaths: ['src/other.ts'], + fileSizes: { 'src/other.ts': { size: 64 } }, + graphData, + }), + ).toBe(graphData); + }); + + it('patches matching node metrics while preserving unchanged nodes', () => { + const unchangedNode = createNode({ id: 'src/unchanged.ts', label: 'unchanged.ts' }); + const graphData = createGraph([ + createNode({ id: 'src\\a.ts', fileSize: 12, churn: 1 }), + unchangedNode, + ]); + + const patchedGraph = patchGraphDataNodeMetrics({ + churnCounts: { 'src/a.ts': 4 }, + filePaths: ['src\\a.ts'], + fileSizes: { 'src/a.ts': { size: 64 } }, + graphData, + }); + + expect(patchedGraph).not.toBe(graphData); + expect(patchedGraph.nodes).toEqual([ + createNode({ id: 'src\\a.ts', fileSize: 64, churn: 4 }), + unchangedNode, + ]); + expect(patchedGraph.nodes[1]).toBe(unchangedNode); + }); + + it('uses symbol file paths when matching metric updates', () => { + const graphData = createGraph([ + createNode({ + id: 'symbol:loadUser', + nodeType: 'symbol', + symbol: { + id: 'symbol:loadUser', + name: 'loadUser', + kind: 'function', + filePath: 'src/users.ts', + }, + }), + ]); + + expect( + patchGraphDataNodeMetrics({ + churnCounts: { 'src/users.ts': 7 }, + filePaths: ['src/users.ts'], + fileSizes: { 'src/users.ts': { size: 128 } }, + graphData, + }).nodes[0], + ).toEqual(createNode({ + id: 'symbol:loadUser', + nodeType: 'symbol', + fileSize: 128, + churn: 7, + symbol: { + id: 'symbol:loadUser', + name: 'loadUser', + kind: 'function', + filePath: 'src/users.ts', + }, + })); + }); + + it('falls back to node ids when symbol file paths are empty', () => { + const graphData = createGraph([ + createNode({ + id: 'src/a.ts', + symbol: { + id: 'symbol:a', + name: 'a', + kind: 'function', + filePath: '', + }, + }), + ]); + + expect( + patchGraphDataNodeMetrics({ + churnCounts: { 'src/a.ts': 3 }, + filePaths: ['src/a.ts'], + fileSizes: { 'src/a.ts': { size: 20 } }, + graphData, + }).nodes[0], + ).toEqual(createNode({ + fileSize: 20, + churn: 3, + symbol: { + id: 'symbol:a', + name: 'a', + kind: 'function', + filePath: '', + }, + })); + }); + + it('returns the same graph when matching metrics are unchanged', () => { + const graphData = createGraph([createNode({ fileSize: 64, churn: 2 })]); + + expect( + patchGraphDataNodeMetrics({ + churnCounts: { 'src/a.ts': 2 }, + filePaths: ['src/a.ts'], + fileSizes: { 'src/a.ts': { size: 64 } }, + graphData, + }), + ).toBe(graphData); + }); + + it('patches when only one metric changes', () => { + const graphData = createGraph([ + createNode({ id: 'src/size.ts', label: 'size.ts', fileSize: 10, churn: 2 }), + createNode({ id: 'src/churn.ts', label: 'churn.ts', fileSize: 20, churn: 1 }), + ]); + + expect( + patchGraphDataNodeMetrics({ + churnCounts: { 'src/size.ts': 2, 'src/churn.ts': 5 }, + filePaths: ['src/size.ts', 'src/churn.ts'], + fileSizes: { + 'src/size.ts': { size: 16 }, + 'src/churn.ts': { size: 20 }, + }, + graphData, + }).nodes, + ).toEqual([ + createNode({ id: 'src/size.ts', label: 'size.ts', fileSize: 16, churn: 2 }), + createNode({ id: 'src/churn.ts', label: 'churn.ts', fileSize: 20, churn: 5 }), + ]); + }); + + it('preserves unchanged matched nodes when another matched node changes', () => { + const graphData = createGraph([ + createNode({ id: 'src/stable.ts', label: 'stable.ts', fileSize: 10, churn: 2 }), + createNode({ id: 'src/changed.ts', label: 'changed.ts', fileSize: 20, churn: 1 }), + ]); + + expect( + patchGraphDataNodeMetrics({ + churnCounts: { 'src/stable.ts': 2, 'src/changed.ts': 5 }, + filePaths: ['src/stable.ts', 'src/changed.ts'], + fileSizes: { + 'src/stable.ts': { size: 10 }, + 'src/changed.ts': { size: 20 }, + }, + graphData, + }).nodes, + ).toEqual([ + createNode({ id: 'src/stable.ts', label: 'stable.ts', fileSize: 10, churn: 2 }), + createNode({ id: 'src/changed.ts', label: 'changed.ts', fileSize: 20, churn: 5 }), + ]); + }); + + it('defaults missing size and churn metrics without throwing', () => { + const graphData = createGraph([createNode()]); + + expect( + patchGraphDataNodeMetrics({ + churnCounts: {}, + filePaths: ['src/a.ts'], + fileSizes: {}, + graphData, + }).nodes[0], + ).toEqual(createNode({ fileSize: undefined, churn: 0 })); + }); +}); From a526210dfbb95fd7feb8191e7ed8adcff8b0dfd9 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 20:03:23 -0700 Subject: [PATCH 157/192] test: kill refresh source mutants --- .../pipeline/service/refresh/source.test.ts | 175 ++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 packages/extension/tests/extension/pipeline/service/refresh/source.test.ts diff --git a/packages/extension/tests/extension/pipeline/service/refresh/source.test.ts b/packages/extension/tests/extension/pipeline/service/refresh/source.test.ts new file mode 100644 index 000000000..276983352 --- /dev/null +++ b/packages/extension/tests/extension/pipeline/service/refresh/source.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { IGraphData } from '../../../../../src/shared/graph/contracts'; +import { + createWorkspaceIndexRefreshSource, + type RefreshSourceFacade, +} from '../../../../../src/extension/pipeline/service/refresh/source'; + +function createGraph(id = 'graph'): IGraphData { + return { + nodes: [{ id, label: id, color: '#67E8F9', nodeType: 'file' }], + edges: [], + }; +} + +function createRefreshFacade(): RefreshSourceFacade { + const graphData = createGraph(); + return { + _analyzeFiles: vi.fn(async () => ({ + fileAnalysis: new Map(), + fileConnections: new Map(), + })) as unknown as RefreshSourceFacade['_analyzeFiles'], + _buildGraphData: vi.fn(() => graphData) as unknown as RefreshSourceFacade['_buildGraphData'], + _buildGraphDataFromAnalysis: vi.fn(() => graphData) as unknown as RefreshSourceFacade['_buildGraphDataFromAnalysis'], + _lastDiscoveredDirectories: ['src'], + _lastDiscoveredFiles: [{ absolutePath: '/workspace/src/a.ts', extension: '.ts', name: 'a.ts', relativePath: 'src/a.ts' }], + _lastFileAnalysis: new Map(), + _lastFileConnections: new Map(), + _lastGraphData: graphData, + _lastWorkspaceRoot: '/workspace', + _patchGraphDataNodeMetrics: vi.fn(() => graphData) as unknown as RefreshSourceFacade['_patchGraphDataNodeMetrics'], + _preAnalyzePlugins: vi.fn(async () => undefined) as unknown as RefreshSourceFacade['_preAnalyzePlugins'], + _readAnalysisFiles: vi.fn(async () => []) as unknown as RefreshSourceFacade['_readAnalysisFiles'], + analyze: vi.fn(async () => graphData) as unknown as RefreshSourceFacade['analyze'], + invalidateWorkspaceFiles: vi.fn() as unknown as RefreshSourceFacade['invalidateWorkspaceFiles'], + }; +} + +describe('extension/pipeline/service/refresh/source', () => { + it('delegates refresh source methods with default and override plugin disable sets', async () => { + const facade = createRefreshFacade(); + const defaultDisabledPlugins = new Set(['plugin.default']); + const overrideDisabledPlugins = new Set(['plugin.override']); + const source = createWorkspaceIndexRefreshSource(facade, defaultDisabledPlugins); + const files = [{ absolutePath: '/workspace/src/a.ts', relativePath: 'src/a.ts' }]; + const progress = vi.fn(); + const abortSignal = new AbortController().signal; + const pluginIds = ['plugin.a']; + const fileConnections = new Map([['src/a.ts', []]]); + const fileAnalysis = new Map([['src/a.ts', { filePath: '/workspace/src/a.ts', relations: [] }]]); + const graphData = createGraph('input'); + const patterns = ['src/**']; + + await source._analyzeFiles( + files as never, + '/workspace', + progress, + abortSignal, + pluginIds, + ); + expect(facade._analyzeFiles).toHaveBeenCalledWith( + files, + '/workspace', + progress, + abortSignal, + pluginIds, + defaultDisabledPlugins, + ); + + await source._analyzeFiles( + files as never, + '/workspace', + progress, + abortSignal, + pluginIds, + overrideDisabledPlugins, + ); + expect(facade._analyzeFiles).toHaveBeenLastCalledWith( + files, + '/workspace', + progress, + abortSignal, + pluginIds, + overrideDisabledPlugins, + ); + + source._buildGraphData(fileConnections as never, '/workspace', overrideDisabledPlugins); + expect(facade._buildGraphData).toHaveBeenCalledWith( + fileConnections, + '/workspace', + true, + overrideDisabledPlugins, + ); + + source._buildGraphDataFromAnalysis(fileAnalysis as never, '/workspace', overrideDisabledPlugins); + expect(facade._buildGraphDataFromAnalysis).toHaveBeenCalledWith( + fileAnalysis, + '/workspace', + true, + overrideDisabledPlugins, + ); + + expect(source._patchGraphDataNodeMetrics).toBeDefined(); + source._patchGraphDataNodeMetrics?.(graphData, ['src/a.ts']); + expect(facade._patchGraphDataNodeMetrics).toHaveBeenCalledWith(graphData, ['src/a.ts']); + + await source._preAnalyzePlugins(files as never, '/workspace', abortSignal); + expect(facade._preAnalyzePlugins).toHaveBeenCalledWith( + files, + '/workspace', + abortSignal, + defaultDisabledPlugins, + ); + + await source._preAnalyzePlugins( + files as never, + '/workspace', + abortSignal, + overrideDisabledPlugins, + ); + expect(facade._preAnalyzePlugins).toHaveBeenLastCalledWith( + files, + '/workspace', + abortSignal, + overrideDisabledPlugins, + ); + + await source._readAnalysisFiles(files as never); + expect(facade._readAnalysisFiles).toHaveBeenCalledWith(files); + + await source.analyze(patterns, overrideDisabledPlugins, abortSignal, progress); + expect(facade.analyze).toHaveBeenCalledWith( + patterns, + overrideDisabledPlugins, + abortSignal, + progress, + ); + + source.invalidateWorkspaceFiles(['src/a.ts']); + expect(facade.invalidateWorkspaceFiles).toHaveBeenCalledWith(['src/a.ts']); + }); + + it('mirrors refresh source retained state through live accessors', () => { + const facade = createRefreshFacade(); + const source = createWorkspaceIndexRefreshSource(facade); + const nextDirectories = ['src', 'test']; + const nextFiles = [{ absolutePath: '/workspace/src/b.ts', extension: '.ts', name: 'b.ts', relativePath: 'src/b.ts' }]; + const nextFileAnalysis = new Map([['src/b.ts', { filePath: '/workspace/src/b.ts', relations: [] }]]); + const nextFileConnections = new Map([['src/b.ts', []]]); + const nextGraphData = createGraph('next'); + + expect(source._lastDiscoveredDirectories).toBe(facade._lastDiscoveredDirectories); + source._lastDiscoveredDirectories = nextDirectories as never; + expect(facade._lastDiscoveredDirectories).toBe(nextDirectories); + + expect(source._lastDiscoveredFiles).toBe(facade._lastDiscoveredFiles); + source._lastDiscoveredFiles = nextFiles as never; + expect(facade._lastDiscoveredFiles).toBe(nextFiles); + + expect(source._lastFileAnalysis).toBe(facade._lastFileAnalysis); + source._lastFileAnalysis = nextFileAnalysis as never; + expect(facade._lastFileAnalysis).toBe(nextFileAnalysis); + + expect(source._lastFileConnections).toBe(facade._lastFileConnections); + source._lastFileConnections = nextFileConnections as never; + expect(facade._lastFileConnections).toBe(nextFileConnections); + + expect(source._lastGraphData).toBe(facade._lastGraphData); + source._lastGraphData = nextGraphData; + expect(facade._lastGraphData).toBe(nextGraphData); + + expect(source._lastWorkspaceRoot).toBe('/workspace'); + source._lastWorkspaceRoot = '/next-workspace'; + expect(facade._lastWorkspaceRoot).toBe('/next-workspace'); + }); +}); From 765b7935ff467b46c9b82630736fddf912d9b959 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 20:07:24 -0700 Subject: [PATCH 158/192] test: kill refresh scope mutants --- .../pipeline/service/refresh/scope.test.ts | 195 ++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 packages/extension/tests/extension/pipeline/service/refresh/scope.test.ts diff --git a/packages/extension/tests/extension/pipeline/service/refresh/scope.test.ts b/packages/extension/tests/extension/pipeline/service/refresh/scope.test.ts new file mode 100644 index 000000000..90935c84d --- /dev/null +++ b/packages/extension/tests/extension/pipeline/service/refresh/scope.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { hasRequiredAnalysisCacheTiers } from '@codegraphy-dev/core'; +import { + canReuseCurrentAnalysisForScope, + rebuildAnalysisScopeFromCurrentAnalysis, + type AnalysisScopeRefreshFacade, +} from '../../../../../src/extension/pipeline/service/refresh/scope'; +import { createWorkspacePipelineAnalysisCacheTiers } from '../../../../../src/extension/pipeline/service/cache/tiers'; +import type { IGraphData } from '../../../../../src/shared/graph/contracts'; + +vi.mock('@codegraphy-dev/core', async (importOriginal) => ({ + ...(await importOriginal()), + hasRequiredAnalysisCacheTiers: vi.fn(), +})); + +vi.mock('../../../../../src/extension/pipeline/service/cache/tiers', () => ({ + createWorkspacePipelineAnalysisCacheTiers: vi.fn(), +})); + +function createGraph(id = 'graph'): IGraphData { + return { + nodes: [{ id, label: id, color: '#67E8F9', nodeType: 'file' }], + edges: [], + }; +} + +function createFile(relativePath: string) { + return { + absolutePath: `/workspace/${relativePath}`, + extension: '.ts', + name: relativePath.split('/').at(-1) ?? relativePath, + relativePath, + }; +} + +describe('extension/pipeline/service/refresh/scope', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(createWorkspacePipelineAnalysisCacheTiers).mockReturnValue({ + active: ['baseline'], + completed: ['baseline'], + required: ['baseline', 'plugin:plugin.a'], + }); + }); + + it('does not reuse current analysis when no files are discovered', () => { + expect( + canReuseCurrentAnalysisForScope({ + activePluginIds: ['plugin.a'], + discoveredFiles: [], + disabledPlugins: new Set(), + lastFileAnalysis: new Map(), + nodeVisibility: { file: true }, + }), + ).toBe(false); + expect(createWorkspacePipelineAnalysisCacheTiers).not.toHaveBeenCalled(); + }); + + it('reuses current analysis only when every discovered file has required tiers', () => { + const firstAnalysis = { filePath: '/workspace/src/a.ts' }; + const secondAnalysis = { filePath: '/workspace/src/b.ts' }; + const lastFileAnalysis = new Map([ + ['src/a.ts', firstAnalysis], + ['src/b.ts', secondAnalysis], + ]); + vi.mocked(hasRequiredAnalysisCacheTiers) + .mockReturnValueOnce(true) + .mockReturnValueOnce(false); + + expect( + canReuseCurrentAnalysisForScope({ + activePluginIds: ['plugin.a'], + discoveredFiles: [createFile('src/a.ts'), createFile('src/b.ts')], + disabledPlugins: new Set(['plugin.disabled']), + lastFileAnalysis: lastFileAnalysis as never, + nodeVisibility: { file: true, symbol: false }, + }), + ).toBe(false); + + expect(createWorkspacePipelineAnalysisCacheTiers).toHaveBeenCalledWith( + { file: true, symbol: false }, + ['plugin.a'], + ); + expect(hasRequiredAnalysisCacheTiers).toHaveBeenNthCalledWith( + 1, + firstAnalysis, + ['baseline', 'plugin:plugin.a'], + ); + expect(hasRequiredAnalysisCacheTiers).toHaveBeenNthCalledWith( + 2, + secondAnalysis, + ['baseline', 'plugin:plugin.a'], + ); + + vi.mocked(hasRequiredAnalysisCacheTiers).mockReset(); + vi.mocked(hasRequiredAnalysisCacheTiers).mockReturnValue(true); + + expect( + canReuseCurrentAnalysisForScope({ + activePluginIds: ['plugin.a'], + discoveredFiles: [createFile('src/a.ts'), createFile('src/b.ts')], + disabledPlugins: new Set(), + lastFileAnalysis: lastFileAnalysis as never, + nodeVisibility: { file: true, symbol: false }, + }), + ).toBe(true); + }); + + it('does not reuse current analysis when a discovered file has no analysis', () => { + vi.mocked(hasRequiredAnalysisCacheTiers).mockReturnValue(true); + + expect( + canReuseCurrentAnalysisForScope({ + activePluginIds: [], + discoveredFiles: [createFile('src/missing.ts')], + disabledPlugins: new Set(), + lastFileAnalysis: new Map() as never, + nodeVisibility: {}, + }), + ).toBe(false); + expect(hasRequiredAnalysisCacheTiers).not.toHaveBeenCalled(); + }); + + it('rebuilds graph data from current analysis and updates retained scope state', async () => { + const graphData = createGraph('rebuilt'); + const fileAnalysis = new Map([['src/a.ts', { filePath: '/workspace/src/a.ts' }]]); + const facade: AnalysisScopeRefreshFacade = { + _buildGraphDataFromAnalysis: vi.fn(() => graphData) as never, + _lastDiscoveredDirectories: [], + _lastDiscoveredFiles: [], + _lastFileAnalysis: fileAnalysis as never, + _lastWorkspaceRoot: '', + _persistIndexMetadata: vi.fn(async () => undefined), + }; + const discoveredDirectories = ['src', 'src/nested']; + const discoveredFiles = [createFile('src/a.ts')]; + const disabledPlugins = new Set(['plugin.disabled']); + const onProgress = vi.fn(); + + await expect( + rebuildAnalysisScopeFromCurrentAnalysis(facade, { + discoveredDirectories, + discoveredFiles, + disabledPlugins, + onProgress, + showOrphans: false, + workspaceRoot: '/workspace', + }), + ).resolves.toBe(graphData); + + expect(onProgress).toHaveBeenNthCalledWith(1, { + phase: 'Applying Scope', + current: 0, + total: 1, + }); + expect(onProgress).toHaveBeenNthCalledWith(2, { + phase: 'Applying Scope', + current: 1, + total: 1, + }); + expect(facade._lastDiscoveredDirectories).toEqual(discoveredDirectories); + expect(facade._lastDiscoveredDirectories).not.toBe(discoveredDirectories); + expect(facade._lastDiscoveredFiles).toBe(discoveredFiles); + expect(facade._lastWorkspaceRoot).toBe('/workspace'); + expect(facade._buildGraphDataFromAnalysis).toHaveBeenCalledWith( + fileAnalysis, + '/workspace', + false, + disabledPlugins, + ); + expect(facade._persistIndexMetadata).toHaveBeenCalledOnce(); + }); + + it('rebuilds analysis scope without progress callbacks', async () => { + const graphData = createGraph('no-progress'); + const facade: AnalysisScopeRefreshFacade = { + _buildGraphDataFromAnalysis: vi.fn(() => graphData) as never, + _lastDiscoveredDirectories: [], + _lastDiscoveredFiles: [], + _lastFileAnalysis: new Map() as never, + _lastWorkspaceRoot: '', + _persistIndexMetadata: vi.fn(async () => undefined), + }; + + await expect( + rebuildAnalysisScopeFromCurrentAnalysis(facade, { + discoveredDirectories: [], + discoveredFiles: [], + disabledPlugins: new Set(), + showOrphans: true, + workspaceRoot: '/workspace', + }), + ).resolves.toBe(graphData); + }); +}); From 17965783e6587392dbb8bcd0bb61a46759046bd8 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 20:11:58 -0700 Subject: [PATCH 159/192] test: kill changed discovery mutants --- .../service/refresh/discovery/changed.test.ts | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 packages/extension/tests/extension/pipeline/service/refresh/discovery/changed.test.ts diff --git a/packages/extension/tests/extension/pipeline/service/refresh/discovery/changed.test.ts b/packages/extension/tests/extension/pipeline/service/refresh/discovery/changed.test.ts new file mode 100644 index 000000000..acb05e0bb --- /dev/null +++ b/packages/extension/tests/extension/pipeline/service/refresh/discovery/changed.test.ts @@ -0,0 +1,119 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import fs from 'node:fs'; +import { + getReusableChangedFileDiscoveryState, + type ChangedFileDiscoveryState, +} from '../../../../../../src/extension/pipeline/service/refresh/discovery/changed'; + +const fsMock = vi.hoisted(() => ({ + existsSync: vi.fn(), +})); + +vi.mock('node:fs', () => ({ + default: fsMock, + existsSync: fsMock.existsSync, +})); + +function createFile(relativePath: string) { + return { + absolutePath: `/workspace/${relativePath.replace(/\\/g, '/')}`, + extension: '.ts', + name: relativePath.split(/[\\/]/).at(-1) ?? relativePath, + relativePath, + }; +} + +function createInput( + overrides: Partial[0]> = {}, +): Parameters[0] { + return { + filePaths: ['src/a.ts'], + lastDiscoveredDirectories: ['src'], + lastDiscoveredFiles: [createFile('src/a.ts')], + lastWorkspaceRoot: '/workspace', + toWorkspaceRelativePath: vi.fn((_workspaceRoot, filePath) => + filePath.replace(/^\/workspace\//, '').replace(/\\/g, '/'), + ), + workspaceRoot: '/workspace', + ...overrides, + }; +} + +describe('extension/pipeline/service/refresh/discovery/changed', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(fs.existsSync).mockReturnValue(true); + }); + + it.each([ + ['without changed file paths', { filePaths: [] }], + ['after the workspace root changes', { lastWorkspaceRoot: '/other-workspace' }], + ])('does not reuse discovery state %s', (_label, overrides) => { + expect(getReusableChangedFileDiscoveryState(createInput(overrides))).toBeUndefined(); + expect(fs.existsSync).not.toHaveBeenCalled(); + }); + + it('does not resolve changed paths without previous discovered files', () => { + const input = createInput({ lastDiscoveredFiles: [] }); + + expect(getReusableChangedFileDiscoveryState(input)).toBeUndefined(); + expect(input.toWorkspaceRelativePath).not.toHaveBeenCalled(); + expect(fs.existsSync).not.toHaveBeenCalled(); + }); + + it('reuses previous discovery when every changed path is still discovered and exists', () => { + const directories = ['src', 'src/nested']; + const files = [ + createFile('src\\a.ts'), + createFile('src/nested/b.ts'), + ]; + const input = createInput({ + filePaths: ['src/a.ts', '/workspace/src/nested/b.ts'], + lastDiscoveredDirectories: directories, + lastDiscoveredFiles: files, + }); + + const result = getReusableChangedFileDiscoveryState(input) as ChangedFileDiscoveryState; + + expect(result.files).toBe(files); + expect(result.directories).toEqual(directories); + expect(result.directories).not.toBe(directories); + expect(input.toWorkspaceRelativePath).toHaveBeenNthCalledWith( + 1, + '/workspace', + 'src/a.ts', + ); + expect(input.toWorkspaceRelativePath).toHaveBeenNthCalledWith( + 2, + '/workspace', + '/workspace/src/nested/b.ts', + ); + expect(fs.existsSync).toHaveBeenNthCalledWith(1, '/workspace/src/a.ts'); + expect(fs.existsSync).toHaveBeenNthCalledWith(2, '/workspace/src/nested/b.ts'); + }); + + it('does not reuse discovery when a changed path cannot become workspace-relative', () => { + expect( + getReusableChangedFileDiscoveryState(createInput({ + toWorkspaceRelativePath: vi.fn(() => undefined), + })), + ).toBeUndefined(); + expect(fs.existsSync).not.toHaveBeenCalled(); + }); + + it('does not reuse discovery when a changed file was not previously discovered', () => { + expect( + getReusableChangedFileDiscoveryState(createInput({ + filePaths: ['src/missing.ts'], + })), + ).toBeUndefined(); + expect(fs.existsSync).not.toHaveBeenCalled(); + }); + + it('does not reuse discovery when a changed file no longer exists on disk', () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + + expect(getReusableChangedFileDiscoveryState(createInput())).toBeUndefined(); + expect(fs.existsSync).toHaveBeenCalledWith('/workspace/src/a.ts'); + }); +}); From 8aebb9ca418ea43f701435ceb049bbce6113659f Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 20:25:20 -0700 Subject: [PATCH 160/192] test: kill workspace discovery mutants --- .../refresh/discovery/workspace.test.ts | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 packages/extension/tests/extension/pipeline/service/refresh/discovery/workspace.test.ts diff --git a/packages/extension/tests/extension/pipeline/service/refresh/discovery/workspace.test.ts b/packages/extension/tests/extension/pipeline/service/refresh/discovery/workspace.test.ts new file mode 100644 index 000000000..2f1cb09e9 --- /dev/null +++ b/packages/extension/tests/extension/pipeline/service/refresh/discovery/workspace.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as vscode from 'vscode'; +import type { ICodeGraphyConfig } from '../../../../../../src/extension/config/defaults'; +import { + discoverRefreshWorkspaceFiles, +} from '../../../../../../src/extension/pipeline/service/refresh/discovery/workspace'; +import { + createWorkspacePipelineDiscoveryDependencies, + discoverWorkspacePipelineFilesWithWarnings, +} from '../../../../../../src/extension/pipeline/service/runtime/discovery'; + +const vscodeMock = vi.hoisted(() => ({ + showWarningMessage: vi.fn(), +})); + +vi.mock('vscode', () => ({ + window: { + showWarningMessage: vscodeMock.showWarningMessage, + }, +})); + +vi.mock('../../../../../../src/extension/pipeline/service/runtime/discovery', () => ({ + createWorkspacePipelineDiscoveryDependencies: vi.fn(), + discoverWorkspacePipelineFilesWithWarnings: vi.fn(), +})); + +describe('extension/pipeline/service/refresh/discovery/workspace', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(createWorkspacePipelineDiscoveryDependencies).mockReturnValue('discovery-deps' as never); + }); + + it('discovers workspace files with enabled filter patterns and relays warnings', async () => { + const config = { + disabledCustomFilterPatterns: ['dist/**'], + disabledPluginFilterPatterns: ['plugin.disabled/**'], + maxFiles: 500, + } as ICodeGraphyConfig; + const discoveryResult = { + directories: ['src'], + durationMs: 10, + files: [{ absolutePath: '/workspace/src/a.ts', relativePath: 'src/a.ts' }], + gitIgnoredPaths: new Set(), + limitReached: true, + totalFound: 1, + }; + vi.mocked(discoverWorkspacePipelineFilesWithWarnings).mockResolvedValue(discoveryResult as never); + const disabledPlugins = new Set(['plugin.disabled']); + const discovery = { discover: vi.fn() }; + const signal = new AbortController().signal; + const getPluginFilterPatterns = vi.fn(() => [ + 'plugin.enabled/**', + 'plugin.disabled/**', + ]); + + const result = await discoverRefreshWorkspaceFiles({ + configReader: { getAll: vi.fn(() => config) }, + disabledPlugins, + discovery, + filterPatterns: ['src/**', 'dist/**', 'tests/**'], + getPluginFilterPatterns, + signal, + workspaceRoot: '/workspace', + }); + + expect(createWorkspacePipelineDiscoveryDependencies).toHaveBeenCalledWith(discovery); + expect(getPluginFilterPatterns).toHaveBeenCalledWith(disabledPlugins); + expect(discoverWorkspacePipelineFilesWithWarnings).toHaveBeenCalledWith( + 'discovery-deps', + '/workspace', + config, + ['src/**', 'tests/**'], + ['plugin.enabled/**'], + signal, + expect.any(Function), + ); + + const warningCallback = vi.mocked(discoverWorkspacePipelineFilesWithWarnings).mock.calls[0][6]; + warningCallback('workspace discovery warning'); + expect(vscode.window.showWarningMessage).toHaveBeenCalledWith('workspace discovery warning'); + expect(result).toEqual({ config, discoveryResult }); + }); +}); From 058b3b154d25a7786b99133a5eb7e2b919453282 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 20:30:20 -0700 Subject: [PATCH 161/192] test: kill analysis scope mode mutants --- .../refresh/modes/analysisScope.test.ts | 244 ++++++++++++++++++ 1 file changed, 244 insertions(+) create mode 100644 packages/extension/tests/extension/pipeline/service/refresh/modes/analysisScope.test.ts diff --git a/packages/extension/tests/extension/pipeline/service/refresh/modes/analysisScope.test.ts b/packages/extension/tests/extension/pipeline/service/refresh/modes/analysisScope.test.ts new file mode 100644 index 000000000..13770b0f8 --- /dev/null +++ b/packages/extension/tests/extension/pipeline/service/refresh/modes/analysisScope.test.ts @@ -0,0 +1,244 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { IGraphData } from '../../../../../../src/shared/graph/contracts'; +import { + refreshAnalysisScopeForFacade, +} from '../../../../../../src/extension/pipeline/service/refresh/modes/analysisScope'; +import type { RefreshFacadeContext } from '../../../../../../src/extension/pipeline/service/refresh/context'; +import { refreshWorkspacePipelineAnalysisScope } from '../../../../../../src/extension/pipeline/service/runtime/refresh'; +import { + canReuseCurrentAnalysisForScope, + rebuildAnalysisScopeFromCurrentAnalysis, +} from '../../../../../../src/extension/pipeline/service/refresh/scope'; +import { createWorkspaceIndexRefreshSource } from '../../../../../../src/extension/pipeline/service/refresh/source'; +import { discoverRefreshWorkspaceFiles } from '../../../../../../src/extension/pipeline/service/refresh/discovery/workspace'; + +vi.mock('../../../../../../src/extension/pipeline/service/runtime/refresh', () => ({ + refreshWorkspacePipelineAnalysisScope: vi.fn(), +})); + +vi.mock('../../../../../../src/extension/pipeline/service/refresh/scope', () => ({ + canReuseCurrentAnalysisForScope: vi.fn(), + rebuildAnalysisScopeFromCurrentAnalysis: vi.fn(), +})); + +vi.mock('../../../../../../src/extension/pipeline/service/refresh/source', () => ({ + createWorkspaceIndexRefreshSource: vi.fn(), +})); + +vi.mock('../../../../../../src/extension/pipeline/service/refresh/discovery/workspace', () => ({ + discoverRefreshWorkspaceFiles: vi.fn(), +})); + +function createGraph(id: string): IGraphData { + return { + nodes: [{ id, label: id, color: '#67E8F9', nodeType: 'file' }], + edges: [], + }; +} + +function createFile(relativePath = 'src/a.ts') { + return { + absolutePath: `/workspace/${relativePath}`, + extension: '.ts', + name: relativePath.split('/').at(-1) ?? relativePath, + relativePath, + }; +} + +function createFacade( + overrides: Partial = {}, +): RefreshFacadeContext { + return { + _analyzeFiles: vi.fn(), + _buildGraphData: vi.fn(), + _buildGraphDataFromAnalysis: vi.fn(), + _config: { + get: vi.fn(() => ({ file: true })), + getAll: vi.fn(), + }, + _discovery: { discover: vi.fn() }, + _getActiveAnalysisPluginIds: vi.fn(() => ['plugin.a']), + _getWorkspaceRoot: vi.fn(() => '/workspace'), + _lastDiscoveredDirectories: [], + _lastDiscoveredFiles: [], + _lastFileAnalysis: new Map([['src/a.ts', { filePath: '/workspace/src/a.ts' }]]), + _lastFileConnections: new Map(), + _lastGitIgnoredPaths: ['old-ignore'], + _lastGraphData: createGraph('last'), + _lastWorkspaceRoot: '/workspace', + _patchGraphDataNodeMetrics: vi.fn(), + _persistCache: vi.fn(), + _persistIndexMetadata: vi.fn(async () => undefined), + _preAnalyzePlugins: vi.fn(), + _readAnalysisFiles: vi.fn(), + _registry: { + list: vi.fn(() => []), + notifyFilesChanged: vi.fn(), + }, + _toWorkspaceRelativePath: vi.fn(), + analyze: vi.fn(), + getPluginFilterPatterns: vi.fn(() => ['plugin/**']), + invalidateWorkspaceFiles: vi.fn(), + ...overrides, + } as unknown as RefreshFacadeContext; +} + +function mockDiscoveryResult( + overrides: Partial>['discoveryResult']> = {}, + config: Record = { showOrphans: false }, +) { + const files = [createFile()]; + vi.mocked(discoverRefreshWorkspaceFiles).mockResolvedValue({ + config, + discoveryResult: { + directories: ['src'], + files, + gitIgnoredPaths: ['ignored.ts'], + ...overrides, + }, + } as never); + return files; +} + +describe('extension/pipeline/service/refresh/modes/analysisScope', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(createWorkspaceIndexRefreshSource).mockReturnValue('refresh-source' as never); + }); + + it('returns an empty graph without workspace discovery when no workspace is open', async () => { + await expect( + refreshAnalysisScopeForFacade(createFacade({ + _getWorkspaceRoot: vi.fn(() => undefined), + }), { + disabledPlugins: new Set(), + filterPatterns: ['src/**'], + }), + ).resolves.toEqual({ nodes: [], edges: [] }); + + expect(discoverRefreshWorkspaceFiles).not.toHaveBeenCalled(); + expect(refreshWorkspacePipelineAnalysisScope).not.toHaveBeenCalled(); + }); + + it('rebuilds the graph from reusable analysis for analysis scope changes', async () => { + const graph = createGraph('rebuilt'); + const files = mockDiscoveryResult(); + const facade = createFacade(); + const disabledPlugins = new Set(['plugin.disabled']); + const signal = new AbortController().signal; + const onProgress = vi.fn(); + vi.mocked(canReuseCurrentAnalysisForScope).mockReturnValue(true); + vi.mocked(rebuildAnalysisScopeFromCurrentAnalysis).mockResolvedValue(graph); + + await expect( + refreshAnalysisScopeForFacade(facade, { + disabledPlugins, + filterPatterns: ['src/**'], + onProgress, + signal, + }), + ).resolves.toBe(graph); + + expect(discoverRefreshWorkspaceFiles).toHaveBeenCalledWith({ + configReader: facade._config, + disabledPlugins, + discovery: facade._discovery, + filterPatterns: ['src/**'], + getPluginFilterPatterns: expect.any(Function), + signal, + workspaceRoot: '/workspace', + }); + const getPluginFilterPatterns = vi.mocked(discoverRefreshWorkspaceFiles).mock.calls[0][0].getPluginFilterPatterns; + expect(getPluginFilterPatterns(disabledPlugins)).toEqual(['plugin/**']); + expect(facade.getPluginFilterPatterns).toHaveBeenCalledWith(disabledPlugins); + expect(facade._lastGitIgnoredPaths).toEqual(['ignored.ts']); + expect(facade._getActiveAnalysisPluginIds).toHaveBeenCalledWith(undefined, disabledPlugins); + expect(facade._config.get).toHaveBeenCalledWith('nodeVisibility', {}); + expect(canReuseCurrentAnalysisForScope).toHaveBeenCalledWith({ + activePluginIds: ['plugin.a'], + disabledPlugins, + discoveredFiles: files, + lastFileAnalysis: facade._lastFileAnalysis, + nodeVisibility: { file: true }, + }); + expect(rebuildAnalysisScopeFromCurrentAnalysis).toHaveBeenCalledWith(facade, { + disabledPlugins, + discoveredDirectories: ['src'], + discoveredFiles: files, + onProgress, + showOrphans: false, + workspaceRoot: '/workspace', + }); + expect(refreshWorkspacePipelineAnalysisScope).not.toHaveBeenCalled(); + }); + + it('uses reusable analysis defaults when discovery omits optional scope fields', async () => { + const graph = createGraph('rebuilt-defaults'); + const files = mockDiscoveryResult({ + directories: undefined, + gitIgnoredPaths: undefined, + }, {}); + const facade = createFacade(); + const disabledPlugins = new Set(); + vi.mocked(canReuseCurrentAnalysisForScope).mockReturnValue(true); + vi.mocked(rebuildAnalysisScopeFromCurrentAnalysis).mockResolvedValue(graph); + + await expect( + refreshAnalysisScopeForFacade(facade, { + disabledPlugins, + filterPatterns: [], + }), + ).resolves.toBe(graph); + + expect(facade._lastGitIgnoredPaths).toEqual([]); + expect(facade._config.get).toHaveBeenCalledWith('nodeVisibility', {}); + expect(rebuildAnalysisScopeFromCurrentAnalysis).toHaveBeenCalledWith(facade, expect.objectContaining({ + discoveredDirectories: [], + discoveredFiles: files, + showOrphans: true, + })); + }); + + it('runs a full analysis-scope refresh when current analysis cannot be reused', async () => { + const graph = createGraph('refreshed'); + const files = mockDiscoveryResult({ + directories: undefined, + gitIgnoredPaths: undefined, + }); + const facade = createFacade(); + const disabledPlugins = new Set(); + const signal = new AbortController().signal; + const onProgress = vi.fn(); + vi.mocked(canReuseCurrentAnalysisForScope).mockReturnValue(false); + vi.mocked(refreshWorkspacePipelineAnalysisScope).mockResolvedValue(graph); + + await expect( + refreshAnalysisScopeForFacade(facade, { + disabledPlugins, + filterPatterns: ['src/**'], + onProgress, + signal, + }), + ).resolves.toBe(graph); + + expect(facade._lastGitIgnoredPaths).toEqual([]); + expect(createWorkspaceIndexRefreshSource).toHaveBeenCalledWith(facade, disabledPlugins); + expect(refreshWorkspacePipelineAnalysisScope).toHaveBeenCalledWith('refresh-source', { + disabledPlugins, + discoveredDirectories: [], + discoveredFiles: files, + onProgress, + persistCache: expect.any(Function), + persistIndexMetadata: expect.any(Function), + signal, + workspaceRoot: '/workspace', + }); + + const refreshOptions = vi.mocked(refreshWorkspacePipelineAnalysisScope).mock.calls[0][1]; + refreshOptions.persistCache(); + await refreshOptions.persistIndexMetadata(); + expect(facade._persistCache).toHaveBeenCalledOnce(); + expect(facade._persistIndexMetadata).toHaveBeenCalledOnce(); + expect(rebuildAnalysisScopeFromCurrentAnalysis).not.toHaveBeenCalled(); + }); +}); From 8ea7d5b0d0c2423c4a01b93296c8a48475cec2cb Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 20:36:00 -0700 Subject: [PATCH 162/192] test: kill changed files mode mutants --- .../refresh/modes/changedFiles.test.ts | 232 ++++++++++++++++++ 1 file changed, 232 insertions(+) create mode 100644 packages/extension/tests/extension/pipeline/service/refresh/modes/changedFiles.test.ts diff --git a/packages/extension/tests/extension/pipeline/service/refresh/modes/changedFiles.test.ts b/packages/extension/tests/extension/pipeline/service/refresh/modes/changedFiles.test.ts new file mode 100644 index 000000000..0a85567ed --- /dev/null +++ b/packages/extension/tests/extension/pipeline/service/refresh/modes/changedFiles.test.ts @@ -0,0 +1,232 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { IGraphData } from '../../../../../../src/shared/graph/contracts'; +import { + refreshChangedFilesForFacade, +} from '../../../../../../src/extension/pipeline/service/refresh/modes/changedFiles'; +import type { RefreshFacadeContext } from '../../../../../../src/extension/pipeline/service/refresh/context'; +import { refreshWorkspacePipelineChangedFiles } from '../../../../../../src/extension/pipeline/service/runtime/refresh'; +import { getReusableChangedFileDiscoveryState } from '../../../../../../src/extension/pipeline/service/refresh/discovery/changed'; +import { discoverRefreshWorkspaceFiles } from '../../../../../../src/extension/pipeline/service/refresh/discovery/workspace'; +import { createWorkspaceIndexRefreshSource } from '../../../../../../src/extension/pipeline/service/refresh/source'; + +vi.mock('../../../../../../src/extension/pipeline/service/runtime/refresh', () => ({ + refreshWorkspacePipelineChangedFiles: vi.fn(), +})); + +vi.mock('../../../../../../src/extension/pipeline/service/refresh/discovery/changed', () => ({ + getReusableChangedFileDiscoveryState: vi.fn(), +})); + +vi.mock('../../../../../../src/extension/pipeline/service/refresh/discovery/workspace', () => ({ + discoverRefreshWorkspaceFiles: vi.fn(), +})); + +vi.mock('../../../../../../src/extension/pipeline/service/refresh/source', () => ({ + createWorkspaceIndexRefreshSource: vi.fn(), +})); + +function createGraph(id: string): IGraphData { + return { + nodes: [{ id, label: id, color: '#67E8F9', nodeType: 'file' }], + edges: [], + }; +} + +function createFile(relativePath = 'src/a.ts') { + return { + absolutePath: `/workspace/${relativePath}`, + extension: '.ts', + name: relativePath.split('/').at(-1) ?? relativePath, + relativePath, + }; +} + +function createFacade( + overrides: Partial = {}, +): RefreshFacadeContext { + return { + _analyzeFiles: vi.fn(), + _buildGraphData: vi.fn(), + _buildGraphDataFromAnalysis: vi.fn(), + _config: { + get: vi.fn(), + getAll: vi.fn(), + }, + _discovery: { discover: vi.fn() }, + _getActiveAnalysisPluginIds: vi.fn(() => []), + _getWorkspaceRoot: vi.fn(() => '/workspace'), + _lastDiscoveredDirectories: ['src'], + _lastDiscoveredFiles: [createFile()], + _lastFileAnalysis: new Map(), + _lastFileConnections: new Map(), + _lastGitIgnoredPaths: ['old-ignore'], + _lastGraphData: createGraph('last'), + _lastWorkspaceRoot: '/workspace', + _patchGraphDataNodeMetrics: vi.fn(), + _persistCache: vi.fn(), + _persistIndexMetadata: vi.fn(async () => undefined), + _preAnalyzePlugins: vi.fn(), + _readAnalysisFiles: vi.fn(), + _registry: { + list: vi.fn(() => []), + notifyFilesChanged: vi.fn(), + }, + _toWorkspaceRelativePath: vi.fn((_root, filePath) => filePath.replace('/workspace/', '')), + analyze: vi.fn(), + getPluginFilterPatterns: vi.fn(() => ['plugin/**']), + invalidateWorkspaceFiles: vi.fn(), + ...overrides, + } as unknown as RefreshFacadeContext; +} + +describe('extension/pipeline/service/refresh/modes/changedFiles', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(createWorkspaceIndexRefreshSource).mockReturnValue('refresh-source' as never); + }); + + it('returns an empty graph without changed-file discovery when no workspace is open', async () => { + await expect( + refreshChangedFilesForFacade(createFacade({ + _getWorkspaceRoot: vi.fn(() => undefined), + }), { + disabledPlugins: new Set(), + filePaths: ['src/a.ts'], + filterPatterns: ['src/**'], + }), + ).resolves.toEqual({ nodes: [], edges: [] }); + + expect(getReusableChangedFileDiscoveryState).not.toHaveBeenCalled(); + expect(refreshWorkspacePipelineChangedFiles).not.toHaveBeenCalled(); + }); + + it('refreshes changed files from reusable discovery state', async () => { + const graph = createGraph('changed'); + const files = [createFile()]; + const directories = ['src', 'src/nested']; + const disabledPlugins = new Set(['plugin.disabled']); + const explicitDisabledPlugins = new Set(['plugin.next']); + const signal = new AbortController().signal; + const onProgress = vi.fn(); + const facade = createFacade(); + vi.mocked(getReusableChangedFileDiscoveryState).mockReturnValue({ directories, files }); + vi.mocked(refreshWorkspacePipelineChangedFiles).mockResolvedValue(graph); + + await expect( + refreshChangedFilesForFacade(facade, { + disabledPlugins, + filePaths: ['src/a.ts'], + filterPatterns: ['src/**'], + onProgress, + signal, + }), + ).resolves.toBe(graph); + + expect(getReusableChangedFileDiscoveryState).toHaveBeenCalledWith({ + filePaths: ['src/a.ts'], + lastDiscoveredDirectories: facade._lastDiscoveredDirectories, + lastDiscoveredFiles: facade._lastDiscoveredFiles, + lastWorkspaceRoot: facade._lastWorkspaceRoot, + toWorkspaceRelativePath: expect.any(Function), + workspaceRoot: '/workspace', + }); + const toWorkspaceRelativePath = vi.mocked(getReusableChangedFileDiscoveryState).mock.calls[0][0].toWorkspaceRelativePath; + expect(toWorkspaceRelativePath('/workspace', '/workspace/src/a.ts')).toBe('src/a.ts'); + expect(facade._toWorkspaceRelativePath).toHaveBeenCalledWith('/workspace', '/workspace/src/a.ts'); + expect(discoverRefreshWorkspaceFiles).not.toHaveBeenCalled(); + expect(createWorkspaceIndexRefreshSource).toHaveBeenCalledWith(facade, disabledPlugins); + expect(refreshWorkspacePipelineChangedFiles).toHaveBeenCalledWith('refresh-source', { + deferMetricOnlyIndexMetadata: true, + disabledPlugins, + discoveredDirectories: directories, + discoveredFiles: files, + filePaths: ['src/a.ts'], + filterPatterns: ['src/**'], + notifyFilesChanged: expect.any(Function), + onDeferredIndexMetadataError: expect.any(Function), + onProgress, + persistCache: expect.any(Function), + persistIndexMetadata: expect.any(Function), + signal, + workspaceRoot: '/workspace', + }); + + const refreshOptions = vi.mocked(refreshWorkspacePipelineChangedFiles).mock.calls[0][1]; + refreshOptions.notifyFilesChanged(['src/a.ts'] as never, '/workspace', 'analysis-context' as never); + expect(facade._registry.notifyFilesChanged).toHaveBeenCalledWith( + ['src/a.ts'], + '/workspace', + 'analysis-context', + disabledPlugins, + ); + refreshOptions.notifyFilesChanged(['src/b.ts'] as never, '/workspace', 'next-context' as never, explicitDisabledPlugins); + expect(facade._registry.notifyFilesChanged).toHaveBeenLastCalledWith( + ['src/b.ts'], + '/workspace', + 'next-context', + explicitDisabledPlugins, + ); + + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const error = new Error('persist failed'); + expect(refreshOptions.onDeferredIndexMetadataError).toBeDefined(); + refreshOptions.onDeferredIndexMetadataError?.(error); + expect(warn).toHaveBeenCalledWith( + '[CodeGraphy] Failed to persist metric-only refresh metadata.', + error, + ); + warn.mockRestore(); + + refreshOptions.persistCache(); + await refreshOptions.persistIndexMetadata(); + expect(facade._persistCache).toHaveBeenCalledOnce(); + expect(facade._persistIndexMetadata).toHaveBeenCalledOnce(); + }); + + it('discovers workspace files when previous changed-file discovery cannot be reused', async () => { + const graph = createGraph('discovered'); + const files = [createFile('src/new.ts')]; + const disabledPlugins = new Set(); + const signal = new AbortController().signal; + const facade = createFacade(); + vi.mocked(getReusableChangedFileDiscoveryState).mockReturnValue(undefined); + vi.mocked(discoverRefreshWorkspaceFiles).mockResolvedValue({ + config: {}, + discoveryResult: { + directories: undefined, + files, + gitIgnoredPaths: undefined, + }, + } as never); + vi.mocked(refreshWorkspacePipelineChangedFiles).mockResolvedValue(graph); + + await expect( + refreshChangedFilesForFacade(facade, { + disabledPlugins, + filePaths: ['src/new.ts'], + filterPatterns: ['src/**'], + signal, + }), + ).resolves.toBe(graph); + + expect(discoverRefreshWorkspaceFiles).toHaveBeenCalledWith({ + configReader: facade._config, + disabledPlugins, + discovery: facade._discovery, + filterPatterns: ['src/**'], + getPluginFilterPatterns: expect.any(Function), + signal, + workspaceRoot: '/workspace', + }); + const getPluginFilterPatterns = vi.mocked(discoverRefreshWorkspaceFiles).mock.calls[0][0].getPluginFilterPatterns; + expect(getPluginFilterPatterns(disabledPlugins)).toEqual(['plugin/**']); + expect(facade.getPluginFilterPatterns).toHaveBeenCalledWith(disabledPlugins); + expect(facade._lastDiscoveredDirectories).toEqual([]); + expect(facade._lastGitIgnoredPaths).toEqual([]); + expect(refreshWorkspacePipelineChangedFiles).toHaveBeenCalledWith('refresh-source', expect.objectContaining({ + discoveredDirectories: [], + discoveredFiles: files, + workspaceRoot: '/workspace', + })); + }); +}); From fa81bc563447a4115f66bb2e140a673a9a80c969 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 20:39:07 -0700 Subject: [PATCH 163/192] test: kill gitignore metadata mode mutants --- .../refresh/modes/gitignoreMetadata.test.ts | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 packages/extension/tests/extension/pipeline/service/refresh/modes/gitignoreMetadata.test.ts diff --git a/packages/extension/tests/extension/pipeline/service/refresh/modes/gitignoreMetadata.test.ts b/packages/extension/tests/extension/pipeline/service/refresh/modes/gitignoreMetadata.test.ts new file mode 100644 index 000000000..e049c738c --- /dev/null +++ b/packages/extension/tests/extension/pipeline/service/refresh/modes/gitignoreMetadata.test.ts @@ -0,0 +1,145 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { IGraphData } from '../../../../../../src/shared/graph/contracts'; +import { + refreshGitignoreMetadataForFacade, +} from '../../../../../../src/extension/pipeline/service/refresh/modes/gitignoreMetadata'; +import type { RefreshFacadeContext } from '../../../../../../src/extension/pipeline/service/refresh/context'; +import { discoverRefreshWorkspaceFiles } from '../../../../../../src/extension/pipeline/service/refresh/discovery/workspace'; + +vi.mock('../../../../../../src/extension/pipeline/service/refresh/discovery/workspace', () => ({ + discoverRefreshWorkspaceFiles: vi.fn(), +})); + +function createGraph(id: string): IGraphData { + return { + nodes: [{ id, label: id, color: '#67E8F9', nodeType: 'file' }], + edges: [], + }; +} + +function createFile(relativePath = 'src/a.ts') { + return { + absolutePath: `/workspace/${relativePath}`, + extension: '.ts', + name: relativePath.split('/').at(-1) ?? relativePath, + relativePath, + }; +} + +function createFacade( + overrides: Partial = {}, +): RefreshFacadeContext { + return { + _analyzeFiles: vi.fn(), + _buildGraphData: vi.fn(), + _buildGraphDataFromAnalysis: vi.fn(() => createGraph('rebuilt')), + _config: { + get: vi.fn(), + getAll: vi.fn(), + }, + _discovery: { discover: vi.fn() }, + _getActiveAnalysisPluginIds: vi.fn(() => []), + _getWorkspaceRoot: vi.fn(() => '/workspace'), + _lastDiscoveredDirectories: ['old-src'], + _lastDiscoveredFiles: [createFile('old.ts')], + _lastFileAnalysis: new Map([['src/a.ts', { filePath: '/workspace/src/a.ts' }]]), + _lastFileConnections: new Map(), + _lastGitIgnoredPaths: ['old-ignore'], + _lastGraphData: createGraph('last'), + _lastWorkspaceRoot: '/old-workspace', + _patchGraphDataNodeMetrics: vi.fn(), + _persistCache: vi.fn(), + _persistIndexMetadata: vi.fn(async () => undefined), + _preAnalyzePlugins: vi.fn(), + _readAnalysisFiles: vi.fn(), + _registry: { + list: vi.fn(() => []), + notifyFilesChanged: vi.fn(), + }, + _toWorkspaceRelativePath: vi.fn(), + analyze: vi.fn(), + getPluginFilterPatterns: vi.fn(() => ['plugin/**']), + invalidateWorkspaceFiles: vi.fn(), + ...overrides, + } as unknown as RefreshFacadeContext; +} + +describe('extension/pipeline/service/refresh/modes/gitignoreMetadata', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns an empty graph without metadata discovery when no workspace is open', async () => { + await expect( + refreshGitignoreMetadataForFacade(createFacade({ + _getWorkspaceRoot: vi.fn(() => undefined), + }), { + disabledPlugins: new Set(), + filterPatterns: ['src/**'], + }), + ).resolves.toEqual({ nodes: [], edges: [] }); + + expect(discoverRefreshWorkspaceFiles).not.toHaveBeenCalled(); + }); + + it('updates retained gitignore discovery state and rebuilds graph data', async () => { + const graph = createGraph('gitignore'); + const files = [createFile('src/new.ts')]; + const disabledPlugins = new Set(['plugin.disabled']); + const signal = new AbortController().signal; + const persistError = new Error('metadata failed'); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const facade = createFacade({ + _buildGraphDataFromAnalysis: vi.fn(() => graph), + _persistIndexMetadata: vi.fn(async () => { + throw persistError; + }), + }); + vi.mocked(discoverRefreshWorkspaceFiles).mockResolvedValue({ + config: { showOrphans: false }, + discoveryResult: { + directories: undefined, + files, + gitIgnoredPaths: undefined, + }, + } as never); + + await expect( + refreshGitignoreMetadataForFacade(facade, { + disabledPlugins, + filterPatterns: ['src/**'], + signal, + }), + ).resolves.toBe(graph); + await Promise.resolve(); + + expect(discoverRefreshWorkspaceFiles).toHaveBeenCalledWith({ + configReader: facade._config, + disabledPlugins, + discovery: facade._discovery, + filterPatterns: ['src/**'], + getPluginFilterPatterns: expect.any(Function), + signal, + workspaceRoot: '/workspace', + }); + const getPluginFilterPatterns = vi.mocked(discoverRefreshWorkspaceFiles).mock.calls[0][0].getPluginFilterPatterns; + expect(getPluginFilterPatterns(disabledPlugins)).toEqual(['plugin/**']); + expect(facade.getPluginFilterPatterns).toHaveBeenCalledWith(disabledPlugins); + expect(facade._lastDiscoveredDirectories).toEqual([]); + expect(facade._lastDiscoveredFiles).toBe(files); + expect(facade._lastGitIgnoredPaths).toEqual([]); + expect(facade._lastWorkspaceRoot).toBe('/workspace'); + expect(facade._persistIndexMetadata).toHaveBeenCalledOnce(); + expect(warn).toHaveBeenCalledWith( + '[CodeGraphy] Failed to persist gitignore metadata refresh.', + persistError, + ); + expect(facade._buildGraphDataFromAnalysis).toHaveBeenCalledWith( + facade._lastFileAnalysis, + '/workspace', + false, + disabledPlugins, + ); + warn.mockRestore(); + }); +}); From 883a6de17922bfa56db73d720f6c0294b9b5b1c3 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 20:42:20 -0700 Subject: [PATCH 164/192] test: kill plugin files mode mutants --- .../service/refresh/modes/pluginFiles.test.ts | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 packages/extension/tests/extension/pipeline/service/refresh/modes/pluginFiles.test.ts diff --git a/packages/extension/tests/extension/pipeline/service/refresh/modes/pluginFiles.test.ts b/packages/extension/tests/extension/pipeline/service/refresh/modes/pluginFiles.test.ts new file mode 100644 index 000000000..9b81e7114 --- /dev/null +++ b/packages/extension/tests/extension/pipeline/service/refresh/modes/pluginFiles.test.ts @@ -0,0 +1,173 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { IGraphData } from '../../../../../../src/shared/graph/contracts'; +import { + refreshPluginFilesForFacade, +} from '../../../../../../src/extension/pipeline/service/refresh/modes/pluginFiles'; +import type { RefreshFacadeContext } from '../../../../../../src/extension/pipeline/service/refresh/context'; +import { refreshWorkspacePipelinePluginFiles } from '../../../../../../src/extension/pipeline/service/runtime/refresh'; +import { discoverRefreshWorkspaceFiles } from '../../../../../../src/extension/pipeline/service/refresh/discovery/workspace'; +import { createWorkspaceIndexRefreshSource } from '../../../../../../src/extension/pipeline/service/refresh/source'; + +vi.mock('../../../../../../src/extension/pipeline/service/runtime/refresh', () => ({ + refreshWorkspacePipelinePluginFiles: vi.fn(), +})); + +vi.mock('../../../../../../src/extension/pipeline/service/refresh/discovery/workspace', () => ({ + discoverRefreshWorkspaceFiles: vi.fn(), +})); + +vi.mock('../../../../../../src/extension/pipeline/service/refresh/source', () => ({ + createWorkspaceIndexRefreshSource: vi.fn(), +})); + +function createGraph(id: string): IGraphData { + return { + nodes: [{ id, label: id, color: '#67E8F9', nodeType: 'file' }], + edges: [], + }; +} + +function createFile(relativePath = 'src/a.ts') { + return { + absolutePath: `/workspace/${relativePath}`, + extension: '.ts', + name: relativePath.split('/').at(-1) ?? relativePath, + relativePath, + }; +} + +function createFacade( + overrides: Partial = {}, +): RefreshFacadeContext { + return { + _analyzeFiles: vi.fn(), + _buildGraphData: vi.fn(), + _buildGraphDataFromAnalysis: vi.fn(), + _config: { + get: vi.fn(), + getAll: vi.fn(), + }, + _discovery: { discover: vi.fn() }, + _getActiveAnalysisPluginIds: vi.fn(() => []), + _getWorkspaceRoot: vi.fn(() => '/workspace'), + _lastDiscoveredDirectories: [], + _lastDiscoveredFiles: [], + _lastFileAnalysis: new Map(), + _lastFileConnections: new Map(), + _lastGitIgnoredPaths: ['old-ignore'], + _lastGraphData: createGraph('last'), + _lastWorkspaceRoot: '/workspace', + _patchGraphDataNodeMetrics: vi.fn(), + _persistCache: vi.fn(), + _persistIndexMetadata: vi.fn(async () => undefined), + _preAnalyzePlugins: vi.fn(), + _readAnalysisFiles: vi.fn(), + _registry: { + list: vi.fn(() => [{ id: 'plugin.a', name: 'Plugin A' }]), + notifyFilesChanged: vi.fn(), + }, + _toWorkspaceRelativePath: vi.fn(), + analyze: vi.fn(), + getPluginFilterPatterns: vi.fn(() => ['plugin/**']), + invalidateWorkspaceFiles: vi.fn(), + ...overrides, + } as unknown as RefreshFacadeContext; +} + +describe('extension/pipeline/service/refresh/modes/pluginFiles', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(createWorkspaceIndexRefreshSource).mockReturnValue('refresh-source' as never); + }); + + it('returns an empty graph without plugin discovery when no workspace is open', async () => { + await expect( + refreshPluginFilesForFacade(createFacade({ + _getWorkspaceRoot: vi.fn(() => undefined), + }), { + disabledPlugins: new Set(), + filterPatterns: ['src/**'], + pluginIds: ['plugin.a'], + }), + ).resolves.toEqual({ nodes: [], edges: [] }); + + expect(discoverRefreshWorkspaceFiles).not.toHaveBeenCalled(); + expect(refreshWorkspacePipelinePluginFiles).not.toHaveBeenCalled(); + }); + + it('returns an empty graph without plugin discovery when no plugin ids are selected', async () => { + await expect( + refreshPluginFilesForFacade(createFacade(), { + disabledPlugins: new Set(), + filterPatterns: ['src/**'], + pluginIds: [], + }), + ).resolves.toEqual({ nodes: [], edges: [] }); + + expect(discoverRefreshWorkspaceFiles).not.toHaveBeenCalled(); + expect(refreshWorkspacePipelinePluginFiles).not.toHaveBeenCalled(); + }); + + it('refreshes selected plugin files with discovered workspace files', async () => { + const graph = createGraph('plugin-files'); + const files = [createFile('src/plugin.ts')]; + const disabledPlugins = new Set(['plugin.disabled']); + const pluginIds = ['plugin.a']; + const signal = new AbortController().signal; + const onProgress = vi.fn(); + const facade = createFacade(); + vi.mocked(discoverRefreshWorkspaceFiles).mockResolvedValue({ + config: {}, + discoveryResult: { + directories: undefined, + files, + gitIgnoredPaths: undefined, + }, + } as never); + vi.mocked(refreshWorkspacePipelinePluginFiles).mockResolvedValue(graph); + + await expect( + refreshPluginFilesForFacade(facade, { + disabledPlugins, + filterPatterns: ['src/**'], + onProgress, + pluginIds, + signal, + }), + ).resolves.toBe(graph); + + expect(discoverRefreshWorkspaceFiles).toHaveBeenCalledWith({ + configReader: facade._config, + disabledPlugins, + discovery: facade._discovery, + filterPatterns: ['src/**'], + getPluginFilterPatterns: expect.any(Function), + signal, + workspaceRoot: '/workspace', + }); + const getPluginFilterPatterns = vi.mocked(discoverRefreshWorkspaceFiles).mock.calls[0][0].getPluginFilterPatterns; + expect(getPluginFilterPatterns(disabledPlugins)).toEqual(['plugin/**']); + expect(facade.getPluginFilterPatterns).toHaveBeenCalledWith(disabledPlugins); + expect(facade._lastGitIgnoredPaths).toEqual([]); + expect(createWorkspaceIndexRefreshSource).toHaveBeenCalledWith(facade, disabledPlugins); + expect(facade._registry.list).toHaveBeenCalledOnce(); + expect(refreshWorkspacePipelinePluginFiles).toHaveBeenCalledWith('refresh-source', { + disabledPlugins, + discoveredDirectories: [], + discoveredFiles: files, + onProgress, + persistCache: expect.any(Function), + persistIndexMetadata: expect.any(Function), + pluginIds, + pluginInfos: [{ id: 'plugin.a', name: 'Plugin A' }], + signal, + workspaceRoot: '/workspace', + }); + + const refreshOptions = vi.mocked(refreshWorkspacePipelinePluginFiles).mock.calls[0][1]; + refreshOptions.persistCache(); + await refreshOptions.persistIndexMetadata(); + expect(facade._persistCache).toHaveBeenCalledOnce(); + expect(facade._persistIndexMetadata).toHaveBeenCalledOnce(); + }); +}); From 3759c36b1e8bff79e5478d63ce91a310c5ddc9f5 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 20:45:45 -0700 Subject: [PATCH 165/192] test: kill graph value equality mutants --- .../execution/publish/equality/values.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 packages/extension/tests/extension/graphView/analysis/execution/publish/equality/values.test.ts diff --git a/packages/extension/tests/extension/graphView/analysis/execution/publish/equality/values.test.ts b/packages/extension/tests/extension/graphView/analysis/execution/publish/equality/values.test.ts new file mode 100644 index 000000000..b7104f8d8 --- /dev/null +++ b/packages/extension/tests/extension/graphView/analysis/execution/publish/equality/values.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest'; +import { areGraphValuesEqual } from '../../../../../../../src/extension/graphView/analysis/execution/publish/equality/values'; + +describe('extension/graphView/analysis/execution/publish/equality/values', () => { + it('treats equal nested array graph values as equal', () => { + expect( + areGraphValuesEqual( + ['src/a.ts', { metrics: [12, 4] }], + ['src/a.ts', { metrics: [12, 4] }], + ), + ).toBe(true); + }); +}); From a1dd8f822ad611b5e7fa9e8a11ac3660db1fbe00 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 20:50:46 -0700 Subject: [PATCH 166/192] test: kill graph collection equality mutants --- .../publish/equality/collections.test.ts | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 packages/extension/tests/extension/graphView/analysis/execution/publish/equality/collections.test.ts diff --git a/packages/extension/tests/extension/graphView/analysis/execution/publish/equality/collections.test.ts b/packages/extension/tests/extension/graphView/analysis/execution/publish/equality/collections.test.ts new file mode 100644 index 000000000..8510bd52d --- /dev/null +++ b/packages/extension/tests/extension/graphView/analysis/execution/publish/equality/collections.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { + compareGraphArrayValues, + compareGraphRecordValues, +} from '../../../../../../../src/extension/graphView/analysis/execution/publish/equality/collections'; + +const comparePrimitiveValue = (left: unknown, right: unknown) => Object.is(left, right); + +describe('extension/graphView/analysis/execution/publish/equality/collections', () => { + it('compares arrays by length and every indexed value', () => { + expect(compareGraphArrayValues([1, 2], [1, 2], comparePrimitiveValue)).toBe(true); + expect(compareGraphArrayValues([1, 2], [1], comparePrimitiveValue)).toBe(false); + expect(compareGraphArrayValues([1], [1, 2], comparePrimitiveValue)).toBe(false); + expect(compareGraphArrayValues([1, 2], [1, 3], comparePrimitiveValue)).toBe(false); + }); + + it('distinguishes one-sided arrays from non-array pairs', () => { + expect(compareGraphArrayValues([1], { 0: 1 }, comparePrimitiveValue)).toBe(false); + expect(compareGraphArrayValues([1], '1', comparePrimitiveValue)).toBe(false); + expect(compareGraphArrayValues('1', [1], comparePrimitiveValue)).toBe(false); + expect(compareGraphArrayValues('left', 'right', comparePrimitiveValue)).toBeUndefined(); + }); + + it('compares records by every key from both records', () => { + expect(compareGraphRecordValues( + { id: 'src/a.ts', kind: 'file' }, + { kind: 'file', id: 'src/a.ts' }, + comparePrimitiveValue, + )).toBe(true); + expect(compareGraphRecordValues({ id: 'src/a.ts' }, { id: 'src/b.ts' }, comparePrimitiveValue)).toBe(false); + expect(compareGraphRecordValues({ id: 'src/a.ts' }, { id: 'src/a.ts', extra: true }, comparePrimitiveValue)).toBe(false); + }); + + it('rejects nulls and one-sided records', () => { + expect(compareGraphRecordValues(null, {}, comparePrimitiveValue)).toBe(false); + expect(compareGraphRecordValues({ id: 'src/a.ts' }, ['src/a.ts'], comparePrimitiveValue)).toBe(false); + }); +}); From b62f0b9a638cd9926efe089119e8ffa822f80174 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 20:56:52 -0700 Subject: [PATCH 167/192] test: kill graph node equality mutants --- .../execution/publish/equality/node.test.ts | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 packages/extension/tests/extension/graphView/analysis/execution/publish/equality/node.test.ts diff --git a/packages/extension/tests/extension/graphView/analysis/execution/publish/equality/node.test.ts b/packages/extension/tests/extension/graphView/analysis/execution/publish/equality/node.test.ts new file mode 100644 index 000000000..3357b536e --- /dev/null +++ b/packages/extension/tests/extension/graphView/analysis/execution/publish/equality/node.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import type { IGraphNode } from '../../../../../../../src/shared/graph/contracts'; +import { areNodesEqualIgnoringMetrics } from '../../../../../../../src/extension/graphView/analysis/execution/publish/equality/node'; + +function createNode(overrides: Partial = {}): IGraphNode { + return { + id: 'src/a.ts', + label: 'a.ts', + color: '#67E8F9', + nodeType: 'file', + ...overrides, + }; +} + +describe('extension/graphView/analysis/execution/publish/equality/node', () => { + it('treats the same node object as equal', () => { + const node = createNode(); + Object.defineProperty(node, 'label', { + enumerable: true, + get: () => { + throw new Error('same-node identity should not read node fields'); + }, + }); + + expect(areNodesEqualIgnoringMetrics(node, node)).toBe(true); + }); + + it('ignores file size and churn metric differences', () => { + expect( + areNodesEqualIgnoringMetrics( + createNode({ churn: 1, fileSize: 10 }), + createNode({ churn: 4, fileSize: 20 }), + ), + ).toBe(true); + }); + + it('rejects non-metric node differences', () => { + expect( + areNodesEqualIgnoringMetrics( + createNode({ label: 'a.ts' }), + createNode({ label: 'renamed.ts' }), + ), + ).toBe(false); + }); +}); From 479132c91bee35906e8f72091e0912c84a6a447e Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 21:03:43 -0700 Subject: [PATCH 168/192] test: kill graph payload equality mutants --- .../publish/equality/payload.test.ts | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 packages/extension/tests/extension/graphView/analysis/execution/publish/equality/payload.test.ts diff --git a/packages/extension/tests/extension/graphView/analysis/execution/publish/equality/payload.test.ts b/packages/extension/tests/extension/graphView/analysis/execution/publish/equality/payload.test.ts new file mode 100644 index 000000000..2f55e8bf7 --- /dev/null +++ b/packages/extension/tests/extension/graphView/analysis/execution/publish/equality/payload.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest'; +import type { IGraphData, IGraphEdge, IGraphNode } from '../../../../../../../src/shared/graph/contracts'; +import { areGraphDataPayloadsEqual } from '../../../../../../../src/extension/graphView/analysis/execution/publish/equality/payload'; + +function createNode(overrides: Partial = {}): IGraphNode { + return { + id: 'src/a.ts', + label: 'a.ts', + color: '#67E8F9', + nodeType: 'file', + ...overrides, + }; +} + +function createEdge(overrides: Partial = {}): IGraphEdge { + return { + id: 'src/a.ts->src/b.ts#import', + from: 'src/a.ts', + to: 'src/b.ts', + kind: 'import', + sources: [], + ...overrides, + }; +} + +function createGraph(overrides: Partial = {}): IGraphData { + return { + nodes: [createNode()], + edges: [createEdge()], + ...overrides, + }; +} + +function withStableJson(items: T, jsonValue: unknown): T { + Object.defineProperty(items, 'toJSON', { + value: () => jsonValue, + }); + return items; +} + +describe('extension/graphView/analysis/execution/publish/equality/payload', () => { + it('treats the same graph payload object as equal without reading fields', () => { + const graph = createGraph(); + Object.defineProperty(graph, 'nodes', { + enumerable: true, + get: () => { + throw new Error('same-payload identity should not read graph fields'); + }, + }); + + expect(areGraphDataPayloadsEqual(graph, graph)).toBe(true); + }); + + it('rejects payloads with different node or edge counts', () => { + expect( + areGraphDataPayloadsEqual( + createGraph({ nodes: [createNode(), createNode({ id: 'src/b.ts' })] }), + createGraph(), + ), + ).toBe(false); + expect( + areGraphDataPayloadsEqual( + createGraph({ edges: [] }), + createGraph(), + ), + ).toBe(false); + }); + + it('rejects count-only differences before serializing payloads', () => { + expect( + areGraphDataPayloadsEqual( + createGraph({ + nodes: withStableJson([createNode(), createNode({ id: 'src/b.ts' })], ['same nodes']), + }), + createGraph({ + nodes: withStableJson([createNode()], ['same nodes']), + }), + ), + ).toBe(false); + expect( + areGraphDataPayloadsEqual( + createGraph({ + edges: withStableJson([], ['same edges']), + }), + createGraph({ + edges: withStableJson([createEdge()], ['same edges']), + }), + ), + ).toBe(false); + }); + + it('compares serialized graph payloads when counts match', () => { + expect(areGraphDataPayloadsEqual(createGraph(), createGraph())).toBe(true); + expect( + areGraphDataPayloadsEqual( + createGraph(), + createGraph({ nodes: [createNode({ label: 'renamed.ts' })] }), + ), + ).toBe(false); + }); + + it('rejects payloads that cannot be serialized', () => { + const circularGraph = createGraph(); + (circularGraph.nodes[0] as IGraphNode & { graph?: IGraphData }).graph = circularGraph; + + expect(areGraphDataPayloadsEqual(circularGraph, createGraph())).toBe(false); + }); +}); From 5429b8a5ce74b7ddb9f0ba1ac3afba77ea59d5d5 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 21:12:21 -0700 Subject: [PATCH 169/192] test: kill graph equality mutants --- .../execution/publish/equality/graph.test.ts | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 packages/extension/tests/extension/graphView/analysis/execution/publish/equality/graph.test.ts diff --git a/packages/extension/tests/extension/graphView/analysis/execution/publish/equality/graph.test.ts b/packages/extension/tests/extension/graphView/analysis/execution/publish/equality/graph.test.ts new file mode 100644 index 000000000..94e4809a9 --- /dev/null +++ b/packages/extension/tests/extension/graphView/analysis/execution/publish/equality/graph.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest'; +import type { IGraphData, IGraphEdge, IGraphNode } from '../../../../../../../src/shared/graph/contracts'; +import { areGraphDataEqualIgnoringNodeMetrics } from '../../../../../../../src/extension/graphView/analysis/execution/publish/equality/graph'; + +function createNode(overrides: Partial = {}): IGraphNode { + return { + id: 'src/a.ts', + label: 'a.ts', + color: '#67E8F9', + nodeType: 'file', + ...overrides, + }; +} + +function createEdge(overrides: Partial = {}): IGraphEdge { + return { + id: 'src/a.ts->src/b.ts#import', + from: 'src/a.ts', + to: 'src/b.ts', + kind: 'import', + sources: [], + ...overrides, + }; +} + +function createGraph(overrides: Partial = {}): IGraphData { + return { + nodes: [createNode()], + edges: [createEdge()], + ...overrides, + }; +} + +function createEmptyTripWireArray(message: string): T[] { + return new Proxy([], { + get(target, property, receiver) { + if (property === '0') { + throw new Error(message); + } + + return Reflect.get(target, property, receiver); + }, + }); +} + +describe('extension/graphView/analysis/execution/publish/equality/graph', () => { + it('treats matching graphs as equal while ignoring node metrics', () => { + expect( + areGraphDataEqualIgnoringNodeMetrics( + createGraph({ nodes: [createNode({ churn: 1, fileSize: 10 })] }), + createGraph({ nodes: [createNode({ churn: 9, fileSize: 90 })] }), + ), + ).toBe(true); + }); + + it('rejects graphs when only the next graph has an extra node', () => { + expect( + areGraphDataEqualIgnoringNodeMetrics( + createGraph(), + createGraph({ nodes: [createNode(), createNode({ id: 'src/b.ts' })] }), + ), + ).toBe(false); + }); + + it('rejects graphs when only the next graph has an extra edge', () => { + expect( + areGraphDataEqualIgnoringNodeMetrics( + createGraph(), + createGraph({ edges: [createEdge(), createEdge({ id: 'src/b.ts->src/c.ts#import' })] }), + ), + ).toBe(false); + }); + + it('rejects non-metric node differences', () => { + expect( + areGraphDataEqualIgnoringNodeMetrics( + createGraph(), + createGraph({ nodes: [createNode({ label: 'renamed.ts' })] }), + ), + ).toBe(false); + }); + + it('rejects edge differences', () => { + expect( + areGraphDataEqualIgnoringNodeMetrics( + createGraph(), + createGraph({ edges: [createEdge({ kind: 'call' })] }), + ), + ).toBe(false); + }); + + it('does not inspect node slots when both graphs have no nodes', () => { + expect( + areGraphDataEqualIgnoringNodeMetrics( + createGraph({ + nodes: createEmptyTripWireArray('empty node arrays should not read item zero'), + edges: [], + }), + createGraph({ + nodes: createEmptyTripWireArray('empty node arrays should not read item zero'), + edges: [], + }), + ), + ).toBe(true); + }); + + it('does not inspect edge slots when both graphs have no edges', () => { + expect( + areGraphDataEqualIgnoringNodeMetrics( + createGraph({ + nodes: [], + edges: createEmptyTripWireArray('empty edge arrays should not read item zero'), + }), + createGraph({ + nodes: [], + edges: createEmptyTripWireArray('empty edge arrays should not read item zero'), + }), + ), + ).toBe(true); + }); +}); From 60620556a8c2800bfc75d21d5146e9a16129f7bf Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 21:18:12 -0700 Subject: [PATCH 170/192] test: kill group recompute mutants --- .../analysis/execution/publish/groupInputs.ts | 3 +- .../execution/publish/groupInputs.test.ts | 119 ++++++++++++++++++ 2 files changed, 120 insertions(+), 2 deletions(-) create mode 100644 packages/extension/tests/extension/graphView/analysis/execution/publish/groupInputs.test.ts diff --git a/packages/extension/src/extension/graphView/analysis/execution/publish/groupInputs.ts b/packages/extension/src/extension/graphView/analysis/execution/publish/groupInputs.ts index 96d809d01..eee405026 100644 --- a/packages/extension/src/extension/graphView/analysis/execution/publish/groupInputs.ts +++ b/packages/extension/src/extension/graphView/analysis/execution/publish/groupInputs.ts @@ -22,8 +22,7 @@ function areGraphGroupSymbolInputsEqual( } function areGraphGroupNodeInputsEqual(left: IGraphNode, right: IGraphNode): boolean { - return left.id === right.id - && left.nodeType === right.nodeType + return left.nodeType === right.nodeType && areGraphGroupSymbolInputsEqual(left.symbol, right.symbol); } diff --git a/packages/extension/tests/extension/graphView/analysis/execution/publish/groupInputs.test.ts b/packages/extension/tests/extension/graphView/analysis/execution/publish/groupInputs.test.ts new file mode 100644 index 000000000..df81f003b --- /dev/null +++ b/packages/extension/tests/extension/graphView/analysis/execution/publish/groupInputs.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest'; +import type { IGraphData, IGraphNode } from '../../../../../../src/shared/graph/contracts'; +import { doGraphViewGroupsNeedRecompute } from '../../../../../../src/extension/graphView/analysis/execution/publish/groupInputs'; + +type GraphNodeSymbol = NonNullable; + +function createSymbol(overrides: Partial = {}): GraphNodeSymbol { + return { + id: 'src/a.ts#Component', + name: 'Component', + kind: 'class', + filePath: 'src/a.ts', + pluginKind: 'typescript:class', + source: 'typescript', + language: 'typescript', + ...overrides, + }; +} + +function createNode(overrides: Partial = {}): IGraphNode { + return { + id: 'src/a.ts', + label: 'a.ts', + color: '#67E8F9', + nodeType: 'file', + ...overrides, + }; +} + +function createGraph(nodes: IGraphNode[]): IGraphData { + return { + nodes, + edges: [], + }; +} + +describe('extension/graphView/analysis/execution/publish/groupInputs', () => { + it('keeps graph view groups when node group inputs are unchanged', () => { + expect( + doGraphViewGroupsNeedRecompute( + createGraph([ + createNode({ id: 'src/a.ts', symbol: createSymbol({ filePath: 'src/a.ts' }) }), + createNode({ id: 'src/b.ts', symbol: createSymbol({ id: 'src/b.ts#Component', filePath: 'src/b.ts' }) }), + ]), + createGraph([ + createNode({ id: 'src/b.ts', symbol: createSymbol({ id: 'src/b.ts#Component', filePath: 'src/b.ts' }) }), + createNode({ id: 'src/a.ts', symbol: createSymbol({ filePath: 'src/a.ts' }) }), + ]), + ), + ).toBe(false); + }); + + it('recomputes graph view groups when the next graph adds a node', () => { + expect( + doGraphViewGroupsNeedRecompute( + createGraph([createNode()]), + createGraph([createNode(), createNode({ id: 'src/b.ts' })]), + ), + ).toBe(true); + }); + + it('recomputes graph view groups when a current node id is absent', () => { + expect( + doGraphViewGroupsNeedRecompute( + createGraph([createNode({ id: 'src/a.ts' })]), + createGraph([createNode({ id: 'src/b.ts' })]), + ), + ).toBe(true); + }); + + it('recomputes graph view groups when node type changes', () => { + expect( + doGraphViewGroupsNeedRecompute( + createGraph([createNode({ nodeType: 'file' })]), + createGraph([createNode({ nodeType: 'symbol' })]), + ), + ).toBe(true); + }); + + it('recomputes graph view groups when symbol presence changes', () => { + expect( + doGraphViewGroupsNeedRecompute( + createGraph([createNode()]), + createGraph([createNode({ symbol: createSymbol() })]), + ), + ).toBe(true); + }); + + it.each([ + ['kind', { kind: 'function' }], + ['plugin kind', { pluginKind: 'typescript:function' }], + ['source', { source: 'markdown' }], + ['language', { language: 'markdown' }], + ['file path', { filePath: 'src/b.ts' }], + ] as const)('recomputes graph view groups when symbol %s changes', (_field, symbolOverrides) => { + expect( + doGraphViewGroupsNeedRecompute( + createGraph([createNode({ symbol: createSymbol() })]), + createGraph([createNode({ symbol: createSymbol(symbolOverrides) })]), + ), + ).toBe(true); + }); + + it('keeps graph view groups when symbol display details change', () => { + expect( + doGraphViewGroupsNeedRecompute( + createGraph([createNode({ symbol: createSymbol() })]), + createGraph([createNode({ + symbol: createSymbol({ + id: 'src/a.ts#RenamedComponent', + name: 'RenamedComponent', + range: { startLine: 10, endLine: 20 }, + signature: 'class RenamedComponent', + }), + })]), + ), + ).toBe(false); + }); +}); From 3955f7d141a97a478cc1d95d2151cd158941a26c Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 21:21:47 -0700 Subject: [PATCH 171/192] test: kill metric update mutants --- .../execution/publish/metrics/updates.test.ts | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 packages/extension/tests/extension/graphView/analysis/execution/publish/metrics/updates.test.ts diff --git a/packages/extension/tests/extension/graphView/analysis/execution/publish/metrics/updates.test.ts b/packages/extension/tests/extension/graphView/analysis/execution/publish/metrics/updates.test.ts new file mode 100644 index 000000000..d13ef1ca8 --- /dev/null +++ b/packages/extension/tests/extension/graphView/analysis/execution/publish/metrics/updates.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; +import type { IGraphNode } from '../../../../../../../src/shared/graph/contracts'; +import { + collectMetricOnlyGraphUpdates, + createNodeMap, +} from '../../../../../../../src/extension/graphView/analysis/execution/publish/metrics/updates'; + +function createNode(overrides: Partial = {}): IGraphNode { + return { + id: 'src/a.ts', + label: 'a.ts', + color: '#67E8F9', + nodeType: 'file', + ...overrides, + }; +} + +describe('extension/graphView/analysis/execution/publish/metrics/updates', () => { + it('indexes graph nodes by id', () => { + const firstNode = createNode({ id: 'src/a.ts' }); + const secondNode = createNode({ id: 'src/b.ts' }); + + expect(createNodeMap([firstNode, secondNode])).toEqual(new Map([ + ['src/a.ts', firstNode], + ['src/b.ts', secondNode], + ])); + }); + + it('returns metric patches for changed file size and churn values', () => { + expect( + collectMetricOnlyGraphUpdates( + [ + createNode({ id: 'src/a.ts', fileSize: 10, churn: 1 }), + createNode({ id: 'src/b.ts', fileSize: 20, churn: 2 }), + ], + createNodeMap([ + createNode({ id: 'src/a.ts', fileSize: 15, churn: 1 }), + createNode({ id: 'src/b.ts', fileSize: 20, churn: 3 }), + ]), + ), + ).toEqual([ + { id: 'src/a.ts', fileSize: 15, churn: 1 }, + { id: 'src/b.ts', fileSize: 20, churn: 3 }, + ]); + }); + + it('returns undefined when no node metrics changed', () => { + expect( + collectMetricOnlyGraphUpdates( + [createNode({ fileSize: 10, churn: 1 })], + createNodeMap([createNode({ fileSize: 10, churn: 1 })]), + ), + ).toBeUndefined(); + }); + + it('returns undefined when a next node is missing', () => { + expect( + collectMetricOnlyGraphUpdates( + [createNode({ id: 'src/a.ts' })], + createNodeMap([createNode({ id: 'src/b.ts' })]), + ), + ).toBeUndefined(); + }); + + it('returns undefined when a changed node has non-metric differences', () => { + expect( + collectMetricOnlyGraphUpdates( + [createNode({ fileSize: 10, churn: 1 })], + createNodeMap([createNode({ label: 'renamed.ts', fileSize: 15, churn: 1 })]), + ), + ).toBeUndefined(); + }); +}); From 32fdb404786a01ecfaf3d57549511eca242746bb Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 21:26:36 -0700 Subject: [PATCH 172/192] test: kill changed path metric mutants --- .../publish/metrics/changedPaths.test.ts | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 packages/extension/tests/extension/graphView/analysis/execution/publish/metrics/changedPaths.test.ts diff --git a/packages/extension/tests/extension/graphView/analysis/execution/publish/metrics/changedPaths.test.ts b/packages/extension/tests/extension/graphView/analysis/execution/publish/metrics/changedPaths.test.ts new file mode 100644 index 000000000..96a2b6c27 --- /dev/null +++ b/packages/extension/tests/extension/graphView/analysis/execution/publish/metrics/changedPaths.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest'; +import type { IGraphData, IGraphNode } from '../../../../../../../src/shared/graph/contracts'; +import { + collectChangedPathNodes, + hasChangedNodeMetricDifference, +} from '../../../../../../../src/extension/graphView/analysis/execution/publish/metrics/changedPaths'; + +function createNode(overrides: Partial = {}): IGraphNode { + return { + id: 'src/a.ts', + label: 'a.ts', + color: '#67E8F9', + nodeType: 'file', + ...overrides, + }; +} + +function createGraph(nodes: IGraphNode[]): IGraphData { + return { + nodes, + edges: [], + }; +} + +describe('extension/graphView/analysis/execution/publish/metrics/changedPaths', () => { + it('returns false when no changed file paths are available', () => { + expect( + hasChangedNodeMetricDifference( + createGraph([createNode({ fileSize: 10 })]), + createGraph([createNode({ fileSize: 20 })]), + undefined, + ), + ).toBe(false); + }); + + it('detects file size metric differences for changed nodes', () => { + expect( + hasChangedNodeMetricDifference( + createGraph([createNode({ fileSize: 10, churn: 1 })]), + createGraph([createNode({ fileSize: 20, churn: 1 })]), + ['src/a.ts'], + ), + ).toBe(true); + }); + + it('detects churn metric differences for changed nodes', () => { + expect( + hasChangedNodeMetricDifference( + createGraph([createNode({ fileSize: 10, churn: 1 })]), + createGraph([createNode({ fileSize: 10, churn: 2 })]), + ['src/a.ts'], + ), + ).toBe(true); + }); + + it('ignores changed paths when either graph is missing the node', () => { + expect( + hasChangedNodeMetricDifference( + createGraph([createNode({ id: 'src/a.ts', fileSize: 10 })]), + createGraph([createNode({ id: 'src/b.ts', fileSize: 20 })]), + ['src/a.ts'], + ), + ).toBe(false); + }); + + it('returns false when changed node metrics are unchanged', () => { + expect( + hasChangedNodeMetricDifference( + createGraph([createNode({ fileSize: 10, churn: 1 })]), + createGraph([createNode({ fileSize: 10, churn: 1 })]), + ['src/a.ts'], + ), + ).toBe(false); + }); + + it('collects nodes whose id exactly matches a changed path', () => { + const matchingNode = createNode({ id: 'src/a.ts' }); + const unrelatedNode = createNode({ id: 'src/b.ts' }); + + expect(collectChangedPathNodes( + createGraph([matchingNode, unrelatedNode]), + ['src/a.ts', 'src/c.ts'], + )).toEqual([matchingNode]); + }); + + it('collects nodes whose id matches a workspace-prefixed changed path', () => { + const matchingNode = createNode({ id: 'src/a.ts' }); + const unrelatedNode = createNode({ id: 'src/b.ts' }); + + expect(collectChangedPathNodes( + createGraph([matchingNode, unrelatedNode]), + ['/workspace/project/src/a.ts'], + )).toEqual([matchingNode]); + }); + + it('collects nodes from Windows-style changed paths', () => { + const matchingNode = createNode({ id: 'src/a.ts' }); + const unrelatedNode = createNode({ id: 'src/b.ts' }); + + expect(collectChangedPathNodes( + createGraph([matchingNode, unrelatedNode]), + ['C:\\workspace\\project\\src\\a.ts'], + )).toEqual([matchingNode]); + }); + + it('collects symbol nodes by symbol file path', () => { + const symbolNode = createNode({ + id: 'src/a.ts#Component', + nodeType: 'symbol', + symbol: { + id: 'src/a.ts#Component', + name: 'Component', + kind: 'class', + filePath: 'src/a.ts', + }, + }); + const unrelatedNode = createNode({ id: 'src/b.ts#Component', nodeType: 'symbol' }); + + expect(collectChangedPathNodes( + createGraph([symbolNode, unrelatedNode]), + ['src/a.ts'], + )).toEqual([symbolNode]); + }); +}); From 9591c7dda71220f1d7fe751b740901a1a37f6f43 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 21:32:16 -0700 Subject: [PATCH 173/192] test: kill metric patch mutants --- .../execution/publish/metrics/patch.ts | 24 +-- .../execution/publish/metrics/patch.test.ts | 139 ++++++++++++++++++ 2 files changed, 142 insertions(+), 21 deletions(-) create mode 100644 packages/extension/tests/extension/graphView/analysis/execution/publish/metrics/patch.test.ts diff --git a/packages/extension/src/extension/graphView/analysis/execution/publish/metrics/patch.ts b/packages/extension/src/extension/graphView/analysis/execution/publish/metrics/patch.ts index a5927e6e4..10684b4c4 100644 --- a/packages/extension/src/extension/graphView/analysis/execution/publish/metrics/patch.ts +++ b/packages/extension/src/extension/graphView/analysis/execution/publish/metrics/patch.ts @@ -7,26 +7,12 @@ import { } from './updates'; import { areGraphDataEqualIgnoringNodeMetrics } from '../equality/graph'; -function canConsiderMetricOnlyGraphUpdate( - currentRawGraphData: IGraphData | undefined, - nextRawGraphData: IGraphData, - changedFilePaths: readonly string[] | undefined, -): currentRawGraphData is IGraphData { - return Boolean( - currentRawGraphData - && changedFilePaths?.length - && currentRawGraphData.nodes.length === nextRawGraphData.nodes.length - && currentRawGraphData.edges.length === nextRawGraphData.edges.length, - ); -} - export function createMetricOnlyGraphUpdate( currentRawGraphData: IGraphData | undefined, nextRawGraphData: IGraphData, changedFilePaths: readonly string[] | undefined, ): IGraphNodeMetricsUpdate[] | undefined { - const changedPaths = changedFilePaths ?? []; - if (!canConsiderMetricOnlyGraphUpdate(currentRawGraphData, nextRawGraphData, changedFilePaths)) { + if (!currentRawGraphData || !changedFilePaths?.length) { return undefined; } @@ -34,11 +20,7 @@ export function createMetricOnlyGraphUpdate( return undefined; } - const currentNodes = collectChangedPathNodes(currentRawGraphData, changedPaths); - const nextNodes = collectChangedPathNodes(nextRawGraphData, changedPaths); - if (currentNodes.length === 0 || currentNodes.length !== nextNodes.length) { - return undefined; - } - + const currentNodes = collectChangedPathNodes(currentRawGraphData, changedFilePaths); + const nextNodes = collectChangedPathNodes(nextRawGraphData, changedFilePaths); return collectMetricOnlyGraphUpdates(currentNodes, createNodeMap(nextNodes)); } diff --git a/packages/extension/tests/extension/graphView/analysis/execution/publish/metrics/patch.test.ts b/packages/extension/tests/extension/graphView/analysis/execution/publish/metrics/patch.test.ts new file mode 100644 index 000000000..4e28d2fff --- /dev/null +++ b/packages/extension/tests/extension/graphView/analysis/execution/publish/metrics/patch.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from 'vitest'; +import type { IGraphData, IGraphEdge, IGraphNode } from '../../../../../../../src/shared/graph/contracts'; +import { createMetricOnlyGraphUpdate } from '../../../../../../../src/extension/graphView/analysis/execution/publish/metrics/patch'; + +function createNode(overrides: Partial = {}): IGraphNode { + return { + id: 'src/a.ts', + label: 'a.ts', + color: '#67E8F9', + nodeType: 'file', + ...overrides, + }; +} + +function createEdge(overrides: Partial = {}): IGraphEdge { + return { + id: 'src/a.ts->src/b.ts#import', + from: 'src/a.ts', + to: 'src/b.ts', + kind: 'import', + sources: [], + ...overrides, + }; +} + +function createGraph(overrides: Partial = {}): IGraphData { + return { + nodes: [createNode({ fileSize: 10, churn: 1 })], + edges: [createEdge()], + ...overrides, + }; +} + +describe('extension/graphView/analysis/execution/publish/metrics/patch', () => { + it('returns metric patches for metric-only changed path updates', () => { + expect( + createMetricOnlyGraphUpdate( + createGraph(), + createGraph({ nodes: [createNode({ fileSize: 15, churn: 2 })] }), + ['src/a.ts'], + ), + ).toEqual([{ id: 'src/a.ts', fileSize: 15, churn: 2 }]); + }); + + it('returns undefined when no current graph exists', () => { + expect( + createMetricOnlyGraphUpdate( + undefined, + createGraph(), + ['src/a.ts'], + ), + ).toBeUndefined(); + }); + + it('returns undefined when no changed paths are available', () => { + expect( + createMetricOnlyGraphUpdate( + createGraph(), + createGraph({ nodes: [createNode({ fileSize: 15 })] }), + [], + ), + ).toBeUndefined(); + }); + + it('returns undefined when node counts changed', () => { + expect( + createMetricOnlyGraphUpdate( + createGraph(), + createGraph({ nodes: [createNode(), createNode({ id: 'src/b.ts' })] }), + ['src/a.ts'], + ), + ).toBeUndefined(); + }); + + it('returns undefined when edge counts changed', () => { + expect( + createMetricOnlyGraphUpdate( + createGraph(), + createGraph({ edges: [createEdge(), createEdge({ id: 'src/b.ts->src/c.ts#import' })] }), + ['src/a.ts'], + ), + ).toBeUndefined(); + }); + + it('returns undefined when graph differences are not metric-only', () => { + expect( + createMetricOnlyGraphUpdate( + createGraph(), + createGraph({ nodes: [createNode({ label: 'renamed.ts', fileSize: 15 })] }), + ['src/a.ts'], + ), + ).toBeUndefined(); + }); + + it('returns undefined when no changed path nodes are present', () => { + expect( + createMetricOnlyGraphUpdate( + createGraph(), + createGraph({ nodes: [createNode({ fileSize: 15 })] }), + ['src/missing.ts'], + ), + ).toBeUndefined(); + }); + + it('returns undefined when changed path node sets differ', () => { + expect( + createMetricOnlyGraphUpdate( + createGraph({ + nodes: [ + createNode({ + id: 'src/a.ts#Component', + nodeType: 'symbol', + symbol: { + id: 'src/a.ts#Component', + name: 'Component', + kind: 'class', + filePath: 'src/a.ts', + }, + }), + ], + }), + createGraph({ + nodes: [createNode({ id: 'src/a.ts#Component', nodeType: 'symbol' })], + }), + ['src/a.ts'], + ), + ).toBeUndefined(); + }); + + it('returns undefined when changed node metrics are unchanged', () => { + expect( + createMetricOnlyGraphUpdate( + createGraph(), + createGraph(), + ['src/a.ts'], + ), + ).toBeUndefined(); + }); +}); From 52f3d120a5f678785d428d1088a0fad4817b101d Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 21:37:01 -0700 Subject: [PATCH 174/192] test: kill graph publish status mutants --- .../analysis/execution/publish/status.test.ts | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 packages/extension/tests/extension/graphView/analysis/execution/publish/status.test.ts diff --git a/packages/extension/tests/extension/graphView/analysis/execution/publish/status.test.ts b/packages/extension/tests/extension/graphView/analysis/execution/publish/status.test.ts new file mode 100644 index 000000000..3ed9bf9d0 --- /dev/null +++ b/packages/extension/tests/extension/graphView/analysis/execution/publish/status.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; +import type { + GraphViewAnalysisExecutionState, + GraphViewAnalysisMode, +} from '../../../../../../src/extension/graphView/analysis/execution'; +import { + resolveGraphIndexStatus, + shouldReportGraphViewUpdateProgress, +} from '../../../../../../src/extension/graphView/analysis/execution/publish/status'; + +function createState( + mode: GraphViewAnalysisMode, + overrides: Partial = {}, +): GraphViewAnalysisExecutionState { + return { + analyzer: undefined, + analyzerInitialized: false, + analyzerInitPromise: undefined, + mode, + filterPatterns: [], + disabledPlugins: new Set(), + ...overrides, + }; +} + +describe('extension/graphView/analysis/execution/publish/status', () => { + it('uses analyzer-provided index status when available', () => { + expect(resolveGraphIndexStatus(createState('load', { + analyzer: { + getIndexStatus: () => ({ + freshness: 'stale', + detail: 'CodeGraphy index is stale: plugins changed.', + }), + } as GraphViewAnalysisExecutionState['analyzer'], + }), true)).toEqual({ + freshness: 'stale', + detail: 'CodeGraphy index is stale: plugins changed.', + }); + }); + + it('falls back to fresh status when an index exists', () => { + expect(resolveGraphIndexStatus(createState('load'), true)).toEqual({ + freshness: 'fresh', + detail: 'CodeGraphy index is fresh.', + }); + }); + + it('falls back to missing status when no index exists', () => { + expect(resolveGraphIndexStatus(undefined, false)).toEqual({ + freshness: 'missing', + detail: 'CodeGraphy index is missing. Index the workspace to build the graph.', + }); + }); + + it.each([ + 'index', + 'refresh', + 'incremental', + ] as const)('reports graph view update progress for %s mode', (mode) => { + expect(shouldReportGraphViewUpdateProgress(createState(mode))).toBe(true); + }); + + it.each([ + 'analyze', + 'load', + ] as const)('skips graph view update progress for %s mode', (mode) => { + expect(shouldReportGraphViewUpdateProgress(createState(mode))).toBe(false); + }); +}); From d2cb6e0132cc740ebaac24af9b2d120c09f4f45f Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 21:41:04 -0700 Subject: [PATCH 175/192] test: kill graph publish plan mutants --- .../analysis/execution/publish/plan.test.ts | 156 ++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 packages/extension/tests/extension/graphView/analysis/execution/publish/plan.test.ts diff --git a/packages/extension/tests/extension/graphView/analysis/execution/publish/plan.test.ts b/packages/extension/tests/extension/graphView/analysis/execution/publish/plan.test.ts new file mode 100644 index 000000000..59b6388f7 --- /dev/null +++ b/packages/extension/tests/extension/graphView/analysis/execution/publish/plan.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it } from 'vitest'; +import type { IGraphData, IGraphEdge, IGraphNode } from '../../../../../../src/shared/graph/contracts'; +import type { + GraphViewAnalysisExecutionHandlers, + GraphViewAnalysisExecutionState, + GraphViewAnalysisMode, +} from '../../../../../../src/extension/graphView/analysis/execution'; +import { createGraphPublicationPlan } from '../../../../../../src/extension/graphView/analysis/execution/publish/plan'; + +function createNode(overrides: Partial = {}): IGraphNode { + return { + id: 'src/a.ts', + label: 'a.ts', + color: '#67E8F9', + nodeType: 'file', + ...overrides, + }; +} + +function createEdge(overrides: Partial = {}): IGraphEdge { + return { + id: 'src/a.ts->src/b.ts#import', + from: 'src/a.ts', + to: 'src/b.ts', + kind: 'import', + sources: [], + ...overrides, + }; +} + +function createGraph(overrides: Partial = {}): IGraphData { + return { + nodes: [createNode({ fileSize: 10, churn: 1 })], + edges: [createEdge()], + ...overrides, + }; +} + +function createState( + mode: GraphViewAnalysisMode, + overrides: Partial = {}, +): GraphViewAnalysisExecutionState { + return { + analyzer: undefined, + analyzerInitialized: false, + analyzerInitPromise: undefined, + mode, + filterPatterns: [], + disabledPlugins: new Set(), + ...overrides, + }; +} + +function createHandlers( + overrides: Partial = {}, +): GraphViewAnalysisExecutionHandlers { + return overrides as GraphViewAnalysisExecutionHandlers; +} + +describe('extension/graphView/analysis/execution/publish/plan', () => { + it('reuses the current graph publication for unchanged fresh incremental graphs', () => { + const currentGraph = createGraph(); + + expect(createGraphPublicationPlan( + createState('incremental'), + createHandlers({ getRawGraphData: () => currentGraph }), + createGraph(), + true, + 'fresh', + )).toMatchObject({ + currentRawGraphData: currentGraph, + metricOnlyUpdate: undefined, + reuseCurrentGraphPublication: true, + shouldSendMetricPatch: false, + }); + }); + + it.each([ + ['analyze mode', createState('analyze'), true, 'fresh'], + ['missing index', createState('incremental'), false, 'fresh'], + ['stale freshness', createState('incremental'), true, 'stale'], + ] as const)('does not reuse the current graph publication for %s', (_caseName, state, actualHasIndex, freshness) => { + const currentGraph = createGraph(); + + expect(createGraphPublicationPlan( + state, + createHandlers({ getRawGraphData: () => currentGraph }), + createGraph(), + actualHasIndex, + freshness, + ).reuseCurrentGraphPublication).toBe(false); + }); + + it('does not reuse the current graph publication when no current graph is available', () => { + expect(createGraphPublicationPlan( + createState('incremental'), + createHandlers(), + createGraph(), + true, + 'fresh', + )).toMatchObject({ + currentRawGraphData: undefined, + metricOnlyUpdate: undefined, + reuseCurrentGraphPublication: false, + shouldSendMetricPatch: false, + }); + }); + + it('enables metric patch publication when a metric-only update and sender are available', () => { + expect(createGraphPublicationPlan( + createState('incremental', { changedFilePaths: ['src/a.ts'] }), + createHandlers({ + getRawGraphData: () => createGraph(), + sendGraphNodeMetricsUpdated: () => {}, + }), + createGraph({ nodes: [createNode({ fileSize: 15, churn: 2 })] }), + true, + 'fresh', + )).toMatchObject({ + metricOnlyUpdate: [{ id: 'src/a.ts', fileSize: 15, churn: 2 }], + reuseCurrentGraphPublication: false, + shouldSendMetricPatch: true, + }); + }); + + it('keeps metric patches disabled when the sender is unavailable', () => { + expect(createGraphPublicationPlan( + createState('incremental', { changedFilePaths: ['src/a.ts'] }), + createHandlers({ getRawGraphData: () => createGraph() }), + createGraph({ nodes: [createNode({ fileSize: 15, churn: 2 })] }), + true, + 'fresh', + )).toMatchObject({ + metricOnlyUpdate: [{ id: 'src/a.ts', fileSize: 15, churn: 2 }], + reuseCurrentGraphPublication: false, + shouldSendMetricPatch: false, + }); + }); + + it('keeps metric patches disabled when no metric-only update exists', () => { + expect(createGraphPublicationPlan( + createState('incremental'), + createHandlers({ + getRawGraphData: () => createGraph(), + sendGraphNodeMetricsUpdated: () => {}, + }), + createGraph(), + true, + 'fresh', + )).toMatchObject({ + metricOnlyUpdate: undefined, + reuseCurrentGraphPublication: true, + shouldSendMetricPatch: false, + }); + }); +}); From 61a651998b05d9828d03a5e20e90d68bbab3d710 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 21:45:54 -0700 Subject: [PATCH 176/192] test: kill graph publish message mutants --- .../execution/publish/messages.test.ts | 203 ++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 packages/extension/tests/extension/graphView/analysis/execution/publish/messages.test.ts diff --git a/packages/extension/tests/extension/graphView/analysis/execution/publish/messages.test.ts b/packages/extension/tests/extension/graphView/analysis/execution/publish/messages.test.ts new file mode 100644 index 000000000..efe30ac6f --- /dev/null +++ b/packages/extension/tests/extension/graphView/analysis/execution/publish/messages.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { IGraphData, IGraphEdge, IGraphNode } from '../../../../../../src/shared/graph/contracts'; +import type { + GraphViewAnalysisExecutionHandlers, + GraphViewAnalysisExecutionState, + GraphViewAnalysisMode, +} from '../../../../../../src/extension/graphView/analysis/execution'; +import type { GraphPublicationPlan } from '../../../../../../src/extension/graphView/analysis/execution/publish/plan'; +import { + publishGraphDataMessage, + publishRawGraphUpdate, + publishStaticGraphMessages, +} from '../../../../../../src/extension/graphView/analysis/execution/publish/messages'; + +function createNode(overrides: Partial = {}): IGraphNode { + return { + id: 'src/a.ts', + label: 'a.ts', + color: '#67E8F9', + nodeType: 'file', + ...overrides, + }; +} + +function createEdge(overrides: Partial = {}): IGraphEdge { + return { + id: 'src/a.ts->src/b.ts#import', + from: 'src/a.ts', + to: 'src/b.ts', + kind: 'import', + sources: [], + ...overrides, + }; +} + +function createGraph(overrides: Partial = {}): IGraphData { + return { + nodes: [createNode()], + edges: [createEdge()], + ...overrides, + }; +} + +function createState(mode: GraphViewAnalysisMode): GraphViewAnalysisExecutionState { + return { + analyzer: undefined, + analyzerInitialized: false, + analyzerInitPromise: undefined, + mode, + filterPatterns: [], + disabledPlugins: new Set(), + }; +} + +function createHandlers( + overrides: Partial = {}, +): GraphViewAnalysisExecutionHandlers { + return { + setRawGraphData: vi.fn(), + updateViewContext: vi.fn(), + applyViewTransform: vi.fn(), + computeMergedGroups: vi.fn(), + sendGroupsUpdated: vi.fn(), + sendDepthState: vi.fn(), + sendPluginStatuses: vi.fn(), + sendDecorations: vi.fn(), + sendContextMenuItems: vi.fn(), + sendGraphDataUpdated: vi.fn(), + isAnalysisStale: vi.fn(), + hasWorkspace: vi.fn(), + setGraphData: vi.fn(), + getGraphData: vi.fn(), + sendGraphIndexStatusUpdated: vi.fn(), + markWorkspaceReady: vi.fn(), + isAbortError: vi.fn(), + logError: vi.fn(), + ...overrides, + } as GraphViewAnalysisExecutionHandlers; +} + +function createPlan(overrides: Partial = {}): GraphPublicationPlan { + return { + currentRawGraphData: undefined, + metricOnlyUpdate: undefined, + reuseCurrentGraphPublication: false, + shouldSendMetricPatch: false, + ...overrides, + }; +} + +describe('extension/graphView/analysis/execution/publish/messages', () => { + it('skips raw graph publication when the current publication can be reused', () => { + const handlers = createHandlers(); + + publishRawGraphUpdate( + createState('incremental'), + handlers, + createGraph(), + createPlan({ reuseCurrentGraphPublication: true }), + ); + + expect(handlers.setRawGraphData).not.toHaveBeenCalled(); + expect(handlers.computeMergedGroups).not.toHaveBeenCalled(); + }); + + it('publishes groups outside incremental mode even when group inputs match', () => { + const currentGraph = createGraph(); + const handlers = createHandlers(); + + publishRawGraphUpdate( + createState('refresh'), + handlers, + createGraph(), + createPlan({ currentRawGraphData: currentGraph }), + ); + + expect(handlers.setRawGraphData).toHaveBeenCalledOnce(); + expect(handlers.computeMergedGroups).toHaveBeenCalledOnce(); + expect(handlers.sendGroupsUpdated).toHaveBeenCalledOnce(); + }); + + it('skips group publication for unchanged incremental group inputs', () => { + const currentGraph = createGraph(); + const handlers = createHandlers(); + + publishRawGraphUpdate( + createState('incremental'), + handlers, + createGraph(), + createPlan({ currentRawGraphData: currentGraph }), + ); + + expect(handlers.setRawGraphData).toHaveBeenCalledOnce(); + expect(handlers.computeMergedGroups).not.toHaveBeenCalled(); + expect(handlers.sendGroupsUpdated).not.toHaveBeenCalled(); + }); + + it('publishes static graph messages without optional contribution broadcasters', () => { + const handlers = createHandlers(); + + expect(() => publishStaticGraphMessages(handlers)).not.toThrow(); + expect(handlers.sendDepthState).toHaveBeenCalledOnce(); + expect(handlers.sendPluginStatuses).toHaveBeenCalledOnce(); + expect(handlers.sendContextMenuItems).toHaveBeenCalledOnce(); + }); + + it('sends metric patches instead of full graph data when the plan enables patches', () => { + const sendGraphNodeMetricsUpdated = vi.fn(); + const handlers = createHandlers({ sendGraphNodeMetricsUpdated }); + const metricOnlyUpdate = [{ id: 'src/a.ts', fileSize: 15, churn: 2 }]; + + publishGraphDataMessage( + handlers, + createGraph(), + createPlan({ metricOnlyUpdate, shouldSendMetricPatch: true }), + ); + + expect(sendGraphNodeMetricsUpdated).toHaveBeenCalledWith(metricOnlyUpdate); + expect(handlers.sendGraphDataUpdated).not.toHaveBeenCalled(); + }); + + it('sends full graph data when metric patch updates are absent', () => { + const sendGraphNodeMetricsUpdated = vi.fn(); + const handlers = createHandlers({ sendGraphNodeMetricsUpdated }); + const graphData = createGraph(); + + publishGraphDataMessage( + handlers, + graphData, + createPlan({ shouldSendMetricPatch: true }), + ); + + expect(sendGraphNodeMetricsUpdated).not.toHaveBeenCalled(); + expect(handlers.sendGraphDataUpdated).toHaveBeenCalledWith(graphData); + }); + + it('sends full graph data when metric patch publication is disabled', () => { + const sendGraphNodeMetricsUpdated = vi.fn(); + const handlers = createHandlers({ sendGraphNodeMetricsUpdated }); + const graphData = createGraph(); + const metricOnlyUpdate = [{ id: 'src/a.ts', fileSize: 15, churn: 2 }]; + + publishGraphDataMessage( + handlers, + graphData, + createPlan({ metricOnlyUpdate, shouldSendMetricPatch: false }), + ); + + expect(sendGraphNodeMetricsUpdated).not.toHaveBeenCalled(); + expect(handlers.sendGraphDataUpdated).toHaveBeenCalledWith(graphData); + }); + + it('does not throw when an inconsistent metric patch plan lacks a sender', () => { + expect(() => publishGraphDataMessage( + createHandlers(), + createGraph(), + createPlan({ + metricOnlyUpdate: [{ id: 'src/a.ts', fileSize: 15, churn: 2 }], + shouldSendMetricPatch: true, + }), + )).not.toThrow(); + }); +}); From 7f6fdea0dbfbdc6f5311546d85a9a4b7c2a6464c Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 21:50:50 -0700 Subject: [PATCH 177/192] test: kill graph publish entry mutants --- .../analysis/execution/publishEntry.test.ts | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 packages/extension/tests/extension/graphView/analysis/execution/publishEntry.test.ts diff --git a/packages/extension/tests/extension/graphView/analysis/execution/publishEntry.test.ts b/packages/extension/tests/extension/graphView/analysis/execution/publishEntry.test.ts new file mode 100644 index 000000000..433fbdeab --- /dev/null +++ b/packages/extension/tests/extension/graphView/analysis/execution/publishEntry.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { IGraphData } from '../../../../../src/shared/graph/contracts'; +import { + publishAnalyzedGraph, + publishAnalysisFailure, +} from '../../../../../src/extension/graphView/analysis/execution/publish'; +import { + createExecutionHandlers, + createExecutionState, +} from './fixtures'; + +const rawGraphData: IGraphData = { + nodes: [{ id: 'src/index.ts', label: 'src/index.ts', color: '#ffffff' }], + edges: [], +}; + +describe('graph view analysis execution publish entry points', () => { + it('skips graph view update progress outside indexed update modes', () => { + const { handlers } = createExecutionHandlers({ + sendIndexProgress: vi.fn(), + }); + + publishAnalyzedGraph( + createExecutionState({ mode: 'analyze' }), + handlers, + rawGraphData, + true, + ); + + expect(handlers.sendIndexProgress).not.toHaveBeenCalled(); + }); + + it('does not require a progress broadcaster for indexed update modes', () => { + const { handlers } = createExecutionHandlers({ + sendIndexProgress: undefined, + }); + + expect(() => publishAnalyzedGraph( + createExecutionState({ mode: 'index' }), + handlers, + rawGraphData, + true, + )).not.toThrow(); + }); + + it('publishes analysis failure without optional contribution status broadcaster', () => { + const { handlers } = createExecutionHandlers({ + sendGraphViewContributionStatuses: undefined, + }); + + expect(() => publishAnalysisFailure(handlers)).not.toThrow(); + expect(handlers.markWorkspaceReady).toHaveBeenCalledWith({ nodes: [], edges: [] }); + }); +}); From 7dc18ed2f1a8b7fe097309a5499600e85e1e1d8b Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 21:57:46 -0700 Subject: [PATCH 178/192] test: kill filtered graph reference cache mutants --- .../search/filteredGraph/referenceCache.ts | 11 +-- .../filteredGraph/referenceCache.test.ts | 80 +++++++++++++++++++ 2 files changed, 82 insertions(+), 9 deletions(-) create mode 100644 packages/extension/tests/webview/search/filteredGraph/referenceCache.test.ts diff --git a/packages/extension/src/webview/search/filteredGraph/referenceCache.ts b/packages/extension/src/webview/search/filteredGraph/referenceCache.ts index 92c28e110..80c2b7c67 100644 --- a/packages/extension/src/webview/search/filteredGraph/referenceCache.ts +++ b/packages/extension/src/webview/search/filteredGraph/referenceCache.ts @@ -29,18 +29,11 @@ export function cacheReferenceResult( result: TValue, ): void { const cacheKey = getReferenceResultCacheKey(cache, reference, key); - if (cache.entries.has(cacheKey)) { - cache.entries.delete(cacheKey); - } - + cache.entries.delete(cacheKey); cache.entries.set(cacheKey, result); while (cache.entries.size > REFERENCE_RESULT_CACHE_LIMIT) { - const oldestKey = cache.entries.keys().next().value; - if (!oldestKey) { - return; - } - + const oldestKey = cache.entries.keys().next().value as string; cache.entries.delete(oldestKey); } } diff --git a/packages/extension/tests/webview/search/filteredGraph/referenceCache.test.ts b/packages/extension/tests/webview/search/filteredGraph/referenceCache.test.ts new file mode 100644 index 000000000..e774ee8d4 --- /dev/null +++ b/packages/extension/tests/webview/search/filteredGraph/referenceCache.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; +import { + cacheReferenceResult, + createReferenceResultCache, + getReferenceResult, +} from '../../../../src/webview/search/filteredGraph/referenceCache'; + +describe('webview/search/filteredGraph/referenceCache', () => { + it('returns cached results for the same reference and key', () => { + const cache = createReferenceResultCache(); + const reference = {}; + + cacheReferenceResult(cache, reference, 'visible', 'cached graph'); + + expect(getReferenceResult(cache, reference, 'visible')).toBe('cached graph'); + }); + + it('keeps results isolated by reference object', () => { + const cache = createReferenceResultCache(); + const firstReference = {}; + const secondReference = {}; + + cacheReferenceResult(cache, firstReference, 'visible', 'first graph'); + cacheReferenceResult(cache, secondReference, 'visible', 'second graph'); + + expect(getReferenceResult(cache, firstReference, 'visible')).toBe('first graph'); + expect(getReferenceResult(cache, secondReference, 'visible')).toBe('second graph'); + }); + + it('assigns increasing ids to new reference objects', () => { + const cache = createReferenceResultCache(); + + cacheReferenceResult(cache, {}, 'visible', 'first graph'); + cacheReferenceResult(cache, {}, 'visible', 'second graph'); + + expect(cache.nextReferenceId).toBe(3); + }); + + it('keeps results isolated by key for the same reference', () => { + const cache = createReferenceResultCache(); + const reference = {}; + + cacheReferenceResult(cache, reference, 'visible', 'visible graph'); + cacheReferenceResult(cache, reference, 'styled', 'styled graph'); + + expect(getReferenceResult(cache, reference, 'visible')).toBe('visible graph'); + expect(getReferenceResult(cache, reference, 'styled')).toBe('styled graph'); + }); + + it('replaces an existing result and moves it to the newest cache slot', () => { + const cache = createReferenceResultCache(); + const reference = {}; + + cacheReferenceResult(cache, reference, 'first', 'stale first'); + cacheReferenceResult(cache, reference, 'second', 'second'); + cacheReferenceResult(cache, reference, 'third', 'third'); + cacheReferenceResult(cache, reference, 'fourth', 'fourth'); + cacheReferenceResult(cache, reference, 'fifth', 'fifth'); + cacheReferenceResult(cache, reference, 'sixth', 'sixth'); + cacheReferenceResult(cache, reference, 'first', 'fresh first'); + cacheReferenceResult(cache, reference, 'seventh', 'seventh'); + + expect(getReferenceResult(cache, reference, 'first')).toBe('fresh first'); + expect(getReferenceResult(cache, reference, 'second')).toBeUndefined(); + expect(getReferenceResult(cache, reference, 'seventh')).toBe('seventh'); + }); + + it('evicts the oldest entries after six cached results', () => { + const cache = createReferenceResultCache(); + const reference = {}; + + for (let index = 1; index <= 7; index += 1) { + cacheReferenceResult(cache, reference, `key-${index}`, `result-${index}`); + } + + expect(getReferenceResult(cache, reference, 'key-1')).toBeUndefined(); + expect(getReferenceResult(cache, reference, 'key-2')).toBe('result-2'); + expect(getReferenceResult(cache, reference, 'key-7')).toBe('result-7'); + }); +}); From 028e89253806feef8d48491ed3c8177b45c53591 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 22:04:31 -0700 Subject: [PATCH 179/192] test: kill filtered graph visible cache mutants --- .../search/filteredGraph/visibleCache.ts | 11 +-- .../search/filteredGraph/visibleCache.test.ts | 71 +++++++++++++++++++ 2 files changed, 73 insertions(+), 9 deletions(-) create mode 100644 packages/extension/tests/webview/search/filteredGraph/visibleCache.test.ts diff --git a/packages/extension/src/webview/search/filteredGraph/visibleCache.ts b/packages/extension/src/webview/search/filteredGraph/visibleCache.ts index 870f3906b..7bdeec705 100644 --- a/packages/extension/src/webview/search/filteredGraph/visibleCache.ts +++ b/packages/extension/src/webview/search/filteredGraph/visibleCache.ts @@ -20,18 +20,11 @@ export function cacheVisibleGraphResult( key: string, result: VisibleGraphResult, ): void { - if (cache.entries.has(key)) { - cache.entries.delete(key); - } - + cache.entries.delete(key); cache.entries.set(key, result); while (cache.entries.size > VISIBLE_GRAPH_CACHE_LIMIT) { - const oldestKey = cache.entries.keys().next().value; - if (!oldestKey) { - return; - } - + const oldestKey = cache.entries.keys().next().value as string; cache.entries.delete(oldestKey); } } diff --git a/packages/extension/tests/webview/search/filteredGraph/visibleCache.test.ts b/packages/extension/tests/webview/search/filteredGraph/visibleCache.test.ts new file mode 100644 index 000000000..e4da92a78 --- /dev/null +++ b/packages/extension/tests/webview/search/filteredGraph/visibleCache.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import type { VisibleGraphResult } from '../../../../src/shared/visibleGraph/contracts'; +import { + cacheVisibleGraphResult, + createVisibleGraphCache, +} from '../../../../src/webview/search/filteredGraph/visibleCache'; + +describe('webview/search/filteredGraph/visibleCache', () => { + it('creates an empty cache ready for visible graph results', () => { + const cache = createVisibleGraphCache(); + + expect(cache.entries).toBeInstanceOf(Map); + expect(cache.entries.size).toBe(0); + expect(cache.graphData).toBeUndefined(); + }); + + it('stores cached results by key', () => { + const cache = createVisibleGraphCache(); + const cachedResult = createVisibleResult(); + + cacheVisibleGraphResult(cache, 'visible', cachedResult); + + expect(cache.entries.get('visible')).toBe(cachedResult); + }); + + it('replaces an existing result and moves it to the newest cache slot', () => { + const cache = createVisibleGraphCache(); + const staleFirstResult = createVisibleResult(); + const freshFirstResult = createVisibleResult(); + const seventhResult = createVisibleResult(); + + cacheVisibleGraphResult(cache, 'first', staleFirstResult); + cacheVisibleGraphResult(cache, 'second', createVisibleResult()); + cacheVisibleGraphResult(cache, 'third', createVisibleResult()); + cacheVisibleGraphResult(cache, 'fourth', createVisibleResult()); + cacheVisibleGraphResult(cache, 'fifth', createVisibleResult()); + cacheVisibleGraphResult(cache, 'sixth', createVisibleResult()); + cacheVisibleGraphResult(cache, 'first', freshFirstResult); + cacheVisibleGraphResult(cache, 'seventh', seventhResult); + + expect(cache.entries.get('first')).toBe(freshFirstResult); + expect(cache.entries.has('second')).toBe(false); + expect(cache.entries.get('seventh')).toBe(seventhResult); + }); + + it('evicts the oldest entries after six cached results', () => { + const cache = createVisibleGraphCache(); + const seventhResult = createVisibleResult(); + + for (let index = 1; index <= 6; index += 1) { + cacheVisibleGraphResult(cache, `key-${index}`, createVisibleResult()); + } + + cacheVisibleGraphResult(cache, 'key-7', seventhResult); + + expect(cache.entries.size).toBe(6); + expect(cache.entries.has('key-1')).toBe(false); + expect(cache.entries.has('key-2')).toBe(true); + expect(cache.entries.get('key-7')).toBe(seventhResult); + }); +}); + +function createVisibleResult(): VisibleGraphResult { + return { + graphData: { + nodes: [], + edges: [], + }, + regexError: null, + }; +} From f6e7649e2583b5499b9688814db36d35279e3978 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 22:08:23 -0700 Subject: [PATCH 180/192] test: kill filtered graph cache key mutants --- .../search/filteredGraph/cacheKeys.test.ts | 193 ++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 packages/extension/tests/webview/search/filteredGraph/cacheKeys.test.ts diff --git a/packages/extension/tests/webview/search/filteredGraph/cacheKeys.test.ts b/packages/extension/tests/webview/search/filteredGraph/cacheKeys.test.ts new file mode 100644 index 000000000..b1503168b --- /dev/null +++ b/packages/extension/tests/webview/search/filteredGraph/cacheKeys.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from 'vitest'; +import type { + IGraphEdgeTypeDefinition, + IGraphNodeTypeDefinition, +} from '../../../../src/shared/graphControls/contracts'; +import type { IGroup } from '../../../../src/shared/settings/groups'; +import { + createLegendGraphCacheKey, + createStyledGraphCacheKey, + createVisibleGraphCacheKey, +} from '../../../../src/webview/search/filteredGraph/cacheKeys'; + +describe('webview/search/filteredGraph/cacheKeys', () => { + it('builds styled graph keys from edge colors and sorted node colors', () => { + const key = createStyledGraphCacheKey({ + edgeTypes: [ + createEdgeType('import', { defaultColor: '#93c5fd' }), + createEdgeType('call', { defaultColor: '#fca5a5' }), + ], + nodeColors: { + symbol: '#fde047', + file: '#86efac', + }, + }); + + expect(JSON.parse(key)).toEqual({ + edgeTypes: [ + ['import', '#93c5fd'], + ['call', '#fca5a5'], + ], + nodeColors: [ + ['file', '#86efac'], + ['symbol', '#fde047'], + ], + }); + }); + + it('keeps styled graph keys stable when node color insertion order changes', () => { + const edgeTypes = [createEdgeType('import', { defaultColor: '#93c5fd' })]; + + const firstKey = createStyledGraphCacheKey({ + edgeTypes, + nodeColors: { + symbol: '#fde047', + file: '#86efac', + }, + }); + const secondKey = createStyledGraphCacheKey({ + edgeTypes, + nodeColors: { + file: '#86efac', + symbol: '#fde047', + }, + }); + + expect(firstKey).toBe(secondKey); + }); + + it('builds visible graph keys from filters, search, sorted visibility, and type defaults', () => { + const key = createVisibleGraphCacheKey({ + edgeTypes: [ + createEdgeType('import', { defaultVisible: true }), + createEdgeType('call', { defaultVisible: false }), + ], + edgeVisibility: { + call: false, + import: true, + }, + filterPatterns: ['dist/**'], + nodeTypes: [ + createNodeType('file', { defaultVisible: true }), + createNodeType('symbol', { defaultVisible: false }), + ], + nodeVisibility: { + symbol: false, + file: true, + }, + searchOptions: { + matchCase: true, + regex: false, + wholeWord: true, + }, + searchQuery: 'GraphView', + showOrphans: false, + }); + + expect(JSON.parse(key)).toEqual({ + edgeTypes: [ + ['import', true], + ['call', false], + ], + edgeVisibility: [ + ['call', false], + ['import', true], + ], + filterPatterns: ['dist/**'], + nodeTypes: [ + ['file', true], + ['symbol', false], + ], + nodeVisibility: [ + ['file', true], + ['symbol', false], + ], + searchOptions: { + matchCase: true, + regex: false, + wholeWord: true, + }, + searchQuery: 'GraphView', + showOrphans: false, + }); + }); + + it('keeps visible graph keys stable when visibility insertion order changes', () => { + const options = { + edgeTypes: [createEdgeType('import', { defaultVisible: true })], + filterPatterns: ['dist/**'], + nodeTypes: [createNodeType('file', { defaultVisible: true })], + searchOptions: { + matchCase: false, + regex: false, + wholeWord: false, + }, + searchQuery: '', + showOrphans: true, + }; + + const firstKey = createVisibleGraphCacheKey({ + ...options, + edgeVisibility: { + type: true, + import: false, + }, + nodeVisibility: { + symbol: false, + file: true, + }, + }); + const secondKey = createVisibleGraphCacheKey({ + ...options, + edgeVisibility: { + import: false, + type: true, + }, + nodeVisibility: { + file: true, + symbol: false, + }, + }); + + expect(firstKey).toBe(secondKey); + }); + + it('serializes legend rules for legend graph keys', () => { + const legends: IGroup[] = [ + { + id: 'highlight-tests', + pattern: '**/*.test.ts', + color: '#f9a8d4', + target: 'node', + }, + ]; + + expect(JSON.parse(createLegendGraphCacheKey(legends))).toEqual(legends); + }); +}); + +function createEdgeType( + id: IGraphEdgeTypeDefinition['id'], + overrides: Partial = {}, +): IGraphEdgeTypeDefinition { + return { + id, + label: id, + defaultColor: '#94a3b8', + defaultVisible: true, + ...overrides, + }; +} + +function createNodeType( + id: IGraphNodeTypeDefinition['id'], + overrides: Partial = {}, +): IGraphNodeTypeDefinition { + return { + id, + label: id, + defaultColor: '#94a3b8', + defaultVisible: true, + ...overrides, + }; +} From 78d35833242e518cd4bd2e26dc2b6f222c3c9d4a Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 22:14:22 -0700 Subject: [PATCH 181/192] test: kill filtered graph color result mutants --- .../search/filteredGraph/coloredResult.ts | 6 +- .../filteredGraph/coloredResult.test.ts | 92 +++++++++++++++++++ 2 files changed, 94 insertions(+), 4 deletions(-) create mode 100644 packages/extension/tests/webview/search/filteredGraph/coloredResult.test.ts diff --git a/packages/extension/src/webview/search/filteredGraph/coloredResult.ts b/packages/extension/src/webview/search/filteredGraph/coloredResult.ts index a24cfc279..f7dee6f60 100644 --- a/packages/extension/src/webview/search/filteredGraph/coloredResult.ts +++ b/packages/extension/src/webview/search/filteredGraph/coloredResult.ts @@ -24,9 +24,7 @@ export function getColoredGraphResult({ return cached; } - const result = applyLegendRules(filteredData, legends); - if (result) { - cacheReferenceResult(cache, filteredData, key, result); - } + const result = applyLegendRules(filteredData, legends)!; + cacheReferenceResult(cache, filteredData, key, result); return result; } diff --git a/packages/extension/tests/webview/search/filteredGraph/coloredResult.test.ts b/packages/extension/tests/webview/search/filteredGraph/coloredResult.test.ts new file mode 100644 index 000000000..090daad3b --- /dev/null +++ b/packages/extension/tests/webview/search/filteredGraph/coloredResult.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest'; +import type { IGraphData } from '../../../../src/shared/graph/contracts'; +import type { IGroup } from '../../../../src/shared/settings/groups'; +import { getColoredGraphResult } from '../../../../src/webview/search/filteredGraph/coloredResult'; +import { createReferenceResultCache } from '../../../../src/webview/search/filteredGraph/referenceCache'; + +describe('webview/search/filteredGraph/coloredResult', () => { + it('returns null when there is no filtered graph data', () => { + const result = getColoredGraphResult({ + cache: createReferenceResultCache(), + filteredData: null, + key: 'legends', + legends: [], + }); + + expect(result).toBeNull(); + }); + + it('applies legend rules and caches the colored result by graph reference and key', () => { + const cache = createReferenceResultCache(); + const filteredData = createGraphData(); + const legends: IGroup[] = [ + { + id: 'source-files', + pattern: 'src/**', + color: '#f9a8d4', + }, + ]; + + const firstResult = getColoredGraphResult({ + cache, + filteredData, + key: 'source-files', + legends, + }); + const secondResult = getColoredGraphResult({ + cache, + filteredData, + key: 'source-files', + legends, + }); + + expect(firstResult).not.toBe(filteredData); + expect(firstResult?.nodes[0]?.color).toBe('#f9a8d4'); + expect(secondResult).toBe(firstResult); + }); + + it('keeps cached colored results isolated by key', () => { + const cache = createReferenceResultCache(); + const filteredData = createGraphData(); + const firstLegend = createLegend('source-files', '#f9a8d4'); + const secondLegend = createLegend('all-files', '#67e8f9'); + + const firstResult = getColoredGraphResult({ + cache, + filteredData, + key: 'source-files', + legends: [firstLegend], + }); + const secondResult = getColoredGraphResult({ + cache, + filteredData, + key: 'all-files', + legends: [secondLegend], + }); + + expect(firstResult?.nodes[0]?.color).toBe('#f9a8d4'); + expect(secondResult?.nodes[0]?.color).toBe('#67e8f9'); + expect(secondResult).not.toBe(firstResult); + }); +}); + +function createGraphData(): IGraphData { + return { + nodes: [ + { + id: 'src/app.ts', + label: 'app.ts', + color: '#94a3b8', + }, + ], + edges: [], + }; +} + +function createLegend(id: string, color: string): IGroup { + return { + id, + pattern: 'src/**', + color, + }; +} From 5b696312230a109cf40452bd17d02121392c264e Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 22:18:16 -0700 Subject: [PATCH 182/192] test: kill filtered graph style result mutants --- .../search/filteredGraph/styledResult.test.ts | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 packages/extension/tests/webview/search/filteredGraph/styledResult.test.ts diff --git a/packages/extension/tests/webview/search/filteredGraph/styledResult.test.ts b/packages/extension/tests/webview/search/filteredGraph/styledResult.test.ts new file mode 100644 index 000000000..fa088fb9e --- /dev/null +++ b/packages/extension/tests/webview/search/filteredGraph/styledResult.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest'; +import type { IGraphData } from '../../../../src/shared/graph/contracts'; +import type { IGraphEdgeTypeDefinition } from '../../../../src/shared/graphControls/contracts'; +import { createReferenceResultCache } from '../../../../src/webview/search/filteredGraph/referenceCache'; +import { getStyledGraphResult } from '../../../../src/webview/search/filteredGraph/styledResult'; + +describe('webview/search/filteredGraph/styledResult', () => { + it('returns null when there is no visible graph data', () => { + const result = getStyledGraphResult({ + cache: createReferenceResultCache(), + edgeTypes: [], + graph: null, + key: 'styles', + nodeColors: {}, + }); + + expect(result).toBeNull(); + }); + + it('applies node and edge colors and caches the styled result by graph reference and key', () => { + const cache = createReferenceResultCache(); + const graph = createGraphData(); + const edgeTypes = [createEdgeType('import', '#38bdf8')]; + + const firstResult = getStyledGraphResult({ + cache, + edgeTypes, + graph, + key: 'blue-files', + nodeColors: { + file: '#a7f3d0', + }, + }); + const secondResult = getStyledGraphResult({ + cache, + edgeTypes, + graph, + key: 'blue-files', + nodeColors: { + file: '#a7f3d0', + }, + }); + + expect(firstResult).not.toBe(graph); + expect(firstResult?.nodes[0]).toMatchObject({ + color: '#a7f3d0', + nodeType: 'file', + }); + expect(firstResult?.edges[0]?.color).toBe('#38bdf8'); + expect(secondResult).toBe(firstResult); + }); + + it('keeps cached styled results isolated by key', () => { + const cache = createReferenceResultCache(); + const graph = createGraphData(); + + const firstResult = getStyledGraphResult({ + cache, + edgeTypes: [createEdgeType('import', '#38bdf8')], + graph, + key: 'blue-imports', + nodeColors: { + file: '#a7f3d0', + }, + }); + const secondResult = getStyledGraphResult({ + cache, + edgeTypes: [createEdgeType('import', '#fca5a5')], + graph, + key: 'red-imports', + nodeColors: { + file: '#fde047', + }, + }); + + expect(firstResult?.nodes[0]?.color).toBe('#a7f3d0'); + expect(firstResult?.edges[0]?.color).toBe('#38bdf8'); + expect(secondResult?.nodes[0]?.color).toBe('#fde047'); + expect(secondResult?.edges[0]?.color).toBe('#fca5a5'); + expect(secondResult).not.toBe(firstResult); + }); +}); + +function createGraphData(): IGraphData { + return { + nodes: [ + { + id: 'src/app.ts', + label: 'app.ts', + color: '#94a3b8', + }, + { + id: 'src/util.ts', + label: 'util.ts', + color: '#94a3b8', + }, + ], + edges: [ + { + id: 'src/app.ts->src/util.ts#import', + from: 'src/app.ts', + to: 'src/util.ts', + kind: 'import', + sources: [], + }, + ], + }; +} + +function createEdgeType( + id: IGraphEdgeTypeDefinition['id'], + defaultColor: string, +): IGraphEdgeTypeDefinition { + return { + id, + label: id, + defaultColor, + defaultVisible: true, + }; +} From 54b42db004612683634653db76fa3208ec53b29f Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 22:22:47 -0700 Subject: [PATCH 183/192] test: kill filtered graph visible result mutants --- .../filteredGraph/visibleResult.test.ts | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 packages/extension/tests/webview/search/filteredGraph/visibleResult.test.ts diff --git a/packages/extension/tests/webview/search/filteredGraph/visibleResult.test.ts b/packages/extension/tests/webview/search/filteredGraph/visibleResult.test.ts new file mode 100644 index 000000000..982a9b872 --- /dev/null +++ b/packages/extension/tests/webview/search/filteredGraph/visibleResult.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest'; +import type { SearchOptions } from '../../../../src/webview/components/searchBar/field/model'; +import type { IGraphData } from '../../../../src/shared/graph/contracts'; +import { createVisibleGraphCache } from '../../../../src/webview/search/filteredGraph/visibleCache'; +import { getVisibleGraphResult } from '../../../../src/webview/search/filteredGraph/visibleResult'; + +describe('webview/search/filteredGraph/visibleResult', () => { + it('returns and caches an empty visible result when there is no graph data', () => { + const cache = createVisibleGraphCache(); + + const result = getVisibleGraphResult({ + ...createVisibleGraphOptions(), + cache, + graphData: null, + key: 'empty', + }); + + expect(result).toEqual({ + graphData: null, + regexError: null, + }); + expect(cache.graphData).toBeNull(); + expect(cache.entries.get('empty')).toBe(result); + }); + + it('derives visible graph data from search and filter settings, then reuses the cached result', () => { + const cache = createVisibleGraphCache(); + const graphData = createGraphData(['src/app.ts', 'src/util.ts', 'src/hidden.ts']); + + const firstResult = getVisibleGraphResult({ + ...createVisibleGraphOptions(), + cache, + filterPatterns: ['src/hidden.ts'], + graphData, + key: 'app-only', + searchQuery: 'app', + }); + const secondResult = getVisibleGraphResult({ + ...createVisibleGraphOptions(), + cache, + filterPatterns: ['src/hidden.ts'], + graphData, + key: 'app-only', + searchQuery: 'app', + }); + + expect(cache.graphData).toBe(graphData); + expect(firstResult.graphData?.nodes.map((node) => node.id)).toEqual(['src/app.ts']); + expect(firstResult.graphData?.edges).toEqual([]); + expect(secondResult).toBe(firstResult); + }); + + it('clears cached visible results when the graph data reference changes', () => { + const cache = createVisibleGraphCache(); + const firstGraph = createGraphData(['src/app.ts']); + const secondGraph = createGraphData(['src/component.ts']); + + getVisibleGraphResult({ + ...createVisibleGraphOptions(), + cache, + graphData: firstGraph, + key: 'same-key', + }); + const secondResult = getVisibleGraphResult({ + ...createVisibleGraphOptions(), + cache, + graphData: secondGraph, + key: 'same-key', + }); + + expect(cache.graphData).toBe(secondGraph); + expect(secondResult.graphData?.nodes.map((node) => node.id)).toEqual(['src/component.ts']); + }); +}); + +function createVisibleGraphOptions(): { + edgeTypes: []; + edgeVisibility: Record; + filterPatterns: readonly string[]; + nodeTypes: []; + nodeVisibility: Record; + searchOptions: SearchOptions; + searchQuery: string; + showOrphans: boolean; +} { + return { + edgeTypes: [], + edgeVisibility: {}, + filterPatterns: [], + nodeTypes: [], + nodeVisibility: {}, + searchOptions: { + matchCase: false, + regex: false, + wholeWord: false, + }, + searchQuery: '', + showOrphans: true, + }; +} + +function createGraphData(nodeIds: string[]): IGraphData { + return { + nodes: nodeIds.map((id) => ({ + id, + label: id.split('/').at(-1) ?? id, + color: '#94a3b8', + })), + edges: nodeIds.length > 1 + ? [ + { + id: `${nodeIds[0]}->${nodeIds[1]}#import`, + from: nodeIds[0] ?? '', + to: nodeIds[1] ?? '', + kind: 'import', + sources: [], + }, + ] + : [], + }; +} From 933dc3dee1bc497ca13d0182928ac2a8e67c8eb3 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 22:27:14 -0700 Subject: [PATCH 184/192] test: kill graph metric update mutants --- .../graphDataMessage/metricUpdates.test.ts | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 packages/extension/tests/webview/store/messageHandlers/graphDataMessage/metricUpdates.test.ts diff --git a/packages/extension/tests/webview/store/messageHandlers/graphDataMessage/metricUpdates.test.ts b/packages/extension/tests/webview/store/messageHandlers/graphDataMessage/metricUpdates.test.ts new file mode 100644 index 000000000..074af0866 --- /dev/null +++ b/packages/extension/tests/webview/store/messageHandlers/graphDataMessage/metricUpdates.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest'; +import type { + GraphNode, + GraphNodeMetricsUpdate, +} from '../../../../../src/webview/store/messageHandlers/graphDataMessage/contracts'; +import { + applyMetricUpdates, + applyMetricUpdatesInPlace, + nodeSizeModeUsesNodeMetrics, +} from '../../../../../src/webview/store/messageHandlers/graphDataMessage/metricUpdates'; + +describe('webview/store/messageHandlers/graphDataMessage/metricUpdates', () => { + it('uses node metrics only for file size and churn node sizing modes', () => { + expect(nodeSizeModeUsesNodeMetrics('file-size')).toBe(true); + expect(nodeSizeModeUsesNodeMetrics('churn')).toBe(true); + expect(nodeSizeModeUsesNodeMetrics('connections')).toBe(false); + expect(nodeSizeModeUsesNodeMetrics('uniform')).toBe(false); + }); + + it('applies changed metric updates in place', () => { + const appNode = createNode('src/app.ts', { fileSize: 100, churn: 1 }); + const libNode = createNode('src/lib.ts', { fileSize: 50, churn: 3 }); + const graphData = { nodes: [appNode, libNode] }; + + const changed = applyMetricUpdatesInPlace(graphData, createUpdates([ + { id: 'src/app.ts', fileSize: 120, churn: 1 }, + { id: 'src/lib.ts', fileSize: 50, churn: 4 }, + ])); + + expect(changed).toBe(true); + expect(graphData.nodes[0]).toBe(appNode); + expect(graphData.nodes[0]).toMatchObject({ fileSize: 120, churn: 1 }); + expect(graphData.nodes[1]).toBe(libNode); + expect(graphData.nodes[1]).toMatchObject({ fileSize: 50, churn: 4 }); + }); + + it('does not change nodes in place when updates are missing or metrics already match', () => { + const appNode = createNode('src/app.ts', { fileSize: 100, churn: 1 }); + const graphData = { nodes: [appNode] }; + + const changed = applyMetricUpdatesInPlace(graphData, createUpdates([ + { id: 'src/app.ts', fileSize: 100, churn: 1 }, + { id: 'src/missing.ts', fileSize: 999, churn: 999 }, + ])); + + expect(changed).toBe(false); + expect(graphData.nodes).toEqual([appNode]); + }); + + it('returns new node objects only for changed immutable metric updates', () => { + const appNode = createNode('src/app.ts', { fileSize: 100, churn: 1 }); + const libNode = createNode('src/lib.ts', { fileSize: 50, churn: 3 }); + + const result = applyMetricUpdates([appNode, libNode], createUpdates([ + { id: 'src/app.ts', fileSize: 100, churn: 2 }, + ])); + + expect(result.changed).toBe(true); + expect(result.nodes[0]).not.toBe(appNode); + expect(result.nodes[0]).toMatchObject({ fileSize: 100, churn: 2 }); + expect(result.nodes[1]).toBe(libNode); + expect(appNode).toMatchObject({ fileSize: 100, churn: 1 }); + }); + + it('preserves immutable node objects when metric updates do not change values', () => { + const appNode = createNode('src/app.ts', { fileSize: 100, churn: 1 }); + + const result = applyMetricUpdates([appNode], createUpdates([ + { id: 'src/app.ts', fileSize: 100, churn: 1 }, + { id: 'src/missing.ts', fileSize: 999, churn: 999 }, + ])); + + expect(result.changed).toBe(false); + expect(result.nodes).toEqual([appNode]); + expect(result.nodes[0]).toBe(appNode); + }); +}); + +function createNode( + id: string, + metrics: Pick, +): GraphNode { + return { + id, + label: id, + color: '#94a3b8', + ...metrics, + }; +} + +function createUpdates( + updates: GraphNodeMetricsUpdate[], +): ReadonlyMap { + return new Map(updates.map((update) => [update.id, update])); +} From a2411e7835b2431cb1e055c2aa59a6b193a61db7 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 22:34:32 -0700 Subject: [PATCH 185/192] test: kill duplicate graph payload mutants --- .../graphDataMessage/duplicate.ts | 6 +- .../graphDataMessage/duplicate.test.ts | 172 ++++++++++++++++++ 2 files changed, 177 insertions(+), 1 deletion(-) create mode 100644 packages/extension/tests/webview/store/messageHandlers/graphDataMessage/duplicate.test.ts diff --git a/packages/extension/src/webview/store/messageHandlers/graphDataMessage/duplicate.ts b/packages/extension/src/webview/store/messageHandlers/graphDataMessage/duplicate.ts index 320977d11..5e0985a06 100644 --- a/packages/extension/src/webview/store/messageHandlers/graphDataMessage/duplicate.ts +++ b/packages/extension/src/webview/store/messageHandlers/graphDataMessage/duplicate.ts @@ -5,7 +5,11 @@ export function shouldSkipDuplicateGraphData( state: ReturnType>, payload: IGraphData, ): boolean { - if (!state.graphData || state.graphIsIndexing || !areGraphDataPayloadsEqual(state.graphData, payload)) { + if ( + !state.graphData + || state.graphIsIndexing + || areGraphDataPayloadsEqual(state.graphData, payload) === false + ) { return false; } diff --git a/packages/extension/tests/webview/store/messageHandlers/graphDataMessage/duplicate.test.ts b/packages/extension/tests/webview/store/messageHandlers/graphDataMessage/duplicate.test.ts new file mode 100644 index 000000000..2da5bcbfa --- /dev/null +++ b/packages/extension/tests/webview/store/messageHandlers/graphDataMessage/duplicate.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from 'vitest'; +import type { IGraphData } from '../../../../../src/shared/graph/contracts'; +import { shouldSkipDuplicateGraphData } from '../../../../../src/webview/store/messageHandlers/graphDataMessage/duplicate'; +import { createState } from '../graph/fixture'; + +describe('webview/store/messageHandlers/graphDataMessage/duplicate', () => { + it('skips an equal graph payload after bootstrap has settled', () => { + const payload = createGraphData(); + const state = createState({ + awaitingInitialBootstrap: false, + bootstrapComplete: true, + graphData: cloneGraphData(payload), + isLoading: false, + }); + + expect(shouldSkipDuplicateGraphData(state, payload)).toBe(true); + }); + + it('skips an equal graph payload while waiting for initial bootstrap completion', () => { + const payload = createGraphData(); + const state = createState({ + awaitingInitialBootstrap: true, + bootstrapComplete: false, + graphData: cloneGraphData(payload), + isLoading: true, + }); + + expect(shouldSkipDuplicateGraphData(state, payload)).toBe(true); + }); + + it('does not skip when there is no current graph data', () => { + const state = createState({ + awaitingInitialBootstrap: false, + bootstrapComplete: true, + graphData: null, + isLoading: false, + }); + + expect(shouldSkipDuplicateGraphData(state, createGraphData())).toBe(false); + }); + + it('does not skip while graph indexing is active', () => { + const payload = createGraphData(); + const state = createState({ + awaitingInitialBootstrap: false, + bootstrapComplete: true, + graphData: cloneGraphData(payload), + graphIsIndexing: true, + isLoading: false, + }); + + expect(shouldSkipDuplicateGraphData(state, payload)).toBe(false); + }); + + it('does not skip when bootstrap has not settled into a duplicate-safe state', () => { + const payload = createGraphData(); + + expect(shouldSkipDuplicateGraphData( + createState({ + awaitingInitialBootstrap: false, + bootstrapComplete: true, + graphData: cloneGraphData(payload), + isLoading: true, + }), + payload, + )).toBe(false); + expect(shouldSkipDuplicateGraphData( + createState({ + awaitingInitialBootstrap: true, + bootstrapComplete: true, + graphData: cloneGraphData(payload), + isLoading: false, + }), + payload, + )).toBe(false); + expect(shouldSkipDuplicateGraphData( + createState({ + awaitingInitialBootstrap: false, + bootstrapComplete: false, + graphData: cloneGraphData(payload), + isLoading: false, + }), + payload, + )).toBe(false); + }); + + it('does not skip when node counts differ', () => { + const payload = createGraphData(); + const currentGraphData = createGraphData(['src/app.ts', 'src/extra.ts']); + spoofSerializedGraphData(currentGraphData, payload); + + expect(shouldSkipDuplicateGraphData( + createSettledState(currentGraphData), + payload, + )).toBe(false); + }); + + it('does not skip when edge counts differ', () => { + const payload = createGraphData(); + const currentGraphData = createGraphData(['src/app.ts'], true); + spoofSerializedGraphData(currentGraphData, payload); + + expect(shouldSkipDuplicateGraphData( + createSettledState(currentGraphData), + payload, + )).toBe(false); + }); + + it('does not skip when graph payload content differs', () => { + const payload = createGraphData(); + const currentGraphData = createGraphData(['src/other.ts']); + + expect(shouldSkipDuplicateGraphData( + createSettledState(currentGraphData), + payload, + )).toBe(false); + }); + + it('does not skip when graph payload equality cannot be serialized', () => { + const payload = createGraphData(); + const currentGraphData = createGraphData(); + const circularMetadata: Record = {}; + circularMetadata.self = circularMetadata; + currentGraphData.nodes[0]!.metadata = circularMetadata as never; + + expect(shouldSkipDuplicateGraphData( + createSettledState(currentGraphData), + payload, + )).toBe(false); + }); +}); + +function createSettledState(graphData: IGraphData): ReturnType { + return createState({ + awaitingInitialBootstrap: false, + bootstrapComplete: true, + graphData, + isLoading: false, + }); +} + +function createGraphData( + nodeIds: string[] = ['src/app.ts'], + includeEdge = false, +): IGraphData { + return { + nodes: nodeIds.map((id) => ({ + id, + label: id, + color: '#94a3b8', + })), + edges: includeEdge + ? [ + { + id: 'src/app.ts->src/lib.ts#import', + from: 'src/app.ts', + to: 'src/lib.ts', + kind: 'import', + sources: [], + }, + ] + : [], + }; +} + +function cloneGraphData(graphData: IGraphData): IGraphData { + return JSON.parse(JSON.stringify(graphData)) as IGraphData; +} + +function spoofSerializedGraphData(graphData: IGraphData, serializedAs: IGraphData): void { + (graphData as IGraphData & { toJSON: () => IGraphData }).toJSON = () => serializedAs; +} From eee2fc4d841edf4df1ccc00a4d7e4cfeec5736ce Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 22:37:59 -0700 Subject: [PATCH 186/192] test: kill graph bootstrap mutants --- .../graphDataMessage/bootstrap.test.ts | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 packages/extension/tests/webview/store/messageHandlers/graphDataMessage/bootstrap.test.ts diff --git a/packages/extension/tests/webview/store/messageHandlers/graphDataMessage/bootstrap.test.ts b/packages/extension/tests/webview/store/messageHandlers/graphDataMessage/bootstrap.test.ts new file mode 100644 index 000000000..a9bfd322b --- /dev/null +++ b/packages/extension/tests/webview/store/messageHandlers/graphDataMessage/bootstrap.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { handleAppBootstrapComplete } from '../../../../../src/webview/store/messageHandlers/graphDataMessage/bootstrap'; +import { createState } from '../graph/fixture'; + +describe('webview/store/messageHandlers/graphDataMessage/bootstrap', () => { + it('settles loading when app bootstrap completes after graph data is ready', () => { + const state = createState({ + awaitingInitialBootstrap: true, + graphData: { + nodes: [{ id: 'src/app.ts', label: 'App', color: '#94a3b8' }], + edges: [], + }, + isLoading: true, + }); + + expect(handleAppBootstrapComplete( + { type: 'APP_BOOTSTRAP_COMPLETE' }, + { getState: () => state }, + )).toEqual({ + bootstrapComplete: true, + awaitingInitialBootstrap: false, + isLoading: false, + }); + }); + + it('preserves loading state when app bootstrap completes before graph data arrives', () => { + const state = createState({ + awaitingInitialBootstrap: true, + graphData: null, + isLoading: true, + }); + + expect(handleAppBootstrapComplete( + { type: 'APP_BOOTSTRAP_COMPLETE' }, + { getState: () => state }, + )).toEqual({ + bootstrapComplete: true, + awaitingInitialBootstrap: true, + isLoading: true, + }); + }); +}); From 4839b754fda115bb0b308b6e1fb4f002201d351c Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 22:41:23 -0700 Subject: [PATCH 187/192] test: kill graph payload handler mutants --- .../graphDataMessage/payload.test.ts | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 packages/extension/tests/webview/store/messageHandlers/graphDataMessage/payload.test.ts diff --git a/packages/extension/tests/webview/store/messageHandlers/graphDataMessage/payload.test.ts b/packages/extension/tests/webview/store/messageHandlers/graphDataMessage/payload.test.ts new file mode 100644 index 000000000..146a1b571 --- /dev/null +++ b/packages/extension/tests/webview/store/messageHandlers/graphDataMessage/payload.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest'; +import type { IGraphData } from '../../../../../src/shared/graph/contracts'; +import { handleGraphDataUpdated } from '../../../../../src/webview/store/messageHandlers/graphDataMessage/payload'; +import { createState } from '../graph/fixture'; + +describe('webview/store/messageHandlers/graphDataMessage/payload', () => { + it('maps graph payload updates without requiring current state context', () => { + const payload = createGraphData(); + + expect(handleGraphDataUpdated({ + type: 'GRAPH_DATA_UPDATED', + payload, + })).toEqual({ + graphData: payload, + isLoading: false, + graphIsIndexing: false, + graphIndexProgress: null, + }); + }); + + it('skips duplicate graph payloads when duplicate-safe bootstrap state has settled', () => { + const payload = createGraphData(); + const state = createState({ + awaitingInitialBootstrap: false, + bootstrapComplete: true, + graphData: cloneGraphData(payload), + graphIsIndexing: false, + isLoading: false, + }); + + expect(handleGraphDataUpdated( + { type: 'GRAPH_DATA_UPDATED', payload }, + { getState: () => state }, + )).toBeUndefined(); + }); + + it('keeps loading while initial bootstrap is still waiting for app bootstrap completion', () => { + const payload = createGraphData(); + const state = createState({ + awaitingInitialBootstrap: true, + bootstrapComplete: false, + graphData: null, + graphIndexProgress: { phase: 'Updating Graph View', current: 1, total: 2 }, + graphIsIndexing: true, + isLoading: true, + }); + + expect(handleGraphDataUpdated( + { type: 'GRAPH_DATA_UPDATED', payload }, + { getState: () => state }, + )).toEqual({ + graphData: payload, + isLoading: true, + graphIsIndexing: false, + graphIndexProgress: null, + }); + }); + + it('settles initial bootstrap when graph data arrives after app bootstrap completes', () => { + const payload = createGraphData(); + const state = createState({ + awaitingInitialBootstrap: true, + bootstrapComplete: true, + graphData: null, + graphIndexProgress: { phase: 'Updating Graph View', current: 1, total: 2 }, + graphIsIndexing: true, + isLoading: true, + }); + + expect(handleGraphDataUpdated( + { type: 'GRAPH_DATA_UPDATED', payload }, + { getState: () => state }, + )).toEqual({ + graphData: payload, + awaitingInitialBootstrap: false, + isLoading: false, + graphIsIndexing: false, + graphIndexProgress: null, + }); + }); +}); + +function createGraphData(): IGraphData { + return { + nodes: [{ id: 'src/app.ts', label: 'App', color: '#94a3b8' }], + edges: [], + }; +} + +function cloneGraphData(graphData: IGraphData): IGraphData { + return JSON.parse(JSON.stringify(graphData)) as IGraphData; +} From a8a28b71312e5fdf6741059503557859833ba200 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 22:47:57 -0700 Subject: [PATCH 188/192] test: kill graph metrics handler mutants --- .../graphDataMessage/metrics.test.ts | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 packages/extension/tests/webview/store/messageHandlers/graphDataMessage/metrics.test.ts diff --git a/packages/extension/tests/webview/store/messageHandlers/graphDataMessage/metrics.test.ts b/packages/extension/tests/webview/store/messageHandlers/graphDataMessage/metrics.test.ts new file mode 100644 index 000000000..b51bfb042 --- /dev/null +++ b/packages/extension/tests/webview/store/messageHandlers/graphDataMessage/metrics.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from 'vitest'; +import type { IGraphData } from '../../../../../src/shared/graph/contracts'; +import type { GraphNodeMetricsUpdateMessage } from '../../../../../src/webview/store/messageHandlers/graphDataMessage/contracts'; +import { handleGraphNodeMetricsUpdated } from '../../../../../src/webview/store/messageHandlers/graphDataMessage/metrics'; +import { createState } from '../graph/fixture'; + +describe('webview/store/messageHandlers/graphDataMessage/metrics', () => { + it('ignores metric updates when there is no current state context', () => { + expect(handleGraphNodeMetricsUpdated(createMetricsMessage([ + { id: 'src/app.ts', fileSize: 120, churn: 2 }, + ]))).toBeUndefined(); + }); + + it('ignores metric updates when graph data has not arrived', () => { + const state = createState({ graphData: null }); + + expect(handleGraphNodeMetricsUpdated( + createMetricsMessage([{ id: 'src/app.ts', fileSize: 120, churn: 2 }]), + { getState: () => state }, + )).toBeUndefined(); + }); + + it('applies metric updates in place when node sizing does not use metrics', () => { + const graphData = createGraphData(); + const state = createState({ + awaitingInitialBootstrap: true, + bootstrapComplete: false, + graphData, + graphIndexProgress: { phase: 'Updating Graph View', current: 1, total: 2 }, + graphIsIndexing: true, + isLoading: true, + nodeSizeMode: 'connections', + }); + + const result = handleGraphNodeMetricsUpdated( + createMetricsMessage([{ id: 'src/app.ts', fileSize: 120, churn: 2 }]), + { getState: () => state }, + ); + + expect(result).toEqual({ + isLoading: true, + graphIsIndexing: false, + graphIndexProgress: null, + }); + expect(state.graphData).toBe(graphData); + expect(graphData.nodes[0]).toMatchObject({ fileSize: 120, churn: 2 }); + }); + + it('returns new graph data when metric sizing depends on changed node metrics', () => { + const graphData = createGraphData(); + const state = createState({ + awaitingInitialBootstrap: false, + bootstrapComplete: true, + graphData, + graphIndexProgress: { phase: 'Updating Graph View', current: 1, total: 2 }, + graphIsIndexing: true, + isLoading: true, + nodeSizeMode: 'file-size', + }); + + const result = handleGraphNodeMetricsUpdated( + createMetricsMessage([{ id: 'src/app.ts', fileSize: 120, churn: 1 }]), + { getState: () => state }, + ); + + expect(result).toEqual({ + graphData: { + ...graphData, + nodes: [ + { id: 'src/app.ts', label: 'App', color: '#94a3b8', fileSize: 120, churn: 1 }, + graphData.nodes[1], + ], + }, + isLoading: false, + graphIsIndexing: false, + graphIndexProgress: null, + }); + expect(result?.graphData).not.toBe(graphData); + expect(graphData.nodes[0]).toMatchObject({ fileSize: 100, churn: 1 }); + }); + + it('settles indexing without replacing graph data when metric sizing values are unchanged', () => { + const graphData = createGraphData(); + const state = createState({ + graphData, + graphIndexProgress: { phase: 'Updating Graph View', current: 1, total: 2 }, + graphIsIndexing: true, + nodeSizeMode: 'churn', + }); + + expect(handleGraphNodeMetricsUpdated( + createMetricsMessage([{ id: 'src/app.ts', fileSize: 100, churn: 1 }]), + { getState: () => state }, + )).toEqual({ + graphIsIndexing: false, + graphIndexProgress: null, + }); + }); + + it('does not enter loading when bootstrap is incomplete but initial bootstrap is not pending', () => { + const graphData = createGraphData(); + const state = createState({ + awaitingInitialBootstrap: false, + bootstrapComplete: false, + graphData, + graphIndexProgress: { phase: 'Updating Graph View', current: 1, total: 2 }, + graphIsIndexing: true, + isLoading: false, + nodeSizeMode: 'connections', + }); + + expect(handleGraphNodeMetricsUpdated( + createMetricsMessage([{ id: 'src/app.ts', fileSize: 120, churn: 2 }]), + { getState: () => state }, + )).toEqual({ + isLoading: false, + graphIsIndexing: false, + graphIndexProgress: null, + }); + }); +}); + +function createGraphData(): IGraphData { + return { + nodes: [ + { id: 'src/app.ts', label: 'App', color: '#94a3b8', fileSize: 100, churn: 1 }, + { id: 'src/lib.ts', label: 'Lib', color: '#94a3b8', fileSize: 50, churn: 3 }, + ], + edges: [ + { + id: 'src/app.ts->src/lib.ts#import', + from: 'src/app.ts', + to: 'src/lib.ts', + kind: 'import', + sources: [], + }, + ], + }; +} + +function createMetricsMessage( + nodes: GraphNodeMetricsUpdateMessage['payload']['nodes'], +): GraphNodeMetricsUpdateMessage { + return { + type: 'GRAPH_NODE_METRICS_UPDATED', + payload: { nodes }, + }; +} From 163e11928f65275746dd1442c0ad8dcd84d8513d Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 22:51:54 -0700 Subject: [PATCH 189/192] test: kill discovery path matching mutants --- packages/core/tests/discovery/pathMatching.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/core/tests/discovery/pathMatching.test.ts b/packages/core/tests/discovery/pathMatching.test.ts index dc5e80719..1f3e32d8b 100644 --- a/packages/core/tests/discovery/pathMatching.test.ts +++ b/packages/core/tests/discovery/pathMatching.test.ts @@ -36,6 +36,10 @@ describe('pathMatching', () => { expect(matchesAnyPattern('src/app.ts', ['*.ts'])).toBe(true); }); + it('matches when any pattern matches the normalized path', () => { + expect(matchesAnyPattern('src/app.ts', ['*.md', '*.ts'])).toBe(true); + }); + it('matches hidden files when dot matching is enabled', () => { expect(matchesAnyPattern('config/.env', ['*.env'])).toBe(true); }); From 60d50e75cf91937c0af4c45f4f4a66648949d048 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 22:55:19 -0700 Subject: [PATCH 190/192] test: kill discovery helper mutants --- .../tests/discovery/knownDirectory.test.ts | 29 +++++++++++++++++++ .../tests/discovery/pathNormalization.test.ts | 12 ++++++++ 2 files changed, 41 insertions(+) create mode 100644 packages/core/tests/discovery/knownDirectory.test.ts create mode 100644 packages/core/tests/discovery/pathNormalization.test.ts diff --git a/packages/core/tests/discovery/knownDirectory.test.ts b/packages/core/tests/discovery/knownDirectory.test.ts new file mode 100644 index 000000000..c31559e21 --- /dev/null +++ b/packages/core/tests/discovery/knownDirectory.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { shouldSkipKnownDirectory } from '../../src/discovery/knownDirectory'; + +describe('discovery/knownDirectory', () => { + it('skips exact generated and repository metadata directories', () => { + expect(shouldSkipKnownDirectory('node_modules')).toBe(true); + expect(shouldSkipKnownDirectory('.git')).toBe(true); + expect(shouldSkipKnownDirectory('.codegraphy')).toBe(true); + }); + + it('skips descendants of generated and repository metadata directories', () => { + expect(shouldSkipKnownDirectory('node_modules/react')).toBe(true); + expect(shouldSkipKnownDirectory('.git/objects')).toBe(true); + expect(shouldSkipKnownDirectory('.codegraphy/graph.lbug')).toBe(true); + }); + + it('normalizes Windows separators before checking known directories', () => { + expect(shouldSkipKnownDirectory('node_modules\\react')).toBe(true); + expect(shouldSkipKnownDirectory('.git\\objects')).toBe(true); + expect(shouldSkipKnownDirectory('.codegraphy\\graph.lbug')).toBe(true); + }); + + it('does not skip similarly named or nested directories', () => { + expect(shouldSkipKnownDirectory('packages/demo/node_modules')).toBe(false); + expect(shouldSkipKnownDirectory('.github')).toBe(false); + expect(shouldSkipKnownDirectory('node_modules_cache')).toBe(false); + expect(shouldSkipKnownDirectory('.codegraphy-cache')).toBe(false); + }); +}); diff --git a/packages/core/tests/discovery/pathNormalization.test.ts b/packages/core/tests/discovery/pathNormalization.test.ts new file mode 100644 index 000000000..413dafb46 --- /dev/null +++ b/packages/core/tests/discovery/pathNormalization.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest'; +import { normalizeDiscoveryPath } from '../../src/discovery/pathNormalization'; + +describe('discovery/pathNormalization', () => { + it('converts Windows path separators to forward slashes', () => { + expect(normalizeDiscoveryPath('src\\nested\\file.ts')).toBe('src/nested/file.ts'); + }); + + it('leaves normalized paths unchanged', () => { + expect(normalizeDiscoveryPath('src/nested/file.ts')).toBe('src/nested/file.ts'); + }); +}); From 2b34ab58713c2db87aa68ca75a7a8ba77a1a1a48 Mon Sep 17 00:00:00 2001 From: joesobo Date: Tue, 23 Jun 2026 23:00:46 -0700 Subject: [PATCH 191/192] test: kill discovery fast exclude mutants --- .../discovery/defaultExcludedPath.test.ts | 61 +++++++++++++++++++ .../tests/discovery/pathExclusions.test.ts | 24 ++++++++ 2 files changed, 85 insertions(+) create mode 100644 packages/core/tests/discovery/defaultExcludedPath.test.ts create mode 100644 packages/core/tests/discovery/pathExclusions.test.ts diff --git a/packages/core/tests/discovery/defaultExcludedPath.test.ts b/packages/core/tests/discovery/defaultExcludedPath.test.ts new file mode 100644 index 000000000..45e1a3449 --- /dev/null +++ b/packages/core/tests/discovery/defaultExcludedPath.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import { isDefaultExcludedPath } from '../../src/discovery/defaultExcludedPath'; +import { DEFAULT_EXCLUDE } from '../../src/discovery/pathExclusions'; + +describe('discovery/defaultExcludedPath', () => { + it('keeps the default exclude pattern contract in sync with fast excludes', () => { + expect(DEFAULT_EXCLUDE).toEqual([ + '**/node_modules/**', + '**/dist/**', + '**/build/**', + '**/out/**', + '**/.git/**', + '**/.codegraphy/**', + '**/.turbo', + '**/.turbo/**', + '**/.worktrees', + '**/.worktrees/**', + '**/coverage/**', + '**/.DS_Store', + '**/*.min.js', + '**/*.bundle.js', + '**/*.map', + ]); + }); + + it('excludes generated and metadata path segments anywhere in the path', () => { + expect(isDefaultExcludedPath('node_modules/react/index.js')).toBe(true); + expect(isDefaultExcludedPath('packages/app/dist/index.js')).toBe(true); + expect(isDefaultExcludedPath('packages/app/build/index.js')).toBe(true); + expect(isDefaultExcludedPath('packages/app/out/index.js')).toBe(true); + expect(isDefaultExcludedPath('.git/objects/HEAD')).toBe(true); + expect(isDefaultExcludedPath('.codegraphy/graph.lbug')).toBe(true); + expect(isDefaultExcludedPath('packages/app/.turbo/cache')).toBe(true); + expect(isDefaultExcludedPath('.worktrees/speed-up-codegraphy/src/app.ts')).toBe(true); + expect(isDefaultExcludedPath('coverage/lcov.info')).toBe(true); + }); + + it('normalizes Windows separators before checking generated segments', () => { + expect(isDefaultExcludedPath('packages\\app\\dist\\index.js')).toBe(true); + expect(isDefaultExcludedPath('.codegraphy\\graph.lbug')).toBe(true); + expect(isDefaultExcludedPath('.worktrees\\branch\\src\\app.ts')).toBe(true); + }); + + it('excludes generated basenames and artifact suffixes', () => { + expect(isDefaultExcludedPath('src/.DS_Store')).toBe(true); + expect(isDefaultExcludedPath('src/vendor.min.js')).toBe(true); + expect(isDefaultExcludedPath('src/generated/.DS_Store')).toBe(true); + expect(isDefaultExcludedPath('src/assets/vendor.min.js')).toBe(true); + expect(isDefaultExcludedPath('src/vendor.bundle.js')).toBe(true); + expect(isDefaultExcludedPath('src/app.js.map')).toBe(true); + expect(isDefaultExcludedPath('src/app.js.map/')).toBe(true); + }); + + it('does not exclude similarly named source paths', () => { + expect(isDefaultExcludedPath('src/node_modules_cache/index.ts')).toBe(false); + expect(isDefaultExcludedPath('src/building/index.ts')).toBe(false); + expect(isDefaultExcludedPath('src/outbound/index.ts')).toBe(false); + expect(isDefaultExcludedPath('src/codegraphy.ts')).toBe(false); + expect(isDefaultExcludedPath('src/vendor.js')).toBe(false); + }); +}); diff --git a/packages/core/tests/discovery/pathExclusions.test.ts b/packages/core/tests/discovery/pathExclusions.test.ts new file mode 100644 index 000000000..848c52369 --- /dev/null +++ b/packages/core/tests/discovery/pathExclusions.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { DEFAULT_EXCLUDE } from '../../src/discovery/pathExclusions'; + +describe('discovery/pathExclusions', () => { + it('keeps default exclude patterns stable for workspace discovery', () => { + expect(DEFAULT_EXCLUDE).toEqual([ + '**/node_modules/**', + '**/dist/**', + '**/build/**', + '**/out/**', + '**/.git/**', + '**/.codegraphy/**', + '**/.turbo', + '**/.turbo/**', + '**/.worktrees', + '**/.worktrees/**', + '**/coverage/**', + '**/.DS_Store', + '**/*.min.js', + '**/*.bundle.js', + '**/*.map', + ]); + }); +}); From 536ebb994b603467e0811fd269bd9efced185a1e Mon Sep 17 00:00:00 2001 From: joesobo Date: Thu, 25 Jun 2026 09:37:41 -0700 Subject: [PATCH 192/192] fix: clean up graph cache sidecars --- .../2026-06-23-mutation-site-file-splits.md | 149 ------------------ .../src/graphCache/database/io/temporary.ts | 27 +++- .../graphCache/database/io/temporary.test.ts | 57 +++++++ .../publish/equality/{graph.ts => data.ts} | 0 .../execution/publish/metrics/patch.ts | 2 +- .../graphView/webview/messages/ready.ts | 2 +- .../{graphBootstrap.ts => bootstrap.ts} | 0 .../components/graph/viewport/shell.tsx | 4 +- ...{pluginViewportState.ts => pluginState.ts} | 2 +- .../shell/{viewportState.ts => state.ts} | 0 .../equality/{graph.test.ts => data.test.ts} | 4 +- .../{viewportState.test.ts => state.test.ts} | 4 +- 12 files changed, 91 insertions(+), 160 deletions(-) delete mode 100644 docs/superpowers/plans/2026-06-23-mutation-site-file-splits.md create mode 100644 packages/core/tests/graphCache/database/io/temporary.test.ts rename packages/extension/src/extension/graphView/analysis/execution/publish/equality/{graph.ts => data.ts} (100%) rename packages/extension/src/extension/graphView/webview/messages/webviewReady/{graphBootstrap.ts => bootstrap.ts} (100%) rename packages/extension/src/webview/components/graph/viewport/shell/{pluginViewportState.ts => pluginState.ts} (96%) rename packages/extension/src/webview/components/graph/viewport/shell/{viewportState.ts => state.ts} (100%) rename packages/extension/tests/extension/graphView/analysis/execution/publish/equality/{graph.test.ts => data.test.ts} (99%) rename packages/extension/tests/webview/graph/viewport/shell/{viewportState.test.ts => state.test.ts} (97%) diff --git a/docs/superpowers/plans/2026-06-23-mutation-site-file-splits.md b/docs/superpowers/plans/2026-06-23-mutation-site-file-splits.md deleted file mode 100644 index e28c9a4ef..000000000 --- a/docs/superpowers/plans/2026-06-23-mutation-site-file-splits.md +++ /dev/null @@ -1,149 +0,0 @@ -# Mutation Site File Splits Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Split PR-touched source files that exceed the 50 mutation-site threshold into smaller feature-owned modules without running mutation survivor/kill tests. - -**Architecture:** Use the existing main-branch mutation seed reports only as a read-only locator for over-threshold files. Keep public imports stable by leaving the original files as the exported behavior surface where practical, and move independently changing helpers into sibling feature modules with matching existing test coverage. - -**Tech Stack:** TypeScript source modules, existing Vitest suites, `quality-tools organize`, and the checked-in PR branch worktree. - ---- - -### Task 1: Split Cached Discovery Warmup From Pipeline Discovery - -**Files:** -- Modify: `packages/extension/src/extension/pipeline/service/discoveryFacade.ts` -- Create: `packages/extension/src/extension/pipeline/service/cachedGraphWarmup.ts` -- Create: `packages/extension/src/extension/pipeline/service/indexStatus.ts` -- Create: `packages/extension/src/extension/pipeline/service/pluginState.ts` -- Test: `packages/extension/tests/extension/pipeline/service/discoveryFacade.test.ts` - -- [x] **Step 1: Move cached warmup helpers** - -Move the cached graph warmup input type, ignored segment set, candidate selection helpers, supported-file filtering, and warmup input creation into `cachedGraphWarmup.ts`. Export a small factory that receives registry/config/discovery callbacks and returns the selected warmup input. - -- [x] **Step 2: Move plugin and index status helpers** - -Move plugin initialization/reload/sync queueing plus effective filter pattern helpers into `pluginState.ts`, and move index status construction into `indexStatus.ts`. - -- [x] **Step 3: Keep facade behavior stable** - -Update `discoveryFacade.ts` so `loadCachedGraph` still schedules the same best-effort warmup, still ignores abort and missing-file errors, and still warns only for unexpected failures. - -- [x] **Step 4: Run the targeted test** - -```bash -pnpm --filter @codegraphy-dev/extension exec vitest run --config vitest.config.ts tests/extension/pipeline/service/discoveryFacade.test.ts -``` - -Expected: passes. - -### Task 2: Split Graph View Analysis Coordination - -**Files:** -- Modify: `packages/extension/src/extension/graphView/provider/analysis/methods.ts` -- Create: `packages/extension/src/extension/graphView/provider/analysis/fullIndex.ts` -- Test: `packages/extension/tests/extension/graphView/provider/analysis/methods.test.ts` - -- [x] **Step 1: Move full-index coordination** - -Move `FullIndexAnalysisCoordinator`, `FullIndexAnalysisCoordinatorState`, `FullIndexAnalysisKind`, and `canReplayStaleCache` into `fullIndex.ts`. - -- [x] **Step 2: Reuse from methods** - -Import the coordinator factory and stale-cache predicate from `methods.ts` so the method factory remains focused on wiring load/analyze/index/refresh actions. - -- [x] **Step 3: Run the targeted test** - -```bash -pnpm --filter @codegraphy-dev/extension exec vitest run --config vitest.config.ts tests/extension/graphView/provider/analysis/methods.test.ts -``` - -Expected: passes. - -### Task 3: Split Webview Graph Message Domains - -**Files:** -- Modify: `packages/extension/src/webview/store/messageHandlers/graph.ts` -- Create: `packages/extension/src/webview/store/messageHandlers/graphData.ts` -- Create: `packages/extension/src/webview/store/messageHandlers/graphControls.ts` -- Test: `packages/extension/tests/webview/store/messageHandlers/graph.test.ts` - -- [x] **Step 1: Move graph-data handlers** - -Move graph-data duplicate detection, graph data updates, and node metric updates into `graphData.ts`. - -- [x] **Step 2: Move graph-control handlers** - -Move graph control equality assignment and control/settings/depth/direction/physics handlers into `graphControls.ts`. - -- [x] **Step 3: Re-export public handlers** - -Keep `graph.ts` as the existing import surface by re-exporting the moved handlers and retaining the remaining legend/filter/favorite handlers. - -- [x] **Step 4: Run the targeted test** - -```bash -pnpm --filter @codegraphy-dev/extension exec vitest run --config vitest.config.ts tests/webview/store/messageHandlers/graph.test.ts -``` - -Expected: passes. - -### Task 4: Split Smaller Over-Threshold Helpers - -**Files:** -- Modify: `packages/core/src/graphCache/database/io/save.ts` -- Create: `packages/core/src/graphCache/database/io/saveAsync.ts` -- Create: `packages/core/src/graphCache/database/io/temporary.ts` -- Modify: `packages/extension/src/extension/graphView/analysis/execution/load.ts` -- Create: `packages/extension/src/extension/graphView/analysis/execution/load/context.ts` -- Modify: `packages/extension/src/extension/graphView/groups/defaults/materialTheme/pathMatch.ts` -- Create: `packages/extension/src/extension/graphView/groups/defaults/materialTheme/pathMatcher.ts` -- Tests: - - `packages/core/tests/graphCache/database/storage.test.ts` - - `packages/extension/tests/extension/graphView/analysis/execution/load.test.ts` - - `packages/extension/tests/extension/graphView/groups/defaults/materialTheme/pathMatch.test.ts` - -- [x] **Step 1: Move async database save** - -Move async save progress/yield logic into `saveAsync.ts`; keep sync save and clear behavior in `save.ts`. Move temp-path rename/cleanup helpers into `temporary.ts`. - -- [x] **Step 2: Move load context helpers** - -Move raw-data context types, replayable-data predicate, and decision selection into `load/context.ts`. - -- [x] **Step 3: Move material matcher construction** - -Move matcher entry creation, basename indexing, and sorting into `pathMatcher.ts`. - -- [x] **Step 4: Run targeted tests** - -```bash -pnpm --filter @codegraphy-dev/core exec vitest run --config vitest.config.ts tests/graphCache/database/storage.test.ts -pnpm --filter @codegraphy-dev/extension exec vitest run --config vitest.config.ts tests/extension/graphView/analysis/execution/load.test.ts tests/extension/graphView/groups/defaults/materialTheme/pathMatch.test.ts -``` - -Expected: passes. - -### Task 5: Organize and Commit - -**Files:** -- Modify only if `organize` reports actionable issues from the new split files. - -- [x] **Step 1: Run organize** - -```bash -pnpm run organize -- . -``` - -Expected: no new organization issues in the changed files. - -- [ ] **Step 2: Commit and push** - -```bash -git status --short -git add docs/superpowers/plans/2026-06-23-mutation-site-file-splits.md packages/core/src/graphCache/database/io/save.ts packages/core/src/graphCache/database/io/saveAsync.ts packages/core/src/graphCache/database/io/temporary.ts packages/extension/src/extension/pipeline/service/discoveryFacade.ts packages/extension/src/extension/pipeline/service/cachedGraphWarmup.ts packages/extension/src/extension/pipeline/service/indexStatus.ts packages/extension/src/extension/pipeline/service/pluginState.ts packages/extension/src/extension/graphView/provider/analysis/methods.ts packages/extension/src/extension/graphView/provider/analysis/fullIndex.ts packages/extension/src/webview/store/messageHandlers/graph.ts packages/extension/src/webview/store/messageHandlers/graphData.ts packages/extension/src/webview/store/messageHandlers/graphControls.ts packages/extension/src/extension/graphView/analysis/execution/load.ts packages/extension/src/extension/graphView/analysis/execution/load/context.ts packages/extension/src/extension/graphView/groups/defaults/materialTheme/pathMatch.ts packages/extension/src/extension/graphView/groups/defaults/materialTheme/pathMatcher.ts -git commit -m "refactor: split mutation-heavy changed modules" -git push -``` diff --git a/packages/core/src/graphCache/database/io/temporary.ts b/packages/core/src/graphCache/database/io/temporary.ts index eace72551..baaa49787 100644 --- a/packages/core/src/graphCache/database/io/temporary.ts +++ b/packages/core/src/graphCache/database/io/temporary.ts @@ -1,15 +1,38 @@ import * as fs from 'node:fs'; +const DATABASE_SIDECAR_SUFFIXES = ['.wal']; + +function getDatabaseSidecarPaths(databasePath: string): string[] { + return DATABASE_SIDECAR_SUFFIXES.map(suffix => `${databasePath}${suffix}`); +} + +function removePathIfPresent(filePath: string): void { + if (fs.existsSync(filePath)) { + fs.rmSync(filePath, { force: true }); + } +} + export function createTemporaryDatabasePath(databasePath: string): string { return `${databasePath}.${process.pid}.${Date.now()}.tmp`; } export function replaceDatabaseCache(tempDatabasePath: string, databasePath: string): void { + for (const databaseSidecarPath of getDatabaseSidecarPaths(databasePath)) { + removePathIfPresent(databaseSidecarPath); + } + fs.renameSync(tempDatabasePath, databasePath); + + for (const suffix of DATABASE_SIDECAR_SUFFIXES) { + const tempSidecarPath = `${tempDatabasePath}${suffix}`; + if (fs.existsSync(tempSidecarPath)) { + fs.renameSync(tempSidecarPath, `${databasePath}${suffix}`); + } + } } export function cleanupTemporaryDatabase(tempDatabasePath: string): void { - if (fs.existsSync(tempDatabasePath)) { - fs.rmSync(tempDatabasePath, { force: true }); + for (const temporaryPath of [tempDatabasePath, ...getDatabaseSidecarPaths(tempDatabasePath)]) { + removePathIfPresent(temporaryPath); } } diff --git a/packages/core/tests/graphCache/database/io/temporary.test.ts b/packages/core/tests/graphCache/database/io/temporary.test.ts new file mode 100644 index 000000000..6b0734ab9 --- /dev/null +++ b/packages/core/tests/graphCache/database/io/temporary.test.ts @@ -0,0 +1,57 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + cleanupTemporaryDatabase, + replaceDatabaseCache, +} from '../../../../src/graphCache/database/io/temporary'; + +let testDirectory: string | undefined; + +function createTestDirectory(): string { + testDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraphy-temp-db-')); + return testDirectory; +} + +function writeFile(filePath: string, contents: string): void { + fs.writeFileSync(filePath, contents, 'utf8'); +} + +afterEach(() => { + if (testDirectory) { + fs.rmSync(testDirectory, { force: true, recursive: true }); + testDirectory = undefined; + } +}); + +describe('graphCache/database/io/temporary', () => { + it('cleans up temporary database sidecar files', () => { + const directory = createTestDirectory(); + const tempDatabasePath = path.join(directory, 'graph.lbug.123.tmp'); + writeFile(tempDatabasePath, 'temp database'); + writeFile(`${tempDatabasePath}.wal`, 'temp wal'); + + cleanupTemporaryDatabase(tempDatabasePath); + + expect(fs.existsSync(tempDatabasePath)).toBe(false); + expect(fs.existsSync(`${tempDatabasePath}.wal`)).toBe(false); + }); + + it('replaces database sidecar files with temporary sidecars', () => { + const directory = createTestDirectory(); + const databasePath = path.join(directory, 'graph.lbug'); + const tempDatabasePath = path.join(directory, 'graph.lbug.123.tmp'); + writeFile(databasePath, 'old database'); + writeFile(`${databasePath}.wal`, 'old wal'); + writeFile(tempDatabasePath, 'new database'); + writeFile(`${tempDatabasePath}.wal`, 'new wal'); + + replaceDatabaseCache(tempDatabasePath, databasePath); + + expect(fs.readFileSync(databasePath, 'utf8')).toBe('new database'); + expect(fs.readFileSync(`${databasePath}.wal`, 'utf8')).toBe('new wal'); + expect(fs.existsSync(tempDatabasePath)).toBe(false); + expect(fs.existsSync(`${tempDatabasePath}.wal`)).toBe(false); + }); +}); diff --git a/packages/extension/src/extension/graphView/analysis/execution/publish/equality/graph.ts b/packages/extension/src/extension/graphView/analysis/execution/publish/equality/data.ts similarity index 100% rename from packages/extension/src/extension/graphView/analysis/execution/publish/equality/graph.ts rename to packages/extension/src/extension/graphView/analysis/execution/publish/equality/data.ts diff --git a/packages/extension/src/extension/graphView/analysis/execution/publish/metrics/patch.ts b/packages/extension/src/extension/graphView/analysis/execution/publish/metrics/patch.ts index 10684b4c4..b0818f9fb 100644 --- a/packages/extension/src/extension/graphView/analysis/execution/publish/metrics/patch.ts +++ b/packages/extension/src/extension/graphView/analysis/execution/publish/metrics/patch.ts @@ -5,7 +5,7 @@ import { collectMetricOnlyGraphUpdates, createNodeMap, } from './updates'; -import { areGraphDataEqualIgnoringNodeMetrics } from '../equality/graph'; +import { areGraphDataEqualIgnoringNodeMetrics } from '../equality/data'; export function createMetricOnlyGraphUpdate( currentRawGraphData: IGraphData | undefined, diff --git a/packages/extension/src/extension/graphView/webview/messages/ready.ts b/packages/extension/src/extension/graphView/webview/messages/ready.ts index 497c50f8a..0b54abf18 100644 --- a/packages/extension/src/extension/graphView/webview/messages/ready.ts +++ b/packages/extension/src/extension/graphView/webview/messages/ready.ts @@ -8,7 +8,7 @@ import { replayWebviewReadyBootstrap as replayWebviewReadyBootstrapImpl, replayWebviewReadyGraphBootstrap as replayWebviewReadyGraphBootstrapImpl, shouldWaitForFirstWorkspaceGraph as shouldWaitForFirstWorkspaceGraphImpl, -} from './webviewReady/graphBootstrap'; +} from './webviewReady/bootstrap'; import { replayWebviewReadySettings as replayWebviewReadySettingsImpl } from './webviewReady/settingsReplay'; export type { diff --git a/packages/extension/src/extension/graphView/webview/messages/webviewReady/graphBootstrap.ts b/packages/extension/src/extension/graphView/webview/messages/webviewReady/bootstrap.ts similarity index 100% rename from packages/extension/src/extension/graphView/webview/messages/webviewReady/graphBootstrap.ts rename to packages/extension/src/extension/graphView/webview/messages/webviewReady/bootstrap.ts diff --git a/packages/extension/src/webview/components/graph/viewport/shell.tsx b/packages/extension/src/webview/components/graph/viewport/shell.tsx index f53561462..b8bccc459 100644 --- a/packages/extension/src/webview/components/graph/viewport/shell.tsx +++ b/packages/extension/src/webview/components/graph/viewport/shell.tsx @@ -22,8 +22,8 @@ import { buildRenderingRuntimeOptions } from './shell/runtimeOptions'; import { useGraphViewportModelOptions } from './shell/modelOptions'; import { createGraphViewportSurfaceProps } from './shell/surfaceProps'; import { publishCurrentGraphAccessibilityItems } from './shell/accessibilityItems'; -import { publishPluginGraphViewViewportState } from './shell/pluginViewportState'; -import type { GraphViewport2dControls } from './shell/viewportState'; +import { publishPluginGraphViewViewportState } from './shell/pluginState'; +import type { GraphViewport2dControls } from './shell/state'; import { type GraphAccessibilityItems, type GraphScreenProjector, diff --git a/packages/extension/src/webview/components/graph/viewport/shell/pluginViewportState.ts b/packages/extension/src/webview/components/graph/viewport/shell/pluginState.ts similarity index 96% rename from packages/extension/src/webview/components/graph/viewport/shell/pluginViewportState.ts rename to packages/extension/src/webview/components/graph/viewport/shell/pluginState.ts index 48a2773fa..e49ead338 100644 --- a/packages/extension/src/webview/components/graph/viewport/shell/pluginViewportState.ts +++ b/packages/extension/src/webview/components/graph/viewport/shell/pluginState.ts @@ -1,7 +1,7 @@ import type { WebviewPluginHost } from '../../../../pluginHost/manager'; import type { GraphViewStoreState } from '../../view/store'; import type { FGNode } from '../../model/build'; -import { createGraphViewViewportState, type GraphViewport2dControls } from './viewportState'; +import { createGraphViewViewportState, type GraphViewport2dControls } from './state'; export function publishPluginGraphViewViewportState({ globalScale, diff --git a/packages/extension/src/webview/components/graph/viewport/shell/viewportState.ts b/packages/extension/src/webview/components/graph/viewport/shell/state.ts similarity index 100% rename from packages/extension/src/webview/components/graph/viewport/shell/viewportState.ts rename to packages/extension/src/webview/components/graph/viewport/shell/state.ts diff --git a/packages/extension/tests/extension/graphView/analysis/execution/publish/equality/graph.test.ts b/packages/extension/tests/extension/graphView/analysis/execution/publish/equality/data.test.ts similarity index 99% rename from packages/extension/tests/extension/graphView/analysis/execution/publish/equality/graph.test.ts rename to packages/extension/tests/extension/graphView/analysis/execution/publish/equality/data.test.ts index 94e4809a9..89454094d 100644 --- a/packages/extension/tests/extension/graphView/analysis/execution/publish/equality/graph.test.ts +++ b/packages/extension/tests/extension/graphView/analysis/execution/publish/equality/data.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import type { IGraphData, IGraphEdge, IGraphNode } from '../../../../../../../src/shared/graph/contracts'; -import { areGraphDataEqualIgnoringNodeMetrics } from '../../../../../../../src/extension/graphView/analysis/execution/publish/equality/graph'; +import { areGraphDataEqualIgnoringNodeMetrics } from '../../../../../../../src/extension/graphView/analysis/execution/publish/equality/data'; function createNode(overrides: Partial = {}): IGraphNode { return { @@ -43,7 +43,7 @@ function createEmptyTripWireArray(message: string): T[] { }); } -describe('extension/graphView/analysis/execution/publish/equality/graph', () => { +describe('extension/graphView/analysis/execution/publish/equality/data', () => { it('treats matching graphs as equal while ignoring node metrics', () => { expect( areGraphDataEqualIgnoringNodeMetrics( diff --git a/packages/extension/tests/webview/graph/viewport/shell/viewportState.test.ts b/packages/extension/tests/webview/graph/viewport/shell/state.test.ts similarity index 97% rename from packages/extension/tests/webview/graph/viewport/shell/viewportState.test.ts rename to packages/extension/tests/webview/graph/viewport/shell/state.test.ts index f1167dba0..904f3b4f1 100644 --- a/packages/extension/tests/webview/graph/viewport/shell/viewportState.test.ts +++ b/packages/extension/tests/webview/graph/viewport/shell/state.test.ts @@ -2,9 +2,9 @@ import { describe, expect, it, vi } from 'vitest'; import { createGraphViewViewportState, toGraphViewViewportNodes, -} from '../../../../../src/webview/components/graph/viewport/shell/viewportState'; +} from '../../../../../src/webview/components/graph/viewport/shell/state'; -describe('graph/viewport/shell/viewportState', () => { +describe('graph/viewport/shell/state', () => { it('sanitizes viewport node fields while preserving plugin-owned custom state', () => { const nodes = toGraphViewViewportNodes([{ customRuntimeState: { owner: 'plugin-a' },