From d6ecfb9e0ea1904b9373981bcc7755b2a6046b89 Mon Sep 17 00:00:00 2001 From: joesobo Date: Thu, 25 Jun 2026 11:19:48 -0700 Subject: [PATCH 01/14] docs: capture graph cache scheduler architecture notes --- ...026-06-25-graph-cache-runtime-scheduler.md | 294 ++++++++++++++++++ 1 file changed, 294 insertions(+) create mode 100644 docs/plans/2026-06-25-graph-cache-runtime-scheduler.md diff --git a/docs/plans/2026-06-25-graph-cache-runtime-scheduler.md b/docs/plans/2026-06-25-graph-cache-runtime-scheduler.md new file mode 100644 index 000000000..0e73475ba --- /dev/null +++ b/docs/plans/2026-06-25-graph-cache-runtime-scheduler.md @@ -0,0 +1,294 @@ +# Graph Cache Runtime Scheduler Notes + +## Setup + +- Trello card: [Rethink Graph Cache, runtime memory, and update scheduling](https://trello.com/c/sawpINvf) +- Branch: `codex/graph-cache-runtime-scheduler` +- Base: `main` after PR [#294](https://github.com/joesobo/CodeGraphyV4/pull/294) +- Scope: planning notes for a follow-up rearchitecture PR + +## Goal + +Keep CodeGraphy interaction fast after the PR #294 performance work by separating +indexed graph evidence from runtime view state. The user should be able to toggle +filters, Graph Scope rows, plugin settings, and visual/display preferences quickly +without each click scheduling its own Graph Cache save. + +The target model: + +- Graph Cache stores durable indexed evidence. +- Runtime memory holds the loaded working graph. +- Projection state decides what the user currently sees. +- Settings store lightweight user preferences. +- D3 receives graph data from memory and should not wait on disk persistence for + ordinary view changes. + +## Current Problem Shape + +The current implementation has several paths where a user action can directly +lead to analysis, plugin sync, or Graph Cache persistence. Each individual path +may be valid in isolation, but rapid bursts can feel like CodeGraphy is saving +each click one at a time. + +Examples that should not require full Graph Cache writes by default: + +- filters +- node visibility +- edge visibility +- Graph Scope toggles +- visual preferences +- plugin UI settings +- plugin settings that do not change analysis output + +Examples that can legitimately require Graph Cache work: + +- explicit index or re-index +- core or extension analysis schema changes +- discovery, include, exclude, gitignore, or max-file-limit changes +- plugin version changes +- plugin analysis options that alter generated nodes, edges, symbols, or filters +- missing or stale plugin-owned indexed evidence + +## Simplified Architecture + +```mermaid +flowchart TD + A["User action"] --> B["Update UI immediately"] + B --> C{"What changed?"} + + C -->|View state| D["Update runtime projection"] + D --> E["Render from memory"] + + C -->|Saved preference| F["Debounced settings save"] + F --> E + + C -->|Indexed evidence| G["Debounced graph work"] + G --> H{"Can update incrementally?"} + H -->|Yes| I["Patch runtime memory + cache"] + H -->|No| J["Re-index Graph Cache"] + I --> E + J --> K["Reload runtime memory"] + K --> E +``` + +```mermaid +flowchart LR + A["Graph Cache\nindexed evidence"] --> B["Runtime Memory\nloaded graph"] + B --> C["Projection\ncurrent view"] + C --> D["D3 Graph\ninteractive physics"] + + E["Settings\nsaved preferences"] --> C +``` + +## Architectural Review Notes + +### Intent Classification Must Be A Contract + +The scheduler should not grow one-off checks such as a particle-specific branch +or a hard-coded setting key special case. Add one canonical impact policy that +classifies each user action or setting change as: + +- `view-state` +- `saved-preference` +- `projection-update` +- `targeted-index-work` +- `full-index-work` + +The policy should live at the core/extension boundary where settings, plugin +metadata, Graph Cache freshness, and runtime graph memory can be reasoned about +together. + +### Plugins Need Explicit Impact Metadata + +Plugin settings are not one bucket. The plugin API likely needs an impact +declaration so plugins can tell CodeGraphy whether a change affects only UI, +runtime projection, plugin-owned analysis, or the whole index. + +Likely impact levels: + +- `view-only` +- `settings-only` +- `projection-only` +- `reanalyze-plugin-files` +- `requires-full-index` + +Conservative fallback: if a plugin cannot declare the impact of an analysis +option, treat it as indexed-evidence-changing rather than silently showing stale +analysis. + +### Runtime Memory Needs Versioning + +Runtime memory and projection updates must carry generation IDs or equivalent +request versions. If an old async refresh completes after a newer user action, +the old result must not overwrite the latest projection or runtime graph state. + +The policy should be latest-state-wins: + +- While work is idle, run the newest requested work after debounce. +- While work is active, keep one pending latest snapshot. +- When active work finishes, apply it only if it still matches the latest + generation. +- If a newer snapshot exists, run one follow-up job for that latest snapshot. + +### Graph Cache Should Not Store Current View State + +The cache should represent indexed evidence, not the user's current lens over +that evidence. Do not include display-only settings in Graph Cache freshness or +persistence decisions. + +Graph Cache should not become stale because of: + +- filters +- Graph Scope visibility +- node or edge visibility +- colors +- labels +- visual effects +- panel state +- plugin settings that only affect UI or display + +### Incremental Patching Needs Fallback Rules + +Incremental refresh should be preferred when it is correct, but the scheduler +must know when a full index is required. + +Full-index candidates: + +- analysis schema changes +- core cache version changes +- parser or analyzer version changes +- plugin version changes +- broad include or exclude changes +- max-file-limit changes +- discovery policy changes +- plugin settings that alter global relationships + +## Deterministic Measurement Plan + +Wall-clock timings are useful for final validation, but they are noisy during +architecture work. The primary iteration metrics should be deterministic counts +that can be asserted in unit and integration tests. + +### Primary Metric: Graph Cache Save Count + +Measure how many Graph Cache save operations are scheduled for a scripted burst. + +Expected thresholds: + +| Scenario | Actions | Expected Graph Cache saves | +| --- | ---: | ---: | +| Visual/plugin UI setting burst | 10 | 0 | +| Filter burst | 10 | 0 | +| Graph Scope/node/edge visibility burst | 10 | 0 | +| Plugin toggles with cached evidence | 10 | 0 | +| Plugin toggles requiring plugin analysis | 10 | <= 1 | +| Explicit re-index | 1 | 1 | + +### Secondary Metric: Index Work Count + +Measure how many analysis/index jobs are scheduled for a burst. + +Expected thresholds: + +- Projection-only bursts schedule 0 index jobs. +- Settings-only bursts schedule 0 index jobs. +- Analysis-affecting plugin bursts schedule at most 1 index job for the latest + state after debounce. +- Explicit re-index bypasses normal debounce and schedules exactly 1 + authoritative full-index job. + +### Secondary Metric: Progress Restart Count + +Measure how many user-visible Graph Cache or indexing progress sequences appear +for a burst. + +Expected thresholds: + +- Projection-only and settings-only bursts show 0 indexing/cache progress + sequences. +- Analysis-affecting bursts show at most 1 progress sequence per coalesced + latest-state job. +- No stale progress sequence should restart for superseded work. + +### Correctness Metric: Latest-State Wins + +Use fake timers and controlled promises to prove stale work cannot overwrite +newer state. + +Required assertions: + +- A slow first refresh finishing after a newer projection update does not + rollback the projection. +- Plugin on/off/on during active work applies the final `on` state only. +- A pending settings flush writes the latest settings snapshot, not each + intermediate value. +- A pending cache refresh persists metadata for the snapshot it actually + analyzed. + +### Final User-Perceived Timing Checks + +After deterministic counts are correct, run real Extension Development Host +checks against the large CodeGraphy monorepo. + +Useful final timing targets: + +- projection update visible within roughly one frame to 100ms +- no repeated `Saving Graph Cache` progress bars for projection-only bursts +- plugin analysis bursts show a single compact sync operation +- explicit re-index still reports full progress clearly + +## Edge Cases To Preserve + +- User toggles a plugin on, off, and on while plugin sync is already running. +- User changes a plugin setting that only affects UI, then changes one that + affects analysis. +- A targeted plugin refresh discovers files outside the current filtered + projection. +- A filter hides nodes while a full re-index is running. +- A cache job finishes after newer projection settings were applied. +- The webview closes before a debounced settings save flushes. +- The extension reloads while index work is pending. +- A plugin version changes while old plugin evidence exists in cache. +- Graph Cache is warm but settings changed since the last session. +- User explicitly clicks Re-index during a debounce window. +- File watcher refreshes and user-driven projection changes happen at the same + time. +- Gitignore or discovery policy changes invalidate runtime memory that a + projection update was using. + +## Mistakes To Avoid + +- Do not add particle-specific architecture in core or extension. +- Do not let every `updateConfig` imply Graph Cache staleness. +- Do not let Graph Cache freshness depend on display-only settings. +- Do not queue every requested save; queue the latest state. +- Do not hide real index progress; make it less spammy. +- Do not make plugin impact guessable from current behavior only. +- Do not update Graph Cache for projection-only changes. +- Do not let stale async work publish over newer runtime graph memory. + +## Acceptance Test Ideas + +- Filter changes update projection without saving Graph Cache. +- Node visibility changes update runtime projection without indexing. +- Edge visibility changes update runtime projection without indexing. +- Graph Scope changes update runtime projection without indexing. +- Plugin view setting saves settings but does not reanalyze. +- Plugin analysis setting schedules one coalesced refresh. +- Plugin toggles with cached evidence project in and out without reindexing. +- Explicit re-index updates Graph Cache immediately. +- Stale async refresh cannot overwrite newer runtime projection. +- Debounced settings persistence flushes on webview dispose or extension + shutdown. + +## Open Design Questions + +- What exact plugin API shape should expose impact metadata? +- Should impact metadata live on plugin settings schema, plugin contributions, + or both? +- Which current settings belong in Graph Cache freshness and which should move + to projection/settings-only freshness? +- Which current refresh paths can patch runtime memory safely, and which still + require full graph rebuilds? +- Where should the coordinator live so core, extension, and plugin API boundaries + stay clean? From 48724117c48131eb1a19e61e5d68b6db385c6e9a Mon Sep 17 00:00:00 2001 From: joesobo Date: Thu, 25 Jun 2026 11:28:31 -0700 Subject: [PATCH 02/14] docs: refine graph cache scheduler decisions --- ...026-06-25-graph-cache-runtime-scheduler.md | 141 +++++++++++++++++- 1 file changed, 140 insertions(+), 1 deletion(-) diff --git a/docs/plans/2026-06-25-graph-cache-runtime-scheduler.md b/docs/plans/2026-06-25-graph-cache-runtime-scheduler.md index 0e73475ba..96cc9ec74 100644 --- a/docs/plans/2026-06-25-graph-cache-runtime-scheduler.md +++ b/docs/plans/2026-06-25-graph-cache-runtime-scheduler.md @@ -17,12 +17,30 @@ without each click scheduling its own Graph Cache save. The target model: - Graph Cache stores durable indexed evidence. -- Runtime memory holds the loaded working graph. +- Runtime memory holds the loaded working graph evidence needed for the current + view, with additional evidence hydrated from Graph Cache on demand. - Projection state decides what the user currently sees. - Settings store lightweight user preferences. - D3 receives graph data from memory and should not wait on disk persistence for ordinary view changes. +## Alignment Decisions + +- Plugin impact metadata should be required everywhere. CodeGraphy owns the + current monorepo plugins, so the implementation should update the Plugin API + and migrate built-in plugins as examples instead of leaving optional or + partially guessed plugin behavior. +- Filters, Graph Scope, node visibility, and edge visibility should primarily be + projection changes. They should update the visible graph from runtime memory + and settings instead of making Graph Cache stale by default. +- Workspace file changes still need to update the graph and Graph Cache. Adding + a file should create new graph nodes/edges in the live view and patch those + indexed changes into Graph Cache without rewriting the whole cache when a + targeted update is possible. +- Explicit Re-index is a force refresh. It should use the current settings, + bypass ordinary debounce, supersede pending scheduled graph work, and rebuild + Graph Cache deterministically. + ## Current Problem Shape The current implementation has several paths where a user action can directly @@ -80,6 +98,20 @@ flowchart LR E["Settings\nsaved preferences"] --> C ``` +Runtime memory should not have to load every cached node and edge up front. The +Graph Cache can contain all indexed evidence while runtime memory hydrates the +evidence needed for the current projection first. + +```mermaid +flowchart LR + A["Graph Cache\nall indexed evidence"] --> B["Runtime Memory\nbase visible graph"] + A --> C["On-demand evidence hydration"] + C --> D["Symbols / variables / disabled scopes / plugin tiers"] + D --> B + B --> E["Projection"] + E --> F["D3 Graph"] +``` + ## Architectural Review Notes ### Intent Classification Must Be A Contract @@ -116,6 +148,38 @@ Conservative fallback: if a plugin cannot declare the impact of an analysis option, treat it as indexed-evidence-changing rather than silently showing stale analysis. +The implementation should not keep this fallback as the normal path for built-in +plugins. Update every monorepo plugin to declare impact metadata so third-party +plugin authors have real examples to copy. + +### Runtime Memory Can Hydrate Evidence Lazily + +Runtime memory does not need to load all Graph Cache evidence on startup. It can +start with the evidence required for the current projection, then hydrate hidden +or disabled tiers only when the user turns them on. + +Examples: + +- Symbol nodes can remain in Graph Cache until the Symbols Graph Scope row is + toggled on. +- Variable nodes and variable-related edges can remain in Graph Cache until that + scope is visible. +- Plugin-owned nodes and edges can remain in Graph Cache until that plugin or + its graph contribution is enabled. + +Once a tier is hydrated into memory, keep it there even if the user toggles it +back off. That keeps first use memory lower while making future toggles fast. + +Hydration rules: + +- Projection changes should first check runtime memory. +- If the requested evidence tier is missing from memory, load it from Graph + Cache without scheduling analysis or cache writes. +- Hydrating from Graph Cache must not mark the cache stale. +- If Graph Cache does not contain the requested tier, schedule the appropriate + targeted analysis lane. +- A full re-index clears and rebuilds the hydration state from the new cache. + ### Runtime Memory Needs Versioning Runtime memory and projection updates must carry generation IDs or equivalent @@ -152,6 +216,16 @@ Graph Cache should not become stale because of: Incremental refresh should be preferred when it is correct, but the scheduler must know when a full index is required. +For workspace edits, prefer patching existing runtime memory and Graph Cache: + +- new file: analyze the file, add its nodes and edges to runtime memory, and + patch the Graph Cache entry +- changed file: invalidate old file-owned evidence, analyze the file, update + runtime memory, and patch the Graph Cache entry +- deleted file: remove file-owned evidence from runtime memory and Graph Cache +- rename or move: update identity/path metadata without full cache rewrite when + the relationship evidence can be preserved safely + Full-index candidates: - analysis schema changes @@ -197,6 +271,35 @@ Expected thresholds: - Explicit re-index bypasses normal debounce and schedules exactly 1 authoritative full-index job. +### Secondary Metric: Graph Cache Patch Count + +Measure whether workspace edits use targeted cache patches instead of whole-cache +rewrites. + +Expected thresholds: + +| Scenario | Expected cache patch jobs | Expected full cache rewrites | +| --- | ---: | ---: | +| Add 1 file | 1 | 0 | +| Change 1 file | 1 | 0 | +| Delete 1 file | 1 | 0 | +| Rename 1 file | 1 | 0 unless identity cannot be preserved | +| Explicit re-index | 0 | 1 | + +### Secondary Metric: Evidence Hydration Count + +Measure how often hidden graph evidence is loaded from Graph Cache into runtime +memory. + +Expected thresholds: + +| Scenario | Expected cache reads | Expected analysis jobs | Expected cache saves | +| --- | ---: | ---: | ---: | +| Toggle Symbols on first time when cached | 1 | 0 | 0 | +| Toggle Symbols off then on again | 0 | 0 | 0 | +| Toggle plugin evidence on when cached | 1 | 0 | 0 | +| Toggle plugin evidence on when missing | 0 | <= 1 targeted job | <= 1 | + ### Secondary Metric: Progress Restart Count Measure how many user-visible Graph Cache or indexing progress sequences appear @@ -224,6 +327,10 @@ Required assertions: intermediate value. - A pending cache refresh persists metadata for the snapshot it actually analyzed. +- A lazily hydrated evidence tier remains in memory after being toggled off and + is reused without another cache read when toggled back on. +- A workspace file patch updates runtime memory and Graph Cache without a full + Graph Cache rewrite. ### Final User-Perceived Timing Checks @@ -255,6 +362,16 @@ Useful final timing targets: time. - Gitignore or discovery policy changes invalidate runtime memory that a projection update was using. +- User toggles a hidden evidence tier on for the first time and Graph Cache has + the evidence. +- User toggles a hidden evidence tier on for the first time and Graph Cache does + not have the evidence. +- User toggles a hydrated evidence tier off and on repeatedly. +- User adds a file while filters hide its folder, then later changes filters so + the file should become visible from runtime memory. +- User renames or moves a file while a projection-only filter update is pending. +- Explicit Re-index starts while a targeted file patch or plugin refresh is + queued. ## Mistakes To Avoid @@ -266,6 +383,12 @@ Useful final timing targets: - Do not make plugin impact guessable from current behavior only. - Do not update Graph Cache for projection-only changes. - Do not let stale async work publish over newer runtime graph memory. +- Do not eagerly load every cached node and edge when the current projection does + not need them. +- Do not rerun analysis just to hydrate evidence that already exists in Graph + Cache. +- Do not implement file changes as whole-cache rewrites when targeted cache + patches are available and correct. ## Acceptance Test Ideas @@ -276,7 +399,18 @@ Useful final timing targets: - Plugin view setting saves settings but does not reanalyze. - Plugin analysis setting schedules one coalesced refresh. - Plugin toggles with cached evidence project in and out without reindexing. +- Plugin impact metadata is required by the Plugin API and present in every + monorepo plugin. +- Hidden symbol or variable evidence hydrates from Graph Cache on first toggle + without analysis or cache save. +- Hydrated evidence stays in runtime memory after being toggled off and is reused + on the next toggle. +- Adding a workspace file patches runtime memory and Graph Cache without full + cache rewrite. +- Changing a workspace file replaces that file's indexed evidence without full + cache rewrite. - Explicit re-index updates Graph Cache immediately. +- Explicit re-index bypasses debounce and supersedes pending graph work. - Stale async refresh cannot overwrite newer runtime projection. - Debounced settings persistence flushes on webview dispose or extension shutdown. @@ -286,9 +420,14 @@ Useful final timing targets: - What exact plugin API shape should expose impact metadata? - Should impact metadata live on plugin settings schema, plugin contributions, or both? +- Does the required Plugin API metadata change require a major version bump? - Which current settings belong in Graph Cache freshness and which should move to projection/settings-only freshness? - Which current refresh paths can patch runtime memory safely, and which still require full graph rebuilds? +- Which Graph Cache query APIs are needed to hydrate hidden evidence tiers + without loading the whole cache? +- Which Graph Cache write APIs are needed to patch add/change/delete/rename + updates without rewriting the whole cache? - Where should the coordinator live so core, extension, and plugin API boundaries stay clean? From 3ac3f088ed064662ab732ed264159b23782b5824 Mon Sep 17 00:00:00 2001 From: joesobo Date: Thu, 25 Jun 2026 11:45:46 -0700 Subject: [PATCH 03/14] feat: patch graph cache changed files --- .../core/src/graphCache/database/io/save.ts | 36 ++++++ .../src/graphCache/database/query/write.ts | 30 +++++ .../core/src/graphCache/database/storage.ts | 9 ++ packages/core/src/index.ts | 1 + packages/core/src/indexing/engine.ts | 42 ++++++- .../core/src/indexing/refresh/contracts.ts | 4 + .../indexing/refresh/modes/changedFiles.ts | 20 +++- .../tests/graphCache/database/storage.test.ts | 111 ++++++++++++++++++ .../refresh/modes/changedFiles.test.ts | 25 ++++ .../pipeline/database/cache/storage.ts | 1 + .../pipeline/service/base/internal.ts | 17 ++- .../pipeline/service/cache/storage.ts | 38 +++++- .../pipeline/service/refresh/context.ts | 2 + .../service/refresh/modes/changedFiles.ts | 3 + .../refresh/modes/changedFiles.test.ts | 10 ++ 15 files changed, 344 insertions(+), 5 deletions(-) diff --git a/packages/core/src/graphCache/database/io/save.ts b/packages/core/src/graphCache/database/io/save.ts index b0056b517..6cac6f353 100644 --- a/packages/core/src/graphCache/database/io/save.ts +++ b/packages/core/src/graphCache/database/io/save.ts @@ -4,7 +4,9 @@ import type { IWorkspaceAnalysisCache } from '../../../analysis/cache'; import { runStatementSync, withConnection } from './connection'; import { ensureDatabaseDirectory, getWorkspaceAnalysisDatabasePath } from './paths'; import { + createWorkspaceAnalysisCachePatchWriter, createWorkspaceAnalysisCacheWriter, + deleteAnalysisEntry, persistAnalysisEntry, sortedCacheEntries, } from '../query/write'; @@ -26,6 +28,11 @@ export interface WorkspaceAnalysisDatabaseSaveOptions { yieldEvery?: number; } +export interface WorkspaceAnalysisDatabasePatch { + deleteFilePaths?: readonly string[]; + upsertFiles?: IWorkspaceAnalysisCache['files']; +} + export function saveWorkspaceAnalysisDatabaseCache( workspaceRoot: string, cache: IWorkspaceAnalysisCache, @@ -55,6 +62,35 @@ export function saveWorkspaceAnalysisDatabaseCache( } } +export function patchWorkspaceAnalysisDatabaseCache( + workspaceRoot: string, + patch: WorkspaceAnalysisDatabasePatch, +): void { + ensureDatabaseDirectory(workspaceRoot); + const databasePath = getWorkspaceAnalysisDatabasePath(workspaceRoot); + if (!fs.existsSync(path.dirname(databasePath))) { + return; + } + + const deleteFilePaths = new Set([ + ...(patch.deleteFilePaths ?? []), + ...Object.keys(patch.upsertFiles ?? {}), + ]); + + withConnection(databasePath, (connection) => { + const writer = createWorkspaceAnalysisCachePatchWriter(connection); + for (const filePath of [...deleteFilePaths].sort()) { + deleteAnalysisEntry(writer, filePath); + } + for (const [filePath, entry] of sortedCacheEntries({ + version: '', + files: patch.upsertFiles ?? {}, + })) { + persistAnalysisEntry(writer, filePath, entry); + } + }); +} + export function clearWorkspaceAnalysisDatabaseCache(workspaceRoot: string): void { const databasePath = getWorkspaceAnalysisDatabasePath(workspaceRoot); if (!fs.existsSync(databasePath)) { diff --git a/packages/core/src/graphCache/database/query/write.ts b/packages/core/src/graphCache/database/query/write.ts index 29a18a457..f87fecbab 100644 --- a/packages/core/src/graphCache/database/query/write.ts +++ b/packages/core/src/graphCache/database/query/write.ts @@ -8,12 +8,21 @@ import { } from '../io/connection'; const CREATE_FILE_ANALYSIS_STATEMENT = 'CREATE (entry:FileAnalysis {filePath: $filePath, mtime: $mtime, size: $size, analysis: $analysis})'; +const DELETE_FILE_ANALYSIS_STATEMENT = 'MATCH (entry:FileAnalysis {filePath: $filePath}) DELETE entry'; +const DELETE_SYMBOL_STATEMENT = 'MATCH (entry:Symbol {filePath: $filePath}) DELETE entry'; +const DELETE_RELATION_STATEMENT = 'MATCH (entry:Relation {filePath: $filePath}) DELETE entry'; export interface WorkspaceAnalysisCacheWriter { connection: lb.Connection; fileAnalysisStatement: lb.PreparedStatement; } +export interface WorkspaceAnalysisCachePatchWriter extends WorkspaceAnalysisCacheWriter { + deleteFileAnalysisStatement: lb.PreparedStatement; + deleteSymbolStatement: lb.PreparedStatement; + deleteRelationStatement: lb.PreparedStatement; +} + function createFileAnalysisParams( filePath: string, entry: IWorkspaceAnalysisCache['files'][string], @@ -41,6 +50,17 @@ export function createWorkspaceAnalysisCacheWriter( }; } +export function createWorkspaceAnalysisCachePatchWriter( + connection: lb.Connection, +): WorkspaceAnalysisCachePatchWriter { + return { + ...createWorkspaceAnalysisCacheWriter(connection), + deleteFileAnalysisStatement: prepareStatementSync(connection, DELETE_FILE_ANALYSIS_STATEMENT), + deleteSymbolStatement: prepareStatementSync(connection, DELETE_SYMBOL_STATEMENT), + deleteRelationStatement: prepareStatementSync(connection, DELETE_RELATION_STATEMENT), + }; +} + export async function createWorkspaceAnalysisCacheWriterAsync( connection: lb.Connection, ): Promise { @@ -59,6 +79,16 @@ export function persistAnalysisEntry( executeStatementSync(writer.connection, writer.fileAnalysisStatement, createFileAnalysisParams(filePath, entry)); } +export function deleteAnalysisEntry( + writer: WorkspaceAnalysisCachePatchWriter, + filePath: string, +): void { + const params = { filePath }; + executeStatementSync(writer.connection, writer.deleteFileAnalysisStatement, params); + executeStatementSync(writer.connection, writer.deleteSymbolStatement, params); + executeStatementSync(writer.connection, writer.deleteRelationStatement, params); +} + async function executeStatementAndYield( writer: WorkspaceAnalysisCacheWriter, preparedStatement: lb.PreparedStatement, diff --git a/packages/core/src/graphCache/database/storage.ts b/packages/core/src/graphCache/database/storage.ts index 419d7a376..0b56282c1 100644 --- a/packages/core/src/graphCache/database/storage.ts +++ b/packages/core/src/graphCache/database/storage.ts @@ -9,8 +9,10 @@ import { } from './snapshot'; import { clearWorkspaceAnalysisDatabaseCache as clearWorkspaceAnalysisDatabaseCacheImpl, + patchWorkspaceAnalysisDatabaseCache as patchWorkspaceAnalysisDatabaseCacheImpl, saveWorkspaceAnalysisDatabaseCache as saveWorkspaceAnalysisDatabaseCacheImpl, saveWorkspaceAnalysisDatabaseCacheAsync as saveWorkspaceAnalysisDatabaseCacheAsyncImpl, + type WorkspaceAnalysisDatabasePatch, type WorkspaceAnalysisDatabaseSaveOptions, } from './io/save'; @@ -53,6 +55,13 @@ export function saveWorkspaceAnalysisDatabaseCache( saveWorkspaceAnalysisDatabaseCacheImpl(workspaceRoot, cache); } +export function patchWorkspaceAnalysisDatabaseCache( + workspaceRoot: string, + patch: WorkspaceAnalysisDatabasePatch, +): void { + patchWorkspaceAnalysisDatabaseCacheImpl(workspaceRoot, patch); +} + export function saveWorkspaceAnalysisDatabaseCacheAsync( workspaceRoot: string, cache: Parameters[1], diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0562908bc..a0182e8eb 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -130,6 +130,7 @@ export { getWorkspaceAnalysisDatabasePath, loadWorkspaceAnalysisDatabaseCache, loadWorkspaceAnalysisDatabaseCacheAsync, + patchWorkspaceAnalysisDatabaseCache, readWorkspaceAnalysisDatabaseSnapshot, saveWorkspaceAnalysisDatabaseCache, saveWorkspaceAnalysisDatabaseCacheAsync, diff --git a/packages/core/src/indexing/engine.ts b/packages/core/src/indexing/engine.ts index 0c4fed220..97f74631a 100644 --- a/packages/core/src/indexing/engine.ts +++ b/packages/core/src/indexing/engine.ts @@ -5,7 +5,10 @@ import type { IDiscoveredFile, IDiscoveryResult } from '../discovery/contracts'; import { FileDiscovery } from '../discovery/file/service'; import { buildWorkspacePipelineGraphFromAnalysis } from '../graph/build'; import type { IGraphData } from '../graph/contracts'; -import { saveWorkspaceAnalysisDatabaseCache } from '../graphCache/database/storage'; +import { + patchWorkspaceAnalysisDatabaseCache, + saveWorkspaceAnalysisDatabaseCache, +} from '../graphCache/database/storage'; import { getGraphCachePath, resolveWorkspaceRoot } from '../workspace/paths'; import { analyzeWorkspaceIndexFiles } from './analysis'; import { createDisabledPluginSet } from '../plugins/activityState/model'; @@ -142,6 +145,38 @@ function persistWorkspaceEngine(runtime: WorkspaceEngineRuntime): void { }); } +function patchWorkspaceEngineCache( + runtime: WorkspaceEngineRuntime, + patch: { + deleteFilePaths: readonly string[]; + upsertFilePaths: readonly string[]; + }, +): void { + const { state, workspaceRoot } = runtime; + if (!state.registry || !state.settings) { + return; + } + + const upsertFiles: IWorkspaceAnalysisCache['files'] = {}; + for (const filePath of patch.upsertFilePaths) { + const entry = state.cache.files[filePath]; + if (entry) { + upsertFiles[filePath] = entry; + } + } + + patchWorkspaceAnalysisDatabaseCache(workspaceRoot, { + deleteFilePaths: patch.deleteFilePaths, + upsertFiles, + }); + persistWorkspaceIndexMetadata({ + loadedPackagePlugins: state.loadedPackagePlugins, + registry: state.registry, + settings: state.settings, + workspaceRoot, + }); +} + async function discoverWorkspaceEngineFiles( runtime: WorkspaceEngineRuntime, disabledPlugins: Set, @@ -336,7 +371,10 @@ export function createCodeGraphyWorkspaceEngine( const graph = buildWorkspaceEngineGraph(runtime, disabledPlugins); registry.notifyPostAnalyze(graph, disabledPlugins); - persistWorkspaceEngine(runtime); + patchWorkspaceEngineCache(runtime, { + deleteFilePaths: [], + upsertFilePaths: filesToAnalyze.map(file => file.relativePath), + }); return createWorkspaceEngineIndexResult(runtime, graph); }; diff --git a/packages/core/src/indexing/refresh/contracts.ts b/packages/core/src/indexing/refresh/contracts.ts index 5bb72000b..cbcb88ca0 100644 --- a/packages/core/src/indexing/refresh/contracts.ts +++ b/packages/core/src/indexing/refresh/contracts.ts @@ -75,6 +75,10 @@ export interface WorkspaceIndexRefreshDependencies { onProgress?: (progress: { phase: string; current: number; total: number }) => void; onDeferredIndexMetadataError?(error: unknown): void; persistCache(): void; + persistCachePatch?(patch: { + deleteFilePaths: readonly string[]; + upsertFilePaths: readonly string[]; + }): void; persistIndexMetadata(): Promise; signal?: AbortSignal; workspaceRoot: string; diff --git a/packages/core/src/indexing/refresh/modes/changedFiles.ts b/packages/core/src/indexing/refresh/modes/changedFiles.ts index 3fb786f92..39a98d963 100644 --- a/packages/core/src/indexing/refresh/modes/changedFiles.ts +++ b/packages/core/src/indexing/refresh/modes/changedFiles.ts @@ -92,7 +92,10 @@ export async function refreshWorkspaceIndexChangedFiles( applyWorkspaceIndexAnalysisResult(source, analysisResult); - dependencies.persistCache(); + persistChangedFilesCachePatch(dependencies, { + deleteFilePaths: [], + upsertFilePaths: filesToAnalyze.map(file => file.relativePath), + }); if ( canPatchWorkspaceIndexRefreshGraphData(graphSnapshot, analysisResult, filesToAnalyze) && source._patchGraphDataNodeMetrics @@ -116,6 +119,21 @@ export async function refreshWorkspaceIndexChangedFiles( return graphData; } +function persistChangedFilesCachePatch( + dependencies: WorkspaceIndexRefreshDependencies, + patch: { + deleteFilePaths: readonly string[]; + upsertFilePaths: readonly string[]; + }, +): void { + if (dependencies.persistCachePatch) { + dependencies.persistCachePatch(patch); + return; + } + + dependencies.persistCache(); +} + function analyzeWorkspaceIndexFromRefresh( source: WorkspaceIndexRefreshSource, dependencies: WorkspaceIndexRefreshDependencies, diff --git a/packages/core/tests/graphCache/database/storage.test.ts b/packages/core/tests/graphCache/database/storage.test.ts index 430694691..bce61a7a2 100644 --- a/packages/core/tests/graphCache/database/storage.test.ts +++ b/packages/core/tests/graphCache/database/storage.test.ts @@ -11,6 +11,7 @@ import { clearWorkspaceAnalysisDatabaseCache, getWorkspaceAnalysisDatabasePath, loadWorkspaceAnalysisDatabaseCache, + patchWorkspaceAnalysisDatabaseCache, readWorkspaceAnalysisDatabaseSnapshot, saveWorkspaceAnalysisDatabaseCache, saveWorkspaceAnalysisDatabaseCacheAsync, @@ -210,6 +211,116 @@ describe('workspace analysis database cache', { timeout: 30000 }, () => { expect(loadWorkspaceAnalysisDatabaseCache(workspaceRoot)).toEqual(secondCache); }); + it('patches changed file analysis rows without rewriting unrelated Graph Cache entries', () => { + const workspaceRoot = createWorkspaceRoot(); + const initialCache: IWorkspaceAnalysisCache = { + version: WORKSPACE_ANALYSIS_CACHE_VERSION, + files: { + 'src/changed.ts': { + mtime: 1, + size: 10, + analysis: { + filePath: '/workspace/src/changed.ts', + relations: [], + }, + }, + 'src/deleted.ts': { + mtime: 2, + size: 20, + analysis: { + filePath: '/workspace/src/deleted.ts', + relations: [], + }, + }, + 'src/stable.ts': { + mtime: 3, + size: 30, + analysis: { + filePath: '/workspace/src/stable.ts', + relations: [{ + kind: 'import', + sourceId: 'core:treesitter:import', + fromFilePath: '/workspace/src/stable.ts', + toFilePath: '/workspace/src/changed.ts', + resolvedPath: '/workspace/src/changed.ts', + }], + }, + }, + }, + }; + saveWorkspaceAnalysisDatabaseCache(workspaceRoot, initialCache); + + patchWorkspaceAnalysisDatabaseCache(workspaceRoot, { + deleteFilePaths: ['src/deleted.ts'], + upsertFiles: { + 'src/changed.ts': { + mtime: 4, + size: 40, + analysis: { + filePath: '/workspace/src/changed.ts', + symbols: [{ + id: '/workspace/src/changed.ts:function:changed', + filePath: '/workspace/src/changed.ts', + kind: 'function', + name: 'changed', + }], + relations: [], + }, + }, + 'src/created.ts': { + mtime: 5, + size: 50, + analysis: { + filePath: '/workspace/src/created.ts', + relations: [], + }, + }, + }, + }); + + expect(loadWorkspaceAnalysisDatabaseCache(workspaceRoot)).toEqual({ + version: WORKSPACE_ANALYSIS_CACHE_VERSION, + files: { + 'src/changed.ts': { + mtime: 4, + size: 40, + analysis: { + filePath: '/workspace/src/changed.ts', + symbols: [{ + id: '/workspace/src/changed.ts:function:changed', + filePath: '/workspace/src/changed.ts', + kind: 'function', + name: 'changed', + }], + relations: [], + }, + }, + 'src/created.ts': { + mtime: 5, + size: 50, + analysis: { + filePath: '/workspace/src/created.ts', + relations: [], + }, + }, + 'src/stable.ts': initialCache.files['src/stable.ts']!, + }, + }); + expect(readWorkspaceAnalysisDatabaseSnapshot(workspaceRoot)).toMatchObject({ + files: [ + { filePath: 'src/changed.ts', mtime: 4, size: 40 }, + { filePath: 'src/created.ts', mtime: 5, size: 50 }, + { filePath: 'src/stable.ts', mtime: 3, size: 30 }, + ], + symbols: [{ + id: '/workspace/src/changed.ts:function:changed', + filePath: '/workspace/src/changed.ts', + kind: 'function', + name: 'changed', + }], + }); + }); + it('falls back to an empty cache when the persisted database is unreadable', () => { const workspaceRoot = createWorkspaceRoot(); const databasePath = getWorkspaceAnalysisDatabasePath(workspaceRoot); diff --git a/packages/core/tests/indexing/refresh/modes/changedFiles.test.ts b/packages/core/tests/indexing/refresh/modes/changedFiles.test.ts index 4cd9940db..f4b4844c1 100644 --- a/packages/core/tests/indexing/refresh/modes/changedFiles.test.ts +++ b/packages/core/tests/indexing/refresh/modes/changedFiles.test.ts @@ -105,6 +105,31 @@ describe('indexing/refresh/modes/changedFiles', () => { expect(source._analyzeFiles).toHaveBeenCalledOnce(); }); + it('persists changed files through a targeted Graph Cache patch instead of a full cache save', async () => { + const persistCache = vi.fn(); + const persistCachePatch = vi.fn(); + const source = createSource({ + _analyzeFiles: vi.fn(async (files: IDiscoveredFile[]) => ({ + cacheHits: 0, + cacheMisses: files.length, + fileAnalysis: new Map([['src/app.ts', createFileAnalysis('/workspace/src/app.ts')]]), + fileConnections: new Map([['src/app.ts', []]]), + })), + }); + + await refreshWorkspaceIndexChangedFiles(source, refreshOptions({ + persistCache, + persistCachePatch, + })); + + expect(persistCache).not.toHaveBeenCalled(); + expect(persistCachePatch).toHaveBeenCalledOnce(); + expect(persistCachePatch).toHaveBeenCalledWith({ + deleteFilePaths: [], + upsertFilePaths: ['src/app.ts'], + }); + }); + it('labels fallback full-analysis progress as applying changes when no phase is provided', async () => { const graph: IGraphData = { nodes: [], edges: [] }; const onProgress = vi.fn(); diff --git a/packages/extension/src/extension/pipeline/database/cache/storage.ts b/packages/extension/src/extension/pipeline/database/cache/storage.ts index acbc307fc..d1e76b6cf 100644 --- a/packages/extension/src/extension/pipeline/database/cache/storage.ts +++ b/packages/extension/src/extension/pipeline/database/cache/storage.ts @@ -4,6 +4,7 @@ export { getWorkspaceAnalysisDatabasePath, loadWorkspaceAnalysisDatabaseCache, loadWorkspaceAnalysisDatabaseCacheAsync, + patchWorkspaceAnalysisDatabaseCache, readWorkspaceAnalysisDatabaseSnapshot, saveWorkspaceAnalysisDatabaseCache, saveWorkspaceAnalysisDatabaseCacheAsync, diff --git a/packages/extension/src/extension/pipeline/service/base/internal.ts b/packages/extension/src/extension/pipeline/service/base/internal.ts index e36de658a..d62028ad3 100644 --- a/packages/extension/src/extension/pipeline/service/base/internal.ts +++ b/packages/extension/src/extension/pipeline/service/base/internal.ts @@ -24,7 +24,11 @@ import { readWorkspacePipelineCurrentCommitSha, readWorkspacePipelineCurrentCommitShaSync, } from '../cache/signatures'; -import { persistWorkspacePipelineCache } from '../cache/storage'; +import { + patchWorkspacePipelineCache, + persistWorkspacePipelineCache, + type WorkspacePipelineCachePatch, +} from '../cache/storage'; import { analyzeWorkspacePipelineDiscoveredFiles, preAnalyzeWorkspacePipelinePlugins, @@ -211,4 +215,15 @@ export abstract class WorkspacePipelineInternalBase extends WorkspacePipelineSta }, ); } + + protected _persistCachePatch(patch: WorkspacePipelineCachePatch): void { + patchWorkspacePipelineCache( + this._getWorkspaceRoot(), + this._cache, + patch, + (message: string, error: unknown) => { + console.warn(message, error); + }, + ); + } } diff --git a/packages/extension/src/extension/pipeline/service/cache/storage.ts b/packages/extension/src/extension/pipeline/service/cache/storage.ts index d17a1d374..5662e2408 100644 --- a/packages/extension/src/extension/pipeline/service/cache/storage.ts +++ b/packages/extension/src/extension/pipeline/service/cache/storage.ts @@ -1,6 +1,14 @@ import type { IWorkspaceAnalysisCache } from '../../cache'; import { clearWorkspacePipelineCache } from '../../analysis/state'; -import { saveWorkspaceAnalysisDatabaseCacheAsync } from '../../database/cache/storage'; +import { + patchWorkspaceAnalysisDatabaseCache, + saveWorkspaceAnalysisDatabaseCacheAsync, +} from '../../database/cache/storage'; + +export interface WorkspacePipelineCachePatch { + deleteFilePaths: readonly string[]; + upsertFilePaths: readonly string[]; +} export function clearWorkspacePipelineStoredCache( workspaceRoot: string | undefined, @@ -23,3 +31,31 @@ export function persistWorkspacePipelineCache( warn('[CodeGraphy] Failed to persist repo-local analysis cache.', error); }); } + +export function patchWorkspacePipelineCache( + workspaceRoot: string | undefined, + cache: IWorkspaceAnalysisCache, + patch: WorkspacePipelineCachePatch, + warn: (message: string, error: unknown) => void, +): void { + if (!workspaceRoot) { + return; + } + + const upsertFiles: IWorkspaceAnalysisCache['files'] = {}; + for (const filePath of patch.upsertFilePaths) { + const entry = cache.files[filePath]; + if (entry) { + upsertFiles[filePath] = entry; + } + } + + try { + patchWorkspaceAnalysisDatabaseCache(workspaceRoot, { + deleteFilePaths: patch.deleteFilePaths, + upsertFiles, + }); + } catch (error) { + warn('[CodeGraphy] Failed to patch repo-local analysis cache.', error); + } +} diff --git a/packages/extension/src/extension/pipeline/service/refresh/context.ts b/packages/extension/src/extension/pipeline/service/refresh/context.ts index c6303150d..bf8596354 100644 --- a/packages/extension/src/extension/pipeline/service/refresh/context.ts +++ b/packages/extension/src/extension/pipeline/service/refresh/context.ts @@ -2,6 +2,7 @@ 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 { WorkspacePipelineCachePatch } from '../cache/storage'; import type { AnalysisScopeRefreshFacade } from './scope'; import type { RefreshSourceFacade } from './source'; @@ -22,6 +23,7 @@ export interface RefreshFacadeContext _getWorkspaceRoot(): string | undefined; _lastGitIgnoredPaths: string[]; _persistCache(): void; + _persistCachePatch(patch: WorkspacePipelineCachePatch): void; _persistIndexMetadata(): Promise; _registry: Pick; _toWorkspaceRelativePath(workspaceRoot: string, filePath: string): string | undefined; diff --git a/packages/extension/src/extension/pipeline/service/refresh/modes/changedFiles.ts b/packages/extension/src/extension/pipeline/service/refresh/modes/changedFiles.ts index 38c6ec18c..4146f5246 100644 --- a/packages/extension/src/extension/pipeline/service/refresh/modes/changedFiles.ts +++ b/packages/extension/src/extension/pipeline/service/refresh/modes/changedFiles.ts @@ -56,6 +56,9 @@ export async function refreshChangedFilesForFacade( persistCache: () => { facade._persistCache(); }, + persistCachePatch: patch => { + facade._persistCachePatch(patch); + }, persistIndexMetadata: async () => { await facade._persistIndexMetadata(); }, 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 index 0a85567ed..103454efd 100644 --- a/packages/extension/tests/extension/pipeline/service/refresh/modes/changedFiles.test.ts +++ b/packages/extension/tests/extension/pipeline/service/refresh/modes/changedFiles.test.ts @@ -64,6 +64,7 @@ function createFacade( _lastWorkspaceRoot: '/workspace', _patchGraphDataNodeMetrics: vi.fn(), _persistCache: vi.fn(), + _persistCachePatch: vi.fn(), _persistIndexMetadata: vi.fn(async () => undefined), _preAnalyzePlugins: vi.fn(), _readAnalysisFiles: vi.fn(), @@ -146,6 +147,7 @@ describe('extension/pipeline/service/refresh/modes/changedFiles', () => { onDeferredIndexMetadataError: expect.any(Function), onProgress, persistCache: expect.any(Function), + persistCachePatch: expect.any(Function), persistIndexMetadata: expect.any(Function), signal, workspaceRoot: '/workspace', @@ -178,8 +180,16 @@ describe('extension/pipeline/service/refresh/modes/changedFiles', () => { warn.mockRestore(); refreshOptions.persistCache(); + refreshOptions.persistCachePatch?.({ + deleteFilePaths: ['src/deleted.ts'], + upsertFilePaths: ['src/a.ts'], + }); await refreshOptions.persistIndexMetadata(); expect(facade._persistCache).toHaveBeenCalledOnce(); + expect(facade._persistCachePatch).toHaveBeenCalledWith({ + deleteFilePaths: ['src/deleted.ts'], + upsertFilePaths: ['src/a.ts'], + }); expect(facade._persistIndexMetadata).toHaveBeenCalledOnce(); }); From deed54d4e12dd7b35b5a9909de5d79243ccec041 Mon Sep 17 00:00:00 2001 From: joesobo Date: Thu, 25 Jun 2026 11:53:03 -0700 Subject: [PATCH 04/14] feat: keep filter updates projection-only --- .../updates/apply/filterPatternState.ts | 2 - .../updates/filterPatterns.ts | 1 - .../webview/settingsMessages/updates.test.ts | 67 +++++++++++++++++-- .../updates/apply/filterPatternState.test.ts | 6 +- .../updates/filterPatterns.test.ts | 5 +- 5 files changed, 69 insertions(+), 12 deletions(-) diff --git a/packages/extension/src/extension/graphView/webview/settingsMessages/updates/apply/filterPatternState.ts b/packages/extension/src/extension/graphView/webview/settingsMessages/updates/apply/filterPatternState.ts index d99dab5bb..1b7c0a9d5 100644 --- a/packages/extension/src/extension/graphView/webview/settingsMessages/updates/apply/filterPatternState.ts +++ b/packages/extension/src/extension/graphView/webview/settingsMessages/updates/apply/filterPatternState.ts @@ -30,7 +30,6 @@ export async function applyFilterPatternStateMessage( sendFilterPatternsUpdated(state, handlers, { [getFilterPatternStateOverrideKey(message.payload.source)]: disabledPatterns, }); - await handlers.analyzeAndSendData(); return true; } @@ -54,6 +53,5 @@ export async function applyFilterPatternGroupMessage( sendFilterPatternsUpdated(state, handlers, { [getFilterPatternStateOverrideKey(message.payload.source)]: disabledPatterns, }); - await handlers.analyzeAndSendData(); return true; } diff --git a/packages/extension/src/extension/graphView/webview/settingsMessages/updates/filterPatterns.ts b/packages/extension/src/extension/graphView/webview/settingsMessages/updates/filterPatterns.ts index 0eaea25fe..01b913ba2 100644 --- a/packages/extension/src/extension/graphView/webview/settingsMessages/updates/filterPatterns.ts +++ b/packages/extension/src/extension/graphView/webview/settingsMessages/updates/filterPatterns.ts @@ -25,6 +25,5 @@ export async function applyFilterPatternsUpdate( disabledPluginPatterns: handlers.getConfig('disabledPluginFilterPatterns', []), }, }); - await handlers.analyzeAndSendData(); return true; } diff --git a/packages/extension/tests/extension/graphView/webview/settingsMessages/updates.test.ts b/packages/extension/tests/extension/graphView/webview/settingsMessages/updates.test.ts index 408d425ad..9c8131444 100644 --- a/packages/extension/tests/extension/graphView/webview/settingsMessages/updates.test.ts +++ b/packages/extension/tests/extension/graphView/webview/settingsMessages/updates.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; +import type { WebviewToExtensionMessage } from '../../../../../src/shared/protocol/webviewToExtension'; import { applySettingsUpdateMessage, } from '../../../../../src/extension/graphView/webview/settingsMessages/updates/apply'; @@ -40,10 +41,11 @@ describe('graph view settings update message', () => { disabledPluginPatterns: [], }, }); - expect(handlers.analyzeAndSendData).toHaveBeenCalledOnce(); + expect(handlers.analyzeAndSendData).not.toHaveBeenCalled(); + expect(handlers.smartRebuild).not.toHaveBeenCalled(); }); - it('persists filter row state and refreshes graph data so old nodes can return', async () => { + it('persists filter row state without scheduling graph work', async () => { const state = createState({ filterPatterns: ['dist/**'] }); const handlers = createHandlers({ getConfig: vi.fn((key: string, defaultValue: T): T => { @@ -64,10 +66,11 @@ describe('graph view settings update message', () => { )).resolves.toBe(true); expect(handlers.updateConfig).toHaveBeenCalledWith('disabledCustomFilterPatterns', []); - expect(handlers.analyzeAndSendData).toHaveBeenCalledOnce(); + expect(handlers.analyzeAndSendData).not.toHaveBeenCalled(); + expect(handlers.smartRebuild).not.toHaveBeenCalled(); }); - it('persists section filter state and refreshes graph data once', async () => { + it('persists section filter state without scheduling graph work', async () => { const state = createState({ filterPatterns: ['dist/**', 'coverage/**'] }); const handlers = createHandlers(); @@ -84,7 +87,61 @@ describe('graph view settings update message', () => { 'dist/**', 'coverage/**', ]); - expect(handlers.analyzeAndSendData).toHaveBeenCalledOnce(); + expect(handlers.analyzeAndSendData).not.toHaveBeenCalled(); + expect(handlers.smartRebuild).not.toHaveBeenCalled(); + }); + + it('keeps filter bursts projection-only with zero graph jobs', async () => { + const state = createState({ filterPatterns: ['dist/**', 'coverage/**'] }); + const handlers = createHandlers({ + getPluginFilterPatterns: vi.fn(() => ['venv/**', '.mypy_cache/**']), + }); + + const messages: WebviewToExtensionMessage[] = [ + { type: 'UPDATE_FILTER_PATTERNS', payload: { patterns: ['dist/**'] } }, + { type: 'UPDATE_FILTER_PATTERNS', payload: { patterns: ['dist/**', 'coverage/**'] } }, + { + type: 'UPDATE_FILTER_PATTERN_STATE', + payload: { source: 'custom', pattern: 'dist/**', enabled: false }, + }, + { + type: 'UPDATE_FILTER_PATTERN_STATE', + payload: { source: 'custom', pattern: 'dist/**', enabled: true }, + }, + { + type: 'UPDATE_FILTER_PATTERN_STATE', + payload: { source: 'plugin', pattern: 'venv/**', enabled: false }, + }, + { + type: 'UPDATE_FILTER_PATTERN_STATE', + payload: { source: 'plugin', pattern: 'venv/**', enabled: true }, + }, + { + type: 'UPDATE_FILTER_PATTERN_GROUP_STATE', + payload: { source: 'custom', enabled: false }, + }, + { + type: 'UPDATE_FILTER_PATTERN_GROUP_STATE', + payload: { source: 'custom', enabled: true }, + }, + { + type: 'UPDATE_FILTER_PATTERN_GROUP_STATE', + payload: { source: 'plugin', enabled: false }, + }, + { + type: 'UPDATE_FILTER_PATTERN_GROUP_STATE', + payload: { source: 'plugin', enabled: true }, + }, + ]; + + for (const message of messages) { + await expect(applySettingsUpdateMessage(message, state, handlers)).resolves.toBe(true); + } + + expect(handlers.analyzeAndSendData).not.toHaveBeenCalled(); + expect(handlers.smartRebuild).not.toHaveBeenCalled(); + expect(handlers.reprocessGraphScope).not.toHaveBeenCalled(); + expect(handlers.reprocessPluginFiles).not.toHaveBeenCalled(); }); it('persists update-show-orphans through config updates', async () => { diff --git a/packages/extension/tests/extension/graphView/webview/settingsMessages/updates/apply/filterPatternState.test.ts b/packages/extension/tests/extension/graphView/webview/settingsMessages/updates/apply/filterPatternState.test.ts index 9fb07d924..ad7ffead2 100644 --- a/packages/extension/tests/extension/graphView/webview/settingsMessages/updates/apply/filterPatternState.test.ts +++ b/packages/extension/tests/extension/graphView/webview/settingsMessages/updates/apply/filterPatternState.test.ts @@ -48,7 +48,8 @@ describe('settingsMessages/updates/apply/filterPatternState', () => { disabledPluginPatterns: ['venv/**', '.mypy_cache/**'], }), })); - expect(handlers.analyzeAndSendData).toHaveBeenCalledOnce(); + expect(handlers.analyzeAndSendData).not.toHaveBeenCalled(); + expect(handlers.smartRebuild).not.toHaveBeenCalled(); }); it('updates plugin group state from all plugin patterns', async () => { @@ -74,7 +75,8 @@ describe('settingsMessages/updates/apply/filterPatternState', () => { disabledPluginPatterns: ['venv/**', '.mypy_cache/**'], }), })); - expect(handlers.analyzeAndSendData).toHaveBeenCalledOnce(); + expect(handlers.analyzeAndSendData).not.toHaveBeenCalled(); + expect(handlers.smartRebuild).not.toHaveBeenCalled(); }); it('starts a disabled row list from an empty config default', async () => { diff --git a/packages/extension/tests/extension/graphView/webview/settingsMessages/updates/filterPatterns.test.ts b/packages/extension/tests/extension/graphView/webview/settingsMessages/updates/filterPatterns.test.ts index a97c8a483..370bbd492 100644 --- a/packages/extension/tests/extension/graphView/webview/settingsMessages/updates/filterPatterns.test.ts +++ b/packages/extension/tests/extension/graphView/webview/settingsMessages/updates/filterPatterns.test.ts @@ -3,7 +3,7 @@ import { applyFilterPatternsUpdate } from '../../../../../../src/extension/graph import { createHandlers, createState } from '../testSupport'; describe('settingsMessages/updates/filterPatterns', () => { - it('stores filter patterns and publishes plugin patterns', async () => { + it('stores filter patterns as projection state without reanalyzing', async () => { const state = createState(); const handlers = createHandlers({ getPluginFilterPatterns: vi.fn(() => ['venv/**']), @@ -27,6 +27,7 @@ describe('settingsMessages/updates/filterPatterns', () => { disabledPluginPatterns: [], }, }); - expect(handlers.analyzeAndSendData).toHaveBeenCalledOnce(); + expect(handlers.analyzeAndSendData).not.toHaveBeenCalled(); + expect(handlers.smartRebuild).not.toHaveBeenCalled(); }); }); From ed2ba47756df3643d98705d7896a3ced29500b83 Mon Sep 17 00:00:00 2001 From: joesobo Date: Thu, 25 Jun 2026 12:04:02 -0700 Subject: [PATCH 05/14] feat: classify plugin update impact --- codegraphy.schema.json | 33 ++++++++++++ .../plugins/installedPluginCache/bundled.ts | 1 + .../installedPluginCache/packageReader.ts | 5 +- .../plugins/installedPluginCache/record.ts | 5 ++ .../workspaceSelection.ts | 23 +++++++- .../core/src/plugins/markdown/metadata.ts | 4 ++ packages/core/src/plugins/packageManifest.ts | 2 + .../core/src/plugins/packageManifestBuild.ts | 5 ++ packages/core/src/plugins/updateImpact.ts | 53 +++++++++++++++++++ .../core/tests/plugins/installedCache.test.ts | 6 +++ .../installedPluginCache/bundled.test.ts | 4 ++ .../plugins/installedPluginRecord.test.ts | 8 +++ .../tests/plugins/packageManifest.test.ts | 14 +++++ .../tests/plugins/workspaceSelection.test.ts | 34 ++++++++++++ packages/extension/package.json | 1 + .../graphView/webview/dispatch/primary.ts | 2 + .../settingsContext/create.ts | 8 ++- .../settingsMessages/defaultOptions.ts | 18 +++++++ .../webview/settingsMessages/router.ts | 2 + .../webview/settingsMessages/toggle.ts | 6 +++ .../settingsMessages/defaultOptions.test.ts | 33 +++++++++++- .../webview/settingsMessages/toggle.test.ts | 47 ++++++++++++++++ packages/extension/tsconfig.json | 1 + packages/extension/tsconfig.tests.json | 1 + packages/plugin-api/src/index.ts | 2 + packages/plugin-api/src/plugin.ts | 25 +++++++++ packages/plugin-godot/codegraphy.json | 4 ++ packages/plugin-godot/src/plugin.ts | 1 + packages/plugin-markdown/codegraphy.json | 4 ++ packages/plugin-markdown/src/plugin.ts | 1 + packages/plugin-particles/codegraphy.json | 4 ++ packages/plugin-particles/src/plugin.ts | 2 + packages/plugin-svelte/codegraphy.json | 4 ++ packages/plugin-svelte/src/plugin.ts | 1 + packages/plugin-typescript/codegraphy.json | 4 ++ packages/plugin-typescript/src/plugin.ts | 1 + packages/plugin-unity/codegraphy.json | 4 ++ packages/plugin-unity/src/lifecycle.ts | 1 + packages/plugin-vue/codegraphy.json | 4 ++ packages/plugin-vue/src/plugin.ts | 1 + pnpm-lock.yaml | 3 ++ 41 files changed, 378 insertions(+), 4 deletions(-) create mode 100644 packages/core/src/plugins/updateImpact.ts diff --git a/codegraphy.schema.json b/codegraphy.schema.json index 5a3235a4e..8573e8376 100644 --- a/codegraphy.schema.json +++ b/codegraphy.schema.json @@ -79,6 +79,39 @@ "type": "array", "items": { "type": "string" }, "description": "Glob patterns to exclude from file discovery by default" + }, + "updateImpact": { + "type": "object", + "required": ["toggle"], + "additionalProperties": false, + "properties": { + "toggle": { + "$ref": "#/definitions/updateImpactLevel", + "description": "Impact of enabling or disabling the plugin" + }, + "defaultSetting": { + "$ref": "#/definitions/updateImpactLevel", + "description": "Fallback impact for plugin-owned settings" + }, + "settings": { + "type": "object", + "additionalProperties": { "$ref": "#/definitions/updateImpactLevel" }, + "description": "Per plugin-owned setting impact overrides" + } + }, + "description": "How plugin toggles and settings affect graph indexing and projection" + } + }, + "definitions": { + "updateImpactLevel": { + "type": "string", + "enum": [ + "view-only", + "settings-only", + "projection-only", + "reanalyze-plugin-files", + "requires-full-index" + ] } } } diff --git a/packages/core/src/plugins/installedPluginCache/bundled.ts b/packages/core/src/plugins/installedPluginCache/bundled.ts index b9be49a3d..4df1415d3 100644 --- a/packages/core/src/plugins/installedPluginCache/bundled.ts +++ b/packages/core/src/plugins/installedPluginCache/bundled.ts @@ -9,6 +9,7 @@ export function createBundledMarkdownInstalledPluginRecord(): CodeGraphyInstalle pluginId: CODEGRAPHY_MARKDOWN_PLUGIN_METADATA.id, pluginName: CODEGRAPHY_MARKDOWN_PLUGIN_METADATA.name, supportedExtensions: [...CODEGRAPHY_MARKDOWN_PLUGIN_METADATA.supportedExtensions], + updateImpact: { ...CODEGRAPHY_MARKDOWN_PLUGIN_METADATA.updateImpact }, disclosures: [...CODEGRAPHY_MARKDOWN_PLUGIN_METADATA.disclosures], packageRoot: '', }; diff --git a/packages/core/src/plugins/installedPluginCache/packageReader.ts b/packages/core/src/plugins/installedPluginCache/packageReader.ts index bdb3290d1..2f8664943 100644 --- a/packages/core/src/plugins/installedPluginCache/packageReader.ts +++ b/packages/core/src/plugins/installedPluginCache/packageReader.ts @@ -2,11 +2,12 @@ import * as fsPromises from 'node:fs/promises'; import * as path from 'node:path'; import { parseCodeGraphyPluginPackageManifest } from '../packageManifest'; import type { CodeGraphyInstalledPluginRecord } from './contracts'; +import { readPluginUpdateImpact } from '../updateImpact'; import { isRecord } from './values'; type PluginPackageDisplayFields = Pick< CodeGraphyInstalledPluginRecord, - 'pluginId' | 'pluginName' | 'supportedExtensions' + 'pluginId' | 'pluginName' | 'supportedExtensions' | 'updateImpact' >; interface PluginPackageStaticDescriptor extends PluginPackageDisplayFields { @@ -33,11 +34,13 @@ function buildPluginPackageDisplayFields( const pluginName = readString(descriptor.name); const supportedExtensions = readSupportedExtensions(descriptor.supportedExtensions); + const updateImpact = readPluginUpdateImpact(descriptor.updateImpact); return { pluginId, ...(pluginName ? { pluginName } : {}), ...(supportedExtensions.length > 0 ? { supportedExtensions } : {}), + ...(updateImpact ? { updateImpact } : {}), }; } diff --git a/packages/core/src/plugins/installedPluginCache/record.ts b/packages/core/src/plugins/installedPluginCache/record.ts index 978c3f124..18ede5a11 100644 --- a/packages/core/src/plugins/installedPluginCache/record.ts +++ b/packages/core/src/plugins/installedPluginCache/record.ts @@ -1,5 +1,6 @@ import { readDisclosures } from '../disclosures'; import type { CodeGraphyInstalledPluginRecord } from './contracts'; +import { readPluginUpdateImpact } from '../updateImpact'; import { isRecord } from './values'; type InstalledPluginRecordFields = Pick< @@ -51,6 +52,10 @@ function addOptionalInstalledPluginRecordFields( if (typeof value.pluginName === 'string' && value.pluginName.length > 0) { record.pluginName = value.pluginName; } + const updateImpact = readPluginUpdateImpact(value.updateImpact); + if (updateImpact) { + record.updateImpact = updateImpact; + } const supportedExtensions = readSupportedExtensions(value.supportedExtensions); if (supportedExtensions) { diff --git a/packages/core/src/plugins/installedPluginCache/workspaceSelection.ts b/packages/core/src/plugins/installedPluginCache/workspaceSelection.ts index ef2bfe903..ac8b5b34e 100644 --- a/packages/core/src/plugins/installedPluginCache/workspaceSelection.ts +++ b/packages/core/src/plugins/installedPluginCache/workspaceSelection.ts @@ -2,6 +2,7 @@ import { readCodeGraphyWorkspaceSettingsOrInitial, writeCodeGraphyWorkspaceSettings, } from '../../workspace/settings'; +import type { IPluginUpdateImpact, IPluginUpdateImpactPolicy } from '@codegraphy-dev/plugin-api'; import type { CodeGraphyWorkspacePluginSettings } from '../../workspace/settings'; import type { CodeGraphyInstalledPluginRecord } from './contracts'; @@ -9,11 +10,13 @@ export interface UpdateCodeGraphyWorkspacePluginSelectionOptions { pluginId: string; enabled: boolean; defaultOptions?: Record; + updateImpact?: IPluginUpdateImpactPolicy; } export type CodeGraphyWorkspacePluginToggleOptions = UpdateCodeGraphyWorkspacePluginSelectionOptions; export type CodeGraphyWorkspacePluginIndexingPlan = + | { kind: 'projection-only' } | { kind: 'analyze-workspace' } | { kind: 'reprocess-plugin-files'; pluginIds: string[] }; @@ -52,10 +55,27 @@ export function createCodeGraphyWorkspacePluginTogglePlan( ): CodeGraphyWorkspacePluginTogglePlan { return { plugins: updateCodeGraphyWorkspacePluginSelection(plugins, options), - indexing: { kind: 'analyze-workspace' }, + indexing: createPluginToggleIndexingPlan(options.pluginId, options.updateImpact?.toggle), }; } +function createPluginToggleIndexingPlan( + pluginId: string, + impact: IPluginUpdateImpact | undefined, +): CodeGraphyWorkspacePluginIndexingPlan { + switch (impact) { + case 'view-only': + case 'settings-only': + case 'projection-only': + return { kind: 'projection-only' }; + case 'reanalyze-plugin-files': + return { kind: 'reprocess-plugin-files', pluginIds: [pluginId] }; + case 'requires-full-index': + default: + return { kind: 'analyze-workspace' }; + } +} + export function enableCodeGraphyWorkspacePlugin( workspaceRoot: string, plugin: CodeGraphyInstalledPluginRecord, @@ -91,6 +111,7 @@ export function enableCodeGraphyWorkspacePlugin( pluginId, enabled: true, defaultOptions: plugin.defaultOptions, + updateImpact: plugin.updateImpact, }), }); } diff --git a/packages/core/src/plugins/markdown/metadata.ts b/packages/core/src/plugins/markdown/metadata.ts index 8524cb455..b42cdabe6 100644 --- a/packages/core/src/plugins/markdown/metadata.ts +++ b/packages/core/src/plugins/markdown/metadata.ts @@ -10,5 +10,9 @@ export const CODEGRAPHY_MARKDOWN_PLUGIN_METADATA = { version: '1.0.0', apiVersion: '^2.0.0', supportedExtensions: ['*'], + updateImpact: { + toggle: 'reanalyze-plugin-files', + defaultSetting: 'reanalyze-plugin-files', + }, disclosures: [], } as const; diff --git a/packages/core/src/plugins/packageManifest.ts b/packages/core/src/plugins/packageManifest.ts index 8d6d15176..762b486e6 100644 --- a/packages/core/src/plugins/packageManifest.ts +++ b/packages/core/src/plugins/packageManifest.ts @@ -1,5 +1,6 @@ import { satisfiesSemverRange } from './apiVersion'; import type { CodeGraphyPluginDisclosure } from './disclosures'; +import type { IPluginUpdateImpactPolicy } from '@codegraphy-dev/plugin-api'; import { CORE_PLUGIN_API_VERSION } from './api'; import { createCodeGraphyPluginPackageManifest } from './packageManifestBuild'; import { isRecord, readRequiredString } from './packageManifestValues'; @@ -11,6 +12,7 @@ export interface CodeGraphyPluginPackageManifest { version: string; apiVersion: string; defaultOptions?: Record; + updateImpact?: IPluginUpdateImpactPolicy; disclosures: CodeGraphyPluginDisclosure[]; } diff --git a/packages/core/src/plugins/packageManifestBuild.ts b/packages/core/src/plugins/packageManifestBuild.ts index f8c0e1059..d4e296beb 100644 --- a/packages/core/src/plugins/packageManifestBuild.ts +++ b/packages/core/src/plugins/packageManifestBuild.ts @@ -1,6 +1,7 @@ import { readDisclosures } from './disclosures'; import type { CodeGraphyPluginPackageManifest } from './packageManifest'; import { readDefaultOptions } from './packageManifestValues'; +import { readPluginUpdateImpact } from './updateImpact'; export function createCodeGraphyPluginPackageManifest(input: { apiVersion: string; @@ -18,6 +19,10 @@ export function createCodeGraphyPluginPackageManifest(input: { if (defaultOptions) { manifest.defaultOptions = defaultOptions; } + const updateImpact = readPluginUpdateImpact(input.codegraphy.updateImpact); + if (updateImpact) { + manifest.updateImpact = updateImpact; + } return manifest; } diff --git a/packages/core/src/plugins/updateImpact.ts b/packages/core/src/plugins/updateImpact.ts new file mode 100644 index 000000000..5c97b239e --- /dev/null +++ b/packages/core/src/plugins/updateImpact.ts @@ -0,0 +1,53 @@ +import type { + IPluginUpdateImpact, + IPluginUpdateImpactPolicy, +} from '@codegraphy-dev/plugin-api'; +import { isRecord } from './packageManifestValues'; + +const UPDATE_IMPACT_VALUES = new Set([ + 'view-only', + 'settings-only', + 'projection-only', + 'reanalyze-plugin-files', + 'requires-full-index', +]); + +function readImpact(value: unknown): IPluginUpdateImpact | undefined { + return typeof value === 'string' && UPDATE_IMPACT_VALUES.has(value as IPluginUpdateImpact) + ? value as IPluginUpdateImpact + : undefined; +} + +function readSettingsImpact(value: unknown): Record | undefined { + if (!isRecord(value)) { + return undefined; + } + + const settings = Object.fromEntries( + Object.entries(value) + .map(([key, impact]) => [key, readImpact(impact)] as const) + .filter((entry): entry is [string, IPluginUpdateImpact] => entry[1] !== undefined), + ); + + return Object.keys(settings).length > 0 ? settings : undefined; +} + +export function readPluginUpdateImpact(value: unknown): IPluginUpdateImpactPolicy | undefined { + if (!isRecord(value)) { + return undefined; + } + + const toggle = readImpact(value.toggle); + if (!toggle) { + return undefined; + } + + const defaultSetting = readImpact(value.defaultSetting); + const settings = readSettingsImpact(value.settings); + + return { + toggle, + ...(defaultSetting ? { defaultSetting } : {}), + ...(settings ? { settings } : {}), + }; +} diff --git a/packages/core/tests/plugins/installedCache.test.ts b/packages/core/tests/plugins/installedCache.test.ts index e88b8fdc7..b7760236a 100644 --- a/packages/core/tests/plugins/installedCache.test.ts +++ b/packages/core/tests/plugins/installedCache.test.ts @@ -192,6 +192,9 @@ describe('CodeGraphy Plugin Registry', () => { id: 'codegraphy.vue', name: 'Vue', supportedExtensions: ['.vue'], + updateImpact: { + toggle: 'reanalyze-plugin-files', + }, }); await createPackage(packageRoot, '@codegraphy-dev/not-a-plugin', { version: '1.0.0', @@ -206,6 +209,9 @@ describe('CodeGraphy Plugin Registry', () => { pluginId: 'codegraphy.vue', pluginName: 'Vue', supportedExtensions: ['.vue'], + updateImpact: { + toggle: 'reanalyze-plugin-files', + }, disclosures: [], packageRoot: pluginRoot, }); diff --git a/packages/core/tests/plugins/installedPluginCache/bundled.test.ts b/packages/core/tests/plugins/installedPluginCache/bundled.test.ts index f0296ea42..2c2676e65 100644 --- a/packages/core/tests/plugins/installedPluginCache/bundled.test.ts +++ b/packages/core/tests/plugins/installedPluginCache/bundled.test.ts @@ -23,6 +23,10 @@ describe('plugins/installedPluginCache/bundled', () => { pluginId: 'codegraphy.markdown', pluginName: 'Markdown', supportedExtensions: ['*'], + updateImpact: { + toggle: 'reanalyze-plugin-files', + defaultSetting: 'reanalyze-plugin-files', + }, disclosures: [], packageRoot: '', }); diff --git a/packages/core/tests/plugins/installedPluginRecord.test.ts b/packages/core/tests/plugins/installedPluginRecord.test.ts index cd465753d..d9732e8ce 100644 --- a/packages/core/tests/plugins/installedPluginRecord.test.ts +++ b/packages/core/tests/plugins/installedPluginRecord.test.ts @@ -20,6 +20,10 @@ describe('plugins/installedPluginCache record normalization', () => { packageRoot: '/global/plugin-vue', disclosures: ['network', 'invalid'], defaultOptions: { includeTests: true }, + updateImpact: { + toggle: 'projection-only', + defaultSetting: 'settings-only', + }, })).toEqual({ package: '@codegraphy-dev/plugin-vue', version: '1.0.0', @@ -27,6 +31,10 @@ describe('plugins/installedPluginCache record normalization', () => { packageRoot: '/global/plugin-vue', disclosures: ['network'], defaultOptions: { includeTests: true }, + updateImpact: { + toggle: 'projection-only', + defaultSetting: 'settings-only', + }, }); }); diff --git a/packages/core/tests/plugins/packageManifest.test.ts b/packages/core/tests/plugins/packageManifest.test.ts index c8dffe5e9..e1fb71fca 100644 --- a/packages/core/tests/plugins/packageManifest.test.ts +++ b/packages/core/tests/plugins/packageManifest.test.ts @@ -28,6 +28,13 @@ describe('CodeGraphy plugin package manifest', () => { defaultOptions: { includeTests: true, }, + updateImpact: { + toggle: 'reanalyze-plugin-files', + defaultSetting: 'settings-only', + settings: { + includeTests: 'reanalyze-plugin-files', + }, + }, disclosures: ['network'], }, })).toEqual({ @@ -37,6 +44,13 @@ describe('CodeGraphy plugin package manifest', () => { defaultOptions: { includeTests: true, }, + updateImpact: { + toggle: 'reanalyze-plugin-files', + defaultSetting: 'settings-only', + settings: { + includeTests: 'reanalyze-plugin-files', + }, + }, disclosures: ['network'], }); }); diff --git a/packages/core/tests/plugins/workspaceSelection.test.ts b/packages/core/tests/plugins/workspaceSelection.test.ts index 3d4cea2b3..b5a08cabe 100644 --- a/packages/core/tests/plugins/workspaceSelection.test.ts +++ b/packages/core/tests/plugins/workspaceSelection.test.ts @@ -92,6 +92,40 @@ describe('plugins/workspaceSelection', () => { }); }); + it('plans projection-only work when plugin metadata says toggles do not affect indexed evidence', () => { + const plan = createCodeGraphyWorkspacePluginTogglePlan([], { + pluginId: 'codegraphy.particles', + enabled: true, + updateImpact: { + toggle: 'projection-only', + }, + }); + + expect(plan).toEqual({ + plugins: [ + { id: 'codegraphy.particles', enabled: true }, + ], + indexing: { + kind: 'projection-only', + }, + }); + }); + + it('plans targeted plugin-file analysis when plugin metadata says toggles affect plugin evidence', () => { + const plan = createCodeGraphyWorkspacePluginTogglePlan([], { + pluginId: 'codegraphy.vue', + enabled: true, + updateImpact: { + toggle: 'reanalyze-plugin-files', + }, + }); + + expect(plan.indexing).toEqual({ + kind: 'reprocess-plugin-files', + pluginIds: ['codegraphy.vue'], + }); + }); + it('plans a workspace analysis refresh when disabling a plugin id', () => { const plan = createCodeGraphyWorkspacePluginTogglePlan([ { id: 'codegraphy.markdown', enabled: true }, diff --git a/packages/extension/package.json b/packages/extension/package.json index 77559b2a1..bea92cd68 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -29,6 +29,7 @@ }, "dependencies": { "@codegraphy-dev/core": "workspace:*", + "@codegraphy-dev/plugin-api": "workspace:*", "@driftlog/tree-sitter-dart": "1.0.4", "@floating-ui/react": "^0.27.19", "@ladybugdb/core": "^0.15.3", diff --git a/packages/extension/src/extension/graphView/webview/dispatch/primary.ts b/packages/extension/src/extension/graphView/webview/dispatch/primary.ts index 6d82a9cf8..f619b19db 100644 --- a/packages/extension/src/extension/graphView/webview/dispatch/primary.ts +++ b/packages/extension/src/extension/graphView/webview/dispatch/primary.ts @@ -2,6 +2,7 @@ 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'; +import type { IPluginUpdateImpactPolicy } from '@codegraphy-dev/plugin-api'; import type { IGroup } from '../../../../shared/settings/groups'; import type { DagMode, NodeSizeMode } from '../../../../shared/settings/modes'; import type { IPhysicsSettings } from '../../../../shared/settings/physics'; @@ -86,6 +87,7 @@ export interface GraphViewPrimaryMessageContext { getConfig(key: string, defaultValue: T): T; updateConfig(key: string, value: unknown): Promise; getInstalledPluginDefaultOptions?(pluginId: string): Record | undefined; + getInstalledPluginUpdateImpact?(pluginId: string): IPluginUpdateImpactPolicy | undefined; reloadWorkspacePlugins(): Promise; syncWorkspacePlugins?(): Promise; sendPluginStatuses?(): void; diff --git a/packages/extension/src/extension/graphView/webview/providerMessages/settingsContext/create.ts b/packages/extension/src/extension/graphView/webview/providerMessages/settingsContext/create.ts index 2e6342f3a..7783a9ddf 100644 --- a/packages/extension/src/extension/graphView/webview/providerMessages/settingsContext/create.ts +++ b/packages/extension/src/extension/graphView/webview/providerMessages/settingsContext/create.ts @@ -6,7 +6,10 @@ import type { } from '../listener'; import { createSettingsConfigPersistence } from './persistence'; import { reprocessPluginFiles } from './pluginFiles'; -import { readInstalledPluginDefaultOptions } from '../../settingsMessages/defaultOptions'; +import { + readInstalledPluginDefaultOptions, + readInstalledPluginUpdateImpact, +} from '../../settingsMessages/defaultOptions'; type GraphViewProviderSettingsContext = Pick< GraphViewMessageListenerContext, @@ -16,6 +19,7 @@ type GraphViewProviderSettingsContext = Pick< | 'getConfig' | 'updateConfig' | 'getInstalledPluginDefaultOptions' + | 'getInstalledPluginUpdateImpact' | 'reloadWorkspacePlugins' | 'syncWorkspacePlugins' | 'sendPluginStatuses' @@ -76,6 +80,8 @@ export function createGraphViewProviderMessageSettingsContext( updateConfig: async (key, value) => persistConfig(key, value), getInstalledPluginDefaultOptions: (pluginId: string) => readInstalledPluginDefaultOptions(pluginId), + getInstalledPluginUpdateImpact: (pluginId: string) => + readInstalledPluginUpdateImpact(pluginId), reloadWorkspacePlugins: () => { const analyzer = source._analyzer; if (!analyzer?.reloadWorkspacePlugins) { diff --git a/packages/extension/src/extension/graphView/webview/settingsMessages/defaultOptions.ts b/packages/extension/src/extension/graphView/webview/settingsMessages/defaultOptions.ts index e72bf30cc..bd452cef5 100644 --- a/packages/extension/src/extension/graphView/webview/settingsMessages/defaultOptions.ts +++ b/packages/extension/src/extension/graphView/webview/settingsMessages/defaultOptions.ts @@ -2,6 +2,7 @@ import { readCodeGraphyInstalledPluginCache, type CodeGraphyUserStateOptions, } from '@codegraphy-dev/core'; +import type { IPluginUpdateImpactPolicy } from '@codegraphy-dev/plugin-api'; export function readInstalledPluginDefaultOptions( pluginId: string, @@ -14,3 +15,20 @@ export function readInstalledPluginDefaultOptions( return defaultOptions ? { ...defaultOptions } : undefined; } + +export function readInstalledPluginUpdateImpact( + pluginId: string, + options: CodeGraphyUserStateOptions = {}, +): IPluginUpdateImpactPolicy | undefined { + const updateImpact = readCodeGraphyInstalledPluginCache(options) + .plugins + .find(plugin => (plugin.pluginId ?? plugin.package) === pluginId) + ?.updateImpact; + + return updateImpact + ? { + ...updateImpact, + ...(updateImpact.settings ? { settings: { ...updateImpact.settings } } : {}), + } + : undefined; +} diff --git a/packages/extension/src/extension/graphView/webview/settingsMessages/router.ts b/packages/extension/src/extension/graphView/webview/settingsMessages/router.ts index e366e933c..7857677f0 100644 --- a/packages/extension/src/extension/graphView/webview/settingsMessages/router.ts +++ b/packages/extension/src/extension/graphView/webview/settingsMessages/router.ts @@ -1,5 +1,6 @@ import type { ExtensionToWebviewMessage } from '../../../../shared/protocol/extensionToWebview'; import type { IPluginFilterPatternGroup } from '../../../../shared/protocol/extensionToWebview'; +import type { IPluginUpdateImpactPolicy } from '@codegraphy-dev/plugin-api'; import type { WebviewToExtensionMessage } from '../../../../shared/protocol/webviewToExtension'; import type * as vscode from 'vscode'; import { applySettingsUpdateMessage } from './updates/apply'; @@ -17,6 +18,7 @@ export interface GraphViewSettingsMessageHandlers { getConfig(key: string, defaultValue: T): T; updateConfig(key: string, value: unknown): Promise; getInstalledPluginDefaultOptions?(pluginId: string): Record | undefined; + getInstalledPluginUpdateImpact?(pluginId: string): IPluginUpdateImpactPolicy | undefined; reloadWorkspacePlugins(): Promise; syncWorkspacePlugins?(): Promise; sendPluginStatuses?(): void; diff --git a/packages/extension/src/extension/graphView/webview/settingsMessages/toggle.ts b/packages/extension/src/extension/graphView/webview/settingsMessages/toggle.ts index 2dfbb213d..09d746822 100644 --- a/packages/extension/src/extension/graphView/webview/settingsMessages/toggle.ts +++ b/packages/extension/src/extension/graphView/webview/settingsMessages/toggle.ts @@ -24,6 +24,7 @@ export async function applySettingsToggleMessage( defaultOptions: message.payload.enabled ? handlers.getInstalledPluginDefaultOptions?.(message.payload.pluginId) : undefined, + updateImpact: handlers.getInstalledPluginUpdateImpact?.(message.payload.pluginId), }, ); await handlers.updateConfig('plugins', plan.plugins); @@ -48,6 +49,11 @@ export async function applySettingsToggleMessage( return true; } + if (plan.indexing.kind === 'projection-only') { + handlers.smartRebuild(message.payload.pluginId); + return true; + } + handlers.smartRebuild(message.payload.pluginId); return true; } diff --git a/packages/extension/tests/extension/graphView/webview/settingsMessages/defaultOptions.test.ts b/packages/extension/tests/extension/graphView/webview/settingsMessages/defaultOptions.test.ts index 74885b364..e3babb1f9 100644 --- a/packages/extension/tests/extension/graphView/webview/settingsMessages/defaultOptions.test.ts +++ b/packages/extension/tests/extension/graphView/webview/settingsMessages/defaultOptions.test.ts @@ -3,7 +3,10 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { writeCodeGraphyInstalledPluginCache } from '@codegraphy-dev/core'; -import { readInstalledPluginDefaultOptions } from '../../../../../src/extension/graphView/webview/settingsMessages/defaultOptions'; +import { + readInstalledPluginDefaultOptions, + readInstalledPluginUpdateImpact, +} from '../../../../../src/extension/graphView/webview/settingsMessages/defaultOptions'; describe('graph view settings plugin default options', () => { let homeDir: string; @@ -89,4 +92,32 @@ describe('graph view settings plugin default options', () => { expect(readInstalledPluginDefaultOptions('codegraphy.vue', { homeDir })).toBeUndefined(); }); + + it('reads plugin update impact metadata from the installed plugin cache', () => { + writeCodeGraphyInstalledPluginCache( + { + version: 1, + plugins: [ + { + package: '@codegraphy-dev/plugin-particles', + pluginId: 'codegraphy.particles', + version: '0.2.1', + apiVersion: '^2.0.0', + disclosures: [], + packageRoot: '/global/node_modules/@codegraphy-dev/plugin-particles', + updateImpact: { + toggle: 'projection-only', + defaultSetting: 'settings-only', + }, + }, + ], + }, + { homeDir }, + ); + + expect(readInstalledPluginUpdateImpact('codegraphy.particles', { homeDir })).toEqual({ + toggle: 'projection-only', + defaultSetting: 'settings-only', + }); + }); }); diff --git a/packages/extension/tests/extension/graphView/webview/settingsMessages/toggle.test.ts b/packages/extension/tests/extension/graphView/webview/settingsMessages/toggle.test.ts index 6ca057534..a02b209a8 100644 --- a/packages/extension/tests/extension/graphView/webview/settingsMessages/toggle.test.ts +++ b/packages/extension/tests/extension/graphView/webview/settingsMessages/toggle.test.ts @@ -30,6 +30,7 @@ function createHandlers( sendPluginToolbarActions: vi.fn(), sendGraphViewContributionStatuses: vi.fn(), sendPluginWebviewInjections: vi.fn(), + getInstalledPluginUpdateImpact: vi.fn(() => undefined), analyzeAndSendData: vi.fn(() => Promise.resolve()), smartRebuild: vi.fn(), getPluginFilterPatterns: vi.fn(() => []), @@ -69,6 +70,52 @@ describe('graph view settings toggle message', () => { expect(handlers.reprocessPluginFiles).not.toHaveBeenCalled(); }); + it('uses projection-only plugin impact metadata without scheduling index work', async () => { + const state = createState(); + const handlers = createHandlers({ + getInstalledPluginUpdateImpact: vi.fn(() => ({ + toggle: 'projection-only' as const, + })), + }); + + const handled = await applySettingsToggleMessage( + { + type: 'TOGGLE_PLUGIN', + payload: { pluginId: 'codegraphy.particles', enabled: true }, + }, + state, + handlers, + ); + + expect(handled).toBe(true); + expect(handlers.analyzeAndSendData).not.toHaveBeenCalled(); + expect(handlers.reprocessPluginFiles).not.toHaveBeenCalled(); + expect(handlers.smartRebuild).toHaveBeenCalledWith('codegraphy.particles'); + }); + + it('uses plugin analysis impact metadata for targeted plugin-file reprocessing', async () => { + const state = createState(); + const handlers = createHandlers({ + getInstalledPluginUpdateImpact: vi.fn(() => ({ + toggle: 'reanalyze-plugin-files' as const, + })), + }); + + const handled = await applySettingsToggleMessage( + { + type: 'TOGGLE_PLUGIN', + payload: { pluginId: 'codegraphy.vue', enabled: true }, + }, + state, + handlers, + ); + + expect(handled).toBe(true); + expect(handlers.reprocessPluginFiles).toHaveBeenCalledWith(['codegraphy.vue']); + expect(handlers.analyzeAndSendData).not.toHaveBeenCalled(); + expect(handlers.smartRebuild).not.toHaveBeenCalled(); + }); + it('disables package-backed plugins by persisting disabled plugin id intent', async () => { const state = createState(); const handlers = createHandlers({ diff --git a/packages/extension/tsconfig.json b/packages/extension/tsconfig.json index 605072774..c06756406 100644 --- a/packages/extension/tsconfig.json +++ b/packages/extension/tsconfig.json @@ -20,6 +20,7 @@ "baseUrl": ".", "paths": { "@codegraphy-dev/core": ["../core/src/index.ts"], + "@codegraphy-dev/plugin-api": ["../plugin-api/src/index.ts"], "@codegraphy-dev/plugin-markdown": ["../plugin-markdown/src/plugin.ts"], "@/*": ["./src/*"] } diff --git a/packages/extension/tsconfig.tests.json b/packages/extension/tsconfig.tests.json index 3e32cf0fe..04d12bc5b 100644 --- a/packages/extension/tsconfig.tests.json +++ b/packages/extension/tsconfig.tests.json @@ -6,6 +6,7 @@ "types": ["vitest/globals", "node"], "paths": { "@codegraphy-dev/core": ["../core/src/index.ts"], + "@codegraphy-dev/plugin-api": ["../plugin-api/src/index.ts"], "@codegraphy-dev/plugin-markdown": ["../plugin-markdown/src/plugin.ts"], "@/*": ["./src/*"], "react-force-graph-2d": ["./tests/types/react-force-graph-2d"], diff --git a/packages/plugin-api/src/index.ts b/packages/plugin-api/src/index.ts index 9153facaf..a04509afa 100644 --- a/packages/plugin-api/src/index.ts +++ b/packages/plugin-api/src/index.ts @@ -149,6 +149,8 @@ export type { IPluginFactory, IPluginFactoryOptions, IPluginHostApi, + IPluginUpdateImpact, + IPluginUpdateImpactPolicy, IPluginToolbarAction, IPluginToolbarActionItem, IPluginWebviewAsset, diff --git a/packages/plugin-api/src/plugin.ts b/packages/plugin-api/src/plugin.ts index 2c0251184..bd9543d82 100644 --- a/packages/plugin-api/src/plugin.ts +++ b/packages/plugin-api/src/plugin.ts @@ -145,6 +145,28 @@ export interface IPluginFactoryOptions { export type IPluginFactory = (options?: IPluginFactoryOptions) => IPlugin | Promise; +export type IPluginUpdateImpact = + | 'view-only' + | 'settings-only' + | 'projection-only' + | 'reanalyze-plugin-files' + | 'requires-full-index'; + +export interface IPluginUpdateImpactPolicy { + /** + * Impact of enabling or disabling the plugin. + * + * Plugins that only contribute UI/runtime projection should use + * `projection-only`. Plugins that emit per-file indexed evidence should use + * `reanalyze-plugin-files` unless a toggle truly invalidates the whole index. + */ + toggle: IPluginUpdateImpact; + /** Fallback impact for plugin-owned setting keys that are not listed below. */ + defaultSetting?: IPluginUpdateImpact; + /** Per plugin-owned setting impact overrides. */ + settings?: Record; +} + /** * The main plugin interface for CodeGraphy. * @@ -222,6 +244,9 @@ export interface IPlugin { /** Optional webview scripts and styles loaded into CodeGraphy Graph View. */ webviewContributions?: IPluginWebviewContributions; + /** Declares how plugin toggles and plugin-owned settings affect graph work. */ + updateImpact?: IPluginUpdateImpactPolicy; + // --------------------------------------------------------------------------- // Core analysis contract // --------------------------------------------------------------------------- diff --git a/packages/plugin-godot/codegraphy.json b/packages/plugin-godot/codegraphy.json index ac2dc4067..ca2ba0c5f 100644 --- a/packages/plugin-godot/codegraphy.json +++ b/packages/plugin-godot/codegraphy.json @@ -18,6 +18,10 @@ "**/addons/**", "**/*.uid" ], + "updateImpact": { + "toggle": "reanalyze-plugin-files", + "defaultSetting": "reanalyze-plugin-files" + }, "sources": [ { "id": "preload", diff --git a/packages/plugin-godot/src/plugin.ts b/packages/plugin-godot/src/plugin.ts index 1c6cee513..029550db9 100644 --- a/packages/plugin-godot/src/plugin.ts +++ b/packages/plugin-godot/src/plugin.ts @@ -61,6 +61,7 @@ class GDScriptPlugin implements IGDScriptAnalyzeFilePlugin { readonly apiVersion = manifest.apiVersion; readonly supportedExtensions = manifest.supportedExtensions; readonly defaultFilters = manifest.defaultFilters; + readonly updateImpact = manifest.updateImpact as IGDScriptAnalyzeFilePlugin['updateImpact']; readonly sources = manifest.sources; contributeGraphScopeCapabilities(): IPluginGraphScopeCapabilities { diff --git a/packages/plugin-markdown/codegraphy.json b/packages/plugin-markdown/codegraphy.json index 6fc4397f3..e758b1711 100644 --- a/packages/plugin-markdown/codegraphy.json +++ b/packages/plugin-markdown/codegraphy.json @@ -8,6 +8,10 @@ "*" ], "defaultFilters": [], + "updateImpact": { + "toggle": "reanalyze-plugin-files", + "defaultSetting": "reanalyze-plugin-files" + }, "fileColors": {}, "sources": [ { diff --git a/packages/plugin-markdown/src/plugin.ts b/packages/plugin-markdown/src/plugin.ts index 0cdd7ab43..9b7f6d177 100644 --- a/packages/plugin-markdown/src/plugin.ts +++ b/packages/plugin-markdown/src/plugin.ts @@ -50,6 +50,7 @@ export function createMarkdownPlugin(): IPlugin { apiVersion: manifest.apiVersion, supportedExtensions: manifest.supportedExtensions, defaultFilters: manifest.defaultFilters, + updateImpact: manifest.updateImpact as IPlugin['updateImpact'], sources: manifest.sources, fileColors: manifest.fileColors, contributeGraphScopeCapabilities: () => ({ edgeTypes: ['reference'] }), diff --git a/packages/plugin-particles/codegraphy.json b/packages/plugin-particles/codegraphy.json index 30c337681..5857fb182 100644 --- a/packages/plugin-particles/codegraphy.json +++ b/packages/plugin-particles/codegraphy.json @@ -6,5 +6,9 @@ "apiVersion": "^2.0.0", "supportedExtensions": [], "defaultFilters": [], + "updateImpact": { + "toggle": "projection-only", + "defaultSetting": "settings-only" + }, "fileColors": {} } diff --git a/packages/plugin-particles/src/plugin.ts b/packages/plugin-particles/src/plugin.ts index 945e2f99c..278f66375 100644 --- a/packages/plugin-particles/src/plugin.ts +++ b/packages/plugin-particles/src/plugin.ts @@ -1,4 +1,5 @@ import manifest from '../codegraphy.json'; +import type { IPlugin } from '@codegraphy-dev/plugin-api'; import { compileCustomParticleEffects } from './customEffects'; export function createParticlesPlugin() { @@ -13,6 +14,7 @@ export function createParticlesPlugin() { version: manifest.version, apiVersion: manifest.apiVersion, supportedExtensions: manifest.supportedExtensions, + updateImpact: manifest.updateImpact as IPlugin['updateImpact'], webviewApiVersion: '^1.0.0', webviewContributions, async initialize(workspaceRoot: string) { diff --git a/packages/plugin-svelte/codegraphy.json b/packages/plugin-svelte/codegraphy.json index f59b7afc0..b22500072 100644 --- a/packages/plugin-svelte/codegraphy.json +++ b/packages/plugin-svelte/codegraphy.json @@ -10,5 +10,9 @@ "defaultFilters": [ "**/.svelte-kit/**" ], + "updateImpact": { + "toggle": "reanalyze-plugin-files", + "defaultSetting": "reanalyze-plugin-files" + }, "fileColors": {} } diff --git a/packages/plugin-svelte/src/plugin.ts b/packages/plugin-svelte/src/plugin.ts index ab173f33c..93cd2a29f 100644 --- a/packages/plugin-svelte/src/plugin.ts +++ b/packages/plugin-svelte/src/plugin.ts @@ -10,6 +10,7 @@ export function createSveltePlugin(): IPlugin { apiVersion: manifest.apiVersion, supportedExtensions: manifest.supportedExtensions, defaultFilters: manifest.defaultFilters, + updateImpact: manifest.updateImpact as IPlugin['updateImpact'], fileColors: manifest.fileColors, contributeGraphScopeCapabilities: () => ({ edgeTypes: ['import', 'type-import', 'call'], diff --git a/packages/plugin-typescript/codegraphy.json b/packages/plugin-typescript/codegraphy.json index b4d5d2dc3..4795adf41 100644 --- a/packages/plugin-typescript/codegraphy.json +++ b/packages/plugin-typescript/codegraphy.json @@ -22,5 +22,9 @@ "**/coverage/**", "**/.turbo/**" ], + "updateImpact": { + "toggle": "reanalyze-plugin-files", + "defaultSetting": "reanalyze-plugin-files" + }, "fileColors": {} } diff --git a/packages/plugin-typescript/src/plugin.ts b/packages/plugin-typescript/src/plugin.ts index 6e5f60d74..db9f85b0f 100644 --- a/packages/plugin-typescript/src/plugin.ts +++ b/packages/plugin-typescript/src/plugin.ts @@ -24,6 +24,7 @@ export function createTypeScriptPlugin(): IPlugin { apiVersion: manifest.apiVersion, supportedExtensions: manifest.supportedExtensions, defaultFilters: manifest.defaultFilters, + updateImpact: manifest.updateImpact as IPlugin['updateImpact'], fileColors: manifest.fileColors, contributeEdgeTypes: () => [TYPESCRIPT_ALIAS_IMPORT_EDGE_TYPE], contributeGraphScopeCapabilities: () => ({ diff --git a/packages/plugin-unity/codegraphy.json b/packages/plugin-unity/codegraphy.json index 1be2eecba..b85f5cbf4 100644 --- a/packages/plugin-unity/codegraphy.json +++ b/packages/plugin-unity/codegraphy.json @@ -54,6 +54,10 @@ "**/Assets/StreamingAssets/aa.meta", "**/Assets/StreamingAssets/aa/**" ], + "updateImpact": { + "toggle": "reanalyze-plugin-files", + "defaultSetting": "reanalyze-plugin-files" + }, "fileColors": { "*.unity": { "color": "#F97316", diff --git a/packages/plugin-unity/src/lifecycle.ts b/packages/plugin-unity/src/lifecycle.ts index 1f5c41b9f..e4fc8508f 100644 --- a/packages/plugin-unity/src/lifecycle.ts +++ b/packages/plugin-unity/src/lifecycle.ts @@ -20,6 +20,7 @@ class UnityPlugin implements IPlugin { readonly apiVersion = manifest.apiVersion; readonly supportedExtensions = manifest.supportedExtensions; readonly defaultFilters = manifest.defaultFilters; + readonly updateImpact = manifest.updateImpact as IPlugin['updateImpact']; readonly fileColors = manifest.fileColors as IPlugin['fileColors']; readonly sources = manifest.sources; diff --git a/packages/plugin-vue/codegraphy.json b/packages/plugin-vue/codegraphy.json index 92bd5b1e7..17f5f724d 100644 --- a/packages/plugin-vue/codegraphy.json +++ b/packages/plugin-vue/codegraphy.json @@ -8,5 +8,9 @@ ".vue" ], "defaultFilters": [], + "updateImpact": { + "toggle": "reanalyze-plugin-files", + "defaultSetting": "reanalyze-plugin-files" + }, "fileColors": {} } diff --git a/packages/plugin-vue/src/plugin.ts b/packages/plugin-vue/src/plugin.ts index d9544f875..a88c2609c 100644 --- a/packages/plugin-vue/src/plugin.ts +++ b/packages/plugin-vue/src/plugin.ts @@ -10,6 +10,7 @@ export function createVuePlugin(): IPlugin { apiVersion: manifest.apiVersion, supportedExtensions: manifest.supportedExtensions, defaultFilters: manifest.defaultFilters, + updateImpact: manifest.updateImpact as IPlugin['updateImpact'], fileColors: manifest.fileColors, contributeGraphScopeCapabilities: () => ({ edgeTypes: ['import', 'type-import', 'call'], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 372cdf852..36017fad6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -211,6 +211,9 @@ importers: '@codegraphy-dev/core': specifier: workspace:* version: link:../core + '@codegraphy-dev/plugin-api': + specifier: workspace:* + version: link:../plugin-api '@driftlog/tree-sitter-dart': specifier: 1.0.4 version: 1.0.4(node-addon-api@8.7.0)(tree-sitter@0.25.0(patch_hash=581e1c376edeabe3d25b64ea7bac59e14cc731627b17b97acccf0cce1dd051cb)) From 5a3152c60592f4908039e42b46ba59137d1d79bf Mon Sep 17 00:00:00 2001 From: joesobo Date: Thu, 25 Jun 2026 12:08:55 -0700 Subject: [PATCH 06/14] feat: patch graph cache deletions --- .../core/src/indexing/refresh/contracts.ts | 5 +- .../indexing/refresh/modes/changedFiles.ts | 65 +++++++++++++++---- .../refresh/modes/changedFiles.test.ts | 53 ++++++++++++++- .../pipeline/service/lifecycleFacade.ts | 7 +- .../pipeline/service/refresh/source.ts | 5 +- .../pipeline/service/refreshFacade.ts | 5 +- .../pipeline/service/lifecycleFacade.test.ts | 10 +++ 7 files changed, 133 insertions(+), 17 deletions(-) diff --git a/packages/core/src/indexing/refresh/contracts.ts b/packages/core/src/indexing/refresh/contracts.ts index cbcb88ca0..d178e8728 100644 --- a/packages/core/src/indexing/refresh/contracts.ts +++ b/packages/core/src/indexing/refresh/contracts.ts @@ -56,7 +56,10 @@ export interface WorkspaceIndexRefreshSource { signal?: AbortSignal, onProgress?: (progress: { phase: string; current: number; total: number }) => void, ): Promise; - invalidateWorkspaceFiles(filePaths: readonly string[]): string[]; + invalidateWorkspaceFiles( + filePaths: readonly string[], + options?: { persist?: boolean }, + ): string[]; } export interface WorkspaceIndexRefreshDependencies { diff --git a/packages/core/src/indexing/refresh/modes/changedFiles.ts b/packages/core/src/indexing/refresh/modes/changedFiles.ts index 39a98d963..c8fe8ae61 100644 --- a/packages/core/src/indexing/refresh/modes/changedFiles.ts +++ b/packages/core/src/indexing/refresh/modes/changedFiles.ts @@ -30,20 +30,25 @@ export async function refreshWorkspaceIndexChangedFiles( dependencies.filePaths, discoveredByRelativePath, ); + const deletionSelection = invalidateDeletedWorkspaceIndexFiles( + source, + changeSelection.unmatchedFilePaths, + ); + const deleteFilePaths = deletionSelection.deleteFilePaths; const changedFiles = changeSelection.files; - if (changeSelection.unmatchedFilePaths.length > 0) { - source.invalidateWorkspaceFiles(changeSelection.unmatchedFilePaths); + if (deletionSelection.unmatchedFilePaths.length > 0) { return analyzeWorkspaceIndexFromRefresh(source, dependencies); } - const changedAnalysisFiles = await source._readAnalysisFiles(changedFiles); - const incrementalLifecycle = await dependencies.notifyFilesChanged( - changedAnalysisFiles, - dependencies.workspaceRoot, - undefined, - dependencies.disabledPlugins, - ); + const incrementalLifecycle = changedFiles.length > 0 + ? await dependencies.notifyFilesChanged( + await source._readAnalysisFiles(changedFiles), + dependencies.workspaceRoot, + undefined, + dependencies.disabledPlugins, + ) + : { additionalFilePaths: [], requiresFullRefresh: false }; if (incrementalLifecycle.requiresFullRefresh) { return analyzeWorkspaceIndexFromRefresh(source, dependencies); @@ -60,6 +65,13 @@ export async function refreshWorkspaceIndexChangedFiles( retainWorkspaceIndexDiscoveredFileConnections(source, dependencies.discoveredFiles); if (filesToAnalyze.length === 0) { + if (deleteFilePaths.length > 0) { + persistChangedFilesCachePatch(dependencies, { + deleteFilePaths, + upsertFilePaths: [], + }); + await dependencies.persistIndexMetadata(); + } return buildWorkspaceIndexGraphFromRefreshState( source, dependencies.workspaceRoot, @@ -68,7 +80,10 @@ export async function refreshWorkspaceIndexChangedFiles( } const graphSnapshot = captureWorkspaceIndexRefreshGraphSnapshot(source, filesToAnalyze); - source.invalidateWorkspaceFiles(filesToAnalyze.map((file) => file.absolutePath)); + source.invalidateWorkspaceFiles( + filesToAnalyze.map((file) => file.absolutePath), + { persist: false }, + ); dependencies.onProgress?.({ phase: 'Applying Changes', current: 0, @@ -93,7 +108,7 @@ export async function refreshWorkspaceIndexChangedFiles( applyWorkspaceIndexAnalysisResult(source, analysisResult); persistChangedFilesCachePatch(dependencies, { - deleteFilePaths: [], + deleteFilePaths, upsertFilePaths: filesToAnalyze.map(file => file.relativePath), }); if ( @@ -119,6 +134,34 @@ export async function refreshWorkspaceIndexChangedFiles( return graphData; } +function invalidateDeletedWorkspaceIndexFiles( + source: WorkspaceIndexRefreshSource, + filePaths: readonly string[], +): { + deleteFilePaths: string[]; + unmatchedFilePaths: string[]; +} { + const deleteFilePaths = new Set(); + const unmatchedFilePaths: string[] = []; + + for (const filePath of filePaths) { + const invalidatedFilePaths = source.invalidateWorkspaceFiles([filePath], { persist: false }); + if (invalidatedFilePaths.length === 0) { + unmatchedFilePaths.push(filePath); + continue; + } + + for (const invalidatedFilePath of invalidatedFilePaths) { + deleteFilePaths.add(invalidatedFilePath); + } + } + + return { + deleteFilePaths: [...deleteFilePaths], + unmatchedFilePaths, + }; +} + function persistChangedFilesCachePatch( dependencies: WorkspaceIndexRefreshDependencies, patch: { diff --git a/packages/core/tests/indexing/refresh/modes/changedFiles.test.ts b/packages/core/tests/indexing/refresh/modes/changedFiles.test.ts index f4b4844c1..97a808316 100644 --- a/packages/core/tests/indexing/refresh/modes/changedFiles.test.ts +++ b/packages/core/tests/indexing/refresh/modes/changedFiles.test.ts @@ -45,7 +45,7 @@ describe('indexing/refresh/modes/changedFiles', () => { expect(source.invalidateWorkspaceFiles).toHaveBeenCalledWith([ '/workspace/src/app.ts', '/workspace/src/generated.ts', - ]); + ], { persist: false }); expect(onProgress).toHaveBeenNthCalledWith(1, { phase: 'Applying Changes', current: 0, @@ -108,6 +108,7 @@ describe('indexing/refresh/modes/changedFiles', () => { it('persists changed files through a targeted Graph Cache patch instead of a full cache save', async () => { const persistCache = vi.fn(); const persistCachePatch = vi.fn(); + const invalidateWorkspaceFiles = vi.fn(() => ['src/app.ts']); const source = createSource({ _analyzeFiles: vi.fn(async (files: IDiscoveredFile[]) => ({ cacheHits: 0, @@ -115,6 +116,7 @@ describe('indexing/refresh/modes/changedFiles', () => { fileAnalysis: new Map([['src/app.ts', createFileAnalysis('/workspace/src/app.ts')]]), fileConnections: new Map([['src/app.ts', []]]), })), + invalidateWorkspaceFiles, }); await refreshWorkspaceIndexChangedFiles(source, refreshOptions({ @@ -128,6 +130,55 @@ describe('indexing/refresh/modes/changedFiles', () => { deleteFilePaths: [], upsertFilePaths: ['src/app.ts'], }); + expect(invalidateWorkspaceFiles).toHaveBeenCalledWith(['/workspace/src/app.ts'], { + persist: false, + }); + }); + + it('patches deleted file evidence without falling back to full cache persistence', async () => { + const persistCache = vi.fn(); + const persistCachePatch = vi.fn(); + const lastFileAnalysis = new Map([ + ['src/deleted.ts', createFileAnalysis('/workspace/src/deleted.ts')], + ['src/app.ts', createFileAnalysis('/workspace/src/app.ts')], + ]); + const lastFileConnections = new Map([ + ['src/deleted.ts', []], + ['src/app.ts', []], + ]); + const invalidateWorkspaceFiles = vi.fn((filePaths: readonly string[]) => { + for (const filePath of filePaths) { + const relativePath = filePath.replace('/workspace/', ''); + lastFileAnalysis.delete(relativePath); + lastFileConnections.delete(relativePath); + } + return ['src/deleted.ts']; + }); + const source = createSource({ + _lastFileAnalysis: lastFileAnalysis, + _lastFileConnections: lastFileConnections, + analyze: vi.fn(async () => ({ nodes: [], edges: [] })), + invalidateWorkspaceFiles, + }); + + await refreshWorkspaceIndexChangedFiles(source, refreshOptions({ + discoveredFiles: [createDiscoveredFile('src/app.ts')], + filePaths: ['/workspace/src/deleted.ts'], + persistCache, + persistCachePatch, + })); + + expect(source.analyze).not.toHaveBeenCalled(); + expect(source._analyzeFiles).not.toHaveBeenCalled(); + expect(persistCache).not.toHaveBeenCalled(); + expect(persistCachePatch).toHaveBeenCalledOnce(); + expect(persistCachePatch).toHaveBeenCalledWith({ + deleteFilePaths: ['src/deleted.ts'], + upsertFilePaths: [], + }); + expect(invalidateWorkspaceFiles).toHaveBeenCalledWith(['/workspace/src/deleted.ts'], { + persist: false, + }); }); it('labels fallback full-analysis progress as applying changes when no phase is provided', async () => { diff --git a/packages/extension/src/extension/pipeline/service/lifecycleFacade.ts b/packages/extension/src/extension/pipeline/service/lifecycleFacade.ts index a8fbaf39d..764946145 100644 --- a/packages/extension/src/extension/pipeline/service/lifecycleFacade.ts +++ b/packages/extension/src/extension/pipeline/service/lifecycleFacade.ts @@ -61,7 +61,10 @@ export class WorkspacePipelineLifecycleFacade extends WorkspacePipelineRefreshFa ); } - override invalidateWorkspaceFiles(filePaths: readonly string[]): string[] { + override invalidateWorkspaceFiles( + filePaths: readonly string[], + options: { persist?: boolean } = {}, + ): string[] { const workspaceRoot = this._getWorkspaceRoot(); if (!workspaceRoot || filePaths.length === 0) { return []; @@ -85,7 +88,7 @@ export class WorkspacePipelineLifecycleFacade extends WorkspacePipelineRefreshFa (root, filePath) => this._toWorkspaceRelativePath(root, filePath), ); - if (invalidated.length > 0) { + if (invalidated.length > 0 && options.persist !== false) { this._persistCache(); } diff --git a/packages/extension/src/extension/pipeline/service/refresh/source.ts b/packages/extension/src/extension/pipeline/service/refresh/source.ts index dbb48dfea..c65bad48d 100644 --- a/packages/extension/src/extension/pipeline/service/refresh/source.ts +++ b/packages/extension/src/extension/pipeline/service/refresh/source.ts @@ -63,7 +63,10 @@ export function createWorkspaceIndexRefreshSource( _readAnalysisFiles: files => facade._readAnalysisFiles(files), analyze: (patterns, selectedPlugins, abortSignal, progress) => facade.analyze(patterns, selectedPlugins, abortSignal, progress), - invalidateWorkspaceFiles: paths => facade.invalidateWorkspaceFiles(paths), + invalidateWorkspaceFiles: (paths, options) => + options + ? facade.invalidateWorkspaceFiles(paths, options) + : facade.invalidateWorkspaceFiles(paths), } as WorkspacePipelineRefreshSource; Object.defineProperties(source, { diff --git a/packages/extension/src/extension/pipeline/service/refreshFacade.ts b/packages/extension/src/extension/pipeline/service/refreshFacade.ts index bd49f98a6..ef498df6f 100644 --- a/packages/extension/src/extension/pipeline/service/refreshFacade.ts +++ b/packages/extension/src/extension/pipeline/service/refreshFacade.ts @@ -84,5 +84,8 @@ export abstract class WorkspacePipelineRefreshFacade extends WorkspacePipelineDi }); } - abstract invalidateWorkspaceFiles(filePaths: readonly string[]): string[]; + abstract invalidateWorkspaceFiles( + filePaths: readonly string[], + options?: { persist?: boolean }, + ): string[]; } diff --git a/packages/extension/tests/extension/pipeline/service/lifecycleFacade.test.ts b/packages/extension/tests/extension/pipeline/service/lifecycleFacade.test.ts index 05b404a9a..7d9b6daa1 100644 --- a/packages/extension/tests/extension/pipeline/service/lifecycleFacade.test.ts +++ b/packages/extension/tests/extension/pipeline/service/lifecycleFacade.test.ts @@ -289,6 +289,16 @@ describe('pipeline/service/lifecycleFacade', () => { expect(facade.persistCache).toHaveBeenCalledOnce(); }); + it('invalidates workspace files without immediate cache persistence when requested by refresh', () => { + const facade = new TestLifecycleFacade(); + + expect(facade.invalidateWorkspaceFiles(['/workspace/src/a.ts'], { persist: false })) + .toEqual(['src/a.ts']); + + expect(invalidateWorkspacePipelineFiles).toHaveBeenCalledOnce(); + expect(facade.persistCache).not.toHaveBeenCalled(); + }); + it('returns early when plugin invalidation receives no plugin ids', () => { const facade = new TestLifecycleFacade(); From 167e68708f39c083120835ddaf797ccd27485cc7 Mon Sep 17 00:00:00 2001 From: joesobo Date: Thu, 25 Jun 2026 12:17:50 -0700 Subject: [PATCH 07/14] feat: hydrate graph scope from cache --- ...026-06-25-graph-cache-runtime-scheduler.md | 57 +++++++++++++ .../graphView/provider/refresh/contracts.ts | 10 +++ .../graphView/provider/refresh/factory.ts | 7 ++ .../provider/refresh/scoped/methods.ts | 42 ++++++++++ .../provider/source/delegates/public.ts | 2 + .../graphView/provider/wiring/publicApi.ts | 2 + .../graphView/webview/dispatch/primary.ts | 1 + .../webview/providerMessages/listener.ts | 1 + .../settingsContext/create.ts | 2 + .../webview/settingsMessages/router.ts | 1 + .../settingsMessages/updates/controls.ts | 16 +++- .../graphView/provider/refresh/fixture.ts | 2 + .../provider/refresh/targeted.test.ts | 37 +++++++++ .../settingsContext/create.test.ts | 82 +++++++++++++++++++ .../webview/settingsMessages/testSupport.ts | 1 + .../settingsMessages/updates/controls.test.ts | 23 ++++++ 16 files changed, 283 insertions(+), 3 deletions(-) diff --git a/docs/plans/2026-06-25-graph-cache-runtime-scheduler.md b/docs/plans/2026-06-25-graph-cache-runtime-scheduler.md index 96cc9ec74..3bb68cb48 100644 --- a/docs/plans/2026-06-25-graph-cache-runtime-scheduler.md +++ b/docs/plans/2026-06-25-graph-cache-runtime-scheduler.md @@ -373,6 +373,63 @@ Useful final timing targets: - Explicit Re-index starts while a targeted file patch or plugin refresh is queued. +## Implementation Progress + +### Projection-Only Settings + +Implemented deterministic tests and extension behavior so filter bursts update +settings and projection state without scheduling analysis, scoped reprocess, or +Graph Cache saves. + +Current deterministic threshold covered: + +| Scenario | Actions | Analysis jobs | Scoped reprocess jobs | Graph Cache saves | +| --- | ---: | ---: | ---: | ---: | +| Filter burst | 10 | 0 | 0 | 0 | + +### Plugin Impact Metadata + +Plugin API now exposes update impact metadata, and all built-in monorepo +plugins declare whether updates are projection-only, plugin-file targeted, or +full-index affecting. Missing metadata remains conservative for third-party +plugins. + +Current deterministic thresholds covered: + +| Scenario | Expected behavior | +| --- | --- | +| Built-in visual/plugin UI settings | Projection/settings path, no analysis | +| Built-in analyzer plugin updates | Target plugin-file refresh path | +| Unknown plugin impact | Conservative analysis path | + +### Incremental Graph Cache Patching + +Changed-file refreshes now patch Graph Cache rows for add/change/delete paths +instead of falling back to whole-cache persistence when targeted patching is +available. Deleted files invalidate runtime memory without immediately writing +the full cache. + +Current deterministic thresholds covered: + +| Scenario | Cache patch jobs | Full cache rewrites | +| --- | ---: | ---: | +| Add 1 file | 1 | 0 | +| Change 1 file | 1 | 0 | +| Delete 1 file | 1 | 0 | + +### Cached Graph Scope Hydration + +Symbol-dependent Graph Scope toggles now try to hydrate graph scope from Graph +Cache before scoped analysis. A successful cache hydration replays cached graph +data with `warmAnalysis: false`, publishes the graph, and avoids scoped analysis +or Graph Cache writes. + +Current deterministic threshold covered: + +| Scenario | Cache reads | Analysis jobs | Graph Cache saves | +| --- | ---: | ---: | ---: | +| Toggle symbol leaf on when cached | 1 | 0 | 0 | + ## Mistakes To Avoid - Do not add particle-specific architecture in core or extension. diff --git a/packages/extension/src/extension/graphView/provider/refresh/contracts.ts b/packages/extension/src/extension/graphView/provider/refresh/contracts.ts index 3bdea7f09..50cbbb1bc 100644 --- a/packages/extension/src/extension/graphView/provider/refresh/contracts.ts +++ b/packages/extension/src/extension/graphView/provider/refresh/contracts.ts @@ -10,6 +10,15 @@ export interface GraphViewProviderRefreshAnalyzerLike { disabledPlugins: Set, showOrphans: boolean, ): IGraphData; + loadCachedGraph?( + filterPatterns?: string[], + disabledPlugins?: Set, + signal?: AbortSignal, + options?: { + includeCurrentGitignoreMetadata?: boolean; + warmAnalysis?: boolean; + }, + ): Promise; registry: { notifyGraphRebuild( graphData: IGraphData, @@ -82,6 +91,7 @@ export interface GraphViewProviderRefreshMethods { refresh(): Promise; refreshIndex(): Promise; refreshGitignoreMetadata(): Promise; + hydrateGraphScope(): Promise; refreshAnalysisScope(): Promise; refreshPluginFiles(pluginIds: readonly string[]): Promise; refreshChangedFiles(filePaths: readonly string[]): Promise; diff --git a/packages/extension/src/extension/graphView/provider/refresh/factory.ts b/packages/extension/src/extension/graphView/provider/refresh/factory.ts index 782add154..268efbe40 100644 --- a/packages/extension/src/extension/graphView/provider/refresh/factory.ts +++ b/packages/extension/src/extension/graphView/provider/refresh/factory.ts @@ -13,6 +13,7 @@ import { } from './requests/methods'; import { createScopedRefreshLifecycle } from './scoped/lifecycle'; import { + createHydrateGraphScopeMethod, createRefreshAnalysisScopeMethod, createRefreshGitignoreMetadataMethod, createRefreshPluginFilesMethod, @@ -33,6 +34,11 @@ export function createGraphViewProviderRefreshMethods( const refresh = createRefreshMethod(source, state); const refreshChangedFiles = createRefreshChangedFilesMethod(source, state); const refreshIndex = createRefreshIndexMethod(source, state, refreshChangedFiles); + const hydrateGraphScope = createHydrateGraphScopeMethod( + source, + state, + scopedRefreshLifecycle, + ); const refreshAnalysisScope = createRefreshAnalysisScopeMethod( source, state, @@ -56,6 +62,7 @@ export function createGraphViewProviderRefreshMethods( refresh, refreshIndex, refreshGitignoreMetadata, + hydrateGraphScope, refreshAnalysisScope, refreshPluginFiles, refreshChangedFiles, 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 a34aa97f5..c2ee76318 100644 --- a/packages/extension/src/extension/graphView/provider/refresh/scoped/methods.ts +++ b/packages/extension/src/extension/graphView/provider/refresh/scoped/methods.ts @@ -1,3 +1,4 @@ +import type { IGraphData } from '../../../../../shared/graph/contracts'; import type { GraphViewProviderRefreshMethodsSource, RefreshCoordinatorState, @@ -9,6 +10,47 @@ import { runScopedRefreshRequest, } from './lifecycle'; +function hasGraphData(graphData: IGraphData | undefined): graphData is IGraphData { + return (graphData?.nodes.length ?? 0) > 0 || (graphData?.edges.length ?? 0) > 0; +} + +export function createHydrateGraphScopeMethod( + source: GraphViewProviderRefreshMethodsSource, + state: RefreshCoordinatorState, + scopedRefreshLifecycle: ScopedRefreshLifecycle, +): () => Promise { + return async (): Promise => { + if (state.indexRefreshPromise) { + await state.indexRefreshPromise; + } + + prepareRefreshInputs(source); + if (!source._analyzer?.loadCachedGraph) { + return false; + } + + const graphData = await runScopedRefreshRequest( + source, + signal => source._analyzer!.loadCachedGraph!( + source._filterPatterns, + source._disabledPlugins, + signal, + { + includeCurrentGitignoreMetadata: true, + warmAnalysis: false, + }, + ), + scopedRefreshLifecycle, + ); + if (!hasGraphData(graphData)) { + return false; + } + + publishGraphDataIfPresent(source, graphData); + return true; + }; +} + export function createRefreshAnalysisScopeMethod( source: GraphViewProviderRefreshMethodsSource, state: RefreshCoordinatorState, diff --git a/packages/extension/src/extension/graphView/provider/source/delegates/public.ts b/packages/extension/src/extension/graphView/provider/source/delegates/public.ts index d4f3141d5..d7424fb3c 100644 --- a/packages/extension/src/extension/graphView/provider/source/delegates/public.ts +++ b/packages/extension/src/extension/graphView/provider/source/delegates/public.ts @@ -14,6 +14,7 @@ export function createGraphViewProviderPublicMethodDelegates( | 'redo' | 'refreshIndex' | 'refreshGitignoreMetadata' + | 'hydrateGraphScope' | 'refreshAnalysisScope' | 'refreshPluginFiles' | 'refreshChangedFiles' @@ -29,6 +30,7 @@ export function createGraphViewProviderPublicMethodDelegates( redo: () => owner._methodContainers.command.redo(), refreshIndex: () => owner._methodContainers.refresh.refreshIndex(), refreshGitignoreMetadata: () => owner._methodContainers.refresh.refreshGitignoreMetadata(), + hydrateGraphScope: () => owner._methodContainers.refresh.hydrateGraphScope(), refreshAnalysisScope: () => owner._methodContainers.refresh.refreshAnalysisScope(), refreshPluginFiles: pluginIds => owner._methodContainers.refresh.refreshPluginFiles(pluginIds), refreshChangedFiles: filePaths => owner._methodContainers.refresh.refreshChangedFiles(filePaths), diff --git a/packages/extension/src/extension/graphView/provider/wiring/publicApi.ts b/packages/extension/src/extension/graphView/provider/wiring/publicApi.ts index 64473b88d..c3bd0f651 100644 --- a/packages/extension/src/extension/graphView/provider/wiring/publicApi.ts +++ b/packages/extension/src/extension/graphView/provider/wiring/publicApi.ts @@ -33,6 +33,7 @@ export interface GraphViewProviderPublicMethods { refresh: () => Promise; refreshIndex: () => Promise; refreshGitignoreMetadata: () => Promise; + hydrateGraphScope: () => Promise; refreshAnalysisScope: () => Promise; refreshPluginFiles: (pluginIds: readonly string[]) => Promise; refreshChangedFiles: (filePaths: readonly string[]) => Promise; @@ -98,6 +99,7 @@ export function assignGraphViewProviderPublicMethods( target.refreshIndex = () => target._methodContainers.refresh.refreshIndex(); target.refreshGitignoreMetadata = () => target._methodContainers.refresh.refreshGitignoreMetadata(); + target.hydrateGraphScope = () => target._methodContainers.refresh.hydrateGraphScope(); target.refreshAnalysisScope = () => target._methodContainers.refresh.refreshAnalysisScope(); target.refreshPluginFiles = pluginIds => target._methodContainers.refresh.refreshPluginFiles(pluginIds); target.refreshChangedFiles = filePaths => diff --git a/packages/extension/src/extension/graphView/webview/dispatch/primary.ts b/packages/extension/src/extension/graphView/webview/dispatch/primary.ts index f619b19db..09fcf19d2 100644 --- a/packages/extension/src/extension/graphView/webview/dispatch/primary.ts +++ b/packages/extension/src/extension/graphView/webview/dispatch/primary.ts @@ -44,6 +44,7 @@ export interface GraphViewPrimaryMessageContext { indexAndSendData(): Promise; analyzeAndSendData(): Promise; refreshIndex(): Promise; + hydrateGraphScope(): Promise; refreshAnalysisScope(): Promise; clearCacheAndRefresh(): Promise; getFileInfo(filePath: string): Promise; diff --git a/packages/extension/src/extension/graphView/webview/providerMessages/listener.ts b/packages/extension/src/extension/graphView/webview/providerMessages/listener.ts index 04b0ade5c..db978c808 100644 --- a/packages/extension/src/extension/graphView/webview/providerMessages/listener.ts +++ b/packages/extension/src/extension/graphView/webview/providerMessages/listener.ts @@ -132,6 +132,7 @@ export interface GraphViewProviderMessageListenerSource { _indexAndSendData(): Promise; _analyzeAndSendData(): Promise; refreshIndex(): Promise; + hydrateGraphScope?(): Promise; refreshAnalysisScope(): Promise; refreshPluginFiles?(pluginIds: readonly string[]): Promise; refreshChangedFiles(filePaths: readonly string[]): Promise; diff --git a/packages/extension/src/extension/graphView/webview/providerMessages/settingsContext/create.ts b/packages/extension/src/extension/graphView/webview/providerMessages/settingsContext/create.ts index 7783a9ddf..d99b310d8 100644 --- a/packages/extension/src/extension/graphView/webview/providerMessages/settingsContext/create.ts +++ b/packages/extension/src/extension/graphView/webview/providerMessages/settingsContext/create.ts @@ -29,6 +29,7 @@ type GraphViewProviderSettingsContext = Pick< | 'sendPluginWebviewInjections' | 'sendGraphControls' | 'analyzeAndSendData' + | 'hydrateGraphScope' | 'reprocessGraphScope' | 'reprocessPluginFiles' | 'resetAllSettings' @@ -117,6 +118,7 @@ export function createGraphViewProviderMessageSettingsContext( source._sendGraphControls?.(); }, analyzeAndSendData: () => source._analyzeAndSendData(), + hydrateGraphScope: () => source.hydrateGraphScope?.() ?? Promise.resolve(false), reprocessGraphScope: () => source.refreshAnalysisScope(), reprocessPluginFiles: async (pluginIds) => reprocessPluginFiles(source, pluginIds), resetAllSettings: async () => { diff --git a/packages/extension/src/extension/graphView/webview/settingsMessages/router.ts b/packages/extension/src/extension/graphView/webview/settingsMessages/router.ts index 7857677f0..05f7d8980 100644 --- a/packages/extension/src/extension/graphView/webview/settingsMessages/router.ts +++ b/packages/extension/src/extension/graphView/webview/settingsMessages/router.ts @@ -30,6 +30,7 @@ export interface GraphViewSettingsMessageHandlers { sendGroupsUpdated(): void; smartRebuild(id: string): void; sendGraphControls(): void; + hydrateGraphScope(): Promise; reprocessGraphScope(): Promise; reprocessPluginFiles(pluginIds: readonly string[]): Promise; getPluginFilterPatterns(): string[]; diff --git a/packages/extension/src/extension/graphView/webview/settingsMessages/updates/controls.ts b/packages/extension/src/extension/graphView/webview/settingsMessages/updates/controls.ts index 73dd0718d..87441fb12 100644 --- a/packages/extension/src/extension/graphView/webview/settingsMessages/updates/controls.ts +++ b/packages/extension/src/extension/graphView/webview/settingsMessages/updates/controls.ts @@ -35,6 +35,16 @@ async function applyGraphControlsUpdate( return true; } +async function hydrateOrReprocessGraphScope( + handlers: GraphViewSettingsMessageHandlers, +): Promise { + if (await handlers.hydrateGraphScope()) { + return; + } + + await handlers.reprocessGraphScope(); +} + function isSymbolDependentNodeType(nodeType: string): boolean { return nodeType === 'variable' || nodeType.startsWith('symbol:') @@ -80,7 +90,7 @@ async function applySymbolVisibilityUpdate( !requiresSymbolAnalysisCacheTier(previousVisibility) && requiresSymbolAnalysisCacheTier(nodeVisibility) ) { - await handlers.reprocessGraphScope(); + await hydrateOrReprocessGraphScope(handlers); } return true; } @@ -113,7 +123,7 @@ async function applySymbolDependentVisibilityUpdate( !requiresSymbolAnalysisCacheTier(previousVisibility) && requiresSymbolAnalysisCacheTier(nodeVisibility) ) { - await handlers.reprocessGraphScope(); + await hydrateOrReprocessGraphScope(handlers); } return true; } @@ -170,7 +180,7 @@ async function applyGraphControlVisibilityBatch( !requiresSymbolAnalysisCacheTier(previousVisibility) && requiresSymbolAnalysisCacheTier(prunedNodeVisibility) ) { - await handlers.reprocessGraphScope(); + await hydrateOrReprocessGraphScope(handlers); } } diff --git a/packages/extension/tests/extension/graphView/provider/refresh/fixture.ts b/packages/extension/tests/extension/graphView/provider/refresh/fixture.ts index 4ee399fdb..3efafff99 100644 --- a/packages/extension/tests/extension/graphView/provider/refresh/fixture.ts +++ b/packages/extension/tests/extension/graphView/provider/refresh/fixture.ts @@ -8,6 +8,7 @@ export function createSource( _analyzer: { hasIndex: ReturnType; rebuildGraph: ReturnType; + loadCachedGraph: ReturnType; refreshGitignoreMetadata: ReturnType; refreshAnalysisScope: ReturnType; refreshPluginFiles: ReturnType; @@ -46,6 +47,7 @@ export function createSource( _analyzer: { hasIndex: vi.fn(() => true), rebuildGraph: vi.fn(() => ({ nodes: [], edges: [] } satisfies IGraphData)), + loadCachedGraph: vi.fn(async () => ({ nodes: [], edges: [] } satisfies IGraphData)), refreshGitignoreMetadata: vi.fn(async () => ({ nodes: [], edges: [] } satisfies IGraphData)), refreshAnalysisScope: vi.fn(async () => ({ nodes: [], edges: [] } satisfies IGraphData)), refreshPluginFiles: vi.fn(async () => ({ nodes: [], edges: [] } satisfies IGraphData)), 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 c058ed654..93755422e 100644 --- a/packages/extension/tests/extension/graphView/provider/refresh/targeted.test.ts +++ b/packages/extension/tests/extension/graphView/provider/refresh/targeted.test.ts @@ -162,4 +162,41 @@ describe('graphView/provider/refresh targeted refreshes', () => { expect(source._rawGraphData).toBe(graphData); expect(source._analysisController).toBeUndefined(); }); + + it('hydrateGraphScope replays cached graph data without scoped analysis or warm analysis', async () => { + const source = createSource(); + const graphData = { + nodes: [{ id: 'symbol-node', label: 'symbol-node', color: '#ffffff' }], + edges: [], + } satisfies IGraphData; + source._analyzer.loadCachedGraph.mockResolvedValueOnce(graphData); + const rebuildGraphData = vi.fn(); + const methods = createGraphViewProviderRefreshMethods(source as never, { + getShowOrphans: vi.fn(() => true), + rebuildGraphData, + smartRebuildGraphData: vi.fn(), + }); + + await expect(methods.hydrateGraphScope()).resolves.toBe(true); + + expect(source._loadDisabledRulesAndPlugins).toHaveBeenCalledOnce(); + expect(source._loadGroupsAndFilterPatterns).toHaveBeenCalledOnce(); + expect(source._analyzer.loadCachedGraph).toHaveBeenCalledWith( + ['src/**'], + source._disabledPlugins, + expect.any(AbortSignal), + { + includeCurrentGitignoreMetadata: true, + warmAnalysis: false, + }, + ); + expect(source._analyzer.refreshAnalysisScope).not.toHaveBeenCalled(); + expect(source._analyzeAndSendData).not.toHaveBeenCalled(); + expect(rebuildGraphData).not.toHaveBeenCalled(); + expect(source._rawGraphData).toBe(graphData); + expect(source._sendMessage).toHaveBeenCalledWith({ + type: 'GRAPH_DATA_UPDATED', + payload: source._graphData, + }); + }); }); diff --git a/packages/extension/tests/extension/graphView/webview/providerMessages/settingsContext/create.test.ts b/packages/extension/tests/extension/graphView/webview/providerMessages/settingsContext/create.test.ts index d063251e9..b9f64c0e1 100644 --- a/packages/extension/tests/extension/graphView/webview/providerMessages/settingsContext/create.test.ts +++ b/packages/extension/tests/extension/graphView/webview/providerMessages/settingsContext/create.test.ts @@ -190,6 +190,88 @@ describe('graph view provider listener settings context', () => { expect(configuration.update).not.toHaveBeenCalled(); }); + it('delegates graph scope hydration when the provider exposes cached hydration', async () => { + vi.mocked(repoSettings.getCodeGraphyConfiguration).mockReturnValue({ + get: vi.fn((_: string, defaultValue: unknown) => defaultValue), + update: vi.fn(() => Promise.resolve()), + } as never); + const hydrateGraphScope = vi.fn(() => Promise.resolve(true)); + + const context = createGraphViewProviderMessageSettingsContext( + { + _context: { workspaceState: { update: vi.fn(() => Promise.resolve()) } }, + _dagMode: null, + _nodeSizeMode: 'connections', + _getPhysicsSettings: vi.fn(() => ({ + repelForce: 1, + linkDistance: 2, + linkForce: 3, + damping: 4, + centerForce: 5, + })), + _sendMessage: vi.fn(), + _sendAllSettings: vi.fn(), + _analyzeAndSendData: vi.fn(() => Promise.resolve()), + hydrateGraphScope, + } as never, + { + workspace: { + workspaceFolders: [], + getConfiguration: vi.fn(), + }, + getConfigTarget: vi.fn(() => 'workspace'), + captureSettingsSnapshot: vi.fn(() => ({ snapshot: true })), + createResetSettingsAction: vi.fn(), + executeUndoAction: vi.fn(() => Promise.resolve()), + dagModeKey: 'dagMode', + nodeSizeModeKey: 'nodeSizeMode', + } as never, + ); + + await expect(context.hydrateGraphScope()).resolves.toBe(true); + + expect(hydrateGraphScope).toHaveBeenCalledOnce(); + }); + + it('reports cache hydration unavailable when the provider cannot hydrate graph scope', async () => { + vi.mocked(repoSettings.getCodeGraphyConfiguration).mockReturnValue({ + get: vi.fn((_: string, defaultValue: unknown) => defaultValue), + update: vi.fn(() => Promise.resolve()), + } as never); + + const context = createGraphViewProviderMessageSettingsContext( + { + _context: { workspaceState: { update: vi.fn(() => Promise.resolve()) } }, + _dagMode: null, + _nodeSizeMode: 'connections', + _getPhysicsSettings: vi.fn(() => ({ + repelForce: 1, + linkDistance: 2, + linkForce: 3, + damping: 4, + centerForce: 5, + })), + _sendMessage: vi.fn(), + _sendAllSettings: vi.fn(), + _analyzeAndSendData: vi.fn(() => Promise.resolve()), + } as never, + { + workspace: { + workspaceFolders: [], + getConfiguration: vi.fn(), + }, + getConfigTarget: vi.fn(() => 'workspace'), + captureSettingsSnapshot: vi.fn(() => ({ snapshot: true })), + createResetSettingsAction: vi.fn(), + executeUndoAction: vi.fn(() => Promise.resolve()), + dagModeKey: 'dagMode', + nodeSizeModeKey: 'nodeSizeMode', + } as never, + ); + + await expect(context.hydrateGraphScope()).resolves.toBe(false); + }); + it('keeps the analyzer initialized after reloading workspace plugins', async () => { vi.mocked(repoSettings.getCodeGraphyConfiguration).mockReturnValue({ get: vi.fn((_: string, defaultValue: unknown) => defaultValue), diff --git a/packages/extension/tests/extension/graphView/webview/settingsMessages/testSupport.ts b/packages/extension/tests/extension/graphView/webview/settingsMessages/testSupport.ts index 4cd051204..f5971604d 100644 --- a/packages/extension/tests/extension/graphView/webview/settingsMessages/testSupport.ts +++ b/packages/extension/tests/extension/graphView/webview/settingsMessages/testSupport.ts @@ -26,6 +26,7 @@ export function createHandlers( sendGraphControls: vi.fn(), analyzeAndSendData: vi.fn(() => Promise.resolve()), smartRebuild: vi.fn(), + hydrateGraphScope: vi.fn(() => Promise.resolve(false)), reprocessGraphScope: vi.fn(() => Promise.resolve()), reprocessPluginFiles: vi.fn(() => Promise.resolve()), sendMessage: vi.fn(), diff --git a/packages/extension/tests/extension/graphView/webview/settingsMessages/updates/controls.test.ts b/packages/extension/tests/extension/graphView/webview/settingsMessages/updates/controls.test.ts index af27a1168..2b3482582 100644 --- a/packages/extension/tests/extension/graphView/webview/settingsMessages/updates/controls.test.ts +++ b/packages/extension/tests/extension/graphView/webview/settingsMessages/updates/controls.test.ts @@ -223,6 +223,29 @@ describe('settingsMessages/updates/controls', () => { expect(handlers.smartRebuild).not.toHaveBeenCalled(); }); + it('hydrates cached graph scope instead of reprocessing when a symbol child type is enabled from cache', async () => { + const handlers = createHandlers({ + getConfig: vi.fn((key: string, defaultValue: T): T => { + if (key === 'nodeVisibility') { + return { symbol: false, 'symbol:function': false } as T; + } + return defaultValue; + }), + hydrateGraphScope: vi.fn(() => Promise.resolve(true)), + }); + + await expect( + applyGraphControlMessage( + { type: 'UPDATE_NODE_VISIBILITY', payload: { nodeType: 'symbol:function', visible: true } }, + handlers, + ), + ).resolves.toBe(true); + + expect(handlers.hydrateGraphScope).toHaveBeenCalledOnce(); + expect(handlers.reprocessGraphScope).not.toHaveBeenCalled(); + expect(handlers.analyzeAndSendData).not.toHaveBeenCalled(); + }); + it('enables Symbols and Variables when a variable child type is enabled', async () => { const handlers = createHandlers({ getConfig: vi.fn((key: string, defaultValue: T): T => { From 7b41e8b347d68a90a748a414783cf2345ea124ef Mon Sep 17 00:00:00 2001 From: joesobo Date: Thu, 25 Jun 2026 12:20:41 -0700 Subject: [PATCH 08/14] fix: let reindex supersede scoped refresh --- ...026-06-25-graph-cache-runtime-scheduler.md | 12 +++++ .../graphView/provider/refresh/factory.ts | 7 ++- .../provider/refresh/requests/methods.ts | 2 + .../provider/refresh/cancellation.test.ts | 46 +++++++++++++++++++ 4 files changed, 66 insertions(+), 1 deletion(-) diff --git a/docs/plans/2026-06-25-graph-cache-runtime-scheduler.md b/docs/plans/2026-06-25-graph-cache-runtime-scheduler.md index 3bb68cb48..46366e17e 100644 --- a/docs/plans/2026-06-25-graph-cache-runtime-scheduler.md +++ b/docs/plans/2026-06-25-graph-cache-runtime-scheduler.md @@ -430,6 +430,18 @@ Current deterministic threshold covered: | --- | ---: | ---: | ---: | | Toggle symbol leaf on when cached | 1 | 0 | 0 | +### Explicit Re-index Supersedes Scoped Work + +Explicit Re-index now aborts any in-flight scoped refresh before starting the +full-index path, so a slow Graph Scope/plugin hydration result cannot publish +over the re-indexed graph. + +Current deterministic threshold covered: + +| Scenario | Full-index jobs | Stale scoped publishes | +| --- | ---: | ---: | +| Re-index during scoped refresh | 1 | 0 | + ## Mistakes To Avoid - Do not add particle-specific architecture in core or extension. diff --git a/packages/extension/src/extension/graphView/provider/refresh/factory.ts b/packages/extension/src/extension/graphView/provider/refresh/factory.ts index 268efbe40..53273bd3c 100644 --- a/packages/extension/src/extension/graphView/provider/refresh/factory.ts +++ b/packages/extension/src/extension/graphView/provider/refresh/factory.ts @@ -33,7 +33,12 @@ export function createGraphViewProviderRefreshMethods( const state = createRefreshCoordinatorState(); const refresh = createRefreshMethod(source, state); const refreshChangedFiles = createRefreshChangedFilesMethod(source, state); - const refreshIndex = createRefreshIndexMethod(source, state, refreshChangedFiles); + const refreshIndex = createRefreshIndexMethod( + source, + state, + refreshChangedFiles, + () => scopedRefreshLifecycle.abort(), + ); const hydrateGraphScope = createHydrateGraphScopeMethod( source, state, 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 511571cf7..6132d7d95 100644 --- a/packages/extension/src/extension/graphView/provider/refresh/requests/methods.ts +++ b/packages/extension/src/extension/graphView/provider/refresh/requests/methods.ts @@ -33,6 +33,7 @@ export function createRefreshIndexMethod( source: GraphViewProviderRefreshMethodsSource, state: RefreshCoordinatorState, refreshChangedFiles: (filePaths: readonly string[]) => Promise, + beforeRefreshIndex?: () => void, ): () => Promise { return async (): Promise => { if (state.indexRefreshPromise) { @@ -40,6 +41,7 @@ export function createRefreshIndexMethod( return; } + beforeRefreshIndex?.(); state.indexRefreshPromise = runIndexRefreshWithInputs(source); try { await state.indexRefreshPromise; diff --git a/packages/extension/tests/extension/graphView/provider/refresh/cancellation.test.ts b/packages/extension/tests/extension/graphView/provider/refresh/cancellation.test.ts index f7855fb17..2c81ac99e 100644 --- a/packages/extension/tests/extension/graphView/provider/refresh/cancellation.test.ts +++ b/packages/extension/tests/extension/graphView/provider/refresh/cancellation.test.ts @@ -98,4 +98,50 @@ describe('graphView/provider/refresh cancellation', () => { expect(rebuildGraphData).toHaveBeenCalledOnce(); expect(source._analysisController).toBeUndefined(); }); + + it('explicit reindex aborts in-flight scoped refreshes before stale results can publish', async () => { + const source = createSource(); + let finishRefresh: (() => void) | undefined; + let refreshSignal: AbortSignal | undefined; + const staleGraph = { + nodes: [{ id: 'stale-scope', label: 'stale-scope', color: '#ffffff' }], + edges: [], + } satisfies IGraphData; + source._analyzer.refreshAnalysisScope.mockImplementationOnce(async ( + _filterPatterns, + _disabledPlugins, + signal: AbortSignal, + ) => { + refreshSignal = signal; + await new Promise(resolve => { + finishRefresh = resolve; + }); + return staleGraph; + }); + const refreshAndSendData = vi.fn(async () => undefined); + source._refreshAndSendData = refreshAndSendData; + const rebuildGraphData = vi.fn(); + const methods = createGraphViewProviderRefreshMethods(source as never, { + getShowOrphans: vi.fn(() => true), + rebuildGraphData, + smartRebuildGraphData: vi.fn(), + }); + + const scopedRefresh = methods.refreshAnalysisScope(); + await Promise.resolve(); + await methods.refreshIndex(); + + expect(refreshSignal?.aborted).toBe(true); + expect(refreshAndSendData).toHaveBeenCalledOnce(); + finishRefresh?.(); + await scopedRefresh; + + expect(source._rawGraphData).not.toBe(staleGraph); + expect(source._sendMessage).not.toHaveBeenCalledWith({ + type: 'GRAPH_DATA_UPDATED', + payload: expect.objectContaining({ nodes: staleGraph.nodes }), + }); + expect(rebuildGraphData).not.toHaveBeenCalled(); + expect(source._analysisController).toBeUndefined(); + }); }); From 834c785c820041d7d1562d09ab48cacee04b94fa Mon Sep 17 00:00:00 2001 From: joesobo Date: Thu, 25 Jun 2026 12:32:40 -0700 Subject: [PATCH 09/14] test: align incremental refresh expectations --- .../indexing/refresh/modes/changedFiles.ts | 2 +- .../webview/messages/listener.test.ts | 2 +- .../service.refreshChangedFiles.test.ts | 31 +++++++++++++------ .../pipeline/service/runtime/refresh.test.ts | 9 ++++-- 4 files changed, 30 insertions(+), 14 deletions(-) diff --git a/packages/core/src/indexing/refresh/modes/changedFiles.ts b/packages/core/src/indexing/refresh/modes/changedFiles.ts index c8fe8ae61..6b82705e9 100644 --- a/packages/core/src/indexing/refresh/modes/changedFiles.ts +++ b/packages/core/src/indexing/refresh/modes/changedFiles.ts @@ -145,7 +145,7 @@ function invalidateDeletedWorkspaceIndexFiles( const unmatchedFilePaths: string[] = []; for (const filePath of filePaths) { - const invalidatedFilePaths = source.invalidateWorkspaceFiles([filePath], { persist: false }); + const invalidatedFilePaths = source.invalidateWorkspaceFiles([filePath], { persist: false }) ?? []; if (invalidatedFilePaths.length === 0) { unmatchedFilePaths.push(filePath); continue; 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 cf82018f6..a035625a5 100644 --- a/packages/extension/tests/extension/graphView/webview/messages/listener.test.ts +++ b/packages/extension/tests/extension/graphView/webview/messages/listener.test.ts @@ -61,7 +61,7 @@ describe('graph view webview message listener', () => { }); expect(context.setFilterPatterns).toHaveBeenCalledWith(['dist/**']); - expect(context.analyzeAndSendData).toHaveBeenCalledOnce(); + expect(context.analyzeAndSendData).not.toHaveBeenCalled(); expect(context.setUserGroups).not.toHaveBeenCalled(); expect(context.setWebviewReadyNotified).not.toHaveBeenCalled(); }); diff --git a/packages/extension/tests/extension/pipeline/service.refreshChangedFiles.test.ts b/packages/extension/tests/extension/pipeline/service.refreshChangedFiles.test.ts index c5548db43..0b8f75f09 100644 --- a/packages/extension/tests/extension/pipeline/service.refreshChangedFiles.test.ts +++ b/packages/extension/tests/extension/pipeline/service.refreshChangedFiles.test.ts @@ -195,7 +195,7 @@ describe('WorkspacePipeline refreshChangedFiles', () => { ); }); - it('falls back to full analysis when a changed path is no longer discovered', async () => { + it('removes cached graph data when a changed path is no longer discovered', async () => { const analyzer = new WorkspacePipeline( createContext() as unknown as vscode.ExtensionContext, ); @@ -215,6 +215,8 @@ describe('WorkspacePipeline refreshChangedFiles', () => { notifyFilesChanged: ReturnType; }; _readAnalysisFiles?: ReturnType; + _persistIndexMetadata?: ReturnType; + _persistCachePatch?: ReturnType; }; const invalidateWorkspaceFiles = vi.spyOn(analyzer, 'invalidateWorkspaceFiles'); @@ -240,20 +242,31 @@ describe('WorkspacePipeline refreshChangedFiles', () => { requiresFullRefresh: false, })); analyzerPrivate._readAnalysisFiles = vi.fn(async () => []); + analyzerPrivate._persistIndexMetadata = vi.fn(async () => undefined); + analyzerPrivate._persistCachePatch = vi.fn(); await expect(analyzer.refreshChangedFiles(['/workspace/src/remove.ts'])).resolves.toEqual({ - nodes: [{ id: 'fresh', label: 'fresh.ts', color: '#ffffff' }], + nodes: [{ + id: 'src/keep.ts', + label: 'keep.ts', + color: '#A1A1AA', + fileSize: undefined, + churn: 0, + }], edges: [], }); - expect(invalidateWorkspaceFiles).toHaveBeenCalledWith(['/workspace/src/remove.ts']); - expect(analyzerPrivate._registry.notifyFilesChanged).not.toHaveBeenCalled(); - expect(analyzer.analyze).toHaveBeenCalledWith( - [], - new Set(), - undefined, - expect.any(Function), + expect(invalidateWorkspaceFiles).toHaveBeenCalledWith( + ['/workspace/src/remove.ts'], + { persist: false }, ); + expect(analyzerPrivate._registry.notifyFilesChanged).not.toHaveBeenCalled(); + expect(analyzer.analyze).not.toHaveBeenCalled(); + expect(analyzerPrivate._persistCachePatch).toHaveBeenCalledWith({ + deleteFilePaths: ['src/remove.ts'], + upsertFilePaths: [], + }); + expect(analyzerPrivate._persistIndexMetadata).toHaveBeenCalledOnce(); }); it('invalidates discovered empty directories below a deleted directory path', () => { 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 8cb177067..a75cc67e2 100644 --- a/packages/extension/tests/extension/pipeline/service/runtime/refresh.test.ts +++ b/packages/extension/tests/extension/pipeline/service/runtime/refresh.test.ts @@ -30,7 +30,7 @@ function createSource() { })), _readAnalysisFiles: vi.fn(), analyze: vi.fn(), - invalidateWorkspaceFiles: vi.fn(), + invalidateWorkspaceFiles: vi.fn(() => []), }; } @@ -109,7 +109,10 @@ describe('pipeline/service/refresh', () => { const graph = await refreshWorkspacePipelineChangedFiles(source as never, dependencies as never); - expect(source.invalidateWorkspaceFiles).toHaveBeenCalledWith(['/workspace/missing.ts']); + expect(source.invalidateWorkspaceFiles).toHaveBeenCalledWith( + ['/workspace/missing.ts'], + { persist: false }, + ); expect(source._readAnalysisFiles).not.toHaveBeenCalled(); expect(dependencies.notifyFilesChanged).not.toHaveBeenCalled(); expect(source._buildGraphDataFromAnalysis).not.toHaveBeenCalled(); @@ -159,7 +162,7 @@ describe('pipeline/service/refresh', () => { expect(source.invalidateWorkspaceFiles).toHaveBeenCalledWith([ '/workspace/src/a.ts', '/workspace/src/b.ts', - ]); + ], { persist: false }); expect(dependencies.onProgress).toHaveBeenNthCalledWith(1, { phase: 'Applying Changes', current: 0, From 84902a1c89adcd751ca7eb20fdb2f30d35b3c0df Mon Sep 17 00:00:00 2001 From: joesobo Date: Thu, 25 Jun 2026 12:43:45 -0700 Subject: [PATCH 10/14] feat: debounce plugin graph work --- packages/core/src/index.ts | 3 + packages/core/src/plugins/installedCache.ts | 3 + .../workspaceSelection.ts | 71 +++++++++ .../tests/plugins/workspaceSelection.test.ts | 35 +++++ .../graphView/webview/dispatch/primary.ts | 3 + .../webview/dispatch/primaryState.ts | 10 +- .../settingsContext/create.ts | 14 ++ .../settingsMessages/pluginGraphWork.ts | 136 ++++++++++++++++++ .../webview/settingsMessages/router.ts | 3 + .../webview/settingsMessages/toggle.ts | 19 +-- .../updates/apply/pluginData.ts | 55 ++++++- .../webview/dispatch/primaryState.test.ts | 37 +++++ .../settingsMessages/pluginGraphWork.test.ts | 60 ++++++++ .../webview/settingsMessages/updates.test.ts | 96 ++++++++++++- 14 files changed, 525 insertions(+), 20 deletions(-) create mode 100644 packages/extension/src/extension/graphView/webview/settingsMessages/pluginGraphWork.ts create mode 100644 packages/extension/tests/extension/graphView/webview/dispatch/primaryState.test.ts create mode 100644 packages/extension/tests/extension/graphView/webview/settingsMessages/pluginGraphWork.test.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a0182e8eb..9399720d5 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -274,6 +274,8 @@ export type { export { parseCodeGraphyPluginPackageManifest } from './plugins/packageManifest'; export type { CodeGraphyWorkspacePluginIndexingPlan, + CodeGraphyWorkspacePluginSettingUpdateIndexingPlan, + CodeGraphyWorkspacePluginSettingUpdatePlanOptions, CodeGraphyWorkspacePluginToggleOptions, CodeGraphyWorkspacePluginTogglePlan, CodeGraphyInstalledPluginCache, @@ -283,6 +285,7 @@ export type { RegisterCodeGraphyInstalledPluginOptions, } from './plugins/installedCache'; export { + createCodeGraphyWorkspacePluginSettingUpdateIndexingPlan, createCodeGraphyWorkspacePluginTogglePlan, createBundledMarkdownInstalledPluginRecord, disableCodeGraphyWorkspacePlugin, diff --git a/packages/core/src/plugins/installedCache.ts b/packages/core/src/plugins/installedCache.ts index 1c938d44b..bfcfbcd27 100644 --- a/packages/core/src/plugins/installedCache.ts +++ b/packages/core/src/plugins/installedCache.ts @@ -7,6 +7,8 @@ export type { } from './installedPluginCache/contracts'; export type { CodeGraphyWorkspacePluginIndexingPlan, + CodeGraphyWorkspacePluginSettingUpdateIndexingPlan, + CodeGraphyWorkspacePluginSettingUpdatePlanOptions, CodeGraphyWorkspacePluginToggleOptions, CodeGraphyWorkspacePluginTogglePlan, UpdateCodeGraphyWorkspacePluginSelectionOptions, @@ -26,6 +28,7 @@ export { writeCodeGraphyInstalledPluginCache, } from './installedPluginCache/storage'; export { + createCodeGraphyWorkspacePluginSettingUpdateIndexingPlan, createCodeGraphyWorkspacePluginTogglePlan, disableCodeGraphyWorkspacePlugin, enableCodeGraphyWorkspacePlugin, diff --git a/packages/core/src/plugins/installedPluginCache/workspaceSelection.ts b/packages/core/src/plugins/installedPluginCache/workspaceSelection.ts index ac8b5b34e..d8ba1d831 100644 --- a/packages/core/src/plugins/installedPluginCache/workspaceSelection.ts +++ b/packages/core/src/plugins/installedPluginCache/workspaceSelection.ts @@ -20,11 +20,21 @@ export type CodeGraphyWorkspacePluginIndexingPlan = | { kind: 'analyze-workspace' } | { kind: 'reprocess-plugin-files'; pluginIds: string[] }; +export type CodeGraphyWorkspacePluginSettingUpdateIndexingPlan = + | { kind: 'settings-only' } + | CodeGraphyWorkspacePluginIndexingPlan; + export interface CodeGraphyWorkspacePluginTogglePlan { plugins: CodeGraphyWorkspacePluginSettings[]; indexing: CodeGraphyWorkspacePluginIndexingPlan; } +export interface CodeGraphyWorkspacePluginSettingUpdatePlanOptions { + pluginId: string; + settingKeys: readonly string[]; + updateImpact?: IPluginUpdateImpactPolicy; +} + export function updateCodeGraphyWorkspacePluginSelection( plugins: readonly CodeGraphyWorkspacePluginSettings[], options: UpdateCodeGraphyWorkspacePluginSelectionOptions, @@ -59,9 +69,36 @@ export function createCodeGraphyWorkspacePluginTogglePlan( }; } +export function createCodeGraphyWorkspacePluginSettingUpdateIndexingPlan( + options: CodeGraphyWorkspacePluginSettingUpdatePlanOptions, +): CodeGraphyWorkspacePluginSettingUpdateIndexingPlan { + const impact = getHighestImpact( + getPluginSettingImpacts(options.updateImpact, options.settingKeys), + ); + switch (impact) { + case 'view-only': + case 'settings-only': + return { kind: 'settings-only' }; + case 'projection-only': + return { kind: 'projection-only' }; + case 'reanalyze-plugin-files': + return { kind: 'reprocess-plugin-files', pluginIds: [options.pluginId] }; + case 'requires-full-index': + default: + return { kind: 'analyze-workspace' }; + } +} + function createPluginToggleIndexingPlan( pluginId: string, impact: IPluginUpdateImpact | undefined, +): CodeGraphyWorkspacePluginIndexingPlan { + return createPluginUpdateIndexingPlan(pluginId, impact); +} + +function createPluginUpdateIndexingPlan( + pluginId: string, + impact: IPluginUpdateImpact | undefined, ): CodeGraphyWorkspacePluginIndexingPlan { switch (impact) { case 'view-only': @@ -76,6 +113,40 @@ function createPluginToggleIndexingPlan( } } +function getPluginSettingImpacts( + updateImpact: IPluginUpdateImpactPolicy | undefined, + settingKeys: readonly string[], +): IPluginUpdateImpact[] { + if (settingKeys.length === 0) { + return [updateImpact?.defaultSetting].filter((impact): impact is IPluginUpdateImpact => + impact !== undefined, + ); + } + + return settingKeys.map(settingKey => + updateImpact?.settings?.[settingKey] ?? updateImpact?.defaultSetting ?? 'requires-full-index', + ); +} + +function getHighestImpact(impacts: readonly IPluginUpdateImpact[]): IPluginUpdateImpact | undefined { + if (impacts.includes('requires-full-index')) { + return 'requires-full-index'; + } + if (impacts.includes('reanalyze-plugin-files')) { + return 'reanalyze-plugin-files'; + } + if (impacts.includes('projection-only')) { + return 'projection-only'; + } + if (impacts.includes('settings-only')) { + return 'settings-only'; + } + if (impacts.includes('view-only')) { + return 'view-only'; + } + return undefined; +} + export function enableCodeGraphyWorkspacePlugin( workspaceRoot: string, plugin: CodeGraphyInstalledPluginRecord, diff --git a/packages/core/tests/plugins/workspaceSelection.test.ts b/packages/core/tests/plugins/workspaceSelection.test.ts index b5a08cabe..004c3cdee 100644 --- a/packages/core/tests/plugins/workspaceSelection.test.ts +++ b/packages/core/tests/plugins/workspaceSelection.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { + createCodeGraphyWorkspacePluginSettingUpdateIndexingPlan, createCodeGraphyWorkspacePluginTogglePlan, updateCodeGraphyWorkspacePluginSelection, type CodeGraphyWorkspacePluginSettings, @@ -126,6 +127,40 @@ describe('plugins/workspaceSelection', () => { }); }); + it('plans plugin setting updates from per-key and default impact metadata', () => { + expect(createCodeGraphyWorkspacePluginSettingUpdateIndexingPlan({ + pluginId: 'codegraphy.particles', + settingKeys: ['speed', 'size'], + updateImpact: { + toggle: 'projection-only', + defaultSetting: 'settings-only', + }, + })).toEqual({ kind: 'settings-only' }); + + expect(createCodeGraphyWorkspacePluginSettingUpdateIndexingPlan({ + pluginId: 'codegraphy.vue', + settingKeys: ['includeTests'], + updateImpact: { + toggle: 'reanalyze-plugin-files', + defaultSetting: 'settings-only', + settings: { + includeTests: 'reanalyze-plugin-files', + }, + }, + })).toEqual({ + kind: 'reprocess-plugin-files', + pluginIds: ['codegraphy.vue'], + }); + + expect(createCodeGraphyWorkspacePluginSettingUpdateIndexingPlan({ + pluginId: 'acme.unknown', + settingKeys: ['mode'], + updateImpact: { + toggle: 'projection-only', + }, + })).toEqual({ kind: 'analyze-workspace' }); + }); + it('plans a workspace analysis refresh when disabling a plugin id', () => { const plan = createCodeGraphyWorkspacePluginTogglePlan([ { id: 'codegraphy.markdown', enabled: true }, diff --git a/packages/extension/src/extension/graphView/webview/dispatch/primary.ts b/packages/extension/src/extension/graphView/webview/dispatch/primary.ts index 09fcf19d2..fc23f117f 100644 --- a/packages/extension/src/extension/graphView/webview/dispatch/primary.ts +++ b/packages/extension/src/extension/graphView/webview/dispatch/primary.ts @@ -9,6 +9,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 type { PluginGraphWorkRequest } from '../settingsMessages/pluginGraphWork'; import { dispatchGraphViewPrimaryRouteMessage } from './routed'; import { dispatchGraphViewPrimaryStateMessage } from './stateful'; @@ -104,6 +105,8 @@ export interface GraphViewPrimaryMessageContext { sendMessage(message: unknown): void; applyViewTransform(): void; smartRebuild(id: string): void; + schedulePluginGraphWork?(request: PluginGraphWorkRequest): void; + cancelScheduledPluginGraphWork?(): void; resetAllSettings(): Promise; } diff --git a/packages/extension/src/extension/graphView/webview/dispatch/primaryState.ts b/packages/extension/src/extension/graphView/webview/dispatch/primaryState.ts index dc228c334..eaf3d335f 100644 --- a/packages/extension/src/extension/graphView/webview/dispatch/primaryState.ts +++ b/packages/extension/src/extension/graphView/webview/dispatch/primaryState.ts @@ -16,8 +16,14 @@ export function createGraphViewPrimaryNodeFileHandlers( ): GraphViewNodeFileHandlers { return { ...context, - indexGraph: () => context.indexAndSendData(), - refreshGraph: () => context.refreshIndex(), + indexGraph: async () => { + context.cancelScheduledPluginGraphWork?.(); + await context.indexAndSendData(); + }, + refreshGraph: async () => { + context.cancelScheduledPluginGraphWork?.(); + await context.refreshIndex(); + }, timelineActive: context.getTimelineActive(), canMutateGraphRevision: context.getCanMutateGraphRevision(), currentCommitSha: context.getCurrentCommitSha(), diff --git a/packages/extension/src/extension/graphView/webview/providerMessages/settingsContext/create.ts b/packages/extension/src/extension/graphView/webview/providerMessages/settingsContext/create.ts index d99b310d8..016bd4c31 100644 --- a/packages/extension/src/extension/graphView/webview/providerMessages/settingsContext/create.ts +++ b/packages/extension/src/extension/graphView/webview/providerMessages/settingsContext/create.ts @@ -10,6 +10,7 @@ import { readInstalledPluginDefaultOptions, readInstalledPluginUpdateImpact, } from '../../settingsMessages/defaultOptions'; +import { createPluginGraphWorkScheduler } from '../../settingsMessages/pluginGraphWork'; type GraphViewProviderSettingsContext = Pick< GraphViewMessageListenerContext, @@ -29,6 +30,8 @@ type GraphViewProviderSettingsContext = Pick< | 'sendPluginWebviewInjections' | 'sendGraphControls' | 'analyzeAndSendData' + | 'schedulePluginGraphWork' + | 'cancelScheduledPluginGraphWork' | 'hydrateGraphScope' | 'reprocessGraphScope' | 'reprocessPluginFiles' @@ -62,6 +65,11 @@ export function createGraphViewProviderMessageSettingsContext( source._analyzerInitPromise = updatePromise; return updatePromise; }; + const pluginGraphWorkScheduler = createPluginGraphWorkScheduler({ + analyzeAndSendData: () => source._analyzeAndSendData(), + reprocessPluginFiles: pluginIds => reprocessPluginFiles(source, pluginIds), + smartRebuild: pluginId => source._smartRebuild(pluginId), + }); return { updateDagMode: async dagMode => { @@ -118,6 +126,12 @@ export function createGraphViewProviderMessageSettingsContext( source._sendGraphControls?.(); }, analyzeAndSendData: () => source._analyzeAndSendData(), + schedulePluginGraphWork: request => { + pluginGraphWorkScheduler.schedule(request); + }, + cancelScheduledPluginGraphWork: () => { + pluginGraphWorkScheduler.cancel(); + }, hydrateGraphScope: () => source.hydrateGraphScope?.() ?? Promise.resolve(false), reprocessGraphScope: () => source.refreshAnalysisScope(), reprocessPluginFiles: async (pluginIds) => reprocessPluginFiles(source, pluginIds), diff --git a/packages/extension/src/extension/graphView/webview/settingsMessages/pluginGraphWork.ts b/packages/extension/src/extension/graphView/webview/settingsMessages/pluginGraphWork.ts new file mode 100644 index 000000000..930d6cca0 --- /dev/null +++ b/packages/extension/src/extension/graphView/webview/settingsMessages/pluginGraphWork.ts @@ -0,0 +1,136 @@ +import type { + CodeGraphyWorkspacePluginIndexingPlan, + CodeGraphyWorkspacePluginSettingUpdateIndexingPlan, +} from '@codegraphy-dev/core'; + +export type PluginGraphWorkRequest = CodeGraphyWorkspacePluginIndexingPlan; + +export interface PluginGraphWorkHandlers { + analyzeAndSendData(): Promise; + reprocessPluginFiles(pluginIds: readonly string[]): Promise; + smartRebuild(pluginId: string): void; +} + +export interface PluginGraphWorkPlanHandlers extends PluginGraphWorkHandlers { + schedulePluginGraphWork?(request: PluginGraphWorkRequest): void; +} + +export interface PluginGraphWorkScheduler { + schedule(request: PluginGraphWorkRequest): void; + cancel(): void; +} + +interface PluginGraphWorkSchedulerOptions { + delayMs?: number; +} + +interface PendingPluginGraphWork { + kind: 'reprocess-plugin-files' | 'analyze-workspace'; + pluginIds: Set; +} + +const DEFAULT_PLUGIN_GRAPH_WORK_DELAY_MS = 150; + +export function createPluginGraphWorkScheduler( + handlers: PluginGraphWorkHandlers, + options: PluginGraphWorkSchedulerOptions = {}, +): PluginGraphWorkScheduler { + const delayMs = options.delayMs ?? DEFAULT_PLUGIN_GRAPH_WORK_DELAY_MS; + let pending: PendingPluginGraphWork | undefined; + let timeout: ReturnType | undefined; + + const clearPendingTimeout = (): void => { + if (!timeout) { + return; + } + + clearTimeout(timeout); + timeout = undefined; + }; + + const schedulePendingWork = (): void => { + clearPendingTimeout(); + timeout = setTimeout(() => { + const work = pending; + pending = undefined; + timeout = undefined; + if (!work) { + return; + } + + void runPluginGraphWork(handlers, work); + }, delayMs); + }; + + return { + schedule(request) { + if (request.kind === 'projection-only') { + return; + } + + pending = mergePluginGraphWork(pending, request); + schedulePendingWork(); + }, + cancel() { + clearPendingTimeout(); + pending = undefined; + }, + }; +} + +export async function applyPluginGraphWorkPlan( + plan: CodeGraphyWorkspacePluginSettingUpdateIndexingPlan, + pluginId: string, + handlers: PluginGraphWorkPlanHandlers, +): Promise { + if (plan.kind === 'settings-only') { + return; + } + + if (plan.kind === 'projection-only') { + handlers.smartRebuild(pluginId); + return; + } + + if (handlers.schedulePluginGraphWork) { + handlers.schedulePluginGraphWork(plan); + return; + } + + await runPluginGraphWork(handlers, { + kind: plan.kind, + pluginIds: new Set(plan.kind === 'reprocess-plugin-files' ? plan.pluginIds : []), + }); +} + +async function runPluginGraphWork( + handlers: PluginGraphWorkHandlers, + work: PendingPluginGraphWork, +): Promise { + if (work.kind === 'analyze-workspace') { + await handlers.analyzeAndSendData(); + return; + } + + await handlers.reprocessPluginFiles([...work.pluginIds]); +} + +function mergePluginGraphWork( + pending: PendingPluginGraphWork | undefined, + request: Exclude, +): PendingPluginGraphWork { + if (request.kind === 'analyze-workspace' || pending?.kind === 'analyze-workspace') { + return { + kind: 'analyze-workspace', + pluginIds: new Set(), + }; + } + + return { + kind: 'reprocess-plugin-files', + pluginIds: new Set([ + ...(pending?.pluginIds ?? []), + ...request.pluginIds, + ]), + }; +} diff --git a/packages/extension/src/extension/graphView/webview/settingsMessages/router.ts b/packages/extension/src/extension/graphView/webview/settingsMessages/router.ts index 05f7d8980..d003da7d4 100644 --- a/packages/extension/src/extension/graphView/webview/settingsMessages/router.ts +++ b/packages/extension/src/extension/graphView/webview/settingsMessages/router.ts @@ -7,6 +7,7 @@ import { applySettingsUpdateMessage } from './updates/apply'; import { applyCssSnippetMessage } from './cssSnippets'; import { applySettingsDirectionMessage } from './direction'; import { applySettingsToggleMessage } from './toggle'; +import type { PluginGraphWorkRequest } from './pluginGraphWork'; export interface GraphViewSettingsMessageState { filterPatterns: string[]; @@ -29,6 +30,8 @@ export interface GraphViewSettingsMessageHandlers { recomputeGroups(): void; sendGroupsUpdated(): void; smartRebuild(id: string): void; + schedulePluginGraphWork?(request: PluginGraphWorkRequest): void; + cancelScheduledPluginGraphWork?(): void; sendGraphControls(): void; hydrateGraphScope(): Promise; reprocessGraphScope(): Promise; diff --git a/packages/extension/src/extension/graphView/webview/settingsMessages/toggle.ts b/packages/extension/src/extension/graphView/webview/settingsMessages/toggle.ts index 09d746822..125f3786b 100644 --- a/packages/extension/src/extension/graphView/webview/settingsMessages/toggle.ts +++ b/packages/extension/src/extension/graphView/webview/settingsMessages/toggle.ts @@ -8,6 +8,7 @@ import type { GraphViewSettingsMessageState, } from './router'; import { sendFilterPatternsUpdated } from './updates/apply/filterPatternNotification'; +import { applyPluginGraphWorkPlan } from './pluginGraphWork'; export async function applySettingsToggleMessage( message: WebviewToExtensionMessage, @@ -39,22 +40,7 @@ export async function applySettingsToggleMessage( handlers.sendGraphViewContributionStatuses?.(); handlers.sendGraphControls(); sendFilterPatternsUpdated(state, handlers); - if (plan.indexing.kind === 'reprocess-plugin-files') { - await handlers.reprocessPluginFiles(plan.indexing.pluginIds); - return true; - } - - if (plan.indexing.kind === 'analyze-workspace') { - await handlers.analyzeAndSendData(); - return true; - } - - if (plan.indexing.kind === 'projection-only') { - handlers.smartRebuild(message.payload.pluginId); - return true; - } - - handlers.smartRebuild(message.payload.pluginId); + await applyPluginGraphWorkPlan(plan.indexing, message.payload.pluginId, handlers); return true; } @@ -63,6 +49,7 @@ export async function applySettingsToggleMessage( } } + function replaySavedPluginData( pluginId: string, handlers: GraphViewSettingsMessageHandlers, diff --git a/packages/extension/src/extension/graphView/webview/settingsMessages/updates/apply/pluginData.ts b/packages/extension/src/extension/graphView/webview/settingsMessages/updates/apply/pluginData.ts index 82cfb37ec..b87bb281c 100644 --- a/packages/extension/src/extension/graphView/webview/settingsMessages/updates/apply/pluginData.ts +++ b/packages/extension/src/extension/graphView/webview/settingsMessages/updates/apply/pluginData.ts @@ -1,5 +1,7 @@ +import { createCodeGraphyWorkspacePluginSettingUpdateIndexingPlan } from '@codegraphy-dev/core'; import type { WebviewToExtensionMessage } from '../../../../../../shared/protocol/webviewToExtension'; import type { GraphViewSettingsMessageHandlers } from '../../router'; +import { applyPluginGraphWorkPlan } from '../../pluginGraphWork'; export async function applyPluginDataMessage( message: WebviewToExtensionMessage, @@ -14,8 +16,10 @@ export async function applyPluginDataMessage( return true; } + const previousPluginData = handlers.getConfig>('pluginData', {}); + const previousData = previousPluginData[pluginId]; const pluginData = { - ...handlers.getConfig>('pluginData', {}), + ...previousPluginData, [pluginId]: data, }; await handlers.updateConfig('pluginData', pluginData); @@ -23,5 +27,54 @@ export async function applyPluginDataMessage( type: 'PLUGIN_DATA_UPDATED', payload: { pluginId, data }, }); + + if (hasPluginDataChanged(previousData, data)) { + await applyPluginGraphWorkPlan( + createCodeGraphyWorkspacePluginSettingUpdateIndexingPlan({ + pluginId, + settingKeys: getChangedPluginDataKeys(previousData, data), + updateImpact: handlers.getInstalledPluginUpdateImpact?.(pluginId), + }), + pluginId, + handlers, + ); + } + return true; } + +function hasPluginDataChanged(previousData: unknown, nextData: unknown): boolean { + if (!isRecord(previousData) || !isRecord(nextData)) { + return !Object.is(previousData, nextData); + } + + return getChangedPluginDataKeys(previousData, nextData).length > 0; +} + +function getChangedPluginDataKeys(previousData: unknown, nextData: unknown): string[] { + const previousRecord = isRecord(previousData) ? previousData : undefined; + const nextRecord = isRecord(nextData) ? nextData : undefined; + + if (!previousRecord && !nextRecord) { + return []; + } + + if (!previousRecord) { + return Object.keys(nextRecord ?? {}); + } + + if (!nextRecord) { + return Object.keys(previousRecord); + } + + const keys = new Set([ + ...Object.keys(previousRecord), + ...Object.keys(nextRecord), + ]); + + return [...keys].filter(key => !Object.is(previousRecord[key], nextRecord[key])); +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} diff --git a/packages/extension/tests/extension/graphView/webview/dispatch/primaryState.test.ts b/packages/extension/tests/extension/graphView/webview/dispatch/primaryState.test.ts new file mode 100644 index 000000000..f3c18c433 --- /dev/null +++ b/packages/extension/tests/extension/graphView/webview/dispatch/primaryState.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createGraphViewPrimaryNodeFileHandlers } from '../../../../../src/extension/graphView/webview/dispatch/primaryState'; +import { createPrimaryMessageContext } from './context'; + +describe('graph view primary message state adapters', () => { + it('cancels queued plugin graph work before explicit graph refreshes', async () => { + const cancelScheduledPluginGraphWork = vi.fn(); + const refreshIndex = vi.fn(() => Promise.resolve()); + const handlers = createGraphViewPrimaryNodeFileHandlers(createPrimaryMessageContext({ + cancelScheduledPluginGraphWork, + refreshIndex, + })); + + await handlers.refreshGraph(); + + expect(cancelScheduledPluginGraphWork).toHaveBeenCalledOnce(); + expect(refreshIndex).toHaveBeenCalledOnce(); + expect(cancelScheduledPluginGraphWork.mock.invocationCallOrder[0]) + .toBeLessThan(refreshIndex.mock.invocationCallOrder[0]); + }); + + it('cancels queued plugin graph work before explicit graph indexing', async () => { + const cancelScheduledPluginGraphWork = vi.fn(); + const indexAndSendData = vi.fn(() => Promise.resolve()); + const handlers = createGraphViewPrimaryNodeFileHandlers(createPrimaryMessageContext({ + cancelScheduledPluginGraphWork, + indexAndSendData, + })); + + await handlers.indexGraph(); + + expect(cancelScheduledPluginGraphWork).toHaveBeenCalledOnce(); + expect(indexAndSendData).toHaveBeenCalledOnce(); + expect(cancelScheduledPluginGraphWork.mock.invocationCallOrder[0]) + .toBeLessThan(indexAndSendData.mock.invocationCallOrder[0]); + }); +}); diff --git a/packages/extension/tests/extension/graphView/webview/settingsMessages/pluginGraphWork.test.ts b/packages/extension/tests/extension/graphView/webview/settingsMessages/pluginGraphWork.test.ts new file mode 100644 index 000000000..92af55529 --- /dev/null +++ b/packages/extension/tests/extension/graphView/webview/settingsMessages/pluginGraphWork.test.ts @@ -0,0 +1,60 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + createPluginGraphWorkScheduler, +} from '../../../../../src/extension/graphView/webview/settingsMessages/pluginGraphWork'; + +describe('graph view plugin graph work scheduler', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('coalesces plugin-file refresh bursts into one debounced latest-state job', async () => { + vi.useFakeTimers(); + const reprocessPluginFiles = vi.fn(() => Promise.resolve()); + const scheduler = createPluginGraphWorkScheduler({ + analyzeAndSendData: vi.fn(() => Promise.resolve()), + reprocessPluginFiles, + smartRebuild: vi.fn(), + }, { delayMs: 50 }); + + for (let index = 0; index < 10; index += 1) { + scheduler.schedule({ + kind: 'reprocess-plugin-files', + pluginIds: [`plugin-${index % 3}`], + }); + } + + expect(reprocessPluginFiles).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(49); + expect(reprocessPluginFiles).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + + expect(reprocessPluginFiles).toHaveBeenCalledOnce(); + expect(reprocessPluginFiles).toHaveBeenCalledWith([ + 'plugin-0', + 'plugin-1', + 'plugin-2', + ]); + }); + + it('lets full workspace analysis supersede queued plugin-file refresh work', async () => { + vi.useFakeTimers(); + const analyzeAndSendData = vi.fn(() => Promise.resolve()); + const reprocessPluginFiles = vi.fn(() => Promise.resolve()); + const scheduler = createPluginGraphWorkScheduler({ + analyzeAndSendData, + reprocessPluginFiles, + smartRebuild: vi.fn(), + }, { delayMs: 50 }); + + scheduler.schedule({ kind: 'reprocess-plugin-files', pluginIds: ['codegraphy.vue'] }); + scheduler.schedule({ kind: 'analyze-workspace' }); + + await vi.advanceTimersByTimeAsync(50); + + expect(analyzeAndSendData).toHaveBeenCalledOnce(); + expect(reprocessPluginFiles).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/extension/tests/extension/graphView/webview/settingsMessages/updates.test.ts b/packages/extension/tests/extension/graphView/webview/settingsMessages/updates.test.ts index 9c8131444..9201d5fe1 100644 --- a/packages/extension/tests/extension/graphView/webview/settingsMessages/updates.test.ts +++ b/packages/extension/tests/extension/graphView/webview/settingsMessages/updates.test.ts @@ -1,11 +1,16 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import type { WebviewToExtensionMessage } from '../../../../../src/shared/protocol/webviewToExtension'; import { applySettingsUpdateMessage, } from '../../../../../src/extension/graphView/webview/settingsMessages/updates/apply'; +import { createPluginGraphWorkScheduler } from '../../../../../src/extension/graphView/webview/settingsMessages/pluginGraphWork'; import { createHandlers, createState } from './testSupport'; describe('graph view settings update message', () => { + afterEach(() => { + vi.useRealTimers(); + }); + it('delegates reset-all requests', async () => { const state = createState(); const handlers = createHandlers(); @@ -218,6 +223,95 @@ describe('graph view settings update message', () => { }); }); + it('uses plugin setting impact metadata instead of hard-coded plugin setting branches', async () => { + const state = createState(); + const schedulePluginGraphWork = vi.fn(); + const handlers = createHandlers({ + getInstalledPluginUpdateImpact: vi.fn(() => ({ + toggle: 'projection-only' as const, + defaultSetting: 'settings-only' as const, + })), + schedulePluginGraphWork, + }); + + await expect( + applySettingsUpdateMessage( + { + type: 'UPDATE_PLUGIN_DATA', + payload: { + pluginId: 'codegraphy.particles', + data: { speed: 0.4, size: 0.8 }, + }, + }, + state, + handlers, + ), + ).resolves.toBe(true); + + expect(schedulePluginGraphWork).not.toHaveBeenCalled(); + expect(handlers.analyzeAndSendData).not.toHaveBeenCalled(); + expect(handlers.reprocessPluginFiles).not.toHaveBeenCalled(); + expect(handlers.smartRebuild).not.toHaveBeenCalled(); + }); + + it('coalesces analyzer plugin setting bursts into one graph work job', async () => { + vi.useFakeTimers(); + const state = createState(); + const pluginData: Record = {}; + const analyzeAndSendData = vi.fn(() => Promise.resolve()); + const reprocessPluginFiles = vi.fn(() => Promise.resolve()); + const scheduler = createPluginGraphWorkScheduler({ + analyzeAndSendData, + reprocessPluginFiles, + smartRebuild: vi.fn(), + }, { delayMs: 50 }); + const handlers = createHandlers({ + getConfig: vi.fn((key: string, defaultValue: T): T => { + if (key === 'pluginData') { + return { ...pluginData } as T; + } + return defaultValue; + }), + updateConfig: vi.fn(async (key: string, value: unknown) => { + if (key === 'pluginData' && value && typeof value === 'object' && !Array.isArray(value)) { + Object.assign(pluginData, value as Record); + } + }), + getInstalledPluginUpdateImpact: vi.fn(() => ({ + toggle: 'reanalyze-plugin-files' as const, + defaultSetting: 'reanalyze-plugin-files' as const, + })), + schedulePluginGraphWork: request => scheduler.schedule(request), + analyzeAndSendData, + reprocessPluginFiles, + }); + + for (let index = 0; index < 10; index += 1) { + await expect( + applySettingsUpdateMessage( + { + type: 'UPDATE_PLUGIN_DATA', + payload: { + pluginId: 'codegraphy.vue', + data: { includeTests: index % 2 === 0 }, + }, + }, + state, + handlers, + ), + ).resolves.toBe(true); + } + + expect(analyzeAndSendData).not.toHaveBeenCalled(); + expect(reprocessPluginFiles).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(50); + + expect(reprocessPluginFiles).toHaveBeenCalledOnce(); + expect(reprocessPluginFiles).toHaveBeenCalledWith(['codegraphy.vue']); + expect(analyzeAndSendData).not.toHaveBeenCalled(); + }); + it('merges plugin-owned data with existing plugin data', async () => { const state = createState(); const handlers = createHandlers({ From 28cf0bce94e43eed908bac6a2301ea203ae4e604 Mon Sep 17 00:00:00 2001 From: joesobo Date: Thu, 25 Jun 2026 12:47:20 -0700 Subject: [PATCH 11/14] feat: reuse hydrated graph scope --- .../graphView/provider/refresh/contracts.ts | 1 + .../graphView/provider/refresh/coordinator.ts | 1 + .../provider/refresh/requests/methods.ts | 1 + .../provider/refresh/scoped/methods.ts | 8 ++ .../provider/refresh/targeted.test.ts | 23 +++++ .../settingsMessages/updates/controls.test.ts | 88 +++++++++++++++++++ 6 files changed, 122 insertions(+) diff --git a/packages/extension/src/extension/graphView/provider/refresh/contracts.ts b/packages/extension/src/extension/graphView/provider/refresh/contracts.ts index 50cbbb1bc..d14354960 100644 --- a/packages/extension/src/extension/graphView/provider/refresh/contracts.ts +++ b/packages/extension/src/extension/graphView/provider/refresh/contracts.ts @@ -47,6 +47,7 @@ export interface GraphViewProviderRefreshAnalyzerLike { } export interface RefreshCoordinatorState { + graphScopeHydrated: boolean; indexRefreshPromise: Promise | undefined; queuedChangedFilePaths: Set; } diff --git a/packages/extension/src/extension/graphView/provider/refresh/coordinator.ts b/packages/extension/src/extension/graphView/provider/refresh/coordinator.ts index 3a7ea0a1e..0b00a016f 100644 --- a/packages/extension/src/extension/graphView/provider/refresh/coordinator.ts +++ b/packages/extension/src/extension/graphView/provider/refresh/coordinator.ts @@ -6,6 +6,7 @@ import { canRunIncrementalChangedFileRefresh } from './run'; export function createRefreshCoordinatorState(): RefreshCoordinatorState { return { + graphScopeHydrated: false, indexRefreshPromise: undefined, queuedChangedFilePaths: new Set(), }; 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 6132d7d95..20468c281 100644 --- a/packages/extension/src/extension/graphView/provider/refresh/requests/methods.ts +++ b/packages/extension/src/extension/graphView/provider/refresh/requests/methods.ts @@ -42,6 +42,7 @@ export function createRefreshIndexMethod( } beforeRefreshIndex?.(); + state.graphScopeHydrated = false; state.indexRefreshPromise = runIndexRefreshWithInputs(source); try { await state.indexRefreshPromise; 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 c2ee76318..a93a09908 100644 --- a/packages/extension/src/extension/graphView/provider/refresh/scoped/methods.ts +++ b/packages/extension/src/extension/graphView/provider/refresh/scoped/methods.ts @@ -24,6 +24,10 @@ export function createHydrateGraphScopeMethod( await state.indexRefreshPromise; } + if (state.graphScopeHydrated) { + return true; + } + prepareRefreshInputs(source); if (!source._analyzer?.loadCachedGraph) { return false; @@ -47,6 +51,7 @@ export function createHydrateGraphScopeMethod( } publishGraphDataIfPresent(source, graphData); + state.graphScopeHydrated = true; return true; }; } @@ -79,6 +84,9 @@ export function createRefreshAnalysisScopeMethod( scopedRefreshLifecycle, ); publishGraphDataIfPresent(source, graphData); + if (hasGraphData(graphData)) { + state.graphScopeHydrated = true; + } }; } 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 93755422e..da63beb89 100644 --- a/packages/extension/tests/extension/graphView/provider/refresh/targeted.test.ts +++ b/packages/extension/tests/extension/graphView/provider/refresh/targeted.test.ts @@ -199,4 +199,27 @@ describe('graphView/provider/refresh targeted refreshes', () => { payload: source._graphData, }); }); + + it('hydrateGraphScope reuses hydrated graph memory without rereading Graph Cache', async () => { + const source = createSource(); + const graphData = { + nodes: [{ id: 'symbol-node', label: 'symbol-node', color: '#ffffff' }], + edges: [], + } satisfies IGraphData; + source._analyzer.loadCachedGraph.mockResolvedValueOnce(graphData); + const rebuildGraphData = vi.fn(); + const methods = createGraphViewProviderRefreshMethods(source as never, { + getShowOrphans: vi.fn(() => true), + rebuildGraphData, + smartRebuildGraphData: vi.fn(), + }); + + await expect(methods.hydrateGraphScope()).resolves.toBe(true); + await expect(methods.hydrateGraphScope()).resolves.toBe(true); + + expect(source._analyzer.loadCachedGraph).toHaveBeenCalledOnce(); + expect(source._analyzer.refreshAnalysisScope).not.toHaveBeenCalled(); + expect(source._analyzeAndSendData).not.toHaveBeenCalled(); + expect(rebuildGraphData).not.toHaveBeenCalled(); + }); }); diff --git a/packages/extension/tests/extension/graphView/webview/settingsMessages/updates/controls.test.ts b/packages/extension/tests/extension/graphView/webview/settingsMessages/updates/controls.test.ts index 2b3482582..cf639162f 100644 --- a/packages/extension/tests/extension/graphView/webview/settingsMessages/updates/controls.test.ts +++ b/packages/extension/tests/extension/graphView/webview/settingsMessages/updates/controls.test.ts @@ -108,6 +108,53 @@ describe('settingsMessages/updates/controls', () => { expect(handlers.reprocessGraphScope).toHaveBeenCalledOnce(); }); + it('keeps node and edge visibility bursts projection-only with zero graph jobs', async () => { + const config = { + nodeVisibility: {} as Record, + edgeVisibility: {} as Record, + nodeColors: {} as Record, + }; + const handlers = createHandlers({ + getConfig: vi.fn((key: string, defaultValue: T): T => ( + key in config ? { ...config[key as keyof typeof config] } as T : defaultValue + )), + updateConfig: vi.fn(async (key: string, value: unknown) => { + if (key in config && value && typeof value === 'object' && !Array.isArray(value)) { + config[key as keyof typeof config] = value as Record; + } + }), + }); + + const messages = [ + { type: 'UPDATE_NODE_VISIBILITY', payload: { nodeType: 'file', visible: false } }, + { type: 'UPDATE_EDGE_VISIBILITY', payload: { edgeKind: 'import', visible: false } }, + { type: 'UPDATE_NODE_VISIBILITY', payload: { nodeType: 'folder', visible: true } }, + { type: 'UPDATE_EDGE_VISIBILITY', payload: { edgeKind: 'reference', visible: true } }, + { type: 'UPDATE_NODE_COLOR', payload: { nodeType: 'file', color: '#123456' } }, + { type: 'UPDATE_NODE_VISIBILITY', payload: { nodeType: 'package', visible: false } }, + { type: 'UPDATE_EDGE_VISIBILITY', payload: { edgeKind: 'nests', visible: true } }, + { type: 'UPDATE_NODE_VISIBILITY', payload: { nodeType: 'external-package', visible: true } }, + { type: 'UPDATE_EDGE_VISIBILITY', payload: { edgeKind: 'exports', visible: false } }, + { + type: 'UPDATE_GRAPH_CONTROL_VISIBILITY_BATCH', + payload: { + nodeVisibility: { file: true }, + edgeVisibility: { import: true }, + }, + }, + ] as const; + + for (const message of messages) { + await expect(applyGraphControlMessage(message, handlers)).resolves.toBe(true); + } + + expect(handlers.analyzeAndSendData).not.toHaveBeenCalled(); + expect(handlers.reprocessGraphScope).not.toHaveBeenCalled(); + expect(handlers.reprocessPluginFiles).not.toHaveBeenCalled(); + expect(handlers.hydrateGraphScope).not.toHaveBeenCalled(); + expect(handlers.smartRebuild).not.toHaveBeenCalled(); + }); + it('ignores empty batched visibility updates', async () => { const handlers = createHandlers(); @@ -246,6 +293,47 @@ describe('settingsMessages/updates/controls', () => { expect(handlers.analyzeAndSendData).not.toHaveBeenCalled(); }); + it('keeps hydrated symbol evidence in memory for later off/on toggles', async () => { + let nodeVisibility: Record = { + symbol: false, + 'symbol:function': false, + }; + const handlers = createHandlers({ + getConfig: vi.fn((key: string, defaultValue: T): T => ( + key === 'nodeVisibility' ? { ...nodeVisibility } as T : defaultValue + )), + updateConfig: vi.fn(async (key: string, value: unknown) => { + if (key === 'nodeVisibility') { + nodeVisibility = value as Record; + } + }), + hydrateGraphScope: vi.fn(() => Promise.resolve(true)), + }); + + await expect( + applyGraphControlMessage( + { type: 'UPDATE_NODE_VISIBILITY', payload: { nodeType: 'symbol:function', visible: true } }, + handlers, + ), + ).resolves.toBe(true); + await expect( + applyGraphControlMessage( + { type: 'UPDATE_NODE_VISIBILITY', payload: { nodeType: 'symbol:function', visible: false } }, + handlers, + ), + ).resolves.toBe(true); + await expect( + applyGraphControlMessage( + { type: 'UPDATE_NODE_VISIBILITY', payload: { nodeType: 'symbol:function', visible: true } }, + handlers, + ), + ).resolves.toBe(true); + + expect(handlers.hydrateGraphScope).toHaveBeenCalledTimes(2); + expect(handlers.reprocessGraphScope).not.toHaveBeenCalled(); + expect(handlers.analyzeAndSendData).not.toHaveBeenCalled(); + }); + it('enables Symbols and Variables when a variable child type is enabled', async () => { const handlers = createHandlers({ getConfig: vi.fn((key: string, defaultValue: T): T => { From a0c47119c7ef5e5979bed37a34394e8e161196e9 Mon Sep 17 00:00:00 2001 From: joesobo Date: Thu, 25 Jun 2026 12:49:18 -0700 Subject: [PATCH 12/14] docs: record scheduler acceptance metrics --- .changeset/snappy-graph-runtime-scheduler.md | 18 ++++++++++ ...026-06-25-graph-cache-runtime-scheduler.md | 36 ++++++++++++++----- 2 files changed, 45 insertions(+), 9 deletions(-) create mode 100644 .changeset/snappy-graph-runtime-scheduler.md diff --git a/.changeset/snappy-graph-runtime-scheduler.md b/.changeset/snappy-graph-runtime-scheduler.md new file mode 100644 index 000000000..a4de0141e --- /dev/null +++ b/.changeset/snappy-graph-runtime-scheduler.md @@ -0,0 +1,18 @@ +--- +"@codegraphy-dev/plugin-api": minor +"@codegraphy-dev/core": patch +"@codegraphy-dev/extension": patch +"@codegraphy-dev/plugin-godot": patch +"@codegraphy-dev/plugin-markdown": patch +"@codegraphy-dev/plugin-particles": patch +"@codegraphy-dev/plugin-svelte": patch +"@codegraphy-dev/plugin-typescript": patch +"@codegraphy-dev/plugin-unity": patch +"@codegraphy-dev/plugin-vue": patch +--- + +Graph View now separates plugin and projection changes from Graph Cache work more aggressively. Filters, node visibility, edge visibility, node colors, and visual plugin settings update the live graph state without scheduling cache saves or index work. + +Plugin metadata now declares whether toggles and plugin-owned settings are visual-only, projection-only, plugin-file analysis, or full-index changes. Built-in plugins provide those declarations as examples for plugin authors. Deterministic scheduler tests verify that a 10-action node/edge visibility burst schedules 0 graph jobs, settings-only plugin data schedules 0 graph jobs, and a 10-action analyzer plugin setting burst coalesces to 1 plugin-file refresh. + +Cached Graph Scope evidence is also reused from runtime memory after the first successful hydration. Toggling a cached symbol tier on reads Graph Cache once with 0 analysis jobs and 0 cache saves, and later off/on toggles reuse the hydrated tier with 0 additional cache reads until an explicit Re-index resets the runtime state. diff --git a/docs/plans/2026-06-25-graph-cache-runtime-scheduler.md b/docs/plans/2026-06-25-graph-cache-runtime-scheduler.md index 46366e17e..76775d5a8 100644 --- a/docs/plans/2026-06-25-graph-cache-runtime-scheduler.md +++ b/docs/plans/2026-06-25-graph-cache-runtime-scheduler.md @@ -391,8 +391,10 @@ Current deterministic threshold covered: Plugin API now exposes update impact metadata, and all built-in monorepo plugins declare whether updates are projection-only, plugin-file targeted, or -full-index affecting. Missing metadata remains conservative for third-party -plugins. +full-index affecting. Plugin-owned data updates use the same policy, so visual +plugin settings can persist and broadcast immediately while analyzer plugin +settings schedule one debounced graph job. Missing metadata remains conservative +for third-party plugins. Current deterministic thresholds covered: @@ -401,6 +403,19 @@ Current deterministic thresholds covered: | Built-in visual/plugin UI settings | Projection/settings path, no analysis | | Built-in analyzer plugin updates | Target plugin-file refresh path | | Unknown plugin impact | Conservative analysis path | +| Analyzer plugin setting burst | 10 actions coalesce to 1 plugin-file graph job | + +### Projection-Only Graph Controls + +Node visibility, edge visibility, node colors, and batched Graph Scope changes +now update saved projection state and Graph Controls without scheduling graph +jobs when no hidden evidence tier is required. + +Current deterministic threshold covered: + +| Scenario | Actions | Analysis jobs | Scoped reprocess jobs | Cache hydration jobs | +| --- | ---: | ---: | ---: | ---: | +| Node/edge visibility burst | 10 | 0 | 0 | 0 | ### Incremental Graph Cache Patching @@ -421,26 +436,29 @@ Current deterministic thresholds covered: Symbol-dependent Graph Scope toggles now try to hydrate graph scope from Graph Cache before scoped analysis. A successful cache hydration replays cached graph -data with `warmAnalysis: false`, publishes the graph, and avoids scoped analysis -or Graph Cache writes. +data with `warmAnalysis: false`, publishes the graph, marks the tier hydrated in +runtime memory, and avoids scoped analysis or Graph Cache writes. Later off/on +toggles reuse the hydrated runtime graph without rereading Graph Cache until a +full Re-index resets the hydration state. Current deterministic threshold covered: | Scenario | Cache reads | Analysis jobs | Graph Cache saves | | --- | ---: | ---: | ---: | | Toggle symbol leaf on when cached | 1 | 0 | 0 | +| Toggle symbol leaf off then on after hydration | 0 additional | 0 | 0 | ### Explicit Re-index Supersedes Scoped Work Explicit Re-index now aborts any in-flight scoped refresh before starting the -full-index path, so a slow Graph Scope/plugin hydration result cannot publish -over the re-indexed graph. +full-index path and cancels queued plugin graph work, so a slow Graph +Scope/plugin hydration result cannot publish over the re-indexed graph. Current deterministic threshold covered: -| Scenario | Full-index jobs | Stale scoped publishes | -| --- | ---: | ---: | -| Re-index during scoped refresh | 1 | 0 | +| Scenario | Full-index jobs | Stale scoped publishes | Queued plugin jobs after re-index | +| --- | ---: | ---: | ---: | +| Re-index during scoped refresh | 1 | 0 | 0 | ## Mistakes To Avoid From c5a304a3eda3304edf256b85ca8d3b6802bcd262 Mon Sep 17 00:00:00 2001 From: joesobo Date: Thu, 25 Jun 2026 13:45:33 -0700 Subject: [PATCH 13/14] feat: hydrate plugin graph evidence from cache --- .changeset/faster-godot-class-names.md | 2 +- .../faster-graph-cache-and-filtering.md | 4 +- .changeset/faster-graph-view-interactions.md | 4 +- .changeset/faster-material-groups.md | 2 +- .changeset/faster-typescript-aliases.md | 2 +- .changeset/snappy-graph-runtime-scheduler.md | 4 +- ...026-06-25-graph-cache-runtime-scheduler.md | 45 ++++++++- .../workspaceSelection.ts | 11 ++- packages/core/tests/indexing/refresh.test.ts | 5 +- .../tests/plugins/workspaceSelection.test.ts | 19 +++- .../graphView/provider/refresh/contracts.ts | 5 +- .../graphView/provider/refresh/coordinator.ts | 2 +- .../graphView/provider/refresh/factory.ts | 7 ++ .../provider/refresh/requests/methods.ts | 2 +- .../provider/refresh/scoped/methods.ts | 80 +++++++++++++++- .../provider/source/delegates/public.ts | 3 + .../graphView/provider/wiring/publicApi.ts | 3 + .../messages/webviewListener/contracts.ts | 1 + .../webview/providerMessages/listener.ts | 1 + .../settingsContext/create.ts | 3 + .../webview/settingsMessages/router.ts | 1 + .../webview/settingsMessages/toggle.ts | 23 ++++- .../extension/pipeline/service/cachedGraph.ts | 83 +++++++++++++++-- .../provider/refresh/targeted.test.ts | 79 ++++++++++++++++ .../webview/settingsMessages/testSupport.ts | 1 + .../webview/settingsMessages/toggle.test.ts | 63 ++++++++----- .../pipeline/service/cachedGraph.test.ts | 93 ++++++++++++++++++- 27 files changed, 491 insertions(+), 57 deletions(-) diff --git a/.changeset/faster-godot-class-names.md b/.changeset/faster-godot-class-names.md index cd89e5dde..89ad2584d 100644 --- a/.changeset/faster-godot-class-names.md +++ b/.changeset/faster-godot-class-names.md @@ -2,4 +2,4 @@ "@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. +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: 67.40s faster, a 64.39% reduction, and 2.81x faster. File analysis improved from 87,918ms to 23,352ms: 64,566ms faster, a 73.44% reduction, and 3.76x faster. diff --git a/.changeset/faster-graph-cache-and-filtering.md b/.changeset/faster-graph-cache-and-filtering.md index 37831df2c..286739ae8 100644 --- a/.changeset/faster-graph-cache-and-filtering.md +++ b/.changeset/faster-graph-cache-and-filtering.md @@ -3,8 +3,8 @@ "@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. +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: 196.76s faster, a 91.93% reduction, and 12.39x faster. Graph Cache saves improved from 122,757ms to 10,904ms: 111,853ms faster, a 91.12% reduction, and 11.26x faster. Graph Cache size shrank from 64,638,976 bytes to 18,153,472 bytes: 46,485,504 bytes smaller, a 71.92% reduction, and 3.56x smaller. -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. +The same benchmark now projects the current Visible Graph in 12ms instead of 775ms: 763ms faster, a 98.45% reduction, and 64.58x faster. Folder-node projection improved from 1,369ms to 32ms: 1,337ms faster, a 97.66% reduction, and 42.78x faster. Import-edge-off projection improved from 153ms to 7ms: 146ms faster, a 95.42% reduction, and 21.86x faster. Search projection improved from 781ms to 12ms: 769ms faster, a 98.46% reduction, and 65.08x faster. 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/.changeset/faster-graph-view-interactions.md b/.changeset/faster-graph-view-interactions.md index 93dd79831..c686b846d 100644 --- a/.changeset/faster-graph-view-interactions.md +++ b/.changeset/faster-graph-view-interactions.md @@ -2,8 +2,8 @@ "@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. +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: 2,795ms faster, a 93.70% reduction, and 15.87x faster. The browser-visible update path measured 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. +Warm Graph View startup improved from 9,917ms to 4,614ms: 5,303ms faster, a 53.47% reduction, and 2.15x faster. 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 index 66dda4826..934df4ebf 100644 --- a/.changeset/faster-material-groups.md +++ b/.changeset/faster-material-groups.md @@ -2,4 +2,4 @@ "@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. +Default Graph View groups from Material Icon Theme rules now resolve faster in large workspaces. The measured group computation improved from 66ms to 38ms: 28ms faster, a 42.42% reduction, and 1.74x faster. Total group publish time improved from 71ms to 39ms: 32ms faster, a 45.07% reduction, and 1.82x faster. diff --git a/.changeset/faster-typescript-aliases.md b/.changeset/faster-typescript-aliases.md index 22592fb52..4c5c99035 100644 --- a/.changeset/faster-typescript-aliases.md +++ b/.changeset/faster-typescript-aliases.md @@ -2,4 +2,4 @@ "@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. +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: 19.99s faster, a 53.64% reduction, and 2.16x faster. File analysis improved from 23,352ms to 3,697ms: 19,655ms faster, an 84.17% reduction, and 6.32x faster. diff --git a/.changeset/snappy-graph-runtime-scheduler.md b/.changeset/snappy-graph-runtime-scheduler.md index a4de0141e..dee2815f2 100644 --- a/.changeset/snappy-graph-runtime-scheduler.md +++ b/.changeset/snappy-graph-runtime-scheduler.md @@ -11,8 +11,8 @@ "@codegraphy-dev/plugin-vue": patch --- -Graph View now separates plugin and projection changes from Graph Cache work more aggressively. Filters, node visibility, edge visibility, node colors, and visual plugin settings update the live graph state without scheduling cache saves or index work. +Graph View now separates plugin and projection changes from Graph Cache work more aggressively. Filters, node visibility, edge visibility, node colors, visual plugin settings, and plugin disable toggles update the live graph state without scheduling cache saves or index work. Plugin metadata now declares whether toggles and plugin-owned settings are visual-only, projection-only, plugin-file analysis, or full-index changes. Built-in plugins provide those declarations as examples for plugin authors. Deterministic scheduler tests verify that a 10-action node/edge visibility burst schedules 0 graph jobs, settings-only plugin data schedules 0 graph jobs, and a 10-action analyzer plugin setting burst coalesces to 1 plugin-file refresh. -Cached Graph Scope evidence is also reused from runtime memory after the first successful hydration. Toggling a cached symbol tier on reads Graph Cache once with 0 analysis jobs and 0 cache saves, and later off/on toggles reuse the hydrated tier with 0 additional cache reads until an explicit Re-index resets the runtime state. +Cached Graph Scope and plugin-owned evidence tiers are also reused from runtime memory after the first successful hydration. Toggling a cached symbol tier or enabling a cached analyzer plugin reads Graph Cache once with 0 analysis jobs and 0 cache saves. Later off/on toggles reuse the hydrated tier with 0 additional cache reads until an explicit Re-index resets runtime hydration state. If Graph Cache does not contain the requested tier, CodeGraphy falls back to the targeted analysis lane instead of incorrectly treating file-only cache data as a complete graph. diff --git a/docs/plans/2026-06-25-graph-cache-runtime-scheduler.md b/docs/plans/2026-06-25-graph-cache-runtime-scheduler.md index 76775d5a8..4a93fe033 100644 --- a/docs/plans/2026-06-25-graph-cache-runtime-scheduler.md +++ b/docs/plans/2026-06-25-graph-cache-runtime-scheduler.md @@ -5,7 +5,7 @@ - Trello card: [Rethink Graph Cache, runtime memory, and update scheduling](https://trello.com/c/sawpINvf) - Branch: `codex/graph-cache-runtime-scheduler` - Base: `main` after PR [#294](https://github.com/joesobo/CodeGraphyV4/pull/294) -- Scope: planning notes for a follow-up rearchitecture PR +- Scope: implementation notes for the runtime scheduler follow-up PR ## Goal @@ -24,6 +24,30 @@ The target model: - D3 receives graph data from memory and should not wait on disk persistence for ordinary view changes. +## Measured Performance Results + +These measurements come from the CodeGraphy monorepo benchmark used during the +performance PR. They are included here so future scheduler work has concrete +numbers to compare against. + +| Area | Before | After | Improvement | +| --- | ---: | ---: | --- | +| Cold indexing | 214.04s | 17.28s | 196.76s faster, 91.93% lower, 12.39x faster | +| Graph Cache save | 122,757ms | 10,904ms | 111,853ms faster, 91.12% lower, 11.26x faster | +| Graph Cache size | 64,638,976 bytes | 18,153,472 bytes | 46,485,504 bytes smaller, 71.92% lower, 3.56x smaller | +| Visible Graph projection | 775ms | 12ms | 763ms faster, 98.45% lower, 64.58x faster | +| Folder-node projection | 1,369ms | 32ms | 1,337ms faster, 97.66% lower, 42.78x faster | +| Import-edge-off projection | 153ms | 7ms | 146ms faster, 95.42% lower, 21.86x faster | +| Search projection | 781ms | 12ms | 769ms faster, 98.46% lower, 65.08x faster | +| Imports Graph Scope row toggle | 2,983ms | 188ms | 2,795ms faster, 93.70% lower, 15.87x faster | +| Warm Graph View startup | 9,917ms | 4,614ms | 5,303ms faster, 53.47% lower, 2.15x faster | +| Material group computation | 66ms | 38ms | 28ms faster, 42.42% lower, 1.74x faster | +| Material group publish | 71ms | 39ms | 32ms faster, 45.07% lower, 1.82x faster | +| Godot metadata cold-indexing slice | 104.67s | 37.27s | 67.40s faster, 64.39% lower, 2.81x faster | +| Godot file analysis | 87,918ms | 23,352ms | 64,566ms faster, 73.44% lower, 3.76x faster | +| TypeScript alias cold-indexing slice | 37.27s | 17.28s | 19.99s faster, 53.64% lower, 2.16x faster | +| TypeScript file analysis | 23,352ms | 3,697ms | 19,655ms faster, 84.17% lower, 6.32x faster | + ## Alignment Decisions - Plugin impact metadata should be required everywhere. CodeGraphy owns the @@ -432,21 +456,32 @@ Current deterministic thresholds covered: | Change 1 file | 1 | 0 | | Delete 1 file | 1 | 0 | -### Cached Graph Scope Hydration +### Cached Evidence-Tier Hydration -Symbol-dependent Graph Scope toggles now try to hydrate graph scope from Graph -Cache before scoped analysis. A successful cache hydration replays cached graph -data with `warmAnalysis: false`, publishes the graph, marks the tier hydrated in +Symbol-dependent Graph Scope toggles and plugin-enable actions now try to +hydrate the requested evidence tier from Graph Cache before scheduling scoped +analysis. The implementation uses the same cache-tier path for `symbols` and +`plugin:` evidence. A successful cache hydration replays cached graph data +with `warmAnalysis: false`, publishes the graph, marks the tier hydrated in runtime memory, and avoids scoped analysis or Graph Cache writes. Later off/on toggles reuse the hydrated runtime graph without rereading Graph Cache until a full Re-index resets the hydration state. +Hydration requires the requested cache tier to actually exist in cached file +analysis. File-only cache data is not treated as a successful symbol or plugin +hydration; when a tier is missing, CodeGraphy falls back to the targeted +analysis lane. + Current deterministic threshold covered: | Scenario | Cache reads | Analysis jobs | Graph Cache saves | | --- | ---: | ---: | ---: | | Toggle symbol leaf on when cached | 1 | 0 | 0 | +| Toggle symbol leaf on when cache tier is missing | 1, then fallback | 1 scoped analysis | targeted only | | Toggle symbol leaf off then on after hydration | 0 additional | 0 | 0 | +| Enable analyzer plugin when cached | 1 | 0 | 0 | +| Enable analyzer plugin when cache tier is missing | 1, then fallback | 1 plugin-file refresh | targeted only | +| Explicit Re-index after cached hydration | next toggle rereads cache | 0 unless tier missing | 0 | ### Explicit Re-index Supersedes Scoped Work diff --git a/packages/core/src/plugins/installedPluginCache/workspaceSelection.ts b/packages/core/src/plugins/installedPluginCache/workspaceSelection.ts index d8ba1d831..f6e05b77b 100644 --- a/packages/core/src/plugins/installedPluginCache/workspaceSelection.ts +++ b/packages/core/src/plugins/installedPluginCache/workspaceSelection.ts @@ -65,7 +65,11 @@ export function createCodeGraphyWorkspacePluginTogglePlan( ): CodeGraphyWorkspacePluginTogglePlan { return { plugins: updateCodeGraphyWorkspacePluginSelection(plugins, options), - indexing: createPluginToggleIndexingPlan(options.pluginId, options.updateImpact?.toggle), + indexing: createPluginToggleIndexingPlan( + options.pluginId, + options.enabled, + options.updateImpact?.toggle, + ), }; } @@ -91,8 +95,13 @@ export function createCodeGraphyWorkspacePluginSettingUpdateIndexingPlan( function createPluginToggleIndexingPlan( pluginId: string, + enabled: boolean, impact: IPluginUpdateImpact | undefined, ): CodeGraphyWorkspacePluginIndexingPlan { + if (!enabled) { + return { kind: 'projection-only' }; + } + return createPluginUpdateIndexingPlan(pluginId, impact); } diff --git a/packages/core/tests/indexing/refresh.test.ts b/packages/core/tests/indexing/refresh.test.ts index ab687b6df..4aae4c49f 100644 --- a/packages/core/tests/indexing/refresh.test.ts +++ b/packages/core/tests/indexing/refresh.test.ts @@ -176,7 +176,10 @@ describe('indexing/refresh', () => { notifyFilesChanged: vi.fn(), })); - expect(source.invalidateWorkspaceFiles).toHaveBeenCalledWith(['/workspace/src/deleted.ts']); + expect(source.invalidateWorkspaceFiles).toHaveBeenCalledWith( + ['/workspace/src/deleted.ts'], + { persist: false }, + ); expect(source.analyze).toHaveBeenCalledWith( ['dist'], disabledPlugins, diff --git a/packages/core/tests/plugins/workspaceSelection.test.ts b/packages/core/tests/plugins/workspaceSelection.test.ts index 004c3cdee..aa218af21 100644 --- a/packages/core/tests/plugins/workspaceSelection.test.ts +++ b/packages/core/tests/plugins/workspaceSelection.test.ts @@ -161,7 +161,7 @@ describe('plugins/workspaceSelection', () => { })).toEqual({ kind: 'analyze-workspace' }); }); - it('plans a workspace analysis refresh when disabling a plugin id', () => { + it('plans projection-only work when disabling a plugin id', () => { const plan = createCodeGraphyWorkspacePluginTogglePlan([ { id: 'codegraphy.markdown', enabled: true }, { id: 'codegraphy.vue', enabled: true }, @@ -176,8 +176,23 @@ describe('plugins/workspaceSelection', () => { { id: 'codegraphy.vue', enabled: false }, ], indexing: { - kind: 'analyze-workspace', + kind: 'projection-only', + }, + }); + }); + + it('keeps enabling plugin evidence conservative until cache hydration or targeted analysis can run', () => { + const plan = createCodeGraphyWorkspacePluginTogglePlan([], { + pluginId: 'codegraphy.vue', + enabled: true, + updateImpact: { + toggle: 'reanalyze-plugin-files', }, }); + + expect(plan.indexing).toEqual({ + kind: 'reprocess-plugin-files', + pluginIds: ['codegraphy.vue'], + }); }); }); diff --git a/packages/extension/src/extension/graphView/provider/refresh/contracts.ts b/packages/extension/src/extension/graphView/provider/refresh/contracts.ts index d14354960..c6f22f8a4 100644 --- a/packages/extension/src/extension/graphView/provider/refresh/contracts.ts +++ b/packages/extension/src/extension/graphView/provider/refresh/contracts.ts @@ -1,6 +1,7 @@ import type { IGraphData } from '../../../../shared/graph/contracts'; import type { ExtensionToWebviewMessage } from '../../../../shared/protocol/extensionToWebview'; import type { rebuildGraphViewData, smartRebuildGraphView } from '../../view/rebuild'; +import type { AnalysisCacheTier } from '@codegraphy-dev/core'; export type GraphViewScopedRefreshProgress = { phase: string; current: number; total: number }; @@ -16,6 +17,7 @@ export interface GraphViewProviderRefreshAnalyzerLike { signal?: AbortSignal, options?: { includeCurrentGitignoreMetadata?: boolean; + requiredAnalysisCacheTiers?: readonly AnalysisCacheTier[]; warmAnalysis?: boolean; }, ): Promise; @@ -47,7 +49,7 @@ export interface GraphViewProviderRefreshAnalyzerLike { } export interface RefreshCoordinatorState { - graphScopeHydrated: boolean; + hydratedAnalysisCacheTiers: Set; indexRefreshPromise: Promise | undefined; queuedChangedFilePaths: Set; } @@ -93,6 +95,7 @@ export interface GraphViewProviderRefreshMethods { refreshIndex(): Promise; refreshGitignoreMetadata(): Promise; hydrateGraphScope(): Promise; + hydratePluginGraphScope(pluginIds: readonly string[]): Promise; refreshAnalysisScope(): Promise; refreshPluginFiles(pluginIds: readonly string[]): Promise; refreshChangedFiles(filePaths: readonly string[]): Promise; diff --git a/packages/extension/src/extension/graphView/provider/refresh/coordinator.ts b/packages/extension/src/extension/graphView/provider/refresh/coordinator.ts index 0b00a016f..4657d5f0c 100644 --- a/packages/extension/src/extension/graphView/provider/refresh/coordinator.ts +++ b/packages/extension/src/extension/graphView/provider/refresh/coordinator.ts @@ -6,7 +6,7 @@ import { canRunIncrementalChangedFileRefresh } from './run'; export function createRefreshCoordinatorState(): RefreshCoordinatorState { return { - graphScopeHydrated: false, + hydratedAnalysisCacheTiers: new Set(), indexRefreshPromise: undefined, queuedChangedFilePaths: new Set(), }; diff --git a/packages/extension/src/extension/graphView/provider/refresh/factory.ts b/packages/extension/src/extension/graphView/provider/refresh/factory.ts index 53273bd3c..c2098535b 100644 --- a/packages/extension/src/extension/graphView/provider/refresh/factory.ts +++ b/packages/extension/src/extension/graphView/provider/refresh/factory.ts @@ -14,6 +14,7 @@ import { import { createScopedRefreshLifecycle } from './scoped/lifecycle'; import { createHydrateGraphScopeMethod, + createHydratePluginGraphScopeMethod, createRefreshAnalysisScopeMethod, createRefreshGitignoreMetadataMethod, createRefreshPluginFilesMethod, @@ -44,6 +45,11 @@ export function createGraphViewProviderRefreshMethods( state, scopedRefreshLifecycle, ); + const hydratePluginGraphScope = createHydratePluginGraphScopeMethod( + source, + state, + scopedRefreshLifecycle, + ); const refreshAnalysisScope = createRefreshAnalysisScopeMethod( source, state, @@ -68,6 +74,7 @@ export function createGraphViewProviderRefreshMethods( refreshIndex, refreshGitignoreMetadata, hydrateGraphScope, + hydratePluginGraphScope, refreshAnalysisScope, refreshPluginFiles, refreshChangedFiles, 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 20468c281..38634d7dc 100644 --- a/packages/extension/src/extension/graphView/provider/refresh/requests/methods.ts +++ b/packages/extension/src/extension/graphView/provider/refresh/requests/methods.ts @@ -42,7 +42,7 @@ export function createRefreshIndexMethod( } beforeRefreshIndex?.(); - state.graphScopeHydrated = false; + state.hydratedAnalysisCacheTiers.clear(); state.indexRefreshPromise = runIndexRefreshWithInputs(source); try { await state.indexRefreshPromise; 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 a93a09908..1103784f7 100644 --- a/packages/extension/src/extension/graphView/provider/refresh/scoped/methods.ts +++ b/packages/extension/src/extension/graphView/provider/refresh/scoped/methods.ts @@ -1,3 +1,9 @@ +import { + BASELINE_ANALYSIS_CACHE_TIER, + SYMBOLS_ANALYSIS_CACHE_TIER, + createPluginAnalysisCacheTier, + type AnalysisCacheTier, +} from '@codegraphy-dev/core'; import type { IGraphData } from '../../../../../shared/graph/contracts'; import type { GraphViewProviderRefreshMethodsSource, @@ -14,17 +20,43 @@ function hasGraphData(graphData: IGraphData | undefined): graphData is IGraphDat return (graphData?.nodes.length ?? 0) > 0 || (graphData?.edges.length ?? 0) > 0; } -export function createHydrateGraphScopeMethod( +function createRequiredAnalysisCacheTiers( + tiers: readonly AnalysisCacheTier[], +): AnalysisCacheTier[] { + return [ + BASELINE_ANALYSIS_CACHE_TIER, + ...tiers.filter(tier => tier !== BASELINE_ANALYSIS_CACHE_TIER), + ]; +} + +function hasHydratedAnalysisCacheTiers( + state: RefreshCoordinatorState, + tiers: readonly AnalysisCacheTier[], +): boolean { + return tiers.every(tier => state.hydratedAnalysisCacheTiers.has(tier)); +} + +function markHydratedAnalysisCacheTiers( + state: RefreshCoordinatorState, + tiers: readonly AnalysisCacheTier[], +): void { + for (const tier of tiers) { + state.hydratedAnalysisCacheTiers.add(tier); + } +} + +function createHydrateAnalysisCacheTiersMethod( source: GraphViewProviderRefreshMethodsSource, state: RefreshCoordinatorState, scopedRefreshLifecycle: ScopedRefreshLifecycle, + tiers: readonly AnalysisCacheTier[], ): () => Promise { return async (): Promise => { if (state.indexRefreshPromise) { await state.indexRefreshPromise; } - if (state.graphScopeHydrated) { + if (hasHydratedAnalysisCacheTiers(state, tiers)) { return true; } @@ -41,6 +73,7 @@ export function createHydrateGraphScopeMethod( signal, { includeCurrentGitignoreMetadata: true, + requiredAnalysisCacheTiers: createRequiredAnalysisCacheTiers(tiers), warmAnalysis: false, }, ), @@ -51,11 +84,44 @@ export function createHydrateGraphScopeMethod( } publishGraphDataIfPresent(source, graphData); - state.graphScopeHydrated = true; + markHydratedAnalysisCacheTiers(state, tiers); return true; }; } +export function createHydrateGraphScopeMethod( + source: GraphViewProviderRefreshMethodsSource, + state: RefreshCoordinatorState, + scopedRefreshLifecycle: ScopedRefreshLifecycle, +): () => Promise { + return createHydrateAnalysisCacheTiersMethod( + source, + state, + scopedRefreshLifecycle, + [SYMBOLS_ANALYSIS_CACHE_TIER], + ); +} + +export function createHydratePluginGraphScopeMethod( + source: GraphViewProviderRefreshMethodsSource, + state: RefreshCoordinatorState, + scopedRefreshLifecycle: ScopedRefreshLifecycle, +): (pluginIds: readonly string[]) => Promise { + return async (pluginIds: readonly string[]): Promise => { + const tiers = pluginIds.map(createPluginAnalysisCacheTier); + if (tiers.length === 0) { + return true; + } + + return createHydrateAnalysisCacheTiersMethod( + source, + state, + scopedRefreshLifecycle, + tiers, + )(); + }; +} + export function createRefreshAnalysisScopeMethod( source: GraphViewProviderRefreshMethodsSource, state: RefreshCoordinatorState, @@ -85,7 +151,7 @@ export function createRefreshAnalysisScopeMethod( ); publishGraphDataIfPresent(source, graphData); if (hasGraphData(graphData)) { - state.graphScopeHydrated = true; + markHydratedAnalysisCacheTiers(state, [SYMBOLS_ANALYSIS_CACHE_TIER]); } }; } @@ -149,5 +215,11 @@ export function createRefreshPluginFilesMethod( scopedRefreshLifecycle, ); publishGraphDataIfPresent(source, graphData); + if (hasGraphData(graphData)) { + markHydratedAnalysisCacheTiers( + state, + pluginIds.map(createPluginAnalysisCacheTier), + ); + } }; } diff --git a/packages/extension/src/extension/graphView/provider/source/delegates/public.ts b/packages/extension/src/extension/graphView/provider/source/delegates/public.ts index d7424fb3c..f81c9967e 100644 --- a/packages/extension/src/extension/graphView/provider/source/delegates/public.ts +++ b/packages/extension/src/extension/graphView/provider/source/delegates/public.ts @@ -15,6 +15,7 @@ export function createGraphViewProviderPublicMethodDelegates( | 'refreshIndex' | 'refreshGitignoreMetadata' | 'hydrateGraphScope' + | 'hydratePluginGraphScope' | 'refreshAnalysisScope' | 'refreshPluginFiles' | 'refreshChangedFiles' @@ -31,6 +32,8 @@ export function createGraphViewProviderPublicMethodDelegates( refreshIndex: () => owner._methodContainers.refresh.refreshIndex(), refreshGitignoreMetadata: () => owner._methodContainers.refresh.refreshGitignoreMetadata(), hydrateGraphScope: () => owner._methodContainers.refresh.hydrateGraphScope(), + hydratePluginGraphScope: pluginIds => + owner._methodContainers.refresh.hydratePluginGraphScope(pluginIds), refreshAnalysisScope: () => owner._methodContainers.refresh.refreshAnalysisScope(), refreshPluginFiles: pluginIds => owner._methodContainers.refresh.refreshPluginFiles(pluginIds), refreshChangedFiles: filePaths => owner._methodContainers.refresh.refreshChangedFiles(filePaths), diff --git a/packages/extension/src/extension/graphView/provider/wiring/publicApi.ts b/packages/extension/src/extension/graphView/provider/wiring/publicApi.ts index c3bd0f651..3505b894a 100644 --- a/packages/extension/src/extension/graphView/provider/wiring/publicApi.ts +++ b/packages/extension/src/extension/graphView/provider/wiring/publicApi.ts @@ -34,6 +34,7 @@ export interface GraphViewProviderPublicMethods { refreshIndex: () => Promise; refreshGitignoreMetadata: () => Promise; hydrateGraphScope: () => Promise; + hydratePluginGraphScope: (pluginIds: readonly string[]) => Promise; refreshAnalysisScope: () => Promise; refreshPluginFiles: (pluginIds: readonly string[]) => Promise; refreshChangedFiles: (filePaths: readonly string[]) => Promise; @@ -100,6 +101,8 @@ export function assignGraphViewProviderPublicMethods( target.refreshGitignoreMetadata = () => target._methodContainers.refresh.refreshGitignoreMetadata(); target.hydrateGraphScope = () => target._methodContainers.refresh.hydrateGraphScope(); + target.hydratePluginGraphScope = pluginIds => + target._methodContainers.refresh.hydratePluginGraphScope(pluginIds); target.refreshAnalysisScope = () => target._methodContainers.refresh.refreshAnalysisScope(); target.refreshPluginFiles = pluginIds => target._methodContainers.refresh.refreshPluginFiles(pluginIds); target.refreshChangedFiles = filePaths => diff --git a/packages/extension/src/extension/graphView/webview/messages/webviewListener/contracts.ts b/packages/extension/src/extension/graphView/webview/messages/webviewListener/contracts.ts index b00c643c2..e2f24268b 100644 --- a/packages/extension/src/extension/graphView/webview/messages/webviewListener/contracts.ts +++ b/packages/extension/src/extension/graphView/webview/messages/webviewListener/contracts.ts @@ -12,6 +12,7 @@ import type { export interface GraphViewMessageListenerContext extends GraphViewPrimaryMessageContext, GraphViewPluginMessageContext { + hydratePluginGraphScope(pluginIds: readonly string[]): Promise; reprocessPluginFiles(pluginIds: readonly string[]): Promise; setUserGroups(groups: IGroup[]): void; setFilterPatterns(patterns: string[]): void; diff --git a/packages/extension/src/extension/graphView/webview/providerMessages/listener.ts b/packages/extension/src/extension/graphView/webview/providerMessages/listener.ts index db978c808..f636e6e0d 100644 --- a/packages/extension/src/extension/graphView/webview/providerMessages/listener.ts +++ b/packages/extension/src/extension/graphView/webview/providerMessages/listener.ts @@ -133,6 +133,7 @@ export interface GraphViewProviderMessageListenerSource { _analyzeAndSendData(): Promise; refreshIndex(): Promise; hydrateGraphScope?(): Promise; + hydratePluginGraphScope?(pluginIds: readonly string[]): Promise; refreshAnalysisScope(): Promise; refreshPluginFiles?(pluginIds: readonly string[]): Promise; refreshChangedFiles(filePaths: readonly string[]): Promise; diff --git a/packages/extension/src/extension/graphView/webview/providerMessages/settingsContext/create.ts b/packages/extension/src/extension/graphView/webview/providerMessages/settingsContext/create.ts index 016bd4c31..5ea1c8a30 100644 --- a/packages/extension/src/extension/graphView/webview/providerMessages/settingsContext/create.ts +++ b/packages/extension/src/extension/graphView/webview/providerMessages/settingsContext/create.ts @@ -33,6 +33,7 @@ type GraphViewProviderSettingsContext = Pick< | 'schedulePluginGraphWork' | 'cancelScheduledPluginGraphWork' | 'hydrateGraphScope' + | 'hydratePluginGraphScope' | 'reprocessGraphScope' | 'reprocessPluginFiles' | 'resetAllSettings' @@ -133,6 +134,8 @@ export function createGraphViewProviderMessageSettingsContext( pluginGraphWorkScheduler.cancel(); }, hydrateGraphScope: () => source.hydrateGraphScope?.() ?? Promise.resolve(false), + hydratePluginGraphScope: pluginIds => + source.hydratePluginGraphScope?.(pluginIds) ?? Promise.resolve(false), reprocessGraphScope: () => source.refreshAnalysisScope(), reprocessPluginFiles: async (pluginIds) => reprocessPluginFiles(source, pluginIds), resetAllSettings: async () => { diff --git a/packages/extension/src/extension/graphView/webview/settingsMessages/router.ts b/packages/extension/src/extension/graphView/webview/settingsMessages/router.ts index d003da7d4..b9acd34ad 100644 --- a/packages/extension/src/extension/graphView/webview/settingsMessages/router.ts +++ b/packages/extension/src/extension/graphView/webview/settingsMessages/router.ts @@ -34,6 +34,7 @@ export interface GraphViewSettingsMessageHandlers { cancelScheduledPluginGraphWork?(): void; sendGraphControls(): void; hydrateGraphScope(): Promise; + hydratePluginGraphScope?(pluginIds: readonly string[]): Promise; reprocessGraphScope(): Promise; reprocessPluginFiles(pluginIds: readonly string[]): Promise; getPluginFilterPatterns(): string[]; diff --git a/packages/extension/src/extension/graphView/webview/settingsMessages/toggle.ts b/packages/extension/src/extension/graphView/webview/settingsMessages/toggle.ts index 125f3786b..307bfdab3 100644 --- a/packages/extension/src/extension/graphView/webview/settingsMessages/toggle.ts +++ b/packages/extension/src/extension/graphView/webview/settingsMessages/toggle.ts @@ -40,7 +40,12 @@ export async function applySettingsToggleMessage( handlers.sendGraphViewContributionStatuses?.(); handlers.sendGraphControls(); sendFilterPatternsUpdated(state, handlers); - await applyPluginGraphWorkPlan(plan.indexing, message.payload.pluginId, handlers); + await applyPluginToggleGraphWorkPlan( + plan.indexing, + message.payload.pluginId, + message.payload.enabled, + handlers, + ); return true; } @@ -49,6 +54,22 @@ export async function applySettingsToggleMessage( } } +async function applyPluginToggleGraphWorkPlan( + plan: ReturnType['indexing'], + pluginId: string, + enabled: boolean, + handlers: GraphViewSettingsMessageHandlers, +): Promise { + if ( + enabled + && plan.kind === 'reprocess-plugin-files' + && await (handlers.hydratePluginGraphScope?.([pluginId]) ?? Promise.resolve(false)) + ) { + return; + } + + await applyPluginGraphWorkPlan(plan, pluginId, handlers); +} function replaySavedPluginData( pluginId: string, diff --git a/packages/extension/src/extension/pipeline/service/cachedGraph.ts b/packages/extension/src/extension/pipeline/service/cachedGraph.ts index 9e2dd4aa5..04c063e5e 100644 --- a/packages/extension/src/extension/pipeline/service/cachedGraph.ts +++ b/packages/extension/src/extension/pipeline/service/cachedGraph.ts @@ -1,4 +1,9 @@ import { + BASELINE_ANALYSIS_CACHE_TIER, + getWorkspaceIndexPluginMatchingFiles, + hasRequiredAnalysisCacheTiers, + SYMBOLS_ANALYSIS_CACHE_TIER, + type AnalysisCacheTier, type IDiscoveredFile, projectFileAnalysisConnections, throwIfWorkspaceAnalysisAborted, @@ -14,9 +19,12 @@ import { createCachedGraphAnalysisWarmupInput } from './cachedGraphWarmup/input' import { WorkspacePipelineAnalysisFacade, } from './analysisFacade'; +import type { IWorkspaceAnalysisCache } from '../cache'; +import type { IPluginInfo } from '../../../core/plugins/types/contracts'; export interface WorkspacePipelineCachedGraphLoadOptions { includeCurrentGitignoreMetadata?: boolean; + requiredAnalysisCacheTiers?: readonly AnalysisCacheTier[]; warmAnalysis?: boolean; } @@ -39,12 +47,6 @@ export abstract class WorkspacePipelineCachedGraphFacade extends WorkspacePipeli 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( @@ -53,6 +55,22 @@ export abstract class WorkspacePipelineCachedGraphFacade extends WorkspacePipeli config.respectGitignore && includeCurrentGitignoreMetadata, ); + if (!canReplayCachedGraphAnalysis( + this._cache.files, + cachedDiscovery.files, + this._registry.list(), + options.requiredAnalysisCacheTiers, + )) { + return { nodes: [], edges: [] }; + } + + const fileAnalysis = new Map( + Object.entries(this._cache.files).map(([filePath, entry]) => [ + filePath, + entry.analysis, + ]), + ); + this._lastDiscoveredFiles = cachedDiscovery.files; this._lastDiscoveredDirectories = cachedDiscovery.directories; this._lastGitIgnoredPaths = cachedDiscovery.gitIgnoredPaths; @@ -110,3 +128,56 @@ export abstract class WorkspacePipelineCachedGraphFacade extends WorkspacePipeli }); } } + +function canReplayCachedGraphAnalysis( + cachedFiles: IWorkspaceAnalysisCache['files'], + discoveredFiles: readonly IDiscoveredFile[], + pluginInfos: readonly IPluginInfo[], + requiredAnalysisCacheTiers: readonly AnalysisCacheTier[] | undefined, +): boolean { + if (!requiredAnalysisCacheTiers || requiredAnalysisCacheTiers.length === 0) { + return true; + } + + const entries = Object.values(cachedFiles); + if (entries.length === 0) { + return false; + } + + const commonTiers = requiredAnalysisCacheTiers.filter(tier => + tier === BASELINE_ANALYSIS_CACHE_TIER || tier === SYMBOLS_ANALYSIS_CACHE_TIER, + ); + if ( + commonTiers.length > 0 + && !entries.every(entry => hasRequiredAnalysisCacheTiers(entry.analysis, commonTiers)) + ) { + return false; + } + + return requiredAnalysisCacheTiers + .filter(isPluginAnalysisCacheTier) + .every(tier => canReplayPluginCacheTier(cachedFiles, discoveredFiles, pluginInfos, tier)); +} + +function isPluginAnalysisCacheTier(tier: AnalysisCacheTier): tier is `plugin:${string}` { + return tier.startsWith('plugin:'); +} + +function canReplayPluginCacheTier( + cachedFiles: IWorkspaceAnalysisCache['files'], + discoveredFiles: readonly IDiscoveredFile[], + pluginInfos: readonly IPluginInfo[], + tier: `plugin:${string}`, +): boolean { + const pluginId = tier.slice('plugin:'.length); + const pluginInfo = pluginInfos.find(info => info.plugin.id === pluginId); + if (!pluginInfo) { + return Object.values(cachedFiles).every(entry => hasRequiredAnalysisCacheTiers(entry.analysis, [tier])); + } + + return getWorkspaceIndexPluginMatchingFiles(pluginInfo, [...discoveredFiles]) + .every(file => { + const analysis = cachedFiles[file.relativePath]?.analysis; + return Boolean(analysis && hasRequiredAnalysisCacheTiers(analysis, [tier])); + }); +} 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 da63beb89..a199a1131 100644 --- a/packages/extension/tests/extension/graphView/provider/refresh/targeted.test.ts +++ b/packages/extension/tests/extension/graphView/provider/refresh/targeted.test.ts @@ -187,6 +187,7 @@ describe('graphView/provider/refresh targeted refreshes', () => { expect.any(AbortSignal), { includeCurrentGitignoreMetadata: true, + requiredAnalysisCacheTiers: ['baseline', 'symbols'], warmAnalysis: false, }, ); @@ -200,6 +201,33 @@ describe('graphView/provider/refresh targeted refreshes', () => { }); }); + it('hydrateGraphScope returns false without publishing when the cached graph lacks symbol evidence', async () => { + const source = createSource(); + source._analyzer.loadCachedGraph.mockResolvedValueOnce({ nodes: [], edges: [] }); + const methods = createGraphViewProviderRefreshMethods(source as never, { + getShowOrphans: vi.fn(() => true), + rebuildGraphData: vi.fn(), + smartRebuildGraphData: vi.fn(), + }); + + await expect(methods.hydrateGraphScope()).resolves.toBe(false); + + expect(source._analyzer.loadCachedGraph).toHaveBeenCalledWith( + ['src/**'], + source._disabledPlugins, + expect.any(AbortSignal), + { + includeCurrentGitignoreMetadata: true, + requiredAnalysisCacheTiers: ['baseline', 'symbols'], + warmAnalysis: false, + }, + ); + expect(source._sendMessage).not.toHaveBeenCalledWith({ + type: 'GRAPH_DATA_UPDATED', + payload: source._graphData, + }); + }); + it('hydrateGraphScope reuses hydrated graph memory without rereading Graph Cache', async () => { const source = createSource(); const graphData = { @@ -222,4 +250,55 @@ describe('graphView/provider/refresh targeted refreshes', () => { expect(source._analyzeAndSendData).not.toHaveBeenCalled(); expect(rebuildGraphData).not.toHaveBeenCalled(); }); + + it('hydratePluginGraphScope replays cached plugin evidence and remembers each hydrated plugin tier', async () => { + const source = createSource(); + const graphData = { + nodes: [{ id: 'plugin-node', label: 'plugin-node', color: '#ffffff' }], + edges: [], + } satisfies IGraphData; + source._analyzer.loadCachedGraph.mockResolvedValueOnce(graphData); + const methods = createGraphViewProviderRefreshMethods(source as never, { + getShowOrphans: vi.fn(() => true), + rebuildGraphData: vi.fn(), + smartRebuildGraphData: vi.fn(), + }); + + await expect(methods.hydratePluginGraphScope(['codegraphy.vue'])).resolves.toBe(true); + await expect(methods.hydratePluginGraphScope(['codegraphy.vue'])).resolves.toBe(true); + + expect(source._analyzer.loadCachedGraph).toHaveBeenCalledOnce(); + expect(source._analyzer.loadCachedGraph).toHaveBeenCalledWith( + ['src/**'], + source._disabledPlugins, + expect.any(AbortSignal), + { + includeCurrentGitignoreMetadata: true, + requiredAnalysisCacheTiers: ['baseline', 'plugin:codegraphy.vue'], + warmAnalysis: false, + }, + ); + expect(source._rawGraphData).toBe(graphData); + expect(source._analyzer.refreshPluginFiles).not.toHaveBeenCalled(); + }); + + it('refreshIndex clears remembered hydrated plugin tiers before the next cache replay', async () => { + const source = createSource(); + const graphData = { + nodes: [{ id: 'plugin-node', label: 'plugin-node', color: '#ffffff' }], + edges: [], + } satisfies IGraphData; + source._analyzer.loadCachedGraph.mockResolvedValue(graphData); + const methods = createGraphViewProviderRefreshMethods(source as never, { + getShowOrphans: vi.fn(() => true), + rebuildGraphData: vi.fn(), + smartRebuildGraphData: vi.fn(), + }); + + await expect(methods.hydratePluginGraphScope(['codegraphy.vue'])).resolves.toBe(true); + await methods.refreshIndex(); + await expect(methods.hydratePluginGraphScope(['codegraphy.vue'])).resolves.toBe(true); + + expect(source._analyzer.loadCachedGraph).toHaveBeenCalledTimes(2); + }); }); diff --git a/packages/extension/tests/extension/graphView/webview/settingsMessages/testSupport.ts b/packages/extension/tests/extension/graphView/webview/settingsMessages/testSupport.ts index f5971604d..d676dfc53 100644 --- a/packages/extension/tests/extension/graphView/webview/settingsMessages/testSupport.ts +++ b/packages/extension/tests/extension/graphView/webview/settingsMessages/testSupport.ts @@ -27,6 +27,7 @@ export function createHandlers( analyzeAndSendData: vi.fn(() => Promise.resolve()), smartRebuild: vi.fn(), hydrateGraphScope: vi.fn(() => Promise.resolve(false)), + hydratePluginGraphScope: vi.fn(() => Promise.resolve(false)), reprocessGraphScope: vi.fn(() => Promise.resolve()), reprocessPluginFiles: vi.fn(() => Promise.resolve()), sendMessage: vi.fn(), diff --git a/packages/extension/tests/extension/graphView/webview/settingsMessages/toggle.test.ts b/packages/extension/tests/extension/graphView/webview/settingsMessages/toggle.test.ts index a02b209a8..946bd79b2 100644 --- a/packages/extension/tests/extension/graphView/webview/settingsMessages/toggle.test.ts +++ b/packages/extension/tests/extension/graphView/webview/settingsMessages/toggle.test.ts @@ -36,6 +36,7 @@ function createHandlers( getPluginFilterPatterns: vi.fn(() => []), getPluginFilterGroups: vi.fn(() => []), sendGraphControls: vi.fn(), + hydratePluginGraphScope: vi.fn(() => Promise.resolve(false)), reprocessPluginFiles: vi.fn(() => Promise.resolve()), sendMessage: vi.fn(), resetAllSettings: vi.fn(() => Promise.resolve()), @@ -65,8 +66,8 @@ describe('graph view settings toggle message', () => { expect(handlers.updateConfig).toHaveBeenCalledWith('plugins', [ { id: 'codegraphy.vue', enabled: false }, ]); - expect(handlers.analyzeAndSendData).toHaveBeenCalledOnce(); - expect(handlers.smartRebuild).not.toHaveBeenCalled(); + expect(handlers.analyzeAndSendData).not.toHaveBeenCalled(); + expect(handlers.smartRebuild).toHaveBeenCalledWith('codegraphy.vue'); expect(handlers.reprocessPluginFiles).not.toHaveBeenCalled(); }); @@ -111,11 +112,37 @@ describe('graph view settings toggle message', () => { ); expect(handled).toBe(true); + expect(handlers.hydratePluginGraphScope).toHaveBeenCalledWith(['codegraphy.vue']); expect(handlers.reprocessPluginFiles).toHaveBeenCalledWith(['codegraphy.vue']); expect(handlers.analyzeAndSendData).not.toHaveBeenCalled(); expect(handlers.smartRebuild).not.toHaveBeenCalled(); }); + it('hydrates cached plugin evidence before scheduling targeted plugin-file reprocessing', async () => { + const state = createState(); + const handlers = createHandlers({ + getInstalledPluginUpdateImpact: vi.fn(() => ({ + toggle: 'reanalyze-plugin-files' as const, + })), + hydratePluginGraphScope: vi.fn(() => Promise.resolve(true)), + }); + + const handled = await applySettingsToggleMessage( + { + type: 'TOGGLE_PLUGIN', + payload: { pluginId: 'codegraphy.vue', enabled: true }, + }, + state, + handlers, + ); + + expect(handled).toBe(true); + expect(handlers.hydratePluginGraphScope).toHaveBeenCalledWith(['codegraphy.vue']); + expect(handlers.reprocessPluginFiles).not.toHaveBeenCalled(); + expect(handlers.analyzeAndSendData).not.toHaveBeenCalled(); + expect(handlers.smartRebuild).not.toHaveBeenCalled(); + }); + it('disables package-backed plugins by persisting disabled plugin id intent', async () => { const state = createState(); const handlers = createHandlers({ @@ -158,8 +185,8 @@ describe('graph view settings toggle message', () => { expect(handlers.updateConfig).not.toHaveBeenCalledWith('disabledPlugins', expect.anything()); expect(handlers.syncWorkspacePlugins).toHaveBeenCalledOnce(); expect(handlers.reloadWorkspacePlugins).not.toHaveBeenCalled(); - expect(handlers.analyzeAndSendData).toHaveBeenCalledOnce(); - expect(handlers.smartRebuild).not.toHaveBeenCalled(); + expect(handlers.analyzeAndSendData).not.toHaveBeenCalled(); + expect(handlers.smartRebuild).toHaveBeenCalledWith('codegraphy.vue'); expect(handlers.reprocessPluginFiles).not.toHaveBeenCalled(); }); @@ -202,7 +229,7 @@ describe('graph view settings toggle message', () => { expect(handlers.smartRebuild).not.toHaveBeenCalled(); }); - it('reanalyzes the workspace when disabling a package-backed plugin', async () => { + it('projects the graph when disabling a package-backed plugin', async () => { const state = createState(); const analyzeAndSendData = vi.fn(() => Promise.resolve()); const handlers = createHandlers({ @@ -228,8 +255,8 @@ describe('graph view settings toggle message', () => { ); expect(handled).toBe(true); - expect(analyzeAndSendData).toHaveBeenCalledOnce(); - expect(handlers.smartRebuild).not.toHaveBeenCalled(); + expect(analyzeAndSendData).not.toHaveBeenCalled(); + expect(handlers.smartRebuild).toHaveBeenCalledWith('codegraphy.unity'); }); it('copies plugin default options into workspace settings when enabling a package-backed plugin', async () => { @@ -296,7 +323,6 @@ describe('graph view settings toggle message', () => { const reloadWorkspacePlugins = vi.fn(() => Promise.resolve()); const syncWorkspacePlugins = vi.fn(() => Promise.resolve()); const sendGraphViewContributionStatuses = vi.fn(); - const analyzeAndSendData = vi.fn(() => Promise.resolve()); const handlers = createHandlers({ getConfig: vi.fn((key: string, defaultValue: T): T => { if (key === 'plugins') { @@ -310,7 +336,6 @@ describe('graph view settings toggle message', () => { reloadWorkspacePlugins, syncWorkspacePlugins, sendGraphViewContributionStatuses, - analyzeAndSendData, }); const handled = await applySettingsToggleMessage( @@ -329,12 +354,12 @@ describe('graph view settings toggle message', () => { expect(syncWorkspacePlugins).toHaveBeenCalledOnce(); expect(reloadWorkspacePlugins).not.toHaveBeenCalled(); expect(sendGraphViewContributionStatuses).toHaveBeenCalledOnce(); - expect(analyzeAndSendData).toHaveBeenCalledOnce(); - expect(handlers.smartRebuild).not.toHaveBeenCalled(); + expect(handlers.analyzeAndSendData).not.toHaveBeenCalled(); + expect(handlers.smartRebuild).toHaveBeenCalledWith('acme.graph-tools'); expect(syncWorkspacePlugins.mock.invocationCallOrder[0]) .toBeLessThan(sendGraphViewContributionStatuses.mock.invocationCallOrder[0]); expect(sendGraphViewContributionStatuses.mock.invocationCallOrder[0]) - .toBeLessThan(analyzeAndSendData.mock.invocationCallOrder[0]); + .toBeLessThan(vi.mocked(handlers.smartRebuild).mock.invocationCallOrder[0]); }); it('sends graph controls after package toggles sync plugin contributions', async () => { @@ -342,7 +367,6 @@ describe('graph view settings toggle message', () => { const reloadWorkspacePlugins = vi.fn(() => Promise.resolve()); const syncWorkspacePlugins = vi.fn(() => Promise.resolve()); const sendGraphControls = vi.fn(); - const analyzeAndSendData = vi.fn(() => Promise.resolve()); const handlers = createHandlers({ getConfig: vi.fn((key: string, defaultValue: T): T => { if (key === 'plugins') { @@ -353,7 +377,6 @@ describe('graph view settings toggle message', () => { reloadWorkspacePlugins, syncWorkspacePlugins, sendGraphControls, - analyzeAndSendData, }); const handled = await applySettingsToggleMessage( @@ -375,7 +398,7 @@ describe('graph view settings toggle message', () => { expect(syncWorkspacePlugins.mock.invocationCallOrder[0]) .toBeLessThan(sendGraphControls.mock.invocationCallOrder[0]); expect(sendGraphControls.mock.invocationCallOrder[0]) - .toBeLessThan(analyzeAndSendData.mock.invocationCallOrder[0]); + .toBeLessThan(vi.mocked(handlers.smartRebuild).mock.invocationCallOrder[0]); }); it('sends fresh filter patterns after package toggles sync plugin filters', async () => { @@ -430,10 +453,10 @@ describe('graph view settings toggle message', () => { expect(syncWorkspacePlugins.mock.invocationCallOrder[0]) .toBeLessThan(vi.mocked(handlers.getPluginFilterPatterns).mock.invocationCallOrder[0]); expect(vi.mocked(handlers.getPluginFilterPatterns).mock.invocationCallOrder[0]) - .toBeLessThan(vi.mocked(handlers.analyzeAndSendData).mock.invocationCallOrder[0]); + .toBeLessThan(vi.mocked(handlers.smartRebuild).mock.invocationCallOrder[0]); }); - it('broadcasts package plugin cleanup before re-analysis when a package is toggled off', async () => { + it('broadcasts package plugin cleanup before graph projection when a package is toggled off', async () => { const state = createState(); const reloadWorkspacePlugins = vi.fn(() => Promise.resolve()); const syncWorkspacePlugins = vi.fn(() => Promise.resolve()); @@ -442,7 +465,6 @@ describe('graph view settings toggle message', () => { const sendPluginToolbarActions = vi.fn(); const sendGraphViewContributionStatuses = vi.fn(); const sendPluginWebviewInjections = vi.fn(); - const analyzeAndSendData = vi.fn(() => Promise.resolve()); const handlers = createHandlers({ getConfig: vi.fn((key: string, defaultValue: T): T => { if (key === 'plugins') { @@ -460,7 +482,6 @@ describe('graph view settings toggle message', () => { sendPluginToolbarActions, sendGraphViewContributionStatuses, sendPluginWebviewInjections, - analyzeAndSendData, }); const handled = await applySettingsToggleMessage( @@ -484,8 +505,8 @@ describe('graph view settings toggle message', () => { expect(sendPluginStatuses.mock.invocationCallOrder[0]) .toBeGreaterThan(syncWorkspacePlugins.mock.invocationCallOrder[0]); expect(sendPluginStatuses.mock.invocationCallOrder[0]) - .toBeLessThan(analyzeAndSendData.mock.invocationCallOrder[0]); - expect(handlers.smartRebuild).not.toHaveBeenCalled(); + .toBeLessThan(vi.mocked(handlers.smartRebuild).mock.invocationCallOrder[0]); + expect(handlers.analyzeAndSendData).not.toHaveBeenCalled(); }); it('sends plugin webview injections before workspace analysis after package toggles', async () => { diff --git a/packages/extension/tests/extension/pipeline/service/cachedGraph.test.ts b/packages/extension/tests/extension/pipeline/service/cachedGraph.test.ts index a1f669dce..988d93121 100644 --- a/packages/extension/tests/extension/pipeline/service/cachedGraph.test.ts +++ b/packages/extension/tests/extension/pipeline/service/cachedGraph.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { + hasRequiredAnalysisCacheTiers, projectFileAnalysisConnections, throwIfWorkspaceAnalysisAborted, type FileDiscovery, @@ -20,6 +21,7 @@ vi.mock('@codegraphy-dev/core', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, + hasRequiredAnalysisCacheTiers: vi.fn(), projectFileAnalysisConnections: vi.fn(), throwIfWorkspaceAnalysisAborted: vi.fn(), }; @@ -47,10 +49,18 @@ vi.mock('vscode', () => ({ })); const cachedAnalysis = { filePath: '/workspace/src/cached.ts', imports: [], relations: [], symbols: [] }; +const readmeAnalysis = { filePath: '/workspace/README.md', imports: [], relations: [], symbols: [] }; const cachedFiles: IDiscoveredFile[] = [{ absolutePath: '/workspace/src/cached.ts', relativePath: 'src/cached.ts', }] as never; +const mixedCachedFiles: IDiscoveredFile[] = [ + ...cachedFiles, + { + absolutePath: '/workspace/README.md', + relativePath: 'README.md', + }, +] as never; class TestCachedGraphFacade extends WorkspacePipelineCachedGraphFacade { readonly getWorkspaceRoot = vi.fn<() => string | undefined>(() => '/workspace'); @@ -115,6 +125,7 @@ class TestCachedGraphFacade extends WorkspacePipelineCachedGraphFacade { } interface CachedGraphState { + _cache: unknown; _lastDiscoveredDirectories: string[]; _lastDiscoveredFiles: IDiscoveredFile[]; _lastFileAnalysis: Map; @@ -127,17 +138,23 @@ function cachedGraphState(facade: TestCachedGraphFacade): CachedGraphState { return facade as unknown as CachedGraphState; } -function setupCachedDiscovery(): Map { - const projectedConnections = new Map([['src/cached.ts', []]]); +function setCachedGraphCache(facade: TestCachedGraphFacade, cache: unknown): void { + cachedGraphState(facade)._cache = cache; +} + +function setupCachedDiscovery(files: readonly IDiscoveredFile[] = cachedFiles): Map { + const projectedConnections = new Map( + files.map(file => [file.relativePath, []]), + ); vi.mocked(projectFileAnalysisConnections).mockReturnValue(projectedConnections as never); vi.mocked(createCachedWorkspaceDiscoveryState).mockReturnValue({ directories: ['src'], - files: cachedFiles, + files: [...files], gitIgnoredPaths: ['dist/generated.ts'], }); vi.mocked(createCachedGraphAnalysisWarmupInput).mockReturnValue({ - file: cachedFiles[0], + file: files[0], } as never); vi.mocked(warmCachedGraphAnalysisFile).mockResolvedValue(undefined); @@ -152,6 +169,7 @@ async function flushWarmupCatch(): Promise { describe('extension/pipeline/service/cachedGraph', () => { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(hasRequiredAnalysisCacheTiers).mockReturnValue(true); vi.mocked(isMissingFileError).mockReturnValue(false); vi.mocked(isWorkspaceAnalysisAbortError).mockReturnValue(false); setupCachedDiscovery(); @@ -263,6 +281,73 @@ describe('extension/pipeline/service/cachedGraph', () => { ); }); + it('returns an empty graph without mutating retained graph state when required cache tiers are missing', async () => { + const facade = new TestCachedGraphFacade(); + vi.mocked(hasRequiredAnalysisCacheTiers).mockReturnValueOnce(false); + + await expect( + facade.loadCachedGraph([], new Set(), undefined, { + requiredAnalysisCacheTiers: ['baseline', 'symbols'], + warmAnalysis: false, + }), + ).resolves.toEqual({ nodes: [], edges: [] }); + + expect(hasRequiredAnalysisCacheTiers).toHaveBeenCalledWith( + cachedAnalysis, + ['baseline', 'symbols'], + ); + expect(projectFileAnalysisConnections).not.toHaveBeenCalled(); + expect(facade.buildGraphDataFromAnalysis).not.toHaveBeenCalled(); + const retainedState = cachedGraphState(facade); + expect(retainedState._lastFileAnalysis).toEqual(new Map()); + expect(retainedState._lastFileConnections).toEqual(new Map()); + }); + + it('requires plugin cache tiers only on files owned by that plugin', async () => { + const facade = new TestCachedGraphFacade(); + setCachedGraphCache(facade, { + files: { + 'src/cached.ts': { analysis: cachedAnalysis, mtime: 1, size: 10 }, + 'README.md': { analysis: readmeAnalysis, mtime: 1, size: 10 }, + }, + }); + vi.mocked(facade._registry.list).mockReturnValue([{ + plugin: { + id: 'codegraphy.typescript', + supportedExtensions: ['.ts'], + }, + }] as never); + vi.mocked(hasRequiredAnalysisCacheTiers).mockImplementation((analysis, tiers) => + analysis === cachedAnalysis + || !tiers?.some(tier => tier === 'plugin:codegraphy.typescript'), + ); + setupCachedDiscovery(mixedCachedFiles); + + await expect( + facade.loadCachedGraph([], new Set(), undefined, { + requiredAnalysisCacheTiers: ['baseline', 'plugin:codegraphy.typescript'], + warmAnalysis: false, + }), + ).resolves.toEqual({ + nodes: [{ id: 'graph', label: 'Graph', color: '#333333' }], + edges: [], + }); + + expect(hasRequiredAnalysisCacheTiers).not.toHaveBeenCalledWith( + readmeAnalysis, + ['plugin:codegraphy.typescript'], + ); + expect(facade.buildGraphDataFromAnalysis).toHaveBeenCalledWith( + new Map([ + ['src/cached.ts', cachedAnalysis], + ['README.md', readmeAnalysis], + ]), + '/workspace', + false, + new Set(), + ); + }); + it('logs only unexpected cached analysis warmup failures', async () => { const facade = new TestCachedGraphFacade(); const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); From 57ef65aa4f65717435d34293613224d383bd8c9d Mon Sep 17 00:00:00 2001 From: joesobo Date: Thu, 25 Jun 2026 16:58:14 -0700 Subject: [PATCH 14/14] Improve graph cache tier hydration --- .changeset/snappy-graph-runtime-scheduler.md | 4 + docs/PLUGINS.md | 2 +- docs/SETTINGS.md | 4 +- .../src/analysis/fileAnalysis/cacheTiers.ts | 96 ++++++++- .../core/src/graphCache/database/io/load.ts | 52 +++-- .../core/src/graphCache/database/io/save.ts | 42 +++- .../core/src/graphCache/database/storage.ts | 8 +- .../analysis/fileAnalysis/cacheTiers.test.ts | 104 ++++++++- .../tests/graphCache/database/io/save.test.ts | 86 +++++++- .../tests/graphCache/database/storage.test.ts | 99 +++++++++ .../extension/pipeline/service/base/state.ts | 130 ++++++++++-- .../extension/pipeline/service/cachedGraph.ts | 26 ++- .../tests/extension/pipeline/adapters.test.ts | 1 - .../pipeline/service/base/state.test.ts | 197 +++++++++++++++++- .../pipeline/service/cachedGraph.test.ts | 40 +++- 15 files changed, 825 insertions(+), 66 deletions(-) diff --git a/.changeset/snappy-graph-runtime-scheduler.md b/.changeset/snappy-graph-runtime-scheduler.md index dee2815f2..1f9d4cfc0 100644 --- a/.changeset/snappy-graph-runtime-scheduler.md +++ b/.changeset/snappy-graph-runtime-scheduler.md @@ -16,3 +16,7 @@ Graph View now separates plugin and projection changes from Graph Cache work mor Plugin metadata now declares whether toggles and plugin-owned settings are visual-only, projection-only, plugin-file analysis, or full-index changes. Built-in plugins provide those declarations as examples for plugin authors. Deterministic scheduler tests verify that a 10-action node/edge visibility burst schedules 0 graph jobs, settings-only plugin data schedules 0 graph jobs, and a 10-action analyzer plugin setting burst coalesces to 1 plugin-file refresh. Cached Graph Scope and plugin-owned evidence tiers are also reused from runtime memory after the first successful hydration. Toggling a cached symbol tier or enabling a cached analyzer plugin reads Graph Cache once with 0 analysis jobs and 0 cache saves. Later off/on toggles reuse the hydrated tier with 0 additional cache reads until an explicit Re-index resets runtime hydration state. If Graph Cache does not contain the requested tier, CodeGraphy falls back to the targeted analysis lane instead of incorrectly treating file-only cache data as a complete graph. + +Graph Cache hydration now keeps baseline runtime memory smaller until symbol or plugin evidence is actually needed. In a current `main` versus PR benchmark on an evidence-rich 37 MB CodeGraphy monorepo Graph Cache, baseline runtime cache size moved from 18,583,676 serialized bytes to 10,781,465 serialized bytes: 7,802,211 bytes less, a 41.98% reduction, and 1.72x smaller. Retained symbol facts moved from 11,631 to 0 until Symbol scope is enabled. Median baseline replay in that same run moved from 343.33ms to 316.56ms: 26.77ms faster, a 7.80% reduction, though the current JSON row format means the main win is retained runtime memory rather than avoiding all row parsing. + +Saved-file Graph Cache persistence now patches changed rows atomically instead of rewriting the entire Graph Cache. In the current `main` versus PR benchmark on the 18 MB CodeGraphy monorepo Graph Cache, edit persistence moved from a 25,705ms average full save to a 341ms average one-row patch: 25,364ms faster, a 98.67% reduction, and 75.47x faster. Full Re-index still replaces the complete Graph Cache, while normal file edits delete and upsert only the changed cache rows inside one transaction. diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index ee64b80eb..4967d1722 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -109,7 +109,7 @@ The `Run Extension` launch config runs `pnpm run build:devhost` before opening t The Plugins panel is a workspace Plugin ID toggle surface backed by static package metadata. It shows installed package-backed plugins that can be enabled, disabled, and reordered for the current CodeGraphy Workspace. Core runtime internals such as Tree-sitter, and legacy VS Code extension plugin entries without a package backing, are not shown as plugin toggle rows. -Disabling a plugin writes `enabled: false` for that Plugin ID in the workspace `plugins` array and unloads its runtime immediately. Package-owned persisted data may remain on disk, but its Graph View nodes, forces, context menu entries, toolbar create entries, webview injections, and UI slots only render while that Plugin ID is enabled and loaded. The Graph View host broadcasts the refreshed plugin status and contribution state immediately after a toggle. Disabling a plugin rebuilds the Graph View from cached analysis instead of rerunning full Indexing; enabling a plugin refreshes only the plugin-owned analysis tier for supported files, then keeps that tier in Graph Cache so future toggles can reuse it. +Disabling a plugin writes `enabled: false` for that Plugin ID in the workspace `plugins` array and unloads its runtime immediately. Package-owned persisted data may remain on disk, but its Graph View nodes, forces, context menu entries, toolbar create entries, webview injections, and UI slots only render while that Plugin ID is enabled and loaded. The Graph View host broadcasts the refreshed plugin status and contribution state immediately after a toggle. Disabling a plugin rebuilds the Graph View from cached analysis instead of rerunning full Indexing. Enabling an analyzer plugin first tries to hydrate that plugin-owned evidence tier from Graph Cache into runtime memory; if the tier is missing, CodeGraphy refreshes only the plugin-owned analysis tier for supported files. Once loaded, that tier remains resident for future toggles, and normal file edits patch changed Graph Cache rows instead of resaving the full cache. When Indexing loads an enabled package, `@codegraphy-dev/core` merges `codegraphy.defaultOptions` from the package manifest with the workspace entry's `options` object. Workspace options win. The merged object is passed to package plugin factories as `factoryOptions.options`, and to `initialize`, `onPreAnalyze`, `onFilesChanged`, and `analyzeFile` as `context.options`, so the same plugin package can run with different settings in different CodeGraphy Workspaces. diff --git a/docs/SETTINGS.md b/docs/SETTINGS.md index e99ee0b86..92bb1e6cf 100644 --- a/docs/SETTINGS.md +++ b/docs/SETTINGS.md @@ -424,7 +424,9 @@ Disabling a plugin makes that plugin inactive for the workspace graph surface. I When several relevant Edge Types are available, built-in Edge Types keep their common-usefulness order: **Imports**, **References**, **Calls**, **Type imports**, **Inherits**, **Loads**, **Nests**, **Contains**, then **Overrides**. Plugin-contributed Edge Types appear after built-ins unless a later product decision defines plugin grouping. -Graph Cache enrichment follows Graph Scope. CodeGraphy caches baseline file nodes and file-level edges first; enabling Symbols or a plugin computes the missing tier and keeps it cached when that scope is turned off again. +Graph Cache enrichment follows Graph Scope. CodeGraphy caches baseline file nodes and file-level edges first. Graph View loads that baseline into runtime memory first, then hydrates Symbol or plugin-owned evidence only when a scope toggle needs it. Once a tier has been loaded, CodeGraphy keeps it in runtime memory for faster future toggles even if the scope is turned off again. + +Normal file edits patch the changed Graph Cache rows atomically. Re-index is the force-refresh path that rebuilds and replaces the complete Graph Cache with the current settings. ## File discovery settings diff --git a/packages/core/src/analysis/fileAnalysis/cacheTiers.ts b/packages/core/src/analysis/fileAnalysis/cacheTiers.ts index 88a00d1a9..056011ec0 100644 --- a/packages/core/src/analysis/fileAnalysis/cacheTiers.ts +++ b/packages/core/src/analysis/fileAnalysis/cacheTiers.ts @@ -1,4 +1,5 @@ import type { + IAnalysisNode, IAnalysisRelation, IFileAnalysisResult, } from '@codegraphy-dev/plugin-api'; @@ -48,6 +49,83 @@ function hasPluginFacts(analysis: IFileAnalysisResult, pluginId: string): boolea ); } +function readStringMetadataValue(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +function readAnalysisNodePluginId(node: IAnalysisNode): string | undefined { + return readStringMetadataValue(node.metadata?.pluginId) + ?? readStringMetadataValue(node.metadata?.source); +} + +function readActivePluginIds(activeTiers: readonly AnalysisCacheTier[]): Set { + return new Set( + activeTiers + .filter((tier): tier is `plugin:${string}` => tier.startsWith('plugin:')) + .map(tier => tier.slice('plugin:'.length)), + ); +} + +function hasInactivePluginId( + pluginId: string | undefined, + activePluginIds: ReadonlySet, +): boolean { + return pluginId !== undefined && !activePluginIds.has(pluginId); +} + +function filterInactivePluginFacts( + analysis: IFileAnalysisResult, + activeTiers: readonly AnalysisCacheTier[], +): IFileAnalysisResult { + const activePluginIds = readActivePluginIds(activeTiers); + const projectedAnalysis: IFileAnalysisResult = { ...analysis }; + if (analysis.nodes) { + projectedAnalysis.nodes = analysis.nodes.filter(node => + !hasInactivePluginId(readAnalysisNodePluginId(node), activePluginIds), + ); + } + if (analysis.symbols) { + projectedAnalysis.symbols = analysis.symbols.filter(symbol => + !hasInactivePluginId(readAnalysisSymbolPluginId(symbol), activePluginIds), + ); + } + if (analysis.relations) { + projectedAnalysis.relations = analysis.relations.filter(relation => + !hasInactivePluginId(relation.pluginId, activePluginIds), + ); + } + return projectedAnalysis; +} + +function projectCacheTierMetadata( + analysis: IFileAnalysisResult, + activeTiers: readonly AnalysisCacheTier[], +): IFileAnalysisResult { + const explicitTiers = readExplicitCacheTiers(analysis); + if (!explicitTiers) { + return analysis; + } + const activeTierSet = new Set(activeTiers); + const retainedTiers = explicitTiers.filter((tier): tier is AnalysisCacheTier => + isKnownAnalysisCacheTier(tier) && activeTierSet.has(tier), + ); + + const tieredAnalysis: TieredFileAnalysisResult = { + ...(analysis as TieredFileAnalysisResult), + cache: { + ...(analysis as TieredFileAnalysisResult).cache, + tiers: sortCacheTiers(retainedTiers), + }, + }; + return tieredAnalysis; +} + +function isKnownAnalysisCacheTier(tier: string): tier is AnalysisCacheTier { + return tier === BASELINE_ANALYSIS_CACHE_TIER + || tier === SYMBOLS_ANALYSIS_CACHE_TIER + || tier.startsWith('plugin:'); +} + function isCacheTierSatisfied(analysis: IFileAnalysisResult, tier: AnalysisCacheTier): boolean { if (tier === BASELINE_ANALYSIS_CACHE_TIER) { return true; @@ -110,17 +188,29 @@ export function projectAnalysisForCacheTiers( analysis: IFileAnalysisResult, activeTiers: readonly AnalysisCacheTier[] | undefined, ): IFileAnalysisResult { - if (isSymbolTierActive(activeTiers)) { + if (activeTiers === undefined) { return analysis; } - return { + const pluginProjectedAnalysis = filterInactivePluginFacts(analysis, activeTiers); + const projectedAnalysis = isSymbolTierActive(activeTiers) + ? pluginProjectedAnalysis + : stripInactiveSymbolFacts(pluginProjectedAnalysis); + + return projectCacheTierMetadata(projectedAnalysis, activeTiers); +} + +function stripInactiveSymbolFacts(analysis: IFileAnalysisResult): IFileAnalysisResult { + const projectedAnalysis: IFileAnalysisResult = { ...analysis, relations: (analysis.relations ?? []) .map(stripSymbolRelationEndpoints) .filter((relation): relation is IAnalysisRelation => relation !== undefined), - symbols: [], }; + if (analysis.symbols) { + projectedAnalysis.symbols = []; + } + return projectedAnalysis; } function sortCacheTiers(tiers: Iterable): AnalysisCacheTier[] { diff --git a/packages/core/src/graphCache/database/io/load.ts b/packages/core/src/graphCache/database/io/load.ts index 20aaf1603..e4e4286ba 100644 --- a/packages/core/src/graphCache/database/io/load.ts +++ b/packages/core/src/graphCache/database/io/load.ts @@ -4,13 +4,42 @@ import { WORKSPACE_ANALYSIS_CACHE_VERSION, type IWorkspaceAnalysisCache, } from '../../../analysis/cache'; +import { + projectAnalysisForCacheTiers, + type AnalysisCacheTier, +} from '../../../analysis/fileAnalysis/cacheTiers'; import { readRowsAsync, readRowsSync, withConnection, withConnectionAsync } from './connection'; import { clearDatabaseArtifacts, getWorkspaceAnalysisDatabasePath } from './paths'; import { createSnapshotFileEntry } from '../records/file'; import { FILE_ANALYSIS_ROWS_QUERY } from '../query/read'; +export interface WorkspaceAnalysisDatabaseLoadOptions { + activeAnalysisCacheTiers?: readonly AnalysisCacheTier[]; +} + +function addSnapshotEntryToCache( + cache: IWorkspaceAnalysisCache, + row: Parameters[0], + options: WorkspaceAnalysisDatabaseLoadOptions, +): void { + const entry = createSnapshotFileEntry(row); + if (!entry) { + return; + } + + cache.files[entry.filePath] = { + mtime: entry.mtime, + size: entry.size, + analysis: projectAnalysisForCacheTiers( + entry.analysis, + options.activeAnalysisCacheTiers, + ), + }; +} + export function loadWorkspaceAnalysisDatabaseCache( workspaceRoot: string, + options: WorkspaceAnalysisDatabaseLoadOptions = {}, ): IWorkspaceAnalysisCache { const databasePath = getWorkspaceAnalysisDatabasePath(workspaceRoot); if (!fs.existsSync(databasePath)) { @@ -24,16 +53,7 @@ export function loadWorkspaceAnalysisDatabaseCache( for (const row of rows) { try { - const entry = createSnapshotFileEntry(row); - if (!entry) { - continue; - } - - cache.files[entry.filePath] = { - mtime: entry.mtime, - size: entry.size, - analysis: entry.analysis, - }; + addSnapshotEntryToCache(cache, row, options); } catch (error) { console.warn('[CodeGraphy] Skipping unreadable persisted analysis row.', error); } @@ -51,6 +71,7 @@ export function loadWorkspaceAnalysisDatabaseCache( export async function loadWorkspaceAnalysisDatabaseCacheAsync( workspaceRoot: string, + options: WorkspaceAnalysisDatabaseLoadOptions = {}, ): Promise { const databasePath = getWorkspaceAnalysisDatabasePath(workspaceRoot); if (!fs.existsSync(databasePath)) { @@ -64,16 +85,7 @@ export async function loadWorkspaceAnalysisDatabaseCacheAsync( for (const row of rows) { try { - const entry = createSnapshotFileEntry(row); - if (!entry) { - continue; - } - - cache.files[entry.filePath] = { - mtime: entry.mtime, - size: entry.size, - analysis: entry.analysis, - }; + addSnapshotEntryToCache(cache, row, options); } catch (error) { console.warn('[CodeGraphy] Skipping unreadable persisted analysis row.', error); } diff --git a/packages/core/src/graphCache/database/io/save.ts b/packages/core/src/graphCache/database/io/save.ts index 6cac6f353..a96ca24b4 100644 --- a/packages/core/src/graphCache/database/io/save.ts +++ b/packages/core/src/graphCache/database/io/save.ts @@ -33,6 +33,26 @@ export interface WorkspaceAnalysisDatabasePatch { upsertFiles?: IWorkspaceAnalysisCache['files']; } +function runTransactionSync(connection: Parameters[0], patch: () => void): void { + runStatementSync(connection, 'BEGIN TRANSACTION'); + let committed = false; + + try { + patch(); + runStatementSync(connection, 'COMMIT'); + committed = true; + } catch (error) { + if (!committed) { + try { + runStatementSync(connection, 'ROLLBACK'); + } catch { + // Keep the original patch failure as the actionable error. + } + } + throw error; + } +} + export function saveWorkspaceAnalysisDatabaseCache( workspaceRoot: string, cache: IWorkspaceAnalysisCache, @@ -78,16 +98,18 @@ export function patchWorkspaceAnalysisDatabaseCache( ]); withConnection(databasePath, (connection) => { - const writer = createWorkspaceAnalysisCachePatchWriter(connection); - for (const filePath of [...deleteFilePaths].sort()) { - deleteAnalysisEntry(writer, filePath); - } - for (const [filePath, entry] of sortedCacheEntries({ - version: '', - files: patch.upsertFiles ?? {}, - })) { - persistAnalysisEntry(writer, filePath, entry); - } + runTransactionSync(connection, () => { + const writer = createWorkspaceAnalysisCachePatchWriter(connection); + for (const filePath of [...deleteFilePaths].sort()) { + deleteAnalysisEntry(writer, filePath); + } + for (const [filePath, entry] of sortedCacheEntries({ + version: '', + files: patch.upsertFiles ?? {}, + })) { + persistAnalysisEntry(writer, filePath, entry); + } + }); }); } diff --git a/packages/core/src/graphCache/database/storage.ts b/packages/core/src/graphCache/database/storage.ts index 0b56282c1..da12786f1 100644 --- a/packages/core/src/graphCache/database/storage.ts +++ b/packages/core/src/graphCache/database/storage.ts @@ -1,6 +1,7 @@ import { loadWorkspaceAnalysisDatabaseCache as loadWorkspaceAnalysisDatabaseCacheImpl, loadWorkspaceAnalysisDatabaseCacheAsync as loadWorkspaceAnalysisDatabaseCacheAsyncImpl, + type WorkspaceAnalysisDatabaseLoadOptions, } from './io/load'; import { getWorkspaceAnalysisDatabasePath as getWorkspaceAnalysisDatabasePathImpl } from './io/paths'; import { @@ -17,6 +18,7 @@ import { } from './io/save'; export type WorkspaceAnalysisDatabaseSnapshot = WorkspaceAnalysisDatabaseSnapshotImpl; +export type { WorkspaceAnalysisDatabaseLoadOptions }; export function getWorkspaceAnalysisDatabasePath( workspaceRoot: string, @@ -26,14 +28,16 @@ export function getWorkspaceAnalysisDatabasePath( export function loadWorkspaceAnalysisDatabaseCache( workspaceRoot: string, + options?: WorkspaceAnalysisDatabaseLoadOptions, ) { - return loadWorkspaceAnalysisDatabaseCacheImpl(workspaceRoot); + return loadWorkspaceAnalysisDatabaseCacheImpl(workspaceRoot, options); } export function loadWorkspaceAnalysisDatabaseCacheAsync( workspaceRoot: string, + options?: WorkspaceAnalysisDatabaseLoadOptions, ) { - return loadWorkspaceAnalysisDatabaseCacheAsyncImpl(workspaceRoot); + return loadWorkspaceAnalysisDatabaseCacheAsyncImpl(workspaceRoot, options); } export function readWorkspaceAnalysisDatabaseSnapshot( diff --git a/packages/core/tests/analysis/fileAnalysis/cacheTiers.test.ts b/packages/core/tests/analysis/fileAnalysis/cacheTiers.test.ts index 4d71e47de..851bfcdb6 100644 --- a/packages/core/tests/analysis/fileAnalysis/cacheTiers.test.ts +++ b/packages/core/tests/analysis/fileAnalysis/cacheTiers.test.ts @@ -91,7 +91,7 @@ describe('analysis/fileAnalysis/cacheTiers', () => { toFilePath: '/workspace/Assets/Prefabs/Player.prefab', toSymbolId: 'Assets/Prefabs/Player.prefab#unity:game-object:1000', }], - }, [BASELINE_ANALYSIS_CACHE_TIER])).toEqual({ + } as never, [BASELINE_ANALYSIS_CACHE_TIER])).toEqual({ filePath: '/workspace/Assets/Prefabs/Player.prefab', symbols: [], relations: [], @@ -113,10 +113,110 @@ describe('analysis/fileAnalysis/cacheTiers', () => { fromFilePath: '/workspace/Assets/Prefabs/Enemy1.prefab', toSymbolId: 'Assets/Prefabs/Enemy1.prefab#unity:game-object:1000', }], - }, [BASELINE_ANALYSIS_CACHE_TIER])).toEqual({ + } as never, [BASELINE_ANALYSIS_CACHE_TIER])).toEqual({ filePath: '/workspace/Assets/Prefabs/Enemy1.prefab', symbols: [], relations: [], }); }); + + it('keeps inactive plugin-owned evidence out of projected runtime cache tiers', () => { + expect(projectAnalysisForCacheTiers({ + filePath: '/workspace/src/App.vue', + cache: { + tiers: [ + BASELINE_ANALYSIS_CACHE_TIER, + SYMBOLS_ANALYSIS_CACHE_TIER, + 'plugin:codegraphy.vue', + ], + }, + nodes: [ + { + id: 'src/App.vue', + label: 'App.vue', + nodeType: 'file', + }, + { + id: 'src/App.vue#component', + label: 'App', + metadata: { pluginId: 'codegraphy.vue' }, + nodeType: 'plugin:codegraphy.vue:component', + }, + ], + symbols: [{ + id: 'src/App.vue#component', + filePath: '/workspace/src/App.vue', + kind: 'component', + metadata: { pluginId: 'codegraphy.vue' }, + name: 'App', + }], + relations: [ + { + kind: 'import', + sourceId: 'core:treesitter:import', + fromFilePath: '/workspace/src/App.vue', + toFilePath: '/workspace/src/main.ts', + }, + { + kind: 'contains', + pluginId: 'codegraphy.vue', + sourceId: 'codegraphy.vue:component', + fromFilePath: '/workspace/src/App.vue', + toNodeId: 'src/App.vue#component', + }, + ], + } as never, [BASELINE_ANALYSIS_CACHE_TIER])).toEqual({ + filePath: '/workspace/src/App.vue', + cache: { + tiers: [BASELINE_ANALYSIS_CACHE_TIER], + }, + nodes: [ + { + id: 'src/App.vue', + label: 'App.vue', + nodeType: 'file', + }, + ], + symbols: [], + relations: [{ + kind: 'import', + sourceId: 'core:treesitter:import', + fromFilePath: '/workspace/src/App.vue', + toFilePath: '/workspace/src/main.ts', + }], + }); + }); + + it('does not mark a requested plugin tier as loaded when persisted cache metadata is missing it', () => { + expect(projectAnalysisForCacheTiers({ + filePath: '/workspace/src/App.vue', + cache: { + tiers: [BASELINE_ANALYSIS_CACHE_TIER], + }, + nodes: [ + { + id: 'src/App.vue', + label: 'App.vue', + nodeType: 'file', + }, + ], + relations: [], + } as never, [ + BASELINE_ANALYSIS_CACHE_TIER, + 'plugin:codegraphy.vue', + ])).toEqual({ + filePath: '/workspace/src/App.vue', + cache: { + tiers: [BASELINE_ANALYSIS_CACHE_TIER], + }, + nodes: [ + { + id: 'src/App.vue', + label: 'App.vue', + nodeType: 'file', + }, + ], + relations: [], + }); + }); }); diff --git a/packages/core/tests/graphCache/database/io/save.test.ts b/packages/core/tests/graphCache/database/io/save.test.ts index ccaa71a3b..0bbb668d3 100644 --- a/packages/core/tests/graphCache/database/io/save.test.ts +++ b/packages/core/tests/graphCache/database/io/save.test.ts @@ -3,6 +3,7 @@ import * as fs from 'node:fs'; import { setImmediate as waitForImmediate } from 'node:timers/promises'; import { clearWorkspaceAnalysisDatabaseCache, + patchWorkspaceAnalysisDatabaseCache, saveWorkspaceAnalysisDatabaseCache, } from '../../../../src/graphCache/database/io/save'; import { saveWorkspaceAnalysisDatabaseCacheAsync } from '../../../../src/graphCache/database/io/saveAsync'; @@ -47,8 +48,10 @@ vi.mock('../../../../src/graphCache/database/io/temporary', () => ({ })); vi.mock('../../../../src/graphCache/database/query/write', () => ({ + createWorkspaceAnalysisCachePatchWriter: vi.fn(), createWorkspaceAnalysisCacheWriter: vi.fn(), createWorkspaceAnalysisCacheWriterAsync: vi.fn(), + deleteAnalysisEntry: vi.fn(), persistAnalysisEntry: vi.fn(), persistAnalysisEntryAsync: vi.fn(), sortedCacheEntries: vi.fn(), @@ -70,12 +73,20 @@ describe('graphCache/database/io/save', () => { .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.sortedCacheEntries).mockImplementation(cacheInput => + Object.entries(cacheInput.files) + .sort(([left], [right]) => left.localeCompare(right)) as never, + ); vi.mocked(writeModule.createWorkspaceAnalysisCacheWriter) .mockReturnValue({ connection: 'connection', fileAnalysisStatement: 'statement' } as never); + vi.mocked(writeModule.createWorkspaceAnalysisCachePatchWriter) + .mockReturnValue({ + connection: 'connection', + deleteFileAnalysisStatement: 'delete-file-statement', + deleteRelationStatement: 'delete-relation-statement', + deleteSymbolStatement: 'delete-symbol-statement', + fileAnalysisStatement: 'statement', + } as never); vi.mocked(writeModule.createWorkspaceAnalysisCacheWriterAsync) .mockResolvedValue({ connection: 'connection', fileAnalysisStatement: 'statement' } as never); vi.mocked(connectionModule.withConnection).mockImplementation((_databasePath, callback) => @@ -178,6 +189,73 @@ describe('graphCache/database/io/save', () => { ); }); + it('patches changed rows inside a transaction and commits the complete patch', () => { + patchWorkspaceAnalysisDatabaseCache('/workspace', { + deleteFilePaths: ['src/deleted.ts'], + upsertFiles: { + 'src/changed.ts': { + mtime: 4, + size: 40, + analysis: { filePath: '/workspace/src/changed.ts' }, + }, + }, + }); + + expect(connectionModule.runStatementSync).toHaveBeenNthCalledWith( + 1, + 'connection', + 'BEGIN TRANSACTION', + ); + expect(writeModule.deleteAnalysisEntry).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ connection: 'connection' }), + 'src/changed.ts', + ); + expect(writeModule.deleteAnalysisEntry).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ connection: 'connection' }), + 'src/deleted.ts', + ); + expect(writeModule.persistAnalysisEntry).toHaveBeenCalledWith( + expect.objectContaining({ connection: 'connection' }), + 'src/changed.ts', + { + mtime: 4, + size: 40, + analysis: { filePath: '/workspace/src/changed.ts' }, + }, + ); + expect(connectionModule.runStatementSync).toHaveBeenLastCalledWith( + 'connection', + 'COMMIT', + ); + }); + + it('rolls back the patch transaction when any patch statement fails', () => { + vi.mocked(writeModule.persistAnalysisEntry).mockImplementationOnce(() => { + throw new Error('patch failed'); + }); + + expect(() => patchWorkspaceAnalysisDatabaseCache('/workspace', { + upsertFiles: { + 'src/changed.ts': { + mtime: 4, + size: 40, + analysis: { filePath: '/workspace/src/changed.ts' }, + }, + }, + })).toThrow('patch failed'); + + expect(connectionModule.runStatementSync).toHaveBeenCalledWith( + 'connection', + 'ROLLBACK', + ); + expect(connectionModule.runStatementSync).not.toHaveBeenCalledWith( + 'connection', + 'COMMIT', + ); + }); + it('does not clear a missing database', () => { vi.mocked(fs.existsSync).mockReturnValueOnce(false); diff --git a/packages/core/tests/graphCache/database/storage.test.ts b/packages/core/tests/graphCache/database/storage.test.ts index bce61a7a2..7ee97e1d7 100644 --- a/packages/core/tests/graphCache/database/storage.test.ts +++ b/packages/core/tests/graphCache/database/storage.test.ts @@ -7,6 +7,10 @@ import { createEmptyWorkspaceAnalysisCache, WORKSPACE_ANALYSIS_CACHE_VERSION, } from '../../../src/analysis/cache'; +import { + BASELINE_ANALYSIS_CACHE_TIER, + SYMBOLS_ANALYSIS_CACHE_TIER, +} from '../../../src/analysis/fileAnalysis/cacheTiers'; import { clearWorkspaceAnalysisDatabaseCache, getWorkspaceAnalysisDatabasePath, @@ -156,6 +160,101 @@ describe('workspace analysis database cache', { timeout: 30000 }, () => { ]); }); + it('loads only requested analysis cache tiers into runtime memory', () => { + const workspaceRoot = createWorkspaceRoot(); + const fullCache: IWorkspaceAnalysisCache = { + version: WORKSPACE_ANALYSIS_CACHE_VERSION, + files: { + 'src/App.vue': { + mtime: 1, + size: 10, + analysis: { + filePath: '/workspace/src/App.vue', + cache: { + tiers: [ + BASELINE_ANALYSIS_CACHE_TIER, + SYMBOLS_ANALYSIS_CACHE_TIER, + 'plugin:codegraphy.vue', + ], + }, + nodes: [ + { + id: 'src/App.vue', + label: 'App.vue', + nodeType: 'file', + }, + { + id: 'src/App.vue#component', + label: 'App', + metadata: { pluginId: 'codegraphy.vue' }, + nodeType: 'plugin:codegraphy.vue:component', + }, + ], + symbols: [{ + id: 'src/App.vue#component', + filePath: '/workspace/src/App.vue', + kind: 'component', + metadata: { pluginId: 'codegraphy.vue' }, + name: 'App', + }], + relations: [ + { + kind: 'import', + sourceId: 'core:treesitter:import', + fromFilePath: '/workspace/src/App.vue', + toFilePath: '/workspace/src/main.ts', + }, + { + kind: 'contains', + pluginId: 'codegraphy.vue', + sourceId: 'codegraphy.vue:component', + fromFilePath: '/workspace/src/App.vue', + toNodeId: 'src/App.vue#component', + }, + ], + }, + }, + }, + } as never; + saveWorkspaceAnalysisDatabaseCache(workspaceRoot, fullCache); + + expect(loadWorkspaceAnalysisDatabaseCache(workspaceRoot, { + activeAnalysisCacheTiers: [BASELINE_ANALYSIS_CACHE_TIER], + })).toEqual({ + version: WORKSPACE_ANALYSIS_CACHE_VERSION, + files: { + 'src/App.vue': { + mtime: 1, + size: 10, + analysis: { + filePath: '/workspace/src/App.vue', + cache: { tiers: [BASELINE_ANALYSIS_CACHE_TIER] }, + nodes: [{ + id: 'src/App.vue', + label: 'App.vue', + nodeType: 'file', + }], + symbols: [], + relations: [{ + kind: 'import', + sourceId: 'core:treesitter:import', + fromFilePath: '/workspace/src/App.vue', + toFilePath: '/workspace/src/main.ts', + }], + }, + }, + }, + }); + + expect(loadWorkspaceAnalysisDatabaseCache(workspaceRoot, { + activeAnalysisCacheTiers: [ + BASELINE_ANALYSIS_CACHE_TIER, + SYMBOLS_ANALYSIS_CACHE_TIER, + 'plugin:codegraphy.vue', + ], + })).toEqual(fullCache); + }); + it('skips persistence when the workspace root no longer exists', () => { const workspaceRoot = path.join(os.tmpdir(), `codegraphy-missing-${Date.now()}`); diff --git a/packages/extension/src/extension/pipeline/service/base/state.ts b/packages/extension/src/extension/pipeline/service/base/state.ts index 17dee518a..b185717cb 100644 --- a/packages/extension/src/extension/pipeline/service/base/state.ts +++ b/packages/extension/src/extension/pipeline/service/base/state.ts @@ -5,8 +5,13 @@ import type { } from '../../../../core/plugins/types/contracts'; import { PluginRegistry } from '../../../../core/plugins/registry/manager'; import { + BASELINE_ANALYSIS_CACHE_TIER, createWorkspaceIndexEngineState, FileDiscovery, + hasRequiredAnalysisCacheTiers, + readAnalysisCacheTiers, + SYMBOLS_ANALYSIS_CACHE_TIER, + type AnalysisCacheTier, type IDiscoveredFile, type WorkspaceIndexEngineState, } from '@codegraphy-dev/core'; @@ -21,6 +26,68 @@ import { } from '../../database/cache/storage'; import { createWorkspacePipelineInitialCache } from '../cache/initialState'; +export interface WorkspacePipelineGraphCacheHydrationOptions { + activeAnalysisCacheTiers?: readonly AnalysisCacheTier[]; +} + +function getDefaultGraphCacheHydrationTiers(): readonly AnalysisCacheTier[] { + return [BASELINE_ANALYSIS_CACHE_TIER]; +} + +function hasCacheFiles(cache: IWorkspaceAnalysisCache): boolean { + return Object.keys(cache.files).length > 0; +} + +function hasHydratedAnalysisCacheTiers( + cache: IWorkspaceAnalysisCache, + tiers: readonly AnalysisCacheTier[], +): boolean { + return hasCacheFiles(cache) + && Object.values(cache.files).every(entry => + hasRequiredAnalysisCacheTiers(entry.analysis, tiers), + ); +} + +function isAnalysisCacheTier(tier: string): tier is AnalysisCacheTier { + return tier === BASELINE_ANALYSIS_CACHE_TIER + || tier === SYMBOLS_ANALYSIS_CACHE_TIER + || tier.startsWith('plugin:'); +} + +function compareAnalysisCacheTiers(left: AnalysisCacheTier, right: AnalysisCacheTier): number { + if (left === BASELINE_ANALYSIS_CACHE_TIER) { + return -1; + } + if (right === BASELINE_ANALYSIS_CACHE_TIER) { + return 1; + } + if (left === SYMBOLS_ANALYSIS_CACHE_TIER) { + return -1; + } + if (right === SYMBOLS_ANALYSIS_CACHE_TIER) { + return 1; + } + return left.localeCompare(right); +} + +function createRuntimeHydrationCacheTiers( + cache: IWorkspaceAnalysisCache, + requestedTiers: readonly AnalysisCacheTier[], +): readonly AnalysisCacheTier[] { + const tiers = new Set([ + BASELINE_ANALYSIS_CACHE_TIER, + ...requestedTiers, + ]); + for (const entry of Object.values(cache.files)) { + for (const tier of readAnalysisCacheTiers(entry.analysis)) { + if (isAnalysisCacheTier(tier)) { + tiers.add(tier); + } + } + } + return [...tiers].sort(compareAnalysisCacheTiers); +} + export abstract class WorkspacePipelineStateBase { protected readonly _config: Configuration; protected readonly _registry: PluginRegistry; @@ -117,7 +184,9 @@ export abstract class WorkspacePipelineStateBase { } async warmGraphCache(): Promise { - await this._hydrateCacheFromGraphCache(); + await this._hydrateCacheFromGraphCache({ + activeAnalysisCacheTiers: getDefaultGraphCacheHydrationTiers(), + }); } readStructuredAnalysisSnapshot(): WorkspaceAnalysisDatabaseSnapshot { @@ -129,24 +198,59 @@ export abstract class WorkspacePipelineStateBase { return readWorkspaceAnalysisDatabaseSnapshot(workspaceRoot); } - protected async _hydrateCacheFromGraphCache(): Promise { + protected async _hydrateCacheFromGraphCache( + options: WorkspacePipelineGraphCacheHydrationOptions = {}, + ): Promise { const workspaceRoot = this._getWorkspaceRoot(); - if (!workspaceRoot || Object.keys(this._cache.files).length > 0) { + if (!workspaceRoot) { return; } - this._cacheHydrationPromise ??= Promise.resolve() - .then(() => loadWorkspaceAnalysisDatabaseCache(workspaceRoot)) - .then((cache) => { - if (Object.keys(this._cache.files).length === 0) { - this._cache = cache; + const requestedAnalysisCacheTiers = options.activeAnalysisCacheTiers + ?? getDefaultGraphCacheHydrationTiers(); + if (hasHydratedAnalysisCacheTiers(this._cache, requestedAnalysisCacheTiers)) { + return; + } + + let attemptedRequestedHydration = false; + while (!hasHydratedAnalysisCacheTiers(this._cache, requestedAnalysisCacheTiers)) { + const existingHydration = this._cacheHydrationPromise; + if (existingHydration) { + await existingHydration; + if (hasHydratedAnalysisCacheTiers(this._cache, requestedAnalysisCacheTiers)) { + return; } - }) - .finally(() => { - this._cacheHydrationPromise = undefined; - }); + } + + if (attemptedRequestedHydration) { + return; + } + + const cacheWasEmptyAtStart = !hasCacheFiles(this._cache); + this._cacheHydrationPromise = Promise.resolve() + .then(() => loadWorkspaceAnalysisDatabaseCache(workspaceRoot, { + activeAnalysisCacheTiers: createRuntimeHydrationCacheTiers( + this._cache, + requestedAnalysisCacheTiers, + ), + })) + .then((cache) => { + if (cacheWasEmptyAtStart && hasCacheFiles(this._cache)) { + return; + } + if (!hasCacheFiles(cache) && hasCacheFiles(this._cache)) { + return; + } - await this._cacheHydrationPromise; + this._cache = cache; + }) + .finally(() => { + this._cacheHydrationPromise = undefined; + }); + + attemptedRequestedHydration = true; + await this._cacheHydrationPromise; + } } protected abstract _getWorkspaceRoot(): string | undefined; diff --git a/packages/extension/src/extension/pipeline/service/cachedGraph.ts b/packages/extension/src/extension/pipeline/service/cachedGraph.ts index 04c063e5e..1c8d57184 100644 --- a/packages/extension/src/extension/pipeline/service/cachedGraph.ts +++ b/packages/extension/src/extension/pipeline/service/cachedGraph.ts @@ -1,5 +1,6 @@ import { BASELINE_ANALYSIS_CACHE_TIER, + createWorkspaceIndexAnalysisCacheTiers, getWorkspaceIndexPluginMatchingFiles, hasRequiredAnalysisCacheTiers, SYMBOLS_ANALYSIS_CACHE_TIER, @@ -36,10 +37,16 @@ export abstract class WorkspacePipelineCachedGraphFacade extends WorkspacePipeli options: WorkspacePipelineCachedGraphLoadOptions = {}, ): Promise { throwIfWorkspaceAnalysisAborted(signal); - await this._hydrateCacheFromGraphCache(); + const workspaceRoot = this._getWorkspaceRoot(); + await this._hydrateCacheFromGraphCache({ + activeAnalysisCacheTiers: getCachedGraphRuntimeCacheTiers( + this._config.get>('nodeVisibility', {}) ?? {}, + this._getActiveAnalysisPluginIds(undefined, disabledPlugins), + options.requiredAnalysisCacheTiers, + ), + }); throwIfWorkspaceAnalysisAborted(signal); - const workspaceRoot = this._getWorkspaceRoot(); if (!workspaceRoot) { return { nodes: [], edges: [] }; } @@ -129,6 +136,21 @@ export abstract class WorkspacePipelineCachedGraphFacade extends WorkspacePipeli } } +function getCachedGraphRuntimeCacheTiers( + nodeVisibility: Record, + activePluginIds: readonly string[], + requiredAnalysisCacheTiers: readonly AnalysisCacheTier[] | undefined, +): readonly AnalysisCacheTier[] { + if (requiredAnalysisCacheTiers && requiredAnalysisCacheTiers.length > 0) { + return requiredAnalysisCacheTiers; + } + + return createWorkspaceIndexAnalysisCacheTiers( + nodeVisibility, + activePluginIds, + ).active ?? [BASELINE_ANALYSIS_CACHE_TIER]; +} + function canReplayCachedGraphAnalysis( cachedFiles: IWorkspaceAnalysisCache['files'], discoveredFiles: readonly IDiscoveredFile[], diff --git a/packages/extension/tests/extension/pipeline/adapters.test.ts b/packages/extension/tests/extension/pipeline/adapters.test.ts index 0d224fb7e..7efae02ce 100644 --- a/packages/extension/tests/extension/pipeline/adapters.test.ts +++ b/packages/extension/tests/extension/pipeline/adapters.test.ts @@ -208,7 +208,6 @@ describe('WorkspacePipeline adapters', () => { cache: { tiers: [BASELINE_ANALYSIS_CACHE_TIER], }, - symbols: [], }], ]), fileConnections: new Map([['src/index.ts', []]]), 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 c9749d8c2..017b0de45 100644 --- a/packages/extension/tests/extension/pipeline/service/base/state.test.ts +++ b/packages/extension/tests/extension/pipeline/service/base/state.test.ts @@ -1,7 +1,12 @@ import { describe, expect, it, vi } from 'vitest'; import * as vscode from 'vscode'; import { EventBus } from '../../../../../src/core/plugins/events/bus'; -import { FileDiscovery } from '@codegraphy-dev/core'; +import { + BASELINE_ANALYSIS_CACHE_TIER, + FileDiscovery, + SYMBOLS_ANALYSIS_CACHE_TIER, + type AnalysisCacheTier, +} from '@codegraphy-dev/core'; import { PluginRegistry } from '../../../../../src/core/plugins/registry/manager'; import { WorkspacePipelineStateBase } from '../../../../../src/extension/pipeline/service/base/state'; @@ -28,6 +33,10 @@ class TestWorkspacePipelineState extends WorkspacePipelineStateBase { protected _getWorkspaceRoot(): string | undefined { return this.workspaceRoot; } + + hydrateCacheFromGraphCache(options?: { activeAnalysisCacheTiers?: readonly AnalysisCacheTier[] }): Promise { + return this._hydrateCacheFromGraphCache(options); + } } function createContext(): vscode.ExtensionContext { @@ -128,7 +137,9 @@ describe('extension/pipeline/service/stateBase', () => { await Promise.all([firstWarm, secondWarm]); expect(stateBaseHarness.loadWorkspaceAnalysisDatabaseCache).toHaveBeenCalledOnce(); - expect(stateBaseHarness.loadWorkspaceAnalysisDatabaseCache).toHaveBeenCalledWith('/workspace'); + expect(stateBaseHarness.loadWorkspaceAnalysisDatabaseCache).toHaveBeenCalledWith('/workspace', { + activeAnalysisCacheTiers: [BASELINE_ANALYSIS_CACHE_TIER], + }); expect(stateBaseHarness.loadWorkspaceAnalysisDatabaseCacheAsync).not.toHaveBeenCalled(); expect(state._cache).toEqual({ version: '2.1.0', @@ -205,6 +216,188 @@ describe('extension/pipeline/service/stateBase', () => { expect(state._cache).toBe(populatedDuringHydration); }); + it('reloads Graph Cache when later hydration needs tiers missing from warm baseline memory', async () => { + stateBaseHarness.loadWorkspaceAnalysisDatabaseCache + .mockReturnValueOnce({ + version: '2.1.0', + files: { + 'src/app.ts': { + mtime: 1, + analysis: { + filePath: '/workspace/src/app.ts', + cache: { tiers: [BASELINE_ANALYSIS_CACHE_TIER] }, + relations: [], + symbols: [], + }, + }, + }, + }) + .mockReturnValueOnce({ + version: '2.1.0', + files: { + 'src/app.ts': { + mtime: 1, + analysis: { + filePath: '/workspace/src/app.ts', + cache: { + tiers: [BASELINE_ANALYSIS_CACHE_TIER, SYMBOLS_ANALYSIS_CACHE_TIER], + }, + relations: [], + symbols: [{ + id: 'src/app.ts#fn', + filePath: '/workspace/src/app.ts', + kind: 'function', + name: 'run', + }], + }, + }, + }, + }); + const state = new TestWorkspacePipelineState(createContext(), '/workspace') as TestWorkspacePipelineState & { + _cache: unknown; + }; + + await state.hydrateCacheFromGraphCache({ + activeAnalysisCacheTiers: [BASELINE_ANALYSIS_CACHE_TIER], + }); + await state.hydrateCacheFromGraphCache({ + activeAnalysisCacheTiers: [BASELINE_ANALYSIS_CACHE_TIER, SYMBOLS_ANALYSIS_CACHE_TIER], + }); + + expect(stateBaseHarness.loadWorkspaceAnalysisDatabaseCache).toHaveBeenNthCalledWith(1, '/workspace', { + activeAnalysisCacheTiers: [BASELINE_ANALYSIS_CACHE_TIER], + }); + expect(stateBaseHarness.loadWorkspaceAnalysisDatabaseCache).toHaveBeenNthCalledWith(2, '/workspace', { + activeAnalysisCacheTiers: [BASELINE_ANALYSIS_CACHE_TIER, SYMBOLS_ANALYSIS_CACHE_TIER], + }); + expect(state._cache).toEqual({ + version: '2.1.0', + files: { + 'src/app.ts': { + mtime: 1, + analysis: { + filePath: '/workspace/src/app.ts', + cache: { + tiers: [BASELINE_ANALYSIS_CACHE_TIER, SYMBOLS_ANALYSIS_CACHE_TIER], + }, + relations: [], + symbols: [{ + id: 'src/app.ts#fn', + filePath: '/workspace/src/app.ts', + kind: 'function', + name: 'run', + }], + }, + }, + }, + }); + }); + + it('keeps previously hydrated plugin evidence resident when loading another plugin tier', async () => { + stateBaseHarness.loadWorkspaceAnalysisDatabaseCache + .mockReturnValueOnce({ + version: '2.1.0', + files: { + 'src/app.ts': { + mtime: 1, + analysis: { + filePath: '/workspace/src/app.ts', + cache: { + tiers: [BASELINE_ANALYSIS_CACHE_TIER, 'plugin:codegraphy.vue'], + }, + relations: [], + }, + }, + }, + }) + .mockReturnValueOnce({ + version: '2.1.0', + files: { + 'src/app.ts': { + mtime: 1, + analysis: { + filePath: '/workspace/src/app.ts', + cache: { + tiers: [ + BASELINE_ANALYSIS_CACHE_TIER, + 'plugin:codegraphy.vue', + 'plugin:codegraphy.unity', + ], + }, + relations: [], + }, + }, + }, + }); + const state = new TestWorkspacePipelineState(createContext(), '/workspace') as TestWorkspacePipelineState & { + _cache: unknown; + }; + + await state.hydrateCacheFromGraphCache({ + activeAnalysisCacheTiers: [BASELINE_ANALYSIS_CACHE_TIER, 'plugin:codegraphy.vue'], + }); + await state.hydrateCacheFromGraphCache({ + activeAnalysisCacheTiers: [BASELINE_ANALYSIS_CACHE_TIER, 'plugin:codegraphy.unity'], + }); + + expect(stateBaseHarness.loadWorkspaceAnalysisDatabaseCache).toHaveBeenNthCalledWith(2, '/workspace', { + activeAnalysisCacheTiers: [ + BASELINE_ANALYSIS_CACHE_TIER, + 'plugin:codegraphy.unity', + 'plugin:codegraphy.vue', + ], + }); + expect(state._cache).toEqual({ + version: '2.1.0', + files: { + 'src/app.ts': { + mtime: 1, + analysis: { + filePath: '/workspace/src/app.ts', + cache: { + tiers: [ + BASELINE_ANALYSIS_CACHE_TIER, + 'plugin:codegraphy.vue', + 'plugin:codegraphy.unity', + ], + }, + relations: [], + }, + }, + }, + }); + }); + + it('keeps existing runtime memory when a later tier load finds an empty Graph Cache', async () => { + stateBaseHarness.loadWorkspaceAnalysisDatabaseCache.mockReturnValueOnce({ + version: '2.1.0', + files: {}, + }); + const state = new TestWorkspacePipelineState(createContext(), '/workspace') as TestWorkspacePipelineState & { + _cache: unknown; + }; + const baselineCache = { + version: '2.1.0', + files: { + 'src/app.ts': { + mtime: 1, + analysis: { + filePath: '/workspace/src/app.ts', + cache: { tiers: [BASELINE_ANALYSIS_CACHE_TIER] }, + relations: [], + }, + }, + }, + }; + state._cache = baselineCache; + + await state.hydrateCacheFromGraphCache({ + activeAnalysisCacheTiers: [BASELINE_ANALYSIS_CACHE_TIER, 'plugin:codegraphy.vue'], + }); + + expect(state._cache).toBe(baselineCache); + }); + it('clears the shared hydration promise so empty cache warms can retry', async () => { stateBaseHarness.loadWorkspaceAnalysisDatabaseCache.mockReturnValue({ version: '2.1.0', diff --git a/packages/extension/tests/extension/pipeline/service/cachedGraph.test.ts b/packages/extension/tests/extension/pipeline/service/cachedGraph.test.ts index 988d93121..76d4cd258 100644 --- a/packages/extension/tests/extension/pipeline/service/cachedGraph.test.ts +++ b/packages/extension/tests/extension/pipeline/service/cachedGraph.test.ts @@ -1,5 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { + BASELINE_ANALYSIS_CACHE_TIER, + SYMBOLS_ANALYSIS_CACHE_TIER, + type AnalysisCacheTier, hasRequiredAnalysisCacheTiers, projectFileAnalysisConnections, throwIfWorkspaceAnalysisAborted, @@ -64,7 +67,9 @@ const mixedCachedFiles: IDiscoveredFile[] = [ class TestCachedGraphFacade extends WorkspacePipelineCachedGraphFacade { readonly getWorkspaceRoot = vi.fn<() => string | undefined>(() => '/workspace'); - readonly hydrateCacheFromGraphCache = vi.fn(async () => undefined); + readonly hydrateCacheFromGraphCache = vi.fn(async ( + _options?: { activeAnalysisCacheTiers?: readonly AnalysisCacheTier[] }, + ) => undefined); readonly activeAnalysisPluginIds = vi.fn(( _pluginIds: readonly string[] | undefined, _disabledPlugins: ReadonlySet, ) => ['plugin.active']); @@ -89,7 +94,7 @@ class TestCachedGraphFacade extends WorkspacePipelineCachedGraphFacade { _config = { get: vi.fn((key: string, defaultValue: unknown) => - key === 'nodeVisibility' ? { Symbol: true } : defaultValue, + key === 'nodeVisibility' ? { symbol: true, 'symbol:function': true } : defaultValue, ), getAll: vi.fn(() => ({ respectGitignore: true, showOrphans: false })), } as unknown as Configuration; @@ -103,8 +108,10 @@ class TestCachedGraphFacade extends WorkspacePipelineCachedGraphFacade { return this.getWorkspaceRoot(); } - protected override async _hydrateCacheFromGraphCache(): Promise { - await this.hydrateCacheFromGraphCache(); + protected override async _hydrateCacheFromGraphCache( + options?: { activeAnalysisCacheTiers?: readonly AnalysisCacheTier[] }, + ): Promise { + await this.hydrateCacheFromGraphCache(options); } protected override _getActiveAnalysisPluginIds( @@ -201,6 +208,13 @@ describe('extension/pipeline/service/cachedGraph', () => { }); expect(throwIfWorkspaceAnalysisAborted).toHaveBeenCalledWith(signal); + expect(facade.hydrateCacheFromGraphCache).toHaveBeenCalledWith({ + activeAnalysisCacheTiers: [ + BASELINE_ANALYSIS_CACHE_TIER, + SYMBOLS_ANALYSIS_CACHE_TIER, + 'plugin:plugin.active', + ], + }); expect(createCachedWorkspaceDiscoveryState).toHaveBeenCalledWith( '/workspace', ['src/cached.ts'], @@ -228,7 +242,7 @@ describe('extension/pipeline/service/cachedGraph', () => { disabledPlugins, files: cachedFiles, getActiveAnalysisPluginIds: expect.any(Function), - nodeVisibility: { Symbol: true }, + nodeVisibility: { symbol: true, 'symbol:function': true }, registry: facade._registry, signal, workspaceRoot: '/workspace', @@ -348,6 +362,22 @@ describe('extension/pipeline/service/cachedGraph', () => { ); }); + it('hydrates only baseline runtime tiers when symbols and plugin analysis are inactive', async () => { + const facade = new TestCachedGraphFacade(); + vi.mocked(facade._config.get).mockImplementation((key: string, defaultValue: unknown) => + key === 'nodeVisibility' ? { symbol: false } : defaultValue, + ); + facade.activeAnalysisPluginIds.mockReturnValue([]); + + await facade.loadCachedGraph([], new Set(), undefined, { + warmAnalysis: false, + }); + + expect(facade.hydrateCacheFromGraphCache).toHaveBeenCalledWith({ + activeAnalysisCacheTiers: [BASELINE_ANALYSIS_CACHE_TIER], + }); + }); + it('logs only unexpected cached analysis warmup failures', async () => { const facade = new TestCachedGraphFacade(); const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);