From 72bd5e08e2f4062e5bd689e94444762e6dbf2d79 Mon Sep 17 00:00:00 2001 From: joesobo Date: Wed, 15 Jul 2026 21:02:58 -0700 Subject: [PATCH] chore(release): prepare WebGPU package releases --- .changeset/custom-graph-renderer.md | 16 - .changeset/honest-graph-performance-hud.md | 7 - .changeset/remove-3d-graph.md | 10 - .../remove-layout-and-uniform-controls.md | 7 - .changeset/remove-timeline-view.md | 9 - docs/PLUGINS.md | 8 +- docs/plugin-api/LIFECYCLE.md | 8 +- docs/plugin-api/TYPES.md | 2 +- packages/core/CHANGELOG.md | 20 + packages/core/README.md | 2 +- packages/core/package.json | 2 +- packages/core/src/plugins/api.ts | 2 +- .../core/src/plugins/markdown/metadata.ts | 2 +- .../core/tests/cli/plugins/command.test.ts | 352 ------------- .../tests/cli/plugins/commandErrors.test.ts | 40 ++ .../core/tests/cli/plugins/commandFixture.ts | 41 ++ .../cli/plugins/commandRegistration.test.ts | 125 +++++ .../cli/plugins/commandWorkspaceState.test.ts | 163 ++++++ .../core/tests/cli/plugins/enable.test.ts | 2 +- .../tests/indexing/defaultPlugins.test.ts | 2 +- .../core/tests/indexing/workspace.test.ts | 490 ------------------ .../core/tests/indexing/workspaceFixture.ts | 132 +++++ .../tests/indexing/workspaceLifecycle.test.ts | 145 ++++++ .../tests/indexing/workspacePlugins.test.ts | 236 +++++++++ .../core/tests/plugins/access/checks.test.ts | 2 +- .../tests/plugins/activityState/model.test.ts | 6 +- .../core/tests/plugins/contributions.test.ts | 4 +- .../core/tests/plugins/installedCache.test.ts | 346 ------------- .../tests/plugins/installedCacheFixture.ts | 24 + .../installedCachePackageManifest.test.ts | 111 ++++ .../installedCacheRegistration.test.ts | 88 ++++ .../installedCacheWorkspaceState.test.ts | 144 +++++ .../installedPluginCache/bundled.test.ts | 2 +- .../plugins/installedPluginRecord.test.ts | 10 +- .../tests/plugins/lifecycle/analysis.test.ts | 14 +- .../plugins/lifecycle/filesChanged.test.ts | 2 +- .../plugins/lifecycle/initialize.test.ts | 2 +- .../tests/plugins/packageEntrypoint.test.ts | 6 +- .../tests/plugins/packageManifest.test.ts | 31 +- .../core/tests/plugins/packageRuntime.test.ts | 412 --------------- .../plugins/packageRuntimeActivation.test.ts | 186 +++++++ .../tests/plugins/packageRuntimeFixture.ts | 123 +++++ .../plugins/packageRuntimeResolution.test.ts | 130 +++++ packages/core/tests/plugins/registry.test.ts | 2 +- ...outing.test.ts => routingAnalysis.test.ts} | 182 +------ packages/core/tests/plugins/routingFixture.ts | 27 + .../core/tests/plugins/routingLookups.test.ts | 98 ++++ .../tests/plugins/routingRelationKeys.test.ts | 65 +++ packages/core/tests/plugins/status.test.ts | 88 +--- .../tests/plugins/statusResolution.test.ts | 81 +++ .../workspace/coreBackedCommands.test.ts | 167 ++++++ ....ts => coreBackedPluginVisibility.test.ts} | 165 +----- packages/core/tests/workspace/status.test.ts | 2 +- packages/extension/CHANGELOG.md | 41 ++ packages/extension/package.json | 2 +- .../src/core/plugins/api/instance/index.ts | 3 +- .../core/plugins/versioning/apiVersions.ts | 2 +- .../package-graph-view-plugin/codegraphy.json | 2 +- .../package-graph-view-plugin/package.json | 2 +- .../package-graph-view-plugin/plugin.js | 2 +- .../pipeline/plugins/bootstrap/packages.ts | 8 +- .../core/plugins/api/instance/index.test.ts | 4 +- .../plugins/lifecycle/notify/analysis.test.ts | 2 +- .../lifecycle/notify/filesChanged.test.ts | 2 +- .../lifecycle/notify/readiness.test.ts | 2 +- .../plugins/lifecycle/pluginLifecycle.test.ts | 2 +- .../plugins/pluginRouting.analyzefile.test.ts | 2 +- ...pportsfiletogetsupportedextensions.test.ts | 2 +- .../tests/core/plugins/pluginRouting.test.ts | 2 +- ...yreadinessforplugincallsforunknown.test.ts | 2 +- .../plugins/registry/errorHandling.test.ts | 22 +- ...ibilitytologsunloadfailureswiththe.test.ts | 2 +- .../plugins/registry/extensionMap.test.ts | 2 +- .../core/plugins/registry/lookup.test.ts | 2 +- .../registry/pluginRegistry.access.test.ts | 72 +++ .../pluginRegistry.collection.test.ts | 436 +--------------- .../pluginRegistry.disabledAnalysis.test.ts | 55 ++ ...ginRegistry.graphScopeCapabilities.test.ts | 106 ++++ .../pluginRegistry.graphTypes.test.ts | 216 ++++++++ .../registry/pluginRegistry.testSupport.ts | 2 +- ...2.notificationhookstoeventemission.test.ts | 2 +- ...readywiththelatesttolifecyclehooks.test.ts | 2 +- .../tests/core/plugins/registry/v2.test.ts | 6 +- .../core/plugins/routing/analyze.test.ts | 6 +- .../core/plugins/routing/lookups.test.ts | 2 +- .../graphView/groups/defaults/builtIn.test.ts | 14 +- .../plugins/registrationFollowup.test.ts | 4 +- .../plugins/registrationLifecycle.test.ts | 8 +- .../settingsMessages/defaultOptions.test.ts | 8 +- .../graphViewProvider.pluginApi.test.ts | 2 +- .../graphViewProvider/plugin/defaults.test.ts | 8 +- .../graphViewProvider/plugin/dispatch.test.ts | 2 +- .../graphViewProvider/plugin/inject.test.ts | 4 +- .../plugin/lifecycle.test.ts | 10 +- .../extension/pipeline/adaptersFixture.ts | 2 +- .../plugins/bootstrapDataAndDisabled.test.ts | 4 +- .../pipeline/plugins/bootstrapFixture.ts | 23 +- .../plugins/bootstrapInitialization.test.ts | 2 +- .../plugins/bootstrapPackages.test.ts | 55 +- .../pipeline/plugins/bootstrapSync.test.ts | 2 +- .../pipeline/plugins/queries.test.ts | 2 +- .../pipeline/plugins/statusContext.test.ts | 6 +- .../pipeline/plugins/testFactories.ts | 4 +- .../service/discoveryFacadeAnalysis.test.ts | 17 +- .../discoveryFacadeCacheReplay.test.ts | 17 +- .../service/discoveryFacadeDiscovery.test.ts | 17 +- .../service/discoveryFacadeFixture.ts | 7 - .../service/discoveryFacadeGitignore.test.ts | 19 +- .../service/discoveryFacadeLifecycle.test.ts | 17 +- .../discoveryFacadeWarmAnalysis.test.ts | 17 +- .../service/lifecycleFacade.cache.test.ts | 112 ++++ ...lifecycleFacade.pluginInvalidation.test.ts | 83 +++ .../service/lifecycleFacade.plugins.test.ts | 99 ++++ .../pipeline/service/lifecycleFacade.test.ts | 356 ------------- .../service/lifecycleFacadeFixture.ts | 154 ++++++ .../pipeline/service/pluginStatuses.test.ts | 2 +- .../pluginIntegration/workspaceFixture.ts | 6 +- .../tests/integration/devLaunchConfig.test.ts | 2 +- .../plugins/Panel.deduplication.test.tsx | 71 +++ .../plugins/Panel.interactions.test.tsx | 88 ++++ ...l.test.tsx => Panel.presentation.test.tsx} | 184 +------ .../tests/webview/plugins/panelFixture.tsx | 24 + packages/graph-renderer/CHANGELOG.md | 17 + packages/graph-renderer/package.json | 2 +- packages/mcp/CHANGELOG.md | 7 + packages/mcp/package.json | 2 +- packages/plugin-api/CHANGELOG.md | 14 + packages/plugin-api/README.md | 8 +- packages/plugin-api/package.json | 2 +- packages/plugin-api/src/plugin.ts | 4 +- .../plugin-api/tests/pluginContracts.test.ts | 10 +- packages/plugin-godot/CHANGELOG.md | 9 + packages/plugin-godot/codegraphy.json | 2 +- packages/plugin-godot/package.json | 4 +- packages/plugin-markdown/CHANGELOG.md | 9 + packages/plugin-markdown/codegraphy.json | 2 +- packages/plugin-markdown/package.json | 4 +- packages/plugin-markdown/tests/plugin.test.ts | 2 +- packages/plugin-particles/CHANGELOG.md | 9 + packages/plugin-particles/codegraphy.json | 2 +- packages/plugin-particles/package.json | 4 +- packages/plugin-svelte/CHANGELOG.md | 9 + packages/plugin-svelte/codegraphy.json | 2 +- packages/plugin-svelte/package.json | 4 +- packages/plugin-typescript/CHANGELOG.md | 9 + packages/plugin-typescript/codegraphy.json | 2 +- packages/plugin-typescript/package.json | 4 +- packages/plugin-unity/CHANGELOG.md | 13 + packages/plugin-unity/codegraphy.json | 2 +- packages/plugin-unity/package.json | 4 +- packages/plugin-vue/CHANGELOG.md | 9 + packages/plugin-vue/codegraphy.json | 2 +- packages/plugin-vue/package.json | 4 +- scripts/release.mjs | 17 +- tests/release/pluginApiCompatibility.test.mjs | 31 ++ tests/scripts/release.test.mjs | 22 +- 156 files changed, 3816 insertions(+), 3241 deletions(-) delete mode 100644 .changeset/custom-graph-renderer.md delete mode 100644 .changeset/honest-graph-performance-hud.md delete mode 100644 .changeset/remove-3d-graph.md delete mode 100644 .changeset/remove-layout-and-uniform-controls.md delete mode 100644 .changeset/remove-timeline-view.md delete mode 100644 packages/core/tests/cli/plugins/command.test.ts create mode 100644 packages/core/tests/cli/plugins/commandErrors.test.ts create mode 100644 packages/core/tests/cli/plugins/commandFixture.ts create mode 100644 packages/core/tests/cli/plugins/commandRegistration.test.ts create mode 100644 packages/core/tests/cli/plugins/commandWorkspaceState.test.ts delete mode 100644 packages/core/tests/indexing/workspace.test.ts create mode 100644 packages/core/tests/indexing/workspaceFixture.ts create mode 100644 packages/core/tests/indexing/workspaceLifecycle.test.ts create mode 100644 packages/core/tests/indexing/workspacePlugins.test.ts delete mode 100644 packages/core/tests/plugins/installedCache.test.ts create mode 100644 packages/core/tests/plugins/installedCacheFixture.ts create mode 100644 packages/core/tests/plugins/installedCachePackageManifest.test.ts create mode 100644 packages/core/tests/plugins/installedCacheRegistration.test.ts create mode 100644 packages/core/tests/plugins/installedCacheWorkspaceState.test.ts delete mode 100644 packages/core/tests/plugins/packageRuntime.test.ts create mode 100644 packages/core/tests/plugins/packageRuntimeActivation.test.ts create mode 100644 packages/core/tests/plugins/packageRuntimeFixture.ts create mode 100644 packages/core/tests/plugins/packageRuntimeResolution.test.ts rename packages/core/tests/plugins/{routing.test.ts => routingAnalysis.test.ts} (51%) create mode 100644 packages/core/tests/plugins/routingFixture.ts create mode 100644 packages/core/tests/plugins/routingLookups.test.ts create mode 100644 packages/core/tests/plugins/routingRelationKeys.test.ts create mode 100644 packages/core/tests/plugins/statusResolution.test.ts create mode 100644 packages/core/tests/workspace/coreBackedCommands.test.ts rename packages/core/tests/workspace/{coreBacked.test.ts => coreBackedPluginVisibility.test.ts} (51%) create mode 100644 packages/extension/tests/core/plugins/registry/pluginRegistry.access.test.ts create mode 100644 packages/extension/tests/core/plugins/registry/pluginRegistry.disabledAnalysis.test.ts create mode 100644 packages/extension/tests/core/plugins/registry/pluginRegistry.graphScopeCapabilities.test.ts create mode 100644 packages/extension/tests/core/plugins/registry/pluginRegistry.graphTypes.test.ts create mode 100644 packages/extension/tests/extension/pipeline/service/lifecycleFacade.cache.test.ts create mode 100644 packages/extension/tests/extension/pipeline/service/lifecycleFacade.pluginInvalidation.test.ts create mode 100644 packages/extension/tests/extension/pipeline/service/lifecycleFacade.plugins.test.ts delete mode 100644 packages/extension/tests/extension/pipeline/service/lifecycleFacade.test.ts create mode 100644 packages/extension/tests/extension/pipeline/service/lifecycleFacadeFixture.ts create mode 100644 packages/extension/tests/webview/plugins/Panel.deduplication.test.tsx create mode 100644 packages/extension/tests/webview/plugins/Panel.interactions.test.tsx rename packages/extension/tests/webview/plugins/{Panel.test.tsx => Panel.presentation.test.tsx} (51%) create mode 100644 packages/extension/tests/webview/plugins/panelFixture.tsx create mode 100644 packages/graph-renderer/CHANGELOG.md create mode 100644 tests/release/pluginApiCompatibility.test.mjs diff --git a/.changeset/custom-graph-renderer.md b/.changeset/custom-graph-renderer.md deleted file mode 100644 index fb0e64a64..000000000 --- a/.changeset/custom-graph-renderer.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@codegraphy-dev/extension": minor -"@codegraphy-dev/graph-renderer": minor ---- - -CodeGraphy now renders the Relationship Graph with its own WebGPU renderer and deterministic WebAssembly force-layout engine instead of `react-force-graph` and `d3-force`. Users get one maintained rendering path with size-aware collisions, stable node stacking, directional edges, device-loss recovery, and the remaining supported 2D graph interactions and settings. The accompanying changesets describe the intentionally removed 3D, Timeline, DAG-layout, Churn-sizing, and Uniform-sizing controls. - -Node bodies and the always-on-top Canvas decoration layer now use the same ascending-size order, stable graph-index tie-break, hover-last order, and hover scale. Labels, images, badges, and plugin Canvas drawings remain a presentation layer above all WebGPU node bodies so labels stay readable; their relative node order matches the body order, but decoration pixels are intentionally not interleaved between WebGPU bodies. - -The Graph View now requires WebGPU and WebAssembly support. When VS Code or the host GPU cannot create a WebGPU device, CodeGraphy shows an unsupported-renderer state rather than falling back to the previous Canvas or Three.js renderers. - -A new public `@codegraphy-dev/graph-renderer` package exposes the renderer contracts, WebGPU renderer, typed-array physics engine, and WASM preparation entry point for browser applications. Package consumers must call `prepareGraphPhysics()` before creating a graph layout and must ship the package's generated WASM asset alongside the JavaScript bundle. Renderer consumers are responsible for their canvas, scheduling, camera, interactions, and recovery UI. The renderer now tries a software adapter when native device creation fails, rejects graph frames that exceed WebGPU buffer limits before changing renderer state, and reports uncaptured runtime GPU errors through the required `onRendererError` callback so hosts can replace the renderer instead of leaving blank output. - -Physics consumers can install host forces through `tick({ beforeIntegration, afterIntegration })`. Owned forces accumulate first, host forces run before integration, and optional finalization runs after collision correction for fixed-coordinate constraints. The package rejects coordinates, velocities, radii, charge multipliers, and force settings outside its documented safe numerical domains instead of silently clamping or resetting nodes. Collision radii remain fixed in graph space: camera zoom never reheats or permanently spreads a settled layout, while a node pinned during dragging still pushes overlapping neighbors aside. Plugin `fx` and `fy` constraints retain per-axis behavior and do not release user or drag pins when a plugin clears them. - -Plugin node and edge colors keep browser CSS compatibility after the WebGPU move. Named colors, HSL, percentage RGB, `currentColor`, and resolvable custom properties are computed in the active Graph View theme before upload; invalid or missing variables fall back to the normal graph color instead of turning black. Plugin and CSS-snippet stylesheet toggles refresh cached GPU colors. Direct `@codegraphy-dev/graph-renderer` consumers must provide concrete hexadecimal, numeric RGB, `color(srgb ...)`, or transparent frame colors. diff --git a/.changeset/honest-graph-performance-hud.md b/.changeset/honest-graph-performance-hud.md deleted file mode 100644 index 4225e9df4..000000000 --- a/.changeset/honest-graph-performance-hud.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@codegraphy-dev/extension": patch ---- - -Add an optional `FPS · ms` readout to the Relationship Graph's Performance settings. FPS is measured from the intervals between successfully completed rendered frames, while `ms` reports average CPU time spent running graph simulation and rendering work. - -The readout waits for two successful frames before showing FPS, excludes rejected GPU submissions and stale renderer generations, and returns to `— FPS · — ms` when the demand-driven graph becomes idle. The setting is off by default and persists per CodeGraphy Workspace. diff --git a/.changeset/remove-3d-graph.md b/.changeset/remove-3d-graph.md deleted file mode 100644 index a1db21cab..000000000 --- a/.changeset/remove-3d-graph.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -"@codegraphy-dev/plugin-api": major -"@codegraphy-dev/core": major -"@codegraphy-dev/extension": minor -"@codegraphy-dev/plugin-unity": patch ---- - -CodeGraphy now provides one supported 2D Relationship Graph and removes the 3D graph mode, its toolbar toggle, 3D node shapes, 3D camera state, and Three.js renderer settings. Existing workspaces open directly in the 2D graph; saved 3D preferences are ignored. - -This is a breaking Plugin API change. Plugin authors must remove `GraphNodeShape3D`, `shape3D`, `graphMode`, three-dimensional node coordinates (`z`, `fz`, and `vz`), and 3D values in selected-node position payloads. Graph View contributions, drag callbacks, context-menu selectors, and viewport adapters now receive only two-dimensional graph state. The Unity plugin continues to contribute Unity graph data but no longer supplies 3D presentation metadata. diff --git a/.changeset/remove-layout-and-uniform-controls.md b/.changeset/remove-layout-and-uniform-controls.md deleted file mode 100644 index e0ac5f66d..000000000 --- a/.changeset/remove-layout-and-uniform-controls.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@codegraphy-dev/extension": minor ---- - -Simplify Relationship Graph presentation controls by removing the radial, top-down, and left-to-right DAG layouts and the Uniform node-size mode. Existing saved DAG selections now open in the force-directed layout, and saved Uniform sizing falls back to Connections sizing. - -Use the force-directed layout for graph positioning. Choose Connections when important hubs should be larger, or File Size when node area should reflect file size. These removals do not change indexed graph data, Graph Scope, filters, or plugin-contributed relationships. diff --git a/.changeset/remove-timeline-view.md b/.changeset/remove-timeline-view.md deleted file mode 100644 index 5215ee745..000000000 --- a/.changeset/remove-timeline-view.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@codegraphy-dev/plugin-api": major -"@codegraphy-dev/core": major -"@codegraphy-dev/extension": minor ---- - -CodeGraphy now opens one current-workspace Relationship Graph and removes the Timeline panel, commit-by-commit Graph Revision playback, revision controls, and Git-history Churn node sizing. Existing workspaces keep their current graph settings, but saved Timeline state and Churn sizing selections no longer affect the graph. Choose Connections or File Size for semantic node sizing. - -This is a breaking Plugin API and Core package change. Plugin authors must remove the `timeline-panel` slot, Timeline lifecycle events and payloads, `timelineActive` contribution/context fields, Timeline analysis mode and `commitSha`, and the optional `churn` graph-node field. Core callers must stop passing churn counts into graph construction. Plugins should analyze the current CodeGraphy Workspace and contribute to the normal Graph View instead of branching on Timeline state. diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index 333caf20a..2f95ddd65 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -68,7 +68,7 @@ Plugin packages declare package-level CodeGraphy metadata in `package.json` so r }, "codegraphy": { "type": "plugin", - "apiVersion": "^2.0.0", + "apiVersion": "^3.0.0", "defaultOptions": { "includeTests": true }, @@ -85,7 +85,7 @@ The same package must also include a static `codegraphy.json` descriptor. Core r "id": "codegraphy.vue", "name": "Vue", "version": "1.0.0", - "apiVersion": "^2.0.0", + "apiVersion": "^3.0.0", "supportedExtensions": [".vue"], "defaultFilters": [] } @@ -122,7 +122,7 @@ const createPlugin: IPluginFactory = ({ dataHost, options } = {}) => ({ id: 'acme.graph-tools', name: 'Acme Graph Tools', version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', supportedExtensions: [], async initialize() { await dataHost?.saveData({ mode: options?.mode ?? 'default' }); @@ -140,7 +140,7 @@ Default options are copied into workspace settings when the plugin is enabled so { "codegraphy": { "type": "plugin", - "apiVersion": "^2.0.0", + "apiVersion": "^3.0.0", "defaultOptions": { "includeSceneResources": true, "includeAutoloads": true, diff --git a/docs/plugin-api/LIFECYCLE.md b/docs/plugin-api/LIFECYCLE.md index e297afe28..d9a3c616f 100644 --- a/docs/plugin-api/LIFECYCLE.md +++ b/docs/plugin-api/LIFECYCLE.md @@ -23,7 +23,7 @@ Registration reads global package metadata without importing plugin runtime code }, "codegraphy": { "type": "plugin", - "apiVersion": "^2.0.0", + "apiVersion": "^3.0.0", "defaultOptions": { "includeTests": true }, @@ -40,7 +40,7 @@ The same package declares its static Plugin ID and display metadata in `codegrap "id": "acme.plugin", "name": "Acme Plugin", "version": "1.0.0", - "apiVersion": "^2.0.0", + "apiVersion": "^3.0.0", "supportedExtensions": [".ts"] } ``` @@ -58,7 +58,7 @@ const plugin: IPlugin = { id: 'acme.plugin', name: 'Acme Plugin', version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', supportedExtensions: ['.ts'], }; ``` @@ -165,7 +165,7 @@ export function createMetricsPlugin(): IPlugin { id: 'codegraphy-metrics', name: 'Metrics', version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', supportedExtensions: ['*'], async initialize(workspaceRoot, context) { diff --git a/docs/plugin-api/TYPES.md b/docs/plugin-api/TYPES.md index 0919f4e38..747339a5c 100644 --- a/docs/plugin-api/TYPES.md +++ b/docs/plugin-api/TYPES.md @@ -39,7 +39,7 @@ The public package is host-agnostic. VS Code-specific bridge types, decorations, Defined in `plugin.ts`. Key points: -- `apiVersion: string` is required (for example `'^2.0.0'`). +- `apiVersion: string` is required (for example `'^3.0.0'`). - `sources?: IConnectionSource[]` declares the plugin's Relationship Source families. - `fileColors?: Record` lets plugins provide default color/shape/imagePath styling by pattern. - `analyzeFile(filePath, content, workspaceRoot, context?)` is the plugin analysis hook for returning relationships, symbols, and contributed Node Types or Edge Types. diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index e2a466f3c..62e511ee8 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,25 @@ # @codegraphy-dev/core +## 2.0.0 + +### Major Changes + +- Advance the host runtime compatibility protocol to Plugin API 3. Plugins must declare `apiVersion: '^3.0.0'`; plugins targeting the removed v2 contracts are rejected before registration. + +- [#308](https://github.com/joesobo/CodeGraphyV4/pull/308) [`b744f20`](https://github.com/joesobo/CodeGraphyV4/commit/b744f20bb1391e9a0c40d3e448a4f3f78bde4974) Thanks [@joesobo](https://github.com/joesobo)! - CodeGraphy now provides one supported 2D Relationship Graph and removes the 3D graph mode, its toolbar toggle, 3D node shapes, 3D camera state, and Three.js renderer settings. Existing workspaces open directly in the 2D graph; saved 3D preferences are ignored. + + This is a breaking Plugin API change. Plugin authors must remove `GraphNodeShape3D`, `shape3D`, `graphMode`, three-dimensional node coordinates (`z`, `fz`, and `vz`), and 3D values in selected-node position payloads. Graph View contributions, drag callbacks, context-menu selectors, and viewport adapters now receive only two-dimensional graph state. The Unity plugin continues to contribute Unity graph data but no longer supplies 3D presentation metadata. + +- [#308](https://github.com/joesobo/CodeGraphyV4/pull/308) [`5a65047`](https://github.com/joesobo/CodeGraphyV4/commit/5a65047d1a715f005760ace0ebf0f550a16efa2e) Thanks [@joesobo](https://github.com/joesobo)! - CodeGraphy now opens one current-workspace Relationship Graph and removes the Timeline panel, commit-by-commit Graph Revision playback, revision controls, and Git-history Churn node sizing. Existing workspaces keep their current graph settings, but saved Timeline state and Churn sizing selections no longer affect the graph. Choose Connections or File Size for semantic node sizing. + + This is a breaking Plugin API and Core package change. Plugin authors must remove the `timeline-panel` slot, Timeline lifecycle events and payloads, `timelineActive` contribution/context fields, Timeline analysis mode and `commitSha`, and the optional `churn` graph-node field. Core callers must stop passing churn counts into graph construction. Plugins should analyze the current CodeGraphy Workspace and contribute to the normal Graph View instead of branching on Timeline state. + +### Patch Changes + +- Updated dependencies [[`b744f20`](https://github.com/joesobo/CodeGraphyV4/commit/b744f20bb1391e9a0c40d3e448a4f3f78bde4974), [`5a65047`](https://github.com/joesobo/CodeGraphyV4/commit/5a65047d1a715f005760ace0ebf0f550a16efa2e)]: + - @codegraphy-dev/plugin-api@6.0.0 + - @codegraphy-dev/plugin-markdown@1.1.9 + ## 1.7.2 ### Patch Changes diff --git a/packages/core/README.md b/packages/core/README.md index 0a7f9ef1f..44de63c74 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -57,7 +57,7 @@ Plugin npm packages identify themselves with package metadata: "version": "1.2.3", "codegraphy": { "type": "plugin", - "apiVersion": "^2.0.0", + "apiVersion": "^3.0.0", "defaultOptions": { "includeTests": true }, diff --git a/packages/core/package.json b/packages/core/package.json index 99f5c79f5..1be154b03 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@codegraphy-dev/core", - "version": "1.7.2", + "version": "2.0.0", "description": "CodeGraphy core indexing and graph query engine", "license": "MIT", "type": "module", diff --git a/packages/core/src/plugins/api.ts b/packages/core/src/plugins/api.ts index ca1c9c8df..e45788547 100644 --- a/packages/core/src/plugins/api.ts +++ b/packages/core/src/plugins/api.ts @@ -1 +1 @@ -export const CORE_PLUGIN_API_VERSION = '2.0.0'; +export const CORE_PLUGIN_API_VERSION = '3.0.0'; diff --git a/packages/core/src/plugins/markdown/metadata.ts b/packages/core/src/plugins/markdown/metadata.ts index b42cdabe6..b836c10de 100644 --- a/packages/core/src/plugins/markdown/metadata.ts +++ b/packages/core/src/plugins/markdown/metadata.ts @@ -8,7 +8,7 @@ export const CODEGRAPHY_MARKDOWN_PLUGIN_METADATA = { id: CODEGRAPHY_MARKDOWN_PLUGIN_ID, name: 'Markdown', version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', supportedExtensions: ['*'], updateImpact: { toggle: 'reanalyze-plugin-files', diff --git a/packages/core/tests/cli/plugins/command.test.ts b/packages/core/tests/cli/plugins/command.test.ts deleted file mode 100644 index 59c2d16f1..000000000 --- a/packages/core/tests/cli/plugins/command.test.ts +++ /dev/null @@ -1,352 +0,0 @@ -import * as fs from 'node:fs/promises'; -import * as os from 'node:os'; -import * as path from 'node:path'; -import { - CODEGRAPHY_MARKDOWN_PLUGIN_ID, - CODEGRAPHY_MARKDOWN_PLUGIN_PACKAGE_NAME, - readCodeGraphyInstalledPluginCache, - readCodeGraphyWorkspaceSettings, - writeCodeGraphyInstalledPluginCache, - type CodeGraphyInstalledPluginRecord, -} from '../../../src'; -import { describe, expect, it } from 'vitest'; - -import { runPluginsCommand } from '../../../src/cli/plugins/command'; - -async function createPackage( - root: string, - packageName: string, - packageJson: Record, - descriptor?: Record, -): Promise { - const packageRoot = path.join(root, ...packageName.split('/')); - await fs.mkdir(packageRoot, { recursive: true }); - await fs.writeFile( - path.join(packageRoot, 'package.json'), - `${JSON.stringify({ name: packageName, ...packageJson }, null, 2)}\n`, - 'utf-8', - ); - if (descriptor) { - await fs.writeFile( - path.join(packageRoot, 'codegraphy.json'), - `${JSON.stringify(descriptor, null, 2)}\n`, - 'utf-8', - ); - } -} - -function createPluginRecord( - packageName: string, - packageRoot: string, - pluginId = packageName, -): CodeGraphyInstalledPluginRecord { - return { - package: packageName, - pluginId, - version: '1.2.3', - apiVersion: '^2.0.0', - disclosures: [], - defaultOptions: { includeTests: true }, - packageRoot, - }; -} - -describe('plugins/command', () => { - it('registers an explicitly named globally installed plugin package without enabling it', async () => { - const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-user-home-')); - const globalRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-global-root-')); - const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-workspace-')); - await createPackage(globalRoot, 'private-plugin', { - version: '4.5.6', - codegraphy: { - type: 'plugin', - apiVersion: '^2.0.0', - }, - }, { - id: 'private-plugin', - }); - - const result = await runPluginsCommand({ - name: 'plugins', - action: 'register', - packageName: 'private-plugin', - }, { - cwd: () => workspaceRoot, - homeDir, - resolveGlobalPackageRoots: () => [globalRoot], - }); - - expect(result).toEqual({ - exitCode: 0, - output: 'Registered private-plugin in ~/.codegraphy/plugins.json.', - }); - expect(readCodeGraphyInstalledPluginCache({ homeDir }).plugins.map(plugin => plugin.package)).toEqual([ - 'private-plugin', - ]); - await expect(fs.stat(path.join(workspaceRoot, '.codegraphy', 'settings.json'))).rejects.toMatchObject({ - code: 'ENOENT', - }); - }); - - it('links a private local plugin package without requiring global npm install', async () => { - const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-user-home-')); - const packageRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-private-package-')); - const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-workspace-')); - await fs.writeFile( - path.join(packageRoot, 'package.json'), - `${JSON.stringify({ - name: '@acme/codegraphy-private-plugin', - version: '0.1.0', - codegraphy: { - type: 'plugin', - apiVersion: '^2.0.0', - }, - }, null, 2)}\n`, - 'utf-8', - ); - await fs.writeFile( - path.join(packageRoot, 'codegraphy.json'), - `${JSON.stringify({ - id: 'acme.private', - name: 'Acme Private', - }, null, 2)}\n`, - 'utf-8', - ); - - const result = await runPluginsCommand({ - name: 'plugins', - action: 'link', - packageRoot, - }, { - cwd: () => workspaceRoot, - homeDir, - }); - - expect(result).toEqual({ - exitCode: 0, - output: `Linked @acme/codegraphy-private-plugin from ${packageRoot} into ~/.codegraphy/plugins.json.`, - }); - expect(readCodeGraphyInstalledPluginCache({ homeDir }).plugins).toEqual([ - expect.objectContaining({ - package: '@acme/codegraphy-private-plugin', - pluginId: 'acme.private', - packageRoot, - }), - ]); - await expect(fs.stat(path.join(workspaceRoot, '.codegraphy', 'settings.json'))).rejects.toMatchObject({ - code: 'ENOENT', - }); - }); - - it('rejects plugin packages that do not declare a static plugin id', async () => { - const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-user-home-')); - const globalRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-global-root-')); - const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-workspace-')); - await createPackage(globalRoot, 'private-plugin', { - version: '4.5.6', - codegraphy: { - type: 'plugin', - apiVersion: '^2.0.0', - }, - }); - - const result = await runPluginsCommand({ - name: 'plugins', - action: 'register', - packageName: 'private-plugin', - }, { - cwd: () => workspaceRoot, - homeDir, - resolveGlobalPackageRoots: () => [globalRoot], - }); - - expect(result).toEqual({ - exitCode: 1, - output: "Package 'private-plugin' is missing codegraphy.json with a static plugin id.", - }); - expect(readCodeGraphyInstalledPluginCache({ homeDir }).plugins).toEqual([]); - }); - - it('enables and disables a cached plugin for one workspace', async () => { - const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-user-home-')); - const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-workspace-plugin-')); - const record = createPluginRecord( - '@codegraphy-dev/plugin-vue', - '/global/@codegraphy-dev/plugin-vue', - 'codegraphy.vue', - ); - writeCodeGraphyInstalledPluginCache({ - version: 1, - plugins: [record], - }, { homeDir }); - - const enableResult = await runPluginsCommand({ - name: 'plugins', - action: 'enable', - packageName: '@codegraphy-dev/plugin-vue', - workspacePath: workspaceRoot, - }, { homeDir }); - - expect(enableResult).toEqual({ - exitCode: 0, - output: `Enabled codegraphy.vue for ${workspaceRoot}. Run \`codegraphy index ${workspaceRoot}\` to refresh the Graph Cache.`, - }); - expect(readCodeGraphyWorkspaceSettings(workspaceRoot).plugins).toEqual([ - { id: CODEGRAPHY_MARKDOWN_PLUGIN_ID, enabled: true }, - { - id: 'codegraphy.vue', - enabled: true, - options: { includeTests: true }, - }, - ]); - - const disableResult = await runPluginsCommand({ - name: 'plugins', - action: 'disable', - packageName: '@codegraphy-dev/plugin-vue', - workspacePath: workspaceRoot, - }, { homeDir }); - - expect(disableResult).toEqual({ - exitCode: 0, - output: `Disabled codegraphy.vue for ${workspaceRoot}. Run \`codegraphy index ${workspaceRoot}\` to refresh the Graph Cache.`, - }); - expect(readCodeGraphyWorkspaceSettings(workspaceRoot).plugins).toEqual([ - { id: CODEGRAPHY_MARKDOWN_PLUGIN_ID, enabled: true }, - { - id: 'codegraphy.vue', - enabled: false, - options: { includeTests: true }, - }, - ]); - }); - - it('enables bundled Markdown without requiring it in the user installed plugin cache', async () => { - const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-user-home-')); - const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-workspace-markdown-')); - - const disableResult = await runPluginsCommand({ - name: 'plugins', - action: 'disable', - packageName: CODEGRAPHY_MARKDOWN_PLUGIN_PACKAGE_NAME, - workspacePath: workspaceRoot, - }, { homeDir }); - expect(disableResult.exitCode).toBe(0); - expect(readCodeGraphyWorkspaceSettings(workspaceRoot).plugins).toEqual([{ - id: CODEGRAPHY_MARKDOWN_PLUGIN_ID, - enabled: false, - }]); - - const enableResult = await runPluginsCommand({ - name: 'plugins', - action: 'enable', - packageName: CODEGRAPHY_MARKDOWN_PLUGIN_PACKAGE_NAME, - workspacePath: workspaceRoot, - }, { homeDir }); - - expect(enableResult).toEqual({ - exitCode: 0, - output: `Enabled ${CODEGRAPHY_MARKDOWN_PLUGIN_ID} for ${workspaceRoot}. Run \`codegraphy index ${workspaceRoot}\` to refresh the Graph Cache.`, - }); - expect(readCodeGraphyWorkspaceSettings(workspaceRoot).plugins).toEqual([{ - id: CODEGRAPHY_MARKDOWN_PLUGIN_ID, - enabled: true, - }]); - }); - - it('lists disabled bundled Markdown without requiring it in the user installed plugin cache', async () => { - const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-user-home-')); - const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-workspace-markdown-')); - - await runPluginsCommand({ - name: 'plugins', - action: 'disable', - packageName: CODEGRAPHY_MARKDOWN_PLUGIN_PACKAGE_NAME, - workspacePath: workspaceRoot, - }, { homeDir }); - - const result = await runPluginsCommand({ - name: 'plugins', - action: 'list', - workspacePath: workspaceRoot, - }, { homeDir }); - - expect(result.output).toContain('Enabled in workspace:\nnone'); - expect(result.output).toContain('Registered but disabled:'); - expect(result.output).toContain(`- ${CODEGRAPHY_MARKDOWN_PLUGIN_ID}`); - }); - - it('lists enabled workspace plugins separately from registered disabled plugins', async () => { - const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-user-home-')); - const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-workspace-plugin-')); - writeCodeGraphyInstalledPluginCache({ - version: 1, - plugins: [ - createPluginRecord( - '@codegraphy-dev/plugin-markdown', - '/global/@codegraphy-dev/plugin-markdown', - CODEGRAPHY_MARKDOWN_PLUGIN_ID, - ), - createPluginRecord( - '@codegraphy-dev/plugin-vue', - '/global/@codegraphy-dev/plugin-vue', - 'codegraphy.vue', - ), - ], - }, { homeDir }); - await runPluginsCommand({ - name: 'plugins', - action: 'enable', - packageName: '@codegraphy-dev/plugin-vue', - workspacePath: workspaceRoot, - }, { homeDir }); - - const result = await runPluginsCommand({ - name: 'plugins', - action: 'list', - workspacePath: workspaceRoot, - }, { homeDir }); - - expect(result.output).toContain(`CodeGraphy plugins for ${workspaceRoot}`); - expect(result.output).toContain('Enabled in workspace:'); - expect(result.output).toContain(`1. ${CODEGRAPHY_MARKDOWN_PLUGIN_ID}`); - expect(result.output).toContain('2. codegraphy.vue'); - expect(result.output).toContain('Registered but disabled:'); - expect(result.output).not.toContain(`- ${CODEGRAPHY_MARKDOWN_PLUGIN_ID}`); - }); - - it('returns plugin help for unknown actions', async () => { - await expect(runPluginsCommand({ - name: 'plugins', - action: 'help', - })).resolves.toEqual({ - exitCode: 0, - output: [ - 'CodeGraphy plugin commands', - '', - 'Commands:', - ' codegraphy plugins register ', - ' codegraphy plugins link ', - ' codegraphy plugins list [workspace]', - ' codegraphy plugins enable [workspace]', - ' codegraphy plugins disable [workspace]', - ].join('\n'), - }); - }); - - it('turns plugin command exceptions into failed command output', async () => { - await expect(runPluginsCommand({ - name: 'plugins', - action: 'register', - packageName: 'private-plugin', - }, { - resolveGlobalPackageRoots: () => ['/global'], - registerInstalledPlugin: async () => { - throw new Error('plugin package is broken'); - }, - })).resolves.toEqual({ - exitCode: 1, - output: 'plugin package is broken', - }); - }); -}); diff --git a/packages/core/tests/cli/plugins/commandErrors.test.ts b/packages/core/tests/cli/plugins/commandErrors.test.ts new file mode 100644 index 000000000..3b8985563 --- /dev/null +++ b/packages/core/tests/cli/plugins/commandErrors.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; + +import { runPluginsCommand } from '../../../src/cli/plugins/command'; + +describe('plugins/command help and failures', () => { + it('returns plugin help for unknown actions', async () => { + await expect(runPluginsCommand({ + name: 'plugins', + action: 'help', + })).resolves.toEqual({ + exitCode: 0, + output: [ + 'CodeGraphy plugin commands', + '', + 'Commands:', + ' codegraphy plugins register ', + ' codegraphy plugins link ', + ' codegraphy plugins list [workspace]', + ' codegraphy plugins enable [workspace]', + ' codegraphy plugins disable [workspace]', + ].join('\n'), + }); + }); + + it('turns plugin command exceptions into failed command output', async () => { + await expect(runPluginsCommand({ + name: 'plugins', + action: 'register', + packageName: 'private-plugin', + }, { + resolveGlobalPackageRoots: () => ['/global'], + registerInstalledPlugin: async () => { + throw new Error('plugin package is broken'); + }, + })).resolves.toEqual({ + exitCode: 1, + output: 'plugin package is broken', + }); + }); +}); diff --git a/packages/core/tests/cli/plugins/commandFixture.ts b/packages/core/tests/cli/plugins/commandFixture.ts new file mode 100644 index 000000000..309ae5d82 --- /dev/null +++ b/packages/core/tests/cli/plugins/commandFixture.ts @@ -0,0 +1,41 @@ +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import type { CodeGraphyInstalledPluginRecord } from '../../../src'; + +export async function createPackage( + root: string, + packageName: string, + packageJson: Record, + descriptor?: Record, +): Promise { + const packageRoot = path.join(root, ...packageName.split('/')); + await fs.mkdir(packageRoot, { recursive: true }); + await fs.writeFile( + path.join(packageRoot, 'package.json'), + `${JSON.stringify({ name: packageName, ...packageJson }, null, 2)}\n`, + 'utf-8', + ); + if (descriptor) { + await fs.writeFile( + path.join(packageRoot, 'codegraphy.json'), + `${JSON.stringify(descriptor, null, 2)}\n`, + 'utf-8', + ); + } +} + +export function createPluginRecord( + packageName: string, + packageRoot: string, + pluginId = packageName, +): CodeGraphyInstalledPluginRecord { + return { + package: packageName, + pluginId, + version: '1.2.3', + apiVersion: '^3.0.0', + disclosures: [], + defaultOptions: { includeTests: true }, + packageRoot, + }; +} diff --git a/packages/core/tests/cli/plugins/commandRegistration.test.ts b/packages/core/tests/cli/plugins/commandRegistration.test.ts new file mode 100644 index 000000000..53f5e6453 --- /dev/null +++ b/packages/core/tests/cli/plugins/commandRegistration.test.ts @@ -0,0 +1,125 @@ +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import { runPluginsCommand } from '../../../src/cli/plugins/command'; +import { readCodeGraphyInstalledPluginCache } from '../../../src'; +import { createPackage } from './commandFixture'; + +describe('plugins/command registration', () => { + it('registers an explicitly named globally installed plugin package without enabling it', async () => { + const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-user-home-')); + const globalRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-global-root-')); + const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-workspace-')); + await createPackage(globalRoot, 'private-plugin', { + version: '4.5.6', + codegraphy: { + type: 'plugin', + apiVersion: '^3.0.0', + }, + }, { + id: 'private-plugin', + }); + + const result = await runPluginsCommand({ + name: 'plugins', + action: 'register', + packageName: 'private-plugin', + }, { + cwd: () => workspaceRoot, + homeDir, + resolveGlobalPackageRoots: () => [globalRoot], + }); + + expect(result).toEqual({ + exitCode: 0, + output: 'Registered private-plugin in ~/.codegraphy/plugins.json.', + }); + expect(readCodeGraphyInstalledPluginCache({ homeDir }).plugins.map(plugin => plugin.package)).toEqual([ + 'private-plugin', + ]); + await expect(fs.stat(path.join(workspaceRoot, '.codegraphy', 'settings.json'))).rejects.toMatchObject({ + code: 'ENOENT', + }); + }); + + it('links a private local plugin package without requiring global npm install', async () => { + const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-user-home-')); + const packageRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-private-package-')); + const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-workspace-')); + await fs.writeFile( + path.join(packageRoot, 'package.json'), + `${JSON.stringify({ + name: '@acme/codegraphy-private-plugin', + version: '0.1.0', + codegraphy: { + type: 'plugin', + apiVersion: '^3.0.0', + }, + }, null, 2)}\n`, + 'utf-8', + ); + await fs.writeFile( + path.join(packageRoot, 'codegraphy.json'), + `${JSON.stringify({ + id: 'acme.private', + name: 'Acme Private', + }, null, 2)}\n`, + 'utf-8', + ); + + const result = await runPluginsCommand({ + name: 'plugins', + action: 'link', + packageRoot, + }, { + cwd: () => workspaceRoot, + homeDir, + }); + + expect(result).toEqual({ + exitCode: 0, + output: `Linked @acme/codegraphy-private-plugin from ${packageRoot} into ~/.codegraphy/plugins.json.`, + }); + expect(readCodeGraphyInstalledPluginCache({ homeDir }).plugins).toEqual([ + expect.objectContaining({ + package: '@acme/codegraphy-private-plugin', + pluginId: 'acme.private', + packageRoot, + }), + ]); + await expect(fs.stat(path.join(workspaceRoot, '.codegraphy', 'settings.json'))).rejects.toMatchObject({ + code: 'ENOENT', + }); + }); + + it('rejects plugin packages that do not declare a static plugin id', async () => { + const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-user-home-')); + const globalRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-global-root-')); + const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-workspace-')); + await createPackage(globalRoot, 'private-plugin', { + version: '4.5.6', + codegraphy: { + type: 'plugin', + apiVersion: '^3.0.0', + }, + }); + + const result = await runPluginsCommand({ + name: 'plugins', + action: 'register', + packageName: 'private-plugin', + }, { + cwd: () => workspaceRoot, + homeDir, + resolveGlobalPackageRoots: () => [globalRoot], + }); + + expect(result).toEqual({ + exitCode: 1, + output: "Package 'private-plugin' is missing codegraphy.json with a static plugin id.", + }); + expect(readCodeGraphyInstalledPluginCache({ homeDir }).plugins).toEqual([]); + }); +}); diff --git a/packages/core/tests/cli/plugins/commandWorkspaceState.test.ts b/packages/core/tests/cli/plugins/commandWorkspaceState.test.ts new file mode 100644 index 000000000..5f420b061 --- /dev/null +++ b/packages/core/tests/cli/plugins/commandWorkspaceState.test.ts @@ -0,0 +1,163 @@ +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import { + CODEGRAPHY_MARKDOWN_PLUGIN_ID, + CODEGRAPHY_MARKDOWN_PLUGIN_PACKAGE_NAME, + readCodeGraphyWorkspaceSettings, + writeCodeGraphyInstalledPluginCache, +} from '../../../src'; +import { runPluginsCommand } from '../../../src/cli/plugins/command'; +import { createPluginRecord } from './commandFixture'; + +describe('plugins/command workspace state', () => { + it('enables and disables a cached plugin for one workspace', async () => { + const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-user-home-')); + const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-workspace-plugin-')); + const record = createPluginRecord( + '@codegraphy-dev/plugin-vue', + '/global/@codegraphy-dev/plugin-vue', + 'codegraphy.vue', + ); + writeCodeGraphyInstalledPluginCache({ + version: 1, + plugins: [record], + }, { homeDir }); + + const enableResult = await runPluginsCommand({ + name: 'plugins', + action: 'enable', + packageName: '@codegraphy-dev/plugin-vue', + workspacePath: workspaceRoot, + }, { homeDir }); + + expect(enableResult).toEqual({ + exitCode: 0, + output: `Enabled codegraphy.vue for ${workspaceRoot}. Run \`codegraphy index ${workspaceRoot}\` to refresh the Graph Cache.`, + }); + expect(readCodeGraphyWorkspaceSettings(workspaceRoot).plugins).toEqual([ + { id: CODEGRAPHY_MARKDOWN_PLUGIN_ID, enabled: true }, + { + id: 'codegraphy.vue', + enabled: true, + options: { includeTests: true }, + }, + ]); + + const disableResult = await runPluginsCommand({ + name: 'plugins', + action: 'disable', + packageName: '@codegraphy-dev/plugin-vue', + workspacePath: workspaceRoot, + }, { homeDir }); + + expect(disableResult).toEqual({ + exitCode: 0, + output: `Disabled codegraphy.vue for ${workspaceRoot}. Run \`codegraphy index ${workspaceRoot}\` to refresh the Graph Cache.`, + }); + expect(readCodeGraphyWorkspaceSettings(workspaceRoot).plugins).toEqual([ + { id: CODEGRAPHY_MARKDOWN_PLUGIN_ID, enabled: true }, + { + id: 'codegraphy.vue', + enabled: false, + options: { includeTests: true }, + }, + ]); + }); + + it('enables bundled Markdown without requiring it in the user installed plugin cache', async () => { + const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-user-home-')); + const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-workspace-markdown-')); + + const disableResult = await runPluginsCommand({ + name: 'plugins', + action: 'disable', + packageName: CODEGRAPHY_MARKDOWN_PLUGIN_PACKAGE_NAME, + workspacePath: workspaceRoot, + }, { homeDir }); + expect(disableResult.exitCode).toBe(0); + expect(readCodeGraphyWorkspaceSettings(workspaceRoot).plugins).toEqual([{ + id: CODEGRAPHY_MARKDOWN_PLUGIN_ID, + enabled: false, + }]); + + const enableResult = await runPluginsCommand({ + name: 'plugins', + action: 'enable', + packageName: CODEGRAPHY_MARKDOWN_PLUGIN_PACKAGE_NAME, + workspacePath: workspaceRoot, + }, { homeDir }); + + expect(enableResult).toEqual({ + exitCode: 0, + output: `Enabled ${CODEGRAPHY_MARKDOWN_PLUGIN_ID} for ${workspaceRoot}. Run \`codegraphy index ${workspaceRoot}\` to refresh the Graph Cache.`, + }); + expect(readCodeGraphyWorkspaceSettings(workspaceRoot).plugins).toEqual([{ + id: CODEGRAPHY_MARKDOWN_PLUGIN_ID, + enabled: true, + }]); + }); + + it('lists disabled bundled Markdown without requiring it in the user installed plugin cache', async () => { + const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-user-home-')); + const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-workspace-markdown-')); + + await runPluginsCommand({ + name: 'plugins', + action: 'disable', + packageName: CODEGRAPHY_MARKDOWN_PLUGIN_PACKAGE_NAME, + workspacePath: workspaceRoot, + }, { homeDir }); + + const result = await runPluginsCommand({ + name: 'plugins', + action: 'list', + workspacePath: workspaceRoot, + }, { homeDir }); + + expect(result.output).toContain('Enabled in workspace:\nnone'); + expect(result.output).toContain('Registered but disabled:'); + expect(result.output).toContain(`- ${CODEGRAPHY_MARKDOWN_PLUGIN_ID}`); + }); + + it('lists enabled workspace plugins separately from registered disabled plugins', async () => { + const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-user-home-')); + const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-workspace-plugin-')); + writeCodeGraphyInstalledPluginCache({ + version: 1, + plugins: [ + createPluginRecord( + '@codegraphy-dev/plugin-markdown', + '/global/@codegraphy-dev/plugin-markdown', + CODEGRAPHY_MARKDOWN_PLUGIN_ID, + ), + createPluginRecord( + '@codegraphy-dev/plugin-vue', + '/global/@codegraphy-dev/plugin-vue', + 'codegraphy.vue', + ), + ], + }, { homeDir }); + await runPluginsCommand({ + name: 'plugins', + action: 'enable', + packageName: '@codegraphy-dev/plugin-vue', + workspacePath: workspaceRoot, + }, { homeDir }); + + const result = await runPluginsCommand({ + name: 'plugins', + action: 'list', + workspacePath: workspaceRoot, + }, { homeDir }); + + expect(result.output).toContain(`CodeGraphy plugins for ${workspaceRoot}`); + expect(result.output).toContain('Enabled in workspace:'); + expect(result.output).toContain(`1. ${CODEGRAPHY_MARKDOWN_PLUGIN_ID}`); + expect(result.output).toContain('2. codegraphy.vue'); + expect(result.output).toContain('Registered but disabled:'); + expect(result.output).not.toContain(`- ${CODEGRAPHY_MARKDOWN_PLUGIN_ID}`); + }); +}); diff --git a/packages/core/tests/cli/plugins/enable.test.ts b/packages/core/tests/cli/plugins/enable.test.ts index de3bc1c7d..88ffb9b32 100644 --- a/packages/core/tests/cli/plugins/enable.test.ts +++ b/packages/core/tests/cli/plugins/enable.test.ts @@ -45,7 +45,7 @@ describe('cli/plugins/enable', () => { package: '@codegraphy-dev/plugin-vue', pluginId: 'codegraphy.vue', version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', disclosures: [], packageRoot: '/global/plugin-vue', }; diff --git a/packages/core/tests/indexing/defaultPlugins.test.ts b/packages/core/tests/indexing/defaultPlugins.test.ts index 956a32791..a7a891d2f 100644 --- a/packages/core/tests/indexing/defaultPlugins.test.ts +++ b/packages/core/tests/indexing/defaultPlugins.test.ts @@ -42,7 +42,7 @@ function plugin(id: string): IPlugin { id, name: id, version: '1.0.0', - apiVersion: '2', + apiVersion: '3', supportedExtensions: ['.test'], }; } diff --git a/packages/core/tests/indexing/workspace.test.ts b/packages/core/tests/indexing/workspace.test.ts deleted file mode 100644 index 775efb4ab..000000000 --- a/packages/core/tests/indexing/workspace.test.ts +++ /dev/null @@ -1,490 +0,0 @@ -import * as fs from 'node:fs/promises'; -import * as os from 'node:os'; -import * as path from 'node:path'; -import type { - IFileAnalysisResult, - IPlugin, - IPluginAnalysisContext, -} from '@codegraphy-dev/plugin-api'; -import { describe, expect, it, vi } from 'vitest'; - -import { - CODEGRAPHY_MARKDOWN_PLUGIN_ID, - createCodeGraphyWorkspaceEngine, - indexCodeGraphyWorkspace, - readGraphCacheStatus, - readCodeGraphyWorkspaceStatus, - readCodeGraphyWorkspaceSettings, - readWorkspaceAnalysisDatabaseSnapshot, - writeCodeGraphyInstalledPluginCache, - writeCodeGraphyWorkspaceSettings, -} from '../../src'; - -async function createWorkspace(): Promise { - const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-core-index-')); - await fs.writeFile(path.join(workspaceRoot, 'source.txt'), 'target.txt\n', 'utf-8'); - await fs.writeFile(path.join(workspaceRoot, 'target.txt'), 'done\n', 'utf-8'); - return workspaceRoot; -} - -async function createPackageBackedPluginPackage( - packageRoot: string, -): Promise { - await fs.mkdir(packageRoot, { recursive: true }); - await fs.writeFile( - path.join(packageRoot, 'package.json'), - JSON.stringify({ - name: '@acme/codegraphy-plugin-options', - version: '1.0.0', - type: 'module', - exports: './plugin.js', - codegraphy: { - type: 'plugin', - apiVersion: '^2.0.0', - defaultOptions: { - targetFile: 'target.txt', - }, - }, - }, null, 2), - 'utf-8', - ); - await fs.writeFile( - path.join(packageRoot, 'plugin.js'), - ` -let preAnalyzeTargetFile = ''; - -export default function createPlugin() { - return { - id: 'acme.options', - name: 'Options Plugin', - version: '1.0.0', - apiVersion: '^2.0.0', - supportedExtensions: ['.txt'], - sources: [{ - id: 'configured-target', - name: 'Configured Target', - description: 'References the target file configured in plugin options.' - }], - async onPreAnalyze(_files, _workspaceRoot, context) { - preAnalyzeTargetFile = typeof context?.options?.targetFile === 'string' - ? context.options.targetFile - : ''; - }, - async analyzeFile(filePath, _content, workspaceRoot, context) { - const targetFile = typeof context?.options?.targetFile === 'string' - ? context.options.targetFile - : ''; - if (!filePath.endsWith('source.txt') || targetFile.length === 0 || targetFile !== preAnalyzeTargetFile) { - return { filePath, relations: [] }; - } - - const targetPath = new URL(targetFile, \`file://\${workspaceRoot}/\`).pathname; - return { - filePath, - relations: [{ - kind: 'reference', - sourceId: 'configured-target', - fromFilePath: filePath, - toFilePath: targetPath, - resolvedPath: targetPath, - specifier: targetFile - }] - }; - } - }; -} -`, - 'utf-8', - ); -} - -function createTextPlugin(calls: { - onPreAnalyze: ReturnType; - onPostAnalyze: ReturnType; - onWorkspaceReady: ReturnType; - analyzeFile: ReturnType; -}): IPlugin { - return { - id: 'codegraphy.test-text', - name: 'Test Text', - version: '1.0.0', - apiVersion: '^2.0.0', - supportedExtensions: ['.txt'], - sources: [{ - id: 'line-reference', - name: 'Line Reference', - description: 'References target files from text lines.', - }], - async onPreAnalyze(files, workspaceRoot) { - calls.onPreAnalyze(files, workspaceRoot); - }, - onPostAnalyze(graph) { - calls.onPostAnalyze(graph); - }, - onWorkspaceReady(graph) { - calls.onWorkspaceReady(graph); - }, - async analyzeFile(filePath, content, workspaceRoot) { - calls.analyzeFile(filePath, content, workspaceRoot); - - if (path.basename(filePath) !== 'source.txt') { - return { filePath, relations: [] }; - } - - const targetPath = path.join(workspaceRoot, content.trim()); - return { - filePath, - relations: [{ - kind: 'import', - sourceId: 'line-reference', - fromFilePath: filePath, - toFilePath: targetPath, - resolvedPath: targetPath, - specifier: content.trim(), - }], - }; - }, - }; -} - -describe('indexCodeGraphyWorkspace', () => { - it('indexes an explicit folder through core plugins and writes the workspace Graph Cache', async () => { - const workspaceRoot = await createWorkspace(); - const calls = { - onPreAnalyze: vi.fn(), - onPostAnalyze: vi.fn(), - onWorkspaceReady: vi.fn(), - analyzeFile: vi.fn(), - }; - - const result = await indexCodeGraphyWorkspace({ - workspaceRoot, - plugins: [createTextPlugin(calls)], - includeCorePlugins: false, - }); - - expect(result.workspaceRoot).toBe(path.resolve(workspaceRoot)); - expect(result.graph.nodes.map(node => node.id)).toEqual( - expect.arrayContaining(['source.txt', 'target.txt']), - ); - expect(result.graph.edges).toContainEqual( - expect.objectContaining({ - from: 'source.txt', - to: 'target.txt', - kind: 'import', - }), - ); - expect(calls.onPreAnalyze).toHaveBeenCalledWith( - expect.arrayContaining([ - expect.objectContaining({ relativePath: 'source.txt' }), - expect.objectContaining({ relativePath: 'target.txt' }), - ]), - path.resolve(workspaceRoot), - ); - expect(calls.analyzeFile).toHaveBeenCalledTimes(2); - expect(calls.onPostAnalyze).toHaveBeenCalledWith(result.graph); - expect(calls.onWorkspaceReady).toHaveBeenCalledWith(result.graph); - expect(readGraphCacheStatus(workspaceRoot).state).toBe('available'); - expect(readWorkspaceAnalysisDatabaseSnapshot(workspaceRoot).files.map(file => file.filePath)).toEqual( - expect.arrayContaining(['source.txt', 'target.txt']), - ); - }); - - it('keeps orphan files in the core index regardless of graph view settings', async () => { - const workspaceRoot = await createWorkspace(); - await fs.writeFile(path.join(workspaceRoot, 'orphan.txt'), 'unlinked\n', 'utf-8'); - writeCodeGraphyWorkspaceSettings(workspaceRoot, { - ...readCodeGraphyWorkspaceSettings(workspaceRoot), - showOrphans: false, - }); - - const result = await indexCodeGraphyWorkspace({ - workspaceRoot, - plugins: [createTextPlugin({ - onPreAnalyze: vi.fn(), - onPostAnalyze: vi.fn(), - onWorkspaceReady: vi.fn(), - analyzeFile: vi.fn(), - })], - includeCorePlugins: false, - }); - - expect(result.graph.nodes.map(node => node.id)).toEqual( - expect.arrayContaining(['source.txt', 'target.txt', 'orphan.txt']), - ); - expect(result.graph.nodes.map(node => node.id)).not.toContain('.codegraphy'); - }); - - it('keeps indexing state in core so changed files update the graph without full indexing', async () => { - const workspaceRoot = await createWorkspace(); - await fs.writeFile(path.join(workspaceRoot, 'target-2.txt'), 'done\n', 'utf-8'); - const calls = { - onPreAnalyze: vi.fn(), - onPostAnalyze: vi.fn(), - onWorkspaceReady: vi.fn(), - analyzeFile: vi.fn(), - onFilesChanged: vi.fn<(files: Array<{ relativePath: string }>) => Promise>(async () => []), - }; - const plugin = { - ...createTextPlugin(calls), - async onFilesChanged(files: Array<{ relativePath: string }>) { - calls.onFilesChanged(files); - return []; - }, - }; - const engine = createCodeGraphyWorkspaceEngine({ - workspaceRoot, - plugins: [plugin], - includeCorePlugins: false, - }); - - const initial = await engine.index(); - await fs.writeFile(path.join(workspaceRoot, 'source.txt'), 'target-2.txt\n', 'utf-8'); - const refreshed = await engine.applyChangedFiles([ - 'source.txt', - ]); - - expect(initial.graph.edges).toContainEqual( - expect.objectContaining({ - from: 'source.txt', - to: 'target.txt', - }), - ); - expect(refreshed.graph.edges).toContainEqual( - expect.objectContaining({ - from: 'source.txt', - to: 'target-2.txt', - }), - ); - expect(refreshed.graph.edges).not.toContainEqual( - expect.objectContaining({ - from: 'source.txt', - to: 'target.txt', - }), - ); - expect(calls.onFilesChanged).toHaveBeenCalledWith([ - expect.objectContaining({ relativePath: 'source.txt' }), - ]); - expect(calls.onPostAnalyze).toHaveBeenCalledTimes(2); - expect(calls.onPostAnalyze).toHaveBeenLastCalledWith(refreshed.graph); - expect(calls.onWorkspaceReady).toHaveBeenCalledTimes(1); - expect(calls.analyzeFile).toHaveBeenCalledTimes(4); - expect(calls.analyzeFile).toHaveBeenLastCalledWith( - path.join(workspaceRoot, 'source.txt'), - 'target-2.txt\n', - path.resolve(workspaceRoot), - ); - expect(readCodeGraphyWorkspaceStatus(workspaceRoot, { plugins: [plugin] }).state).toBe('fresh'); - }); - - it('passes provided plugin entry options through the core workspace engine', async () => { - const workspaceRoot = await createWorkspace(); - const analyzeFile = vi.fn(async ( - filePath: string, - _content: string, - rootPath: string, - context?: IPluginAnalysisContext, - ): Promise => { - if (!filePath.endsWith('source.txt')) { - return { filePath, relations: [] }; - } - - const targetFile = String(context?.options?.targetFile ?? ''); - const targetPath = path.join(rootPath, targetFile); - return { - filePath, - relations: [{ - kind: 'reference', - sourceId: 'configured-target', - fromFilePath: filePath, - toFilePath: targetPath, - resolvedPath: targetPath, - specifier: targetFile, - }], - }; - }); - const engine = createCodeGraphyWorkspaceEngine({ - workspaceRoot, - includeCorePlugins: false, - plugins: [{ - plugin: { - id: 'acme.configured', - name: 'Configured Plugin', - version: '1.0.0', - apiVersion: '^2.0.0', - supportedExtensions: ['.txt'], - sources: [{ - id: 'configured-target', - name: 'Configured Target', - description: 'References the configured target file.', - }], - analyzeFile, - }, - options: { - targetFile: 'target.txt', - }, - }], - }); - - const result = await engine.index(); - - expect(result.graph.edges).toContainEqual( - expect.objectContaining({ - from: 'source.txt', - to: 'target.txt', - kind: 'reference', - }), - ); - expect(analyzeFile).toHaveBeenCalledWith( - path.join(workspaceRoot, 'source.txt'), - 'target.txt\n', - path.resolve(workspaceRoot), - expect.objectContaining({ - options: { - targetFile: 'target.txt', - }, - }), - ); - }); - - it('enables and runs the Markdown plugin by default for a new workspace', async () => { - const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-core-markdown-')); - await fs.writeFile(path.join(workspaceRoot, 'Home.md'), 'See [[Target.md]].\n', 'utf-8'); - await fs.writeFile(path.join(workspaceRoot, 'Target.md'), 'Done.\n', 'utf-8'); - - const result = await indexCodeGraphyWorkspace({ - workspaceRoot, - }); - - expect(readCodeGraphyWorkspaceSettings(workspaceRoot).plugins).toEqual([{ - id: CODEGRAPHY_MARKDOWN_PLUGIN_ID, - enabled: true, - }]); - expect(result.graph.edges).toContainEqual( - expect.objectContaining({ - from: 'Home.md', - to: 'Target.md', - kind: 'reference', - }), - ); - }); - - it('loads enabled npm plugin packages and delivers workspace options to plugin hooks', async () => { - const workspaceRoot = await createWorkspace(); - const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-core-home-')); - const packageRoot = path.join( - await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-global-package-')), - 'node_modules', - '@acme', - 'codegraphy-plugin-options', - ); - - await createPackageBackedPluginPackage(packageRoot); - writeCodeGraphyInstalledPluginCache({ - version: 1, - plugins: [{ - package: '@acme/codegraphy-plugin-options', - version: '1.0.0', - apiVersion: '^2.0.0', - disclosures: [], - packageRoot, - pluginId: 'acme.options', - defaultOptions: { - targetFile: 'target.txt', - }, - }], - }, { homeDir }); - writeCodeGraphyWorkspaceSettings(workspaceRoot, { - ...readCodeGraphyWorkspaceSettings(workspaceRoot), - plugins: [{ - id: 'acme.options', - enabled: true, - options: { - targetFile: 'target.txt', - }, - }], - }); - - const result = await indexCodeGraphyWorkspace({ - workspaceRoot, - userHomeDir: homeDir, - }); - - expect(result.graph.edges).toContainEqual( - expect.objectContaining({ - from: 'source.txt', - to: 'target.txt', - kind: 'reference', - }), - ); - expect(readCodeGraphyWorkspaceStatus(workspaceRoot, { userHomeDir: homeDir })).toMatchObject({ - state: 'fresh', - staleReasons: [], - }); - }); - - it('honors disabled filter patterns from enabled workspace plugin entries', async () => { - const workspaceRoot = await createWorkspace(); - await fs.writeFile(path.join(workspaceRoot, 'ignored.txt'), 'target.txt\n', 'utf-8'); - - const calls = { - onPreAnalyze: vi.fn(), - onPostAnalyze: vi.fn(), - onWorkspaceReady: vi.fn(), - analyzeFile: vi.fn(), - }; - - const result = await indexCodeGraphyWorkspace({ - workspaceRoot, - includeCorePlugins: false, - plugins: [{ - ...createTextPlugin(calls), - defaultFilters: ['**/ignored.txt'], - }], - settings: { - ...readCodeGraphyWorkspaceSettings(workspaceRoot), - plugins: [{ - id: 'codegraphy.test-text', - enabled: true, - disabledFilterPatterns: ['**/ignored.txt'], - }], - }, - }); - - expect(result.files.map(file => file.relativePath)).toContain('ignored.txt'); - expect(calls.analyzeFile).toHaveBeenCalledWith( - path.join(workspaceRoot, 'ignored.txt'), - 'target.txt\n', - path.resolve(workspaceRoot), - ); - }); - - it('keeps settings-disabled provided plugins unloaded during indexing', async () => { - const workspaceRoot = await createWorkspace(); - const calls = { - onPreAnalyze: vi.fn(), - onPostAnalyze: vi.fn(), - onWorkspaceReady: vi.fn(), - analyzeFile: vi.fn(), - }; - - const result = await indexCodeGraphyWorkspace({ - workspaceRoot, - includeCorePlugins: false, - plugins: [createTextPlugin(calls)], - settings: { - ...readCodeGraphyWorkspaceSettings(workspaceRoot), - plugins: [{ - id: 'codegraphy.test-text', - enabled: false, - }], - }, - }); - - expect(calls.onPreAnalyze).not.toHaveBeenCalled(); - expect(calls.analyzeFile).not.toHaveBeenCalled(); - expect(calls.onPostAnalyze).not.toHaveBeenCalled(); - expect(calls.onWorkspaceReady).not.toHaveBeenCalled(); - expect(result.graph.edges).toEqual([]); - }); -}); diff --git a/packages/core/tests/indexing/workspaceFixture.ts b/packages/core/tests/indexing/workspaceFixture.ts new file mode 100644 index 000000000..db5006a12 --- /dev/null +++ b/packages/core/tests/indexing/workspaceFixture.ts @@ -0,0 +1,132 @@ +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import type { IPlugin } from '@codegraphy-dev/plugin-api'; +import { vi } from 'vitest'; + +export async function createWorkspace(): Promise { + const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-core-index-')); + await fs.writeFile(path.join(workspaceRoot, 'source.txt'), 'target.txt\n', 'utf-8'); + await fs.writeFile(path.join(workspaceRoot, 'target.txt'), 'done\n', 'utf-8'); + return workspaceRoot; +} + +export async function createPackageBackedPluginPackage( + packageRoot: string, +): Promise { + await fs.mkdir(packageRoot, { recursive: true }); + await fs.writeFile( + path.join(packageRoot, 'package.json'), + JSON.stringify({ + name: '@acme/codegraphy-plugin-options', + version: '1.0.0', + type: 'module', + exports: './plugin.js', + codegraphy: { + type: 'plugin', + apiVersion: '^3.0.0', + defaultOptions: { + targetFile: 'target.txt', + }, + }, + }, null, 2), + 'utf-8', + ); + await fs.writeFile( + path.join(packageRoot, 'plugin.js'), + ` +let preAnalyzeTargetFile = ''; + +export default function createPlugin() { + return { + id: 'acme.options', + name: 'Options Plugin', + version: '1.0.0', + apiVersion: '^3.0.0', + supportedExtensions: ['.txt'], + sources: [{ + id: 'configured-target', + name: 'Configured Target', + description: 'References the target file configured in plugin options.' + }], + async onPreAnalyze(_files, _workspaceRoot, context) { + preAnalyzeTargetFile = typeof context?.options?.targetFile === 'string' + ? context.options.targetFile + : ''; + }, + async analyzeFile(filePath, _content, workspaceRoot, context) { + const targetFile = typeof context?.options?.targetFile === 'string' + ? context.options.targetFile + : ''; + if (!filePath.endsWith('source.txt') || targetFile.length === 0 || targetFile !== preAnalyzeTargetFile) { + return { filePath, relations: [] }; + } + + const targetPath = new URL(targetFile, \`file://\${workspaceRoot}/\`).pathname; + return { + filePath, + relations: [{ + kind: 'reference', + sourceId: 'configured-target', + fromFilePath: filePath, + toFilePath: targetPath, + resolvedPath: targetPath, + specifier: targetFile + }] + }; + } + }; +} +`, + 'utf-8', + ); +} + +export function createTextPlugin(calls: { + onPreAnalyze: ReturnType; + onPostAnalyze: ReturnType; + onWorkspaceReady: ReturnType; + analyzeFile: ReturnType; +}): IPlugin { + return { + id: 'codegraphy.test-text', + name: 'Test Text', + version: '1.0.0', + apiVersion: '^3.0.0', + supportedExtensions: ['.txt'], + sources: [{ + id: 'line-reference', + name: 'Line Reference', + description: 'References target files from text lines.', + }], + async onPreAnalyze(files, workspaceRoot) { + calls.onPreAnalyze(files, workspaceRoot); + }, + onPostAnalyze(graph) { + calls.onPostAnalyze(graph); + }, + onWorkspaceReady(graph) { + calls.onWorkspaceReady(graph); + }, + async analyzeFile(filePath, content, workspaceRoot) { + calls.analyzeFile(filePath, content, workspaceRoot); + + if (path.basename(filePath) !== 'source.txt') { + return { filePath, relations: [] }; + } + + const targetPath = path.join(workspaceRoot, content.trim()); + return { + filePath, + relations: [{ + kind: 'import', + sourceId: 'line-reference', + fromFilePath: filePath, + toFilePath: targetPath, + resolvedPath: targetPath, + specifier: content.trim(), + }], + }; + }, + }; +} diff --git a/packages/core/tests/indexing/workspaceLifecycle.test.ts b/packages/core/tests/indexing/workspaceLifecycle.test.ts new file mode 100644 index 000000000..5b0a8868e --- /dev/null +++ b/packages/core/tests/indexing/workspaceLifecycle.test.ts @@ -0,0 +1,145 @@ +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { describe, expect, it, vi } from 'vitest'; + +import { + createCodeGraphyWorkspaceEngine, + indexCodeGraphyWorkspace, + readGraphCacheStatus, + readCodeGraphyWorkspaceStatus, + readCodeGraphyWorkspaceSettings, + readWorkspaceAnalysisDatabaseSnapshot, + writeCodeGraphyWorkspaceSettings, +} from '../../src'; +import { createTextPlugin, createWorkspace } from './workspaceFixture'; + +describe('indexCodeGraphyWorkspace indexing lifecycle', () => { + it('indexes an explicit folder through core plugins and writes the workspace Graph Cache', async () => { + const workspaceRoot = await createWorkspace(); + const calls = { + onPreAnalyze: vi.fn(), + onPostAnalyze: vi.fn(), + onWorkspaceReady: vi.fn(), + analyzeFile: vi.fn(), + }; + + const result = await indexCodeGraphyWorkspace({ + workspaceRoot, + plugins: [createTextPlugin(calls)], + includeCorePlugins: false, + }); + + expect(result.workspaceRoot).toBe(path.resolve(workspaceRoot)); + expect(result.graph.nodes.map(node => node.id)).toEqual( + expect.arrayContaining(['source.txt', 'target.txt']), + ); + expect(result.graph.edges).toContainEqual( + expect.objectContaining({ + from: 'source.txt', + to: 'target.txt', + kind: 'import', + }), + ); + expect(calls.onPreAnalyze).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ relativePath: 'source.txt' }), + expect.objectContaining({ relativePath: 'target.txt' }), + ]), + path.resolve(workspaceRoot), + ); + expect(calls.analyzeFile).toHaveBeenCalledTimes(2); + expect(calls.onPostAnalyze).toHaveBeenCalledWith(result.graph); + expect(calls.onWorkspaceReady).toHaveBeenCalledWith(result.graph); + expect(readGraphCacheStatus(workspaceRoot).state).toBe('available'); + expect(readWorkspaceAnalysisDatabaseSnapshot(workspaceRoot).files.map(file => file.filePath)).toEqual( + expect.arrayContaining(['source.txt', 'target.txt']), + ); + }); + + it('keeps orphan files in the core index regardless of graph view settings', async () => { + const workspaceRoot = await createWorkspace(); + await fs.writeFile(path.join(workspaceRoot, 'orphan.txt'), 'unlinked\n', 'utf-8'); + writeCodeGraphyWorkspaceSettings(workspaceRoot, { + ...readCodeGraphyWorkspaceSettings(workspaceRoot), + showOrphans: false, + }); + + const result = await indexCodeGraphyWorkspace({ + workspaceRoot, + plugins: [createTextPlugin({ + onPreAnalyze: vi.fn(), + onPostAnalyze: vi.fn(), + onWorkspaceReady: vi.fn(), + analyzeFile: vi.fn(), + })], + includeCorePlugins: false, + }); + + expect(result.graph.nodes.map(node => node.id)).toEqual( + expect.arrayContaining(['source.txt', 'target.txt', 'orphan.txt']), + ); + expect(result.graph.nodes.map(node => node.id)).not.toContain('.codegraphy'); + }); + + it('keeps indexing state in core so changed files update the graph without full indexing', async () => { + const workspaceRoot = await createWorkspace(); + await fs.writeFile(path.join(workspaceRoot, 'target-2.txt'), 'done\n', 'utf-8'); + const calls = { + onPreAnalyze: vi.fn(), + onPostAnalyze: vi.fn(), + onWorkspaceReady: vi.fn(), + analyzeFile: vi.fn(), + onFilesChanged: vi.fn<(files: Array<{ relativePath: string }>) => Promise>(async () => []), + }; + const plugin = { + ...createTextPlugin(calls), + async onFilesChanged(files: Array<{ relativePath: string }>) { + calls.onFilesChanged(files); + return []; + }, + }; + const engine = createCodeGraphyWorkspaceEngine({ + workspaceRoot, + plugins: [plugin], + includeCorePlugins: false, + }); + + const initial = await engine.index(); + await fs.writeFile(path.join(workspaceRoot, 'source.txt'), 'target-2.txt\n', 'utf-8'); + const refreshed = await engine.applyChangedFiles([ + 'source.txt', + ]); + + expect(initial.graph.edges).toContainEqual( + expect.objectContaining({ + from: 'source.txt', + to: 'target.txt', + }), + ); + expect(refreshed.graph.edges).toContainEqual( + expect.objectContaining({ + from: 'source.txt', + to: 'target-2.txt', + }), + ); + expect(refreshed.graph.edges).not.toContainEqual( + expect.objectContaining({ + from: 'source.txt', + to: 'target.txt', + }), + ); + expect(calls.onFilesChanged).toHaveBeenCalledWith([ + expect.objectContaining({ relativePath: 'source.txt' }), + ]); + expect(calls.onPostAnalyze).toHaveBeenCalledTimes(2); + expect(calls.onPostAnalyze).toHaveBeenLastCalledWith(refreshed.graph); + expect(calls.onWorkspaceReady).toHaveBeenCalledTimes(1); + expect(calls.analyzeFile).toHaveBeenCalledTimes(4); + expect(calls.analyzeFile).toHaveBeenLastCalledWith( + path.join(workspaceRoot, 'source.txt'), + 'target-2.txt\n', + path.resolve(workspaceRoot), + ); + expect(readCodeGraphyWorkspaceStatus(workspaceRoot, { plugins: [plugin] }).state).toBe('fresh'); + }); +}); diff --git a/packages/core/tests/indexing/workspacePlugins.test.ts b/packages/core/tests/indexing/workspacePlugins.test.ts new file mode 100644 index 000000000..332ef963e --- /dev/null +++ b/packages/core/tests/indexing/workspacePlugins.test.ts @@ -0,0 +1,236 @@ +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import type { + IFileAnalysisResult, + IPluginAnalysisContext, +} from '@codegraphy-dev/plugin-api'; +import { describe, expect, it, vi } from 'vitest'; + +import { + CODEGRAPHY_MARKDOWN_PLUGIN_ID, + createCodeGraphyWorkspaceEngine, + indexCodeGraphyWorkspace, + readCodeGraphyWorkspaceStatus, + readCodeGraphyWorkspaceSettings, + writeCodeGraphyInstalledPluginCache, + writeCodeGraphyWorkspaceSettings, +} from '../../src'; +import { + createPackageBackedPluginPackage, + createTextPlugin, + createWorkspace, +} from './workspaceFixture'; + +describe('indexCodeGraphyWorkspace plugin configuration', () => { + it('passes provided plugin entry options through the core workspace engine', async () => { + const workspaceRoot = await createWorkspace(); + const analyzeFile = vi.fn(async ( + filePath: string, + _content: string, + rootPath: string, + context?: IPluginAnalysisContext, + ): Promise => { + if (!filePath.endsWith('source.txt')) { + return { filePath, relations: [] }; + } + + const targetFile = String(context?.options?.targetFile ?? ''); + const targetPath = path.join(rootPath, targetFile); + return { + filePath, + relations: [{ + kind: 'reference', + sourceId: 'configured-target', + fromFilePath: filePath, + toFilePath: targetPath, + resolvedPath: targetPath, + specifier: targetFile, + }], + }; + }); + const engine = createCodeGraphyWorkspaceEngine({ + workspaceRoot, + includeCorePlugins: false, + plugins: [{ + plugin: { + id: 'acme.configured', + name: 'Configured Plugin', + version: '1.0.0', + apiVersion: '^3.0.0', + supportedExtensions: ['.txt'], + sources: [{ + id: 'configured-target', + name: 'Configured Target', + description: 'References the configured target file.', + }], + analyzeFile, + }, + options: { + targetFile: 'target.txt', + }, + }], + }); + + const result = await engine.index(); + + expect(result.graph.edges).toContainEqual( + expect.objectContaining({ + from: 'source.txt', + to: 'target.txt', + kind: 'reference', + }), + ); + expect(analyzeFile).toHaveBeenCalledWith( + path.join(workspaceRoot, 'source.txt'), + 'target.txt\n', + path.resolve(workspaceRoot), + expect.objectContaining({ + options: { + targetFile: 'target.txt', + }, + }), + ); + }); + + it('enables and runs the Markdown plugin by default for a new workspace', async () => { + const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-core-markdown-')); + await fs.writeFile(path.join(workspaceRoot, 'Home.md'), 'See [[Target.md]].\n', 'utf-8'); + await fs.writeFile(path.join(workspaceRoot, 'Target.md'), 'Done.\n', 'utf-8'); + + const result = await indexCodeGraphyWorkspace({ + workspaceRoot, + }); + + expect(readCodeGraphyWorkspaceSettings(workspaceRoot).plugins).toEqual([{ + id: CODEGRAPHY_MARKDOWN_PLUGIN_ID, + enabled: true, + }]); + expect(result.graph.edges).toContainEqual( + expect.objectContaining({ + from: 'Home.md', + to: 'Target.md', + kind: 'reference', + }), + ); + }); + + it('loads enabled npm plugin packages and delivers workspace options to plugin hooks', async () => { + const workspaceRoot = await createWorkspace(); + const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-core-home-')); + const packageRoot = path.join( + await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-global-package-')), + 'node_modules', + '@acme', + 'codegraphy-plugin-options', + ); + + await createPackageBackedPluginPackage(packageRoot); + writeCodeGraphyInstalledPluginCache({ + version: 1, + plugins: [{ + package: '@acme/codegraphy-plugin-options', + version: '1.0.0', + apiVersion: '^3.0.0', + disclosures: [], + packageRoot, + pluginId: 'acme.options', + defaultOptions: { + targetFile: 'target.txt', + }, + }], + }, { homeDir }); + writeCodeGraphyWorkspaceSettings(workspaceRoot, { + ...readCodeGraphyWorkspaceSettings(workspaceRoot), + plugins: [{ + id: 'acme.options', + enabled: true, + options: { + targetFile: 'target.txt', + }, + }], + }); + + const result = await indexCodeGraphyWorkspace({ + workspaceRoot, + userHomeDir: homeDir, + }); + + expect(result.graph.edges).toContainEqual( + expect.objectContaining({ + from: 'source.txt', + to: 'target.txt', + kind: 'reference', + }), + ); + expect(readCodeGraphyWorkspaceStatus(workspaceRoot, { userHomeDir: homeDir })).toMatchObject({ + state: 'fresh', + staleReasons: [], + }); + }); + + it('honors disabled filter patterns from enabled workspace plugin entries', async () => { + const workspaceRoot = await createWorkspace(); + await fs.writeFile(path.join(workspaceRoot, 'ignored.txt'), 'target.txt\n', 'utf-8'); + + const calls = { + onPreAnalyze: vi.fn(), + onPostAnalyze: vi.fn(), + onWorkspaceReady: vi.fn(), + analyzeFile: vi.fn(), + }; + + const result = await indexCodeGraphyWorkspace({ + workspaceRoot, + includeCorePlugins: false, + plugins: [{ + ...createTextPlugin(calls), + defaultFilters: ['**/ignored.txt'], + }], + settings: { + ...readCodeGraphyWorkspaceSettings(workspaceRoot), + plugins: [{ + id: 'codegraphy.test-text', + enabled: true, + disabledFilterPatterns: ['**/ignored.txt'], + }], + }, + }); + + expect(result.files.map(file => file.relativePath)).toContain('ignored.txt'); + expect(calls.analyzeFile).toHaveBeenCalledWith( + path.join(workspaceRoot, 'ignored.txt'), + 'target.txt\n', + path.resolve(workspaceRoot), + ); + }); + + it('keeps settings-disabled provided plugins unloaded during indexing', async () => { + const workspaceRoot = await createWorkspace(); + const calls = { + onPreAnalyze: vi.fn(), + onPostAnalyze: vi.fn(), + onWorkspaceReady: vi.fn(), + analyzeFile: vi.fn(), + }; + + const result = await indexCodeGraphyWorkspace({ + workspaceRoot, + includeCorePlugins: false, + plugins: [createTextPlugin(calls)], + settings: { + ...readCodeGraphyWorkspaceSettings(workspaceRoot), + plugins: [{ + id: 'codegraphy.test-text', + enabled: false, + }], + }, + }); + + expect(calls.onPreAnalyze).not.toHaveBeenCalled(); + expect(calls.analyzeFile).not.toHaveBeenCalled(); + expect(calls.onPostAnalyze).not.toHaveBeenCalled(); + expect(calls.onWorkspaceReady).not.toHaveBeenCalled(); + expect(result.graph.edges).toEqual([]); + }); +}); diff --git a/packages/core/tests/plugins/access/checks.test.ts b/packages/core/tests/plugins/access/checks.test.ts index 6b69ab5e5..be3bb215e 100644 --- a/packages/core/tests/plugins/access/checks.test.ts +++ b/packages/core/tests/plugins/access/checks.test.ts @@ -8,7 +8,7 @@ function createPlugin(overrides: Partial): IPlugin { id: 'codegraphy.test', name: 'Test Plugin', version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', supportedExtensions: [], ...overrides, }; diff --git a/packages/core/tests/plugins/activityState/model.test.ts b/packages/core/tests/plugins/activityState/model.test.ts index f6e08032f..dd086414e 100644 --- a/packages/core/tests/plugins/activityState/model.test.ts +++ b/packages/core/tests/plugins/activityState/model.test.ts @@ -19,7 +19,7 @@ describe('plugins/activityState/model', () => { { package: '@acme/codegraphy-vue-one', version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', disclosures: [], packageRoot: '/global/@acme/codegraphy-vue-one', pluginId: 'codegraphy.vue', @@ -27,7 +27,7 @@ describe('plugins/activityState/model', () => { { package: '@acme/codegraphy-vue-two', version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', disclosures: [], packageRoot: '/global/@acme/codegraphy-vue-two', pluginId: 'codegraphy.vue', @@ -75,7 +75,7 @@ describe('plugins/activityState/model', () => { installedPlugins: [{ package: '@codegraphy-dev/plugin-vue', version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', disclosures: [], packageRoot: '/global/@codegraphy-dev/plugin-vue', pluginId: 'codegraphy.vue', diff --git a/packages/core/tests/plugins/contributions.test.ts b/packages/core/tests/plugins/contributions.test.ts index 7734779e3..9cbedb028 100644 --- a/packages/core/tests/plugins/contributions.test.ts +++ b/packages/core/tests/plugins/contributions.test.ts @@ -9,7 +9,7 @@ describe('plugins/contributions', () => { id: 'first', name: 'First', version: '1.0.0', - apiVersion: '2', + apiVersion: '3', supportedExtensions: ['.ts'], contributeNodeTypes: () => [ { id: 'custom', label: 'First Custom', defaultColor: '#111111', defaultVisible: true }, @@ -19,7 +19,7 @@ describe('plugins/contributions', () => { id: 'second', name: 'Second', version: '1.0.0', - apiVersion: '2', + apiVersion: '3', supportedExtensions: ['.md'], contributeNodeTypes: () => [ { id: 'custom', label: 'Second Custom', defaultColor: '#222222', defaultVisible: true }, diff --git a/packages/core/tests/plugins/installedCache.test.ts b/packages/core/tests/plugins/installedCache.test.ts deleted file mode 100644 index b7760236a..000000000 --- a/packages/core/tests/plugins/installedCache.test.ts +++ /dev/null @@ -1,346 +0,0 @@ -import * as fs from 'node:fs/promises'; -import * as os from 'node:os'; -import * as path from 'node:path'; -import { describe, expect, it } from 'vitest'; - -import { - CODEGRAPHY_MARKDOWN_PLUGIN_ID, - disableCodeGraphyWorkspacePlugin, - enableCodeGraphyWorkspacePlugin, - linkCodeGraphyInstalledPluginPackage, - readCodeGraphyInstalledPluginCache, - readCodeGraphyWorkspaceSettings, - registerCodeGraphyInstalledPlugin, - writeCodeGraphyWorkspaceSettings, -} from '../../src'; -import { - readPackageManifest, - readRequiredPackageManifest, -} from '../../src/plugins/installedPluginCache/packageReader'; - -async function createPackage( - root: string, - packageName: string, - packageJson: Record, - descriptor?: Record, -): Promise { - const packageRoot = path.join(root, ...packageName.split('/')); - await fs.mkdir(packageRoot, { recursive: true }); - await fs.writeFile( - path.join(packageRoot, 'package.json'), - `${JSON.stringify({ name: packageName, ...packageJson }, null, 2)}\n`, - 'utf-8', - ); - if (descriptor) { - await fs.writeFile( - path.join(packageRoot, 'codegraphy.json'), - `${JSON.stringify(descriptor, null, 2)}\n`, - 'utf-8', - ); - } -} - -describe('CodeGraphy Plugin Registry', () => { - it('registers an explicitly named globally installed plugin package to the user-level registry', async () => { - const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-user-home-')); - const globalRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-global-root-')); - await createPackage(globalRoot, 'private-plugin', { - version: '4.5.6', - codegraphy: { - type: 'plugin', - apiVersion: '^2.0.0', - disclosures: ['externalProcesses'], - }, - }, { - id: 'private-plugin', - }); - - const record = await registerCodeGraphyInstalledPlugin({ - homeDir, - packageName: 'private-plugin', - globalPackageRoots: [globalRoot], - }); - - expect(record).toEqual({ - package: 'private-plugin', - version: '4.5.6', - apiVersion: '^2.0.0', - pluginId: 'private-plugin', - disclosures: ['externalProcesses'], - packageRoot: path.join(globalRoot, 'private-plugin'), - }); - expect(readCodeGraphyInstalledPluginCache({ homeDir }).plugins).toEqual([record]); - }); - - it('links a private local plugin package root into the user-level cache', async () => { - const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-user-home-')); - const packageRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-private-package-')); - await fs.writeFile( - path.join(packageRoot, 'package.json'), - `${JSON.stringify({ - name: '@acme/codegraphy-private-plugin', - version: '0.1.0', - codegraphy: { - type: 'plugin', - apiVersion: '^2.0.0', - disclosures: ['workspaceWrites'], - }, - }, null, 2)}\n`, - 'utf-8', - ); - await fs.writeFile( - path.join(packageRoot, 'codegraphy.json'), - `${JSON.stringify({ - id: 'acme.private', - name: 'Acme Private', - supportedExtensions: ['.acme'], - }, null, 2)}\n`, - 'utf-8', - ); - - const record = await linkCodeGraphyInstalledPluginPackage({ - homeDir, - packageRoot, - }); - - expect(record).toEqual({ - package: '@acme/codegraphy-private-plugin', - version: '0.1.0', - apiVersion: '^2.0.0', - pluginId: 'acme.private', - pluginName: 'Acme Private', - supportedExtensions: ['.acme'], - disclosures: ['workspaceWrites'], - packageRoot, - }); - expect(readCodeGraphyInstalledPluginCache({ homeDir }).plugins).toEqual([record]); - }); - - it('enables a cached plugin for one workspace without installing or importing it', async () => { - const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-workspace-plugin-')); - - enableCodeGraphyWorkspacePlugin(workspaceRoot, { - package: '@codegraphy-dev/plugin-vue', - version: '1.2.3', - apiVersion: '^2.0.0', - defaultOptions: { includeTests: true }, - disclosures: [], - packageRoot: '/global/@codegraphy-dev/plugin-vue', - pluginId: 'codegraphy.vue', - }); - - expect(readCodeGraphyInstalledPluginCache({ - homeDir: path.join(workspaceRoot, 'missing-home'), - }).plugins).toEqual([]); - expect(JSON.parse( - await fs.readFile(path.join(workspaceRoot, '.codegraphy', 'settings.json'), 'utf-8'), - ).plugins).toEqual([ - { id: CODEGRAPHY_MARKDOWN_PLUGIN_ID, enabled: true }, - { - id: 'codegraphy.vue', - enabled: true, - options: { includeTests: true }, - }, - ]); - }); - - it('merges default options into existing workspace plugin entries and disables by plugin id', async () => { - const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-workspace-plugin-')); - writeCodeGraphyWorkspaceSettings(workspaceRoot, { - ...readCodeGraphyWorkspaceSettings(workspaceRoot), - plugins: [{ - id: 'codegraphy.vue', - enabled: true, - options: { includeTests: false }, - }], - }); - - enableCodeGraphyWorkspacePlugin(workspaceRoot, { - package: '@codegraphy-dev/plugin-vue', - version: '1.2.3', - apiVersion: '^2.0.0', - defaultOptions: { includeTests: true, pythonVersion: '3.12' }, - disclosures: [], - packageRoot: '/global/@codegraphy-dev/plugin-vue', - pluginId: 'codegraphy.vue', - }); - - expect(readCodeGraphyWorkspaceSettings(workspaceRoot).plugins).toEqual([{ - id: 'codegraphy.vue', - enabled: true, - options: { includeTests: false, pythonVersion: '3.12' }, - }]); - - disableCodeGraphyWorkspacePlugin(workspaceRoot, 'codegraphy.vue'); - - expect(readCodeGraphyWorkspaceSettings(workspaceRoot).plugins).toEqual([{ - id: 'codegraphy.vue', - enabled: false, - options: { includeTests: false, pythonVersion: '3.12' }, - }]); - }); - - it('reads optional package manifests and returns null for missing or non-plugin packages', async () => { - const packageRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-package-reader-')); - await createPackage(packageRoot, '@codegraphy-dev/plugin-vue', { - version: '1.2.3', - codegraphy: { - type: 'plugin', - apiVersion: '^2.0.0', - }, - }, { - id: 'codegraphy.vue', - name: 'Vue', - supportedExtensions: ['.vue'], - updateImpact: { - toggle: 'reanalyze-plugin-files', - }, - }); - await createPackage(packageRoot, '@codegraphy-dev/not-a-plugin', { - version: '1.0.0', - }); - const pluginRoot = path.join(packageRoot, '@codegraphy-dev', 'plugin-vue'); - const nonPluginRoot = path.join(packageRoot, '@codegraphy-dev', 'not-a-plugin'); - - await expect(readPackageManifest(pluginRoot)).resolves.toEqual({ - package: '@codegraphy-dev/plugin-vue', - version: '1.2.3', - apiVersion: '^2.0.0', - pluginId: 'codegraphy.vue', - pluginName: 'Vue', - supportedExtensions: ['.vue'], - updateImpact: { - toggle: 'reanalyze-plugin-files', - }, - disclosures: [], - packageRoot: pluginRoot, - }); - await expect(readPackageManifest(nonPluginRoot)).resolves.toBeNull(); - await expect(readPackageManifest(path.join(pluginRoot, 'missing'))).resolves.toBeNull(); - }); - - it('requires plugin packages to declare a static plugin id in codegraphy.json', async () => { - const packageRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-package-reader-')); - await createPackage(packageRoot, '@codegraphy-dev/plugin-missing-id', { - version: '1.2.3', - codegraphy: { - type: 'plugin', - apiVersion: '^2.0.0', - }, - }); - const pluginRoot = path.join(packageRoot, '@codegraphy-dev', 'plugin-missing-id'); - - await expect(readPackageManifest(pluginRoot)).resolves.toBeNull(); - await expect(readRequiredPackageManifest('@codegraphy-dev/plugin-missing-id', pluginRoot)) - .rejects.toThrow("Package '@codegraphy-dev/plugin-missing-id' is missing codegraphy.json with a static plugin id."); - }); - - it('requires a matching CodeGraphy plugin package manifest', async () => { - const packageRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-package-reader-')); - await createPackage(packageRoot, '@codegraphy-dev/plugin-vue', { - version: '1.2.3', - codegraphy: { - type: 'plugin', - apiVersion: '^2.0.0', - disclosures: ['network'], - }, - }, { - id: 'codegraphy.vue', - }); - await createPackage(packageRoot, '@codegraphy-dev/not-a-plugin', { - version: '1.0.0', - }); - await createPackage(packageRoot, '@codegraphy-dev/plugin-ruby', { - version: '1.2.3', - codegraphy: { - type: 'plugin', - apiVersion: '^2.0.0', - }, - }, { - id: 'codegraphy.ruby', - }); - const pluginRoot = path.join(packageRoot, '@codegraphy-dev', 'plugin-vue'); - const nonPluginRoot = path.join(packageRoot, '@codegraphy-dev', 'not-a-plugin'); - const mismatchedRoot = path.join(packageRoot, '@codegraphy-dev', 'plugin-ruby'); - - await expect(readRequiredPackageManifest('@codegraphy-dev/plugin-vue', pluginRoot)).resolves.toEqual({ - package: '@codegraphy-dev/plugin-vue', - version: '1.2.3', - apiVersion: '^2.0.0', - pluginId: 'codegraphy.vue', - disclosures: ['network'], - packageRoot: pluginRoot, - }); - await expect(readRequiredPackageManifest('@codegraphy-dev/plugin-vue', path.join(pluginRoot, 'missing'))) - .rejects.toThrow("Run `npm i -g @codegraphy-dev/plugin-vue` first."); - await expect(readRequiredPackageManifest('@codegraphy-dev/not-a-plugin', nonPluginRoot)) - .rejects.toThrow("Package '@codegraphy-dev/not-a-plugin' is not a CodeGraphy plugin."); - await expect(readRequiredPackageManifest('@codegraphy-dev/plugin-vue', mismatchedRoot)) - .rejects.toThrow("Package '@codegraphy-dev/plugin-vue' resolved to CodeGraphy plugin '@codegraphy-dev/plugin-ruby'."); - }); - - it('omits empty option objects and persists disabled plugin intent', async () => { - const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-workspace-plugin-')); - - enableCodeGraphyWorkspacePlugin(workspaceRoot, { - package: '@codegraphy-dev/plugin-ruby', - version: '1.2.3', - apiVersion: '^2.0.0', - disclosures: [], - packageRoot: '/global/@codegraphy-dev/plugin-ruby', - pluginId: 'codegraphy.ruby', - }); - - expect(readCodeGraphyWorkspaceSettings(workspaceRoot).plugins).toContainEqual({ - id: 'codegraphy.ruby', - enabled: true, - }); - - disableCodeGraphyWorkspacePlugin(workspaceRoot, 'codegraphy.ruby'); - - expect(readCodeGraphyWorkspaceSettings(workspaceRoot).plugins).toContainEqual({ - id: 'codegraphy.ruby', - enabled: false, - }); - }); - - it('does not add empty options when re-enabling a plugin without defaults', async () => { - const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-workspace-plugin-')); - writeCodeGraphyWorkspaceSettings(workspaceRoot, { - ...readCodeGraphyWorkspaceSettings(workspaceRoot), - plugins: [{ id: 'codegraphy.ruby', enabled: false }], - }); - - enableCodeGraphyWorkspacePlugin(workspaceRoot, { - package: '@codegraphy-dev/plugin-ruby', - version: '1.2.3', - apiVersion: '^2.0.0', - disclosures: [], - packageRoot: '/global/@codegraphy-dev/plugin-ruby', - pluginId: 'codegraphy.ruby', - }); - - expect(readCodeGraphyWorkspaceSettings(workspaceRoot).plugins).toEqual([{ - id: 'codegraphy.ruby', - enabled: true, - }]); - }); - - it('keeps unrelated workspace plugins when disabling one plugin id', async () => { - const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-workspace-plugin-')); - writeCodeGraphyWorkspaceSettings(workspaceRoot, { - ...readCodeGraphyWorkspaceSettings(workspaceRoot), - plugins: [ - { id: 'codegraphy.vue', enabled: true }, - { id: 'codegraphy.ruby', enabled: true }, - ], - }); - - disableCodeGraphyWorkspacePlugin(workspaceRoot, 'codegraphy.vue'); - - expect(readCodeGraphyWorkspaceSettings(workspaceRoot).plugins).toEqual([ - { id: 'codegraphy.vue', enabled: false }, - { id: 'codegraphy.ruby', enabled: true }, - ]); - }); -}); diff --git a/packages/core/tests/plugins/installedCacheFixture.ts b/packages/core/tests/plugins/installedCacheFixture.ts new file mode 100644 index 000000000..7806cabe3 --- /dev/null +++ b/packages/core/tests/plugins/installedCacheFixture.ts @@ -0,0 +1,24 @@ +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; + +export async function createPackage( + root: string, + packageName: string, + packageJson: Record, + descriptor?: Record, +): Promise { + const packageRoot = path.join(root, ...packageName.split('/')); + await fs.mkdir(packageRoot, { recursive: true }); + await fs.writeFile( + path.join(packageRoot, 'package.json'), + `${JSON.stringify({ name: packageName, ...packageJson }, null, 2)}\n`, + 'utf-8', + ); + if (descriptor) { + await fs.writeFile( + path.join(packageRoot, 'codegraphy.json'), + `${JSON.stringify(descriptor, null, 2)}\n`, + 'utf-8', + ); + } +} diff --git a/packages/core/tests/plugins/installedCachePackageManifest.test.ts b/packages/core/tests/plugins/installedCachePackageManifest.test.ts new file mode 100644 index 000000000..f62fbc6c9 --- /dev/null +++ b/packages/core/tests/plugins/installedCachePackageManifest.test.ts @@ -0,0 +1,111 @@ +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import { + readPackageManifest, + readRequiredPackageManifest, +} from '../../src/plugins/installedPluginCache/packageReader'; +import { createPackage } from './installedCacheFixture'; + +describe('CodeGraphy installed plugin package manifests', () => { + it('reads optional package manifests and returns null for missing or non-plugin packages', async () => { + const packageRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-package-reader-')); + await createPackage(packageRoot, '@codegraphy-dev/plugin-vue', { + version: '1.2.3', + codegraphy: { + type: 'plugin', + apiVersion: '^3.0.0', + }, + }, { + id: 'codegraphy.vue', + name: 'Vue', + supportedExtensions: ['.vue'], + updateImpact: { + toggle: 'reanalyze-plugin-files', + }, + }); + await createPackage(packageRoot, '@codegraphy-dev/not-a-plugin', { + version: '1.0.0', + }); + const pluginRoot = path.join(packageRoot, '@codegraphy-dev', 'plugin-vue'); + const nonPluginRoot = path.join(packageRoot, '@codegraphy-dev', 'not-a-plugin'); + + await expect(readPackageManifest(pluginRoot)).resolves.toEqual({ + package: '@codegraphy-dev/plugin-vue', + version: '1.2.3', + apiVersion: '^3.0.0', + pluginId: 'codegraphy.vue', + pluginName: 'Vue', + supportedExtensions: ['.vue'], + updateImpact: { + toggle: 'reanalyze-plugin-files', + }, + disclosures: [], + packageRoot: pluginRoot, + }); + await expect(readPackageManifest(nonPluginRoot)).resolves.toBeNull(); + await expect(readPackageManifest(path.join(pluginRoot, 'missing'))).resolves.toBeNull(); + }); + + it('requires plugin packages to declare a static plugin id in codegraphy.json', async () => { + const packageRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-package-reader-')); + await createPackage(packageRoot, '@codegraphy-dev/plugin-missing-id', { + version: '1.2.3', + codegraphy: { + type: 'plugin', + apiVersion: '^3.0.0', + }, + }); + const pluginRoot = path.join(packageRoot, '@codegraphy-dev', 'plugin-missing-id'); + + await expect(readPackageManifest(pluginRoot)).resolves.toBeNull(); + await expect(readRequiredPackageManifest('@codegraphy-dev/plugin-missing-id', pluginRoot)) + .rejects.toThrow("Package '@codegraphy-dev/plugin-missing-id' is missing codegraphy.json with a static plugin id."); + }); + + it('requires a matching CodeGraphy plugin package manifest', async () => { + const packageRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-package-reader-')); + await createPackage(packageRoot, '@codegraphy-dev/plugin-vue', { + version: '1.2.3', + codegraphy: { + type: 'plugin', + apiVersion: '^3.0.0', + disclosures: ['network'], + }, + }, { + id: 'codegraphy.vue', + }); + await createPackage(packageRoot, '@codegraphy-dev/not-a-plugin', { + version: '1.0.0', + }); + await createPackage(packageRoot, '@codegraphy-dev/plugin-ruby', { + version: '1.2.3', + codegraphy: { + type: 'plugin', + apiVersion: '^3.0.0', + }, + }, { + id: 'codegraphy.ruby', + }); + const pluginRoot = path.join(packageRoot, '@codegraphy-dev', 'plugin-vue'); + const nonPluginRoot = path.join(packageRoot, '@codegraphy-dev', 'not-a-plugin'); + const mismatchedRoot = path.join(packageRoot, '@codegraphy-dev', 'plugin-ruby'); + + await expect(readRequiredPackageManifest('@codegraphy-dev/plugin-vue', pluginRoot)).resolves.toEqual({ + package: '@codegraphy-dev/plugin-vue', + version: '1.2.3', + apiVersion: '^3.0.0', + pluginId: 'codegraphy.vue', + disclosures: ['network'], + packageRoot: pluginRoot, + }); + await expect(readRequiredPackageManifest('@codegraphy-dev/plugin-vue', path.join(pluginRoot, 'missing'))) + .rejects.toThrow('Run `npm i -g @codegraphy-dev/plugin-vue` first.'); + await expect(readRequiredPackageManifest('@codegraphy-dev/not-a-plugin', nonPluginRoot)) + .rejects.toThrow("Package '@codegraphy-dev/not-a-plugin' is not a CodeGraphy plugin."); + await expect(readRequiredPackageManifest('@codegraphy-dev/plugin-vue', mismatchedRoot)) + .rejects.toThrow("Package '@codegraphy-dev/plugin-vue' resolved to CodeGraphy plugin '@codegraphy-dev/plugin-ruby'."); + }); +}); diff --git a/packages/core/tests/plugins/installedCacheRegistration.test.ts b/packages/core/tests/plugins/installedCacheRegistration.test.ts new file mode 100644 index 000000000..7987cfaeb --- /dev/null +++ b/packages/core/tests/plugins/installedCacheRegistration.test.ts @@ -0,0 +1,88 @@ +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import { + linkCodeGraphyInstalledPluginPackage, + readCodeGraphyInstalledPluginCache, + registerCodeGraphyInstalledPlugin, +} from '../../src'; +import { createPackage } from './installedCacheFixture'; + +describe('CodeGraphy installed plugin registration', () => { + it('registers an explicitly named globally installed plugin package to the user-level registry', async () => { + const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-user-home-')); + const globalRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-global-root-')); + await createPackage(globalRoot, 'private-plugin', { + version: '4.5.6', + codegraphy: { + type: 'plugin', + apiVersion: '^3.0.0', + disclosures: ['externalProcesses'], + }, + }, { + id: 'private-plugin', + }); + + const record = await registerCodeGraphyInstalledPlugin({ + homeDir, + packageName: 'private-plugin', + globalPackageRoots: [globalRoot], + }); + + expect(record).toEqual({ + package: 'private-plugin', + version: '4.5.6', + apiVersion: '^3.0.0', + pluginId: 'private-plugin', + disclosures: ['externalProcesses'], + packageRoot: path.join(globalRoot, 'private-plugin'), + }); + expect(readCodeGraphyInstalledPluginCache({ homeDir }).plugins).toEqual([record]); + }); + + it('links a private local plugin package root into the user-level cache', async () => { + const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-user-home-')); + const packageRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-private-package-')); + await fs.writeFile( + path.join(packageRoot, 'package.json'), + `${JSON.stringify({ + name: '@acme/codegraphy-private-plugin', + version: '0.1.0', + codegraphy: { + type: 'plugin', + apiVersion: '^3.0.0', + disclosures: ['workspaceWrites'], + }, + }, null, 2)}\n`, + 'utf-8', + ); + await fs.writeFile( + path.join(packageRoot, 'codegraphy.json'), + `${JSON.stringify({ + id: 'acme.private', + name: 'Acme Private', + supportedExtensions: ['.acme'], + }, null, 2)}\n`, + 'utf-8', + ); + + const record = await linkCodeGraphyInstalledPluginPackage({ + homeDir, + packageRoot, + }); + + expect(record).toEqual({ + package: '@acme/codegraphy-private-plugin', + version: '0.1.0', + apiVersion: '^3.0.0', + pluginId: 'acme.private', + pluginName: 'Acme Private', + supportedExtensions: ['.acme'], + disclosures: ['workspaceWrites'], + packageRoot, + }); + expect(readCodeGraphyInstalledPluginCache({ homeDir }).plugins).toEqual([record]); + }); +}); diff --git a/packages/core/tests/plugins/installedCacheWorkspaceState.test.ts b/packages/core/tests/plugins/installedCacheWorkspaceState.test.ts new file mode 100644 index 000000000..febd53b78 --- /dev/null +++ b/packages/core/tests/plugins/installedCacheWorkspaceState.test.ts @@ -0,0 +1,144 @@ +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import { + CODEGRAPHY_MARKDOWN_PLUGIN_ID, + disableCodeGraphyWorkspacePlugin, + enableCodeGraphyWorkspacePlugin, + readCodeGraphyInstalledPluginCache, + readCodeGraphyWorkspaceSettings, + writeCodeGraphyWorkspaceSettings, +} from '../../src'; + +describe('CodeGraphy installed plugin workspace state', () => { + it('enables a cached plugin for one workspace without installing or importing it', async () => { + const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-workspace-plugin-')); + + enableCodeGraphyWorkspacePlugin(workspaceRoot, { + package: '@codegraphy-dev/plugin-vue', + version: '1.2.3', + apiVersion: '^3.0.0', + defaultOptions: { includeTests: true }, + disclosures: [], + packageRoot: '/global/@codegraphy-dev/plugin-vue', + pluginId: 'codegraphy.vue', + }); + + expect(readCodeGraphyInstalledPluginCache({ + homeDir: path.join(workspaceRoot, 'missing-home'), + }).plugins).toEqual([]); + expect(JSON.parse( + await fs.readFile(path.join(workspaceRoot, '.codegraphy', 'settings.json'), 'utf-8'), + ).plugins).toEqual([ + { id: CODEGRAPHY_MARKDOWN_PLUGIN_ID, enabled: true }, + { + id: 'codegraphy.vue', + enabled: true, + options: { includeTests: true }, + }, + ]); + }); + + it('merges default options into existing workspace plugin entries and disables by plugin id', async () => { + const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-workspace-plugin-')); + writeCodeGraphyWorkspaceSettings(workspaceRoot, { + ...readCodeGraphyWorkspaceSettings(workspaceRoot), + plugins: [{ + id: 'codegraphy.vue', + enabled: true, + options: { includeTests: false }, + }], + }); + + enableCodeGraphyWorkspacePlugin(workspaceRoot, { + package: '@codegraphy-dev/plugin-vue', + version: '1.2.3', + apiVersion: '^3.0.0', + defaultOptions: { includeTests: true, pythonVersion: '3.12' }, + disclosures: [], + packageRoot: '/global/@codegraphy-dev/plugin-vue', + pluginId: 'codegraphy.vue', + }); + + expect(readCodeGraphyWorkspaceSettings(workspaceRoot).plugins).toEqual([{ + id: 'codegraphy.vue', + enabled: true, + options: { includeTests: false, pythonVersion: '3.12' }, + }]); + + disableCodeGraphyWorkspacePlugin(workspaceRoot, 'codegraphy.vue'); + + expect(readCodeGraphyWorkspaceSettings(workspaceRoot).plugins).toEqual([{ + id: 'codegraphy.vue', + enabled: false, + options: { includeTests: false, pythonVersion: '3.12' }, + }]); + }); + + it('omits empty option objects and persists disabled plugin intent', async () => { + const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-workspace-plugin-')); + + enableCodeGraphyWorkspacePlugin(workspaceRoot, { + package: '@codegraphy-dev/plugin-ruby', + version: '1.2.3', + apiVersion: '^3.0.0', + disclosures: [], + packageRoot: '/global/@codegraphy-dev/plugin-ruby', + pluginId: 'codegraphy.ruby', + }); + + expect(readCodeGraphyWorkspaceSettings(workspaceRoot).plugins).toContainEqual({ + id: 'codegraphy.ruby', + enabled: true, + }); + + disableCodeGraphyWorkspacePlugin(workspaceRoot, 'codegraphy.ruby'); + + expect(readCodeGraphyWorkspaceSettings(workspaceRoot).plugins).toContainEqual({ + id: 'codegraphy.ruby', + enabled: false, + }); + }); + + it('does not add empty options when re-enabling a plugin without defaults', async () => { + const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-workspace-plugin-')); + writeCodeGraphyWorkspaceSettings(workspaceRoot, { + ...readCodeGraphyWorkspaceSettings(workspaceRoot), + plugins: [{ id: 'codegraphy.ruby', enabled: false }], + }); + + enableCodeGraphyWorkspacePlugin(workspaceRoot, { + package: '@codegraphy-dev/plugin-ruby', + version: '1.2.3', + apiVersion: '^3.0.0', + disclosures: [], + packageRoot: '/global/@codegraphy-dev/plugin-ruby', + pluginId: 'codegraphy.ruby', + }); + + expect(readCodeGraphyWorkspaceSettings(workspaceRoot).plugins).toEqual([{ + id: 'codegraphy.ruby', + enabled: true, + }]); + }); + + it('keeps unrelated workspace plugins when disabling one plugin id', async () => { + const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-workspace-plugin-')); + writeCodeGraphyWorkspaceSettings(workspaceRoot, { + ...readCodeGraphyWorkspaceSettings(workspaceRoot), + plugins: [ + { id: 'codegraphy.vue', enabled: true }, + { id: 'codegraphy.ruby', enabled: true }, + ], + }); + + disableCodeGraphyWorkspacePlugin(workspaceRoot, 'codegraphy.vue'); + + expect(readCodeGraphyWorkspaceSettings(workspaceRoot).plugins).toEqual([ + { id: 'codegraphy.vue', enabled: false }, + { id: 'codegraphy.ruby', enabled: true }, + ]); + }); +}); diff --git a/packages/core/tests/plugins/installedPluginCache/bundled.test.ts b/packages/core/tests/plugins/installedPluginCache/bundled.test.ts index 2c2676e65..a870fed0f 100644 --- a/packages/core/tests/plugins/installedPluginCache/bundled.test.ts +++ b/packages/core/tests/plugins/installedPluginCache/bundled.test.ts @@ -19,7 +19,7 @@ describe('plugins/installedPluginCache/bundled', () => { expect(createBundledMarkdownInstalledPluginRecord()).toEqual({ package: '@codegraphy-dev/plugin-markdown', version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', pluginId: 'codegraphy.markdown', pluginName: 'Markdown', supportedExtensions: ['*'], diff --git a/packages/core/tests/plugins/installedPluginRecord.test.ts b/packages/core/tests/plugins/installedPluginRecord.test.ts index d9732e8ce..ea6e98ddf 100644 --- a/packages/core/tests/plugins/installedPluginRecord.test.ts +++ b/packages/core/tests/plugins/installedPluginRecord.test.ts @@ -8,7 +8,7 @@ describe('plugins/installedPluginCache record normalization', () => { expect(normalizeInstalledPluginRecord({ package: '@codegraphy-dev/plugin-vue', version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', })).toBeNull(); }); @@ -16,7 +16,7 @@ describe('plugins/installedPluginCache record normalization', () => { expect(normalizeInstalledPluginRecord({ package: '@codegraphy-dev/plugin-vue', version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', packageRoot: '/global/plugin-vue', disclosures: ['network', 'invalid'], defaultOptions: { includeTests: true }, @@ -27,7 +27,7 @@ describe('plugins/installedPluginCache record normalization', () => { })).toEqual({ package: '@codegraphy-dev/plugin-vue', version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', packageRoot: '/global/plugin-vue', disclosures: ['network'], defaultOptions: { includeTests: true }, @@ -42,14 +42,14 @@ describe('plugins/installedPluginCache record normalization', () => { expect(normalizeInstalledPluginRecord({ package: '@codegraphy-dev/plugin-vue', version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', packageRoot: '/global/plugin-vue', disclosures: [], defaultOptions: true, })).toEqual({ package: '@codegraphy-dev/plugin-vue', version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', packageRoot: '/global/plugin-vue', disclosures: [], }); diff --git a/packages/core/tests/plugins/lifecycle/analysis.test.ts b/packages/core/tests/plugins/lifecycle/analysis.test.ts index bede73c7c..f1c3943a4 100644 --- a/packages/core/tests/plugins/lifecycle/analysis.test.ts +++ b/packages/core/tests/plugins/lifecycle/analysis.test.ts @@ -23,7 +23,7 @@ describe('plugins/lifecycle analysis notifications', () => { id: 'full', name: 'Full', version: '1.0.0', - apiVersion: '2', + apiVersion: '3', supportedExtensions: ['.ts'], onPreAnalyze, onPostAnalyze, @@ -33,7 +33,7 @@ describe('plugins/lifecycle analysis notifications', () => { id: 'empty', name: 'Empty', version: '1.0.0', - apiVersion: '2', + apiVersion: '3', supportedExtensions: ['.ts'], })], ]); @@ -57,7 +57,7 @@ describe('plugins/lifecycle analysis notifications', () => { id: 'ts', name: 'TypeScript', version: '1.0.0', - apiVersion: '2', + apiVersion: '3', supportedExtensions: ['.ts'], onPreAnalyze: onTypeScriptPreAnalyze, })], @@ -65,7 +65,7 @@ describe('plugins/lifecycle analysis notifications', () => { id: 'markdown', name: 'Markdown', version: '1.0.0', - apiVersion: '2', + apiVersion: '3', supportedExtensions: ['.md'], onPreAnalyze: onMarkdownPreAnalyze, })], @@ -73,7 +73,7 @@ describe('plugins/lifecycle analysis notifications', () => { id: 'wildcard', name: 'Wildcard', version: '1.0.0', - apiVersion: '2', + apiVersion: '3', supportedExtensions: ['*'], onPreAnalyze: onWildcardPreAnalyze, })], @@ -104,7 +104,7 @@ describe('plugins/lifecycle analysis notifications', () => { id: 'failing', name: 'Failing', version: '1.0.0', - apiVersion: '2', + apiVersion: '3', supportedExtensions: ['.ts'], onGraphRebuild: () => { throw new Error('boom'); @@ -114,7 +114,7 @@ describe('plugins/lifecycle analysis notifications', () => { id: 'after', name: 'After', version: '1.0.0', - apiVersion: '2', + apiVersion: '3', supportedExtensions: ['.ts'], onGraphRebuild: afterRebuild, })], diff --git a/packages/core/tests/plugins/lifecycle/filesChanged.test.ts b/packages/core/tests/plugins/lifecycle/filesChanged.test.ts index abd779615..6431d20cd 100644 --- a/packages/core/tests/plugins/lifecycle/filesChanged.test.ts +++ b/packages/core/tests/plugins/lifecycle/filesChanged.test.ts @@ -9,7 +9,7 @@ function pluginInfo(plugin: Partial): ILifecyclePluginInfo { id: 'test-plugin', name: 'Test Plugin', version: '1.0.0', - apiVersion: '2', + apiVersion: '3', supportedExtensions: ['.ts'], ...plugin, }, diff --git a/packages/core/tests/plugins/lifecycle/initialize.test.ts b/packages/core/tests/plugins/lifecycle/initialize.test.ts index 7d1328cc0..d7a661246 100644 --- a/packages/core/tests/plugins/lifecycle/initialize.test.ts +++ b/packages/core/tests/plugins/lifecycle/initialize.test.ts @@ -7,7 +7,7 @@ function plugin(id: string, initialize?: IPlugin['initialize']): IPlugin { id, name: id, version: '1.0.0', - apiVersion: '2', + apiVersion: '3', supportedExtensions: ['.test'], ...(initialize ? { initialize } : {}), }; diff --git a/packages/core/tests/plugins/packageEntrypoint.test.ts b/packages/core/tests/plugins/packageEntrypoint.test.ts index 2385923c5..5c0d0b344 100644 --- a/packages/core/tests/plugins/packageEntrypoint.test.ts +++ b/packages/core/tests/plugins/packageEntrypoint.test.ts @@ -38,7 +38,7 @@ describe('plugins/package entrypoint and runtime values', () => { id: 'default-plugin', name: 'Default Plugin', version: '1.0.0', - apiVersion: '2', + apiVersion: '3', supportedExtensions: ['.ts'], }, }, 'default-package')).resolves.toMatchObject({ id: 'default-plugin' }); @@ -48,7 +48,7 @@ describe('plugins/package entrypoint and runtime values', () => { id: 'factory-plugin', name: 'Factory Plugin', version: '1.0.0', - apiVersion: '2', + apiVersion: '3', supportedExtensions: ['.ts'], }), }, 'factory-package')).resolves.toMatchObject({ id: 'factory-plugin' }); @@ -58,7 +58,7 @@ describe('plugins/package entrypoint and runtime values', () => { id: 'named-plugin', name: 'Named Plugin', version: '1.0.0', - apiVersion: '2', + apiVersion: '3', supportedExtensions: ['.ts'], }, }, 'named-package')).resolves.toMatchObject({ id: 'named-plugin' }); diff --git a/packages/core/tests/plugins/packageManifest.test.ts b/packages/core/tests/plugins/packageManifest.test.ts index e1fb71fca..6fe55b143 100644 --- a/packages/core/tests/plugins/packageManifest.test.ts +++ b/packages/core/tests/plugins/packageManifest.test.ts @@ -24,7 +24,7 @@ describe('CodeGraphy plugin package manifest', () => { version: '1.2.3', codegraphy: { type: 'plugin', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', defaultOptions: { includeTests: true, }, @@ -40,7 +40,7 @@ describe('CodeGraphy plugin package manifest', () => { })).toEqual({ package: '@codegraphy-dev/plugin-vue', version: '1.2.3', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', defaultOptions: { includeTests: true, }, @@ -61,12 +61,12 @@ describe('CodeGraphy plugin package manifest', () => { version: '1.0.0', codegraphy: { type: 'plugin', - apiVersion: '2', + apiVersion: '3', }, })).toEqual({ package: '@codegraphy-dev/plugin-markdown', version: '1.0.0', - apiVersion: '2', + apiVersion: '3', disclosures: [], }); }); @@ -82,9 +82,9 @@ describe('CodeGraphy plugin package manifest', () => { version: '1.0.0', codegraphy: { type: 'plugin', - apiVersion: '^3.0.0', + apiVersion: '^4.0.0', }, - })).toThrow("Plugin '@codegraphy-dev/plugin-future' targets unsupported CodeGraphy Plugin API '^3.0.0'."); + })).toThrow("Plugin '@codegraphy-dev/plugin-future' targets unsupported CodeGraphy Plugin API '^4.0.0'."); }); it('rejects plugin metadata with missing required manifest values', () => { @@ -94,7 +94,7 @@ describe('CodeGraphy plugin package manifest', () => { version: '1.0.0', codegraphy: { type: 'plugin', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', }, })).toBeNull(); expect(parseCodeGraphyPluginPackageManifest({ @@ -102,7 +102,7 @@ describe('CodeGraphy plugin package manifest', () => { version: '', codegraphy: { type: 'plugin', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', }, })).toBeNull(); expect(parseCodeGraphyPluginPackageManifest({ @@ -110,7 +110,7 @@ describe('CodeGraphy plugin package manifest', () => { version: '1.0.0', codegraphy: { type: 'theme', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', }, })).toBeNull(); expect(parseCodeGraphyPluginPackageManifest({ @@ -123,14 +123,17 @@ describe('CodeGraphy plugin package manifest', () => { })).toBeNull(); }); - it('accepts compatible plugin API ranges and rejects unsupported versions', () => { - expect(() => assertPluginApiCompatibility(plugin('2'))).not.toThrow(); - expect(() => assertPluginApiCompatibility(plugin('^2.0.0'))).not.toThrow(); + it('accepts current plugin API ranges and rejects old or future versions', () => { + expect(() => assertPluginApiCompatibility(plugin('3'))).not.toThrow(); + expect(() => assertPluginApiCompatibility(plugin('^3.0.0'))).not.toThrow(); expect(() => assertPluginApiCompatibility(plugin(undefined))).toThrow( "Plugin 'codegraphy.test' must declare a string apiVersion", ); - expect(() => assertPluginApiCompatibility(plugin('^3.0.0'))).toThrow( - "Plugin 'codegraphy.test' targets unsupported CodeGraphy Plugin API '^3.0.0'.", + expect(() => assertPluginApiCompatibility(plugin('^2.0.0'))).toThrow( + "Plugin 'codegraphy.test' targets unsupported CodeGraphy Plugin API '^2.0.0'. Host provides '3.0.0'.", + ); + expect(() => assertPluginApiCompatibility(plugin('^4.0.0'))).toThrow( + "Plugin 'codegraphy.test' targets unsupported CodeGraphy Plugin API '^4.0.0'. Host provides '3.0.0'.", ); }); diff --git a/packages/core/tests/plugins/packageRuntime.test.ts b/packages/core/tests/plugins/packageRuntime.test.ts deleted file mode 100644 index 986908103..000000000 --- a/packages/core/tests/plugins/packageRuntime.test.ts +++ /dev/null @@ -1,412 +0,0 @@ -import * as fs from 'node:fs/promises'; -import * as os from 'node:os'; -import * as path from 'node:path'; -import { describe, expect, it, vi } from 'vitest'; - -import { - loadCodeGraphyWorkspacePluginPackages, - readCodeGraphyWorkspaceSettings, - writeCodeGraphyInstalledPluginCache, - writeCodeGraphyWorkspaceSettings, -} from '../../src'; - -async function createWorkspace(): Promise { - return fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-package-runtime-workspace-')); -} - -async function createPackageFixtureRoot(prefix: string): Promise { - const root = path.join(process.cwd(), 'node_modules', '.cache', 'codegraphy-test-packages'); - await fs.mkdir(root, { recursive: true }); - return fs.mkdtemp(path.join(root, prefix)); -} - -async function createPluginPackage(packageRoot: string): Promise { - await fs.mkdir(packageRoot, { recursive: true }); - await fs.writeFile( - path.join(packageRoot, 'package.json'), - `${JSON.stringify({ - name: '@acme/codegraphy-plugin-data-host', - version: '1.0.0', - type: 'module', - exports: './plugin.js', - codegraphy: { - type: 'plugin', - apiVersion: '^2.0.0', - defaultOptions: { - marker: 'from-default-options', - }, - }, - }, null, 2)}\n`, - 'utf-8', - ); - await fs.writeFile( - path.join(packageRoot, 'plugin.js'), - ` -export default function createPlugin(factoryOptions = {}) { - const dataHost = factoryOptions.dataHost; - const marker = factoryOptions.options?.marker ?? 'missing-options'; - - return { - id: 'acme.data-host', - name: 'Data Host Plugin', - version: '1.0.0', - apiVersion: '^2.0.0', - supportedExtensions: [], - async initialize() { - if (!dataHost) { - throw new Error('Expected factory dataHost.'); - } - await dataHost.saveData({ marker }); - } - }; -} -`, - 'utf-8', - ); -} - -async function createPluginPackageWithRuntimeMarkers( - packageRoot: string, - packageName = '@acme/codegraphy-plugin-disabled-runtime', - pluginId = 'acme.disabled-runtime', - pluginName = 'Disabled Runtime Plugin', - version = '1.0.0', -): Promise<{ - factoryMarkerPath: string; - importMarkerPath: string; -}> { - const importMarkerPath = path.join(packageRoot, 'runtime-imported.txt'); - const factoryMarkerPath = path.join(packageRoot, 'factory-called.txt'); - - await fs.mkdir(packageRoot, { recursive: true }); - await fs.writeFile( - path.join(packageRoot, 'package.json'), - `${JSON.stringify({ - name: packageName, - version, - type: 'module', - exports: './plugin.js', - codegraphy: { - type: 'plugin', - apiVersion: '^2.0.0', - }, - }, null, 2)}\n`, - 'utf-8', - ); - await fs.writeFile( - path.join(packageRoot, 'codegraphy.json'), - `${JSON.stringify({ - id: pluginId, - name: pluginName, - version, - apiVersion: '^2.0.0', - supportedExtensions: ['.disabled'], - }, null, 2)}\n`, - 'utf-8', - ); - await fs.writeFile( - path.join(packageRoot, 'plugin.js'), - ` -import { writeFileSync } from 'node:fs'; - -writeFileSync(${JSON.stringify(importMarkerPath)}, 'imported'); - -export default function createPlugin() { - writeFileSync(${JSON.stringify(factoryMarkerPath)}, 'factory called'); - return { - id: ${JSON.stringify(pluginId)}, - name: ${JSON.stringify(pluginName)}, - version: ${JSON.stringify(version)}, - apiVersion: '^2.0.0', - supportedExtensions: ['.disabled'] - }; -} -`, - 'utf-8', - ); - - return { factoryMarkerPath, importMarkerPath }; -} - -describe('CodeGraphy package runtime', () => { - it('passes workspace plugin data host and options to package plugin factories', async () => { - const workspaceRoot = await createWorkspace(); - const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-package-runtime-home-')); - const packageRoot = path.join( - await createPackageFixtureRoot('codegraphy-package-runtime-package-'), - 'node_modules', - '@acme', - 'codegraphy-plugin-data-host', - ); - - await createPluginPackage(packageRoot); - writeCodeGraphyInstalledPluginCache({ - version: 1, - plugins: [{ - package: '@acme/codegraphy-plugin-data-host', - version: '1.0.0', - apiVersion: '^2.0.0', - disclosures: ['workspaceWrites'], - packageRoot, - pluginId: 'acme.data-host', - defaultOptions: { - marker: 'from-default-options', - }, - }], - }, { homeDir }); - writeCodeGraphyWorkspaceSettings(workspaceRoot, { - ...readCodeGraphyWorkspaceSettings(workspaceRoot), - plugins: [{ - id: 'acme.data-host', - enabled: true, - options: { - marker: 'from-workspace-options', - }, - }], - }); - - const [loadedPlugin] = await loadCodeGraphyWorkspacePluginPackages({ - settings: readCodeGraphyWorkspaceSettings(workspaceRoot), - homeDir, - workspaceRoot, - }); - - await loadedPlugin?.plugin.initialize?.(workspaceRoot); - - expect(readCodeGraphyWorkspaceSettings(workspaceRoot).pluginData).toEqual({ - 'acme.data-host': { - marker: 'from-workspace-options', - }, - }); - }); - - it('leaves disabled installed package runtimes unloaded while retaining static metadata', async () => { - const workspaceRoot = await createWorkspace(); - const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-package-runtime-home-')); - const packageRoot = path.join( - await createPackageFixtureRoot('codegraphy-package-runtime-package-'), - 'node_modules', - '@acme', - 'codegraphy-plugin-disabled-runtime', - ); - const { factoryMarkerPath, importMarkerPath } = await createPluginPackageWithRuntimeMarkers(packageRoot); - - writeCodeGraphyInstalledPluginCache({ - version: 1, - plugins: [{ - package: '@acme/codegraphy-plugin-disabled-runtime', - version: '1.0.0', - apiVersion: '^2.0.0', - disclosures: [], - packageRoot, - pluginId: 'acme.disabled-runtime', - pluginName: 'Disabled Runtime Plugin', - supportedExtensions: ['.disabled'], - }], - }, { homeDir }); - writeCodeGraphyWorkspaceSettings(workspaceRoot, { - ...readCodeGraphyWorkspaceSettings(workspaceRoot), - plugins: [{ - id: 'acme.disabled-runtime', - enabled: true, - }], - }); - - const loadedPlugins = await loadCodeGraphyWorkspacePluginPackages({ - settings: readCodeGraphyWorkspaceSettings(workspaceRoot), - homeDir, - workspaceRoot, - disabledPlugins: new Set(['acme.disabled-runtime']), - }); - - expect(loadedPlugins).toEqual([]); - await expect(fs.access(importMarkerPath)).rejects.toThrow(); - await expect(fs.access(factoryMarkerPath)).rejects.toThrow(); - }); - - it('leaves conflicting enabled package runtimes unloaded and warns from static metadata', async () => { - const workspaceRoot = await createWorkspace(); - const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-package-runtime-home-')); - const packageRootOne = path.join( - await createPackageFixtureRoot('codegraphy-package-runtime-package-'), - 'node_modules', - '@acme', - 'codegraphy-plugin-vue-one', - ); - const packageRootTwo = path.join( - await createPackageFixtureRoot('codegraphy-package-runtime-package-'), - 'node_modules', - '@acme', - 'codegraphy-plugin-vue-two', - ); - const firstMarkers = await createPluginPackageWithRuntimeMarkers( - packageRootOne, - '@acme/codegraphy-plugin-vue-one', - 'codegraphy.vue', - ); - const secondMarkers = await createPluginPackageWithRuntimeMarkers( - packageRootTwo, - '@acme/codegraphy-plugin-vue-two', - 'codegraphy.vue', - ); - const warn = vi.fn(); - - writeCodeGraphyInstalledPluginCache({ - version: 1, - plugins: [ - { - package: '@acme/codegraphy-plugin-vue-one', - version: '1.0.0', - apiVersion: '^2.0.0', - disclosures: [], - packageRoot: packageRootOne, - pluginId: 'codegraphy.vue', - }, - { - package: '@acme/codegraphy-plugin-vue-two', - version: '1.0.0', - apiVersion: '^2.0.0', - disclosures: [], - packageRoot: packageRootTwo, - pluginId: 'codegraphy.vue', - }, - ], - }, { homeDir }); - writeCodeGraphyWorkspaceSettings(workspaceRoot, { - ...readCodeGraphyWorkspaceSettings(workspaceRoot), - plugins: [{ - id: 'codegraphy.vue', - enabled: true, - }], - }); - - const loadedPlugins = await loadCodeGraphyWorkspacePluginPackages({ - settings: readCodeGraphyWorkspaceSettings(workspaceRoot), - homeDir, - workspaceRoot, - warn, - }); - - expect(loadedPlugins).toEqual([]); - expect(warn).toHaveBeenCalledWith( - "CodeGraphy plugin 'codegraphy.vue' is enabled but multiple installed packages claim it: @acme/codegraphy-plugin-vue-one, @acme/codegraphy-plugin-vue-two. No runtime was loaded.", - ); - await expect(fs.access(firstMarkers.importMarkerPath)).rejects.toThrow(); - await expect(fs.access(firstMarkers.factoryMarkerPath)).rejects.toThrow(); - await expect(fs.access(secondMarkers.importMarkerPath)).rejects.toThrow(); - await expect(fs.access(secondMarkers.factoryMarkerPath)).rejects.toThrow(); - }); - - it('prefers bundled package roots over stale installed package records', async () => { - const workspaceRoot = await createWorkspace(); - const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-package-runtime-home-')); - const packageName = '@acme/codegraphy-plugin-bundled-runtime'; - const pluginId = 'acme.bundled-runtime'; - const stalePackageRoot = path.join( - await createPackageFixtureRoot('codegraphy-package-runtime-package-'), - 'node_modules', - '@acme', - 'codegraphy-plugin-bundled-runtime', - ); - const bundledPackageRoot = path.join( - await createPackageFixtureRoot('codegraphy-package-runtime-bundled-'), - 'codegraphy-plugin-bundled-runtime', - ); - - await createPluginPackageWithRuntimeMarkers( - stalePackageRoot, - packageName, - pluginId, - 'Stale Runtime Plugin', - '1.0.0', - ); - await createPluginPackageWithRuntimeMarkers( - bundledPackageRoot, - packageName, - pluginId, - 'Bundled Runtime Plugin', - '1.0.1', - ); - writeCodeGraphyInstalledPluginCache({ - version: 1, - plugins: [{ - package: packageName, - version: '1.0.0', - apiVersion: '^2.0.0', - disclosures: [], - packageRoot: stalePackageRoot, - pluginId, - pluginName: 'Stale Runtime Plugin', - }], - }, { homeDir }); - writeCodeGraphyWorkspaceSettings(workspaceRoot, { - ...readCodeGraphyWorkspaceSettings(workspaceRoot), - plugins: [{ - id: pluginId, - enabled: true, - }], - }); - - const [loadedPlugin] = await loadCodeGraphyWorkspacePluginPackages({ - bundledPackageRoots: [bundledPackageRoot], - settings: readCodeGraphyWorkspaceSettings(workspaceRoot), - homeDir, - workspaceRoot, - warn: message => { - throw new Error(message); - }, - }); - - expect(loadedPlugin?.bundled).toBe(true); - expect(loadedPlugin?.record.packageRoot).toBe(bundledPackageRoot); - expect(loadedPlugin?.plugin.name).toBe('Bundled Runtime Plugin'); - }); - - it('refuses package runtimes whose plugin id does not match static metadata', async () => { - const workspaceRoot = await createWorkspace(); - const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-package-runtime-home-')); - const packageRoot = path.join( - await createPackageFixtureRoot('codegraphy-package-runtime-package-'), - 'node_modules', - '@acme', - 'codegraphy-plugin-id-mismatch', - ); - await createPluginPackageWithRuntimeMarkers( - packageRoot, - '@acme/codegraphy-plugin-id-mismatch', - 'acme.runtime-id', - ); - const warn = vi.fn(); - - writeCodeGraphyInstalledPluginCache({ - version: 1, - plugins: [{ - package: '@acme/codegraphy-plugin-id-mismatch', - version: '1.0.0', - apiVersion: '^2.0.0', - disclosures: [], - packageRoot, - pluginId: 'acme.static-id', - }], - }, { homeDir }); - writeCodeGraphyWorkspaceSettings(workspaceRoot, { - ...readCodeGraphyWorkspaceSettings(workspaceRoot), - plugins: [{ - id: 'acme.static-id', - enabled: true, - }], - }); - - const loadedPlugins = await loadCodeGraphyWorkspacePluginPackages({ - settings: readCodeGraphyWorkspaceSettings(workspaceRoot), - homeDir, - workspaceRoot, - warn, - }); - - expect(loadedPlugins).toEqual([]); - expect(warn).toHaveBeenCalledWith( - "CodeGraphy plugin 'acme.static-id' could not be loaded: Package '@acme/codegraphy-plugin-id-mismatch' exported plugin id 'acme.runtime-id', but codegraphy.json declares 'acme.static-id'.", - ); - }); -}); diff --git a/packages/core/tests/plugins/packageRuntimeActivation.test.ts b/packages/core/tests/plugins/packageRuntimeActivation.test.ts new file mode 100644 index 000000000..bdc02d975 --- /dev/null +++ b/packages/core/tests/plugins/packageRuntimeActivation.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + loadCodeGraphyWorkspacePluginPackages, + readCodeGraphyWorkspaceSettings, + writeCodeGraphyInstalledPluginCache, + writeCodeGraphyWorkspaceSettings, +} from '../../src'; +import { + createPackageFixtureRoot, + createPluginPackage, + createPluginPackageWithRuntimeMarkers, + createWorkspace, + fs, + os, + path, +} from './packageRuntimeFixture'; + +describe('CodeGraphy package runtime', () => { + it('passes workspace plugin data host and options to package plugin factories', async () => { + const workspaceRoot = await createWorkspace(); + const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-package-runtime-home-')); + const packageRoot = path.join( + await createPackageFixtureRoot('codegraphy-package-runtime-package-'), + 'node_modules', + '@acme', + 'codegraphy-plugin-data-host', + ); + + await createPluginPackage(packageRoot); + writeCodeGraphyInstalledPluginCache({ + version: 1, + plugins: [{ + package: '@acme/codegraphy-plugin-data-host', + version: '1.0.0', + apiVersion: '^3.0.0', + disclosures: ['workspaceWrites'], + packageRoot, + pluginId: 'acme.data-host', + defaultOptions: { + marker: 'from-default-options', + }, + }], + }, { homeDir }); + writeCodeGraphyWorkspaceSettings(workspaceRoot, { + ...readCodeGraphyWorkspaceSettings(workspaceRoot), + plugins: [{ + id: 'acme.data-host', + enabled: true, + options: { + marker: 'from-workspace-options', + }, + }], + }); + + const [loadedPlugin] = await loadCodeGraphyWorkspacePluginPackages({ + settings: readCodeGraphyWorkspaceSettings(workspaceRoot), + homeDir, + workspaceRoot, + }); + + await loadedPlugin?.plugin.initialize?.(workspaceRoot); + + expect(readCodeGraphyWorkspaceSettings(workspaceRoot).pluginData).toEqual({ + 'acme.data-host': { + marker: 'from-workspace-options', + }, + }); + }); + + it('leaves disabled installed package runtimes unloaded while retaining static metadata', async () => { + const workspaceRoot = await createWorkspace(); + const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-package-runtime-home-')); + const packageRoot = path.join( + await createPackageFixtureRoot('codegraphy-package-runtime-package-'), + 'node_modules', + '@acme', + 'codegraphy-plugin-disabled-runtime', + ); + const { factoryMarkerPath, importMarkerPath } = await createPluginPackageWithRuntimeMarkers(packageRoot); + + writeCodeGraphyInstalledPluginCache({ + version: 1, + plugins: [{ + package: '@acme/codegraphy-plugin-disabled-runtime', + version: '1.0.0', + apiVersion: '^3.0.0', + disclosures: [], + packageRoot, + pluginId: 'acme.disabled-runtime', + pluginName: 'Disabled Runtime Plugin', + supportedExtensions: ['.disabled'], + }], + }, { homeDir }); + writeCodeGraphyWorkspaceSettings(workspaceRoot, { + ...readCodeGraphyWorkspaceSettings(workspaceRoot), + plugins: [{ + id: 'acme.disabled-runtime', + enabled: true, + }], + }); + + const loadedPlugins = await loadCodeGraphyWorkspacePluginPackages({ + settings: readCodeGraphyWorkspaceSettings(workspaceRoot), + homeDir, + workspaceRoot, + disabledPlugins: new Set(['acme.disabled-runtime']), + }); + + expect(loadedPlugins).toEqual([]); + await expect(fs.access(importMarkerPath)).rejects.toThrow(); + await expect(fs.access(factoryMarkerPath)).rejects.toThrow(); + }); + + it('leaves conflicting enabled package runtimes unloaded and warns from static metadata', async () => { + const workspaceRoot = await createWorkspace(); + const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-package-runtime-home-')); + const packageRootOne = path.join( + await createPackageFixtureRoot('codegraphy-package-runtime-package-'), + 'node_modules', + '@acme', + 'codegraphy-plugin-vue-one', + ); + const packageRootTwo = path.join( + await createPackageFixtureRoot('codegraphy-package-runtime-package-'), + 'node_modules', + '@acme', + 'codegraphy-plugin-vue-two', + ); + const firstMarkers = await createPluginPackageWithRuntimeMarkers( + packageRootOne, + '@acme/codegraphy-plugin-vue-one', + 'codegraphy.vue', + ); + const secondMarkers = await createPluginPackageWithRuntimeMarkers( + packageRootTwo, + '@acme/codegraphy-plugin-vue-two', + 'codegraphy.vue', + ); + const warn = vi.fn(); + + writeCodeGraphyInstalledPluginCache({ + version: 1, + plugins: [ + { + package: '@acme/codegraphy-plugin-vue-one', + version: '1.0.0', + apiVersion: '^3.0.0', + disclosures: [], + packageRoot: packageRootOne, + pluginId: 'codegraphy.vue', + }, + { + package: '@acme/codegraphy-plugin-vue-two', + version: '1.0.0', + apiVersion: '^3.0.0', + disclosures: [], + packageRoot: packageRootTwo, + pluginId: 'codegraphy.vue', + }, + ], + }, { homeDir }); + writeCodeGraphyWorkspaceSettings(workspaceRoot, { + ...readCodeGraphyWorkspaceSettings(workspaceRoot), + plugins: [{ + id: 'codegraphy.vue', + enabled: true, + }], + }); + + const loadedPlugins = await loadCodeGraphyWorkspacePluginPackages({ + settings: readCodeGraphyWorkspaceSettings(workspaceRoot), + homeDir, + workspaceRoot, + warn, + }); + + expect(loadedPlugins).toEqual([]); + expect(warn).toHaveBeenCalledWith( + "CodeGraphy plugin 'codegraphy.vue' is enabled but multiple installed packages claim it: @acme/codegraphy-plugin-vue-one, @acme/codegraphy-plugin-vue-two. No runtime was loaded.", + ); + await expect(fs.access(firstMarkers.importMarkerPath)).rejects.toThrow(); + await expect(fs.access(firstMarkers.factoryMarkerPath)).rejects.toThrow(); + await expect(fs.access(secondMarkers.importMarkerPath)).rejects.toThrow(); + await expect(fs.access(secondMarkers.factoryMarkerPath)).rejects.toThrow(); + }); +}); diff --git a/packages/core/tests/plugins/packageRuntimeFixture.ts b/packages/core/tests/plugins/packageRuntimeFixture.ts new file mode 100644 index 000000000..2ccac2908 --- /dev/null +++ b/packages/core/tests/plugins/packageRuntimeFixture.ts @@ -0,0 +1,123 @@ +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +export async function createWorkspace(): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-package-runtime-workspace-')); +} + +export async function createPackageFixtureRoot(prefix: string): Promise { + const root = path.join(process.cwd(), 'node_modules', '.cache', 'codegraphy-test-packages'); + await fs.mkdir(root, { recursive: true }); + return fs.mkdtemp(path.join(root, prefix)); +} + +export async function createPluginPackage(packageRoot: string): Promise { + await fs.mkdir(packageRoot, { recursive: true }); + await fs.writeFile( + path.join(packageRoot, 'package.json'), + `${JSON.stringify({ + name: '@acme/codegraphy-plugin-data-host', + version: '1.0.0', + type: 'module', + exports: './plugin.js', + codegraphy: { + type: 'plugin', + apiVersion: '^3.0.0', + defaultOptions: { + marker: 'from-default-options', + }, + }, + }, null, 2)}\n`, + 'utf-8', + ); + await fs.writeFile( + path.join(packageRoot, 'plugin.js'), + ` +export default function createPlugin(factoryOptions = {}) { + const dataHost = factoryOptions.dataHost; + const marker = factoryOptions.options?.marker ?? 'missing-options'; + + return { + id: 'acme.data-host', + name: 'Data Host Plugin', + version: '1.0.0', + apiVersion: '^3.0.0', + supportedExtensions: [], + async initialize() { + if (!dataHost) { + throw new Error('Expected factory dataHost.'); + } + await dataHost.saveData({ marker }); + } + }; +} +`, + 'utf-8', + ); +} + +export async function createPluginPackageWithRuntimeMarkers( + packageRoot: string, + packageName = '@acme/codegraphy-plugin-disabled-runtime', + pluginId = 'acme.disabled-runtime', + pluginName = 'Disabled Runtime Plugin', + version = '1.0.0', +): Promise<{ + factoryMarkerPath: string; + importMarkerPath: string; +}> { + const importMarkerPath = path.join(packageRoot, 'runtime-imported.txt'); + const factoryMarkerPath = path.join(packageRoot, 'factory-called.txt'); + + await fs.mkdir(packageRoot, { recursive: true }); + await fs.writeFile( + path.join(packageRoot, 'package.json'), + `${JSON.stringify({ + name: packageName, + version, + type: 'module', + exports: './plugin.js', + codegraphy: { + type: 'plugin', + apiVersion: '^3.0.0', + }, + }, null, 2)}\n`, + 'utf-8', + ); + await fs.writeFile( + path.join(packageRoot, 'codegraphy.json'), + `${JSON.stringify({ + id: pluginId, + name: pluginName, + version, + apiVersion: '^3.0.0', + supportedExtensions: ['.disabled'], + }, null, 2)}\n`, + 'utf-8', + ); + await fs.writeFile( + path.join(packageRoot, 'plugin.js'), + ` +import { writeFileSync } from 'node:fs'; + +writeFileSync(${JSON.stringify(importMarkerPath)}, 'imported'); + +export default function createPlugin() { + writeFileSync(${JSON.stringify(factoryMarkerPath)}, 'factory called'); + return { + id: ${JSON.stringify(pluginId)}, + name: ${JSON.stringify(pluginName)}, + version: ${JSON.stringify(version)}, + apiVersion: '^3.0.0', + supportedExtensions: ['.disabled'] + }; +} +`, + 'utf-8', + ); + + return { factoryMarkerPath, importMarkerPath }; +} + +export { fs, os, path }; diff --git a/packages/core/tests/plugins/packageRuntimeResolution.test.ts b/packages/core/tests/plugins/packageRuntimeResolution.test.ts new file mode 100644 index 000000000..dd6c0480d --- /dev/null +++ b/packages/core/tests/plugins/packageRuntimeResolution.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + loadCodeGraphyWorkspacePluginPackages, + readCodeGraphyWorkspaceSettings, + writeCodeGraphyInstalledPluginCache, + writeCodeGraphyWorkspaceSettings, +} from '../../src'; +import { + createPackageFixtureRoot, + createPluginPackageWithRuntimeMarkers, + createWorkspace, + fs, + os, + path, +} from './packageRuntimeFixture'; + +describe('CodeGraphy package runtime', () => { + it('prefers bundled package roots over stale installed package records', async () => { + const workspaceRoot = await createWorkspace(); + const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-package-runtime-home-')); + const packageName = '@acme/codegraphy-plugin-bundled-runtime'; + const pluginId = 'acme.bundled-runtime'; + const stalePackageRoot = path.join( + await createPackageFixtureRoot('codegraphy-package-runtime-package-'), + 'node_modules', + '@acme', + 'codegraphy-plugin-bundled-runtime', + ); + const bundledPackageRoot = path.join( + await createPackageFixtureRoot('codegraphy-package-runtime-bundled-'), + 'codegraphy-plugin-bundled-runtime', + ); + + await createPluginPackageWithRuntimeMarkers( + stalePackageRoot, + packageName, + pluginId, + 'Stale Runtime Plugin', + '1.0.0', + ); + await createPluginPackageWithRuntimeMarkers( + bundledPackageRoot, + packageName, + pluginId, + 'Bundled Runtime Plugin', + '1.0.1', + ); + writeCodeGraphyInstalledPluginCache({ + version: 1, + plugins: [{ + package: packageName, + version: '1.0.0', + apiVersion: '^3.0.0', + disclosures: [], + packageRoot: stalePackageRoot, + pluginId, + pluginName: 'Stale Runtime Plugin', + }], + }, { homeDir }); + writeCodeGraphyWorkspaceSettings(workspaceRoot, { + ...readCodeGraphyWorkspaceSettings(workspaceRoot), + plugins: [{ + id: pluginId, + enabled: true, + }], + }); + + const [loadedPlugin] = await loadCodeGraphyWorkspacePluginPackages({ + bundledPackageRoots: [bundledPackageRoot], + settings: readCodeGraphyWorkspaceSettings(workspaceRoot), + homeDir, + workspaceRoot, + warn: message => { + throw new Error(message); + }, + }); + + expect(loadedPlugin?.bundled).toBe(true); + expect(loadedPlugin?.record.packageRoot).toBe(bundledPackageRoot); + expect(loadedPlugin?.plugin.name).toBe('Bundled Runtime Plugin'); + }); + + it('refuses package runtimes whose plugin id does not match static metadata', async () => { + const workspaceRoot = await createWorkspace(); + const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-package-runtime-home-')); + const packageRoot = path.join( + await createPackageFixtureRoot('codegraphy-package-runtime-package-'), + 'node_modules', + '@acme', + 'codegraphy-plugin-id-mismatch', + ); + await createPluginPackageWithRuntimeMarkers( + packageRoot, + '@acme/codegraphy-plugin-id-mismatch', + 'acme.runtime-id', + ); + const warn = vi.fn(); + + writeCodeGraphyInstalledPluginCache({ + version: 1, + plugins: [{ + package: '@acme/codegraphy-plugin-id-mismatch', + version: '1.0.0', + apiVersion: '^3.0.0', + disclosures: [], + packageRoot, + pluginId: 'acme.static-id', + }], + }, { homeDir }); + writeCodeGraphyWorkspaceSettings(workspaceRoot, { + ...readCodeGraphyWorkspaceSettings(workspaceRoot), + plugins: [{ + id: 'acme.static-id', + enabled: true, + }], + }); + + const loadedPlugins = await loadCodeGraphyWorkspacePluginPackages({ + settings: readCodeGraphyWorkspaceSettings(workspaceRoot), + homeDir, + workspaceRoot, + warn, + }); + + expect(loadedPlugins).toEqual([]); + expect(warn).toHaveBeenCalledWith( + "CodeGraphy plugin 'acme.static-id' could not be loaded: Package '@acme/codegraphy-plugin-id-mismatch' exported plugin id 'acme.runtime-id', but codegraphy.json declares 'acme.static-id'.", + ); + }); +}); diff --git a/packages/core/tests/plugins/registry.test.ts b/packages/core/tests/plugins/registry.test.ts index 4cc747e47..41df1851a 100644 --- a/packages/core/tests/plugins/registry.test.ts +++ b/packages/core/tests/plugins/registry.test.ts @@ -8,7 +8,7 @@ function plugin(overrides: Partial = {}): IPlugin { id: 'codegraphy.test', name: 'Test', version: '1.0.0', - apiVersion: '2', + apiVersion: '3', supportedExtensions: ['.test'], ...overrides, }; diff --git a/packages/core/tests/plugins/routing.test.ts b/packages/core/tests/plugins/routingAnalysis.test.ts similarity index 51% rename from packages/core/tests/plugins/routing.test.ts rename to packages/core/tests/plugins/routingAnalysis.test.ts index eacdb293a..2736a8c3a 100644 --- a/packages/core/tests/plugins/routing.test.ts +++ b/packages/core/tests/plugins/routingAnalysis.test.ts @@ -1,61 +1,14 @@ -import type { IFileAnalysisResult, IPlugin } from '@codegraphy-dev/plugin-api'; +import type { IFileAnalysisResult } from '@codegraphy-dev/plugin-api'; import { describe, expect, it, vi } from 'vitest'; import { analyzeFileResult, - CorePluginRegistry, - getPluginsForExtension, toProjectedConnectionsFromFileAnalysis, withPluginProvenance, } from '../../src'; -import { - getPluginForFile, - getPluginInfosForFile, - getPluginsForFile, - getSupportedExtensions, - supportsFile, - type IRoutablePluginInfo, -} from '../../src/plugins/routing/router/lookups'; -import { getRelationKey } from '../../src/plugins/routing/router/results/keys'; - -type Relation = NonNullable[number]; - -function plugin(id: string, extensions: string[], analyzeFile?: IPlugin['analyzeFile']): IPlugin { - return { - id, - name: id, - version: '1.0.0', - apiVersion: '2', - supportedExtensions: extensions, - analyzeFile, - }; -} - -function relation(overrides: Partial): Relation { - return { - kind: 'import', - sourceId: 'source', - fromFilePath: 'src/source.ts', - ...overrides, - }; -} +import type { IRoutablePluginInfo } from '../../src/plugins/routing/router/lookups'; +import { plugin, relation } from './routingFixture'; describe('plugins/routing', () => { - it('returns extension-specific plugins followed by wildcard plugins', () => { - const plugins = new Map([ - ['typescript', { plugin: plugin('typescript', ['.ts']) }], - ['wildcard', { plugin: plugin('wildcard', ['*']) }], - ]); - const extensionMap = new Map([ - ['.ts', ['typescript']], - ['*', ['wildcard']], - ]); - - expect(getPluginsForExtension('.TS', plugins, extensionMap).map((candidate) => candidate.id)).toEqual([ - 'typescript', - 'wildcard', - ]); - }); - it('projects file analysis relations with plugin provenance and normalized defaults', () => { const analysis: IFileAnalysisResult = { filePath: 'src/source.ts', @@ -281,133 +234,4 @@ describe('plugins/routing', () => { edgeTypes: [], }); }); - - it('matches plugin declarations case-insensitively after normalization', () => { - const plugins = new Map([ - ['typescript', { plugin: plugin('typescript', ['.TS']) }], - ['wildcard', { plugin: plugin('wildcard', ['*']) }], - ]); - const extensionMap = new Map([ - ['.ts', ['typescript']], - ['*', ['wildcard']], - ]); - - expect(getPluginsForExtension('TS', plugins, extensionMap).map((candidate) => candidate.id)).toEqual([ - 'typescript', - 'wildcard', - ]); - }); - - it('routes through the registry facade for wildcard support checks', () => { - const registry = new CorePluginRegistry(); - registry.register(plugin('wildcard', ['*'])); - - expect(registry.supportsFile('src/app.anything')).toBe(true); - expect(registry.getPluginsForExtension('.anything').map((candidate) => candidate.id)).toEqual(['wildcard']); - }); - - it('returns the first available plugin for a file, falling back to wildcard plugins', () => { - const wildcard = plugin('wildcard', ['*']); - const plugins = new Map([ - ['wildcard', { plugin: wildcard }], - ]); - const extensionMap = new Map([ - ['.ts', ['missing-typescript']], - ['*', ['wildcard']], - ]); - - expect(getPluginForFile('src/app.ts', plugins, extensionMap)).toBe(wildcard); - expect(getPluginForFile('src/app.rb', new Map(), new Map())).toBeUndefined(); - }); - - it('returns plugin and plugin-info lists for matching file extensions', () => { - const typescript = plugin('typescript', ['.ts']); - const wildcard = plugin('wildcard', ['*']); - const plugins = new Map([ - ['typescript', { plugin: typescript, options: { strict: true } }], - ['wildcard', { plugin: wildcard }], - ]); - const extensionMap = new Map([ - ['.ts', ['typescript', 'missing']], - ['*', ['wildcard']], - ]); - - expect(getPluginsForFile('src/app.TS', plugins, extensionMap)).toEqual([typescript, wildcard]); - expect(getPluginInfosForFile('src/app.ts', plugins, extensionMap)).toEqual([ - { plugin: typescript, options: { strict: true } }, - { plugin: wildcard }, - ]); - }); - - it('reports support and supported extension keys from the extension map', () => { - const extensionMap = new Map([ - ['.ts', ['typescript']], - ['*', ['wildcard']], - ]); - - expect(supportsFile('src/app.ts', extensionMap)).toBe(true); - expect(supportsFile('src/app.rb', new Map([['.ts', ['typescript']]]))).toBe(false); - expect(supportsFile('src/app.rb', extensionMap)).toBe(true); - expect(getSupportedExtensions(extensionMap)).toEqual(['.ts', '*']); - }); - - it('builds stable relation keys from base relation identity fields', () => { - expect(getRelationKey(relation({ - kind: 'import', - sourceId: 'import-source', - fromFilePath: 'src/a.ts', - fromNodeId: 'node:a', - fromSymbolId: 'symbol:a', - specifier: './b', - type: 'static', - variant: 'value', - toFilePath: 'src/b.ts', - }))).toBe('import|import-source|src/a.ts|node:a|symbol:a|./b|static|value'); - }); - - it('adds node and symbol destinations for non-resolved relation kinds', () => { - expect(getRelationKey(relation({ - kind: 'import', - toFilePath: 'src/b.ts', - toNodeId: 'node:b', - toSymbolId: 'symbol:b', - resolvedPath: 'src/resolved.ts', - }))).toBe('import|source|src/source.ts||||||node:b|symbol:b'); - }); - - it('adds file, node, symbol, and resolved destinations for call and reference relations', () => { - expect(getRelationKey(relation({ - kind: 'call', - sourceId: 'call-run', - specifier: 'run', - toFilePath: 'src/run.ts', - toNodeId: 'node:run', - toSymbolId: 'symbol:run', - resolvedPath: 'src/run.ts', - }))).toBe('call|call-run|src/source.ts|||run|||src/run.ts|node:run|symbol:run|src/run.ts'); - - expect(getRelationKey(relation({ - kind: 'reference', - sourceId: 'reference-user', - toFilePath: 'src/user.ts', - }))).toBe('reference|reference-user|src/source.ts||||||src/user.ts|||'); - }); - - it('includes resolved destinations for event relations', () => { - expect(getRelationKey(relation({ - kind: 'event', - sourceId: 'unity-event', - fromSymbolId: 'button-component', - specifier: 'Toggle', - toFilePath: 'src/controls-hint.ts', - resolvedPath: 'src/controls-hint.ts', - }))).not.toBe(getRelationKey(relation({ - kind: 'event', - sourceId: 'unity-event', - fromSymbolId: 'button-component', - specifier: 'Toggle', - toFilePath: 'src/menu-controller.ts', - resolvedPath: 'src/menu-controller.ts', - }))); - }); }); diff --git a/packages/core/tests/plugins/routingFixture.ts b/packages/core/tests/plugins/routingFixture.ts new file mode 100644 index 000000000..7f7b07b56 --- /dev/null +++ b/packages/core/tests/plugins/routingFixture.ts @@ -0,0 +1,27 @@ +import type { IFileAnalysisResult, IPlugin } from '@codegraphy-dev/plugin-api'; + +export type Relation = NonNullable[number]; + +export function plugin( + id: string, + extensions: string[], + analyzeFile?: IPlugin['analyzeFile'], +): IPlugin { + return { + id, + name: id, + version: '1.0.0', + apiVersion: '3', + supportedExtensions: extensions, + analyzeFile, + }; +} + +export function relation(overrides: Partial): Relation { + return { + kind: 'import', + sourceId: 'source', + fromFilePath: 'src/source.ts', + ...overrides, + }; +} diff --git a/packages/core/tests/plugins/routingLookups.test.ts b/packages/core/tests/plugins/routingLookups.test.ts new file mode 100644 index 000000000..82cad52c1 --- /dev/null +++ b/packages/core/tests/plugins/routingLookups.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest'; +import { CorePluginRegistry, getPluginsForExtension } from '../../src'; +import { + getPluginForFile, + getPluginInfosForFile, + getPluginsForFile, + getSupportedExtensions, + supportsFile, + type IRoutablePluginInfo, +} from '../../src/plugins/routing/router/lookups'; +import { plugin } from './routingFixture'; + +describe('plugins/routing', () => { + it('returns extension-specific plugins followed by wildcard plugins', () => { + const plugins = new Map([ + ['typescript', { plugin: plugin('typescript', ['.ts']) }], + ['wildcard', { plugin: plugin('wildcard', ['*']) }], + ]); + const extensionMap = new Map([ + ['.ts', ['typescript']], + ['*', ['wildcard']], + ]); + + expect(getPluginsForExtension('.TS', plugins, extensionMap).map((candidate) => candidate.id)).toEqual([ + 'typescript', + 'wildcard', + ]); + }); + + it('matches plugin declarations case-insensitively after normalization', () => { + const plugins = new Map([ + ['typescript', { plugin: plugin('typescript', ['.TS']) }], + ['wildcard', { plugin: plugin('wildcard', ['*']) }], + ]); + const extensionMap = new Map([ + ['.ts', ['typescript']], + ['*', ['wildcard']], + ]); + + expect(getPluginsForExtension('TS', plugins, extensionMap).map((candidate) => candidate.id)).toEqual([ + 'typescript', + 'wildcard', + ]); + }); + + it('routes through the registry facade for wildcard support checks', () => { + const registry = new CorePluginRegistry(); + registry.register(plugin('wildcard', ['*'])); + + expect(registry.supportsFile('src/app.anything')).toBe(true); + expect(registry.getPluginsForExtension('.anything').map((candidate) => candidate.id)).toEqual(['wildcard']); + }); + + it('returns the first available plugin for a file, falling back to wildcard plugins', () => { + const wildcard = plugin('wildcard', ['*']); + const plugins = new Map([ + ['wildcard', { plugin: wildcard }], + ]); + const extensionMap = new Map([ + ['.ts', ['missing-typescript']], + ['*', ['wildcard']], + ]); + + expect(getPluginForFile('src/app.ts', plugins, extensionMap)).toBe(wildcard); + expect(getPluginForFile('src/app.rb', new Map(), new Map())).toBeUndefined(); + }); + + it('returns plugin and plugin-info lists for matching file extensions', () => { + const typescript = plugin('typescript', ['.ts']); + const wildcard = plugin('wildcard', ['*']); + const plugins = new Map([ + ['typescript', { plugin: typescript, options: { strict: true } }], + ['wildcard', { plugin: wildcard }], + ]); + const extensionMap = new Map([ + ['.ts', ['typescript', 'missing']], + ['*', ['wildcard']], + ]); + + expect(getPluginsForFile('src/app.TS', plugins, extensionMap)).toEqual([typescript, wildcard]); + expect(getPluginInfosForFile('src/app.ts', plugins, extensionMap)).toEqual([ + { plugin: typescript, options: { strict: true } }, + { plugin: wildcard }, + ]); + }); + + it('reports support and supported extension keys from the extension map', () => { + const extensionMap = new Map([ + ['.ts', ['typescript']], + ['*', ['wildcard']], + ]); + + expect(supportsFile('src/app.ts', extensionMap)).toBe(true); + expect(supportsFile('src/app.rb', new Map([['.ts', ['typescript']]]))).toBe(false); + expect(supportsFile('src/app.rb', extensionMap)).toBe(true); + expect(getSupportedExtensions(extensionMap)).toEqual(['.ts', '*']); + }); +}); diff --git a/packages/core/tests/plugins/routingRelationKeys.test.ts b/packages/core/tests/plugins/routingRelationKeys.test.ts new file mode 100644 index 000000000..3172db974 --- /dev/null +++ b/packages/core/tests/plugins/routingRelationKeys.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import { getRelationKey } from '../../src/plugins/routing/router/results/keys'; +import { relation } from './routingFixture'; + +describe('plugins/routing', () => { + it('builds stable relation keys from base relation identity fields', () => { + expect(getRelationKey(relation({ + kind: 'import', + sourceId: 'import-source', + fromFilePath: 'src/a.ts', + fromNodeId: 'node:a', + fromSymbolId: 'symbol:a', + specifier: './b', + type: 'static', + variant: 'value', + toFilePath: 'src/b.ts', + }))).toBe('import|import-source|src/a.ts|node:a|symbol:a|./b|static|value'); + }); + + it('adds node and symbol destinations for non-resolved relation kinds', () => { + expect(getRelationKey(relation({ + kind: 'import', + toFilePath: 'src/b.ts', + toNodeId: 'node:b', + toSymbolId: 'symbol:b', + resolvedPath: 'src/resolved.ts', + }))).toBe('import|source|src/source.ts||||||node:b|symbol:b'); + }); + + it('adds file, node, symbol, and resolved destinations for call and reference relations', () => { + expect(getRelationKey(relation({ + kind: 'call', + sourceId: 'call-run', + specifier: 'run', + toFilePath: 'src/run.ts', + toNodeId: 'node:run', + toSymbolId: 'symbol:run', + resolvedPath: 'src/run.ts', + }))).toBe('call|call-run|src/source.ts|||run|||src/run.ts|node:run|symbol:run|src/run.ts'); + + expect(getRelationKey(relation({ + kind: 'reference', + sourceId: 'reference-user', + toFilePath: 'src/user.ts', + }))).toBe('reference|reference-user|src/source.ts||||||src/user.ts|||'); + }); + + it('includes resolved destinations for event relations', () => { + expect(getRelationKey(relation({ + kind: 'event', + sourceId: 'unity-event', + fromSymbolId: 'button-component', + specifier: 'Toggle', + toFilePath: 'src/controls-hint.ts', + resolvedPath: 'src/controls-hint.ts', + }))).not.toBe(getRelationKey(relation({ + kind: 'event', + sourceId: 'unity-event', + fromSymbolId: 'button-component', + specifier: 'Toggle', + toFilePath: 'src/menu-controller.ts', + resolvedPath: 'src/menu-controller.ts', + }))); + }); +}); diff --git a/packages/core/tests/plugins/status.test.ts b/packages/core/tests/plugins/status.test.ts index b993bd88c..068b49534 100644 --- a/packages/core/tests/plugins/status.test.ts +++ b/packages/core/tests/plugins/status.test.ts @@ -3,14 +3,10 @@ import { buildWorkspaceIndexPluginStatuses, countWorkspaceIndexPluginConnections, getRegisteredWorkspaceIndexPluginPackageNames, - getWorkspaceIndexPluginMatchingFiles, - getWorkspaceIndexPluginNameForFile, getWorkspaceIndexPluginStatuses, - resolveWorkspaceIndexPluginNameForFile, - supportsWorkspaceIndexPluginExtension, } from '../../src'; -describe('plugins/status', () => { +describe('plugins/status construction', () => { it('counts resolved connections owned by matching plugins', () => { expect( countWorkspaceIndexPluginConnections( @@ -48,7 +44,7 @@ describe('plugins/status', () => { ]), installedPlugins: [ { - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', disclosures: [], package: '@codegraphy-dev/plugin-typescript', packageRoot: '/plugins/typescript', @@ -58,7 +54,7 @@ describe('plugins/status', () => { supportedExtensions: ['.ts'], }, { - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', disclosures: [], package: '@acme/missing', packageRoot: '/plugins/missing', @@ -121,7 +117,7 @@ describe('plugins/status', () => { discoveredFiles: [{ relativePath: 'src/index.ts' }], fileConnections: new Map(), installedPlugins: [{ - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', disclosures: [], package: '@codegraphy-dev/plugin-typescript', packageRoot: '/plugins/typescript', @@ -159,7 +155,7 @@ describe('plugins/status', () => { discoveredFiles: [{ relativePath: 'src/index.ts' }], fileConnections: new Map(), installedPlugins: [{ - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', disclosures: [], package: '@codegraphy-dev/plugin-typescript', packageRoot: '/plugins/typescript', @@ -223,7 +219,7 @@ describe('plugins/status', () => { id: 'plugin.typescript', name: 'TypeScript', version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', supportedExtensions: ['.ts'], sources: [], }, @@ -248,76 +244,4 @@ describe('plugins/status', () => { ]); expect(list).toHaveBeenCalledOnce(); }); - - it('returns undefined for plugin names when no workspace root is available', () => { - const getPluginForFile = vi.fn(); - - expect( - resolveWorkspaceIndexPluginNameForFile( - 'src/index.ts', - '', - () => undefined, - { getPluginForFile } as never, - ), - ).toBeUndefined(); - expect(getPluginForFile).not.toHaveBeenCalled(); - }); - - it('resolves plugin names from the current workspace root when no cached root exists', () => { - const getWorkspaceRoot = vi.fn(() => '/workspace'); - const getPluginForFile = vi.fn(() => ({ name: 'TypeScript' })); - - expect( - resolveWorkspaceIndexPluginNameForFile( - 'src/index.ts', - '', - getWorkspaceRoot, - { getPluginForFile } as never, - ), - ).toBe('TypeScript'); - expect(getWorkspaceRoot).toHaveBeenCalledOnce(); - expect(getPluginForFile).toHaveBeenCalledWith('/workspace/src/index.ts'); - }); - - it('prefers the cached workspace root when resolving plugin names', () => { - const getWorkspaceRoot = vi.fn(() => '/other'); - const getPluginForFile = vi.fn(() => ({ name: 'TypeScript' })); - - expect( - resolveWorkspaceIndexPluginNameForFile( - 'src/index.ts', - '/workspace', - getWorkspaceRoot, - { getPluginForFile } as never, - ), - ).toBe('TypeScript'); - expect(getWorkspaceRoot).not.toHaveBeenCalled(); - expect(getPluginForFile).toHaveBeenCalledWith('/workspace/src/index.ts'); - }); - - it('returns undefined when the resolved plugin lookup has no match', () => { - const getPluginForFile = vi.fn(() => undefined); - - expect( - getWorkspaceIndexPluginNameForFile( - 'src/index.ts', - '/workspace', - { getPluginForFile } as never, - ), - ).toBeUndefined(); - expect(getPluginForFile).toHaveBeenCalledWith('/workspace/src/index.ts'); - }); - - it('matches plugin files case-insensitively for targeted refreshes', () => { - expect(supportsWorkspaceIndexPluginExtension(['.TS'], '.ts')).toBe(true); - expect( - getWorkspaceIndexPluginMatchingFiles( - { plugin: { supportedExtensions: ['.TS'] } }, - [ - { relativePath: 'src/app.ts' }, - { relativePath: 'README.md' }, - ], - ), - ).toEqual([{ relativePath: 'src/app.ts' }]); - }); }); diff --git a/packages/core/tests/plugins/statusResolution.test.ts b/packages/core/tests/plugins/statusResolution.test.ts new file mode 100644 index 000000000..4e4819671 --- /dev/null +++ b/packages/core/tests/plugins/statusResolution.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + getWorkspaceIndexPluginMatchingFiles, + getWorkspaceIndexPluginNameForFile, + resolveWorkspaceIndexPluginNameForFile, + supportsWorkspaceIndexPluginExtension, +} from '../../src'; + +describe('plugins/status resolution', () => { + it('returns undefined for plugin names when no workspace root is available', () => { + const getPluginForFile = vi.fn(); + + expect( + resolveWorkspaceIndexPluginNameForFile( + 'src/index.ts', + '', + () => undefined, + { getPluginForFile } as never, + ), + ).toBeUndefined(); + expect(getPluginForFile).not.toHaveBeenCalled(); + }); + + it('resolves plugin names from the current workspace root when no cached root exists', () => { + const getWorkspaceRoot = vi.fn(() => '/workspace'); + const getPluginForFile = vi.fn(() => ({ name: 'TypeScript' })); + + expect( + resolveWorkspaceIndexPluginNameForFile( + 'src/index.ts', + '', + getWorkspaceRoot, + { getPluginForFile } as never, + ), + ).toBe('TypeScript'); + expect(getWorkspaceRoot).toHaveBeenCalledOnce(); + expect(getPluginForFile).toHaveBeenCalledWith('/workspace/src/index.ts'); + }); + + it('prefers the cached workspace root when resolving plugin names', () => { + const getWorkspaceRoot = vi.fn(() => '/other'); + const getPluginForFile = vi.fn(() => ({ name: 'TypeScript' })); + + expect( + resolveWorkspaceIndexPluginNameForFile( + 'src/index.ts', + '/workspace', + getWorkspaceRoot, + { getPluginForFile } as never, + ), + ).toBe('TypeScript'); + expect(getWorkspaceRoot).not.toHaveBeenCalled(); + expect(getPluginForFile).toHaveBeenCalledWith('/workspace/src/index.ts'); + }); + + it('returns undefined when the resolved plugin lookup has no match', () => { + const getPluginForFile = vi.fn(() => undefined); + + expect( + getWorkspaceIndexPluginNameForFile( + 'src/index.ts', + '/workspace', + { getPluginForFile } as never, + ), + ).toBeUndefined(); + expect(getPluginForFile).toHaveBeenCalledWith('/workspace/src/index.ts'); + }); + + it('matches plugin files case-insensitively for targeted refreshes', () => { + expect(supportsWorkspaceIndexPluginExtension(['.TS'], '.ts')).toBe(true); + expect( + getWorkspaceIndexPluginMatchingFiles( + { plugin: { supportedExtensions: ['.TS'] } }, + [ + { relativePath: 'src/app.ts' }, + { relativePath: 'README.md' }, + ], + ), + ).toEqual([{ relativePath: 'src/app.ts' }]); + }); +}); diff --git a/packages/core/tests/workspace/coreBackedCommands.test.ts b/packages/core/tests/workspace/coreBackedCommands.test.ts new file mode 100644 index 000000000..abfe07048 --- /dev/null +++ b/packages/core/tests/workspace/coreBackedCommands.test.ts @@ -0,0 +1,167 @@ +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import { collectDiagnosticEvents } from '../../src/diagnostics/events'; +import { requestCodeGraphyIndexWorkspace } from '../../src/workspace/requestIndexing'; +import { requestWorkspaceGraphQuery } from '../../src/workspace/requestQuery'; +import { readCodeGraphyWorkspaceStatusForCli } from '../../src/workspace/requestStatus'; + +describe('core-backed CodeGraphy Workspace commands', () => { + it('indexes, reports fresh status, and queries a workspace without VS Code', async () => { + const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-mcp-workspace-')); + await fs.writeFile(path.join(workspaceRoot, 'Home.md'), 'See [[Target.md]].\n', 'utf-8'); + await fs.writeFile(path.join(workspaceRoot, 'Target.md'), 'Done.\n', 'utf-8'); + + const indexResult = await requestCodeGraphyIndexWorkspace({ + workspacePath: workspaceRoot, + }); + const status = readCodeGraphyWorkspaceStatusForCli({ + workspacePath: workspaceRoot, + }); + const queryResult = await requestWorkspaceGraphQuery({ + workspacePath: workspaceRoot, + report: 'edges', + arguments: { from: 'Home.md' }, + }); + + expect(indexResult).toMatchObject({ + workspaceRoot, + graphCache: '.codegraphy/graph.lbug', + }); + expect(status).toMatchObject({ + workspaceRoot, + state: 'fresh', + enabledPlugins: ['codegraphy.markdown'], + }); + expect(queryResult.edges).toEqual([ + expect.objectContaining({ + from: 'Home.md', + to: 'Target.md', + }), + ]); + }); + + it('emits high-signal verbose diagnostics for indexing requests', async () => { + const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-diagnostics-workspace-')); + await fs.writeFile(path.join(workspaceRoot, 'Home.md'), 'See [[Target.md]].\n', 'utf-8'); + await fs.writeFile(path.join(workspaceRoot, 'Target.md'), 'Done.\n', 'utf-8'); + const diagnostics = collectDiagnosticEvents(true); + + await requestCodeGraphyIndexWorkspace({ + workspacePath: workspaceRoot, + diagnostics, + }); + + expect(diagnostics.events).toEqual(expect.arrayContaining([ + expect.objectContaining({ + area: 'workspace', + event: 'index-started', + context: expect.objectContaining({ + operationId: expect.any(String), + workspaceRoot, + }), + }), + expect.objectContaining({ + area: 'indexing', + event: 'completed', + context: expect.objectContaining({ + operationId: expect.any(String), + files: 2, + nodes: 2, + graphCache: '.codegraphy/graph.lbug', + }), + }), + ])); + expect(diagnostics.events).toEqual(expect.arrayContaining([ + expect.objectContaining({ + area: 'indexing', + event: 'phase-completed', + context: expect.objectContaining({ + phase: 'discover-files', + durationMs: expect.any(Number), + files: 2, + }), + }), + expect.objectContaining({ + area: 'indexing', + event: 'phase-completed', + context: expect.objectContaining({ + phase: 'analyze-files', + durationMs: expect.any(Number), + files: 2, + }), + }), + expect.objectContaining({ + area: 'indexing', + event: 'phase-completed', + context: expect.objectContaining({ + phase: 'build-graph', + durationMs: expect.any(Number), + nodes: 2, + }), + }), + ])); + }); + + it('emits factual verbose diagnostics for status requests', async () => { + const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-status-diagnostics-')); + const diagnostics = collectDiagnosticEvents(true); + + readCodeGraphyWorkspaceStatusForCli({ + workspacePath: workspaceRoot, + diagnostics, + }); + + expect(diagnostics.events).toContainEqual(expect.objectContaining({ + area: 'workspace', + event: 'status-read', + context: expect.objectContaining({ + workspaceRoot, + state: 'missing', + hasGraphCache: false, + staleReasons: ['never-indexed'], + enabledPluginCount: 1, + }), + })); + }); + + it('emits factual verbose diagnostics for graph query requests', async () => { + const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-query-diagnostics-')); + await fs.writeFile(path.join(workspaceRoot, 'Home.md'), 'See [[Target.md]].\n', 'utf-8'); + await fs.writeFile(path.join(workspaceRoot, 'Target.md'), 'Done.\n', 'utf-8'); + await requestCodeGraphyIndexWorkspace({ workspacePath: workspaceRoot }); + const diagnostics = collectDiagnosticEvents(true); + + await requestWorkspaceGraphQuery({ + workspacePath: workspaceRoot, + report: 'edges', + arguments: { from: 'Home.md' }, + diagnostics, + }); + + expect(diagnostics.events).toEqual(expect.arrayContaining([ + expect.objectContaining({ + area: 'graph-query', + event: 'started', + context: expect.objectContaining({ + operationId: expect.any(String), + workspaceRoot, + report: 'edges', + }), + }), + expect.objectContaining({ + area: 'graph-query', + event: 'completed', + context: expect.objectContaining({ + operationId: expect.any(String), + report: 'edges', + cacheState: 'fresh', + nodeCount: 2, + edgeCount: 1, + }), + }), + ])); + }); +}); diff --git a/packages/core/tests/workspace/coreBacked.test.ts b/packages/core/tests/workspace/coreBackedPluginVisibility.test.ts similarity index 51% rename from packages/core/tests/workspace/coreBacked.test.ts rename to packages/core/tests/workspace/coreBackedPluginVisibility.test.ts index 03d36762a..a07569ef6 100644 --- a/packages/core/tests/workspace/coreBacked.test.ts +++ b/packages/core/tests/workspace/coreBackedPluginVisibility.test.ts @@ -4,170 +4,11 @@ import * as path from 'node:path'; import type { IPlugin } from '@codegraphy-dev/plugin-api'; import { describe, expect, it } from 'vitest'; -import { collectDiagnosticEvents } from '../../src/diagnostics/events'; import { indexCodeGraphyWorkspace } from '../../src/indexing/workspace'; -import { requestCodeGraphyIndexWorkspace } from '../../src/workspace/requestIndexing'; import { requestWorkspaceGraphQuery } from '../../src/workspace/requestQuery'; import { writeCodeGraphyWorkspaceSettings } from '../../src/workspace/settings'; -import { readCodeGraphyWorkspaceStatusForCli } from '../../src/workspace/requestStatus'; - -describe('core-backed CodeGraphy Workspace commands', () => { - it('indexes, reports fresh status, and queries a workspace without VS Code', async () => { - const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-mcp-workspace-')); - await fs.writeFile(path.join(workspaceRoot, 'Home.md'), 'See [[Target.md]].\n', 'utf-8'); - await fs.writeFile(path.join(workspaceRoot, 'Target.md'), 'Done.\n', 'utf-8'); - - const indexResult = await requestCodeGraphyIndexWorkspace({ - workspacePath: workspaceRoot, - }); - const status = readCodeGraphyWorkspaceStatusForCli({ - workspacePath: workspaceRoot, - }); - const queryResult = await requestWorkspaceGraphQuery({ - workspacePath: workspaceRoot, - report: 'edges', - arguments: { from: 'Home.md' }, - }); - - expect(indexResult).toMatchObject({ - workspaceRoot, - graphCache: '.codegraphy/graph.lbug', - }); - expect(status).toMatchObject({ - workspaceRoot, - state: 'fresh', - enabledPlugins: ['codegraphy.markdown'], - }); - expect(queryResult.edges).toEqual([ - expect.objectContaining({ - from: 'Home.md', - to: 'Target.md', - }), - ]); - }); - - it('emits high-signal verbose diagnostics for indexing requests', async () => { - const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-diagnostics-workspace-')); - await fs.writeFile(path.join(workspaceRoot, 'Home.md'), 'See [[Target.md]].\n', 'utf-8'); - await fs.writeFile(path.join(workspaceRoot, 'Target.md'), 'Done.\n', 'utf-8'); - const diagnostics = collectDiagnosticEvents(true); - - await requestCodeGraphyIndexWorkspace({ - workspacePath: workspaceRoot, - diagnostics, - }); - - expect(diagnostics.events).toEqual(expect.arrayContaining([ - expect.objectContaining({ - area: 'workspace', - event: 'index-started', - context: expect.objectContaining({ - operationId: expect.any(String), - workspaceRoot, - }), - }), - expect.objectContaining({ - area: 'indexing', - event: 'completed', - context: expect.objectContaining({ - operationId: expect.any(String), - files: 2, - nodes: 2, - graphCache: '.codegraphy/graph.lbug', - }), - }), - ])); - expect(diagnostics.events).toEqual(expect.arrayContaining([ - expect.objectContaining({ - area: 'indexing', - event: 'phase-completed', - context: expect.objectContaining({ - phase: 'discover-files', - durationMs: expect.any(Number), - files: 2, - }), - }), - expect.objectContaining({ - area: 'indexing', - event: 'phase-completed', - context: expect.objectContaining({ - phase: 'analyze-files', - durationMs: expect.any(Number), - files: 2, - }), - }), - expect.objectContaining({ - area: 'indexing', - event: 'phase-completed', - context: expect.objectContaining({ - phase: 'build-graph', - durationMs: expect.any(Number), - nodes: 2, - }), - }), - ])); - }); - - it('emits factual verbose diagnostics for status requests', async () => { - const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-status-diagnostics-')); - const diagnostics = collectDiagnosticEvents(true); - - readCodeGraphyWorkspaceStatusForCli({ - workspacePath: workspaceRoot, - diagnostics, - }); - - expect(diagnostics.events).toContainEqual(expect.objectContaining({ - area: 'workspace', - event: 'status-read', - context: expect.objectContaining({ - workspaceRoot, - state: 'missing', - hasGraphCache: false, - staleReasons: ['never-indexed'], - enabledPluginCount: 1, - }), - })); - }); - - it('emits factual verbose diagnostics for graph query requests', async () => { - const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-query-diagnostics-')); - await fs.writeFile(path.join(workspaceRoot, 'Home.md'), 'See [[Target.md]].\n', 'utf-8'); - await fs.writeFile(path.join(workspaceRoot, 'Target.md'), 'Done.\n', 'utf-8'); - await requestCodeGraphyIndexWorkspace({ workspacePath: workspaceRoot }); - const diagnostics = collectDiagnosticEvents(true); - - await requestWorkspaceGraphQuery({ - workspacePath: workspaceRoot, - report: 'edges', - arguments: { from: 'Home.md' }, - diagnostics, - }); - - expect(diagnostics.events).toEqual(expect.arrayContaining([ - expect.objectContaining({ - area: 'graph-query', - event: 'started', - context: expect.objectContaining({ - operationId: expect.any(String), - workspaceRoot, - report: 'edges', - }), - }), - expect.objectContaining({ - area: 'graph-query', - event: 'completed', - context: expect.objectContaining({ - operationId: expect.any(String), - report: 'edges', - cacheState: 'fresh', - nodeCount: 2, - edgeCount: 1, - }), - }), - ])); - }); +describe('core-backed CodeGraphy Workspace plugin visibility', () => { it('excludes disabled plugin facts from graph query reports', async () => { const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-query-disabled-plugin-')); const entryPath = path.join(workspaceRoot, 'entry.plug'); @@ -178,7 +19,7 @@ describe('core-backed CodeGraphy Workspace commands', () => { id: pluginId, name: 'Example Query Plugin', version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', supportedExtensions: ['.plug'], async analyzeFile(filePath) { if (filePath !== entryPath) { @@ -267,7 +108,7 @@ describe('core-backed CodeGraphy Workspace commands', () => { id: pluginId, name: 'Absent Query Plugin', version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', supportedExtensions: ['.plug'], async analyzeFile(filePath) { if (filePath !== entryPath) { diff --git a/packages/core/tests/workspace/status.test.ts b/packages/core/tests/workspace/status.test.ts index 77daad1ae..25be014dc 100644 --- a/packages/core/tests/workspace/status.test.ts +++ b/packages/core/tests/workspace/status.test.ts @@ -23,7 +23,7 @@ const textPlugin: IPlugin = { id: 'codegraphy.test-text', name: 'Test Text', version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', supportedExtensions: ['.txt'], async analyzeFile(filePath) { return { filePath, relations: [] }; diff --git a/packages/extension/CHANGELOG.md b/packages/extension/CHANGELOG.md index 3d99daca1..04150bcb9 100644 --- a/packages/extension/CHANGELOG.md +++ b/packages/extension/CHANGELOG.md @@ -1,5 +1,46 @@ # @codegraphy-dev/extension +## 5.13.0 + +### Minor Changes + +- Advance the host runtime compatibility protocol to Plugin API 3. Incompatible installed plugins are skipped with a warning instead of aborting workspace initialization. + +- [#308](https://github.com/joesobo/CodeGraphyV4/pull/308) [`ae76da4`](https://github.com/joesobo/CodeGraphyV4/commit/ae76da4c7c59436dcaa7e8776c8145e5b057926d) Thanks [@joesobo](https://github.com/joesobo)! - CodeGraphy now renders the Relationship Graph with its own WebGPU renderer and deterministic WebAssembly force-layout engine instead of `react-force-graph` and `d3-force`. Users get one maintained rendering path with size-aware collisions, stable node stacking, directional edges, device-loss recovery, and the remaining supported 2D graph interactions and settings. The accompanying changesets describe the intentionally removed 3D, Timeline, DAG-layout, Churn-sizing, and Uniform-sizing controls. + + Node bodies and the always-on-top Canvas decoration layer now use the same ascending-size order, stable graph-index tie-break, hover-last order, and hover scale. Labels, images, badges, and plugin Canvas drawings remain a presentation layer above all WebGPU node bodies so labels stay readable; their relative node order matches the body order, but decoration pixels are intentionally not interleaved between WebGPU bodies. + + The Graph View now requires WebGPU and WebAssembly support. When VS Code or the host GPU cannot create a WebGPU device, CodeGraphy shows an unsupported-renderer state rather than falling back to the previous Canvas or Three.js renderers. + + A new public `@codegraphy-dev/graph-renderer` package exposes the renderer contracts, WebGPU renderer, typed-array physics engine, and WASM preparation entry point for browser applications. Package consumers must call `prepareGraphPhysics()` before creating a graph layout and must ship the package's generated WASM asset alongside the JavaScript bundle. Renderer consumers are responsible for their canvas, scheduling, camera, interactions, and recovery UI. The renderer now tries a software adapter when native device creation fails, rejects graph frames that exceed WebGPU buffer limits before changing renderer state, and reports uncaptured runtime GPU errors through the required `onRendererError` callback so hosts can replace the renderer instead of leaving blank output. + + Physics consumers can install host forces through `tick({ beforeIntegration, afterIntegration })`. Owned forces accumulate first, host forces run before integration, and optional finalization runs after collision correction for fixed-coordinate constraints. The package rejects coordinates, velocities, radii, charge multipliers, and force settings outside its documented safe numerical domains instead of silently clamping or resetting nodes. Collision radii remain fixed in graph space: camera zoom never reheats or permanently spreads a settled layout, while a node pinned during dragging still pushes overlapping neighbors aside. Plugin `fx` and `fy` constraints retain per-axis behavior and do not release user or drag pins when a plugin clears them. + + Plugin node and edge colors keep browser CSS compatibility after the WebGPU move. Named colors, HSL, percentage RGB, `currentColor`, and resolvable custom properties are computed in the active Graph View theme before upload; invalid or missing variables fall back to the normal graph color instead of turning black. Plugin and CSS-snippet stylesheet toggles refresh cached GPU colors. Direct `@codegraphy-dev/graph-renderer` consumers must provide concrete hexadecimal, numeric RGB, `color(srgb ...)`, or transparent frame colors. + +- [#308](https://github.com/joesobo/CodeGraphyV4/pull/308) [`b744f20`](https://github.com/joesobo/CodeGraphyV4/commit/b744f20bb1391e9a0c40d3e448a4f3f78bde4974) Thanks [@joesobo](https://github.com/joesobo)! - CodeGraphy now provides one supported 2D Relationship Graph and removes the 3D graph mode, its toolbar toggle, 3D node shapes, 3D camera state, and Three.js renderer settings. Existing workspaces open directly in the 2D graph; saved 3D preferences are ignored. + + This is a breaking Plugin API change. Plugin authors must remove `GraphNodeShape3D`, `shape3D`, `graphMode`, three-dimensional node coordinates (`z`, `fz`, and `vz`), and 3D values in selected-node position payloads. Graph View contributions, drag callbacks, context-menu selectors, and viewport adapters now receive only two-dimensional graph state. The Unity plugin continues to contribute Unity graph data but no longer supplies 3D presentation metadata. + +- [#308](https://github.com/joesobo/CodeGraphyV4/pull/308) [`b5081b8`](https://github.com/joesobo/CodeGraphyV4/commit/b5081b81f0cf90a3132f7b56f1e63d9bfbb0d9a7) Thanks [@joesobo](https://github.com/joesobo)! - Simplify Relationship Graph presentation controls by removing the radial, top-down, and left-to-right DAG layouts and the Uniform node-size mode. Existing saved DAG selections now open in the force-directed layout, and saved Uniform sizing falls back to Connections sizing. + + Use the force-directed layout for graph positioning. Choose Connections when important hubs should be larger, or File Size when node area should reflect file size. These removals do not change indexed graph data, Graph Scope, filters, or plugin-contributed relationships. + +- [#308](https://github.com/joesobo/CodeGraphyV4/pull/308) [`5a65047`](https://github.com/joesobo/CodeGraphyV4/commit/5a65047d1a715f005760ace0ebf0f550a16efa2e) Thanks [@joesobo](https://github.com/joesobo)! - CodeGraphy now opens one current-workspace Relationship Graph and removes the Timeline panel, commit-by-commit Graph Revision playback, revision controls, and Git-history Churn node sizing. Existing workspaces keep their current graph settings, but saved Timeline state and Churn sizing selections no longer affect the graph. Choose Connections or File Size for semantic node sizing. + + This is a breaking Plugin API and Core package change. Plugin authors must remove the `timeline-panel` slot, Timeline lifecycle events and payloads, `timelineActive` contribution/context fields, Timeline analysis mode and `commitSha`, and the optional `churn` graph-node field. Core callers must stop passing churn counts into graph construction. Plugins should analyze the current CodeGraphy Workspace and contribute to the normal Graph View instead of branching on Timeline state. + +### Patch Changes + +- [#308](https://github.com/joesobo/CodeGraphyV4/pull/308) [`c43b50d`](https://github.com/joesobo/CodeGraphyV4/commit/c43b50d53c439d18f96a28f6d5340e752075ceff) Thanks [@joesobo](https://github.com/joesobo)! - Add an optional `FPS · ms` readout to the Relationship Graph's Performance settings. FPS is measured from the intervals between successfully completed rendered frames, while `ms` reports average CPU time spent running graph simulation and rendering work. + + The readout waits for two successful frames before showing FPS, excludes rejected GPU submissions and stale renderer generations, and returns to `— FPS · — ms` when the demand-driven graph becomes idle. The setting is off by default and persists per CodeGraphy Workspace. + +- Updated dependencies [[`ae76da4`](https://github.com/joesobo/CodeGraphyV4/commit/ae76da4c7c59436dcaa7e8776c8145e5b057926d), [`b744f20`](https://github.com/joesobo/CodeGraphyV4/commit/b744f20bb1391e9a0c40d3e448a4f3f78bde4974), [`5a65047`](https://github.com/joesobo/CodeGraphyV4/commit/5a65047d1a715f005760ace0ebf0f550a16efa2e)]: + - @codegraphy-dev/graph-renderer@0.1.0 + - @codegraphy-dev/plugin-api@6.0.0 + - @codegraphy-dev/core@2.0.0 + ## 5.12.3 ### Patch Changes diff --git a/packages/extension/package.json b/packages/extension/package.json index cbce0289d..484f70143 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -1,6 +1,6 @@ { "name": "@codegraphy-dev/extension", - "version": "5.12.3", + "version": "5.13.0", "private": true, "description": "CodeGraphy VS Code extension core", "license": "MIT", diff --git a/packages/extension/src/core/plugins/api/instance/index.ts b/packages/extension/src/core/plugins/api/instance/index.ts index 5e784ba29..d5464d0e7 100644 --- a/packages/extension/src/core/plugins/api/instance/index.ts +++ b/packages/extension/src/core/plugins/api/instance/index.ts @@ -4,6 +4,7 @@ * Tracks all disposables for automatic cleanup on plugin unload. */ +import { CORE_PLUGIN_API_VERSION } from '../../versioning/apiVersions'; import { DisposableStore } from '../../disposable'; import { clearPluginDecorations, decoratePluginEdge, decoratePluginNode } from './runtime/access/decorations'; import { disposePluginApi } from './runtime/access/dispose'; @@ -59,7 +60,7 @@ import type { } from '../contracts'; export class CodeGraphyAPIImpl implements CodeGraphyAPI { - readonly version = '2.0.0'; + readonly version = CORE_PLUGIN_API_VERSION; private readonly _context: ApiContext; constructor( diff --git a/packages/extension/src/core/plugins/versioning/apiVersions.ts b/packages/extension/src/core/plugins/versioning/apiVersions.ts index f85325032..3c1fe3812 100644 --- a/packages/extension/src/core/plugins/versioning/apiVersions.ts +++ b/packages/extension/src/core/plugins/versioning/apiVersions.ts @@ -3,7 +3,7 @@ * @module core/plugins/versioning/apiVersions */ -export const CORE_PLUGIN_API_VERSION = '2.0.0'; +export { CORE_PLUGIN_API_VERSION } from '@codegraphy-dev/core'; export const WEBVIEW_PLUGIN_API_VERSION = '1.0.0'; export type { ISemver } from './semver/value'; diff --git a/packages/extension/src/e2e/fixtures/package-graph-view-plugin/codegraphy.json b/packages/extension/src/e2e/fixtures/package-graph-view-plugin/codegraphy.json index 7ba8248bb..f5fcb9bcc 100644 --- a/packages/extension/src/e2e/fixtures/package-graph-view-plugin/codegraphy.json +++ b/packages/extension/src/e2e/fixtures/package-graph-view-plugin/codegraphy.json @@ -3,7 +3,7 @@ "id": "e2e.graph-view-plugin", "name": "E2E Graph View Plugin", "version": "1.0.0", - "apiVersion": "^2.0.0", + "apiVersion": "^3.0.0", "supportedExtensions": [], "defaultFilters": [] } diff --git a/packages/extension/src/e2e/fixtures/package-graph-view-plugin/package.json b/packages/extension/src/e2e/fixtures/package-graph-view-plugin/package.json index 10ef3994e..7bd643c8c 100644 --- a/packages/extension/src/e2e/fixtures/package-graph-view-plugin/package.json +++ b/packages/extension/src/e2e/fixtures/package-graph-view-plugin/package.json @@ -5,7 +5,7 @@ "exports": "./plugin.js", "codegraphy": { "type": "plugin", - "apiVersion": "^2.0.0", + "apiVersion": "^3.0.0", "disclosures": [] } } diff --git a/packages/extension/src/e2e/fixtures/package-graph-view-plugin/plugin.js b/packages/extension/src/e2e/fixtures/package-graph-view-plugin/plugin.js index e0c5d93ed..038690f3f 100644 --- a/packages/extension/src/e2e/fixtures/package-graph-view-plugin/plugin.js +++ b/packages/extension/src/e2e/fixtures/package-graph-view-plugin/plugin.js @@ -3,7 +3,7 @@ export default function createPlugin() { id: 'e2e.graph-view-plugin', name: 'E2E Graph View Plugin', version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', supportedExtensions: [], webviewApiVersion: '^1.0.0', webviewContributions: { diff --git a/packages/extension/src/extension/pipeline/plugins/bootstrap/packages.ts b/packages/extension/src/extension/pipeline/plugins/bootstrap/packages.ts index e5b9cf993..38a1d7b54 100644 --- a/packages/extension/src/extension/pipeline/plugins/bootstrap/packages.ts +++ b/packages/extension/src/extension/pipeline/plugins/bootstrap/packages.ts @@ -49,7 +49,13 @@ export async function registerWorkspacePackagePlugins( dependencies, ); + const warn = dependencies.warn ?? console.warn; for (const registration of registrations) { - registry.register(registration.plugin, registration.options); + try { + registry.register(registration.plugin, registration.options); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + warn(`CodeGraphy plugin '${registration.plugin.id}' could not be registered: ${message}`); + } } } diff --git a/packages/extension/tests/core/plugins/api/instance/index.test.ts b/packages/extension/tests/core/plugins/api/instance/index.test.ts index b9fc78bd1..619d3fd6e 100644 --- a/packages/extension/tests/core/plugins/api/instance/index.test.ts +++ b/packages/extension/tests/core/plugins/api/instance/index.test.ts @@ -2,8 +2,8 @@ import { describe, expect, it } from 'vitest'; import { createTestAPI } from './runtime/access/testSupport'; describe('CodeGraphyAPIImpl version', () => { - it('exposes version 2.0.0', () => { + it('exposes the current core plugin API version', () => { const { api } = createTestAPI(); - expect(api.version).toBe('2.0.0'); + expect(api.version).toBe('3.0.0'); }); }); diff --git a/packages/extension/tests/core/plugins/lifecycle/notify/analysis.test.ts b/packages/extension/tests/core/plugins/lifecycle/notify/analysis.test.ts index 3331f1617..0c79187cc 100644 --- a/packages/extension/tests/core/plugins/lifecycle/notify/analysis.test.ts +++ b/packages/extension/tests/core/plugins/lifecycle/notify/analysis.test.ts @@ -13,7 +13,7 @@ function makePlugin(overrides: Partial = {}): IPlugin { id: 'test-plugin', name: 'Test Plugin', version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', supportedExtensions: ['.ts'], analyzeFile: vi.fn(async (filePath: string) => ({ filePath, relations: [] })), ...overrides, diff --git a/packages/extension/tests/core/plugins/lifecycle/notify/filesChanged.test.ts b/packages/extension/tests/core/plugins/lifecycle/notify/filesChanged.test.ts index 0775c2ca4..faafb40a5 100644 --- a/packages/extension/tests/core/plugins/lifecycle/notify/filesChanged.test.ts +++ b/packages/extension/tests/core/plugins/lifecycle/notify/filesChanged.test.ts @@ -7,7 +7,7 @@ function makePlugin(overrides: Partial = {}): IPlugin { id: 'test-plugin', name: 'Test Plugin', version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', supportedExtensions: ['.ts'], analyzeFile: vi.fn(async (filePath: string) => ({ filePath, relations: [] })), ...overrides, diff --git a/packages/extension/tests/core/plugins/lifecycle/notify/readiness.test.ts b/packages/extension/tests/core/plugins/lifecycle/notify/readiness.test.ts index 2d31b34c4..a521a8794 100644 --- a/packages/extension/tests/core/plugins/lifecycle/notify/readiness.test.ts +++ b/packages/extension/tests/core/plugins/lifecycle/notify/readiness.test.ts @@ -14,7 +14,7 @@ function makePlugin(overrides: Partial = {}): IPlugin { id: 'test-plugin', name: 'Test Plugin', version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', supportedExtensions: ['.ts'], analyzeFile: vi.fn(async (filePath: string) => ({ filePath, relations: [] })), ...overrides, diff --git a/packages/extension/tests/core/plugins/lifecycle/pluginLifecycle.test.ts b/packages/extension/tests/core/plugins/lifecycle/pluginLifecycle.test.ts index d6658b6a5..fe36b88e2 100644 --- a/packages/extension/tests/core/plugins/lifecycle/pluginLifecycle.test.ts +++ b/packages/extension/tests/core/plugins/lifecycle/pluginLifecycle.test.ts @@ -25,7 +25,7 @@ function makePlugin(overrides: Partial = {}): IPlugin { id: 'test-plugin', name: 'Test Plugin', version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', supportedExtensions: ['.ts'], analyzeFile: vi.fn(async (filePath: string) => ({ filePath, relations: [] })), ...overrides, diff --git a/packages/extension/tests/core/plugins/pluginRouting.analyzefile.test.ts b/packages/extension/tests/core/plugins/pluginRouting.analyzefile.test.ts index 8c9c2f93f..84daec1f8 100644 --- a/packages/extension/tests/core/plugins/pluginRouting.analyzefile.test.ts +++ b/packages/extension/tests/core/plugins/pluginRouting.analyzefile.test.ts @@ -10,7 +10,7 @@ function makePlugin(id: string, extensions: string[]): IPlugin { id, name: id, version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', supportedExtensions: extensions, analyzeFile: vi.fn(async (filePath: string) => ({ filePath, diff --git a/packages/extension/tests/core/plugins/pluginRouting.supportsfiletogetsupportedextensions.test.ts b/packages/extension/tests/core/plugins/pluginRouting.supportsfiletogetsupportedextensions.test.ts index 81db82412..2f522373a 100644 --- a/packages/extension/tests/core/plugins/pluginRouting.supportsfiletogetsupportedextensions.test.ts +++ b/packages/extension/tests/core/plugins/pluginRouting.supportsfiletogetsupportedextensions.test.ts @@ -10,7 +10,7 @@ function makePlugin(id: string, extensions: string[]): IPlugin { id, name: id, version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', supportedExtensions: extensions, analyzeFile: vi.fn(async (filePath: string) => ({ filePath, diff --git a/packages/extension/tests/core/plugins/pluginRouting.test.ts b/packages/extension/tests/core/plugins/pluginRouting.test.ts index e4fa5a5fd..ba2991745 100644 --- a/packages/extension/tests/core/plugins/pluginRouting.test.ts +++ b/packages/extension/tests/core/plugins/pluginRouting.test.ts @@ -10,7 +10,7 @@ function makePlugin(id: string, extensions: string[]): IPlugin { id, name: id, version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', supportedExtensions: extensions, analyzeFile: vi.fn(async (filePath: string) => ({ filePath, diff --git a/packages/extension/tests/core/plugins/registry/errorHandling.logsonloadfailureswiththetoignoresreplayreadinessforplugincallsforunknown.test.ts b/packages/extension/tests/core/plugins/registry/errorHandling.logsonloadfailureswiththetoignoresreplayreadinessforplugincallsforunknown.test.ts index 93a34c219..c9d43a99e 100644 --- a/packages/extension/tests/core/plugins/registry/errorHandling.logsonloadfailureswiththetoignoresreplayreadinessforplugincallsforunknown.test.ts +++ b/packages/extension/tests/core/plugins/registry/errorHandling.logsonloadfailureswiththetoignoresreplayreadinessforplugincallsforunknown.test.ts @@ -10,7 +10,7 @@ function createPlugin(id: string, overrides: Partial = {}): IPlugin { id, name: `Test Plugin ${id}`, version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', supportedExtensions: ['.test'], analyzeFile: vi.fn(async (filePath: string) => ({ filePath, relations: [] })), ...overrides, diff --git a/packages/extension/tests/core/plugins/registry/errorHandling.test.ts b/packages/extension/tests/core/plugins/registry/errorHandling.test.ts index 2dddbc040..ec40c23f2 100644 --- a/packages/extension/tests/core/plugins/registry/errorHandling.test.ts +++ b/packages/extension/tests/core/plugins/registry/errorHandling.test.ts @@ -10,7 +10,7 @@ function createPlugin(id: string, overrides: Partial = {}): IPlugin { id, name: `Test Plugin ${id}`, version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', supportedExtensions: ['.test'], analyzeFile: vi.fn(async (filePath: string) => ({ filePath, relations: [] })), ...overrides, @@ -59,7 +59,7 @@ describe('PluginRegistry error handling', () => { it('accepts whitespace-padded compatible core api ranges', () => { const registry = createConfiguredRegistry(); const plugin = createPlugin('trimmed-core-range', { - apiVersion: ' ^2.0.0 ', + apiVersion: ' ^3.0.0 ', }); expect(() => registry.register(plugin)).not.toThrow(); @@ -74,7 +74,7 @@ describe('PluginRegistry error handling', () => { }); expect(() => registry.register(plugin)).toThrow( - "Plugin 'bad-range-plugin' declares invalid apiVersion 'latest'. Use '^2.0.0' or an exact semver string." + "Plugin 'bad-range-plugin' declares invalid apiVersion 'latest'. Use '^3.0.0' or an exact semver string." ); }); @@ -83,11 +83,11 @@ describe('PluginRegistry error handling', () => { it('reports future core api ranges with the host version in the message', () => { const registry = createConfiguredRegistry(); const plugin = createPlugin('future-plugin', { - apiVersion: '^3.0.0', + apiVersion: '^4.0.0', }); expect(() => registry.register(plugin)).toThrow( - "Plugin 'future-plugin' requires future CodeGraphy Plugin API '^3.0.0', but host provides '2.0.0'." + "Plugin 'future-plugin' requires future CodeGraphy Plugin API '^4.0.0', but host provides '3.0.0'." ); }); @@ -96,11 +96,11 @@ describe('PluginRegistry error handling', () => { it('classifies whitespace-padded future core api ranges as future requirements', () => { const registry = createConfiguredRegistry(); const plugin = createPlugin('trimmed-future-plugin', { - apiVersion: ' ^3.0.0 ', + apiVersion: ' ^4.0.0 ', }); expect(() => registry.register(plugin)).toThrow( - "Plugin 'trimmed-future-plugin' requires future CodeGraphy Plugin API ' ^3.0.0 ', but host provides '2.0.0'." + "Plugin 'trimmed-future-plugin' requires future CodeGraphy Plugin API ' ^4.0.0 ', but host provides '3.0.0'." ); }); @@ -109,11 +109,11 @@ describe('PluginRegistry error handling', () => { it('reports unsupported older core api ranges with the host version in the message', () => { const registry = createConfiguredRegistry(); const plugin = createPlugin('legacy-plugin', { - apiVersion: '^1.0.0', + apiVersion: '^2.0.0', }); expect(() => registry.register(plugin)).toThrow( - "Plugin 'legacy-plugin' targets unsupported CodeGraphy Plugin API '^1.0.0'. Host provides '2.0.0'." + "Plugin 'legacy-plugin' targets unsupported CodeGraphy Plugin API '^2.0.0'. Host provides '3.0.0'." ); }); @@ -122,11 +122,11 @@ describe('PluginRegistry error handling', () => { it('treats same-major newer minor ranges as unsupported instead of future-only', () => { const registry = createConfiguredRegistry(); const plugin = createPlugin('minor-ahead-plugin', { - apiVersion: '^2.1.0', + apiVersion: '^3.1.0', }); expect(() => registry.register(plugin)).toThrow( - "Plugin 'minor-ahead-plugin' targets unsupported CodeGraphy Plugin API '^2.1.0'. Host provides '2.0.0'." + "Plugin 'minor-ahead-plugin' targets unsupported CodeGraphy Plugin API '^3.1.0'. Host provides '3.0.0'." ); }); }); diff --git a/packages/extension/tests/core/plugins/registry/errorHandling.warnswiththefullcompatibilitytologsunloadfailureswiththe.test.ts b/packages/extension/tests/core/plugins/registry/errorHandling.warnswiththefullcompatibilitytologsunloadfailureswiththe.test.ts index 88ec6c341..d1ebf208c 100644 --- a/packages/extension/tests/core/plugins/registry/errorHandling.warnswiththefullcompatibilitytologsunloadfailureswiththe.test.ts +++ b/packages/extension/tests/core/plugins/registry/errorHandling.warnswiththefullcompatibilitytologsunloadfailureswiththe.test.ts @@ -11,7 +11,7 @@ function createPlugin(id: string, overrides: Partial = {}): IPlugin { id, name: `Test Plugin ${id}`, version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', supportedExtensions: ['.test'], analyzeFile: vi.fn(async (filePath: string) => ({ filePath, relations: [] })), ...overrides, diff --git a/packages/extension/tests/core/plugins/registry/extensionMap.test.ts b/packages/extension/tests/core/plugins/registry/extensionMap.test.ts index a54ff46e3..f9ae39a43 100644 --- a/packages/extension/tests/core/plugins/registry/extensionMap.test.ts +++ b/packages/extension/tests/core/plugins/registry/extensionMap.test.ts @@ -7,7 +7,7 @@ function createPlugin(overrides: Partial = {}): IPlugin { id: 'test-plugin', name: 'Test Plugin', version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', supportedExtensions: ['.ts'], analyzeFile: async filePath => ({ filePath, relations: [] }), ...overrides, diff --git a/packages/extension/tests/core/plugins/registry/lookup.test.ts b/packages/extension/tests/core/plugins/registry/lookup.test.ts index d3957869c..9973b3e88 100644 --- a/packages/extension/tests/core/plugins/registry/lookup.test.ts +++ b/packages/extension/tests/core/plugins/registry/lookup.test.ts @@ -10,7 +10,7 @@ function createPlugin(id: string, overrides: Partial = {}): IPlugin { id, name: `Test Plugin ${id}`, version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', supportedExtensions: ['.test'], analyzeFile: vi.fn(async (filePath: string) => ({ filePath, relations: [] })), ...overrides, diff --git a/packages/extension/tests/core/plugins/registry/pluginRegistry.access.test.ts b/packages/extension/tests/core/plugins/registry/pluginRegistry.access.test.ts new file mode 100644 index 000000000..d3696f096 --- /dev/null +++ b/packages/extension/tests/core/plugins/registry/pluginRegistry.access.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createConfiguredRegistry, createMockPlugin } from './pluginRegistry.testSupport'; + +describe('PluginRegistry access', () => { + it('reports plugin availability through registered access providers', async () => { + const registry = createConfiguredRegistry(); + const getAccess = vi.fn(async ({ access, pluginId, workspaceRoot }) => ({ + access, + reason: `${pluginId}:${workspaceRoot}`, + state: 'granted' as const, + })); + registry.register(createMockPlugin({ + id: 'pro-plugin', + accessProvider: { + id: 'pro-access', + provides: ['pro'], + getAccess, + }, + requiresAccess: 'pro', + })); + + await expect(registry.getPluginAvailability('missing')).resolves.toBeUndefined(); + await expect(registry.getPluginAvailability('pro-plugin', { workspaceRoot: '/workspace' })).resolves.toEqual({ + pluginId: 'pro-plugin', + available: true, + access: [{ + access: 'pro', + reason: 'pro-plugin:/workspace', + state: 'granted', + }], + }); + expect(getAccess).toHaveBeenCalledWith({ + access: 'pro', + pluginId: 'pro-plugin', + workspaceRoot: '/workspace', + }); + }); + + it('lists only graph view contributions available to the registry access context', async () => { + const registry = createConfiguredRegistry(); + const availableContribution = { + id: 'available-section', + label: 'Available Section', + createNodes: vi.fn(() => []), + }; + const lockedContribution = { + id: 'locked-section', + label: 'Locked Section', + requiresAccess: 'locked', + createNodes: vi.fn(() => []), + }; + + registry.register(createMockPlugin({ + id: 'organize', + accessProvider: { + id: 'organize-access', + provides: ['pro'], + getAccess: vi.fn(async ({ access }) => ({ access, state: 'granted' as const })), + }, + graphView: { + runtimeNodes: [availableContribution, lockedContribution], + }, + })); + + await expect(registry.listAvailableGraphViewContributions()).resolves.toEqual(expect.objectContaining({ + runtimeNodes: [{ + pluginId: 'organize', + contribution: availableContribution, + }], + })); + }); +}); diff --git a/packages/extension/tests/core/plugins/registry/pluginRegistry.collection.test.ts b/packages/extension/tests/core/plugins/registry/pluginRegistry.collection.test.ts index 11ac62eb2..cb73df0e5 100644 --- a/packages/extension/tests/core/plugins/registry/pluginRegistry.collection.test.ts +++ b/packages/extension/tests/core/plugins/registry/pluginRegistry.collection.test.ts @@ -29,7 +29,7 @@ describe('PluginRegistry collection', () => { expect(registry.get('typescript')?.plugin).toBe(plugin); expect(registry.size).toBe(1); expect(registry.getPluginAPI('typescript')).toEqual( - expect.objectContaining({ version: '2.0.0' }), + expect.objectContaining({ version: '3.0.0' }), ); expect(registry.supportsFile('src/app.ts')).toBe(true); expect(registry.supportsFile('src/app.py')).toBe(false); @@ -58,372 +58,6 @@ describe('PluginRegistry collection', () => { expect(registry.getSupportedExtensions()).toEqual([]); }); - it('returns plugin-contributed node types', () => { - const registry = createConfiguredRegistry(); - registry.register(createMockPlugin({ - id: 'first', - contributeNodeTypes: () => [ - { - id: 'route', - label: 'Route', - defaultColor: '#00ff00', - defaultVisible: true, - }, - ], - })); - registry.register(createMockPlugin({ - id: 'second', - contributeNodeTypes: () => [ - { - id: 'tool', - label: 'Tool', - defaultColor: '#0000ff', - defaultVisible: true, - }, - ], - })); - - expect(registry.listNodeTypes()).toEqual([ - { - id: 'route', - label: 'Route', - defaultColor: '#00ff00', - defaultVisible: true, - }, - { - id: 'tool', - label: 'Tool', - defaultColor: '#0000ff', - defaultVisible: true, - }, - ]); - }); - - it('excludes disabled plugins from graph type and capability contributions', () => { - const registry = createConfiguredRegistry(); - const disabledPlugins = new Set(['godot']); - registry.register(createMockPlugin({ - id: 'godot', - supportedExtensions: ['.gd'], - contributeNodeTypes: () => [ - { - id: 'godot-scene', - label: 'Godot Scene', - defaultColor: '#478CBF', - defaultVisible: true, - }, - ], - contributeEdgeTypes: () => [ - { - id: 'load', - label: 'Loads', - defaultColor: '#478CBF', - defaultVisible: true, - }, - ], - contributeGraphScopeCapabilities: () => ({ - nodeTypes: ['plugin:codegraphy.gdscript:symbol:godot-class-name'], - edgeTypes: ['load'], - }), - })); - registry.register(createMockPlugin({ - id: 'typescript', - supportedExtensions: ['.ts'], - contributeNodeTypes: () => [ - { - id: 'route', - label: 'Route', - defaultColor: '#00ff00', - defaultVisible: true, - }, - ], - contributeEdgeTypes: () => [ - { - id: 'plugin:route', - label: 'Route', - defaultColor: '#00ff00', - defaultVisible: true, - }, - ], - contributeGraphScopeCapabilities: () => ({ - nodeTypes: ['route'], - edgeTypes: ['plugin:route'], - }), - })); - - expect(registry.listNodeTypes(disabledPlugins)).toEqual([ - { - id: 'route', - label: 'Route', - defaultColor: '#00ff00', - defaultVisible: true, - }, - ]); - expect(registry.listEdgeTypes(disabledPlugins)).toEqual([ - { - id: 'plugin:route', - label: 'Route', - defaultColor: '#00ff00', - defaultVisible: true, - }, - ]); - expect(registry.listGraphScopeCapabilities(['game/player.gd', 'src/app.ts'], disabledPlugins)).toEqual({ - nodeTypes: ['route'], - edgeTypes: ['plugin:route'], - }); - }); - - it('includes core graph scope capabilities without treating core analysis as a plugin', () => { - const registry = createConfiguredRegistry(); - registry.setCoreGraphScopeCapabilitiesProvider((filePaths) => { - expect(filePaths).toEqual(['src/app.cpp']); - return { - nodeTypes: ['symbol:function', 'symbol:class'], - edgeTypes: ['import', 'call', 'contains', 'inherit', 'overrides'], - }; - }); - - expect(registry.list()).toEqual([]); - expect(registry.listGraphScopeCapabilities(['src/app.cpp'])).toEqual({ - nodeTypes: ['symbol:function', 'symbol:class'], - edgeTypes: ['import', 'call', 'contains', 'inherit', 'overrides'], - }); - }); - - it('excludes disabled plugins from file analysis', async () => { - const registry = createConfiguredRegistry(); - const analyzeFile = vi.fn(async (filePath: string) => ({ - filePath, - relations: [], - })); - registry.register(createMockPlugin({ - id: 'plugin.disabled', - supportedExtensions: ['.ts'], - analyzeFile, - })); - - await expect( - registry.analyzeFileResult( - '/workspace/src/app.ts', - "import './target'", - '/workspace', - undefined, - { disabledPlugins: new Set(['plugin.disabled']) }, - ), - ).resolves.toBeNull(); - - expect(analyzeFile).not.toHaveBeenCalled(); - }); - - it('excludes disabled plugins from targeted file analysis', async () => { - const registry = createConfiguredRegistry(); - const analyzeFile = vi.fn(async (filePath: string) => ({ - filePath, - relations: [], - })); - registry.register(createMockPlugin({ - id: 'plugin.disabled', - supportedExtensions: ['.ts'], - analyzeFile, - })); - - await expect( - registry.analyzeFileResultForPlugins( - '/workspace/src/app.ts', - "import './target'", - '/workspace', - ['plugin.disabled'], - undefined, - { disabledPlugins: new Set(['plugin.disabled']) }, - ), - ).resolves.toBeNull(); - - expect(analyzeFile).not.toHaveBeenCalled(); - }); - - - it('ignores plugins without node-type contributions when listing node types', () => { - const registry = createConfiguredRegistry(); - registry.register(createMockPlugin({ id: 'noop' })); - registry.register(createMockPlugin({ - id: 'routes', - contributeNodeTypes: () => [ - { - id: 'import', - label: 'Import', - defaultColor: '#22C55E', - defaultVisible: true, - }, - ], - })); - - expect(registry.listNodeTypes()).toEqual([ - { - id: 'import', - label: 'Import', - defaultColor: '#22C55E', - defaultVisible: true, - }, - ]); - }); - - it('returns plugin-contributed edge types with later plugins overriding duplicate ids', () => { - const registry = createConfiguredRegistry(); - registry.register(createMockPlugin({ - id: 'first', - contributeEdgeTypes: () => [ - { - id: 'call', - label: 'Calls', - defaultColor: '#ff0000', - defaultVisible: true, - }, - ], - })); - registry.register(createMockPlugin({ - id: 'second', - contributeEdgeTypes: () => [ - { - id: 'call', - label: 'Calls Override', - defaultColor: '#ffaa00', - defaultVisible: false, - }, - { - id: 'plugin:verify', - label: 'Verifies', - defaultColor: '#00aaff', - defaultVisible: true, - }, - ], - })); - - expect(registry.listEdgeTypes()).toEqual([ - { - id: 'call', - label: 'Calls Override', - defaultColor: '#ffaa00', - defaultVisible: false, - }, - { - id: 'plugin:verify', - label: 'Verifies', - defaultColor: '#00aaff', - defaultVisible: true, - }, - ]); - }); - - it('ignores plugins without edge-type contributions when listing edge types', () => { - const registry = createConfiguredRegistry(); - registry.register(createMockPlugin({ id: 'noop' })); - registry.register(createMockPlugin({ - id: 'routes', - contributeEdgeTypes: () => [ - { - id: 'import', - label: 'Import', - defaultColor: '#22C55E', - defaultVisible: true, - }, - ], - })); - - expect(registry.listEdgeTypes()).toEqual([ - { - id: 'import', - label: 'Import', - defaultColor: '#22C55E', - defaultVisible: true, - }, - ]); - }); - - it('returns graph scope capabilities from plugins that support workspace files', () => { - const registry = createConfiguredRegistry(); - const readTypeScriptCapabilities = vi.fn(() => - ({ - nodeTypes: ['symbol:function', 'symbol:interface'], - edgeTypes: ['import', 'plugin:route'], - }) as const - ); - registry.register(createMockPlugin({ - id: 'typescript', - supportedExtensions: ['.ts'], - contributeGraphScopeCapabilities: readTypeScriptCapabilities, - })); - registry.register(createMockPlugin({ - id: 'python', - supportedExtensions: ['.py'], - contributeGraphScopeCapabilities: () => ({ edgeTypes: ['reference'] }), - })); - registry.register(createMockPlugin({ - id: 'wildcard', - supportedExtensions: ['*'], - contributeGraphScopeCapabilities: () => ({ - nodeTypes: ['plugin:test-node'], - edgeTypes: ['plugin:test'], - }), - })); - - expect(registry.listGraphScopeCapabilities(['src/app.ts'])).toEqual({ - nodeTypes: ['symbol:function', 'symbol:interface', 'plugin:test-node'], - edgeTypes: ['import', 'plugin:route', 'plugin:test'], - }); - expect(readTypeScriptCapabilities).toHaveBeenCalledWith({ - filePaths: ['src/app.ts'], - }); - }); - - it('passes only each plugin applicable workspace files into graph scope capabilities', () => { - const registry = createConfiguredRegistry(); - const readTypeScriptCapabilities = vi.fn(() => ({ edgeTypes: ['import'] }) as const); - const readSvelteCapabilities = vi.fn(() => ({ edgeTypes: ['call'] }) as const); - - registry.register(createMockPlugin({ - id: 'typescript', - supportedExtensions: ['.ts'], - contributeGraphScopeCapabilities: readTypeScriptCapabilities, - })); - registry.register(createMockPlugin({ - id: 'svelte', - supportedExtensions: ['.svelte'], - contributeGraphScopeCapabilities: readSvelteCapabilities, - })); - - expect(registry.listGraphScopeCapabilities(['src/app.ts', 'src/App.svelte'])).toEqual({ - nodeTypes: [], - edgeTypes: ['import', 'call'], - }); - expect(readTypeScriptCapabilities).toHaveBeenCalledWith({ filePaths: ['src/app.ts'] }); - expect(readSvelteCapabilities).toHaveBeenCalledWith({ filePaths: ['src/App.svelte'] }); - }); - - it('deduplicates graph scope capabilities when multiple applicable plugins declare the same kind', () => { - const registry = createConfiguredRegistry(); - registry.register(createMockPlugin({ - id: 'first', - supportedExtensions: ['.ts'], - contributeGraphScopeCapabilities: () => ({ - nodeTypes: ['symbol:function', 'symbol:interface'], - edgeTypes: ['import', 'reference'], - }), - })); - registry.register(createMockPlugin({ - id: 'second', - supportedExtensions: ['.tsx'], - contributeGraphScopeCapabilities: () => ({ - nodeTypes: ['symbol:function', 'symbol:class'], - edgeTypes: ['import', 'call'], - }), - })); - - expect(registry.listGraphScopeCapabilities(['src/app.ts', 'src/view.tsx'])).toEqual({ - nodeTypes: ['symbol:function', 'symbol:interface', 'symbol:class'], - edgeTypes: ['import', 'reference', 'call'], - }); - }); - it('disposes every registered plugin through unregister', () => { const registry = createConfiguredRegistry(); registry.register(createMockPlugin({ id: 'first' })); @@ -458,72 +92,4 @@ describe('PluginRegistry collection', () => { expect(plugin.onWorkspaceReady).toHaveBeenCalledWith(readyGraph); expect(plugin.onWebviewReady).toHaveBeenCalledOnce(); }); - - it('reports plugin availability through registered access providers', async () => { - const registry = createConfiguredRegistry(); - const getAccess = vi.fn(async ({ access, pluginId, workspaceRoot }) => ({ - access, - reason: `${pluginId}:${workspaceRoot}`, - state: 'granted' as const, - })); - registry.register(createMockPlugin({ - id: 'pro-plugin', - accessProvider: { - id: 'pro-access', - provides: ['pro'], - getAccess, - }, - requiresAccess: 'pro', - })); - - await expect(registry.getPluginAvailability('missing')).resolves.toBeUndefined(); - await expect(registry.getPluginAvailability('pro-plugin', { workspaceRoot: '/workspace' })).resolves.toEqual({ - pluginId: 'pro-plugin', - available: true, - access: [{ - access: 'pro', - reason: 'pro-plugin:/workspace', - state: 'granted', - }], - }); - expect(getAccess).toHaveBeenCalledWith({ - access: 'pro', - pluginId: 'pro-plugin', - workspaceRoot: '/workspace', - }); - }); - - it('lists only graph view contributions available to the registry access context', async () => { - const registry = createConfiguredRegistry(); - const availableContribution = { - id: 'available-section', - label: 'Available Section', - createNodes: vi.fn(() => []), - }; - const lockedContribution = { - id: 'locked-section', - label: 'Locked Section', - requiresAccess: 'locked', - createNodes: vi.fn(() => []), - }; - - registry.register(createMockPlugin({ - id: 'organize', - accessProvider: { - id: 'organize-access', - provides: ['pro'], - getAccess: vi.fn(async ({ access }) => ({ access, state: 'granted' as const })), - }, - graphView: { - runtimeNodes: [availableContribution, lockedContribution], - }, - })); - - await expect(registry.listAvailableGraphViewContributions()).resolves.toEqual(expect.objectContaining({ - runtimeNodes: [{ - pluginId: 'organize', - contribution: availableContribution, - }], - })); - }); }); diff --git a/packages/extension/tests/core/plugins/registry/pluginRegistry.disabledAnalysis.test.ts b/packages/extension/tests/core/plugins/registry/pluginRegistry.disabledAnalysis.test.ts new file mode 100644 index 000000000..459cbc5be --- /dev/null +++ b/packages/extension/tests/core/plugins/registry/pluginRegistry.disabledAnalysis.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createConfiguredRegistry, createMockPlugin } from './pluginRegistry.testSupport'; + +describe('PluginRegistry disabled analysis', () => { + it('excludes disabled plugins from file analysis', async () => { + const registry = createConfiguredRegistry(); + const analyzeFile = vi.fn(async (filePath: string) => ({ + filePath, + relations: [], + })); + registry.register(createMockPlugin({ + id: 'plugin.disabled', + supportedExtensions: ['.ts'], + analyzeFile, + })); + + await expect( + registry.analyzeFileResult( + '/workspace/src/app.ts', + "import './target'", + '/workspace', + undefined, + { disabledPlugins: new Set(['plugin.disabled']) }, + ), + ).resolves.toBeNull(); + + expect(analyzeFile).not.toHaveBeenCalled(); + }); + + it('excludes disabled plugins from targeted file analysis', async () => { + const registry = createConfiguredRegistry(); + const analyzeFile = vi.fn(async (filePath: string) => ({ + filePath, + relations: [], + })); + registry.register(createMockPlugin({ + id: 'plugin.disabled', + supportedExtensions: ['.ts'], + analyzeFile, + })); + + await expect( + registry.analyzeFileResultForPlugins( + '/workspace/src/app.ts', + "import './target'", + '/workspace', + ['plugin.disabled'], + undefined, + { disabledPlugins: new Set(['plugin.disabled']) }, + ), + ).resolves.toBeNull(); + + expect(analyzeFile).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/extension/tests/core/plugins/registry/pluginRegistry.graphScopeCapabilities.test.ts b/packages/extension/tests/core/plugins/registry/pluginRegistry.graphScopeCapabilities.test.ts new file mode 100644 index 000000000..4f2cb18eb --- /dev/null +++ b/packages/extension/tests/core/plugins/registry/pluginRegistry.graphScopeCapabilities.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createConfiguredRegistry, createMockPlugin } from './pluginRegistry.testSupport'; + +describe('PluginRegistry graph scope capabilities', () => { + it('includes core graph scope capabilities without treating core analysis as a plugin', () => { + const registry = createConfiguredRegistry(); + registry.setCoreGraphScopeCapabilitiesProvider((filePaths) => { + expect(filePaths).toEqual(['src/app.cpp']); + return { + nodeTypes: ['symbol:function', 'symbol:class'], + edgeTypes: ['import', 'call', 'contains', 'inherit', 'overrides'], + }; + }); + + expect(registry.list()).toEqual([]); + expect(registry.listGraphScopeCapabilities(['src/app.cpp'])).toEqual({ + nodeTypes: ['symbol:function', 'symbol:class'], + edgeTypes: ['import', 'call', 'contains', 'inherit', 'overrides'], + }); + }); + + it('returns graph scope capabilities from plugins that support workspace files', () => { + const registry = createConfiguredRegistry(); + const readTypeScriptCapabilities = vi.fn(() => + ({ + nodeTypes: ['symbol:function', 'symbol:interface'], + edgeTypes: ['import', 'plugin:route'], + }) as const + ); + registry.register(createMockPlugin({ + id: 'typescript', + supportedExtensions: ['.ts'], + contributeGraphScopeCapabilities: readTypeScriptCapabilities, + })); + registry.register(createMockPlugin({ + id: 'python', + supportedExtensions: ['.py'], + contributeGraphScopeCapabilities: () => ({ edgeTypes: ['reference'] }), + })); + registry.register(createMockPlugin({ + id: 'wildcard', + supportedExtensions: ['*'], + contributeGraphScopeCapabilities: () => ({ + nodeTypes: ['plugin:test-node'], + edgeTypes: ['plugin:test'], + }), + })); + + expect(registry.listGraphScopeCapabilities(['src/app.ts'])).toEqual({ + nodeTypes: ['symbol:function', 'symbol:interface', 'plugin:test-node'], + edgeTypes: ['import', 'plugin:route', 'plugin:test'], + }); + expect(readTypeScriptCapabilities).toHaveBeenCalledWith({ + filePaths: ['src/app.ts'], + }); + }); + + it('passes only each plugin applicable workspace files into graph scope capabilities', () => { + const registry = createConfiguredRegistry(); + const readTypeScriptCapabilities = vi.fn(() => ({ edgeTypes: ['import'] }) as const); + const readSvelteCapabilities = vi.fn(() => ({ edgeTypes: ['call'] }) as const); + + registry.register(createMockPlugin({ + id: 'typescript', + supportedExtensions: ['.ts'], + contributeGraphScopeCapabilities: readTypeScriptCapabilities, + })); + registry.register(createMockPlugin({ + id: 'svelte', + supportedExtensions: ['.svelte'], + contributeGraphScopeCapabilities: readSvelteCapabilities, + })); + + expect(registry.listGraphScopeCapabilities(['src/app.ts', 'src/App.svelte'])).toEqual({ + nodeTypes: [], + edgeTypes: ['import', 'call'], + }); + expect(readTypeScriptCapabilities).toHaveBeenCalledWith({ filePaths: ['src/app.ts'] }); + expect(readSvelteCapabilities).toHaveBeenCalledWith({ filePaths: ['src/App.svelte'] }); + }); + + it('deduplicates graph scope capabilities when multiple applicable plugins declare the same kind', () => { + const registry = createConfiguredRegistry(); + registry.register(createMockPlugin({ + id: 'first', + supportedExtensions: ['.ts'], + contributeGraphScopeCapabilities: () => ({ + nodeTypes: ['symbol:function', 'symbol:interface'], + edgeTypes: ['import', 'reference'], + }), + })); + registry.register(createMockPlugin({ + id: 'second', + supportedExtensions: ['.tsx'], + contributeGraphScopeCapabilities: () => ({ + nodeTypes: ['symbol:function', 'symbol:class'], + edgeTypes: ['import', 'call'], + }), + })); + + expect(registry.listGraphScopeCapabilities(['src/app.ts', 'src/view.tsx'])).toEqual({ + nodeTypes: ['symbol:function', 'symbol:interface', 'symbol:class'], + edgeTypes: ['import', 'reference', 'call'], + }); + }); +}); diff --git a/packages/extension/tests/core/plugins/registry/pluginRegistry.graphTypes.test.ts b/packages/extension/tests/core/plugins/registry/pluginRegistry.graphTypes.test.ts new file mode 100644 index 000000000..447e71c9c --- /dev/null +++ b/packages/extension/tests/core/plugins/registry/pluginRegistry.graphTypes.test.ts @@ -0,0 +1,216 @@ +import { describe, expect, it } from 'vitest'; +import { createConfiguredRegistry, createMockPlugin } from './pluginRegistry.testSupport'; + +describe('PluginRegistry graph type contributions', () => { + it('returns plugin-contributed node types', () => { + const registry = createConfiguredRegistry(); + registry.register(createMockPlugin({ + id: 'first', + contributeNodeTypes: () => [ + { + id: 'route', + label: 'Route', + defaultColor: '#00ff00', + defaultVisible: true, + }, + ], + })); + registry.register(createMockPlugin({ + id: 'second', + contributeNodeTypes: () => [ + { + id: 'tool', + label: 'Tool', + defaultColor: '#0000ff', + defaultVisible: true, + }, + ], + })); + + expect(registry.listNodeTypes()).toEqual([ + { + id: 'route', + label: 'Route', + defaultColor: '#00ff00', + defaultVisible: true, + }, + { + id: 'tool', + label: 'Tool', + defaultColor: '#0000ff', + defaultVisible: true, + }, + ]); + }); + + it('excludes disabled plugins from graph type and capability contributions', () => { + const registry = createConfiguredRegistry(); + const disabledPlugins = new Set(['godot']); + registry.register(createMockPlugin({ + id: 'godot', + supportedExtensions: ['.gd'], + contributeNodeTypes: () => [ + { + id: 'godot-scene', + label: 'Godot Scene', + defaultColor: '#478CBF', + defaultVisible: true, + }, + ], + contributeEdgeTypes: () => [ + { + id: 'load', + label: 'Loads', + defaultColor: '#478CBF', + defaultVisible: true, + }, + ], + contributeGraphScopeCapabilities: () => ({ + nodeTypes: ['plugin:codegraphy.gdscript:symbol:godot-class-name'], + edgeTypes: ['load'], + }), + })); + registry.register(createMockPlugin({ + id: 'typescript', + supportedExtensions: ['.ts'], + contributeNodeTypes: () => [ + { + id: 'route', + label: 'Route', + defaultColor: '#00ff00', + defaultVisible: true, + }, + ], + contributeEdgeTypes: () => [ + { + id: 'plugin:route', + label: 'Route', + defaultColor: '#00ff00', + defaultVisible: true, + }, + ], + contributeGraphScopeCapabilities: () => ({ + nodeTypes: ['route'], + edgeTypes: ['plugin:route'], + }), + })); + + expect(registry.listNodeTypes(disabledPlugins)).toEqual([ + { + id: 'route', + label: 'Route', + defaultColor: '#00ff00', + defaultVisible: true, + }, + ]); + expect(registry.listEdgeTypes(disabledPlugins)).toEqual([ + { + id: 'plugin:route', + label: 'Route', + defaultColor: '#00ff00', + defaultVisible: true, + }, + ]); + expect(registry.listGraphScopeCapabilities(['game/player.gd', 'src/app.ts'], disabledPlugins)).toEqual({ + nodeTypes: ['route'], + edgeTypes: ['plugin:route'], + }); + }); + + it('ignores plugins without node-type contributions when listing node types', () => { + const registry = createConfiguredRegistry(); + registry.register(createMockPlugin({ id: 'noop' })); + registry.register(createMockPlugin({ + id: 'routes', + contributeNodeTypes: () => [ + { + id: 'import', + label: 'Import', + defaultColor: '#22C55E', + defaultVisible: true, + }, + ], + })); + + expect(registry.listNodeTypes()).toEqual([ + { + id: 'import', + label: 'Import', + defaultColor: '#22C55E', + defaultVisible: true, + }, + ]); + }); + + it('returns plugin-contributed edge types with later plugins overriding duplicate ids', () => { + const registry = createConfiguredRegistry(); + registry.register(createMockPlugin({ + id: 'first', + contributeEdgeTypes: () => [ + { + id: 'call', + label: 'Calls', + defaultColor: '#ff0000', + defaultVisible: true, + }, + ], + })); + registry.register(createMockPlugin({ + id: 'second', + contributeEdgeTypes: () => [ + { + id: 'call', + label: 'Calls Override', + defaultColor: '#ffaa00', + defaultVisible: false, + }, + { + id: 'plugin:verify', + label: 'Verifies', + defaultColor: '#00aaff', + defaultVisible: true, + }, + ], + })); + + expect(registry.listEdgeTypes()).toEqual([ + { + id: 'call', + label: 'Calls Override', + defaultColor: '#ffaa00', + defaultVisible: false, + }, + { + id: 'plugin:verify', + label: 'Verifies', + defaultColor: '#00aaff', + defaultVisible: true, + }, + ]); + }); + + it('ignores plugins without edge-type contributions when listing edge types', () => { + const registry = createConfiguredRegistry(); + registry.register(createMockPlugin({ id: 'noop' })); + registry.register(createMockPlugin({ + id: 'routes', + contributeEdgeTypes: () => [ + { + id: 'import', + label: 'Import', + defaultColor: '#22C55E', + defaultVisible: true, + }, + ], + })); + + expect(registry.listEdgeTypes()).toEqual([ + { + id: 'import', + label: 'Import', + defaultColor: '#22C55E', + defaultVisible: true, + }, + ]); + }); +}); diff --git a/packages/extension/tests/core/plugins/registry/pluginRegistry.testSupport.ts b/packages/extension/tests/core/plugins/registry/pluginRegistry.testSupport.ts index 185ea26b1..3587d3bba 100644 --- a/packages/extension/tests/core/plugins/registry/pluginRegistry.testSupport.ts +++ b/packages/extension/tests/core/plugins/registry/pluginRegistry.testSupport.ts @@ -10,7 +10,7 @@ export function createMockPlugin(overrides: Partial = {}): IPlugin { id: 'test.plugin', name: 'Test Plugin', version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', supportedExtensions: ['.test'], analyzeFile: vi.fn(async (filePath: string) => ({ filePath, diff --git a/packages/extension/tests/core/plugins/registry/v2.notificationhookstoeventemission.test.ts b/packages/extension/tests/core/plugins/registry/v2.notificationhookstoeventemission.test.ts index 576b4a424..50a2dfc4b 100644 --- a/packages/extension/tests/core/plugins/registry/v2.notificationhookstoeventemission.test.ts +++ b/packages/extension/tests/core/plugins/registry/v2.notificationhookstoeventemission.test.ts @@ -20,7 +20,7 @@ function createV2Plugin(id: string, overrides: Record = {}): IP id, name: `Test Plugin ${id}`, version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', supportedExtensions: ['.test'], analyzeFile: vi.fn(async (filePath: string) => ({ filePath, relations: [] })), onLoad: vi.fn(), diff --git a/packages/extension/tests/core/plugins/registry/v2.replaysonwebviewreadyforpluginsregisteredtoreplaysonworkspacereadywiththelatesttolifecyclehooks.test.ts b/packages/extension/tests/core/plugins/registry/v2.replaysonwebviewreadyforpluginsregisteredtoreplaysonworkspacereadywiththelatesttolifecyclehooks.test.ts index 89899e2af..198e9bcc4 100644 --- a/packages/extension/tests/core/plugins/registry/v2.replaysonwebviewreadyforpluginsregisteredtoreplaysonworkspacereadywiththelatesttolifecyclehooks.test.ts +++ b/packages/extension/tests/core/plugins/registry/v2.replaysonwebviewreadyforpluginsregisteredtoreplaysonworkspacereadywiththelatesttolifecyclehooks.test.ts @@ -20,7 +20,7 @@ function createV2Plugin(id: string, overrides: Record = {}): IP id, name: `Test Plugin ${id}`, version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', supportedExtensions: ['.test'], analyzeFile: vi.fn(async (filePath: string) => ({ filePath, relations: [] })), onLoad: vi.fn(), diff --git a/packages/extension/tests/core/plugins/registry/v2.test.ts b/packages/extension/tests/core/plugins/registry/v2.test.ts index 8aec546ba..21a1eebec 100644 --- a/packages/extension/tests/core/plugins/registry/v2.test.ts +++ b/packages/extension/tests/core/plugins/registry/v2.test.ts @@ -19,7 +19,7 @@ function createV2Plugin(id: string, overrides: Record = {}): IP id, name: `Test Plugin ${id}`, version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', supportedExtensions: ['.test'], analyzeFile: vi.fn(async (filePath: string) => ({ filePath, relations: [] })), onLoad: vi.fn(), @@ -91,7 +91,7 @@ describe('PluginRegistry v2', () => { it('rejects a plugin targeting a future core API major', () => { const { registry } = createConfiguredRegistry(); - const plugin = createV2Plugin('future-plugin', { apiVersion: '^3.0.0' }); + const plugin = createV2Plugin('future-plugin', { apiVersion: '^4.0.0' }); expect(() => registry.register(plugin)).toThrow(/future CodeGraphy Plugin API/); expect(registry.size).toBe(0); @@ -101,7 +101,7 @@ describe('PluginRegistry v2', () => { it('rejects a plugin targeting an unsupported older core API major', () => { const { registry } = createConfiguredRegistry(); - const plugin = createV2Plugin('legacy-plugin', { apiVersion: '^1.0.0' }); + const plugin = createV2Plugin('legacy-plugin', { apiVersion: '^2.0.0' }); expect(() => registry.register(plugin)).toThrow(/unsupported CodeGraphy Plugin API/); expect(registry.size).toBe(0); diff --git a/packages/extension/tests/core/plugins/routing/analyze.test.ts b/packages/extension/tests/core/plugins/routing/analyze.test.ts index 071e711c5..ae089c826 100644 --- a/packages/extension/tests/core/plugins/routing/analyze.test.ts +++ b/packages/extension/tests/core/plugins/routing/analyze.test.ts @@ -29,7 +29,7 @@ function makePlugin(id: string, extensions: string[], result: object): IPlugin { id, name: id, version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', supportedExtensions: extensions, analyzeFile: vi.fn().mockResolvedValue(result), } as IPlugin; @@ -140,7 +140,7 @@ describe('routing/analyze', () => { id: 'passive', name: 'passive', version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', supportedExtensions: ['.ts'], } as IPlugin; const { pluginsMap, extensionMap } = buildMaps([passive]); @@ -186,7 +186,7 @@ describe('routing/analyze', () => { id: 'failing', name: 'failing', version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', supportedExtensions: ['.ts'], analyzeFile: vi.fn().mockRejectedValue(new Error('boom')), } as IPlugin; diff --git a/packages/extension/tests/core/plugins/routing/lookups.test.ts b/packages/extension/tests/core/plugins/routing/lookups.test.ts index 9601e9ced..33e1d89f3 100644 --- a/packages/extension/tests/core/plugins/routing/lookups.test.ts +++ b/packages/extension/tests/core/plugins/routing/lookups.test.ts @@ -13,7 +13,7 @@ function makePlugin(id: string, extensions: string[]): IPlugin { id, name: id, version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', supportedExtensions: extensions, } as IPlugin; } diff --git a/packages/extension/tests/extension/graphView/groups/defaults/builtIn.test.ts b/packages/extension/tests/extension/graphView/groups/defaults/builtIn.test.ts index 7caf541b8..b3f889df7 100644 --- a/packages/extension/tests/extension/graphView/groups/defaults/builtIn.test.ts +++ b/packages/extension/tests/extension/graphView/groups/defaults/builtIn.test.ts @@ -1,9 +1,15 @@ import * as vscode from 'vscode'; import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { getBuiltInGraphViewDefaultGroups } from '../../../../../src/extension/graphView/groups/defaults/builtIn'; import type { IGraphData } from '../../../../../src/shared/graph/contracts'; +const REPOSITORY_ROOT = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../../../../../../..', +); + describe('graphView/builtInDefaultGroups', () => { beforeEach(() => { vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ @@ -23,7 +29,7 @@ describe('graphView/builtInDefaultGroups', () => { ], edges: [], }, - vscode.Uri.file(path.resolve(process.cwd(), '../..')), + vscode.Uri.file(REPOSITORY_ROOT), ); expect(groups).toEqual(expect.arrayContaining([ @@ -147,7 +153,7 @@ describe('graphView/builtInDefaultGroups', () => { ], edges: [], }, - vscode.Uri.file(path.resolve(process.cwd(), '../..')), + vscode.Uri.file(REPOSITORY_ROOT), ); expect(groups).toEqual(expect.arrayContaining([ @@ -237,7 +243,7 @@ describe('graphView/builtInDefaultGroups', () => { ], edges: [], }; - const extensionUri = vscode.Uri.file(path.resolve(process.cwd(), '../..')); + const extensionUri = vscode.Uri.file(REPOSITORY_ROOT); const first = getBuiltInGraphViewDefaultGroups(graphData, extensionUri); const second = getBuiltInGraphViewDefaultGroups(graphData, extensionUri); @@ -260,7 +266,7 @@ describe('graphView/builtInDefaultGroups', () => { ], edges: [], }; - const extensionUri = vscode.Uri.file(path.resolve(process.cwd(), '../..')); + const extensionUri = vscode.Uri.file(REPOSITORY_ROOT); const hiddenFolders = getBuiltInGraphViewDefaultGroups(graphData, extensionUri); nodeVisibility = { folder: true }; diff --git a/packages/extension/tests/extension/graphView/webview/plugins/registrationFollowup.test.ts b/packages/extension/tests/extension/graphView/webview/plugins/registrationFollowup.test.ts index 560541d9c..fd48c1a12 100644 --- a/packages/extension/tests/extension/graphView/webview/plugins/registrationFollowup.test.ts +++ b/packages/extension/tests/extension/graphView/webview/plugins/registrationFollowup.test.ts @@ -52,7 +52,7 @@ describe('graphView/webview/plugins/registration', () => { id: 'plugin.test', name: 'Plugin', version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', supportedExtensions: ['.ts'], analyzeFile: async (filePath: string) => ({ filePath, relations: [] }), }, @@ -98,7 +98,7 @@ describe('graphView/webview/plugins/registration', () => { id: 'plugin.test', name: 'Plugin', version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', supportedExtensions: ['.ts'], analyzeFile: async (filePath: string) => ({ filePath, relations: [] }), }, diff --git a/packages/extension/tests/extension/graphView/webview/plugins/registrationLifecycle.test.ts b/packages/extension/tests/extension/graphView/webview/plugins/registrationLifecycle.test.ts index 0c1848f46..d90bf2562 100644 --- a/packages/extension/tests/extension/graphView/webview/plugins/registrationLifecycle.test.ts +++ b/packages/extension/tests/extension/graphView/webview/plugins/registrationLifecycle.test.ts @@ -22,7 +22,7 @@ describe('graphView/webview/plugins/registration', () => { id: 'plugin.test', name: 'Plugin', version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', supportedExtensions: ['.ts'], analyzeFile: async (filePath: string) => ({ filePath, relations: [] }), }; @@ -75,7 +75,7 @@ describe('graphView/webview/plugins/registration', () => { id: 'plugin.test', name: 'Plugin', version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', supportedExtensions: ['.ts'], analyzeFile: async (filePath: string) => ({ filePath, relations: [] }), }, @@ -106,7 +106,7 @@ describe('graphView/webview/plugins/registration', () => { id: 'plugin.test', name: 'Plugin', version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', supportedExtensions: ['.ts'], analyzeFile: async (filePath: string) => ({ filePath, relations: [] }), }, @@ -142,7 +142,7 @@ describe('graphView/webview/plugins/registration', () => { id: 'plugin.test', name: 'Plugin', version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', supportedExtensions: ['.ts'], analyzeFile: async (filePath: string) => ({ filePath, relations: [] }), }, 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 e3babb1f9..71e93c437 100644 --- a/packages/extension/tests/extension/graphView/webview/settingsMessages/defaultOptions.test.ts +++ b/packages/extension/tests/extension/graphView/webview/settingsMessages/defaultOptions.test.ts @@ -28,7 +28,7 @@ describe('graph view settings plugin default options', () => { package: '@codegraphy-dev/plugin-godot', pluginId: 'codegraphy.godot', version: '2.1.2', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', disclosures: [], packageRoot: '/global/node_modules/@codegraphy-dev/plugin-godot', defaultOptions: { @@ -55,7 +55,7 @@ describe('graph view settings plugin default options', () => { { package: '@codegraphy-dev/plugin-godot', version: '2.1.2', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', disclosures: [], packageRoot: '/global/node_modules/@codegraphy-dev/plugin-godot', defaultOptions: { @@ -81,7 +81,7 @@ describe('graph view settings plugin default options', () => { package: '@codegraphy-dev/plugin-vue', pluginId: 'codegraphy.vue', version: '2.0.4', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', disclosures: [], packageRoot: '/global/node_modules/@codegraphy-dev/plugin-vue', }, @@ -102,7 +102,7 @@ describe('graph view settings plugin default options', () => { package: '@codegraphy-dev/plugin-particles', pluginId: 'codegraphy.particles', version: '0.2.1', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', disclosures: [], packageRoot: '/global/node_modules/@codegraphy-dev/plugin-particles', updateImpact: { diff --git a/packages/extension/tests/extension/graphViewProvider.pluginApi.test.ts b/packages/extension/tests/extension/graphViewProvider.pluginApi.test.ts index b935c7427..da4c6887c 100644 --- a/packages/extension/tests/extension/graphViewProvider.pluginApi.test.ts +++ b/packages/extension/tests/extension/graphViewProvider.pluginApi.test.ts @@ -24,7 +24,7 @@ describe('GraphViewProvider public API', () => { id: 'plugin.commands', name: 'Commands', version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', supportedExtensions: ['.ts'], analyzeFile: async (filePath: string) => ({ filePath, relations: [] }), onLoad: (api: { registerCommand: (command: { id: string; action: () => void }) => void }) => { diff --git a/packages/extension/tests/extension/graphViewProvider/plugin/defaults.test.ts b/packages/extension/tests/extension/graphViewProvider/plugin/defaults.test.ts index 9d3202e75..91eff4449 100644 --- a/packages/extension/tests/extension/graphViewProvider/plugin/defaults.test.ts +++ b/packages/extension/tests/extension/graphViewProvider/plugin/defaults.test.ts @@ -1,4 +1,5 @@ import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; import * as vscode from 'vscode'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -8,6 +9,11 @@ import { createGraphViewProviderTestHarness } from '../testHarness'; import { getGraphViewProviderInternals } from '../internals'; import { createTypeScriptPlugin } from '../../../../../plugin-typescript/src/plugin'; +const REPOSITORY_ROOT = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../../../../../..', +); + type GroupSummary = Pick & Partial>; interface PluginDefaultsProvider { @@ -65,7 +71,7 @@ describe('GraphViewProvider plugin defaults and toggles', () => { }); it('computeMergedGroups includes built-in default groups', async () => { - harness.mockContext.extensionUri = vscode.Uri.file(path.resolve(process.cwd(), '../..')); + harness.mockContext.extensionUri = vscode.Uri.file(REPOSITORY_ROOT); harness.recreateProvider(); const provider = getProvider(harness); const internals = getGraphViewProviderInternals(harness.provider); diff --git a/packages/extension/tests/extension/graphViewProvider/plugin/dispatch.test.ts b/packages/extension/tests/extension/graphViewProvider/plugin/dispatch.test.ts index 4f251bdda..5e6c282d8 100644 --- a/packages/extension/tests/extension/graphViewProvider/plugin/dispatch.test.ts +++ b/packages/extension/tests/extension/graphViewProvider/plugin/dispatch.test.ts @@ -18,7 +18,7 @@ describe('GraphViewProvider plugin bridge dispatch', () => { id: 'test.plugin', name: 'Test Plugin', version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', supportedExtensions: ['.ts'], analyzeFile: async (filePath: string) => ({ filePath, relations: [] }), onLoad: (api: { onWebviewMessage: (handler: (msg: { type: string; data: unknown }) => void) => void }) => { diff --git a/packages/extension/tests/extension/graphViewProvider/plugin/inject.test.ts b/packages/extension/tests/extension/graphViewProvider/plugin/inject.test.ts index a382325ed..c47ba1d06 100644 --- a/packages/extension/tests/extension/graphViewProvider/plugin/inject.test.ts +++ b/packages/extension/tests/extension/graphViewProvider/plugin/inject.test.ts @@ -18,7 +18,7 @@ describe('GraphViewProvider plugin bridge injection', () => { id: 'test.webview-plugin', name: 'Webview Plugin', version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', supportedExtensions: ['.ts'], analyzeFile: async (filePath: string) => ({ filePath, relations: [] }), webviewContributions: { @@ -60,7 +60,7 @@ describe('GraphViewProvider plugin bridge injection', () => { id: 'test.external-webview-plugin', name: 'External Webview Plugin', version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', supportedExtensions: ['.ts'], analyzeFile: async (filePath: string) => ({ filePath, relations: [] }), webviewContributions: { diff --git a/packages/extension/tests/extension/graphViewProvider/plugin/lifecycle.test.ts b/packages/extension/tests/extension/graphViewProvider/plugin/lifecycle.test.ts index 6092c7d88..69cd05cce 100644 --- a/packages/extension/tests/extension/graphViewProvider/plugin/lifecycle.test.ts +++ b/packages/extension/tests/extension/graphViewProvider/plugin/lifecycle.test.ts @@ -18,7 +18,7 @@ describe('GraphViewProvider plugin bridge lifecycle', () => { id: 'test.webview-ready-once', name: 'Webview Ready Once', version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', supportedExtensions: ['.ts'], analyzeFile: async (filePath: string) => ({ filePath, relations: [] }), onWebviewReady, @@ -40,7 +40,7 @@ describe('GraphViewProvider plugin bridge lifecycle', () => { id: 'test.initial-order', name: 'Initial Order', version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', supportedExtensions: ['.ts'], analyzeFile: async (filePath: string) => ({ filePath, relations: [] }), onWorkspaceReady, @@ -80,7 +80,7 @@ describe('GraphViewProvider plugin bridge lifecycle', () => { id: 'test.mid-first-analysis', name: 'Mid First Analysis', version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', supportedExtensions: ['.ts'], analyzeFile: async (filePath: string) => ({ filePath, relations: [] }), onWorkspaceReady, @@ -107,7 +107,7 @@ describe('GraphViewProvider plugin bridge lifecycle', () => { id: 'test.late-both-order', name: 'Late Both Order', version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', supportedExtensions: ['.ts'], analyzeFile: async (filePath: string) => ({ filePath, relations: [] }), onWorkspaceReady, @@ -134,7 +134,7 @@ describe('GraphViewProvider plugin bridge lifecycle', () => { id: 'test.late-webview-order', name: 'Late Webview Order', version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', supportedExtensions: ['.ts'], analyzeFile: async (filePath: string) => ({ filePath, relations: [] }), initialize, diff --git a/packages/extension/tests/extension/pipeline/adaptersFixture.ts b/packages/extension/tests/extension/pipeline/adaptersFixture.ts index 22c985618..c4bbae1f7 100644 --- a/packages/extension/tests/extension/pipeline/adaptersFixture.ts +++ b/packages/extension/tests/extension/pipeline/adaptersFixture.ts @@ -34,7 +34,7 @@ export function createPlugin(id: string, name: string, supportedExtensions: stri id, name, version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', supportedExtensions, analyzeFile: vi.fn(async (filePath: string) => ({ filePath, relations: [] })), } as IPlugin; diff --git a/packages/extension/tests/extension/pipeline/plugins/bootstrapDataAndDisabled.test.ts b/packages/extension/tests/extension/pipeline/plugins/bootstrapDataAndDisabled.test.ts index 3a74550ab..66d87da76 100644 --- a/packages/extension/tests/extension/pipeline/plugins/bootstrapDataAndDisabled.test.ts +++ b/packages/extension/tests/extension/pipeline/plugins/bootstrapDataAndDisabled.test.ts @@ -32,7 +32,7 @@ describe('pipeline/plugins/bootstrap plugin data and disabled plugins', () => { plugins: [{ package: '@acme/codegraphy-plugin-extension-data-host', version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', disclosures: ['workspaceWrites'], packageRoot, pluginId: 'acme.extension-data-host', @@ -106,7 +106,7 @@ describe('pipeline/plugins/bootstrap plugin data and disabled plugins', () => { plugins: [{ package: '@acme/codegraphy-plugin-extension-bootstrap', version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', disclosures: [], packageRoot, pluginId: 'acme.extension-bootstrap', diff --git a/packages/extension/tests/extension/pipeline/plugins/bootstrapFixture.ts b/packages/extension/tests/extension/pipeline/plugins/bootstrapFixture.ts index 487e12567..d2f6dbe78 100644 --- a/packages/extension/tests/extension/pipeline/plugins/bootstrapFixture.ts +++ b/packages/extension/tests/extension/pipeline/plugins/bootstrapFixture.ts @@ -38,7 +38,10 @@ export async function createPackageFixtureRoot(prefix: string): Promise return fs.mkdtemp(path.join(root, prefix)); } -export async function createPluginPackage(packageRoot: string): Promise { +export async function createPluginPackage( + packageRoot: string, + apiVersion = '^3.0.0', +): Promise { await fs.mkdir(packageRoot, { recursive: true }); await fs.writeFile( path.join(packageRoot, 'package.json'), @@ -49,7 +52,7 @@ export async function createPluginPackage(packageRoot: string): Promise { exports: './plugin.js', codegraphy: { type: 'plugin', - apiVersion: '^2.0.0', + apiVersion, }, }, null, 2), 'utf-8', @@ -62,7 +65,7 @@ export default function createPlugin() { id: 'acme.extension-bootstrap', name: 'Extension Bootstrap', version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '${apiVersion}', supportedExtensions: ['.txt'], async analyzeFile(filePath) { return { filePath, relations: [] }; @@ -99,7 +102,7 @@ export async function createManifestPluginPackage( exports: './plugin.js', codegraphy: { type: 'plugin', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', }, }, null, 2), 'utf-8', @@ -110,7 +113,7 @@ export async function createManifestPluginPackage( id: pluginId, name: pluginName, version, - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', supportedExtensions: ['.txt'], fileColors: { '*.txt': { @@ -130,7 +133,7 @@ export default function createPlugin() { id: ${JSON.stringify(pluginId)}, name: ${JSON.stringify(pluginName)}, version: ${JSON.stringify(version)}, - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', supportedExtensions: ['.txt'], fileColors: { '*.txt': { @@ -164,7 +167,7 @@ export async function createPluginPackageWithRuntimeMarkers(packageRoot: string) exports: './plugin.js', codegraphy: { type: 'plugin', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', }, }, null, 2), 'utf-8', @@ -182,7 +185,7 @@ export default function createPlugin() { id: 'acme.extension-bootstrap', name: 'Extension Bootstrap', version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', supportedExtensions: ['.txt'] }; } @@ -204,7 +207,7 @@ export async function createDataHostPluginPackage(packageRoot: string): Promise< exports: './plugin.js', codegraphy: { type: 'plugin', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', defaultOptions: { mode: 'default', }, @@ -223,7 +226,7 @@ export default function createPlugin(factoryOptions = {}) { id: 'acme.extension-data-host', name: 'Extension Data Host', version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', supportedExtensions: [], async initialize() { if (!dataHost) { diff --git a/packages/extension/tests/extension/pipeline/plugins/bootstrapInitialization.test.ts b/packages/extension/tests/extension/pipeline/plugins/bootstrapInitialization.test.ts index 21a8578c2..b8d1112d0 100644 --- a/packages/extension/tests/extension/pipeline/plugins/bootstrapInitialization.test.ts +++ b/packages/extension/tests/extension/pipeline/plugins/bootstrapInitialization.test.ts @@ -54,7 +54,7 @@ describe('pipeline/plugins/bootstrap initialization', () => { plugins: [{ package: '@acme/codegraphy-plugin-extension-bootstrap', version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', disclosures: [], packageRoot, pluginId: 'acme.extension-bootstrap', diff --git a/packages/extension/tests/extension/pipeline/plugins/bootstrapPackages.test.ts b/packages/extension/tests/extension/pipeline/plugins/bootstrapPackages.test.ts index 50b3ab3ea..95b7db088 100644 --- a/packages/extension/tests/extension/pipeline/plugins/bootstrapPackages.test.ts +++ b/packages/extension/tests/extension/pipeline/plugins/bootstrapPackages.test.ts @@ -49,7 +49,7 @@ describe('pipeline/plugins/bootstrap packages', () => { plugins: [{ package: packageName, version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', disclosures: [], packageRoot: stalePackageRoot, pluginId, @@ -110,7 +110,7 @@ describe('pipeline/plugins/bootstrap packages', () => { plugins: [{ package: '@acme/codegraphy-plugin-extension-bootstrap', version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', disclosures: [], packageRoot, pluginId: 'acme.extension-bootstrap', @@ -152,6 +152,55 @@ describe('pipeline/plugins/bootstrap packages', () => { expect(registry.initializeAll).toHaveBeenCalledWith(workspaceRoot); }); + it('warns and skips an incompatible installed plugin without aborting initialization', async () => { + const registry = createRegistry(); + const workspaceRoot = await createWorkspace(); + const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codegraphy-extension-home-')); + const packageRoot = path.join( + await createPackageFixtureRoot('codegraphy-extension-global-'), + 'node_modules', + '@acme', + 'codegraphy-plugin-extension-bootstrap', + ); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + await createPluginPackage(packageRoot, '^2.0.0'); + writeCodeGraphyInstalledPluginCache({ + version: 1, + plugins: [{ + package: '@acme/codegraphy-plugin-extension-bootstrap', + version: '1.0.0', + apiVersion: '^2.0.0', + disclosures: [], + packageRoot, + pluginId: 'acme.extension-bootstrap', + }], + }, { homeDir }); + writeCodeGraphyWorkspaceSettings(workspaceRoot, { + ...readCodeGraphyWorkspaceSettings(workspaceRoot), + plugins: [ + { id: 'codegraphy.markdown', enabled: true }, + { id: 'acme.extension-bootstrap', enabled: true }, + ], + }); + registry.register.mockImplementation((plugin) => { + if (plugin.apiVersion === '^2.0.0') { + throw new Error("Plugin 'acme.extension-bootstrap' targets unsupported CodeGraphy Plugin API '^2.0.0'. Host provides '3.0.0'."); + } + }); + + await expect(initializeWorkspacePipeline(registry as never, { + getWorkspaceRoot: () => workspaceRoot, + userHomeDir: homeDir, + })).resolves.toBeUndefined(); + + expect(warn).toHaveBeenCalledWith( + "CodeGraphy plugin 'acme.extension-bootstrap' could not be registered: Plugin 'acme.extension-bootstrap' targets unsupported CodeGraphy Plugin API '^2.0.0'. Host provides '3.0.0'.", + ); + expect(registry.initializeAll).toHaveBeenCalledWith(workspaceRoot); + warn.mockRestore(); + }); + it('does not register installed package plugins that are not enabled for the current CodeGraphy Workspace', async () => { const registry = createRegistry(); const workspaceRoot = await createWorkspace(); @@ -169,7 +218,7 @@ describe('pipeline/plugins/bootstrap packages', () => { plugins: [{ package: '@acme/codegraphy-plugin-extension-bootstrap', version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', disclosures: [], packageRoot, pluginId: 'acme.extension-bootstrap', diff --git a/packages/extension/tests/extension/pipeline/plugins/bootstrapSync.test.ts b/packages/extension/tests/extension/pipeline/plugins/bootstrapSync.test.ts index 8b1a308bf..b7c030bf8 100644 --- a/packages/extension/tests/extension/pipeline/plugins/bootstrapSync.test.ts +++ b/packages/extension/tests/extension/pipeline/plugins/bootstrapSync.test.ts @@ -69,7 +69,7 @@ describe('pipeline/plugins/bootstrap synchronization', () => { plugins: [{ package: '@acme/codegraphy-plugin-extension-bootstrap', version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', disclosures: [], packageRoot, pluginId: 'acme.extension-bootstrap', diff --git a/packages/extension/tests/extension/pipeline/plugins/queries.test.ts b/packages/extension/tests/extension/pipeline/plugins/queries.test.ts index 0ed4d00af..154884cca 100644 --- a/packages/extension/tests/extension/pipeline/plugins/queries.test.ts +++ b/packages/extension/tests/extension/pipeline/plugins/queries.test.ts @@ -14,7 +14,7 @@ describe('pipeline/plugins/queries', () => { id: 'plugin.typescript', name: 'TypeScript', version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', supportedExtensions: ['.ts'], sources: [], }, diff --git a/packages/extension/tests/extension/pipeline/plugins/statusContext.test.ts b/packages/extension/tests/extension/pipeline/plugins/statusContext.test.ts index e5dca66cd..2380395cf 100644 --- a/packages/extension/tests/extension/pipeline/plugins/statusContext.test.ts +++ b/packages/extension/tests/extension/pipeline/plugins/statusContext.test.ts @@ -35,7 +35,7 @@ describe('pipeline/plugins/statusContext', () => { { package: '@codegraphy-dev/plugin-vue', version: '2.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', disclosures: [], packageRoot: '/global/node_modules/@codegraphy-dev/plugin-vue', pluginId: 'codegraphy.vue', @@ -43,7 +43,7 @@ describe('pipeline/plugins/statusContext', () => { { package: '@codegraphy-dev/plugin-godot', version: '3.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', disclosures: [], packageRoot: '/global/node_modules/@codegraphy-dev/plugin-godot', pluginId: 'codegraphy.godot', @@ -82,7 +82,7 @@ describe('pipeline/plugins/statusContext', () => { { package: '@codegraphy-dev/plugin-vue', version: '2.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', disclosures: [], packageRoot: '/global/node_modules/@codegraphy-dev/plugin-vue', }, diff --git a/packages/extension/tests/extension/pipeline/plugins/testFactories.ts b/packages/extension/tests/extension/pipeline/plugins/testFactories.ts index f4eb4adbb..c858b99a0 100644 --- a/packages/extension/tests/extension/pipeline/plugins/testFactories.ts +++ b/packages/extension/tests/extension/pipeline/plugins/testFactories.ts @@ -7,7 +7,7 @@ export function createPluginInfo(overrides: Partial): IPluginInfo { id: 'test.plugin', name: 'Test Plugin', version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', supportedExtensions: ['.ts'], analyzeFile: vi.fn(async (filePath: string) => ({ filePath, relations: [] })), ...overrides, @@ -25,7 +25,7 @@ export function createInstalledPlugin( return { package: '@codegraphy-dev/plugin-test', version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', disclosures: [], packageRoot: '/global/node_modules/@codegraphy-dev/plugin-test', ...overrides, diff --git a/packages/extension/tests/extension/pipeline/service/discoveryFacadeAnalysis.test.ts b/packages/extension/tests/extension/pipeline/service/discoveryFacadeAnalysis.test.ts index 9bfef589f..117f09f0e 100644 --- a/packages/extension/tests/extension/pipeline/service/discoveryFacadeAnalysis.test.ts +++ b/packages/extension/tests/extension/pipeline/service/discoveryFacadeAnalysis.test.ts @@ -19,11 +19,22 @@ vi.mock('../../../../src/extension/pipeline/service/runtime/run', () => ({ rebuildWorkspacePipelineGraph: vi.fn(), })); -vi.mock('node:child_process', async (importOriginal) => ({ - ...(await importOriginal()), - spawnSync: vi.fn(), +const childProcessMock = vi.hoisted(() => ({ + spawnSync: vi.fn(() => ({ error: undefined, status: 1, stdout: '' })), })); +vi.mock('node:child_process', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + spawnSync: childProcessMock.spawnSync, + default: { + ...original, + spawnSync: childProcessMock.spawnSync, + }, + }; +}); + vi.mock('vscode', () => ({ workspace: { workspaceFolders: [{ uri: { fsPath: '/workspace' } }], diff --git a/packages/extension/tests/extension/pipeline/service/discoveryFacadeCacheReplay.test.ts b/packages/extension/tests/extension/pipeline/service/discoveryFacadeCacheReplay.test.ts index 122e77189..a8e879b1d 100644 --- a/packages/extension/tests/extension/pipeline/service/discoveryFacadeCacheReplay.test.ts +++ b/packages/extension/tests/extension/pipeline/service/discoveryFacadeCacheReplay.test.ts @@ -19,11 +19,22 @@ vi.mock('../../../../src/extension/pipeline/service/runtime/run', () => ({ rebuildWorkspacePipelineGraph: vi.fn(), })); -vi.mock('node:child_process', async (importOriginal) => ({ - ...(await importOriginal()), - spawnSync: vi.fn(), +const childProcessMock = vi.hoisted(() => ({ + spawnSync: vi.fn(() => ({ error: undefined, status: 1, stdout: '' })), })); +vi.mock('node:child_process', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + spawnSync: childProcessMock.spawnSync, + default: { + ...original, + spawnSync: childProcessMock.spawnSync, + }, + }; +}); + vi.mock('vscode', () => ({ workspace: { workspaceFolders: [{ uri: { fsPath: '/workspace' } }], diff --git a/packages/extension/tests/extension/pipeline/service/discoveryFacadeDiscovery.test.ts b/packages/extension/tests/extension/pipeline/service/discoveryFacadeDiscovery.test.ts index 50d96d70c..2c88da342 100644 --- a/packages/extension/tests/extension/pipeline/service/discoveryFacadeDiscovery.test.ts +++ b/packages/extension/tests/extension/pipeline/service/discoveryFacadeDiscovery.test.ts @@ -19,11 +19,22 @@ vi.mock('../../../../src/extension/pipeline/service/runtime/run', () => ({ rebuildWorkspacePipelineGraph: vi.fn(), })); -vi.mock('node:child_process', async (importOriginal) => ({ - ...(await importOriginal()), - spawnSync: vi.fn(), +const childProcessMock = vi.hoisted(() => ({ + spawnSync: vi.fn(() => ({ error: undefined, status: 1, stdout: '' })), })); +vi.mock('node:child_process', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + spawnSync: childProcessMock.spawnSync, + default: { + ...original, + spawnSync: childProcessMock.spawnSync, + }, + }; +}); + vi.mock('vscode', () => ({ workspace: { workspaceFolders: [{ uri: { fsPath: '/workspace' } }], diff --git a/packages/extension/tests/extension/pipeline/service/discoveryFacadeFixture.ts b/packages/extension/tests/extension/pipeline/service/discoveryFacadeFixture.ts index 2bfa7319a..e4754f5b2 100644 --- a/packages/extension/tests/extension/pipeline/service/discoveryFacadeFixture.ts +++ b/packages/extension/tests/extension/pipeline/service/discoveryFacadeFixture.ts @@ -1,6 +1,5 @@ import { vi } from 'vitest'; import * as vscode from 'vscode'; -import { spawnSync } from 'node:child_process'; import { WorkspacePipelineDiscoveryFacade } from '../../../../src/extension/pipeline/service/discoveryFacade'; import type { Configuration } from '../../../../src/extension/config/reader'; import type { FileDiscovery } from '@codegraphy-dev/core'; @@ -97,11 +96,6 @@ export const discoveryState = ( export function setUpDiscoveryFacade(): void { vi.clearAllMocks(); - vi.mocked(spawnSync).mockReturnValue({ - error: undefined, - status: 1, - stdout: '', - } as never); vi.mocked(createWorkspacePipelineDiscoveryDependencies).mockReturnValue('discovery-deps' as never); vi.mocked(discoverWorkspacePipelineFilesWithWarnings).mockResolvedValue({ directories: ['src/new-folder'], @@ -126,7 +120,6 @@ export function setUpDiscoveryFacade(): void { export { vscode, - spawnSync, createWorkspacePipelineDiscoveryDependencies, discoverWorkspacePipelineFilesWithWarnings, getWorkspacePipelinePluginFilterPatterns, diff --git a/packages/extension/tests/extension/pipeline/service/discoveryFacadeGitignore.test.ts b/packages/extension/tests/extension/pipeline/service/discoveryFacadeGitignore.test.ts index d097f6db1..e6d1f855f 100644 --- a/packages/extension/tests/extension/pipeline/service/discoveryFacadeGitignore.test.ts +++ b/packages/extension/tests/extension/pipeline/service/discoveryFacadeGitignore.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { spawnSync } from 'node:child_process'; vi.mock('../../../../src/extension/pipeline/service/runtime/discovery', () => ({ createWorkspacePipelineDiscoveryDependencies: vi.fn(), discoverWorkspacePipelineFilesWithWarnings: vi.fn(), @@ -19,11 +20,22 @@ vi.mock('../../../../src/extension/pipeline/service/runtime/run', () => ({ rebuildWorkspacePipelineGraph: vi.fn(), })); -vi.mock('node:child_process', async (importOriginal) => ({ - ...(await importOriginal()), - spawnSync: vi.fn(), +const childProcessMock = vi.hoisted(() => ({ + spawnSync: vi.fn(() => ({ error: undefined, status: 1, stdout: '' })), })); +vi.mock('node:child_process', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + spawnSync: childProcessMock.spawnSync, + default: { + ...original, + spawnSync: childProcessMock.spawnSync, + }, + }; +}); + vi.mock('vscode', () => ({ workspace: { workspaceFolders: [{ uri: { fsPath: '/workspace' } }], @@ -44,7 +56,6 @@ vi.mock('vscode', () => ({ import { TestDiscoveryFacade, discoveryState, - spawnSync, discoverWorkspacePipelineFilesWithWarnings, setUpDiscoveryFacade, } from './discoveryFacadeFixture'; diff --git a/packages/extension/tests/extension/pipeline/service/discoveryFacadeLifecycle.test.ts b/packages/extension/tests/extension/pipeline/service/discoveryFacadeLifecycle.test.ts index 74792590a..93774d83c 100644 --- a/packages/extension/tests/extension/pipeline/service/discoveryFacadeLifecycle.test.ts +++ b/packages/extension/tests/extension/pipeline/service/discoveryFacadeLifecycle.test.ts @@ -19,11 +19,22 @@ vi.mock('../../../../src/extension/pipeline/service/runtime/run', () => ({ rebuildWorkspacePipelineGraph: vi.fn(), })); -vi.mock('node:child_process', async (importOriginal) => ({ - ...(await importOriginal()), - spawnSync: vi.fn(), +const childProcessMock = vi.hoisted(() => ({ + spawnSync: vi.fn(() => ({ error: undefined, status: 1, stdout: '' })), })); +vi.mock('node:child_process', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + spawnSync: childProcessMock.spawnSync, + default: { + ...original, + spawnSync: childProcessMock.spawnSync, + }, + }; +}); + vi.mock('vscode', () => ({ workspace: { workspaceFolders: [{ uri: { fsPath: '/workspace' } }], diff --git a/packages/extension/tests/extension/pipeline/service/discoveryFacadeWarmAnalysis.test.ts b/packages/extension/tests/extension/pipeline/service/discoveryFacadeWarmAnalysis.test.ts index a9520a7bf..86c61c426 100644 --- a/packages/extension/tests/extension/pipeline/service/discoveryFacadeWarmAnalysis.test.ts +++ b/packages/extension/tests/extension/pipeline/service/discoveryFacadeWarmAnalysis.test.ts @@ -19,11 +19,22 @@ vi.mock('../../../../src/extension/pipeline/service/runtime/run', () => ({ rebuildWorkspacePipelineGraph: vi.fn(), })); -vi.mock('node:child_process', async (importOriginal) => ({ - ...(await importOriginal()), - spawnSync: vi.fn(), +const childProcessMock = vi.hoisted(() => ({ + spawnSync: vi.fn(() => ({ error: undefined, status: 1, stdout: '' })), })); +vi.mock('node:child_process', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + spawnSync: childProcessMock.spawnSync, + default: { + ...original, + spawnSync: childProcessMock.spawnSync, + }, + }; +}); + vi.mock('vscode', () => ({ workspace: { workspaceFolders: [{ uri: { fsPath: '/workspace' } }], diff --git a/packages/extension/tests/extension/pipeline/service/lifecycleFacade.cache.test.ts b/packages/extension/tests/extension/pipeline/service/lifecycleFacade.cache.test.ts new file mode 100644 index 000000000..ebb12e772 --- /dev/null +++ b/packages/extension/tests/extension/pipeline/service/lifecycleFacade.cache.test.ts @@ -0,0 +1,112 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../../src/extension/pipeline/service/cache/storage', () => ({ + clearWorkspacePipelineStoredCache: vi.fn(), +})); +vi.mock('../../../../src/extension/pipeline/service/cache/invalidation', () => ({ + invalidateWorkspacePipelineFiles: vi.fn(), + resolveWorkspacePipelinePluginFilePaths: vi.fn(), +})); +vi.mock('../../../../src/extension/pipeline/service/runtime/plugins', () => ({ + getWorkspacePipelinePluginName: vi.fn(), + getWorkspacePipelineStatusList: vi.fn(), +})); +vi.mock('../../../../src/extension/pipeline/plugins/statusContext', () => ({ + readWorkspacePluginStatusContext: vi.fn(), +})); +vi.mock('vscode', () => ({ + workspace: { + workspaceFolders: [{ uri: { fsPath: '/workspace' } }], + getConfiguration: vi.fn(() => ({ get: vi.fn(), update: vi.fn(), inspect: vi.fn() })), + createFileSystemWatcher: vi.fn(), + onDidSaveTextDocument: vi.fn(), + onDidChangeConfiguration: vi.fn(), + }, +})); + +import { + TestLifecycleFacade, + clearWorkspacePipelineStoredCache, + invalidateWorkspacePipelineFiles, + lifecycleState, + setUpLifecycleFacade, +} from './lifecycleFacadeFixture'; + +describe('pipeline/service/lifecycleFacade', () => { + beforeEach(setUpLifecycleFacade); + + it('replaces the cache through the stored-cache helper and logs its messages', () => { + const facade = new TestLifecycleFacade(); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + facade.clearCache(); + + expect(clearWorkspacePipelineStoredCache).toHaveBeenCalledWith( + '/workspace', + expect.any(Function), + ); + const onMessage = vi.mocked(clearWorkspacePipelineStoredCache).mock.calls[0][1]; + onMessage('cache cleared'); + expect(logSpy).toHaveBeenCalledWith('cache cleared'); + expect(facade._cache).toEqual({ files: { 'src/a.ts': { cached: true } } }); + }); + + it('short-circuits file invalidation when no workspace root exists or no files are provided', () => { + const facade = new TestLifecycleFacade(); + facade.getWorkspaceRoot.mockReturnValue(undefined); + + expect(facade.invalidateWorkspaceFiles(['/workspace/src/a.ts'])).toEqual([]); + expect(invalidateWorkspacePipelineFiles).not.toHaveBeenCalled(); + }); + + it('returns an empty invalidation set when no file paths are provided even with a workspace root', () => { + const facade = new TestLifecycleFacade(); + + expect(facade.invalidateWorkspaceFiles([])).toEqual([]); + expect(invalidateWorkspacePipelineFiles).not.toHaveBeenCalled(); + }); + + it('invalidates workspace files and persists only when files were removed from the cache', () => { + const facade = new TestLifecycleFacade(); + lifecycleState(facade)._lastDiscoveredDirectories = ['src', 'src/a.ts', 'src/a.ts/deep']; + const toWorkspaceRelativePath = vi + .spyOn( + facade as unknown as { + _toWorkspaceRelativePath: (workspaceRoot: string, filePath: string) => string | undefined; + }, + '_toWorkspaceRelativePath', + ) + .mockReturnValue('src/a.ts'); + + expect(facade.invalidateWorkspaceFiles(['/workspace/src/a.ts'])).toEqual(['src/a.ts']); + expect(invalidateWorkspacePipelineFiles).toHaveBeenCalledWith( + { + cache: facade._cache, + lastFileAnalysis: facade._lastFileAnalysis, + lastFileConnections: facade._lastFileConnections, + }, + '/workspace', + ['/workspace/src/a.ts'], + expect.any(Function), + ); + const relativePathResolver = vi.mocked(invalidateWorkspacePipelineFiles).mock.calls[0][3]; + expect(relativePathResolver('/workspace', '/workspace/src/a.ts')).toBe('src/a.ts'); + expect(toWorkspaceRelativePath).toHaveBeenCalledWith('/workspace', '/workspace/src/a.ts'); + expect(lifecycleState(facade)._lastDiscoveredDirectories).toEqual(['src']); + expect(facade.persistCache).toHaveBeenCalledOnce(); + + vi.mocked(invalidateWorkspacePipelineFiles).mockReturnValueOnce([]); + expect(facade.invalidateWorkspaceFiles(['/workspace/src/b.ts'])).toEqual([]); + 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(); + }); +}); diff --git a/packages/extension/tests/extension/pipeline/service/lifecycleFacade.pluginInvalidation.test.ts b/packages/extension/tests/extension/pipeline/service/lifecycleFacade.pluginInvalidation.test.ts new file mode 100644 index 000000000..63e657c0b --- /dev/null +++ b/packages/extension/tests/extension/pipeline/service/lifecycleFacade.pluginInvalidation.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../../src/extension/pipeline/service/cache/storage', () => ({ + clearWorkspacePipelineStoredCache: vi.fn(), +})); +vi.mock('../../../../src/extension/pipeline/service/cache/invalidation', () => ({ + invalidateWorkspacePipelineFiles: vi.fn(), + resolveWorkspacePipelinePluginFilePaths: vi.fn(), +})); +vi.mock('../../../../src/extension/pipeline/service/runtime/plugins', () => ({ + getWorkspacePipelinePluginName: vi.fn(), + getWorkspacePipelineStatusList: vi.fn(), +})); +vi.mock('../../../../src/extension/pipeline/plugins/statusContext', () => ({ + readWorkspacePluginStatusContext: vi.fn(), +})); +vi.mock('vscode', () => ({ + workspace: { + workspaceFolders: [{ uri: { fsPath: '/workspace' } }], + getConfiguration: vi.fn(() => ({ get: vi.fn(), update: vi.fn(), inspect: vi.fn() })), + createFileSystemWatcher: vi.fn(), + onDidSaveTextDocument: vi.fn(), + onDidChangeConfiguration: vi.fn(), + }, +})); + +import { + TestLifecycleFacade, + lifecycleState, + resolveWorkspacePipelinePluginFilePaths, + setUpLifecycleFacade, +} from './lifecycleFacadeFixture'; + +describe('pipeline/service/lifecycleFacade', () => { + beforeEach(setUpLifecycleFacade); + + it('returns early when plugin invalidation receives no plugin ids', () => { + const facade = new TestLifecycleFacade(); + + expect(facade.invalidatePluginFiles([])).toEqual([]); + expect(lifecycleState(facade)._registry.list).not.toHaveBeenCalled(); + expect(resolveWorkspacePipelinePluginFilePaths).not.toHaveBeenCalled(); + }); + + it('returns early when plugin invalidation has no discovered files', () => { + const facade = new TestLifecycleFacade(); + + lifecycleState(facade)._lastDiscoveredFiles = []; + expect(facade.invalidatePluginFiles(['plugin.a'])).toEqual([]); + expect(lifecycleState(facade)._registry.list).not.toHaveBeenCalled(); + expect(resolveWorkspacePipelinePluginFilePaths).not.toHaveBeenCalled(); + }); + + it('returns early when plugin invalidation finds no matching plugins', () => { + const facade = new TestLifecycleFacade(); + + lifecycleState(facade)._lastDiscoveredFiles = [ + { absolutePath: '/workspace/src/a.ts', relativePath: 'src/a.ts', extension: '.ts', name: 'a.ts' }, + ]; + lifecycleState(facade)._registry.list.mockReturnValueOnce([ + { plugin: { id: 'plugin.other', supportedExtensions: ['.py'] } }, + ]); + expect(facade.invalidatePluginFiles(['plugin.a'])).toEqual([]); + expect(resolveWorkspacePipelinePluginFilePaths).not.toHaveBeenCalled(); + }); + + it('resolves plugin file paths and invalidates them through the shared invalidation flow', () => { + const facade = new TestLifecycleFacade(); + const invalidateWorkspaceFiles = vi.spyOn(facade, 'invalidateWorkspaceFiles'); + lifecycleState(facade)._registry.list.mockReturnValueOnce([ + { plugin: { id: 'plugin.a', supportedExtensions: ['.ts'] } }, + { plugin: { id: 'plugin.b', supportedExtensions: ['.py'] } }, + ]); + + expect(facade.invalidatePluginFiles(['plugin.a'])).toEqual(['src/a.ts']); + expect(resolveWorkspacePipelinePluginFilePaths).toHaveBeenCalledWith( + '/workspace', + facade._lastDiscoveredFiles, + [{ plugin: { id: 'plugin.a', supportedExtensions: ['.ts'] } }], + ); + expect(invalidateWorkspaceFiles).toHaveBeenCalledWith(['/workspace/src/a.ts']); + }); +}); diff --git a/packages/extension/tests/extension/pipeline/service/lifecycleFacade.plugins.test.ts b/packages/extension/tests/extension/pipeline/service/lifecycleFacade.plugins.test.ts new file mode 100644 index 000000000..9f661c2b7 --- /dev/null +++ b/packages/extension/tests/extension/pipeline/service/lifecycleFacade.plugins.test.ts @@ -0,0 +1,99 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../../src/extension/pipeline/service/cache/storage', () => ({ + clearWorkspacePipelineStoredCache: vi.fn(), +})); +vi.mock('../../../../src/extension/pipeline/service/cache/invalidation', () => ({ + invalidateWorkspacePipelineFiles: vi.fn(), + resolveWorkspacePipelinePluginFilePaths: vi.fn(), +})); +vi.mock('../../../../src/extension/pipeline/service/runtime/plugins', () => ({ + getWorkspacePipelinePluginName: vi.fn(), + getWorkspacePipelineStatusList: vi.fn(), +})); +vi.mock('../../../../src/extension/pipeline/plugins/statusContext', () => ({ + readWorkspacePluginStatusContext: vi.fn(), +})); +vi.mock('vscode', () => ({ + workspace: { + workspaceFolders: [{ uri: { fsPath: '/workspace' } }], + getConfiguration: vi.fn(() => ({ get: vi.fn(), update: vi.fn(), inspect: vi.fn() })), + createFileSystemWatcher: vi.fn(), + onDidSaveTextDocument: vi.fn(), + onDidChangeConfiguration: vi.fn(), + }, +})); + +import { + TestLifecycleFacade, + getWorkspacePipelinePluginName, + getWorkspacePipelineStatusList, + lifecycleState, + readWorkspacePluginStatusContext, + setUpLifecycleFacade, + vscode, +} from './lifecycleFacadeFixture'; + +describe('pipeline/service/lifecycleFacade', () => { + beforeEach(setUpLifecycleFacade); + + it('returns plugin statuses using the installed plugin status context', () => { + const facade = new TestLifecycleFacade(); + const disabledPlugins = new Set(['plugin.disabled']); + + expect(facade.getPluginStatuses(disabledPlugins)).toEqual([{ id: 'plugin.a' }]); + expect(readWorkspacePluginStatusContext).toHaveBeenCalledWith('/workspace'); + expect(getWorkspacePipelineStatusList).toHaveBeenCalledWith( + facade._registry, + disabledPlugins, + facade._lastDiscoveredFiles, + facade._lastFileConnections, + { + installedPlugins: [ + { + package: '@codegraphy-dev/plugin-vue', + version: '2.0.0', + apiVersion: '^3.0.0', + disclosures: [], + packageRoot: '/global/node_modules/@codegraphy-dev/plugin-vue', + }, + ], + workspaceEnabledPluginIds: new Set(['codegraphy.vue']), + }, + ); + }); + + it('resolves plugin names using the cached workspace root and vscode workspace folders', () => { + const facade = new TestLifecycleFacade(); + + expect(facade.getPluginNameForFile('src/a.ts')).toBe('TypeScript'); + expect(getWorkspacePipelinePluginName).toHaveBeenCalledWith( + 'src/a.ts', + '/workspace', + facade._registry, + vscode.workspace.workspaceFolders, + ); + }); + + it('resolves plugin names for contributed graph source plugin ids', () => { + const facade = new TestLifecycleFacade(); + lifecycleState(facade)._registry.list.mockReturnValue([ + { plugin: { id: 'codegraphy.markdown', name: 'Markdown' } }, + { plugin: { id: 'codegraphy.vue', name: 'Vue' } }, + ]); + + expect(facade.getPluginNamesForIds([ + 'codegraphy.markdown', + 'codegraphy.unknown', + 'codegraphy.vue', + ])).toEqual(['Markdown', 'Vue']); + }); + + it('disposes the plugin registry', () => { + const facade = new TestLifecycleFacade(); + + facade.dispose(); + + expect(lifecycleState(facade)._registry.disposeAll).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/extension/tests/extension/pipeline/service/lifecycleFacade.test.ts b/packages/extension/tests/extension/pipeline/service/lifecycleFacade.test.ts deleted file mode 100644 index 7d9b6daa1..000000000 --- a/packages/extension/tests/extension/pipeline/service/lifecycleFacade.test.ts +++ /dev/null @@ -1,356 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import * as vscode from 'vscode'; -import { WorkspacePipelineLifecycleFacade } from '../../../../src/extension/pipeline/service/lifecycleFacade'; -import type { PluginRegistry } from '../../../../src/core/plugins/registry/manager'; -import type { IWorkspaceAnalysisCache } from '../../../../src/extension/pipeline/cache'; -import type { IDiscoveredFile } from '@codegraphy-dev/core'; -import type { - IFileAnalysisResult, - IProjectedConnection, -} from '../../../../src/core/plugins/types/contracts'; -import { clearWorkspacePipelineStoredCache } from '../../../../src/extension/pipeline/service/cache/storage'; -import { - invalidateWorkspacePipelineFiles, - resolveWorkspacePipelinePluginFilePaths, -} from '../../../../src/extension/pipeline/service/cache/invalidation'; -import { - getWorkspacePipelinePluginName, - getWorkspacePipelineStatusList, -} from '../../../../src/extension/pipeline/service/runtime/plugins'; -import { readWorkspacePluginStatusContext } from '../../../../src/extension/pipeline/plugins/statusContext'; - -vi.mock('../../../../src/extension/pipeline/service/cache/storage', () => ({ - clearWorkspacePipelineStoredCache: vi.fn(), -})); - -vi.mock('../../../../src/extension/pipeline/service/cache/invalidation', () => ({ - invalidateWorkspacePipelineFiles: vi.fn(), - resolveWorkspacePipelinePluginFilePaths: vi.fn(), -})); - -vi.mock('../../../../src/extension/pipeline/service/runtime/plugins', () => ({ - getWorkspacePipelinePluginName: vi.fn(), - getWorkspacePipelineStatusList: vi.fn(), -})); - -vi.mock('../../../../src/extension/pipeline/plugins/statusContext', () => ({ - readWorkspacePluginStatusContext: vi.fn(), -})); - -vi.mock('vscode', () => ({ - workspace: { - workspaceFolders: [{ uri: { fsPath: '/workspace' } }], - getConfiguration: vi.fn(() => ({ - get: vi.fn(), - update: vi.fn(), - inspect: vi.fn(), - })), - createFileSystemWatcher: vi.fn(), - onDidSaveTextDocument: vi.fn(), - onDidChangeConfiguration: vi.fn(), - }, -})); - -class TestLifecycleFacade extends WorkspacePipelineLifecycleFacade { - readonly getWorkspaceRoot = vi.fn<() => string | undefined>(() => '/workspace'); - readonly persistCache = vi.fn(); - - constructor() { - super({ - subscriptions: [], - workspaceState: { - get: vi.fn(), - update: vi.fn(), - }, - } as never); - this._cache = { files: {} } as unknown as IWorkspaceAnalysisCache; - this._lastDiscoveredFiles = [ - { absolutePath: '/workspace/src/a.ts', relativePath: 'src/a.ts', extension: '.ts', name: 'a.ts' }, - ] as unknown as IDiscoveredFile[]; - this._lastFileAnalysis = new Map([['src/a.ts', { id: 'analysis' }]]) as unknown as Map< - string, - IFileAnalysisResult - >; - this._lastFileConnections = new Map([['src/a.ts', [{ id: 'edge' }]]]) as unknown as Map< - string, - IProjectedConnection[] - >; - this._lastWorkspaceRoot = '/workspace'; - } - - _registry = { - list: vi.fn(() => []), - disposeAll: vi.fn(), - } as unknown as PluginRegistry; - - public override get _cache(): IWorkspaceAnalysisCache { - return super._cache; - } - - public override set _cache(cache: IWorkspaceAnalysisCache) { - super._cache = cache; - } - - public override get _lastDiscoveredFiles(): IDiscoveredFile[] { - return super._lastDiscoveredFiles; - } - - public override set _lastDiscoveredFiles(files: IDiscoveredFile[]) { - super._lastDiscoveredFiles = files; - } - - public override get _lastFileAnalysis(): Map { - return super._lastFileAnalysis; - } - - public override set _lastFileAnalysis(fileAnalysis: Map) { - super._lastFileAnalysis = fileAnalysis; - } - - public override get _lastFileConnections(): Map { - return super._lastFileConnections; - } - - public override set _lastFileConnections(fileConnections: Map) { - super._lastFileConnections = fileConnections; - } - - public override get _lastWorkspaceRoot(): string { - return super._lastWorkspaceRoot; - } - - public override set _lastWorkspaceRoot(workspaceRoot: string) { - super._lastWorkspaceRoot = workspaceRoot; - } - - protected override _getWorkspaceRoot(): string | undefined { - return this.getWorkspaceRoot(); - } - - protected override _persistCache(): void { - this.persistCache(); - } -} - -describe('pipeline/service/lifecycleFacade', () => { - const lifecycleState = ( - facade: TestLifecycleFacade, - ): { - _lastDiscoveredDirectories: string[]; - _lastDiscoveredFiles: IDiscoveredFile[]; - _registry: { - list: ReturnType; - disposeAll: ReturnType; - }; - } => facade as unknown as { - _lastDiscoveredDirectories: string[]; - _lastDiscoveredFiles: IDiscoveredFile[]; - _registry: { - list: ReturnType; - disposeAll: ReturnType; - }; - }; - - beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(clearWorkspacePipelineStoredCache).mockReturnValue({ files: { 'src/a.ts': { cached: true } } } as never); - vi.mocked(getWorkspacePipelineStatusList).mockReturnValue([{ id: 'plugin.a' }] as never); - vi.mocked(getWorkspacePipelinePluginName).mockReturnValue('TypeScript'); - vi.mocked(readWorkspacePluginStatusContext).mockReturnValue({ - installedPlugins: [ - { - package: '@codegraphy-dev/plugin-vue', - version: '2.0.0', - apiVersion: '^2.0.0', - disclosures: [], - packageRoot: '/global/node_modules/@codegraphy-dev/plugin-vue', - }, - ], - workspaceEnabledPluginIds: new Set(['codegraphy.vue']), - }); - vi.mocked(invalidateWorkspacePipelineFiles).mockReturnValue(['src/a.ts']); - vi.mocked(resolveWorkspacePipelinePluginFilePaths).mockReturnValue(['/workspace/src/a.ts']); - }); - - it('returns plugin statuses using the installed plugin status context', () => { - const facade = new TestLifecycleFacade(); - const disabledPlugins = new Set(['plugin.disabled']); - - expect(facade.getPluginStatuses(disabledPlugins)).toEqual([{ id: 'plugin.a' }]); - expect(readWorkspacePluginStatusContext).toHaveBeenCalledWith('/workspace'); - expect(getWorkspacePipelineStatusList).toHaveBeenCalledWith( - facade._registry, - disabledPlugins, - facade._lastDiscoveredFiles, - facade._lastFileConnections, - { - installedPlugins: [ - { - package: '@codegraphy-dev/plugin-vue', - version: '2.0.0', - apiVersion: '^2.0.0', - disclosures: [], - packageRoot: '/global/node_modules/@codegraphy-dev/plugin-vue', - }, - ], - workspaceEnabledPluginIds: new Set(['codegraphy.vue']), - }, - ); - }); - - it('resolves plugin names using the cached workspace root and vscode workspace folders', () => { - const facade = new TestLifecycleFacade(); - - expect(facade.getPluginNameForFile('src/a.ts')).toBe('TypeScript'); - expect(getWorkspacePipelinePluginName).toHaveBeenCalledWith( - 'src/a.ts', - '/workspace', - facade._registry, - vscode.workspace.workspaceFolders, - ); - }); - - it('resolves plugin names for contributed graph source plugin ids', () => { - const facade = new TestLifecycleFacade(); - lifecycleState(facade)._registry.list.mockReturnValue([ - { plugin: { id: 'codegraphy.markdown', name: 'Markdown' } }, - { plugin: { id: 'codegraphy.vue', name: 'Vue' } }, - ]); - - expect(facade.getPluginNamesForIds([ - 'codegraphy.markdown', - 'codegraphy.unknown', - 'codegraphy.vue', - ])).toEqual(['Markdown', 'Vue']); - }); - - it('replaces the cache through the stored-cache helper and logs its messages', () => { - const facade = new TestLifecycleFacade(); - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); - - facade.clearCache(); - - expect(clearWorkspacePipelineStoredCache).toHaveBeenCalledWith( - '/workspace', - expect.any(Function), - ); - const onMessage = vi.mocked(clearWorkspacePipelineStoredCache).mock.calls[0][1]; - onMessage('cache cleared'); - expect(logSpy).toHaveBeenCalledWith('cache cleared'); - expect(facade._cache).toEqual({ files: { 'src/a.ts': { cached: true } } }); - }); - - it('short-circuits file invalidation when no workspace root exists or no files are provided', () => { - const facade = new TestLifecycleFacade(); - facade.getWorkspaceRoot.mockReturnValue(undefined); - - expect(facade.invalidateWorkspaceFiles(['/workspace/src/a.ts'])).toEqual([]); - expect(invalidateWorkspacePipelineFiles).not.toHaveBeenCalled(); - }); - - it('returns an empty invalidation set when no file paths are provided even with a workspace root', () => { - const facade = new TestLifecycleFacade(); - - expect(facade.invalidateWorkspaceFiles([])).toEqual([]); - expect(invalidateWorkspacePipelineFiles).not.toHaveBeenCalled(); - }); - - it('invalidates workspace files and persists only when files were removed from the cache', () => { - const facade = new TestLifecycleFacade(); - lifecycleState(facade)._lastDiscoveredDirectories = ['src', 'src/a.ts', 'src/a.ts/deep']; - const toWorkspaceRelativePath = vi - .spyOn( - facade as unknown as { - _toWorkspaceRelativePath: (workspaceRoot: string, filePath: string) => string | undefined; - }, - '_toWorkspaceRelativePath', - ) - .mockReturnValue('src/a.ts'); - - expect(facade.invalidateWorkspaceFiles(['/workspace/src/a.ts'])).toEqual(['src/a.ts']); - expect(invalidateWorkspacePipelineFiles).toHaveBeenCalledWith( - { - cache: facade._cache, - lastFileAnalysis: facade._lastFileAnalysis, - lastFileConnections: facade._lastFileConnections, - }, - '/workspace', - ['/workspace/src/a.ts'], - expect.any(Function), - ); - const relativePathResolver = vi.mocked(invalidateWorkspacePipelineFiles).mock.calls[0][3]; - expect(relativePathResolver('/workspace', '/workspace/src/a.ts')).toBe('src/a.ts'); - expect(toWorkspaceRelativePath).toHaveBeenCalledWith('/workspace', '/workspace/src/a.ts'); - expect(lifecycleState(facade)._lastDiscoveredDirectories).toEqual(['src']); - expect(facade.persistCache).toHaveBeenCalledOnce(); - - vi.mocked(invalidateWorkspacePipelineFiles).mockReturnValueOnce([]); - expect(facade.invalidateWorkspaceFiles(['/workspace/src/b.ts'])).toEqual([]); - 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(); - - expect(facade.invalidatePluginFiles([])).toEqual([]); - expect(lifecycleState(facade)._registry.list).not.toHaveBeenCalled(); - expect(resolveWorkspacePipelinePluginFilePaths).not.toHaveBeenCalled(); - }); - - it('returns early when plugin invalidation has no discovered files', () => { - const facade = new TestLifecycleFacade(); - - lifecycleState(facade)._lastDiscoveredFiles = []; - expect(facade.invalidatePluginFiles(['plugin.a'])).toEqual([]); - expect(lifecycleState(facade)._registry.list).not.toHaveBeenCalled(); - expect(resolveWorkspacePipelinePluginFilePaths).not.toHaveBeenCalled(); - }); - - it('returns early when plugin invalidation finds no matching plugins', () => { - const facade = new TestLifecycleFacade(); - - lifecycleState(facade)._lastDiscoveredFiles = [ - { absolutePath: '/workspace/src/a.ts', relativePath: 'src/a.ts', extension: '.ts', name: 'a.ts' }, - ]; - lifecycleState(facade)._registry.list.mockReturnValueOnce([ - { plugin: { id: 'plugin.other', supportedExtensions: ['.py'] } }, - ]); - expect(facade.invalidatePluginFiles(['plugin.a'])).toEqual([]); - expect(resolveWorkspacePipelinePluginFilePaths).not.toHaveBeenCalled(); - }); - - it('resolves plugin file paths and invalidates them through the shared invalidation flow', () => { - const facade = new TestLifecycleFacade(); - const invalidateWorkspaceFiles = vi.spyOn(facade, 'invalidateWorkspaceFiles'); - lifecycleState(facade)._registry.list.mockReturnValueOnce([ - { plugin: { id: 'plugin.a', supportedExtensions: ['.ts'] } }, - { plugin: { id: 'plugin.b', supportedExtensions: ['.py'] } }, - ]); - - expect(facade.invalidatePluginFiles(['plugin.a'])).toEqual(['src/a.ts']); - expect(resolveWorkspacePipelinePluginFilePaths).toHaveBeenCalledWith( - '/workspace', - facade._lastDiscoveredFiles, - [{ plugin: { id: 'plugin.a', supportedExtensions: ['.ts'] } }], - ); - expect(invalidateWorkspaceFiles).toHaveBeenCalledWith(['/workspace/src/a.ts']); - }); - - it('disposes the plugin registry', () => { - const facade = new TestLifecycleFacade(); - - facade.dispose(); - - expect(lifecycleState(facade)._registry.disposeAll).toHaveBeenCalledOnce(); - }); -}); diff --git a/packages/extension/tests/extension/pipeline/service/lifecycleFacadeFixture.ts b/packages/extension/tests/extension/pipeline/service/lifecycleFacadeFixture.ts new file mode 100644 index 000000000..0fb1a41e4 --- /dev/null +++ b/packages/extension/tests/extension/pipeline/service/lifecycleFacadeFixture.ts @@ -0,0 +1,154 @@ +import type { IDiscoveredFile } from '@codegraphy-dev/core'; +import { vi } from 'vitest'; +import * as vscode from 'vscode'; +import type { PluginRegistry } from '../../../../src/core/plugins/registry/manager'; +import type { + IFileAnalysisResult, + IProjectedConnection, +} from '../../../../src/core/plugins/types/contracts'; +import type { IWorkspaceAnalysisCache } from '../../../../src/extension/pipeline/cache'; +import { readWorkspacePluginStatusContext } from '../../../../src/extension/pipeline/plugins/statusContext'; +import { + invalidateWorkspacePipelineFiles, + resolveWorkspacePipelinePluginFilePaths, +} from '../../../../src/extension/pipeline/service/cache/invalidation'; +import { clearWorkspacePipelineStoredCache } from '../../../../src/extension/pipeline/service/cache/storage'; +import { WorkspacePipelineLifecycleFacade } from '../../../../src/extension/pipeline/service/lifecycleFacade'; +import { + getWorkspacePipelinePluginName, + getWorkspacePipelineStatusList, +} from '../../../../src/extension/pipeline/service/runtime/plugins'; + +export class TestLifecycleFacade extends WorkspacePipelineLifecycleFacade { + readonly getWorkspaceRoot = vi.fn<() => string | undefined>(() => '/workspace'); + readonly persistCache = vi.fn(); + + constructor() { + super({ + subscriptions: [], + workspaceState: { + get: vi.fn(), + update: vi.fn(), + }, + } as never); + this._cache = { files: {} } as unknown as IWorkspaceAnalysisCache; + this._lastDiscoveredFiles = [ + { absolutePath: '/workspace/src/a.ts', relativePath: 'src/a.ts', extension: '.ts', name: 'a.ts' }, + ] as unknown as IDiscoveredFile[]; + this._lastFileAnalysis = new Map([['src/a.ts', { id: 'analysis' }]]) as unknown as Map< + string, + IFileAnalysisResult + >; + this._lastFileConnections = new Map([['src/a.ts', [{ id: 'edge' }]]]) as unknown as Map< + string, + IProjectedConnection[] + >; + this._lastWorkspaceRoot = '/workspace'; + } + + _registry = { + list: vi.fn(() => []), + disposeAll: vi.fn(), + } as unknown as PluginRegistry; + + public override get _cache(): IWorkspaceAnalysisCache { + return super._cache; + } + + public override set _cache(cache: IWorkspaceAnalysisCache) { + super._cache = cache; + } + + public override get _lastDiscoveredFiles(): IDiscoveredFile[] { + return super._lastDiscoveredFiles; + } + + public override set _lastDiscoveredFiles(files: IDiscoveredFile[]) { + super._lastDiscoveredFiles = files; + } + + public override get _lastFileAnalysis(): Map { + return super._lastFileAnalysis; + } + + public override set _lastFileAnalysis(fileAnalysis: Map) { + super._lastFileAnalysis = fileAnalysis; + } + + public override get _lastFileConnections(): Map { + return super._lastFileConnections; + } + + public override set _lastFileConnections(fileConnections: Map) { + super._lastFileConnections = fileConnections; + } + + public override get _lastWorkspaceRoot(): string { + return super._lastWorkspaceRoot; + } + + public override set _lastWorkspaceRoot(workspaceRoot: string) { + super._lastWorkspaceRoot = workspaceRoot; + } + + protected override _getWorkspaceRoot(): string | undefined { + return this.getWorkspaceRoot(); + } + + protected override _persistCache(): void { + this.persistCache(); + } +} + +export function lifecycleState( + facade: TestLifecycleFacade, +): { + _lastDiscoveredDirectories: string[]; + _lastDiscoveredFiles: IDiscoveredFile[]; + _registry: { + list: ReturnType; + disposeAll: ReturnType; + }; +} { + return facade as unknown as { + _lastDiscoveredDirectories: string[]; + _lastDiscoveredFiles: IDiscoveredFile[]; + _registry: { + list: ReturnType; + disposeAll: ReturnType; + }; + }; +} + +export function setUpLifecycleFacade(): void { + vi.clearAllMocks(); + vi.mocked(clearWorkspacePipelineStoredCache).mockReturnValue({ + files: { 'src/a.ts': { cached: true } }, + } as never); + vi.mocked(getWorkspacePipelineStatusList).mockReturnValue([{ id: 'plugin.a' }] as never); + vi.mocked(getWorkspacePipelinePluginName).mockReturnValue('TypeScript'); + vi.mocked(readWorkspacePluginStatusContext).mockReturnValue({ + installedPlugins: [ + { + package: '@codegraphy-dev/plugin-vue', + version: '2.0.0', + apiVersion: '^3.0.0', + disclosures: [], + packageRoot: '/global/node_modules/@codegraphy-dev/plugin-vue', + }, + ], + workspaceEnabledPluginIds: new Set(['codegraphy.vue']), + }); + vi.mocked(invalidateWorkspacePipelineFiles).mockReturnValue(['src/a.ts']); + vi.mocked(resolveWorkspacePipelinePluginFilePaths).mockReturnValue(['/workspace/src/a.ts']); +} + +export { + vscode, + clearWorkspacePipelineStoredCache, + invalidateWorkspacePipelineFiles, + resolveWorkspacePipelinePluginFilePaths, + getWorkspacePipelinePluginName, + getWorkspacePipelineStatusList, + readWorkspacePluginStatusContext, +}; diff --git a/packages/extension/tests/extension/pipeline/service/pluginStatuses.test.ts b/packages/extension/tests/extension/pipeline/service/pluginStatuses.test.ts index 7310e82bf..982b2d418 100644 --- a/packages/extension/tests/extension/pipeline/service/pluginStatuses.test.ts +++ b/packages/extension/tests/extension/pipeline/service/pluginStatuses.test.ts @@ -59,7 +59,7 @@ class PluginStatusReader extends WorkspacePipelinePluginStatusReader { id: 'codegraphy.typescript', name: 'TypeScript/JavaScript', version: '2.1.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', supportedExtensions: ['.ts', '.tsx'], analyzeFile: vi.fn(), }, diff --git a/packages/extension/tests/extension/pluginIntegration/workspaceFixture.ts b/packages/extension/tests/extension/pluginIntegration/workspaceFixture.ts index 62a76c6d4..bbfcd31db 100644 --- a/packages/extension/tests/extension/pluginIntegration/workspaceFixture.ts +++ b/packages/extension/tests/extension/pluginIntegration/workspaceFixture.ts @@ -77,7 +77,7 @@ export async function installPluginIntegrationPackage( exports: './plugin.js', codegraphy: { type: 'plugin', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', }, }, null, 2), 'utf-8', @@ -94,7 +94,7 @@ export default function createPlugin() { id: '${pluginId}', name: 'Integration Package Plugin', version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', supportedExtensions: ['.ts'], ${options.webviewContributions ? ` webviewApiVersion: '^1.0.0', @@ -174,7 +174,7 @@ export default function createPlugin() { plugins: [{ package: packageName, version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', disclosures: [], packageRoot, pluginId, diff --git a/packages/extension/tests/integration/devLaunchConfig.test.ts b/packages/extension/tests/integration/devLaunchConfig.test.ts index 8ab8936b3..ed78f2998 100644 --- a/packages/extension/tests/integration/devLaunchConfig.test.ts +++ b/packages/extension/tests/integration/devLaunchConfig.test.ts @@ -85,7 +85,7 @@ describe('dev launch config', () => { JSON.stringify({ name: '@codegraphy-pro/organize', version: '0.1.0', - codegraphy: { type: 'plugin', apiVersion: '^2.0.0' }, + codegraphy: { type: 'plugin', apiVersion: '^3.0.0' }, }), ); diff --git a/packages/extension/tests/webview/plugins/Panel.deduplication.test.tsx b/packages/extension/tests/webview/plugins/Panel.deduplication.test.tsx new file mode 100644 index 000000000..c3f58ba90 --- /dev/null +++ b/packages/extension/tests/webview/plugins/Panel.deduplication.test.tsx @@ -0,0 +1,71 @@ +import { screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../src/webview/vscodeApi', () => ({ + postMessage: vi.fn(), + vscode: { getState: () => undefined, setState: vi.fn() }, +})); + +import { renderPanel, resetPanelState } from './panelFixture'; + +describe('PluginsPanel', () => { + beforeEach(resetPanelState); + + it('deduplicates stale package status rows in favor of the latest package row', () => { + renderPanel([ + { + id: 'acme.old-tools', + name: 'Old Tools', + version: '0.1.0', + packageName: '@acme/plugin-tools', + supportedExtensions: [], + status: 'installed', + enabled: true, + connectionCount: 0, + }, + { + id: 'acme.tools', + name: 'Tools', + version: '0.1.0', + packageName: '@acme/plugin-tools', + supportedExtensions: [], + status: 'installed', + enabled: true, + connectionCount: 0, + }, + ]); + + expect(screen.getByText('Tools')).toBeInTheDocument(); + expect(screen.queryByText('Old Tools')).not.toBeInTheDocument(); + expect(screen.queryAllByRole('switch')).toHaveLength(1); + }); + + it('keeps an active package row when a later duplicate package row is only installed', () => { + renderPanel([ + { + id: 'acme.tools', + name: 'Tools', + version: '0.1.0', + packageName: '@acme/plugin-tools', + supportedExtensions: [], + status: 'active', + enabled: true, + connectionCount: 0, + }, + { + id: 'acme.tools-package', + name: '@acme/plugin-tools', + version: '0.1.0', + packageName: '@acme/plugin-tools', + supportedExtensions: [], + status: 'installed', + enabled: true, + connectionCount: 0, + }, + ]); + + expect(screen.getByText('Tools')).toBeInTheDocument(); + expect(screen.queryByText('@acme/plugin-tools')).not.toBeInTheDocument(); + expect(screen.queryAllByRole('switch')).toHaveLength(1); + }); +}); diff --git a/packages/extension/tests/webview/plugins/Panel.interactions.test.tsx b/packages/extension/tests/webview/plugins/Panel.interactions.test.tsx new file mode 100644 index 000000000..01ff06684 --- /dev/null +++ b/packages/extension/tests/webview/plugins/Panel.interactions.test.tsx @@ -0,0 +1,88 @@ +import { fireEvent, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const sentMessages = vi.hoisted((): unknown[] => []); + +vi.mock('../../../src/webview/vscodeApi', () => ({ + postMessage: (message: unknown) => sentMessages.push(message), + vscode: { getState: () => undefined, setState: vi.fn() }, +})); + +import { + graphStore, + renderPanel, + resetPanelState, +} from './panelFixture'; + +describe('PluginsPanel', () => { + beforeEach(() => { + sentMessages.length = 0; + resetPanelState(); + }); + + it('posts a plugin toggle message when the plugin switch changes', () => { + renderPanel([ + { + id: 'codegraphy.typescript', + name: 'TypeScript', + version: '1.0.0', + packageName: '@codegraphy-dev/plugin-typescript', + supportedExtensions: ['.ts'], + status: 'active', + enabled: true, + connectionCount: 12, + }, + ]); + + fireEvent.click(screen.getByRole('switch')); + + expect(sentMessages).toContainEqual({ + type: 'TOGGLE_PLUGIN', + payload: { + pluginId: 'codegraphy.typescript', + enabled: false, + }, + }); + expect(graphStore.getState().pluginStatuses).toEqual([ + expect.objectContaining({ + id: 'codegraphy.typescript', + enabled: false, + }), + ]); + }); + + it('does not expose plugin rows as draggable reorder targets', () => { + const { container } = renderPanel([ + { + id: 'codegraphy.typescript', + name: 'TypeScript', + version: '1.0.0', + packageName: '@codegraphy-dev/plugin-typescript', + supportedExtensions: ['.ts'], + status: 'active', + enabled: true, + connectionCount: 12, + }, + { + id: 'codegraphy.markdown', + name: 'Markdown', + version: '1.0.0', + packageName: '@codegraphy-dev/plugin-markdown', + supportedExtensions: ['.md'], + status: 'active', + enabled: true, + connectionCount: 1, + }, + ]); + + const pluginRows = container.querySelectorAll('[data-testid="plugin-row"]'); + expect(pluginRows).toHaveLength(2); + expect(container.querySelectorAll('[draggable="true"]')).toHaveLength(0); + + fireEvent.dragStart(pluginRows[1]); + fireEvent.dragOver(pluginRows[0]); + fireEvent.drop(pluginRows[0]); + + expect(sentMessages).toEqual([]); + }); +}); diff --git a/packages/extension/tests/webview/plugins/Panel.test.tsx b/packages/extension/tests/webview/plugins/Panel.presentation.test.tsx similarity index 51% rename from packages/extension/tests/webview/plugins/Panel.test.tsx rename to packages/extension/tests/webview/plugins/Panel.presentation.test.tsx index a7197c626..8379e3d14 100644 --- a/packages/extension/tests/webview/plugins/Panel.test.tsx +++ b/packages/extension/tests/webview/plugins/Panel.presentation.test.tsx @@ -1,35 +1,22 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; import { fireEvent, render, screen } from '@testing-library/react'; -import PluginsPanel from '../../../src/webview/components/plugins/Panel'; -import { graphStore } from '../../../src/webview/store/state'; -import type { IPluginStatus } from '../../../src/shared/plugins/status'; -import { PluginRegistry } from '../../../src/core/plugins/registry/manager'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { buildWorkspaceIndexPluginStatuses as buildWorkspacePluginStatuses } from '@codegraphy-dev/core'; - -const sentMessages: unknown[] = []; +import { PluginRegistry } from '../../../src/core/plugins/registry/manager'; vi.mock('../../../src/webview/vscodeApi', () => ({ - postMessage: (message: unknown) => sentMessages.push(message), + postMessage: vi.fn(), vscode: { getState: () => undefined, setState: vi.fn() }, })); -function setPluginStatuses(pluginStatuses: IPluginStatus[]) { - graphStore.setState({ pluginStatuses }); -} - -function renderPanel(pluginStatuses: IPluginStatus[], isOpen = true) { - setPluginStatuses(pluginStatuses); - const onClose = vi.fn(); - const result = render(); - return { ...result, onClose }; -} +import { + PluginsPanel, + graphStore, + renderPanel, + resetPanelState, +} from './panelFixture'; describe('PluginsPanel', () => { - beforeEach(() => { - sentMessages.length = 0; - graphStore.setState({ graphIsIndexing: false }); - setPluginStatuses([]); - }); + beforeEach(resetPanelState); it('returns null when the panel is closed', () => { const { container } = renderPanel([], false); @@ -70,7 +57,7 @@ describe('PluginsPanel', () => { id: 'codegraphy.typescript', name: 'TypeScript/JavaScript', version: '2.1.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', supportedExtensions: ['.ts', '.tsx'], analyzeFile: vi.fn(), }, @@ -96,7 +83,8 @@ describe('PluginsPanel', () => { expect(screen.queryByText('No plugins registered.')).not.toBeInTheDocument(); expect(screen.getByText('TypeScript/JavaScript')).toBeInTheDocument(); - expect(screen.getByRole('switch', { name: 'TypeScript/JavaScript' })).toHaveAttribute('aria-checked', 'true'); + expect(screen.getByRole('switch', { name: 'TypeScript/JavaScript' })) + .toHaveAttribute('aria-checked', 'true'); expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'true'); }); @@ -108,37 +96,6 @@ describe('PluginsPanel', () => { expect(onClose).toHaveBeenCalledTimes(1); }); - it('posts a plugin toggle message when the plugin switch changes', () => { - renderPanel([ - { - id: 'codegraphy.typescript', - name: 'TypeScript', - version: '1.0.0', - packageName: '@codegraphy-dev/plugin-typescript', - supportedExtensions: ['.ts'], - status: 'active', - enabled: true, - connectionCount: 12, - }, - ]); - - fireEvent.click(screen.getByRole('switch')); - - expect(sentMessages).toContainEqual({ - type: 'TOGGLE_PLUGIN', - payload: { - pluginId: 'codegraphy.typescript', - enabled: false, - }, - }); - expect(graphStore.getState().pluginStatuses).toEqual([ - expect.objectContaining({ - id: 'codegraphy.typescript', - enabled: false, - }), - ]); - }); - it('renders plugin-only rows without per-source content', () => { renderPanel([ { @@ -185,64 +142,6 @@ describe('PluginsPanel', () => { expect(screen.queryAllByRole('switch')).toHaveLength(1); }); - it('deduplicates stale package status rows in favor of the latest package row', () => { - renderPanel([ - { - id: 'acme.old-tools', - name: 'Old Tools', - version: '0.1.0', - packageName: '@acme/plugin-tools', - supportedExtensions: [], - status: 'installed', - enabled: true, - connectionCount: 0, - }, - { - id: 'acme.tools', - name: 'Tools', - version: '0.1.0', - packageName: '@acme/plugin-tools', - supportedExtensions: [], - status: 'installed', - enabled: true, - connectionCount: 0, - }, - ]); - - expect(screen.getByText('Tools')).toBeInTheDocument(); - expect(screen.queryByText('Old Tools')).not.toBeInTheDocument(); - expect(screen.queryAllByRole('switch')).toHaveLength(1); - }); - - it('keeps an active package row when a later duplicate package row is only installed', () => { - renderPanel([ - { - id: 'acme.tools', - name: 'Tools', - version: '0.1.0', - packageName: '@acme/plugin-tools', - supportedExtensions: [], - status: 'active', - enabled: true, - connectionCount: 0, - }, - { - id: 'acme.tools-package', - name: '@acme/plugin-tools', - version: '0.1.0', - packageName: '@acme/plugin-tools', - supportedExtensions: [], - status: 'installed', - enabled: true, - connectionCount: 0, - }, - ]); - - expect(screen.getByText('Tools')).toBeInTheDocument(); - expect(screen.queryByText('@acme/plugin-tools')).not.toBeInTheDocument(); - expect(screen.queryAllByRole('switch')).toHaveLength(1); - }); - it('does not render runtime availability subtext for plugin rows', () => { renderPanel([ { @@ -261,62 +160,17 @@ describe('PluginsPanel', () => { expect(screen.getByTestId('plugin-row').querySelector('.text-\\[10px\\]')).toBeNull(); }); - it('does not expose plugin rows as draggable reorder targets', () => { - const { container } = renderPanel([ - { - id: 'codegraphy.typescript', - name: 'TypeScript', - version: '1.0.0', - packageName: '@codegraphy-dev/plugin-typescript', - supportedExtensions: ['.ts'], - status: 'active', - enabled: true, - connectionCount: 12, - }, - { - id: 'codegraphy.markdown', - name: 'Markdown', - version: '1.0.0', - packageName: '@codegraphy-dev/plugin-markdown', - supportedExtensions: ['.md'], - status: 'active', - enabled: true, - connectionCount: 1, - }, - ]); - - const pluginRows = container.querySelectorAll('[data-testid="plugin-row"]'); - expect(pluginRows).toHaveLength(2); - expect(container.querySelectorAll('[draggable="true"]')).toHaveLength(0); - - fireEvent.dragStart(pluginRows[1]); - fireEvent.dragOver(pluginRows[0]); - fireEvent.drop(pluginRows[0]); - - expect(sentMessages).toEqual([]); - }); - it('renders plugin rows inside the shared divided list style', () => { const { container } = renderPanel([ { - id: 'codegraphy.typescript', - name: 'TypeScript', - version: '1.0.0', - packageName: '@codegraphy-dev/plugin-typescript', - supportedExtensions: ['.ts'], - status: 'active', - enabled: true, - connectionCount: 12, + id: 'codegraphy.typescript', name: 'TypeScript', version: '1.0.0', + packageName: '@codegraphy-dev/plugin-typescript', supportedExtensions: ['.ts'], + status: 'active', enabled: true, connectionCount: 12, }, { - id: 'codegraphy.markdown', - name: 'Markdown', - version: '1.0.0', - packageName: '@codegraphy-dev/plugin-markdown', - supportedExtensions: ['.md'], - status: 'active', - enabled: true, - connectionCount: 1, + id: 'codegraphy.markdown', name: 'Markdown', version: '1.0.0', + packageName: '@codegraphy-dev/plugin-markdown', supportedExtensions: ['.md'], + status: 'active', enabled: true, connectionCount: 1, }, ]); diff --git a/packages/extension/tests/webview/plugins/panelFixture.tsx b/packages/extension/tests/webview/plugins/panelFixture.tsx new file mode 100644 index 000000000..089390319 --- /dev/null +++ b/packages/extension/tests/webview/plugins/panelFixture.tsx @@ -0,0 +1,24 @@ +import { render } from '@testing-library/react'; +import { vi } from 'vitest'; +import type { IPluginStatus } from '../../../src/shared/plugins/status'; +import PluginsPanel from '../../../src/webview/components/plugins/Panel'; +import { graphStore } from '../../../src/webview/store/state'; + +export function setPluginStatuses(pluginStatuses: IPluginStatus[]): void { + graphStore.setState({ pluginStatuses }); +} + +export function renderPanel(pluginStatuses: IPluginStatus[], isOpen = true) { + setPluginStatuses(pluginStatuses); + const onClose = vi.fn(); + const result = render(); + return { ...result, onClose }; +} + +export function resetPanelState(): void { + graphStore.setState({ graphIsIndexing: false }); + setPluginStatuses([]); +} + +export { PluginsPanel, graphStore }; +export type { IPluginStatus }; diff --git a/packages/graph-renderer/CHANGELOG.md b/packages/graph-renderer/CHANGELOG.md new file mode 100644 index 000000000..9097d04a7 --- /dev/null +++ b/packages/graph-renderer/CHANGELOG.md @@ -0,0 +1,17 @@ +# @codegraphy-dev/graph-renderer + +## 0.1.0 + +### Minor Changes + +- [#308](https://github.com/joesobo/CodeGraphyV4/pull/308) [`ae76da4`](https://github.com/joesobo/CodeGraphyV4/commit/ae76da4c7c59436dcaa7e8776c8145e5b057926d) Thanks [@joesobo](https://github.com/joesobo)! - CodeGraphy now renders the Relationship Graph with its own WebGPU renderer and deterministic WebAssembly force-layout engine instead of `react-force-graph` and `d3-force`. Users get one maintained rendering path with size-aware collisions, stable node stacking, directional edges, device-loss recovery, and the remaining supported 2D graph interactions and settings. The accompanying changesets describe the intentionally removed 3D, Timeline, DAG-layout, Churn-sizing, and Uniform-sizing controls. + + Node bodies and the always-on-top Canvas decoration layer now use the same ascending-size order, stable graph-index tie-break, hover-last order, and hover scale. Labels, images, badges, and plugin Canvas drawings remain a presentation layer above all WebGPU node bodies so labels stay readable; their relative node order matches the body order, but decoration pixels are intentionally not interleaved between WebGPU bodies. + + The Graph View now requires WebGPU and WebAssembly support. When VS Code or the host GPU cannot create a WebGPU device, CodeGraphy shows an unsupported-renderer state rather than falling back to the previous Canvas or Three.js renderers. + + A new public `@codegraphy-dev/graph-renderer` package exposes the renderer contracts, WebGPU renderer, typed-array physics engine, and WASM preparation entry point for browser applications. Package consumers must call `prepareGraphPhysics()` before creating a graph layout and must ship the package's generated WASM asset alongside the JavaScript bundle. Renderer consumers are responsible for their canvas, scheduling, camera, interactions, and recovery UI. The renderer now tries a software adapter when native device creation fails, rejects graph frames that exceed WebGPU buffer limits before changing renderer state, and reports uncaptured runtime GPU errors through the required `onRendererError` callback so hosts can replace the renderer instead of leaving blank output. + + Physics consumers can install host forces through `tick({ beforeIntegration, afterIntegration })`. Owned forces accumulate first, host forces run before integration, and optional finalization runs after collision correction for fixed-coordinate constraints. The package rejects coordinates, velocities, radii, charge multipliers, and force settings outside its documented safe numerical domains instead of silently clamping or resetting nodes. Collision radii remain fixed in graph space: camera zoom never reheats or permanently spreads a settled layout, while a node pinned during dragging still pushes overlapping neighbors aside. Plugin `fx` and `fy` constraints retain per-axis behavior and do not release user or drag pins when a plugin clears them. + + Plugin node and edge colors keep browser CSS compatibility after the WebGPU move. Named colors, HSL, percentage RGB, `currentColor`, and resolvable custom properties are computed in the active Graph View theme before upload; invalid or missing variables fall back to the normal graph color instead of turning black. Plugin and CSS-snippet stylesheet toggles refresh cached GPU colors. Direct `@codegraphy-dev/graph-renderer` consumers must provide concrete hexadecimal, numeric RGB, `color(srgb ...)`, or transparent frame colors. diff --git a/packages/graph-renderer/package.json b/packages/graph-renderer/package.json index 704de825a..29a631023 100644 --- a/packages/graph-renderer/package.json +++ b/packages/graph-renderer/package.json @@ -1,6 +1,6 @@ { "name": "@codegraphy-dev/graph-renderer", - "version": "0.0.0", + "version": "0.1.0", "description": "CodeGraphy's WebGPU graph renderer and deterministic WebAssembly physics engine", "license": "MIT", "type": "module", diff --git a/packages/mcp/CHANGELOG.md b/packages/mcp/CHANGELOG.md index b8b042d29..b9d01efd0 100644 --- a/packages/mcp/CHANGELOG.md +++ b/packages/mcp/CHANGELOG.md @@ -1,5 +1,12 @@ # @codegraphy-dev/mcp +## 2.2.8 + +### Patch Changes + +- Updated dependencies [[`b744f20`](https://github.com/joesobo/CodeGraphyV4/commit/b744f20bb1391e9a0c40d3e448a4f3f78bde4974), [`5a65047`](https://github.com/joesobo/CodeGraphyV4/commit/5a65047d1a715f005760ace0ebf0f550a16efa2e)]: + - @codegraphy-dev/core@2.0.0 + ## 2.2.7 ### Patch Changes diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 5cacae997..410757bce 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@codegraphy-dev/mcp", - "version": "2.2.7", + "version": "2.2.8", "description": "CodeGraphy MCP stdio server", "license": "MIT", "type": "module", diff --git a/packages/plugin-api/CHANGELOG.md b/packages/plugin-api/CHANGELOG.md index 31338f2b6..d29edd574 100644 --- a/packages/plugin-api/CHANGELOG.md +++ b/packages/plugin-api/CHANGELOG.md @@ -1,5 +1,19 @@ # @codegraphy-dev/plugin-api +## 6.0.0 + +### Major Changes + +- Advance the host runtime compatibility protocol to Plugin API 3. Plugins must declare `apiVersion: '^3.0.0'`; plugins targeting the removed v2 contracts are rejected before registration. + +- [#308](https://github.com/joesobo/CodeGraphyV4/pull/308) [`b744f20`](https://github.com/joesobo/CodeGraphyV4/commit/b744f20bb1391e9a0c40d3e448a4f3f78bde4974) Thanks [@joesobo](https://github.com/joesobo)! - CodeGraphy now provides one supported 2D Relationship Graph and removes the 3D graph mode, its toolbar toggle, 3D node shapes, 3D camera state, and Three.js renderer settings. Existing workspaces open directly in the 2D graph; saved 3D preferences are ignored. + + This is a breaking Plugin API change. Plugin authors must remove `GraphNodeShape3D`, `shape3D`, `graphMode`, three-dimensional node coordinates (`z`, `fz`, and `vz`), and 3D values in selected-node position payloads. Graph View contributions, drag callbacks, context-menu selectors, and viewport adapters now receive only two-dimensional graph state. The Unity plugin continues to contribute Unity graph data but no longer supplies 3D presentation metadata. + +- [#308](https://github.com/joesobo/CodeGraphyV4/pull/308) [`5a65047`](https://github.com/joesobo/CodeGraphyV4/commit/5a65047d1a715f005760ace0ebf0f550a16efa2e) Thanks [@joesobo](https://github.com/joesobo)! - CodeGraphy now opens one current-workspace Relationship Graph and removes the Timeline panel, commit-by-commit Graph Revision playback, revision controls, and Git-history Churn node sizing. Existing workspaces keep their current graph settings, but saved Timeline state and Churn sizing selections no longer affect the graph. Choose Connections or File Size for semantic node sizing. + + This is a breaking Plugin API and Core package change. Plugin authors must remove the `timeline-panel` slot, Timeline lifecycle events and payloads, `timelineActive` contribution/context fields, Timeline analysis mode and `commitSha`, and the optional `churn` graph-node field. Core callers must stop passing churn counts into graph construction. Plugins should analyze the current CodeGraphy Workspace and contribute to the normal Graph View instead of branching on Timeline state. + ## 5.3.0 ### Minor Changes diff --git a/packages/plugin-api/README.md b/packages/plugin-api/README.md index 677788d08..fb7473353 100644 --- a/packages/plugin-api/README.md +++ b/packages/plugin-api/README.md @@ -58,7 +58,7 @@ Package plugins need static metadata before Core can import runtime code. Put pa "id": "acme.plugin", "name": "Acme Plugin", "version": "1.0.0", - "apiVersion": "^2.0.0", + "apiVersion": "^3.0.0", "supportedExtensions": [".ts"] } ``` @@ -89,7 +89,7 @@ const plugin: IPlugin = { id: 'acme.plugin', name: 'Acme Plugin', version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', supportedExtensions: ['.ts'], contributeGraphScopeCapabilities({ filePaths }) { const hasTypeScript = filePaths.some((filePath) => filePath.endsWith('.ts')); @@ -135,7 +135,7 @@ const plugin: IPlugin = { id: 'acme.plugin', name: 'Acme Plugin', version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', supportedExtensions: ['.ts'], }; ``` @@ -147,7 +147,7 @@ const createPlugin: IPluginFactory = ({ dataHost } = {}) => ({ id: 'acme.plugin', name: 'Acme Plugin', version: '1.0.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', supportedExtensions: [], async initialize() { await dataHost?.saveData({ expanded: true }); diff --git a/packages/plugin-api/package.json b/packages/plugin-api/package.json index 26b772f48..785a8d831 100644 --- a/packages/plugin-api/package.json +++ b/packages/plugin-api/package.json @@ -1,6 +1,6 @@ { "name": "@codegraphy-dev/plugin-api", - "version": "5.3.0", + "version": "6.0.0", "description": "Type definitions for CodeGraphy plugins", "engines": { "node": ">=20" diff --git a/packages/plugin-api/src/plugin.ts b/packages/plugin-api/src/plugin.ts index 320a96deb..7e0ae5ad5 100644 --- a/packages/plugin-api/src/plugin.ts +++ b/packages/plugin-api/src/plugin.ts @@ -1,7 +1,7 @@ /** * @fileoverview The IPlugin interface — the canonical plugin contract. * - * Plugin API v2 is required. Every plugin must declare `apiVersion` + * Plugin API v3 is required. Every plugin must declare `apiVersion` * and is validated by the host at registration time. * * @module @codegraphy-dev/plugin-api/plugin @@ -175,7 +175,7 @@ export interface IPluginUpdateImpactPolicy { * id: 'myplugin.coverage', * name: 'Coverage Overlay', * version: '0.1.0', - * apiVersion: '^2.0.0', + * apiVersion: '^3.0.0', * supportedExtensions: [], * async analyzeFile(filePath) { * return { filePath, relations: [] }; diff --git a/packages/plugin-api/tests/pluginContracts.test.ts b/packages/plugin-api/tests/pluginContracts.test.ts index b61e4a35e..22fc9ec60 100644 --- a/packages/plugin-api/tests/pluginContracts.test.ts +++ b/packages/plugin-api/tests/pluginContracts.test.ts @@ -24,7 +24,7 @@ describe('plugin API contracts', () => { id: 'acme.routes', name: 'Acme Routes', version: '0.1.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', supportedExtensions: ['.route'], contributeEdgeTypes: () => [{ id: 'acme.routes:route', @@ -51,7 +51,7 @@ describe('plugin API contracts', () => { id: 'acme.account', name: 'Acme Account', version: '0.1.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', supportedExtensions: [], accessProvider: { id: 'acme.account.access', @@ -150,7 +150,7 @@ describe('plugin API contracts', () => { id: 'acme.graph-tools', name: 'Acme Graph Tools', version: '0.1.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', supportedExtensions: ['*'], requiresAccess: premiumAccess, graphView: { @@ -183,7 +183,7 @@ describe('plugin API contracts', () => { id: 'acme.graph-tools', name: 'Acme Graph Tools', version: '0.1.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', supportedExtensions: [], async initialize() { await options?.dataHost?.saveData({ runtimeNodes: [] }); @@ -202,7 +202,7 @@ describe('plugin API contracts', () => { id: 'acme.graph-tools', name: 'Acme Graph Tools', version: '0.1.0', - apiVersion: '^2.0.0', + apiVersion: '^3.0.0', supportedExtensions: [], webviewApiVersion: '^1.0.0', webviewContributions: { diff --git a/packages/plugin-godot/CHANGELOG.md b/packages/plugin-godot/CHANGELOG.md index 2d8328316..17cc3fdc0 100644 --- a/packages/plugin-godot/CHANGELOG.md +++ b/packages/plugin-godot/CHANGELOG.md @@ -1,5 +1,14 @@ # @codegraphy-dev/plugin-godot +## 2.3.2 + +### Patch Changes + +- Declare compatibility with the new CodeGraphy Plugin API 3 runtime contract. + +- Updated dependencies [[`b744f20`](https://github.com/joesobo/CodeGraphyV4/commit/b744f20bb1391e9a0c40d3e448a4f3f78bde4974), [`5a65047`](https://github.com/joesobo/CodeGraphyV4/commit/5a65047d1a715f005760ace0ebf0f550a16efa2e)]: + - @codegraphy-dev/plugin-api@6.0.0 + ## 2.3.1 ### Patch Changes diff --git a/packages/plugin-godot/codegraphy.json b/packages/plugin-godot/codegraphy.json index ca2ba0c5f..1477f018d 100644 --- a/packages/plugin-godot/codegraphy.json +++ b/packages/plugin-godot/codegraphy.json @@ -3,7 +3,7 @@ "id": "codegraphy.gdscript", "name": "GDScript (Godot)", "version": "1.0.0", - "apiVersion": "^2.0.0", + "apiVersion": "^3.0.0", "supportedExtensions": [ ".gd", ".godot", diff --git a/packages/plugin-godot/package.json b/packages/plugin-godot/package.json index 6322e547a..5c60aa474 100644 --- a/packages/plugin-godot/package.json +++ b/packages/plugin-godot/package.json @@ -1,6 +1,6 @@ { "name": "@codegraphy-dev/plugin-godot", - "version": "2.3.1", + "version": "2.3.2", "description": "CodeGraphy Godot plugin", "license": "MIT", "type": "module", @@ -56,7 +56,7 @@ }, "codegraphy": { "type": "plugin", - "apiVersion": "^2.0.0", + "apiVersion": "^3.0.0", "disclosures": [] } } diff --git a/packages/plugin-markdown/CHANGELOG.md b/packages/plugin-markdown/CHANGELOG.md index 9de75c47f..d5b5ce1f3 100644 --- a/packages/plugin-markdown/CHANGELOG.md +++ b/packages/plugin-markdown/CHANGELOG.md @@ -1,5 +1,14 @@ # @codegraphy-dev/plugin-markdown +## 1.1.9 + +### Patch Changes + +- Declare compatibility with the new CodeGraphy Plugin API 3 runtime contract. + +- Updated dependencies [[`b744f20`](https://github.com/joesobo/CodeGraphyV4/commit/b744f20bb1391e9a0c40d3e448a4f3f78bde4974), [`5a65047`](https://github.com/joesobo/CodeGraphyV4/commit/5a65047d1a715f005760ace0ebf0f550a16efa2e)]: + - @codegraphy-dev/plugin-api@6.0.0 + ## 1.1.8 ### Patch Changes diff --git a/packages/plugin-markdown/codegraphy.json b/packages/plugin-markdown/codegraphy.json index e758b1711..cca215bb7 100644 --- a/packages/plugin-markdown/codegraphy.json +++ b/packages/plugin-markdown/codegraphy.json @@ -3,7 +3,7 @@ "id": "codegraphy.markdown", "name": "Markdown", "version": "1.0.0", - "apiVersion": "^2.0.0", + "apiVersion": "^3.0.0", "supportedExtensions": [ "*" ], diff --git a/packages/plugin-markdown/package.json b/packages/plugin-markdown/package.json index 6aacc1eb1..70e06f902 100644 --- a/packages/plugin-markdown/package.json +++ b/packages/plugin-markdown/package.json @@ -1,6 +1,6 @@ { "name": "@codegraphy-dev/plugin-markdown", - "version": "1.1.8", + "version": "1.1.9", "description": "CodeGraphy markdown plugin", "license": "MIT", "type": "module", @@ -36,7 +36,7 @@ }, "codegraphy": { "type": "plugin", - "apiVersion": "^2.0.0", + "apiVersion": "^3.0.0", "disclosures": [] } } diff --git a/packages/plugin-markdown/tests/plugin.test.ts b/packages/plugin-markdown/tests/plugin.test.ts index ddd37b3c8..6bc27d00e 100644 --- a/packages/plugin-markdown/tests/plugin.test.ts +++ b/packages/plugin-markdown/tests/plugin.test.ts @@ -51,7 +51,7 @@ describe('createMarkdownPlugin', () => { it('exposes the apiVersion from codegraphy.json', () => { const plugin = createMarkdownPlugin(); - expect(plugin.apiVersion).toBe('^2.0.0'); + expect(plugin.apiVersion).toBe('^3.0.0'); }); it('supports wildcard file scanning', () => { diff --git a/packages/plugin-particles/CHANGELOG.md b/packages/plugin-particles/CHANGELOG.md index 27d5746f0..f8c9e4726 100644 --- a/packages/plugin-particles/CHANGELOG.md +++ b/packages/plugin-particles/CHANGELOG.md @@ -1,5 +1,14 @@ # @codegraphy-dev/plugin-particles +## 0.2.3 + +### Patch Changes + +- Declare compatibility with the new CodeGraphy Plugin API 3 runtime contract. + +- Updated dependencies [[`b744f20`](https://github.com/joesobo/CodeGraphyV4/commit/b744f20bb1391e9a0c40d3e448a4f3f78bde4974), [`5a65047`](https://github.com/joesobo/CodeGraphyV4/commit/5a65047d1a715f005760ace0ebf0f550a16efa2e)]: + - @codegraphy-dev/plugin-api@6.0.0 + ## 0.2.2 ### Patch Changes diff --git a/packages/plugin-particles/codegraphy.json b/packages/plugin-particles/codegraphy.json index 5857fb182..3063c851a 100644 --- a/packages/plugin-particles/codegraphy.json +++ b/packages/plugin-particles/codegraphy.json @@ -3,7 +3,7 @@ "id": "codegraphy.particles", "name": "Particles", "version": "0.1.0", - "apiVersion": "^2.0.0", + "apiVersion": "^3.0.0", "supportedExtensions": [], "defaultFilters": [], "updateImpact": { diff --git a/packages/plugin-particles/package.json b/packages/plugin-particles/package.json index 4d70d970f..f9f022611 100644 --- a/packages/plugin-particles/package.json +++ b/packages/plugin-particles/package.json @@ -1,6 +1,6 @@ { "name": "@codegraphy-dev/plugin-particles", - "version": "0.2.2", + "version": "0.2.3", "description": "CodeGraphy graph background particle effects plugin", "license": "MIT", "type": "module", @@ -63,6 +63,6 @@ }, "codegraphy": { "type": "plugin", - "apiVersion": "^2.0.0" + "apiVersion": "^3.0.0" } } diff --git a/packages/plugin-svelte/CHANGELOG.md b/packages/plugin-svelte/CHANGELOG.md index efd5372a0..c31613563 100644 --- a/packages/plugin-svelte/CHANGELOG.md +++ b/packages/plugin-svelte/CHANGELOG.md @@ -1,5 +1,14 @@ # @codegraphy-dev/plugin-svelte +## 0.2.5 + +### Patch Changes + +- Declare compatibility with the new CodeGraphy Plugin API 3 runtime contract. + +- Updated dependencies [[`b744f20`](https://github.com/joesobo/CodeGraphyV4/commit/b744f20bb1391e9a0c40d3e448a4f3f78bde4974), [`5a65047`](https://github.com/joesobo/CodeGraphyV4/commit/5a65047d1a715f005760ace0ebf0f550a16efa2e)]: + - @codegraphy-dev/plugin-api@6.0.0 + ## 0.2.4 ### Patch Changes diff --git a/packages/plugin-svelte/codegraphy.json b/packages/plugin-svelte/codegraphy.json index b22500072..638bc0d2f 100644 --- a/packages/plugin-svelte/codegraphy.json +++ b/packages/plugin-svelte/codegraphy.json @@ -3,7 +3,7 @@ "id": "codegraphy.svelte", "name": "Svelte", "version": "0.1.0", - "apiVersion": "^2.0.0", + "apiVersion": "^3.0.0", "supportedExtensions": [ ".svelte" ], diff --git a/packages/plugin-svelte/package.json b/packages/plugin-svelte/package.json index e04dffee0..fd6937766 100644 --- a/packages/plugin-svelte/package.json +++ b/packages/plugin-svelte/package.json @@ -1,6 +1,6 @@ { "name": "@codegraphy-dev/plugin-svelte", - "version": "0.2.4", + "version": "0.2.5", "description": "CodeGraphy Svelte plugin", "license": "MIT", "type": "module", @@ -53,7 +53,7 @@ }, "codegraphy": { "type": "plugin", - "apiVersion": "^2.0.0", + "apiVersion": "^3.0.0", "disclosures": [] } } diff --git a/packages/plugin-typescript/CHANGELOG.md b/packages/plugin-typescript/CHANGELOG.md index dc570d65b..a5e4d7606 100644 --- a/packages/plugin-typescript/CHANGELOG.md +++ b/packages/plugin-typescript/CHANGELOG.md @@ -1,5 +1,14 @@ # @codegraphy-dev/plugin-typescript +## 2.2.6 + +### Patch Changes + +- Declare compatibility with the new CodeGraphy Plugin API 3 runtime contract. + +- Updated dependencies [[`b744f20`](https://github.com/joesobo/CodeGraphyV4/commit/b744f20bb1391e9a0c40d3e448a4f3f78bde4974), [`5a65047`](https://github.com/joesobo/CodeGraphyV4/commit/5a65047d1a715f005760ace0ebf0f550a16efa2e)]: + - @codegraphy-dev/plugin-api@6.0.0 + ## 2.2.5 ### Patch Changes diff --git a/packages/plugin-typescript/codegraphy.json b/packages/plugin-typescript/codegraphy.json index 4795adf41..64c2aec07 100644 --- a/packages/plugin-typescript/codegraphy.json +++ b/packages/plugin-typescript/codegraphy.json @@ -3,7 +3,7 @@ "id": "codegraphy.typescript", "name": "TypeScript/JavaScript", "version": "1.0.0", - "apiVersion": "^2.0.0", + "apiVersion": "^3.0.0", "supportedExtensions": [ ".ts", ".tsx", diff --git a/packages/plugin-typescript/package.json b/packages/plugin-typescript/package.json index 0e07455b5..42de94154 100644 --- a/packages/plugin-typescript/package.json +++ b/packages/plugin-typescript/package.json @@ -1,6 +1,6 @@ { "name": "@codegraphy-dev/plugin-typescript", - "version": "2.2.5", + "version": "2.2.6", "description": "CodeGraphy TypeScript and JavaScript plugin", "license": "MIT", "type": "module", @@ -53,7 +53,7 @@ }, "codegraphy": { "type": "plugin", - "apiVersion": "^2.0.0", + "apiVersion": "^3.0.0", "disclosures": [] } } diff --git a/packages/plugin-unity/CHANGELOG.md b/packages/plugin-unity/CHANGELOG.md index 7c03646bd..c2ae19b9a 100644 --- a/packages/plugin-unity/CHANGELOG.md +++ b/packages/plugin-unity/CHANGELOG.md @@ -1,5 +1,18 @@ # @codegraphy-dev/plugin-unity +## 0.2.4 + +### Patch Changes + +- Declare compatibility with the new CodeGraphy Plugin API 3 runtime contract. + +- [#308](https://github.com/joesobo/CodeGraphyV4/pull/308) [`b744f20`](https://github.com/joesobo/CodeGraphyV4/commit/b744f20bb1391e9a0c40d3e448a4f3f78bde4974) Thanks [@joesobo](https://github.com/joesobo)! - CodeGraphy now provides one supported 2D Relationship Graph and removes the 3D graph mode, its toolbar toggle, 3D node shapes, 3D camera state, and Three.js renderer settings. Existing workspaces open directly in the 2D graph; saved 3D preferences are ignored. + + This is a breaking Plugin API change. Plugin authors must remove `GraphNodeShape3D`, `shape3D`, `graphMode`, three-dimensional node coordinates (`z`, `fz`, and `vz`), and 3D values in selected-node position payloads. Graph View contributions, drag callbacks, context-menu selectors, and viewport adapters now receive only two-dimensional graph state. The Unity plugin continues to contribute Unity graph data but no longer supplies 3D presentation metadata. + +- Updated dependencies [[`b744f20`](https://github.com/joesobo/CodeGraphyV4/commit/b744f20bb1391e9a0c40d3e448a4f3f78bde4974), [`5a65047`](https://github.com/joesobo/CodeGraphyV4/commit/5a65047d1a715f005760ace0ebf0f550a16efa2e)]: + - @codegraphy-dev/plugin-api@6.0.0 + ## 0.2.3 ### Patch Changes diff --git a/packages/plugin-unity/codegraphy.json b/packages/plugin-unity/codegraphy.json index 29cd84723..6555e5203 100644 --- a/packages/plugin-unity/codegraphy.json +++ b/packages/plugin-unity/codegraphy.json @@ -3,7 +3,7 @@ "id": "codegraphy.unity", "name": "Unity", "version": "1.0.1", - "apiVersion": "^2.0.0", + "apiVersion": "^3.0.0", "supportedExtensions": [ ".unity", ".prefab", diff --git a/packages/plugin-unity/package.json b/packages/plugin-unity/package.json index bc954abdf..3046a5333 100644 --- a/packages/plugin-unity/package.json +++ b/packages/plugin-unity/package.json @@ -1,6 +1,6 @@ { "name": "@codegraphy-dev/plugin-unity", - "version": "0.2.3", + "version": "0.2.4", "description": "CodeGraphy Unity plugin", "license": "MIT", "type": "module", @@ -52,7 +52,7 @@ }, "codegraphy": { "type": "plugin", - "apiVersion": "^2.0.0", + "apiVersion": "^3.0.0", "disclosures": [] } } diff --git a/packages/plugin-vue/CHANGELOG.md b/packages/plugin-vue/CHANGELOG.md index 498a53255..daf1d74e2 100644 --- a/packages/plugin-vue/CHANGELOG.md +++ b/packages/plugin-vue/CHANGELOG.md @@ -1,5 +1,14 @@ # @codegraphy-dev/plugin-vue +## 0.3.5 + +### Patch Changes + +- Declare compatibility with the new CodeGraphy Plugin API 3 runtime contract. + +- Updated dependencies [[`b744f20`](https://github.com/joesobo/CodeGraphyV4/commit/b744f20bb1391e9a0c40d3e448a4f3f78bde4974), [`5a65047`](https://github.com/joesobo/CodeGraphyV4/commit/5a65047d1a715f005760ace0ebf0f550a16efa2e)]: + - @codegraphy-dev/plugin-api@6.0.0 + ## 0.3.4 ### Patch Changes diff --git a/packages/plugin-vue/codegraphy.json b/packages/plugin-vue/codegraphy.json index 17f5f724d..993481079 100644 --- a/packages/plugin-vue/codegraphy.json +++ b/packages/plugin-vue/codegraphy.json @@ -3,7 +3,7 @@ "id": "codegraphy.vue", "name": "Vue", "version": "0.1.0", - "apiVersion": "^2.0.0", + "apiVersion": "^3.0.0", "supportedExtensions": [ ".vue" ], diff --git a/packages/plugin-vue/package.json b/packages/plugin-vue/package.json index a0a5af621..a915273e4 100644 --- a/packages/plugin-vue/package.json +++ b/packages/plugin-vue/package.json @@ -1,6 +1,6 @@ { "name": "@codegraphy-dev/plugin-vue", - "version": "0.3.4", + "version": "0.3.5", "description": "CodeGraphy Vue plugin", "license": "MIT", "type": "module", @@ -53,7 +53,7 @@ }, "codegraphy": { "type": "plugin", - "apiVersion": "^2.0.0", + "apiVersion": "^3.0.0", "disclosures": [] } } diff --git a/scripts/release.mjs b/scripts/release.mjs index db91f7d6e..a36b73902 100644 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -1,4 +1,4 @@ -import { mkdirSync, readdirSync, readFileSync } from 'node:fs'; +import { existsSync, mkdirSync, readdirSync, readFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { spawnSync } from 'node:child_process'; @@ -155,6 +155,20 @@ function toWorkspaceReleaseTarget(workspacePackage) { }; } +export function assertReleaseVersionsPrepared(baseDir = repoRoot) { + const changesetDirectory = path.join(baseDir, '.changeset'); + if (!existsSync(changesetDirectory)) return; + + const pendingChangesets = readdirSync(changesetDirectory) + .filter(name => name.endsWith('.md') && name !== 'README.md') + .sort(); + if (pendingChangesets.length === 0) return; + + throw new Error( + `Release versions are not prepared; run 'pnpm exec changeset version' and commit the result before publishing. Pending changesets: ${pendingChangesets.join(', ')}`, + ); +} + export function collectReleaseTargets(baseDir = repoRoot) { const workspaceTargets = sortByWorkspaceDependencies( collectWorkspacePackages(baseDir).filter(({ manifest }) => manifest.private !== true), @@ -282,6 +296,7 @@ export function runRelease(mode, requestedTarget, baseDir = repoRoot, runCommand process.exit(1); } + assertReleaseVersionsPrepared(baseDir); const targets = resolveReleaseTargets(requestedTarget, baseDir); if (targets.length === 0) { diff --git a/tests/release/pluginApiCompatibility.test.mjs b/tests/release/pluginApiCompatibility.test.mjs new file mode 100644 index 000000000..929d0445b --- /dev/null +++ b/tests/release/pluginApiCompatibility.test.mjs @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; + +const PLUGIN_API_RANGE = '^3.0.0'; +const BUNDLED_PLUGINS = [ + 'godot', + 'markdown', + 'particles', + 'svelte', + 'typescript', + 'unity', + 'vue', +]; + +function readJson(relativePath) { + return JSON.parse(fs.readFileSync(path.join(process.cwd(), relativePath), 'utf8')); +} + +test('bundled plugin manifests declare the current runtime API consistently', () => { + for (const plugin of BUNDLED_PLUGINS) { + const packageRoot = `packages/plugin-${plugin}`; + const manifest = readJson(`${packageRoot}/codegraphy.json`); + const packageManifest = readJson(`${packageRoot}/package.json`); + + assert.equal(manifest.apiVersion, PLUGIN_API_RANGE, `${plugin} runtime manifest`); + assert.equal(packageManifest.codegraphy?.apiVersion, PLUGIN_API_RANGE, `${plugin} package metadata`); + assert.equal(packageManifest.codegraphy.apiVersion, manifest.apiVersion, `${plugin} metadata agreement`); + } +}); diff --git a/tests/scripts/release.test.mjs b/tests/scripts/release.test.mjs index 115ed252f..6ef529aa1 100644 --- a/tests/scripts/release.test.mjs +++ b/tests/scripts/release.test.mjs @@ -3,7 +3,12 @@ import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { test } from 'node:test'; -import { collectReleaseTargets, resolveReleaseTargets, runRelease } from '../../scripts/release.mjs'; +import { + assertReleaseVersionsPrepared, + collectReleaseTargets, + resolveReleaseTargets, + runRelease, +} from '../../scripts/release.mjs'; test('language plugin release targets resolve by short language name', () => { const repoRoot = createReleaseFixture({ @@ -71,6 +76,21 @@ test('release target list exposes plugin and short aliases', () => { }); }); +test('publishing rejects pending unversioned changesets', () => { + const repoRoot = createReleaseFixture({}); + const changesetDirectory = path.join(repoRoot, '.changeset'); + mkdirSync(changesetDirectory, { recursive: true }); + writeFileSync(path.join(changesetDirectory, 'README.md'), '# Changesets\n'); + + assert.doesNotThrow(() => assertReleaseVersionsPrepared(repoRoot)); + + writeFileSync(path.join(changesetDirectory, 'pending-release.md'), '---\n---\n'); + assert.throws( + () => assertReleaseVersionsPrepared(repoRoot), + /run 'pnpm exec changeset version'.*pending-release\.md/, + ); +}); + test('already-published workspace dependencies still build before dependent npm targets publish', () => { const repoRoot = createReleaseFixture({ 'packages/plugin-api/package.json': packageManifest('@codegraphy-dev/plugin-api', {