diff --git a/.github/workflows/build-vscode-extension.yml b/.github/workflows/build-vscode-extension.yml index 2af28cff0..24855bdda 100644 --- a/.github/workflows/build-vscode-extension.yml +++ b/.github/workflows/build-vscode-extension.yml @@ -43,49 +43,10 @@ jobs: - name: Build and Test VS Code Extension run: npm run test:vscode - integration-test: - name: Integration Test (VSCode ${{ matrix.vscode-version }}) - runs-on: ubuntu-latest - needs: build-and-test - # Stable is an informational matrix entry (CDN-resolved, can flake on - # transient network issues); pinned versions are the real gate. - continue-on-error: ${{ matrix.vscode-version == 'stable' }} - strategy: - fail-fast: false - matrix: - # 1.115.0 is the last known-good version for issue #2361. - # 1.116.0 is the first version exhibiting the blank-paint regression. - # 'stable' tracks whatever is current — early warning for new regressions. - vscode-version: ['1.115.0', '1.116.0', 'stable'] - - steps: - - name: Checkout code - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - - - name: Setup Node.js - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 - with: - node-version-file: '.nvmrc' - - - name: Install workspace - run: npm ci - - - name: Build workspace dependencies - # tsup in the vscode extension needs @finos/calm-shared and - # @finos/calm-models pre-built (they're file:../../ workspace deps). - run: npm run build:shared - - - name: Integration Test VS Code Extension (Xvfb) - # Xvfb provides a virtual display so @vscode/test-electron can launch - # a real VSCode instance headlessly. xvfb-run wraps the command. - env: - VSCODE_VERSION: ${{ matrix.vscode-version }} - run: xvfb-run -a npm run test:integration --workspace=calm-plugins/vscode - package-and-publish: name: Package and Publish VS Code Extension runs-on: ubuntu-latest - needs: [build-and-test, integration-test] + needs: [build-and-test] steps: - name: Checkout code diff --git a/.github/workflows/build-vscode-screenshots.yml b/.github/workflows/build-vscode-screenshots.yml deleted file mode 100644 index 65e6563c2..000000000 --- a/.github/workflows/build-vscode-screenshots.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: Build VS Code Screenshots Tool - -permissions: - contents: read - -# Typecheck and lint the documentation-screenshot generator. The tool itself -# is not run in CI — see issue #2529 "Testing & Continuous Integration" for -# the rationale (renderer non-determinism across OSes; PNGs are committed -# artefacts gated by human PR review). -on: - pull_request: - branches: - - 'main' - - 'release*' - paths: - - 'calm-plugins/vscode/screenshots/**' - - 'docs/static/img/vscode/**' - - '.github/workflows/build-vscode-screenshots.yml' - push: - branches: - - 'main' - - 'release*' - paths: - - 'calm-plugins/vscode/screenshots/**' - - 'docs/static/img/vscode/**' - - '.github/workflows/build-vscode-screenshots.yml' - workflow_dispatch: - -jobs: - typecheck-and-lint: - name: Typecheck and Lint - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - - - name: Setup Node.js - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 - with: - node-version-file: '.nvmrc' - - - name: Install dependencies - run: npm ci - working-directory: calm-plugins/vscode/screenshots - - - name: Typecheck - run: npm run typecheck - working-directory: calm-plugins/vscode/screenshots - - - name: Lint - run: npm run lint - working-directory: calm-plugins/vscode/screenshots - - - name: Smoke test (verifies committed PNGs match manifest) - run: npm run test - working-directory: calm-plugins/vscode/screenshots diff --git a/calm-plugins/vscode/.vscodeignore b/calm-plugins/vscode/.vscodeignore index 22e2286c9..9fba1c954 100644 --- a/calm-plugins/vscode/.vscodeignore +++ b/calm-plugins/vscode/.vscodeignore @@ -1,29 +1,6 @@ -# Strict bundle-only .vscodeignore -# Ignore everything by default, then allow only essential files for the VSIX -** - -# Keep package metadata -!package.json -!README.md -!CHANGELOG.md -!LICENSE -!docs/** - -# Explicitly exclude developer documentation -DEVELOPER.md - -# Keep built extension bundles +**/* !dist/** - -# Keep media/icons used by the extension (if present) !media/** -!icons/** - -# Keep this ignore file itself -!.vscodeignore - -# Include the templates directory -!templates/** - -# Exclude node_modules entirely to avoid pulling workspace symlinks or repo-top files -# (If you need to vendor runtime packages, copy just their runtime output into `dist/` before packaging.) +!package.json +!LICENSE +!README.md diff --git a/calm-plugins/vscode/Makefile b/calm-plugins/vscode/Makefile new file mode 100644 index 000000000..2ee8de1bc --- /dev/null +++ b/calm-plugins/vscode/Makefile @@ -0,0 +1,46 @@ +PKG_JSON := package.json +CURRENT_VERSION := $(shell node -p "require('./$(PKG_JSON)').version") +VSIX_NAME := calm-vscode-plugin-$(CURRENT_VERSION).vsix + +MAJOR := $(word 1,$(subst ., ,$(CURRENT_VERSION))) +MINOR := $(word 2,$(subst ., ,$(CURRENT_VERSION))) +PATCH := $(word 3,$(subst ., ,$(CURRENT_VERSION))) + +.PHONY: build package install install-nobump bump-patch bump-minor bump-major clean version + +version: + @echo "Current version: $(CURRENT_VERSION)" + +build: + node esbuild.mjs + npx vite build --config vite.webview.config.ts + +package: bump-patch build + npx @vscode/vsce package --no-dependencies + @echo "Built: calm-vscode-plugin-$$(node -p "require('./$(PKG_JSON)').version").vsix" + +install: package + code --install-extension calm-vscode-plugin-$$(node -p "require('./$(PKG_JSON)').version").vsix --force + @echo "Installed extension v$$(node -p "require('./$(PKG_JSON)').version")" + +install-nobump: build + npx @vscode/vsce package --no-dependencies + code --install-extension calm-vscode-plugin-$(CURRENT_VERSION).vsix --force + @echo "Installed extension v$(CURRENT_VERSION) (no version bump)" + +bump-patch: + @node -e "const p=require('./$(PKG_JSON)');const v=p.version.split('.');v[2]=+v[2]+1;p.version=v.join('.');require('fs').writeFileSync('./$(PKG_JSON)',JSON.stringify(p,null,2)+'\n')" + @echo "Bumped to $$(node -p "require('./$(PKG_JSON)').version")" + +bump-minor: + @node -e "const p=require('./$(PKG_JSON)');const v=p.version.split('.');v[1]=+v[1]+1;v[2]=0;p.version=v.join('.');require('fs').writeFileSync('./$(PKG_JSON)',JSON.stringify(p,null,2)+'\n')" + @echo "Bumped to $$(node -p "require('./$(PKG_JSON)').version")" + +bump-major: + @node -e "const p=require('./$(PKG_JSON)');const v=p.version.split('.');v[0]=+v[0]+1;v[1]=0;v[2]=0;p.version=v.join('.');require('fs').writeFileSync('./$(PKG_JSON)',JSON.stringify(p,null,2)+'\n')" + @echo "Bumped to $$(node -p "require('./$(PKG_JSON)').version")" + +clean: + rm -f *.vsix + rm -rf dist/ + @echo "Cleaned" diff --git a/calm-plugins/vscode/esbuild.mjs b/calm-plugins/vscode/esbuild.mjs new file mode 100644 index 000000000..a74b89d9e --- /dev/null +++ b/calm-plugins/vscode/esbuild.mjs @@ -0,0 +1,25 @@ +import * as esbuild from 'esbuild'; + +const production = process.argv.includes('--production'); +const watch = process.argv.includes('--watch'); + +const ctx = await esbuild.context({ + entryPoints: ['src/extension/extension.ts'], + bundle: true, + format: 'cjs', + platform: 'node', + target: 'node18', + outfile: 'dist/extension.js', + external: ['vscode'], + sourcemap: !production, + minify: production, + logLevel: 'info', +}); + +if (watch) { + await ctx.watch(); + console.log('[esbuild] watching extension...'); +} else { + await ctx.rebuild(); + await ctx.dispose(); +} diff --git a/calm-plugins/vscode/eslint.config.mjs b/calm-plugins/vscode/eslint.config.mjs index 65250b6be..be8f111f9 100644 --- a/calm-plugins/vscode/eslint.config.mjs +++ b/calm-plugins/vscode/eslint.config.mjs @@ -1,25 +1,60 @@ -import globals from 'globals' -import tseslint from '@typescript-eslint/eslint-plugin' -import tsparser from '@typescript-eslint/parser' -import importPlugin from 'eslint-plugin-import' +import js from '@eslint/js'; +import globals from 'globals'; +import reactHooks from 'eslint-plugin-react-hooks'; +import tseslint from 'typescript-eslint'; -export default [ - { - files: ['src/**/*.ts'], - languageOptions: { - parser: tsparser, - parserOptions: { project: false, ecmaVersion: 'latest', sourceType: 'module' }, - globals: globals.node +export default tseslint.config( + { ignores: ['dist', 'node_modules', '**/*.vsix'] }, + { + // Webview (React) sources — browser environment. + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ['src/webview/**/*.{ts,tsx}'], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + plugins: { + 'react-hooks': reactHooks, + }, + rules: { + ...reactHooks.configs.recommended.rules, + // The canvas transforms lean on `any` for the loosely-typed CALM JSON. + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unused-vars': [ + 'warn', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, + ], + }, }, - plugins: { - '@typescript-eslint': tseslint, - 'import': importPlugin + { + // Extension host sources — Node environment. + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ['src/extension/**/*.ts'], + languageOptions: { + ecmaVersion: 2020, + globals: globals.node, + }, + rules: { + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unused-vars': [ + 'warn', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, + ], + }, }, - rules: { - 'no-unused-vars': 'off', - '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }], - 'no-console': 'off', - 'import/no-useless-path-segments': 'error' + { + // Test files + the vscode mock use both environments and TS syntax. + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ['src/**/*.test.{ts,tsx}', 'src/test/**/*.ts'], + languageOptions: { + globals: { ...globals.node, ...globals.browser }, + }, + rules: { + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unused-vars': [ + 'warn', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, + ], + }, } - } -] +); diff --git a/calm-plugins/vscode/media/calm-canvas.svg b/calm-plugins/vscode/media/calm-canvas.svg new file mode 100644 index 000000000..cb8a619db --- /dev/null +++ b/calm-plugins/vscode/media/calm-canvas.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/calm-plugins/vscode/media/preview.css b/calm-plugins/vscode/media/preview.css deleted file mode 100644 index 7cbe70617..000000000 --- a/calm-plugins/vscode/media/preview.css +++ /dev/null @@ -1,424 +0,0 @@ -* { - box-sizing: border-box; -} -html, -body { - height: 100%; - padding: 0; - margin: 0; -} -/* Make toolbar buttons match VS Code themed button styles */ -#toolbar button { - appearance: none; - background-color: var(--vscode-button-background); - color: var(--vscode-button-foreground); - border: 1px solid var(--vscode-button-border, transparent); - border-radius: 4px; - padding: 4px 10px; - font-size: 12px; - line-height: 1.4; - cursor: pointer; -} -#toolbar button:hover { - background-color: var(--vscode-button-hoverBackground); -} -#toolbar button:focus-visible { - outline: 1px solid var(--vscode-focusBorder); - outline-offset: 2px; -} -#toolbar button:disabled { - opacity: 0.6; - cursor: default; -} -#container { - display: grid; - grid-template-columns: 1fr 4px 280px; - height: calc(100% - 36px); -} -#cy { - width: 100%; - height: 100%; -} -#divider { - cursor: col-resize; - background: #eee; - border-left: 1px solid #ddd; - border-right: 1px solid #ddd; -} -#details { - border-left: 1px solid #ddd; - padding: 8px; - overflow: auto; -} -#details pre { - white-space: pre-wrap; - word-break: break-word; - font-size: 11px; -} - -/* Single content area for tabs: only one tab-content is visible at a time */ -#container { - display: block; - height: calc(100% - 64px); - padding: 8px; -} - -#docify-panel, #validation-panel { - background: var(--vscode-editor-background, #ffffff); - border: 1px solid var(--vscode-editorWidget-border, #ddd); - border-radius: 6px; - padding: 8px; - box-shadow: 0 1px 0 rgba(0,0,0,0.03) inset; - overflow: hidden; - display: flex; - flex-direction: column; - height: 100%; -} - -#docify-panel h2, #validation-panel h2 { - margin: 0 0 6px 0; - font-size: 12px; - color: var(--vscode-editor-foreground); -} - -/* toolbar under the header */ -#docify-content, #validation-content { - overflow: auto; - flex: 1 1 auto; - padding: 6px; /* Remove padding to allow diagram containers full width */ - background: transparent; -} - -@media (max-width: 900px) { - #container { - grid-template-columns: 1fr; - grid-template-rows: auto auto auto auto; - } - #validation-panel { grid-row: 3 / 4; } - #docify-panel { grid-row: 4 / 5; } -} - -/* Tabs */ -.tabs { - display: flex; - gap: 6px; - padding: 8px; - border-bottom: 1px solid var(--vscode-editorWidget-border, #ddd); -} -.tab-button { - background: transparent; - border: 1px solid transparent; - padding: 6px 10px; - border-radius: 4px 4px 0 0; - cursor: pointer; - color: var(--vscode-editor-foreground); -} -.tab-button.active { - background: var(--vscode-editorWidget-background, rgba(255,255,255,0.03)); - border-color: var(--vscode-editorWidget-border, #ddd); - border-bottom-color: transparent; -} - -/* Only the active tab-content is visible */ -.tab-content { display: none; } -.tab-content.active { display: flex; height: calc(100vh - 140px); } - -/* Honor the hidden attribute strictly to avoid leaking visuals */ -[hidden], .tab-content[hidden] { display: none !important; visibility: hidden !important; } - -/* GitHub-style Markdown CSS for Docify content */ -#docify-content h1, #docify-content h2, #docify-content h3, #docify-content h4, #docify-content h5, #docify-content h6 { - margin-top: 24px; - margin-bottom: 16px; - font-weight: 600; - line-height: 1.25; - color: var(--vscode-editor-foreground); -} - -#docify-content h1 { - font-size: 2em; - border-bottom: 1px solid var(--vscode-editorWidget-border, #d1d9e0); - padding-bottom: 0.3em; -} - -#docify-content h2 { - font-size: 1.5em; - border-bottom: 1px solid var(--vscode-editorWidget-border, #d1d9e0); - padding-bottom: 0.3em; -} - -#docify-content h3 { - font-size: 1.25em; -} - -#docify-content h4 { - font-size: 1em; -} - -#docify-content h5 { - font-size: 0.875em; -} - -#docify-content h6 { - font-size: 0.85em; - color: var(--vscode-descriptionForeground, #6a737d); -} - -#docify-content p { - margin-top: 0; - margin-bottom: 16px; - line-height: 1.5; -} - -#docify-content blockquote { - padding: 0 1em; - color: var(--vscode-descriptionForeground, #6a737d); - border-left: 0.25em solid var(--vscode-editorWidget-border, #dfe2e5); - margin: 0 0 16px; -} - -#docify-content ul, #docify-content ol { - padding-left: 2em; - margin-top: 0; - margin-bottom: 16px; -} - -#docify-content li { - margin: 0.25em 0; -} - -#docify-content code { - padding: 0.2em 0.4em; - font-size: 85%; - background-color: var(--vscode-textCodeBlock-background, rgba(175,184,193,0.2)); - border-radius: 6px; - font-family: var(--vscode-editor-font-family, 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace); -} - -#docify-content pre { - padding: 16px; - font-size: 85%; - line-height: 1.45; - background-color: var(--vscode-textCodeBlock-background, #f6f8fa); - border-radius: 6px; - overflow: auto; - margin: 0 0 16px; - font-family: var(--vscode-editor-font-family, 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace); -} - -#docify-content pre code { - background: transparent; - padding: 0; - border-radius: 0; -} - -#docify-content table { - border-spacing: 0; - border-collapse: collapse; - display: block; - width: max-content; - max-width: 100%; - overflow: auto; - margin-bottom: 16px; - font-variant: tabular-nums; -} - -#docify-content table th, -#docify-content table td { - padding: 6px 13px; - border: 1px solid var(--vscode-editorWidget-border, #d1d9e0); -} - -#docify-content table th { - font-weight: 600; - background-color: var(--vscode-editorWidget-background, #f6f8fa); -} - -#docify-content table tr { - background-color: var(--vscode-editor-background, #ffffff); - border-top: 1px solid var(--vscode-editorWidget-border, #d1d9e0); -} - -#docify-content table tr:nth-child(2n) { - background-color: var(--vscode-editorWidget-background, #f6f8fa); -} - -#docify-content hr { - height: 0.25em; - padding: 0; - margin: 24px 0; - background-color: var(--vscode-editorWidget-border, #e1e4e8); - border: 0; - border-radius: 2px; -} - -#docify-content a { - color: var(--vscode-textLink-foreground, #0366d6); - text-decoration: none; -} - -#docify-content a:hover { - text-decoration: underline; -} - -#docify-content strong { - font-weight: 600; -} - -#docify-content em { - font-style: italic; -} - -/* Style for the block-architecture widget output */ -#docify-content .block-architecture { - margin: 16px 0; - border: 1px solid var(--vscode-editorWidget-border, #d1d9e0); - border-radius: 6px; - overflow: hidden; -} - -/* Diagram zoom and pan controls */ -.diagram-controls { - position: absolute; - top: 16px; - right: 16px; - z-index: 1000; - display: flex; - gap: 4px; - background: var(--vscode-editor-background, #fff); - border: 1px solid var(--vscode-editorWidget-border, #ddd); - border-radius: 6px; - padding: 4px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); -} - -.diagram-control-btn { - appearance: none; - background-color: var(--vscode-button-background); - color: var(--vscode-button-foreground); - border: 1px solid var(--vscode-button-border, transparent); - border-radius: 4px; - padding: 6px 10px; - font-size: 14px; - line-height: 1; - cursor: pointer; - min-width: 32px; - display: flex; - align-items: center; - justify-content: center; - transition: background-color 0.2s; -} - -.diagram-control-btn:hover { - background-color: var(--vscode-button-hoverBackground); -} - -.diagram-control-btn:focus-visible { - outline: 1px solid var(--vscode-focusBorder); - outline-offset: 2px; -} - -.diagram-control-btn:active { - transform: translateY(1px); -} - -.diagram-control-btn:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -/* Visually separates the export control from the zoom/pan controls */ -.diagram-control-group-start { - margin-left: 4px; - padding-left: 10px; - border-left: 1px solid var(--vscode-editorWidget-border, #ddd); -} - -/* Wraps the "Export" button and its SVG/PNG dropdown menu */ -.diagram-export-control { - position: relative; - display: flex; - align-items: center; -} - -.diagram-export-menu { - position: absolute; - top: 100%; - right: 0; - margin-top: 4px; - z-index: 1; - min-width: 160px; - display: flex; - flex-direction: column; - background: var(--vscode-menu-background, var(--vscode-editor-background, #fff)); - border: 1px solid var(--vscode-menu-border, var(--vscode-editorWidget-border, #ddd)); - border-radius: 4px; - padding: 4px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); -} - -.diagram-export-menu-item { - appearance: none; - background: none; - border: none; - color: var(--vscode-menu-foreground, var(--vscode-foreground)); - text-align: left; - padding: 6px 10px; - font-size: 13px; - border-radius: 3px; - cursor: pointer; -} - -.diagram-export-menu-item:hover, -.diagram-export-menu-item:focus-visible { - background-color: var(--vscode-menu-selectionBackground, var(--vscode-list-hoverBackground)); - color: var(--vscode-menu-selectionForeground, var(--vscode-foreground)); -} - -.diagram-export-menu-item:focus-visible { - outline: 1px solid var(--vscode-focusBorder); - outline-offset: -1px; -} - -/* Mermaid diagram container styling */ -.mermaid-diagram-container { - position: relative; - width: 100%; - height: 600px; - margin: 16px 0; - border: 1px solid var(--vscode-editorWidget-border, #ddd); - border-radius: 6px; - background: var(--vscode-editor-background, #fff); - overflow: hidden; -} - -.mermaid-diagram-container svg { - display: block; -} - -/* Clickable nodes and edges in Mermaid diagrams */ -.clickable-node, -.clickable-edge { - cursor: pointer !important; - transition: opacity 0.2s ease; -} - -.clickable-node:hover { - opacity: 0.7; -} - -.clickable-edge:hover { - opacity: 0.8; -} - -/* Back button styles */ -#back-button:hover { - background: var(--vscode-button-secondaryHoverBackground); - opacity: 0.9; -} - -#back-button:active { - transform: translateY(1px); -} - -/* End of styles */ diff --git a/calm-plugins/vscode/media/preview.html b/calm-plugins/vscode/media/preview.html deleted file mode 100644 index ad4edf109..000000000 --- a/calm-plugins/vscode/media/preview.html +++ /dev/null @@ -1,71 +0,0 @@ - - - - - - - - - CALM Preview - - -
-
CALM Preview
-
Version: {{version}}
-
- -
-
- - - -
-
- - - -
-
- -
- -
-
-
- -
-
-
- -
-
-
-
- - - - - diff --git a/calm-plugins/vscode/package.json b/calm-plugins/vscode/package.json index 0bb980821..715e44c26 100644 --- a/calm-plugins/vscode/package.json +++ b/calm-plugins/vscode/package.json @@ -1,214 +1,137 @@ { - "name": "calm-vscode-plugin", - "displayName": "CALM Tools", - "description": "Live-visualize CALM architecture models, validate, and generate docs.", - "version": "0.7.0", - "publisher": "FINOS", - "homepage": "https://calm.finos.org", - "repository": { - "type": "git", - "url": "https://github.com/finos/architecture-as-code.git" - }, - "engines": { - "vscode": "^1.88.0" - }, - "categories": [ - "Programming Languages", - "Visualization" - ], - "keywords": [ - "calm", - "architecture-as-code", - "architecture", - "modeling", - "documentation", - "finos" + "name": "calm-vscode-plugin", + "displayName": "CALM Canvas", + "description": "Visual architecture editor for CALM models using ReactFlow", + "version": "1.0.0-beta", + "publisher": "FINOS", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/finos/architecture-as-code.git", + "directory": "calm-plugins/vscode" + }, + "engines": { + "vscode": "^1.88.0" + }, + "icon": "media/icon.png", + "categories": [ + "Programming Languages", + "Visualization" + ], + "activationEvents": [ + "workspaceContains:**/*.calm.json", + "workspaceContains:**/*.architecture.json", + "workspaceContains:**/*.template.json", + "workspaceContains:**/*.solution.json", + "workspaceContains:**/*.standard.json", + "workspaceContains:**/*.guideline.json" + ], + "main": "./dist/extension.js", + "contributes": { + "commands": [ + { + "command": "calm.openCanvas", + "title": "View in CALM Canvas", + "category": "CALM", + "icon": { + "light": "media/calm-canvas.svg", + "dark": "media/calm-canvas.svg" + } + } ], - "icon": "media/icon.png", - "main": "dist/extension.js", - "activationEvents": [ - "onStartupFinished" + "keybindings": [ + { + "command": "calm.openCanvas", + "key": "ctrl+shift+k", + "mac": "cmd+shift+k", + "when": "resourceFilename =~ /\\.(calm|architecture|template|solution|standard|guideline)\\.json$/" + } ], - "contributes": { - "commands": [ - { - "command": "calm.openPreview", - "title": "CALM: Open Preview" - }, - { - "command": "calm.searchTreeView", - "title": "Search Model Elements", - "icon": "$(search)" - }, - { - "command": "calm.clearTreeViewSearch", - "title": "Clear Search", - "icon": "$(clear-all)" - }, - { - "command": "calm.createWebsite", - "title": "CALM: Create Documentation Website" - } - ], - "viewsContainers": { - "activitybar": [ - { - "id": "calm", - "title": "CALM", - "icon": "media/icon.png" - } - ] - }, - "views": { - "calm": [ - { - "id": "calmSidebar", - "name": "Model Elements", - "canSelectMany": false, - "visibility": "visible" - } - ] + "menus": { + "editor/title": [ + { + "command": "calm.openCanvas", + "when": "resourceFilename =~ /\\.(calm|architecture|template|solution|standard|guideline)\\.json$/", + "group": "navigation" + } + ], + "editor/context": [ + { + "command": "calm.openCanvas", + "when": "resourceFilename =~ /\\.(calm|architecture|template|solution|standard|guideline)\\.json$/", + "group": "navigation" + } + ], + "explorer/context": [ + { + "command": "calm.openCanvas", + "when": "resourceFilename =~ /\\.(calm|architecture|template|solution|standard|guideline)\\.json$/", + "group": "navigation" + } + ] + }, + "configuration": { + "title": "CALM Canvas", + "properties": { + "calm.externalAssetsPath": { + "type": "string", + "default": "", + "markdownDescription": "Absolute path to an external folder containing shared `nodes/`, `standards/`, `patterns/`, and `templates/` folders." }, - "configuration": { - "title": "CALM", - "properties": { - "calm.cli.path": { - "type": "string", - "default": "./cli", - "markdownDescription": "Path to the CALM CLI entry. If not available, the extension will fall back to internal validation." - }, - "calm.files.globs": { - "type": "array", - "items": { - "type": "string" - }, - "default": [ - "calm/**/*.json", - "calm/**/*.y?(a)ml" - ], - "markdownDescription": "Glob patterns for CALM model files in the workspace." - }, - "calm.template.globs": { - "type": "array", - "items": { - "type": "string" - }, - "default": [ - "**/*.md", - "**/*.markdown", - "**/*.hbs", - "**/*.handlebars" - ], - "markdownDescription": "Glob patterns for template files that may reference CALM architecture files." - }, - "calm.docify.theme": { - "type": "string", - "enum": [ - "light", - "dark", - "high-contrast-light", - "high-contrast-dark", - "auto" - ], - "default": "auto", - "description": "Default theme for CALM diagrams." - }, - "calm.preview.layout": { - "type": "string", - "enum": [ - "dagre", - "elk" - ], - "default": "elk", - "markdownDescription": "Mermaid layout engine for architecture diagrams. **ELK** provides better automatic layout for complex diagrams with improved edge routing." - }, - "calm.urlMapping": { - "type": "string", - "description": "Path to a JSON file mapping URLs to local file paths for detailed-architecture navigation.", - "default": "" - }, - "calm.schemas.additionalFolders": { - "type": "array", - "items": { - "type": "string" - }, - "default": [], - "markdownDescription": "Additional folders containing CALM schemas for validation. Useful for schema developers testing local schema changes. Paths are relative to the workspace root." - } - } + "calm.packs.enabled": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "markdownDescription": "List of extension pack IDs to show in the palette (e.g. `[\"aws\", \"k8s\"]`). Leave empty to show all packs. Available packs: `core`, `fluxnova`, `ai`, `aws`, `gcp`, `azure`, `k8s`, `messaging`, `identity`, `opengris`." }, - "keybindings": [ - { - "command": "calm.openPreview", - "key": "ctrl+shift+c", - "mac": "cmd+shift+c", - "when": "editorTextFocus" - } - ], - "menus": { - "view/title": [ - { - "command": "calm.searchTreeView", - "when": "view == calmSidebar", - "group": "navigation" - }, - { - "command": "calm.clearTreeViewSearch", - "when": "view == calmSidebar", - "group": "navigation" - } - ], - "editor/context": [ - { - "command": "calm.openPreview", - "group": "navigation@9" - }, - { - "command": "calm.createWebsite", - "group": "navigation@10" - } - ] + "calm.packs.excludeNodes": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "markdownDescription": "List of node type IDs to hide from the palette (e.g. `[\"core:ldap\", \"aws:s3\"]`). Use the `packId:nodeType` format." } - }, - "scripts": { - "build": "tsup", - "postbuild": "node ./scripts/copy-calm-assets.js && copyfiles \"../../calm/release/**/meta/*\" \"../../calm/draft/**/meta/*\" dist/calm/ --up 3", - "watch": "tsup --watch", - "test": "vitest run", - "test:watch": "vitest", - "test:coverage": "vitest run --coverage", - "test:integration:compile": "tsc -p test/integration", - "test:integration": "npm run build && npm run test:integration:compile && node ./out/integration/runTest.js", - "lint": "eslint src", - "lint-fix": "eslint src --fix", - "package": "npm run build && npx @vscode/vsce package --no-dependencies", - "vscode:prepublish": "npm run build" - }, - "devDependencies": { - "@types/markdown-it": "^14.1.2", - "@types/mocha": "^10.0.6", - "@types/svg-pan-zoom": "^3.3.0", - "@types/vscode": "^1.88.0", - "@vscode/dts": "^0.4.1", - "@vscode/test-electron": "^2.3.9", - "@vscode/vsce": "^3.7.1", - "copyfiles": "^2.4.1", - "eslint-plugin-import": "^2.32.0", - "glob": "^10.3.10", - "mocha": "^10.2.0", - "tsup": "^8.4.0" - }, - "dependencies": { - "@finos/calm-models": "file:../../calm-models", - "@finos/calm-shared": "file:../../shared", - "@mermaid-js/layout-elk": "^0.2.0", - "elkjs": "^0.11.0", - "jsdom": "^26.1.0", - "lodash": "^4.18.1", - "markdown-it": "^14.1.1", - "mermaid": "^11.15.0", - "svg-pan-zoom": "^3.6.2", - "yaml": "^2.8.3", - "zustand": "^5.0.8" + } } + }, + "scripts": { + "build": "node esbuild.mjs && npx vite build --config vite.webview.config.ts", + "build:extension": "node esbuild.mjs", + "build:webview": "npx vite build --config vite.webview.config.ts", + "watch": "node esbuild.mjs --watch", + "package": "npm run build && npx @vscode/vsce package --no-dependencies", + "vscode:prepublish": "npm run build", + "test": "vitest run", + "lint": "eslint src/" + }, + "dependencies": { + "@finos/calm-models": "file:../../calm-models", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "reactflow": "^11.11.4", + "html-to-image": "1.11.13", + "yaml": "^2.7.0", + "@dagrejs/dagre": "^1.1.4", + "elkjs": "^0.9.0", + "zustand": "^5.0.0", + "lucide-react": "^0.577.0", + "ajv": "^8.17.0", + "ajv-formats": "^3.0.0" + }, + "devDependencies": { + "vite": "^6.3.0", + "@vitejs/plugin-react": "^4.0.0", + "@tailwindcss/vite": "^4.1.0", + "tailwindcss": "^4.1.0", + "esbuild": "^0.25.0", + "@types/vscode": "^1.88.0", + "@types/react": "^19.1.0", + "@types/react-dom": "^19.1.0", + "typescript": "^5.8.0", + "vitest": "^4.1.0", + "@vscode/vsce": "^3.0.0" + } } diff --git a/calm-plugins/vscode/sample.calm.json b/calm-plugins/vscode/sample.calm.json new file mode 100644 index 000000000..47b6e69d3 --- /dev/null +++ b/calm-plugins/vscode/sample.calm.json @@ -0,0 +1,261 @@ +{ + "nodes": [ + { + "unique-id": "actor-1784654671109", + "node-type": "actor", + "name": "Actor", + "description": "" + }, + { + "unique-id": "database-1784654678206", + "node-type": "database", + "name": "Database", + "description": "" + }, + { + "unique-id": "service-1784746705981", + "node-type": "service", + "name": "Fidelity Microservice", + "description": "", + "interfaces": [], + "controls": { + "api-gateway": { + "description": "All microservices must be fronted by Stratum (Fidelity Enterprise API Gateway) for edge routing, rate limiting, and OAuth2 validation", + "requirements": [ + { + "requirement-url": "standards/integration-services/api-gateway.md", + "config": { + "value": "Stratum" + } + } + ], + "metadata": { + "validation": { + "allowed-values": [ + "Stratum", + "AWS API GW", + "Azure API GW" + ] + } + } + }, + "api-security": { + "description": "Service must implement API authentication and authorization", + "requirements": [ + { + "requirement-url": "standards/cybersecurity-services/api-security.md", + "config": { + "value": "OAuth2" + } + } + ], + "metadata": { + "validation": { + "allowed-values": [ + "OAuth2", + "mTLS" + ] + } + } + }, + "health-api": { + "description": "Service must expose a health check endpoint for liveness and readiness probes", + "requirements": [ + { + "requirement-url": "standards/application-software-delivery/health-api.md", + "config": { + "value": "/health" + } + } + ], + "metadata": { + "validation": { + "pattern": "^/[a-z][a-z0-9/-]*$", + "example": "/actuator/health" + } + } + }, + "caching-strategy": { + "description": "Service must define a caching strategy for performance and resilience", + "requirements": [ + { + "requirement-url": "standards/application-software-delivery/caching-strategy.md", + "config": { + "value": "Valkey" + } + } + ], + "metadata": { + "validation": { + "allowed-values": [ + "Valkey", + "In-Memory", + "None" + ] + } + } + }, + "technology-stack": { + "description": "Service must use an approved technology runtime", + "requirements": [ + { + "requirement-url": "standards/application-software-delivery/technology-stack.md", + "config": { + "value": "Java" + } + } + ], + "metadata": { + "validation": { + "allowed-values": [ + "Java", + "Node.js", + "Golang", + "Python", + ".NET", + "Rust" + ] + } + } + }, + "observability": { + "description": "All microservices must use OpenTelemetry (OTel) for distributed tracing, structured logging, and metrics collection", + "requirements": [ + { + "requirement-url": "standards/application-software-delivery/observability.md", + "config": { + "value": "OpenTelemetry" + } + } + ], + "metadata": { + "validation": { + "allowed-values": [ + "OpenTelemetry" + ] + } + } + } + }, + "metadata": { + "source-fidelity-node": "Fidelity_Microservice" + } + }, + { + "unique-id": "standard-1784749417153", + "node-type": "standard", + "name": "STD100002 Fidelity Application Software Delivery Standard", + "description": "", + "interfaces": [], + "controls": { + "app-id": { + "description": "Application ID", + "requirements": [ + { + "requirement-url": "standards/application-software-delivery/STD100002-fidelity-application-software-delivery-standard.md", + "config": { + "value": "AP187183" + } + } + ], + "metadata": { + "validation": { + "pattern": "^AP\\d+$", + "example": "AP187183" + } + } + } + }, + "metadata": { + "source-fidelity-node": "standards:STD100002-fidelity-application-software-delivery-standard" + } + } + ], + "relationships": [ + { + "unique-id": "rel-1784746708886", + "relationship-type": { + "connects": { + "source": { + "node": "actor-1784654671109" + }, + "destination": { + "node": "service-1784746705981" + } + } + }, + "protocol": "HTTPS", + "metadata": { + "line-style": "dashed" + } + }, + { + "unique-id": "rel-1784746710612", + "relationship-type": { + "connects": { + "source": { + "node": "service-1784746705981" + }, + "destination": { + "node": "database-1784654678206" + } + } + } + }, + { + "unique-id": "rel-1785160067822", + "relationship-type": { + "connects": { + "source": { + "node": "actor-1784654671109" + }, + "destination": { + "node": "service-1784746705981" + } + } + } + }, + { + "unique-id": "rel-1785160072029", + "relationship-type": { + "connects": { + "source": { + "node": "actor-1784654671109" + }, + "destination": { + "node": "service-1784746705981" + } + } + } + } + ], + "$schema": "https://calm.finos.org/release/1.2/meta/core.json", + "metadata": { + "_layout": { + "actor-1784654671109": { + "x": 438, + "y": -89, + "w": 44, + "h": 63 + }, + "database-1784654678206": { + "x": 431, + "y": 205, + "w": 56, + "h": 63 + }, + "service-1784746705981": { + "x": 386, + "y": 67, + "w": 149, + "h": 33 + }, + "standard-1784749417153": { + "x": 500, + "y": -149, + "w": 345, + "h": 33 + } + } + } +} \ No newline at end of file diff --git a/calm-plugins/vscode/screenshots/.gitignore b/calm-plugins/vscode/screenshots/.gitignore deleted file mode 100644 index 28e03647e..000000000 --- a/calm-plugins/vscode/screenshots/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -node_modules/ -.vscode-test/ -out/ -*.log diff --git a/calm-plugins/vscode/screenshots/AGENTS.md b/calm-plugins/vscode/screenshots/AGENTS.md deleted file mode 100644 index 0a837b98f..000000000 --- a/calm-plugins/vscode/screenshots/AGENTS.md +++ /dev/null @@ -1,119 +0,0 @@ -# CALM VSCode Screenshots — AI Assistant Guide - -This folder generates documentation screenshots for the CALM VSCode extension. It is a **dev-only orchestrator** that drives a real VSCode instance via Playwright Electron and writes PNGs into `docs/static/img/vscode/`. - -This guide is for AI assistants working in this folder. Human contributors should read `README.md` first. - -## What this folder is and is not - -- **Is**: a standalone Node tool, with its own `package.json`, that orchestrates VSCode + the extension to produce documentation imagery. -- **Is not**: an npm workspace member. Adding it to root `workspaces` would pull Playwright (~150 MB) into every contributor's `npm install`. Keep it standalone. -- **Is not**: shipped in the VSIX. The extension's `package.json` does not reference this folder. - -## Key commands - -Run from the repo root unless noted otherwise. - -```bash -# First-time install (only when this folder is touched) -npm --prefix calm-plugins/vscode/screenshots install - -# Generate all shots into docs/static/img/vscode/ -npm --prefix calm-plugins/vscode/screenshots run shoot - -# Smoke test (runs orchestrator, asserts outputs) -npm --prefix calm-plugins/vscode/screenshots run test - -# Static checks (run in CI) -npm --prefix calm-plugins/vscode/screenshots run typecheck -npm --prefix calm-plugins/vscode/screenshots run lint -npm --prefix calm-plugins/vscode/screenshots run lint-fix # Auto-fix lint issues -``` - -The extension must be built first. The orchestrator will fail with a helpful message if `calm-plugins/vscode/dist/extension.js` is missing. - -## Layout - -``` -src/ -├── launch.ts Download VSCode binary, launch via Playwright Electron, return { app, window }. -├── normalise.ts Fixed viewport, forced theme, close aux side bar, dismiss notifications. -├── frames.ts Find the inner webview iframe given the outer wrapper. -├── shoot.ts Capture (full window or selector-bound crop), write PNG, append manifest entry. -├── shots.ts Declarative list: each shot has { name, fixture, setup(window), capture(window) }. -└── index.ts Orchestrator: launch → for-each-shot → close → write manifest. - -test/ -└── smoke.test.ts Runs orchestrator end-to-end; asserts every shot produced a non-empty PNG matching the manifest. - -fixtures/ -└── three-tier/ Sample CALM architecture used by most shots. -``` - -## How a shot works - -Each shot in `src/shots.ts` is a declarative object: - -```ts -{ - name: '04-preview-hero', // becomes 04-preview-hero.png - fixture: 'three-tier', // fixtures/three-tier/ opens as the workspace - description: 'Live preview …', // caption hint surfaced in the docs page - implemented: true, // false to scaffold a TODO entry - async setup(window) { - // Drive the UI into the state we want to capture. - // Examples: Cmd+Shift+P to open Command Palette, type a command, press Enter. - // Or: click the activity bar, expand tree nodes, hover an element. - }, - async capture(window) { - // Return Buffer. Usually `window.screenshot()` of the whole viewport. - // For cropped shots, get bounding box of a selector first. - return await window.screenshot() - }, -} -``` - -The orchestrator handles: opening the fixture, calling `setup`, waiting for renders to settle, calling `capture`, writing the PNG, updating the manifest, and resetting state between shots. - -## Common workflows - -### Add a shot -1. If no existing fixture fits, add one under `fixtures//architecture.json` (and any other files VSCode needs to open it). -2. Append an entry to `src/shots.ts` with all six required `Shot` fields (`name`, `fixture`, `description`, `implemented`, `setup`, `capture`). -3. Run `npm run shoot` and inspect the resulting PNG in `docs/static/img/vscode/`. -4. Commit the PNG, the updated `_manifest.json`, and (if added) the fixture. - -### Update a shot after a UI change -1. Run `npm run shoot`. -2. Inspect the resulting PNG visually. -3. Commit both PNG and the manifest update. - -### Pin a different VSCode version -Bump the version string in `src/launch.ts`. Then run all shots and re-commit every PNG and the manifest in the same PR — the rendered output across versions will differ, and reviewers should see the change as one atomic update. - -## Pitfalls and gotchas - -**VSCode selector drift.** Internal class names (`.monaco-workbench`, `.activitybar`, `.tab .tab-label`, etc.) are not a stable API. They change between VSCode releases. When a shot starts failing after a `@vscode/test-electron` version bump, the most likely cause is a selector that no longer matches — not a Playwright issue. Inspect the launched window with `await window.pause()` (when running headed) to find the new selector. - -**Two webview iframes per panel.** VSCode wraps the extension's webview in an outer iframe (`index.html`) for sandboxing, with the actual content in an inner iframe (`fake.html`). To click or hover *inside* the preview (e.g. for the hover-info shot), use `frames.ts` to traverse to the inner frame, not the outer wrapper. - -**`--disable-extensions` shows a banner.** Using it loads only our extension but displays "All installed extensions disabled" in the status area. We instead use `--extensions-dir=` so the workbench loads no other extensions without showing the banner. Do not switch to `--disable-extensions` without a way to suppress that banner. - -**Renderer non-determinism across OSes.** macOS and Linux produce subtly different PNGs (font subpixel rendering, GPU compositor, scaling). Shots committed from one OS may report drift if regenerated on another. Initial generation should happen on macOS (the maintainer's OS); if a contributor on Linux regenerates, expect every PNG to change slightly. - -**Activate event is `onStartupFinished`.** The extension may still be activating when the first window appears. Always wait for the `.monaco-workbench` selector and a short stability delay before issuing commands. - -**Notifications can cover the screenshot.** Workspace-trust prompts, update prompts, and welcome tabs all draw over the workbench. `normalise.ts` suppresses them via launch flags; if a new one shows up, add the relevant `--skip-*` or close it explicitly. - -## Trust / security model - -- The extension's `package.json` and VSIX are not modified by anything in this folder. -- Playwright and `@vscode/test-electron` are deps of this folder alone, not of the extension. -- Marketplace users see no change in extension behaviour, capability, or VSIX size. -- The shipped artefacts are the resulting PNGs only, committed under `docs/static/img/vscode/`. - -## Related - -- Issue: [finos/architecture-as-code#2529](https://github.com/finos/architecture-as-code/issues/2529) — design and CI rationale. -- Structural model: [PR #2523](https://github.com/finos/architecture-as-code/pull/2523) — CALM Hub docs page with annotated screenshots. -- Extension folder: `calm-plugins/vscode/` (with its own `AGENTS.md`). diff --git a/calm-plugins/vscode/screenshots/CLAUDE.md b/calm-plugins/vscode/screenshots/CLAUDE.md deleted file mode 100644 index 43c994c2d..000000000 --- a/calm-plugins/vscode/screenshots/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -@AGENTS.md diff --git a/calm-plugins/vscode/screenshots/README.md b/calm-plugins/vscode/screenshots/README.md deleted file mode 100644 index a80eabcef..000000000 --- a/calm-plugins/vscode/screenshots/README.md +++ /dev/null @@ -1,104 +0,0 @@ -# CALM VSCode Extension — Documentation Screenshot Generator - -Dev-only tool that launches a pinned VSCode build with the CALM extension loaded, drives it through a declarative shot list, and writes PNGs into `docs/static/img/vscode/` for use by the Docusaurus site. - -**This tool is not published and is not bundled in the VSIX.** It only runs when a contributor with a clone of the monorepo explicitly invokes it. - -## When to run it - -- You changed the extension UI (new view, new command, renamed label) and the docs need refreshing. -- You added a new documentation page or section and need new shots. -- A reviewer asks you to regenerate the screenshot manifest. - -You **do not** need to run it for every PR — only when extension UI or documentation imagery is in scope. - -## Prerequisites - -- Node 26 (see root `AGENTS.md` for the Node-version policy). -- The extension must be built. From the repo root: - ```bash - npm run build --workspace calm-plugins/vscode - ``` - -## Running - -From the repo root: - -```bash -npm --prefix calm-plugins/vscode/screenshots install # first time only -npm --prefix calm-plugins/vscode/screenshots run shoot -``` - -On first run this downloads a pinned VSCode build (~210 MB) into `.vscode-test/`. Subsequent runs reuse the cache. - -The output lands directly in `docs/static/img/vscode/.png`, alongside `docs/static/img/vscode/_manifest.json` recording each shot's dimensions and content hash for drift detection. - -## How it works - -1. Resolves the pinned VSCode binary via `@vscode/test-electron`. -2. Launches it via Playwright's Electron support (`_electron.launch()`) with the built extension loaded via `--extensionDevelopmentPath`. The launch uses a clean temporary `--user-data-dir` so previous local state never leaks in. -3. Normalises the workbench: fixed viewport, forced theme, closes the auxiliary side bar (Copilot panel), dismisses notifications. -4. Iterates the shot list in `src/shots.ts`. Each shot declares the fixture file to open, a `setup(window)` function that prepares the UI (open preview, expand tree, focus Problems panel, etc.), and a `capture` step that either screenshots the whole window or crops to a selector's bounding box. -5. Writes the manifest and shuts down cleanly. - -## Adding a new shot - -1. Add a fixture under `fixtures/` if an existing one doesn't fit your case. -2. Append an entry to `src/shots.ts` — the `Shot` interface requires `name`, `fixture`, `description`, `implemented`, `setup`, and `capture`: - ```ts - { - name: '11-my-new-shot', - fixture: 'three-tier', - description: 'Short caption hint shown in the docs page.', - implemented: true, - async setup(window) { - // Whatever the shot needs: open command, expand tree, hover, etc. - }, - async capture(window) { - return await window.screenshot({ /* options */ }) - }, - } - ``` -3. Run `npm run shoot` and inspect the output PNG. -4. Commit both the new shot's PNG and the updated `_manifest.json`. - -## Layout - -``` -screenshots/ -├── package.json (private; not an npm workspace member) -├── tsconfig.json -├── eslint.config.mjs -├── README.md (this file) -├── AGENTS.md (AI-assistant guide) -├── CLAUDE.md (one-liner that imports AGENTS.md) -├── src/ -│ ├── launch.ts (download VSCode + Playwright launch) -│ ├── normalise.ts (viewport, theme, close aux panels, dismiss notifications) -│ ├── frames.ts (find the webview iframe content frame) -│ ├── shoot.ts (capture + crop + manifest entry helpers) -│ ├── shots.ts (declarative shot list) -│ └── index.ts (orchestrator) -├── test/ -│ └── smoke.test.ts (asserts every shot in shots.ts produced a non-empty PNG) -└── fixtures/ - └── three-tier/ (sample CALM architecture used by most shots) -``` - -## Troubleshooting - -**"Extension not built"** — the orchestrator checks for `calm-plugins/vscode/dist/extension.js`. Build the extension first. - -**"VSCode download failed"** — the CDN occasionally times out. Re-run; `@vscode/test-electron` caches the binary after the first successful download. - -**A shot is blank or partly clipped** — the renderer often needs more time than the default `waitForStable` allows for that specific shot. Increase the per-shot timeout, not the global one. - -**Selectors stop matching after a VSCode bump** — VSCode internal class names drift between releases. The pinned version in `src/launch.ts` is deliberate; only bump it intentionally and re-verify every shot. - -## What is and isn't tested in CI - -- **Typecheck and lint** run in CI on every PR that touches `calm-plugins/vscode/screenshots/**`. -- **The screenshot tool itself does not run in CI.** Renderer non-determinism across operating systems would produce constant false positives, and the PNGs are committed artefacts — human PR review is the right gate. -- **The smoke test (`test/smoke.test.ts`)** is local-only by design. It runs the full orchestrator end-to-end and asserts every shot produced a non-empty PNG of expected dimensions. - -See the [Testing & Continuous Integration section of issue #2529](https://github.com/finos/architecture-as-code/issues/2529) for the full rationale. diff --git a/calm-plugins/vscode/screenshots/eslint.config.mjs b/calm-plugins/vscode/screenshots/eslint.config.mjs deleted file mode 100644 index aa1a853d4..000000000 --- a/calm-plugins/vscode/screenshots/eslint.config.mjs +++ /dev/null @@ -1,28 +0,0 @@ -import globals from 'globals' -import tseslint from '@typescript-eslint/eslint-plugin' -import tsparser from '@typescript-eslint/parser' -import importPlugin from 'eslint-plugin-import' - -export default [ - { - files: ['src/**/*.ts', 'test/**/*.ts'], - languageOptions: { - parser: tsparser, - parserOptions: { project: false, ecmaVersion: 'latest', sourceType: 'module' }, - globals: globals.node, - }, - plugins: { - '@typescript-eslint': tseslint, - import: importPlugin, - }, - rules: { - 'no-unused-vars': 'off', - '@typescript-eslint/no-unused-vars': [ - 'warn', - { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, - ], - 'no-console': 'off', - 'import/no-useless-path-segments': 'error', - }, - }, -] diff --git a/calm-plugins/vscode/screenshots/fixtures/broken/architecture.json b/calm-plugins/vscode/screenshots/fixtures/broken/architecture.json deleted file mode 100644 index e16d099e2..000000000 --- a/calm-plugins/vscode/screenshots/fixtures/broken/architecture.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "$schema": "https://calm.finos.org/release/1.2/meta/calm.json", - "unique-id": "broken-architecture", - "name": "Intentionally Broken Architecture", - "description": "Used by the validation/problems-panel screenshot. Each node is missing a required field so the extension surfaces multiple diagnostics.", - "nodes": [ - { - "unique-id": "missing-type", - "name": "Missing node-type" - }, - { - "unique-id": "missing-name", - "node-type": "service" - } - ], - "relationships": [ - { - "description": "Relationship missing unique-id and relationship-type" - } - ] -} diff --git a/calm-plugins/vscode/screenshots/fixtures/docify-template/architecture.json b/calm-plugins/vscode/screenshots/fixtures/docify-template/architecture.json deleted file mode 100644 index 20f013cfc..000000000 --- a/calm-plugins/vscode/screenshots/fixtures/docify-template/architecture.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "$schema": "https://calm.finos.org/release/1.2/meta/calm.json", - "unique-id": "docify-demo", - "name": "Docify Demo", - "description": "Small architecture used by the docify-template fixture to demonstrate live documentation generation.", - "nodes": [ - { - "unique-id": "client", - "node-type": "webclient", - "name": "Client", - "description": "A web client." - }, - { - "unique-id": "service", - "node-type": "service", - "name": "Service", - "description": "A backend service." - } - ], - "relationships": [ - { - "unique-id": "client-to-service", - "description": "Calls", - "relationship-type": { - "connects": { - "source": { "node": "client" }, - "destination": { "node": "service" } - } - } - } - ] -} diff --git a/calm-plugins/vscode/screenshots/fixtures/docify-template/architecture.md b/calm-plugins/vscode/screenshots/fixtures/docify-template/architecture.md deleted file mode 100644 index d1618bc05..000000000 --- a/calm-plugins/vscode/screenshots/fixtures/docify-template/architecture.md +++ /dev/null @@ -1,15 +0,0 @@ -# {{name}} - -{{description}} - -## Components - -{{#each nodes}} -- **{{name}}** ({{node-type}}) — {{description}} -{{/each}} - -## Interactions - -{{#each relationships}} -- {{description}} -{{/each}} diff --git a/calm-plugins/vscode/screenshots/fixtures/three-tier/architecture.json b/calm-plugins/vscode/screenshots/fixtures/three-tier/architecture.json deleted file mode 100644 index 7a7d074a5..000000000 --- a/calm-plugins/vscode/screenshots/fixtures/three-tier/architecture.json +++ /dev/null @@ -1,76 +0,0 @@ -{ - "$schema": "https://calm.finos.org/release/1.2/meta/calm.json", - "unique-id": "three-tier-web-app", - "name": "Three-Tier Web Application", - "description": "A classic three-tier web application architecture with presentation, application, and data tiers", - "nodes": [ - { - "unique-id": "user", - "node-type": "actor", - "name": "End User", - "description": "Users accessing the web application through a browser" - }, - { - "unique-id": "web-frontend", - "node-type": "webclient", - "name": "Web Frontend", - "description": "Presentation tier - React-based single page application served to users" - }, - { - "unique-id": "api-server", - "node-type": "service", - "name": "API Server", - "description": "Application tier - RESTful API server handling business logic and request processing" - }, - { - "unique-id": "database", - "node-type": "database", - "name": "PostgreSQL Database", - "description": "Data tier - Relational database storing application data" - } - ], - "relationships": [ - { - "unique-id": "user-to-frontend", - "description": "Accesses the web application through", - "relationship-type": { - "interacts": { - "actor": "user", - "nodes": [ - "web-frontend" - ] - } - } - }, - { - "unique-id": "frontend-to-api", - "description": "Sends API requests to", - "relationship-type": { - "connects": { - "source": { - "node": "web-frontend" - }, - "destination": { - "node": "api-server" - } - } - }, - "protocol": "HTTPS" - }, - { - "unique-id": "api-to-database", - "description": "Reads and writes application data to", - "relationship-type": { - "connects": { - "source": { - "node": "api-server" - }, - "destination": { - "node": "database" - } - } - }, - "protocol": "JDBC" - } - ] -} \ No newline at end of file diff --git a/calm-plugins/vscode/screenshots/fixtures/timeline/arch-v1.json b/calm-plugins/vscode/screenshots/fixtures/timeline/arch-v1.json deleted file mode 100644 index d4857ec6f..000000000 --- a/calm-plugins/vscode/screenshots/fixtures/timeline/arch-v1.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "$schema": "https://calm.finos.org/release/1.2/meta/calm.json", - "nodes": [ - { - "unique-id": "web-app", - "name": "Web Application", - "description": "Frontend web application", - "node-type": "webclient" - }, - { - "unique-id": "api-service", - "name": "API Service", - "description": "Backend API service", - "node-type": "service" - } - ], - "relationships": [ - { - "unique-id": "web-to-api", - "description": "Web app calls API", - "relationship-type": { - "connects": { - "source": { "node": "web-app" }, - "destination": { "node": "api-service" } - } - } - } - ] -} - diff --git a/calm-plugins/vscode/screenshots/fixtures/timeline/arch-v2.json b/calm-plugins/vscode/screenshots/fixtures/timeline/arch-v2.json deleted file mode 100644 index d28849c6d..000000000 --- a/calm-plugins/vscode/screenshots/fixtures/timeline/arch-v2.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "$schema": "https://calm.finos.org/release/1.2/meta/calm.json", - "nodes": [ - { - "unique-id": "web-app", - "name": "Web Application", - "description": "Frontend web application", - "node-type": "webclient" - }, - { - "unique-id": "api-service", - "name": "API Service", - "description": "Backend API service", - "node-type": "service" - }, - { - "unique-id": "database", - "name": "Database", - "description": "Persistent data store", - "node-type": "database" - } - ], - "relationships": [ - { - "unique-id": "web-to-api", - "description": "Web app calls API", - "relationship-type": { - "connects": { - "source": { "node": "web-app" }, - "destination": { "node": "api-service" } - } - } - }, - { - "unique-id": "api-to-db", - "description": "API stores data", - "relationship-type": { - "connects": { - "source": { "node": "api-service" }, - "destination": { "node": "database" } - } - } - } - ] -} - diff --git a/calm-plugins/vscode/screenshots/fixtures/timeline/calm-timeline.json b/calm-plugins/vscode/screenshots/fixtures/timeline/calm-timeline.json deleted file mode 100644 index 2950afe0a..000000000 --- a/calm-plugins/vscode/screenshots/fixtures/timeline/calm-timeline.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "$schema": "https://calm.finos.org/release/1.2/meta/calm-timeline.json", - "current-moment": "initial", - "moments": [ - { - "unique-id": "initial", - "node-type": "moment", - "name": "Initial Architecture", - "description": "The initial architecture design", - "valid-from": "2024-01-01", - "details": { - "detailed-architecture": "arch-v1.json" - } - }, - { - "unique-id": "enhanced", - "node-type": "moment", - "name": "Enhanced Architecture", - "description": "Architecture with additional components", - "valid-from": "2024-06-01", - "details": { - "detailed-architecture": "arch-v2.json" - } - } - ], - "metadata": { - "title": "Test Timeline", - "description": "Timeline for testing VSCode plugin timeline navigation" - } -} - diff --git a/calm-plugins/vscode/screenshots/package-lock.json b/calm-plugins/vscode/screenshots/package-lock.json deleted file mode 100644 index e51e63c03..000000000 --- a/calm-plugins/vscode/screenshots/package-lock.json +++ /dev/null @@ -1,5864 +0,0 @@ -{ - "name": "calm-vscode-screenshots", - "version": "0.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "calm-vscode-screenshots", - "version": "0.0.0", - "devDependencies": { - "@types/node": "^26", - "@typescript-eslint/eslint-plugin": "^8.0.0", - "@typescript-eslint/parser": "^8.0.0", - "@vscode/test-electron": "^2.4.1", - "eslint": "^9.0.0", - "eslint-plugin-import": "^2.32.0", - "globals": "^15.0.0", - "playwright": "^1.50.0", - "tsx": "^4.19.0", - "typescript": "^5.5.0", - "vitest": "^4.0.0" - } - }, - "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", - "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", - "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", - "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", - "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", - "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", - "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", - "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", - "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", - "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", - "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", - "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", - "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", - "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", - "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", - "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", - "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", - "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", - "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", - "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", - "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", - "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", - "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", - "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", - "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", - "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", - "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", - "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/config-array": { - "version": "0.21.2", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", - "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^2.1.7", - "debug": "^4.3.1", - "minimatch": "^3.1.5" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/config-array/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", - "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.14.0", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.1", - "minimatch": "^3.1.5", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/eslintrc/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@eslint/eslintrc/node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@eslint/eslintrc/node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/@eslint/js": { - "version": "9.39.4", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", - "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - } - }, - "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0", - "levn": "^0.4.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@humanfs/core": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", - "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/types": "^0.15.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node": { - "version": "0.16.8", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", - "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/core": "^0.19.2", - "@humanfs/types": "^0.15.0", - "@humanwhocodes/retry": "^0.4.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/types": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", - "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", - "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" - } - }, - "node_modules/@oxc-project/types": { - "version": "0.133.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", - "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" - } - }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", - "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", - "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", - "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", - "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", - "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", - "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", - "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", - "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", - "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", - "cpu": [ - "s390x" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", - "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", - "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", - "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", - "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", - "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", - "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", - "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rtsao/scc": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", - "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", - "dev": true, - "license": "MIT" - }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", - "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" - } - }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/json5": { - "version": "0.0.29", - "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", - "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "26.0.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.0.tgz", - "integrity": "sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~8.3.0" - } - }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.0.tgz", - "integrity": "sha512-o+mpz7EYiMzXoySXiKmzlabIvTVqUuK5yLrAedRPRDA0IpPFMUV1IXt6OqljIxX/kumN6EjUYp41Hqelh6p/Dw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.62.0", - "@typescript-eslint/type-utils": "8.62.0", - "@typescript-eslint/utils": "8.62.0", - "@typescript-eslint/visitor-keys": "8.62.0", - "ignore": "^7.0.5", - "natural-compare": "^1.4.0", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^8.62.0", - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/parser": { - "version": "8.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.62.0.tgz", - "integrity": "sha512-dzHeT2gySzZtLDsuqxU9AkYgIsQoHAHtRBpOqM+Ofzx1Bwrd2RcCjQJ+6iQbsHOIR6NS33bF2W1k3blN1zLDrA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/scope-manager": "8.62.0", - "@typescript-eslint/types": "8.62.0", - "@typescript-eslint/typescript-estree": "8.62.0", - "@typescript-eslint/visitor-keys": "8.62.0", - "debug": "^4.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/project-service": { - "version": "8.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.62.0.tgz", - "integrity": "sha512-wexnCqiTg7BOGtbLDftYpRWlmLq4xfoMd7BKFR6Y75sZS3QmRKLdN3yWLhmIYgqMmP/OXWpj3H8odkb5nGURCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.62.0", - "@typescript-eslint/types": "^8.62.0", - "debug": "^4.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.62.0.tgz", - "integrity": "sha512-1lX38kNxXIRb8mEc3lbq5mdHq1Pf2+U0nFU65KfT18mtPxxl0fvjuEE92mHuXPuCtElJhOrddOpyMlM3Z0umEA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.62.0", - "@typescript-eslint/visitor-keys": "8.62.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.62.0.tgz", - "integrity": "sha512-y2GAdB6ykaXUvuspbYnizQc4oDDz0Tz/Yc7iWrXf9mx8vm/L/0vLHCe0tS2boG96Zy+DivnVDQ9ZUEWoHqqx1g==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.62.0.tgz", - "integrity": "sha512-+g5O3j0w2ldzC86Pv6fvbO/xhAonbJFIdf/MKQ1d30gndlsVzUOE83ldfSE15Qrl9fhFjK6AovHs5Wpp6vx86w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.62.0", - "@typescript-eslint/typescript-estree": "8.62.0", - "@typescript-eslint/utils": "8.62.0", - "debug": "^4.4.3", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/types": { - "version": "8.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.62.0.tgz", - "integrity": "sha512-KvAclkktORPvM54TgLgA4z9HIV1M8zOgw9ZVNXl9f/8dLYfXYX1wkMXP7qmabpijQRV5bHJLOmoyGQbLMaUYeg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.62.0.tgz", - "integrity": "sha512-+hVbNxtW64pIcZWDPGbyaKF7vp2IBTVY5ma1blwwksrjdsbdqqEKvJWMGbBofei4F6Dovx1M0RJgoFeNu2279A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/project-service": "8.62.0", - "@typescript-eslint/tsconfig-utils": "8.62.0", - "@typescript-eslint/types": "8.62.0", - "@typescript-eslint/visitor-keys": "8.62.0", - "debug": "^4.4.3", - "minimatch": "^10.2.2", - "semver": "^7.7.3", - "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/utils": { - "version": "8.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.62.0.tgz", - "integrity": "sha512-82r66fi9zYwZ+mTq3vKgwjbZ1PVk/DJzrXFLpG6RnBbdvH8TEGVHIs9H4d2drhkOzf0syZuD/OZvvlu6GDbP4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.62.0", - "@typescript-eslint/types": "8.62.0", - "@typescript-eslint/typescript-estree": "8.62.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.62.0.tgz", - "integrity": "sha512-CY3uyFSRbcQv3nnSv8S0+lDftMVz6P963PoRlxrV7ew/Md564g9ut60PYzdLM5qW4jFn93GBF+Soi90ISAN+GQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.62.0", - "eslint-visitor-keys": "^5.0.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@vitest/expect": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", - "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.1.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.9", - "@vitest/utils": "4.1.9", - "chai": "^6.2.2", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", - "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "4.1.9", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/pretty-format": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", - "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", - "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "4.1.9", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", - "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.9", - "@vitest/utils": "4.1.9", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", - "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", - "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.9", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vscode/test-electron": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/@vscode/test-electron/-/test-electron-2.5.2.tgz", - "integrity": "sha512-8ukpxv4wYe0iWMRQU18jhzJOHkeGKbnw7xWRX3Zw1WJA4cEKbHcmmLPdPrPtL6rhDcrlCZN+xKRpv09n4gRHYg==", - "dev": true, - "license": "MIT", - "dependencies": { - "http-proxy-agent": "^7.0.2", - "https-proxy-agent": "^7.0.5", - "jszip": "^3.10.1", - "ora": "^8.1.0", - "semver": "^7.6.2" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/acorn": { - "version": "8.17.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", - "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/ajv": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", - "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/array-buffer-byte-length": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", - "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "is-array-buffer": "^3.0.5" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array-includes": { - "version": "3.1.9", - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", - "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.24.0", - "es-object-atoms": "^1.1.1", - "get-intrinsic": "^1.3.0", - "is-string": "^1.1.1", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.findlastindex": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", - "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.9", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "es-shim-unscopables": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.flat": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", - "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.flatmap": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", - "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/arraybuffer.prototype.slice": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", - "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-buffer-byte-length": "^1.0.1", - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "is-array-buffer": "^3.0.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/async-function": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", - "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/available-typed-arrays": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", - "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "possible-typed-array-names": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/call-bind": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", - "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "get-intrinsic": "^1.3.0", - "set-function-length": "^1.2.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/cli-cursor": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", - "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", - "dev": true, - "license": "MIT", - "dependencies": { - "restore-cursor": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-spinners": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", - "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/data-view-buffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", - "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/data-view-byte-length": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", - "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/inspect-js" - } - }, - "node_modules/data-view-byte-offset": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", - "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/define-properties": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", - "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.0.1", - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/doctrine": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", - "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "dev": true, - "license": "MIT" - }, - "node_modules/es-abstract": { - "version": "1.24.2", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", - "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-buffer-byte-length": "^1.0.2", - "arraybuffer.prototype.slice": "^1.0.4", - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "data-view-buffer": "^1.0.2", - "data-view-byte-length": "^1.0.2", - "data-view-byte-offset": "^1.0.1", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "es-set-tostringtag": "^2.1.0", - "es-to-primitive": "^1.3.0", - "function.prototype.name": "^1.1.8", - "get-intrinsic": "^1.3.0", - "get-proto": "^1.0.1", - "get-symbol-description": "^1.1.0", - "globalthis": "^1.0.4", - "gopd": "^1.2.0", - "has-property-descriptors": "^1.0.2", - "has-proto": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "internal-slot": "^1.1.0", - "is-array-buffer": "^3.0.5", - "is-callable": "^1.2.7", - "is-data-view": "^1.0.2", - "is-negative-zero": "^2.0.3", - "is-regex": "^1.2.1", - "is-set": "^2.0.3", - "is-shared-array-buffer": "^1.0.4", - "is-string": "^1.1.1", - "is-typed-array": "^1.1.15", - "is-weakref": "^1.1.1", - "math-intrinsics": "^1.1.0", - "object-inspect": "^1.13.4", - "object-keys": "^1.1.1", - "object.assign": "^4.1.7", - "own-keys": "^1.0.1", - "regexp.prototype.flags": "^1.5.4", - "safe-array-concat": "^1.1.3", - "safe-push-apply": "^1.0.0", - "safe-regex-test": "^1.1.0", - "set-proto": "^1.0.0", - "stop-iteration-iterator": "^1.1.0", - "string.prototype.trim": "^1.2.10", - "string.prototype.trimend": "^1.0.9", - "string.prototype.trimstart": "^1.0.8", - "typed-array-buffer": "^1.0.3", - "typed-array-byte-length": "^1.0.3", - "typed-array-byte-offset": "^1.0.4", - "typed-array-length": "^1.0.7", - "unbox-primitive": "^1.1.0", - "which-typed-array": "^1.1.19" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/es-abstract-get": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-abstract-get/-/es-abstract-get-1.0.0.tgz", - "integrity": "sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.2", - "is-callable": "^1.2.7", - "object-inspect": "^1.13.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-module-lexer": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", - "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/es-object-atoms": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", - "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-shim-unscopables": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", - "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-to-primitive": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.1.tgz", - "integrity": "sha512-CxN9N56HYfd2m/acc/NOFrZQsN9kU4eh+2kk6A707Kz1krH8tKmfrs5RnftB8WNX80T0NS7vSQsDOlg23diR2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-abstract-get": "^1.0.0", - "es-errors": "^1.3.0", - "is-callable": "^1.2.7", - "is-date-object": "^1.1.0", - "is-symbol": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/esbuild": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", - "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.1", - "@esbuild/android-arm": "0.28.1", - "@esbuild/android-arm64": "0.28.1", - "@esbuild/android-x64": "0.28.1", - "@esbuild/darwin-arm64": "0.28.1", - "@esbuild/darwin-x64": "0.28.1", - "@esbuild/freebsd-arm64": "0.28.1", - "@esbuild/freebsd-x64": "0.28.1", - "@esbuild/linux-arm": "0.28.1", - "@esbuild/linux-arm64": "0.28.1", - "@esbuild/linux-ia32": "0.28.1", - "@esbuild/linux-loong64": "0.28.1", - "@esbuild/linux-mips64el": "0.28.1", - "@esbuild/linux-ppc64": "0.28.1", - "@esbuild/linux-riscv64": "0.28.1", - "@esbuild/linux-s390x": "0.28.1", - "@esbuild/linux-x64": "0.28.1", - "@esbuild/netbsd-arm64": "0.28.1", - "@esbuild/netbsd-x64": "0.28.1", - "@esbuild/openbsd-arm64": "0.28.1", - "@esbuild/openbsd-x64": "0.28.1", - "@esbuild/openharmony-arm64": "0.28.1", - "@esbuild/sunos-x64": "0.28.1", - "@esbuild/win32-arm64": "0.28.1", - "@esbuild/win32-ia32": "0.28.1", - "@esbuild/win32-x64": "0.28.1" - } - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint": { - "version": "9.39.4", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", - "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.2", - "@eslint/config-helpers": "^0.4.2", - "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.5", - "@eslint/js": "9.39.4", - "@eslint/plugin-kit": "^0.4.1", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", - "ajv": "^6.14.0", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.6", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.5", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } - } - }, - "node_modules/eslint-import-resolver-node": { - "version": "0.3.10", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.10.tgz", - "integrity": "sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^3.2.7", - "is-core-module": "^2.16.1", - "resolve": "^2.0.0-next.6" - } - }, - "node_modules/eslint-import-resolver-node/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-module-utils": { - "version": "2.13.0", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.13.0.tgz", - "integrity": "sha512-bLohSkT6469rRs8czj0tLTD8vaeIS/whvPRJVjDr7IuoTT1k5DYDERlNycjDj/HkOlvQdYurmfZ/g3fG5bgeLQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^3.2.7" - }, - "engines": { - "node": ">=4" - }, - "peerDependenciesMeta": { - "eslint": { - "optional": true - } - } - }, - "node_modules/eslint-module-utils/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-plugin-import": { - "version": "2.32.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", - "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rtsao/scc": "^1.1.0", - "array-includes": "^3.1.9", - "array.prototype.findlastindex": "^1.2.6", - "array.prototype.flat": "^1.3.3", - "array.prototype.flatmap": "^1.3.3", - "debug": "^3.2.7", - "doctrine": "^2.1.0", - "eslint-import-resolver-node": "^0.3.9", - "eslint-module-utils": "^2.12.1", - "hasown": "^2.0.2", - "is-core-module": "^2.16.1", - "is-glob": "^4.0.3", - "minimatch": "^3.1.2", - "object.fromentries": "^2.0.8", - "object.groupby": "^1.0.3", - "object.values": "^1.2.1", - "semver": "^6.3.1", - "string.prototype.trimend": "^1.0.9", - "tsconfig-paths": "^3.15.0" - }, - "engines": { - "node": ">=4" - }, - "peerDependencies": { - "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" - } - }, - "node_modules/eslint-plugin-import/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/eslint-plugin-import/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/eslint-plugin-import/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-plugin-import/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/eslint-plugin-import/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/eslint/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint/node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.15.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/espree/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esquery": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", - "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "flat-cache": "^4.0.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/flatted": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", - "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", - "dev": true, - "license": "ISC" - }, - "node_modules/for-each": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", - "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-callable": "^1.2.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/function.prototype.name": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.2.0.tgz", - "integrity": "sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.9", - "call-bound": "^1.0.4", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "functions-have-names": "^1.2.3", - "has-property-descriptors": "^1.0.2", - "hasown": "^2.0.4", - "is-callable": "^1.2.7", - "is-document.all": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/functions-have-names": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", - "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/generator-function": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", - "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/get-east-asian-width": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", - "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/get-symbol-description": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", - "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/globals": { - "version": "15.15.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", - "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/globalthis": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", - "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-properties": "^1.2.1", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-bigints": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", - "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-proto": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", - "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", - "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", - "dev": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/immediate": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", - "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/internal-slot": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", - "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "hasown": "^2.0.2", - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/is-array-buffer": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", - "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-async-function": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", - "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "async-function": "^1.0.0", - "call-bound": "^1.0.3", - "get-proto": "^1.0.1", - "has-tostringtag": "^1.0.2", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-bigint": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", - "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-bigints": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-boolean-object": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", - "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-callable": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-core-module": { - "version": "2.16.2", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", - "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-data-view": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", - "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "get-intrinsic": "^1.2.6", - "is-typed-array": "^1.1.13" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-date-object": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", - "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-document.all": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-document.all/-/is-document.all-1.0.0.tgz", - "integrity": "sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-finalizationregistry": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", - "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-generator-function": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", - "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.4", - "generator-function": "^2.0.0", - "get-proto": "^1.0.1", - "has-tostringtag": "^1.0.2", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-interactive": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", - "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-map": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", - "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-negative-zero": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", - "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-number-object": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", - "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-regex": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", - "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-set": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", - "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-shared-array-buffer": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", - "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-string": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", - "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-symbol": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", - "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-symbols": "^1.1.0", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-typed-array": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", - "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "which-typed-array": "^1.1.16" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-unicode-supported": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", - "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-weakmap": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", - "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-weakref": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", - "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-weakset": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", - "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, - "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/nodeca" - } - ], - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/json5": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", - "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", - "dev": true, - "license": "MIT", - "dependencies": { - "minimist": "^1.2.0" - }, - "bin": { - "json5": "lib/cli.js" - } - }, - "node_modules/jszip": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", - "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", - "dev": true, - "license": "(MIT OR GPL-3.0-or-later)", - "dependencies": { - "lie": "~3.3.0", - "pako": "~1.0.2", - "readable-stream": "~2.3.6", - "setimmediate": "^1.0.5" - } - }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/lie": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", - "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "immediate": "~3.0.5" - } - }, - "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", - "dev": true, - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" - } - }, - "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/log-symbols": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz", - "integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^5.3.0", - "is-unicode-supported": "^1.3.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-symbols/node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/log-symbols/node_modules/is-unicode-supported": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", - "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/mimic-function": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", - "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, - "license": "MIT" - }, - "node_modules/node-exports-info": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.2.tgz", - "integrity": "sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag==", - "dev": true, - "license": "MIT", - "dependencies": { - "array.prototype.flatmap": "^1.3.3", - "es-errors": "^1.3.0", - "object.entries": "^1.1.9", - "semver": "^6.3.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/node-exports-info/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.assign": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", - "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0", - "has-symbols": "^1.1.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object.entries": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", - "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.fromentries": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", - "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object.groupby": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", - "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.values": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", - "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/obug": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", - "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", - "dev": true, - "funding": [ - "https://github.com/sponsors/sxzz", - "https://opencollective.com/debug" - ], - "license": "MIT", - "engines": { - "node": ">=12.20.0" - } - }, - "node_modules/onetime": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", - "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-function": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/ora": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz", - "integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^5.3.0", - "cli-cursor": "^5.0.0", - "cli-spinners": "^2.9.2", - "is-interactive": "^2.0.0", - "is-unicode-supported": "^2.0.0", - "log-symbols": "^6.0.0", - "stdin-discarder": "^0.2.2", - "string-width": "^7.2.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ora/node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/own-keys": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", - "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-intrinsic": "^1.2.6", - "object-keys": "^1.1.1", - "safe-push-apply": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/pako": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", - "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", - "dev": true, - "license": "(MIT AND Zlib)" - }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true, - "license": "MIT" - }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/playwright": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz", - "integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "playwright-core": "1.61.0" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "fsevents": "2.3.2" - } - }, - "node_modules/playwright-core": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz", - "integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/possible-typed-array-names": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", - "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/postcss": { - "version": "8.5.23", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", - "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.16", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "dev": true, - "license": "MIT" - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "dev": true, - "license": "MIT", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/reflect.getprototypeof": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", - "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.9", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.7", - "get-proto": "^1.0.1", - "which-builtin-type": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/regexp.prototype.flags": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", - "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-errors": "^1.3.0", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "set-function-name": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/resolve": { - "version": "2.0.0-next.7", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", - "integrity": "sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "is-core-module": "^2.16.2", - "node-exports-info": "^1.6.0", - "object-keys": "^1.1.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/restore-cursor": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", - "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", - "dev": true, - "license": "MIT", - "dependencies": { - "onetime": "^7.0.0", - "signal-exit": "^4.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/rolldown": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", - "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@oxc-project/types": "=0.133.0", - "@rolldown/pluginutils": "^1.0.0" - }, - "bin": { - "rolldown": "bin/cli.mjs" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.3", - "@rolldown/binding-darwin-arm64": "1.0.3", - "@rolldown/binding-darwin-x64": "1.0.3", - "@rolldown/binding-freebsd-x64": "1.0.3", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", - "@rolldown/binding-linux-arm64-gnu": "1.0.3", - "@rolldown/binding-linux-arm64-musl": "1.0.3", - "@rolldown/binding-linux-ppc64-gnu": "1.0.3", - "@rolldown/binding-linux-s390x-gnu": "1.0.3", - "@rolldown/binding-linux-x64-gnu": "1.0.3", - "@rolldown/binding-linux-x64-musl": "1.0.3", - "@rolldown/binding-openharmony-arm64": "1.0.3", - "@rolldown/binding-wasm32-wasi": "1.0.3", - "@rolldown/binding-win32-arm64-msvc": "1.0.3", - "@rolldown/binding-win32-x64-msvc": "1.0.3" - } - }, - "node_modules/safe-array-concat": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", - "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.9", - "call-bound": "^1.0.4", - "get-intrinsic": "^1.3.0", - "has-symbols": "^1.1.0", - "isarray": "^2.0.5" - }, - "engines": { - "node": ">=0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/safe-array-concat/node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true, - "license": "MIT" - }, - "node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true, - "license": "MIT" - }, - "node_modules/safe-push-apply": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", - "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "isarray": "^2.0.5" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/safe-push-apply/node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true, - "license": "MIT" - }, - "node_modules/safe-regex-test": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", - "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-regex": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/set-function-length": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/set-function-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", - "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "functions-have-names": "^1.2.3", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/set-proto": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", - "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/setimmediate": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", - "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", - "dev": true, - "license": "MIT" - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/side-channel": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", - "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.4", - "side-channel-list": "^1.0.1", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", - "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, - "node_modules/std-env": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", - "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/stdin-discarder": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", - "integrity": "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/stop-iteration-iterator": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", - "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "internal-slot": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, - "node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string.prototype.trim": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.11.tgz", - "integrity": "sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.9", - "call-bound": "^1.0.4", - "define-data-property": "^1.1.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.24.2", - "es-object-atoms": "^1.1.2", - "has-property-descriptors": "^1.0.2", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.trimend": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.10.tgz", - "integrity": "sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.9", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.trimstart": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", - "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/strip-bom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", - "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyexec": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", - "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinyrainbow": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", - "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/ts-api-utils": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", - "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.12" - }, - "peerDependencies": { - "typescript": ">=4.8.4" - } - }, - "node_modules/tsconfig-paths": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", - "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/json5": "^0.0.29", - "json5": "^1.0.2", - "minimist": "^1.2.6", - "strip-bom": "^3.0.0" - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true - }, - "node_modules/tsx": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", - "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "~0.28.0" - }, - "bin": { - "tsx": "dist/cli.mjs" - }, - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - } - }, - "node_modules/tsx/node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/typed-array-buffer": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", - "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-typed-array": "^1.1.14" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/typed-array-byte-length": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", - "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "for-each": "^0.3.3", - "gopd": "^1.2.0", - "has-proto": "^1.2.0", - "is-typed-array": "^1.1.14" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/typed-array-byte-offset": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", - "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "for-each": "^0.3.3", - "gopd": "^1.2.0", - "has-proto": "^1.2.0", - "is-typed-array": "^1.1.15", - "reflect.getprototypeof": "^1.0.9" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/typed-array-length": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.8.tgz", - "integrity": "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.9", - "for-each": "^0.3.5", - "gopd": "^1.2.0", - "is-typed-array": "^1.1.15", - "possible-typed-array-names": "^1.1.0", - "reflect.getprototypeof": "^1.0.10" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/unbox-primitive": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", - "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-bigints": "^1.0.2", - "has-symbols": "^1.1.0", - "which-boxed-primitive": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/undici-types": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", - "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, - "license": "MIT" - }, - "node_modules/vite": { - "version": "8.0.16", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", - "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", - "dev": true, - "license": "MIT", - "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.15", - "rolldown": "1.0.3", - "tinyglobby": "^0.2.17" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.18", - "esbuild": "^0.27.0 || ^0.28.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "@vitejs/devtools": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vite/node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/vitest": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", - "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "4.1.9", - "@vitest/mocker": "4.1.9", - "@vitest/pretty-format": "4.1.9", - "@vitest/runner": "4.1.9", - "@vitest/snapshot": "4.1.9", - "@vitest/spy": "4.1.9", - "@vitest/utils": "4.1.9", - "es-module-lexer": "^2.0.0", - "expect-type": "^1.3.0", - "magic-string": "^0.30.21", - "obug": "^2.1.1", - "pathe": "^2.0.3", - "picomatch": "^4.0.3", - "std-env": "^4.0.0-rc.1", - "tinybench": "^2.9.0", - "tinyexec": "^1.0.2", - "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.1.0", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@opentelemetry/api": "^1.9.0", - "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.9", - "@vitest/browser-preview": "4.1.9", - "@vitest/browser-webdriverio": "4.1.9", - "@vitest/coverage-istanbul": "4.1.9", - "@vitest/coverage-v8": "4.1.9", - "@vitest/ui": "4.1.9", - "happy-dom": "*", - "jsdom": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@opentelemetry/api": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser-playwright": { - "optional": true - }, - "@vitest/browser-preview": { - "optional": true - }, - "@vitest/browser-webdriverio": { - "optional": true - }, - "@vitest/coverage-istanbul": { - "optional": true - }, - "@vitest/coverage-v8": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - }, - "vite": { - "optional": false - } - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/which-boxed-primitive": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", - "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-bigint": "^1.1.0", - "is-boolean-object": "^1.2.1", - "is-number-object": "^1.1.1", - "is-string": "^1.1.1", - "is-symbol": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-builtin-type": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", - "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "function.prototype.name": "^1.1.6", - "has-tostringtag": "^1.0.2", - "is-async-function": "^2.0.0", - "is-date-object": "^1.1.0", - "is-finalizationregistry": "^1.1.0", - "is-generator-function": "^1.0.10", - "is-regex": "^1.2.1", - "is-weakref": "^1.0.2", - "isarray": "^2.0.5", - "which-boxed-primitive": "^1.1.0", - "which-collection": "^1.0.2", - "which-typed-array": "^1.1.16" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-builtin-type/node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true, - "license": "MIT" - }, - "node_modules/which-collection": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", - "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-map": "^2.0.3", - "is-set": "^2.0.3", - "is-weakmap": "^2.0.2", - "is-weakset": "^2.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-typed-array": { - "version": "1.1.22", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", - "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.9", - "call-bound": "^1.0.4", - "for-each": "^0.3.5", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - } - } -} diff --git a/calm-plugins/vscode/screenshots/package.json b/calm-plugins/vscode/screenshots/package.json deleted file mode 100644 index 92f365889..000000000 --- a/calm-plugins/vscode/screenshots/package.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "name": "calm-vscode-screenshots", - "private": true, - "version": "0.0.0", - "description": "Documentation screenshot generator for the CALM VSCode extension. Dev-only — not published.", - "type": "module", - "scripts": { - "shoot": "tsx src/index.ts", - "test": "vitest run", - "typecheck": "tsc --noEmit", - "lint": "eslint src test", - "lint-fix": "eslint src test --fix" - }, - "devDependencies": { - "@types/node": "^26", - "@typescript-eslint/eslint-plugin": "^8.0.0", - "@typescript-eslint/parser": "^8.0.0", - "@vscode/test-electron": "^2.4.1", - "eslint": "^9.0.0", - "eslint-plugin-import": "^2.32.0", - "globals": "^15.0.0", - "playwright": "^1.50.0", - "tsx": "^4.19.0", - "typescript": "^5.5.0", - "vitest": "^4.0.0" - }, - "overrides": { - "brace-expansion@^1.0.0": "^1.1.18", - "brace-expansion@^5.0.0": "^5.0.8", - "js-yaml@^4.0.0": "^4.3.0", - "postcss": "^8.5.18" - } -} diff --git a/calm-plugins/vscode/screenshots/src/frames.ts b/calm-plugins/vscode/screenshots/src/frames.ts deleted file mode 100644 index 565426856..000000000 --- a/calm-plugins/vscode/screenshots/src/frames.ts +++ /dev/null @@ -1,41 +0,0 @@ -// VSCode wraps extension webviews in two nested iframes: an outer index.html -// wrapper (sandbox boundary) and an inner fake.html where the extension's -// actual content lives. Most automation needs to reach the inner frame to -// click or hover on elements inside the preview canvas. - -import type { Frame, Page } from 'playwright' - -// Identifier for the outer webview wrapper frame. -const OUTER_WEBVIEW_PATTERN = /vscode-webview:\/\/.+\/index\.html/ - -// Identifier for the inner content frame, where the extension renders its HTML. -const INNER_WEBVIEW_PATTERN = /vscode-webview:\/\/.+\/fake\.html/ - -// Returns the outer wrapper frame of the (first) extension webview, or -// undefined if none has loaded yet. For the single-panel case (only the CALM -// preview is open), this is sufficient. If multiple webviews are open at -// once a future caller will need to disambiguate by inspecting frame state. -export function findOuterWebviewFrame(window: Page): Frame | undefined { - return window.frames().find((f) => OUTER_WEBVIEW_PATTERN.test(f.url())) -} - -export function findInnerWebviewFrame(window: Page): Frame | undefined { - return window.frames().find((f) => INNER_WEBVIEW_PATTERN.test(f.url())) -} - -// Convenience: wait until both wrapper + content frames have loaded. -export async function waitForWebviewReady( - window: Page, - timeoutMs = 10_000 -): Promise<{ outer: Frame; inner: Frame }> { - const deadline = Date.now() + timeoutMs - while (Date.now() < deadline) { - const outer = findOuterWebviewFrame(window) - const inner = findInnerWebviewFrame(window) - if (outer && inner) { - return { outer, inner } - } - await window.waitForTimeout(200) - } - throw new Error(`Webview frames did not appear within ${timeoutMs}ms`) -} diff --git a/calm-plugins/vscode/screenshots/src/index.ts b/calm-plugins/vscode/screenshots/src/index.ts deleted file mode 100644 index 106300943..000000000 --- a/calm-plugins/vscode/screenshots/src/index.ts +++ /dev/null @@ -1,103 +0,0 @@ -// Orchestrator: iterate the implemented shots in shots.ts, launching VSCode -// once per shot for deterministic state. Writes PNGs into the docs static -// folder and a manifest alongside. - -import { writeFileSync, mkdirSync } from 'node:fs' -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import { launchVSCodeWithExtension, PINNED_VSCODE_VERSION } from './launch.js' -import { normaliseWorkbench } from './normalise.js' -import { writePng, type ManifestEntry } from './shoot.js' -import { shots, implementedShots } from './shots.js' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const SCREENSHOTS_DIR = path.resolve(__dirname, '..') -const EXTENSION_DIR = path.resolve(SCREENSHOTS_DIR, '..') -const REPO_ROOT = path.resolve(EXTENSION_DIR, '../..') -const FIXTURES_DIR = path.join(SCREENSHOTS_DIR, 'fixtures') -const OUTPUT_DIR = path.join(REPO_ROOT, 'docs/static/img/vscode') -const MANIFEST_PATH = path.join(OUTPUT_DIR, '_manifest.json') - -interface Manifest { - generatedAt: string - vscodeVersion: string - shots: ManifestEntry[] - skipped: { name: string; reason: string }[] -} - -async function main() { - mkdirSync(OUTPUT_DIR, { recursive: true }) - - console.log(`[shoot] ${implementedShots.length} implemented shots, ${shots.length - implementedShots.length} skipped (TODO)`) - console.log(`[shoot] output → ${OUTPUT_DIR}`) - - const entries: ManifestEntry[] = [] - - // Optional filter via SHOOT_ONLY=03-tree-search,04-preview-hero to - // iterate one or a few shots without running the whole set. Useful while - // developing. Names not in `implementedShots` (i.e. `implemented: false`) - // are still ignored — the filter narrows the implemented set, it doesn't - // bypass the implementation flag. - const onlyEnv = process.env.SHOOT_ONLY?.trim() - const allowed = onlyEnv ? new Set(onlyEnv.split(',').map((s) => s.trim())) : null - const todoShots = shots.filter((s) => !s.implemented && (!allowed || allowed.has(s.name))) - - for (const shot of implementedShots) { - if (allowed && !allowed.has(shot.name)) continue - - const workspaceFile = shot.workspaceFile ?? 'architecture.json' - const fixturePath = path.join(FIXTURES_DIR, shot.fixture, workspaceFile) - console.log(`\n[shoot] ${shot.name} fixture=${shot.fixture}/${workspaceFile}`) - - const { window, cleanup } = await launchVSCodeWithExtension({ - extensionPath: EXTENSION_DIR, - workspacePath: fixturePath, - settingsOverrides: shot.settings, - }) - - try { - await normaliseWorkbench(window) - // Extension activates on `onStartupFinished`; give it a moment. - await window.waitForTimeout(1_500) - - await shot.setup(window) - await window.waitForTimeout(500) - - const png = await shot.capture(window) - const outFile = path.join(OUTPUT_DIR, `${shot.name}.png`) - const entry = writePng(outFile, png) - entries.push(entry) - console.log(`[shoot] wrote ${entry.name} (${entry.width}x${entry.height}, ${entry.bytes} bytes)`) - } finally { - await cleanup() - } - } - - const skipped = todoShots.map((s) => ({ - name: s.name, - reason: 'TODO — see issue #2529', - })) - - if (allowed) { - console.log( - `\n[shoot] SHOOT_ONLY active — manifest NOT updated. Run a full shoot to regenerate the manifest.` - ) - console.log(`[shoot] ${entries.length} shot(s) written.`) - return - } - - const manifest: Manifest = { - generatedAt: new Date().toISOString(), - vscodeVersion: PINNED_VSCODE_VERSION, - shots: entries, - skipped, - } - writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2) + '\n') - console.log(`\n[shoot] manifest → ${MANIFEST_PATH}`) - console.log(`[shoot] ${entries.length} shot(s) written, ${skipped.length} skipped.`) -} - -main().catch((err) => { - console.error('[shoot] FAILED:', err) - process.exit(1) -}) diff --git a/calm-plugins/vscode/screenshots/src/launch.ts b/calm-plugins/vscode/screenshots/src/launch.ts deleted file mode 100644 index 5e61995a9..000000000 --- a/calm-plugins/vscode/screenshots/src/launch.ts +++ /dev/null @@ -1,114 +0,0 @@ -// Resolve a pinned VSCode binary and launch it via Playwright Electron with -// our extension loaded under --extensionDevelopmentPath. Returns the ElectronApplication -// + the first window so the orchestrator can drive the UI. - -import { downloadAndUnzipVSCode } from '@vscode/test-electron' -import { _electron as electron, type ElectronApplication, type Page } from 'playwright' -import { mkdtempSync, mkdirSync, writeFileSync, existsSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import path from 'node:path' - -// User settings written into each fresh user-data-dir before launch. Anything -// that the workbench reads at startup belongs here, not in a runCommand call. -// Hiding the secondary side bar at launch is the only reliable way — closing -// it via the `closeAuxiliaryBar` command leaves the panel visible if VSCode -// or a built-in feature re-opens it during activation. -const WORKBENCH_SETTINGS = { - 'workbench.startupEditor': 'none', - 'workbench.secondarySideBar.defaultVisibility': 'hidden', - 'workbench.layoutControl.enabled': false, - 'chat.commandCenter.enabled': false, - 'update.mode': 'none', - 'update.showReleaseNotes': false, - 'telemetry.telemetryLevel': 'off', - 'workbench.tips.enabled': false, - 'workbench.welcomePage.walkthroughs.openOnInstall': false, - 'extensions.ignoreRecommendations': true, - // Auto-detect quietly overrides an explicit `workbench.colorTheme` when - // the OS reports a different colour scheme or contrast state. Disabling - // both so per-shot theme overrides are honoured — particularly the - // High Contrast variants, which were otherwise silently swapped back to - // their non-HC counterparts on a non-HC OS. - 'window.autoDetectColorScheme': false, - 'window.autoDetectHighContrast': false, -} - -// Bumping this is intentional and requires regenerating every PNG in the same PR. -// See AGENTS.md → "Pin a different VSCode version". -export const PINNED_VSCODE_VERSION = '1.121.0' - -export interface LaunchOptions { - extensionPath: string - // Path passed to VSCode as the workspace argument — either a single file - // (opens with that file active) or a folder (opens a folder workspace). - workspacePath: string - // Optional workbench-settings overrides merged into the seeded settings.json - // for this launch. Used by shots that need a specific theme, layout engine, - // or any other setting that has to be in place before the first paint. - settingsOverrides?: Record -} - -export interface LaunchResult { - app: ElectronApplication - window: Page - cleanup: () => Promise -} - -export async function launchVSCodeWithExtension(opts: LaunchOptions): Promise { - if (!existsSync(path.join(opts.extensionPath, 'dist/extension.js'))) { - throw new Error( - `Extension not built at ${opts.extensionPath}/dist/extension.js. ` + - `From the repo root: npm run build --workspace calm-plugins/vscode` - ) - } - - const executablePath = await downloadAndUnzipVSCode(PINNED_VSCODE_VERSION) - - const userDataDir = mkdtempSync(path.join(tmpdir(), 'calm-vscode-shots-user-')) - const extensionsDir = mkdtempSync(path.join(tmpdir(), 'calm-vscode-shots-ext-')) - - // Seed workbench settings into the user-data-dir so the layout is correct - // from the first paint — no flash of the secondary side bar. - const userDir = path.join(userDataDir, 'User') - mkdirSync(userDir, { recursive: true }) - const mergedSettings = { ...WORKBENCH_SETTINGS, ...(opts.settingsOverrides ?? {}) } - writeFileSync( - path.join(userDir, 'settings.json'), - JSON.stringify(mergedSettings, null, 2) + '\n' - ) - - const args = [ - `--extensionDevelopmentPath=${opts.extensionPath}`, - `--user-data-dir=${userDataDir}`, - `--extensions-dir=${extensionsDir}`, - '--disable-workspace-trust', - '--disable-updates', - '--disable-telemetry', - '--skip-welcome', - '--skip-release-notes', - '--no-sandbox', - opts.workspacePath, - ] - - const app = await electron.launch({ - executablePath, - args, - env: { ...process.env, ELECTRON_RUN_AS_NODE: '' }, - }) - - const window = await app.firstWindow() - await window.waitForSelector('.monaco-workbench', { timeout: 30_000 }) - - const cleanup = async () => { - try { - await app.close() - } catch { - // best effort - } - // mkdtempSync dirs are not auto-cleaned; remove them once the app is gone. - rmSync(userDataDir, { recursive: true, force: true }) - rmSync(extensionsDir, { recursive: true, force: true }) - } - - return { app, window, cleanup } -} diff --git a/calm-plugins/vscode/screenshots/src/normalise.ts b/calm-plugins/vscode/screenshots/src/normalise.ts deleted file mode 100644 index 3c07ee706..000000000 --- a/calm-plugins/vscode/screenshots/src/normalise.ts +++ /dev/null @@ -1,49 +0,0 @@ -// Make the workbench visually consistent before any screenshot is taken. -// Sets viewport, closes panels that would otherwise leak into shots, dismisses -// notifications. Run once at startup, then again between shots as a defensive -// reset. - -import type { Page } from 'playwright' - -export const DEFAULT_VIEWPORT = { width: 1600, height: 1000 } as const - -export async function normaliseWorkbench(window: Page): Promise { - await window.setViewportSize(DEFAULT_VIEWPORT) - - // Close the auxiliary side bar (where Copilot Chat lives on default installs). - // The command is a no-op if no aux bar is open. - await runCommand(window, 'workbench.action.closeAuxiliaryBar') - - // Dismiss any visible notification toasts (welcome, update prompts, etc.). - await runCommand(window, 'notifications.clearAll') - - // Settle. - await window.waitForTimeout(300) -} - -export async function runCommand(window: Page, commandId: string): Promise { - // Open Command Palette - await window.keyboard.press('ControlOrMeta+Shift+P') - await window.waitForSelector('.quick-input-widget', { timeout: 5_000 }) - - // The palette opens with `>` prefix already. Type the command ID — VSCode - // matches against the registered command ID strings. - await window.keyboard.type(`>${commandId}`) - await window.waitForTimeout(200) - await window.keyboard.press('Enter') - - // Give the command a moment to run before the next palette open. - await window.waitForTimeout(200) -} - -// Trigger a command via its UI label (what shows in the palette) instead of its -// command ID. Use when the command ID isn't known or the title-cased label is -// more reliable. -export async function runCommandByTitle(window: Page, title: string): Promise { - await window.keyboard.press('ControlOrMeta+Shift+P') - await window.waitForSelector('.quick-input-widget', { timeout: 5_000 }) - await window.keyboard.type(title) - await window.waitForTimeout(300) - await window.keyboard.press('Enter') - await window.waitForTimeout(200) -} diff --git a/calm-plugins/vscode/screenshots/src/shoot.ts b/calm-plugins/vscode/screenshots/src/shoot.ts deleted file mode 100644 index 7101e8ccf..000000000 --- a/calm-plugins/vscode/screenshots/src/shoot.ts +++ /dev/null @@ -1,83 +0,0 @@ -// Screenshot helpers: capture (full window or selector-cropped), write to disk, -// produce a manifest entry. The orchestrator owns the manifest lifecycle. - -import { writeFileSync, statSync } from 'node:fs' -import { createHash } from 'node:crypto' -import path from 'node:path' -import type { Page } from 'playwright' -import { DEFAULT_VIEWPORT } from './normalise.js' - -export interface ManifestEntry { - name: string - width: number - height: number - bytes: number - sha256: string -} - -export interface CaptureOptions { - // If provided, crop the screenshot to this CSS selector's bounding box. - cropToSelector?: string - // Pad the crop region by this many pixels on each side (default 0). - cropPadding?: number -} - -export async function captureFullWindow(window: Page): Promise { - return await window.screenshot({ type: 'png' }) -} - -export async function captureCropped(window: Page, opts: CaptureOptions): Promise { - if (!opts.cropToSelector) { - return await captureFullWindow(window) - } - - const handle = await window.waitForSelector(opts.cropToSelector, { timeout: 5_000 }) - const box = await handle.boundingBox() - if (!box) { - throw new Error(`Selector ${opts.cropToSelector} has no bounding box`) - } - - const pad = opts.cropPadding ?? 0 - const x = Math.max(0, Math.floor(box.x - pad)) - const y = Math.max(0, Math.floor(box.y - pad)) - const clip = { - x, - y, - // Clamp the clip extent to what remains of the viewport from the - // origin — clamping width to DEFAULT_VIEWPORT.width alone allows - // x + width to exceed the viewport when x > 0. - width: Math.min(DEFAULT_VIEWPORT.width - x, Math.ceil(box.width + pad * 2)), - height: Math.min(DEFAULT_VIEWPORT.height - y, Math.ceil(box.height + pad * 2)), - } - - return await window.screenshot({ type: 'png', clip }) -} - -export function writePng(filePath: string, png: Buffer): ManifestEntry { - writeFileSync(filePath, png) - const stat = statSync(filePath) - const sha256 = createHash('sha256').update(png).digest('hex') - const { width, height } = readPngDimensions(png) - return { - name: path.basename(filePath), - width, - height, - bytes: stat.size, - sha256, - } -} - -// PNG dimensions are at fixed offsets in the IHDR chunk: bytes 16-19 = width, -// 20-23 = height (big-endian). No need for a full PNG decoder. The full PNG -// magic is 8 bytes starting with 0x89 'P' 'N' 'G'; we check the magic byte -// and the ASCII signature together to match the smoke test's assertion. -function readPngDimensions(png: Buffer): { width: number; height: number } { - const isPng = - png.length >= 24 && png[0] === 0x89 && png.toString('ascii', 1, 4) === 'PNG' - if (!isPng) { - throw new Error('Buffer is not a PNG') - } - const width = png.readUInt32BE(16) - const height = png.readUInt32BE(20) - return { width, height } -} diff --git a/calm-plugins/vscode/screenshots/src/shots.ts b/calm-plugins/vscode/screenshots/src/shots.ts deleted file mode 100644 index 12c7d1090..000000000 --- a/calm-plugins/vscode/screenshots/src/shots.ts +++ /dev/null @@ -1,399 +0,0 @@ -// Declarative shot list. Each entry produces one PNG under docs/static/img/vscode/. -// -// Adding a shot: see AGENTS.md → "Common workflows → Add a shot". - -import type { Page } from 'playwright' -import { runCommand, runCommandByTitle } from './normalise.js' -import { captureFullWindow } from './shoot.js' -import { findInnerWebviewFrame } from './frames.js' - -export interface Shot { - name: string - // Fixture folder name under fixtures/. The orchestrator opens - // fixtures// by default; override `workspaceFile` - // if the shot needs an entry file other than architecture.json. - fixture: string - workspaceFile?: string - description: string - implemented: boolean - // Optional workbench-settings overrides merged into the seeded settings.json - // for this shot's VSCode launch. Use for theme, layout engine, or any other - // setting that needs to be in place before the first paint. - settings?: Record - setup: (window: Page) => Promise - capture: (window: Page) => Promise -} - -// Wait for a Mermaid-rendered diagram inside the preview's inner webview frame -// to settle. The preview emits no event we can hook, so we poll for the -// presence of node-shaped elements and then settle. -// -// NOTE: on timeout we log a warning and return rather than throwing — the -// caller's screenshot will still run, producing whatever the preview managed -// to render. This means a regression in the preview can silently produce a -// degraded PNG; manual review of the PR diff remains the gate for catching it. -async function waitForDiagramRendered(window: Page, timeoutMs = 10_000): Promise { - const deadline = Date.now() + timeoutMs - while (Date.now() < deadline) { - const inner = findInnerWebviewFrame(window) - if (inner) { - try { - const count = await inner.locator('svg .node, svg g.node').count() - if (count > 0) { - await window.waitForTimeout(800) - return - } - } catch { - // frame detached mid-poll; retry - } - } - await window.waitForTimeout(200) - } - // Don't throw — let the shot capture whatever rendered. Logs the partial. - console.warn(`[shoot] waitForDiagramRendered timed out after ${timeoutMs}ms`) -} - -async function openPreview(window: Page): Promise { - await runCommandByTitle(window, 'CALM: Open Preview') - await window.waitForFunction( - () => { - const tabs = Array.from(document.querySelectorAll('.tab .tab-label')) - return tabs.some((t) => /preview/i.test(t.textContent || '')) - }, - { timeout: 20_000 } - ) - await waitForDiagramRendered(window) -} - -export const shots: Shot[] = [ - { - name: '01-activity-bar', - fixture: 'three-tier', - description: 'CALM icon and Model Elements view in the activity bar.', - implemented: true, - async setup(window) { - await runCommand(window, 'workbench.view.extension.calm') - await window.waitForTimeout(800) - }, - async capture(window) { - return await captureFullWindow(window) - }, - }, - - { - name: '02-tree-view', - fixture: 'three-tier', - description: 'Model Elements tree with Nodes, Relationships, Flows expanded.', - implemented: true, - async setup(window) { - await runCommand(window, 'workbench.view.extension.calm') - await window.waitForTimeout(1_200) - - const first = window.locator('[role="treeitem"]').first() - await first.click() - await window.waitForTimeout(200) - - await window.keyboard.press('ArrowRight') - await window.waitForTimeout(300) - - // Walk down the tree with ArrowDown + ArrowRight to expand each - // visible top-level group (Nodes, Relationships, Flows). Six - // iterations covers the group rows plus a couple of buffer steps - // for any future top-level entries; on a closed leaf ArrowRight - // is a no-op, on an already-expanded node it just moves focus. - for (let i = 0; i < 6; i++) { - await window.keyboard.press('ArrowDown') - await window.keyboard.press('ArrowRight') - await window.waitForTimeout(120) - } - }, - async capture(window) { - return await captureFullWindow(window) - }, - }, - - { - name: '03-tree-search', - fixture: 'three-tier', - description: 'Search & filter in the Model Elements tree.', - implemented: true, - async setup(window) { - await runCommand(window, 'workbench.view.extension.calm') - await window.waitForTimeout(1_000) - // The search command opens a quick-input prompt; type a substring - // of a node ID and confirm so the tree shows the filtered result. - await runCommandByTitle(window, 'Search Model Elements') - await window.waitForSelector('.quick-input-widget', { timeout: 5_000 }) - await window.keyboard.type('api') - await window.waitForTimeout(400) - await window.keyboard.press('Enter') - await window.waitForTimeout(800) - // Expand the root so the matched item is visible. - const first = window.locator('[role="treeitem"]').first() - await first.click() - await window.keyboard.press('ArrowRight') - await window.waitForTimeout(300) - for (let i = 0; i < 6; i++) { - await window.keyboard.press('ArrowDown') - await window.keyboard.press('ArrowRight') - await window.waitForTimeout(100) - } - }, - async capture(window) { - return await captureFullWindow(window) - }, - }, - - { - name: '04-preview-hero', - fixture: 'three-tier', - description: 'Live preview of the architecture next to its JSON source.', - implemented: true, - async setup(window) { - await openPreview(window) - }, - async capture(window) { - return await captureFullWindow(window) - }, - }, - - // Four theme variants. Each is a separate launch with `calm.docify.theme` - // and `workbench.colorTheme` overridden in the seeded settings. The docs - // page uses these as a 2x2 markdown gallery rather than a composite PNG, - // because each variant can be linked / inspected on its own. - // Theme variants. `workbench.colorTheme` in the seeded settings is - // honoured at launch for the standard Modern/Dark/Light themes. HC - // variants are NOT honoured by the same path on VSCode 1.121 when - // launched via --extensionDevelopmentPath (VSCode falls back to its - // internal default regardless of the seeded value, and the post-launch - // Color Theme picker can't be driven reliably either because the - // theme list is loaded asynchronously). The two HC entries are kept - // declared but `implemented: false` until that's resolvable. - { - name: '05-theme-light', - fixture: 'three-tier', - description: 'Preview rendered with the light theme.', - implemented: true, - settings: { 'calm.docify.theme': 'light', 'workbench.colorTheme': 'Default Light Modern' }, - async setup(window) { - await openPreview(window) - }, - async capture(window) { - return await captureFullWindow(window) - }, - }, - { - name: '05-theme-dark', - fixture: 'three-tier', - description: 'Preview rendered with the dark theme.', - implemented: true, - settings: { 'calm.docify.theme': 'dark', 'workbench.colorTheme': 'Default Dark Modern' }, - async setup(window) { - await openPreview(window) - }, - async capture(window) { - return await captureFullWindow(window) - }, - }, - // HC LIGHT — NOT implemented (see block-comment above the theme entries). - { - name: '05-theme-hc-light', - fixture: 'three-tier', - description: 'Preview rendered with the high-contrast light theme.', - implemented: false, - settings: { - 'calm.docify.theme': 'high-contrast-light', - 'workbench.colorTheme': 'Default High Contrast Light', - }, - async setup(window) { - await openPreview(window) - }, - async capture(window) { - return await captureFullWindow(window) - }, - }, - // HC DARK — NOT implemented (same reason as HC light). - { - name: '05-theme-hc-dark', - fixture: 'three-tier', - description: 'Preview rendered with the high-contrast dark theme.', - implemented: false, - settings: { - 'calm.docify.theme': 'high-contrast-dark', - 'workbench.colorTheme': 'Default High Contrast', - }, - async setup(window) { - await openPreview(window) - }, - async capture(window) { - return await captureFullWindow(window) - }, - }, - - // Layout engines: ELK (default) and Dagre. Same fixture, different - // `calm.preview.layout`. The docs page shows the two side by side. - { - name: '06-layout-elk', - fixture: 'three-tier', - description: 'Preview rendered with the ELK layout engine (default).', - implemented: true, - settings: { 'calm.preview.layout': 'elk' }, - async setup(window) { - await openPreview(window) - }, - async capture(window) { - return await captureFullWindow(window) - }, - }, - { - name: '06-layout-dagre', - fixture: 'three-tier', - description: 'Preview rendered with the Dagre layout engine.', - implemented: true, - settings: { 'calm.preview.layout': 'dagre' }, - async setup(window) { - await openPreview(window) - }, - async capture(window) { - return await captureFullWindow(window) - }, - }, - - { - name: '07-validation-problems', - fixture: 'broken', - description: 'Real-time validation surfaces errors in the Problems panel.', - implemented: true, - async setup(window) { - await runCommand(window, 'workbench.actions.view.problems') - await window.waitForTimeout(2_500) - }, - async capture(window) { - return await captureFullWindow(window) - }, - }, - - // Hover info on a node reference inside the JSON editor. The extension - // contributes a hover provider, but reliably triggering its tooltip from - // Playwright requires either a known editor pixel coordinate (mouse - // hover) or a working Cmd+K Cmd+I chord. Cursor positioning works but - // the chord doesn't fire the tooltip via Playwright's keyboard.press - // sequence in this VSCode build. Left as a TODO follow-up — the docs - // section can describe hover without a screenshot. - // - // The setup body below is retained as scaffolding for whoever picks up - // this TODO: cursor lands on the right token; only the show-hover trigger - // is unsolved. Toggle `implemented: true` once that's fixed. - { - name: '08-hover', - fixture: 'three-tier', - description: 'Hover info on a node reference in the JSON editor.', - implemented: false, - async setup(window) { - const editor = window.locator('.monaco-editor').first() - await editor.click() - await window.waitForTimeout(300) - // Pin the cursor at line 1 deterministically. ArrowUp past the - // top is a no-op, so 100 presses guarantees we land at line 1 - // regardless of where the click() positioned us. - for (let i = 0; i < 100; i++) { - await window.keyboard.press('ArrowUp') - } - // Line 14 of the fixture is `"unique-id": "web-frontend",`. - for (let i = 0; i < 13; i++) { - await window.keyboard.press('ArrowDown') - } - await window.keyboard.press('End') - // Step back into the value string so the hover provider has a - // token to resolve. - for (let i = 0; i < 5; i++) { - await window.keyboard.press('ArrowLeft') - } - await window.waitForTimeout(300) - // Use the native show-hover keybinding directly. Going through - // the palette would prefix with `>` and depend on fuzzy matching. - await window.keyboard.press('ControlOrMeta+K') - await window.keyboard.press('ControlOrMeta+I') - await window.waitForTimeout(1_500) - }, - async capture(window) { - return await captureFullWindow(window) - }, - }, - - // Timeline navigation: the extension's tree-view-model enters "timeline - // mode" when the active file is detected as a calm-timeline document - // (see calm-plugins/vscode/src/features/tree-view/view-model/ - // tree-view-model.ts → buildTimelineTree). The CALM sidebar then shows - // "📅 Architecture Timeline" with each moment as a child item. - { - name: '09-timeline', - fixture: 'timeline', - workspaceFile: 'calm-timeline.json', - description: 'Timeline navigation showing architecture moments in the sidebar.', - implemented: true, - async setup(window) { - const editor = window.locator('.monaco-editor').first() - await editor.click() - await window.waitForTimeout(500) - - // Trigger a save (Cmd+S) to force the extension's - // onDidSaveTextDocument handler to re-detect the file type. The - // initial onDidChangeActiveTextEditor on launch is sometimes - // missed because the extension's onStartupFinished activation - // races with the editor opening. - await window.keyboard.press('ControlOrMeta+S') - await window.waitForTimeout(2_000) - - await runCommand(window, 'workbench.view.extension.calm') - await window.waitForTimeout(1_500) - - // Expand the timeline group so the moment items are visible. - const first = window.locator('[role="treeitem"]').first() - await first.click() - await window.waitForTimeout(200) - await window.keyboard.press('ArrowRight') - await window.waitForTimeout(200) - for (let i = 0; i < 4; i++) { - await window.keyboard.press('ArrowDown') - await window.keyboard.press('ArrowRight') - await window.waitForTimeout(120) - } - }, - async capture(window) { - return await captureFullWindow(window) - }, - }, - - // Docify tab: the preview panel has Docify, Template, and Model tabs. - // The Docify tab is the one that actually *renders* the architecture - // through Mermaid / widgets (see template-tab.view.ts — the Template - // tab just shows escaped source). openPreview() already opens the - // preview on the Docify tab (the default), so we just need to wait for - // the diagram to render. - { - name: '10-docify', - fixture: 'docify-template', - workspaceFile: 'architecture.json', - description: 'Docify tab rendering the architecture through CALM widgets.', - implemented: true, - async setup(window) { - await openPreview(window) - // Belt-and-braces: explicitly click the Docify tab in case a - // future change makes a different tab the default. - const inner = findInnerWebviewFrame(window) - if (inner) { - const docifyTab = inner.locator('text=Docify').first() - if ((await docifyTab.count()) > 0) { - await docifyTab.click() - await window.waitForTimeout(2_500) - } - } - }, - async capture(window) { - return await captureFullWindow(window) - }, - }, -] - -export const implementedShots = shots.filter((s) => s.implemented) diff --git a/calm-plugins/vscode/screenshots/test/smoke.test.ts b/calm-plugins/vscode/screenshots/test/smoke.test.ts deleted file mode 100644 index c36670a28..000000000 --- a/calm-plugins/vscode/screenshots/test/smoke.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -// Smoke test: assert every IMPLEMENTED shot produced a non-empty PNG matching -// the manifest. Does NOT regenerate shots — runs against whatever is committed -// in docs/static/img/vscode/. -// -// This is local-only by design; see issue #2529 for the rationale on not -// running it in CI. - -import { describe, it, expect } from 'vitest' -import { readFileSync, existsSync } from 'node:fs' -import { createHash } from 'node:crypto' -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import { implementedShots, shots } from '../src/shots.js' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const REPO_ROOT = path.resolve(__dirname, '../../../..') -const OUTPUT_DIR = path.join(REPO_ROOT, 'docs/static/img/vscode') -const MANIFEST_PATH = path.join(OUTPUT_DIR, '_manifest.json') - -interface ManifestShot { - name: string - width: number - height: number - bytes: number - sha256: string -} - -interface Manifest { - generatedAt: string - vscodeVersion: string - shots: ManifestShot[] - skipped: { name: string; reason: string }[] -} - -describe('screenshot manifest', () => { - it('manifest file exists', () => { - expect(existsSync(MANIFEST_PATH)).toBe(true) - }) - - it('manifest lists every implemented shot exactly once', () => { - const manifest: Manifest = JSON.parse(readFileSync(MANIFEST_PATH, 'utf-8')) - const manifestNames = manifest.shots.map((s) => s.name).sort() - const expectedNames = implementedShots.map((s) => `${s.name}.png`).sort() - expect(manifestNames).toEqual(expectedNames) - }) - - it('manifest lists every TODO shot under skipped', () => { - const manifest: Manifest = JSON.parse(readFileSync(MANIFEST_PATH, 'utf-8')) - const skippedNames = manifest.skipped.map((s) => s.name).sort() - const expectedSkipped = shots - .filter((s) => !s.implemented) - .map((s) => s.name) - .sort() - expect(skippedNames).toEqual(expectedSkipped) - }) -}) - -describe.each(implementedShots)('shot $name', (shot) => { - const pngPath = path.join(OUTPUT_DIR, `${shot.name}.png`) - - it('PNG exists', () => { - expect(existsSync(pngPath)).toBe(true) - }) - - it('PNG is non-empty', () => { - const bytes = readFileSync(pngPath) - expect(bytes.length).toBeGreaterThan(1_000) - }) - - it('PNG signature is valid', () => { - const bytes = readFileSync(pngPath) - // PNG starts with 0x89 0x50 0x4E 0x47 0x0D 0x0A 0x1A 0x0A - expect(bytes[0]).toBe(0x89) - expect(bytes.toString('ascii', 1, 4)).toBe('PNG') - }) - - it('PNG dimensions match the manifest entry', () => { - const bytes = readFileSync(pngPath) - const width = bytes.readUInt32BE(16) - const height = bytes.readUInt32BE(20) - const manifest: Manifest = JSON.parse(readFileSync(MANIFEST_PATH, 'utf-8')) - const entry = manifest.shots.find((s) => s.name === `${shot.name}.png`) - expect(entry).toBeDefined() - expect(entry!.width).toBe(width) - expect(entry!.height).toBe(height) - }) - - it('PNG sha256 matches the manifest entry', () => { - const bytes = readFileSync(pngPath) - const sha = createHash('sha256').update(bytes).digest('hex') - const manifest: Manifest = JSON.parse(readFileSync(MANIFEST_PATH, 'utf-8')) - const entry = manifest.shots.find((s) => s.name === `${shot.name}.png`) - expect(entry!.sha256).toBe(sha) - }) -}) diff --git a/calm-plugins/vscode/screenshots/tsconfig.json b/calm-plugins/vscode/screenshots/tsconfig.json deleted file mode 100644 index 7c1de01de..000000000 --- a/calm-plugins/vscode/screenshots/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "lib": ["ES2022", "DOM"], - "module": "ESNext", - "moduleResolution": "Bundler", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "resolveJsonModule": true, - "isolatedModules": true, - "noEmit": true, - "types": ["node", "vitest"] - }, - "include": ["src/**/*.ts", "test/**/*.ts"] -} diff --git a/calm-plugins/vscode/screenshots/vitest.config.ts b/calm-plugins/vscode/screenshots/vitest.config.ts deleted file mode 100644 index bb567fb70..000000000 --- a/calm-plugins/vscode/screenshots/vitest.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { defineConfig } from 'vitest/config' - -// Local vitest config so the runner doesn't walk up the directory tree and -// pick up the extension's vitest.config.mts (which lives one folder above -// and depends on different node_modules). -export default defineConfig({ - test: { - include: ['test/**/*.test.ts'], - }, -}) diff --git a/calm-plugins/vscode/scripts/copy-calm-assets.js b/calm-plugins/vscode/scripts/copy-calm-assets.js deleted file mode 100644 index 774be93e7..000000000 --- a/calm-plugins/vscode/scripts/copy-calm-assets.js +++ /dev/null @@ -1,63 +0,0 @@ -const fs = require('fs') -const path = require('path') - -async function copyDir(src, dest) { - await fs.promises.mkdir(dest, { recursive: true }) - const entries = await fs.promises.readdir(src, { withFileTypes: true }) - for (const entry of entries) { - const srcPath = path.join(src, entry.name) - const destPath = path.join(dest, entry.name) - if (entry.isDirectory()) { - await copyDir(srcPath, destPath) - } else if (entry.isFile()) { - await fs.promises.copyFile(srcPath, destPath) - } - } -} - -async function copyWidgets(repoRoot, distDir) { - const builtWidgetSrc = path.join(repoRoot, 'calm-widgets', 'dist', 'cli', 'widgets') - const srcWidgetSrc = path.join(repoRoot, 'calm-widgets', 'src', 'widgets') - const widgetDest = path.join(distDir, 'widgets') - - let widgetSrc = null - if (fs.existsSync(builtWidgetSrc)) { - widgetSrc = builtWidgetSrc - console.log('Using built widgets from', builtWidgetSrc) - } else if (fs.existsSync(srcWidgetSrc)) { - widgetSrc = srcWidgetSrc - console.log('Using source widgets from', srcWidgetSrc) - } else { - throw new Error(`Widget source directory not found:\n ${builtWidgetSrc}\n ${srcWidgetSrc}`) - } - - await copyDir(widgetSrc, widgetDest) - console.log('Widgets copied to', widgetDest) -} - -async function copyTemplateBundles(repoRoot, distDir) { - const templateBundleSrc = path.join(repoRoot, 'shared', 'dist', 'template-bundles') - const templateBundleDest = path.join(distDir, 'template-bundles') - - if (!fs.existsSync(templateBundleSrc)) { - throw new Error(`Template bundles not found at ${templateBundleSrc}\nRun "npm run build" in shared package first`) - } - - await copyDir(templateBundleSrc, templateBundleDest) - console.log('Template bundles copied to', templateBundleDest) -} - -async function main() { - try { - const repoRoot = path.resolve(__dirname, '..', '..', '..') - const distDir = path.join(__dirname, '..', 'dist') - - await copyWidgets(repoRoot, distDir) - await copyTemplateBundles(repoRoot, distDir) - } catch (e) { - console.error('Failed to copy CALM assets:', e.message || e) - process.exit(1) - } -} - -main() diff --git a/calm-plugins/vscode/src/application-store.ts b/calm-plugins/vscode/src/application-store.ts deleted file mode 100644 index b301c2442..000000000 --- a/calm-plugins/vscode/src/application-store.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { create, type StoreApi } from 'zustand' -import { subscribeWithSelector } from 'zustand/middleware' -import type { ModelIndex } from './models/model-index' -import type { CalmTimeline } from '@finos/calm-models/model' -import * as vscode from 'vscode' - -export interface ApplicationState { - currentModelIndex: ModelIndex | undefined - currentDocumentUri: vscode.Uri | undefined - currentTimeline: CalmTimeline | undefined - isTimelineMode: boolean - isTemplateMode: boolean - templateFilePath: string | undefined - architectureFilePath: string | undefined - selectedElementId: string | undefined - searchFilter: string - showLabels: boolean - forceCreatePreview: boolean -} - -export interface ApplicationActions { - setModelIndex: (modelIndex: ModelIndex | undefined) => void - setCurrentDocument: (uri: vscode.Uri | undefined) => void - setTimeline: (timeline: CalmTimeline | undefined) => void - setTimelineMode: (enabled: boolean) => void - setTemplateMode: (enabled: boolean, templatePath?: string, architecturePath?: string) => void - setSelectedElement: (id: string | undefined) => void - setSearchFilter: (filter: string) => void - setShowLabels: (show: boolean) => void - setForceCreatePreview: (force: boolean) => void - clearSelection: () => void - resetDocument: () => void -} - -export type ApplicationStore = ApplicationState & ApplicationActions -export type ApplicationStoreApi = StoreApi - -export function createApplicationStore(): ApplicationStoreApi { - return create()( - subscribeWithSelector((set, _get) => ({ - currentModelIndex: undefined, - currentDocumentUri: undefined, - currentTimeline: undefined, - isTimelineMode: false, - isTemplateMode: false, - templateFilePath: undefined, - architectureFilePath: undefined, - selectedElementId: undefined, - searchFilter: '', - showLabels: true, - forceCreatePreview: false, - - setModelIndex: (modelIndex) => - set({ currentModelIndex: modelIndex }), - - setCurrentDocument: (uri) => - set({ currentDocumentUri: uri }), - - setTimeline: (timeline) => - set({ currentTimeline: timeline, isTimelineMode: !!timeline }), - - setTimelineMode: (enabled) => - set({ isTimelineMode: enabled, currentTimeline: enabled ? _get().currentTimeline : undefined }), - - setTemplateMode: (enabled, templatePath, architecturePath) => - set({ - isTemplateMode: enabled, - templateFilePath: templatePath, - architectureFilePath: architecturePath - }), - - setSelectedElement: (id) => - set({ selectedElementId: id }), - - setSearchFilter: (filter) => - set({ searchFilter: filter }), - - setShowLabels: (show) => - set({ showLabels: show }), - - setForceCreatePreview: (force) => - set({ forceCreatePreview: force }), - - clearSelection: () => - set({ selectedElementId: undefined }), - - resetDocument: () => - set({ - currentModelIndex: undefined, - currentDocumentUri: undefined, - currentTimeline: undefined, - isTimelineMode: false, - isTemplateMode: false, - templateFilePath: undefined, - architectureFilePath: undefined, - selectedElementId: undefined - }), - })) - ) -} diff --git a/calm-plugins/vscode/src/calm-extension-controller.ts b/calm-plugins/vscode/src/calm-extension-controller.ts deleted file mode 100644 index e5576847f..000000000 --- a/calm-plugins/vscode/src/calm-extension-controller.ts +++ /dev/null @@ -1,124 +0,0 @@ -import * as vscode from 'vscode' -import { NavigationService } from './core/services/navigation-service' -import { LoggingService } from './core/services/logging-service' -import type { Logger } from './core/ports/logger' -import { ConfigService } from './core/services/config-service' -import { Config } from './core/ports/config' -import { RefreshService } from './core/mediators/refresh-service' -import { SelectionService } from './core/mediators/selection-service' -import { StoreReactionMediator } from './core/mediators/store-reaction-mediator' -import { PreviewPanelFactory } from './features/preview/preview-panel-factory' -import { WatchService } from './core/mediators/watch-service' -import { TreeViewFactory } from './features/tree-view/tree-view-factory' -import { EditorFactory } from './features/editor/editor-factory' -import { CommandRegistrar } from './commands/command-registrar' -import { DiagnosticsService } from './core/services/diagnostics-service' -import { createApplicationStore, type ApplicationStoreApi } from './application-store' -import { setWidgetLogger } from '@finos/calm-shared' -import { ValidationService } from './features/validation/validation-service' -import { createTestApi, CalmExtensionTestApi } from './test-api' - -/** - * Main extension controller that orchestrates all VS Code extension functionality - */ -export class CalmExtensionController { - private disposables: vscode.Disposable[] = [] - private logging: LoggingService | undefined - private previewPanelFactory: PreviewPanelFactory | undefined - - getTestApi(): CalmExtensionTestApi | undefined { - return this.previewPanelFactory ? createTestApi(this.previewPanelFactory) : undefined - } - - async start(context: vscode.ExtensionContext) { - this.logging = new LoggingService('vscode-ext') - const log: Logger = this.logging - - // Configure calm-widgets to log to the CALM output channel - setWidgetLogger({ - debug: (msg) => log.debug?.(`[widget] ${msg}`), - info: (msg) => log.info?.(`[widget] ${msg}`), - warn: (msg) => log.warn?.(`[widget] ${msg}`), - error: (msg) => log.error?.(`[widget] ${msg}`), - }) - - const diagnostics = new DiagnosticsService(log) - const store: ApplicationStoreApi = createApplicationStore() - void diagnostics.logStartup(context) - - const configService: Config = new ConfigService() - const previewPanelFactory = new PreviewPanelFactory() - this.previewPanelFactory = previewPanelFactory - const treeManager = new TreeViewFactory(store) - const editorFactory = new EditorFactory(store) - const navigationService = new NavigationService(log, configService) - - // Listen for configuration changes to reset navigation service and refresh preview - this.disposables.push(vscode.workspace.onDidChangeConfiguration(e => { - if (e.affectsConfiguration('calm.urlMapping')) { - log.info?.('[extension] Configuration changed: calm.urlMapping - resetting navigation service') - navigationService.reset() - } - if (e.affectsConfiguration('calm.docify.theme') || e.affectsConfiguration('workbench.colorTheme')) { - log.info?.('[extension] Configuration changed: calm.docify.theme - refreshing docify view') - const previewPanel = previewPanelFactory.get() - if (previewPanel) { - const vm = previewPanelFactory.getViewModel() - vm.configurationChanged(); - } - } - })) - - let _isCurrentlyInTemplateMode = false - const setTemplateMode = (enabled: boolean) => { - _isCurrentlyInTemplateMode = enabled - store.getState().setTemplateMode(enabled) - } - const selectionService = new SelectionService( - store, - () => previewPanelFactory.getViewModel(), - treeManager, - async (doc: vscode.TextDocument, id: string) => await editorFactory.revealById(doc, id), - navigationService - ) - - treeManager.bindSelectionService(selectionService) - editorFactory.bindSelectionService(selectionService) - - const refreshService = new RefreshService(log, configService, () => previewPanelFactory.get(), store) - editorFactory.bindActiveEditorWatcher(previewPanelFactory, refreshService, setTemplateMode, log) - const watchService = new WatchService(configService, refreshService) - watchService.registerAll(context) - - new CommandRegistrar(context, store, navigationService).registerAll() - - // Initialize validation service (await to ensure schemas are loaded before validating documents) - const validationService = new ValidationService(log, configService) - await validationService.register(context) - - const storeReactionMediator = new StoreReactionMediator( - store, - previewPanelFactory, - refreshService, - selectionService, - log, - context, - configService - ) - - storeReactionMediator.setupReactions() - - this.disposables.push( - previewPanelFactory, - treeManager, - editorFactory, - storeReactionMediator, - validationService - ) - } - - dispose() { - this.logging?.dispose() - this.disposables.forEach(d => { try { d.dispose() } catch { } }) - } -} diff --git a/calm-plugins/vscode/src/cli/docifier-factory.ts b/calm-plugins/vscode/src/cli/docifier-factory.ts deleted file mode 100644 index eb1ef78f1..000000000 --- a/calm-plugins/vscode/src/cli/docifier-factory.ts +++ /dev/null @@ -1,51 +0,0 @@ -/** - * DocifierFactory - Creates Docifier instances for various use cases - * Centralizes the import and instantiation of the Docifier from @finos/calm-shared - */ - -import { Docifier, DocifyMode, TemplateProcessingMode } from '@finos/calm-shared' - -export interface IDocifier { - docify(): Promise -} - -export interface DocifierOptions { - mode: DocifyMode - inputPath: string - outputPath: string - urlMappingPath?: string - templateProcessingMode: TemplateProcessingMode - templatePath?: string - clearOutputDirectory?: boolean - scaffoldOnly?: boolean -} - -export interface IDocifierFactory { - create(options: DocifierOptions): IDocifier -} - -// Re-export for convenience -export type { DocifyMode, TemplateProcessingMode } - -/** - * Default implementation that creates Docifier instances - */ -export class DocifierFactory implements IDocifierFactory { - create(options: DocifierOptions): IDocifier { - return new Docifier( - options.mode, - options.inputPath, - options.outputPath, - options.urlMappingPath, - options.templateProcessingMode, - options.templatePath, - options.clearOutputDirectory ?? false, - options.scaffoldOnly ?? false - ) - } -} - -/** - * Singleton instance for convenience - */ -export const docifierFactory = new DocifierFactory() diff --git a/calm-plugins/vscode/src/cli/docify-processor.ts b/calm-plugins/vscode/src/cli/docify-processor.ts deleted file mode 100644 index 95c54582d..000000000 --- a/calm-plugins/vscode/src/cli/docify-processor.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** - * DocifyProcessor - Pure domain logic for docification process - * Handles content processing and orchestration without file-system or external dependencies - */ -export class DocifyProcessor { - - detectContentFormat(content: string): 'html' | 'markdown' { - return content.trim().startsWith('<') ? 'html' : 'markdown' - } - - generateTempFileNames(tempDir: string) { - return { - outFile: `${tempDir}/output.md`, - autoTemplate: `${tempDir}/auto-template.hbs` - } - } - - getDocifyConfiguration(templatePath: string | undefined) { - if (templatePath) { - return { - docifyMode: 'USER_PROVIDED' as const, - templateMode: 'template' as const - } - } else { - return { - docifyMode: 'WEBSITE' as const, - templateMode: 'bundle' as const - } - } - } - - - /** - * Validate docify result and extract content information - */ - processDocifyResult(files: string[], expectedOutputFile: string) { - const primaryCandidate = expectedOutputFile - const candidates = files.filter(f => - f.endsWith('.html') || f.endsWith('.md') || f.endsWith('.txt') - ) - - if (candidates.length) { - return { - outputPath: candidates[0], - hasOutput: true - } - } else if (files.length) { - return { - outputPath: files[0], - hasOutput: true - } - } - - return { - outputPath: primaryCandidate, - hasOutput: false - } - } - - createLogTemplate(templateContent: string): string { - return templateContent.replace(/\n/g, '\\n') - } -} \ No newline at end of file diff --git a/calm-plugins/vscode/src/cli/docify-service.ts b/calm-plugins/vscode/src/cli/docify-service.ts deleted file mode 100644 index 4861dcab7..000000000 --- a/calm-plugins/vscode/src/cli/docify-service.ts +++ /dev/null @@ -1,188 +0,0 @@ -import * as fs from 'fs' -import * as path from 'path' -import * as os from 'os' -import { injectWidgetOptionsIntoContent, parseFrontMatter, replaceVariables } from '@finos/calm-shared' -import { DocifyProcessor } from './docify-processor' -import { TemplateService } from './template-service' -import { Logger } from '../core/ports/logger' -import { GraphData } from "../models/model" -import { IDocifierFactory, DocifierFactory } from './docifier-factory' - -type DocifyResult = { content: string; format: 'html' | 'markdown'; sourceFile: string } - -/** - * DocifyService - Framework-agnostic docification service - * Handles file system operations and external library interactions for docify process - */ -export class DocifyService { - private processor = new DocifyProcessor() - - constructor( - private log: Logger, - private templateService: TemplateService, - private docifierFactory: IDocifierFactory = new DocifierFactory() - ) { } - - async run(params: { - currentFilePath: string | undefined - isTemplateMode: boolean - templateFilePath: string | undefined - architectureFilePath: string | undefined - selectedId: string | undefined - getCurrentTreeSelection: (() => string | undefined) | undefined - lastData: { graph: GraphData; selectedId?: string; settings?: any; positions?: Record; viewport?: { pan: { x: number; y: number }; zoom: number } } | undefined - showLabels: boolean - }): Promise { - if (!params.currentFilePath) { - throw new Error('No current file open in preview') - } - - // Determine architecture file and template content - const { archFilePath, templateContentToUse, urlMappingPath } = await this.prepareDocifyInputs(params) - - if (!fs.existsSync(archFilePath)) { - throw new Error(`Architecture file not found: ${archFilePath}`) - } - - // Determine selected ID - const treeSelection = params.getCurrentTreeSelection && params.getCurrentTreeSelection() - const selectedId = params.selectedId || treeSelection - this.log.info(`[docify-service] Selection determination:`) - this.log.info(`[docify-service] - params.selectedId: ${params.selectedId}`) - this.log.info(`[docify-service] - tree selection: ${treeSelection}`) - this.log.info(`[docify-service] - final selectedId: ${selectedId}`) - - // Setup temporary directory and files - const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'calm-docify-')) - const fileNames = this.processor.generateTempFileNames(tmpDir) - - // Generate or use provided template - this.log.info(`[docify-service] Preparing template with selectedId: ${selectedId}`) - const templatePath = await this.prepareTemplate( - fileNames.autoTemplate, - templateContentToUse, - selectedId, - params - ) - this.log.info(`[docify-service] Template prepared at: ${templatePath}`) - - // Execute docification - this.log.info(`[docify-service] Executing docify:`) - this.log.info(`[docify-service] - architecture: ${archFilePath}`) - this.log.info(`[docify-service] - template: ${templatePath}`) - this.log.info(`[docify-service] - output: ${fileNames.outFile}`) - await this.executeDocify(archFilePath, fileNames.outFile, urlMappingPath, templatePath) - - // Process results - use the original template file path for image resolution - const originalSourceFile = params.isTemplateMode && params.templateFilePath - ? params.templateFilePath - : params.currentFilePath - - return await this.processResults(tmpDir, fileNames.outFile, originalSourceFile) - } - - private async prepareDocifyInputs(params: any) { - let archFilePath: string - let templateContentToUse: string | undefined - let urlMappingPath: string | undefined - - if (params.isTemplateMode && params.architectureFilePath && params.templateFilePath) { - archFilePath = params.architectureFilePath - const parsed = parseFrontMatter(params.templateFilePath) - - if (parsed) { - templateContentToUse = replaceVariables(parsed.content, parsed.frontMatter) - urlMappingPath = parsed.urlMappingPath - const widgetOptions = parsed.frontMatter['widget-options'] - - // Reinstate widget options into the content as YAML frontmatter - if (widgetOptions && typeof widgetOptions === 'object') { - this.log.info('[docify-service] Widget options detected in front matter, reinstating into template content.') - templateContentToUse = injectWidgetOptionsIntoContent(templateContentToUse, widgetOptions) - } - } else { - templateContentToUse = fs.readFileSync(params.templateFilePath, 'utf8') - } - } else { - archFilePath = params.currentFilePath as string - } - - return { archFilePath, templateContentToUse, urlMappingPath } - } - - private async prepareTemplate( - autoTemplateFile: string, - templateContentToUse: string | undefined, - selectedId: string | undefined, - params: any - ): Promise { - if (templateContentToUse) { - await fs.promises.writeFile(autoTemplateFile, templateContentToUse, 'utf8') - this.log.info('[preview] Generated template: ' + this.processor.createLogTemplate(templateContentToUse)) - return autoTemplateFile - } - - const templateContent = await this.templateService.generateTemplateContent( - selectedId, - params.lastData?.graph, - params.currentFilePath, - params.showLabels, - params.isTemplateMode, - params.architectureFilePath - ) - - await fs.promises.writeFile(autoTemplateFile, templateContent, 'utf8') - this.log.info('[preview] Generated template: ' + this.processor.createLogTemplate(templateContent)) - return autoTemplateFile - } - - private async executeDocify( - architectureFilePath: string, - outputFilePath: string, - urlMappingPath: string | undefined, - templatePath: string | undefined - ) { - const config = this.processor.getDocifyConfiguration(templatePath) - - const docifier = this.docifierFactory.create({ - mode: config.docifyMode, - inputPath: architectureFilePath, - outputPath: outputFilePath, - urlMappingPath: urlMappingPath, - templateProcessingMode: config.templateMode, - templatePath: templatePath, - clearOutputDirectory: false, - scaffoldOnly: false - }) - - await docifier.docify() - this.log.info('[preview] Docify finished') - } - - private async processResults(tmpDir: string, expectedOutputFile: string, originalSourceFile: string): Promise { - let content: string - let outputPath: string - - try { - content = await fs.promises.readFile(expectedOutputFile, 'utf8') - outputPath = expectedOutputFile - } catch { - // Fallback to finding any output file - const files = await fs.promises.readdir(tmpDir) - const result = this.processor.processDocifyResult(files.map(f => path.join(tmpDir, f)), expectedOutputFile) - - if (result.hasOutput) { - outputPath = result.outputPath - content = await fs.promises.readFile(outputPath, 'utf8') - } else { - throw new Error('Docify completed but no output file was found') - } - } - - return { - content, - format: this.processor.detectContentFormat(content), - sourceFile: originalSourceFile // Use the original template file path for image resolution - } - } -} diff --git a/calm-plugins/vscode/src/cli/html-builder.ts b/calm-plugins/vscode/src/cli/html-builder.ts deleted file mode 100644 index 5f432be1b..000000000 --- a/calm-plugins/vscode/src/cli/html-builder.ts +++ /dev/null @@ -1,36 +0,0 @@ -import * as vscode from 'vscode' -import * as fs from 'fs' - -function getNonce() { - let text = '' - const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' - for (let i = 0; i < 32; i++) { - text += possible.charAt(Math.floor(Math.random() * possible.length)) - } - return text -} - -export class HtmlBuilder { - constructor(private context: vscode.ExtensionContext) {} - getHtml(panel: vscode.WebviewPanel) { - let version = 'unknown' - try { - const pkgUri = vscode.Uri.joinPath(this.context.extensionUri, 'package.json') - const pkg = require(pkgUri.fsPath) - if (pkg?.version) version = String(pkg.version) - } catch {} - const webview = panel.webview - const nonce = getNonce() - const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(this.context.extensionUri, 'dist', 'webview', 'main.global.js')) - const styleUri = webview.asWebviewUri(vscode.Uri.joinPath(this.context.extensionUri, 'media', 'preview.css')) - const htmlPath = vscode.Uri.joinPath(this.context.extensionUri, 'media', 'preview.html') - let html = fs.readFileSync(htmlPath.fsPath, 'utf8') - html = html - .replace(/{{cspSource}}/g, webview.cspSource) - .replace(/{{styleUri}}/g, String(styleUri)) - .replace(/{{scriptUri}}/g, String(scriptUri)) - .replace(/{{nonce}}/g, nonce) - .replace(/{{version}}/g, version) - return html - } -} diff --git a/calm-plugins/vscode/src/cli/template-processor.spec.ts b/calm-plugins/vscode/src/cli/template-processor.spec.ts deleted file mode 100644 index e4d9d7f1f..000000000 --- a/calm-plugins/vscode/src/cli/template-processor.spec.ts +++ /dev/null @@ -1,266 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { TemplateProcessor } from './template-processor' - -describe('TemplateProcessor', () => { - describe('processTemplateForLabels', () => { - it('should return content unchanged when showLabels is true', () => { - const processor = new TemplateProcessor() - const content = '{{block-architecture}}' - const result = processor.processTemplateForLabels(content, true) - expect(result).toBe('{{block-architecture}}') - }) - - it('should add edge-labels="none" when showLabels is false', () => { - const processor = new TemplateProcessor() - const content = '{{block-architecture}}' - const result = processor.processTemplateForLabels(content, false) - expect(result).toBe('{{block-architecture edge-labels="none"}}') - }) - - it('should handle block-architecture with whitespace', () => { - const processor = new TemplateProcessor() - const content = '{{block-architecture }}' - const result = processor.processTemplateForLabels(content, false) - expect(result).toBe('{{block-architecture edge-labels="none"}}') - }) - - it('should handle multiple block-architecture occurrences', () => { - const processor = new TemplateProcessor() - const content = '{{block-architecture}}\n\nSome text\n\n{{block-architecture}}' - const result = processor.processTemplateForLabels(content, false) - expect(result).toBe('{{block-architecture edge-labels="none"}}\n\nSome text\n\n{{block-architecture edge-labels="none"}}') - }) - }) - - describe('processTemplateForTheme', () => { - it('should return content unchanged when theme is "auto"', () => { - const processor = new TemplateProcessor() - const content = '{{block-architecture}}' - const result = processor.processTemplateForTheme(content, 'auto', 'elk') - expect(result).toBe('{{block-architecture}}') - }) - - it('should inject widget-options frontmatter for light theme on content without frontmatter', () => { - const processor = new TemplateProcessor() - const content = '{{block-architecture}}' - const result = processor.processTemplateForTheme(content, 'light', 'elk') - - expect(result).toContain('---') - expect(result).toContain('widget-options:') - expect(result).toContain('block-architecture:') - expect(result).toContain('theme: light') - expect(result).toContain('render-node-type-shapes: true') - expect(result).toContain('{{block-architecture}}') - }) - - it('should inject widget-options frontmatter for dark theme on content without frontmatter', () => { - const processor = new TemplateProcessor() - const content = '# My Template\n{{block-architecture}}' - const result = processor.processTemplateForTheme(content, 'dark', 'elk') - - expect(result).toContain('---') - expect(result).toContain('widget-options:') - expect(result).toContain('block-architecture:') - expect(result).toContain('theme: dark') - expect(result).toContain('render-node-type-shapes: true') - expect(result).toContain('# My Template') - expect(result).toContain('{{block-architecture}}') - }) - - it('should handle content that already has YAML frontmatter', () => { - const processor = new TemplateProcessor() - const content = `--- -title: My Architecture -description: A cool architecture ---- -{{block-architecture}}` - - const result = processor.processTemplateForTheme(content, 'light', 'elk') - - // Should have widget-options injected - expect(result).toContain('widget-options:') - expect(result).toContain('theme: light') - expect(result).toContain('render-node-type-shapes: true') - // Should preserve existing frontmatter - expect(result).toContain('title: My Architecture') - expect(result).toContain('description: A cool architecture') - // Should preserve content - expect(result).toContain('{{block-architecture}}') - // Should still be valid YAML frontmatter (only one frontmatter block) - const frontmatterCount = (result.match(/---/g) || []).length - expect(frontmatterCount).toBe(2) // Opening and closing --- - }) - - it('should handle content with existing widget-options in frontmatter', () => { - const processor = new TemplateProcessor() - const content = `--- -widget-options: - table: - columns: 3 ---- -{{block-architecture}}` - - const result = processor.processTemplateForTheme(content, 'dark', 'elk') - - // Should have both widget-options - expect(result).toContain('widget-options:') - expect(result).toContain('block-architecture:') - expect(result).toContain('theme: dark') - expect(result).toContain('table:') - expect(result).toContain('columns: 3') - // Should preserve content - expect(result).toContain('{{block-architecture}}') - }) - - it('should handle empty content', () => { - const processor = new TemplateProcessor() - const result = processor.processTemplateForTheme('', 'light', 'elk') - - expect(result).toContain('---') - expect(result).toContain('widget-options:') - expect(result).toContain('theme: light') - }) - - it('should handle multiline content without frontmatter', () => { - const processor = new TemplateProcessor() - const content = `# Architecture Diagram - -This is my architecture. - -{{block-architecture}} - -## Details - -More information here.` - - const result = processor.processTemplateForTheme(content, 'light', 'elk') - - expect(result).toContain('widget-options:') - expect(result).toContain('theme: light') - expect(result).toContain('# Architecture Diagram') - expect(result).toContain('More information here.') - }) - }) - - describe('getTemplateNameForSelection', () => { - it('should return default template when no selection', () => { - const processor = new TemplateProcessor() - const result = processor.getTemplateNameForSelection(undefined, null) - expect(result).toBe('default-template.hbs') - }) - - it('should return default template for group selection', () => { - const processor = new TemplateProcessor() - const result = processor.getTemplateNameForSelection('group:my-group', null) - expect(result).toBe('default-template.hbs') - }) - - it('should return node-focus-template for node selection', () => { - const processor = new TemplateProcessor() - const graph = { - nodes: [{ id: 'node-1' }, { id: 'node-2' }], - edges: [] - } - const result = processor.getTemplateNameForSelection('node-1', graph) - expect(result).toBe('node-focus-template.hbs') - }) - - it('should return flow-focus-template for flow edge selection', () => { - const processor = new TemplateProcessor() - const graph = { - nodes: [], - edges: [{ id: 'edge-1', type: 'flow' }] - } - const result = processor.getTemplateNameForSelection('edge-1', graph) - expect(result).toBe('flow-focus-template.hbs') - }) - - it('should return relationship-focus-template for relationship edge selection', () => { - const processor = new TemplateProcessor() - const graph = { - nodes: [], - edges: [{ id: 'edge-1', type: 'relationship' }] - } - const result = processor.getTemplateNameForSelection('edge-1', graph) - expect(result).toBe('relationship-focus-template.hbs') - }) - - it('should return default template when graph is null', () => { - const processor = new TemplateProcessor() - const result = processor.getTemplateNameForSelection('some-id', null) - expect(result).toBe('default-template.hbs') - }) - - it('should return default template when selection not found in graph', () => { - const processor = new TemplateProcessor() - const graph = { - nodes: [{ id: 'node-1' }], - edges: [{ id: 'edge-1', type: 'flow' }] - } - const result = processor.getTemplateNameForSelection('node-999', graph) - expect(result).toBe('default-template.hbs') - }) - }) - - describe('replacePlaceholders', () => { - it('should replace single placeholder', () => { - const processor = new TemplateProcessor() - const template = 'Hello {{name}}!' - const result = processor.replacePlaceholders(template, { name: 'World' }) - expect(result).toBe('Hello World!') - }) - - it('should replace multiple placeholders', () => { - const processor = new TemplateProcessor() - const template = '{{greeting}} {{name}}, welcome to {{place}}!' - const result = processor.replacePlaceholders(template, { - greeting: 'Hello', - name: 'Alice', - place: 'Wonderland' - }) - expect(result).toBe('Hello Alice, welcome to Wonderland!') - }) - - it('should replace multiple occurrences of the same placeholder', () => { - const processor = new TemplateProcessor() - const template = '{{name}} said hello to {{name}}' - const result = processor.replacePlaceholders(template, { name: 'Bob' }) - expect(result).toBe('Bob said hello to Bob') - }) - - it('should leave unknown placeholders unchanged', () => { - const processor = new TemplateProcessor() - const template = 'Hello {{name}}, you are {{age}} years old' - const result = processor.replacePlaceholders(template, { name: 'Charlie' }) - expect(result).toBe('Hello Charlie, you are {{age}} years old') - }) - - it('should handle empty placeholders object', () => { - const processor = new TemplateProcessor() - const template = 'Hello {{name}}!' - const result = processor.replacePlaceholders(template, {}) - expect(result).toBe('Hello {{name}}!') - }) - - it('should handle template with no placeholders', () => { - const processor = new TemplateProcessor() - const template = 'Hello World!' - const result = processor.replacePlaceholders(template, { name: 'Alice' }) - expect(result).toBe('Hello World!') - }) - }) - - describe('generateFallbackTemplate', () => { - it('should generate template without edge-labels when showLabels is true', () => { - const processor = new TemplateProcessor() - const result = processor.generateFallbackTemplate(true) - expect(result).toBe('{{block-architecture}}') - }) - - it('should generate template with edge-labels="none" when showLabels is false', () => { - const processor = new TemplateProcessor() - const result = processor.generateFallbackTemplate(false) - expect(result).toBe('{{block-architecture edge-labels="none"}}') - }) - }) -}) diff --git a/calm-plugins/vscode/src/cli/template-processor.ts b/calm-plugins/vscode/src/cli/template-processor.ts deleted file mode 100644 index e446ed919..000000000 --- a/calm-plugins/vscode/src/cli/template-processor.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { injectWidgetOptionsIntoContent } from '@finos/calm-shared' - -/** - * TemplateProcessor - Pure domain service for template processing logic - * Framework-free service for template content manipulation - */ -export class TemplateProcessor { - constructor() { } - - /** - * Process template content for label display settings - */ - processTemplateForLabels(content: string, showLabels: boolean): string { - if (!showLabels) { - content = content.replace( - new RegExp('\\{\\{block-architecture(\\s*)\\}\\}', 'g'), - '{{block-architecture$1 edge-labels="none"}}' - ) - } - return content - } - - /** - * Apply documentation theme to template content - * Handles both content with and without existing YAML frontmatter - */ - processTemplateForTheme(content: string, theme: string, layoutEngine: string): string { - if (theme === 'auto') { - return content - } - - const widgetOptions = { - 'block-architecture': { - theme: theme, - 'render-node-type-shapes': true, - 'layout-engine': layoutEngine - } - } - - return injectWidgetOptionsIntoContent(content, widgetOptions) - } - - /** - * Get template name based on selection and graph structure - */ - getTemplateNameForSelection(selectedId: string | undefined, graph: any): string { - if (!selectedId) return 'default-template.hbs' - if (selectedId.startsWith('group:')) return 'default-template.hbs' - - if (graph) { - const isNode = graph.nodes?.some((n: any) => n.id === selectedId) - if (isNode) return 'node-focus-template.hbs' - - const edge = graph.edges?.find((x: any) => x.id === selectedId) - if (edge) { - return edge.type === 'flow' ? 'flow-focus-template.hbs' : 'relationship-focus-template.hbs' - } - } - - return 'default-template.hbs' - } - - /** - * Replace template placeholders with actual values - */ - replacePlaceholders(template: string, placeholders: Record): string { - let result = template - - for (const [key, value] of Object.entries(placeholders)) { - const regex = new RegExp(`\\{\\{${key}\\}\\}`, 'g') - result = result.replace(regex, value) - } - - return result - } - - /** - * Generate fallback template content - */ - generateFallbackTemplate(showLabels: boolean): string { - const edge = showLabels ? '' : ' edge-labels="none"' - return `{{block-architecture${edge}}}` - } -} \ No newline at end of file diff --git a/calm-plugins/vscode/src/cli/template-service.spec.ts b/calm-plugins/vscode/src/cli/template-service.spec.ts deleted file mode 100644 index 11fbc55d5..000000000 --- a/calm-plugins/vscode/src/cli/template-service.spec.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { TemplateService } from './template-service' - -vi.mock('vscode', () => ({ - workspace: { - getConfiguration: vi.fn(function () { return { - get: vi.fn(function () { return 'auto'; }) - }; }) - } -})) - -const mockContext = { - extensionUri: { fsPath: '/tmp' } -} as any - -const mockLogger = { - info: vi.fn(), - error: vi.fn() -} as any - -describe('TemplateService', () => { - let service: TemplateService - - beforeEach(() => { - service = new TemplateService(mockContext, mockLogger) - vi.clearAllMocks() - }) - - it('should return relationship-focus template when relationship unique-id matches model (no graph edge)', async () => { - const loadTemplateSpy = vi - .spyOn(service as any, 'loadTemplate') - .mockResolvedValue('REL {{focused-relationship-id}}') - vi - .spyOn((service as any).modelService, 'readModelAsync') - .mockResolvedValue({ - relationships: [{ 'unique-id': 'rel-1' }], - flows: [] - }) - - const result = await service.generateTemplateContent( - 'rel-1', - { nodes: [], edges: [] }, - '/path/to/model.json', - true, - false, - undefined - ) - - expect(loadTemplateSpy).toHaveBeenCalledWith('relationship-focus-template.hbs', true) - expect(result).toBe('REL rel-1') - }) - - it('should prefer relationship match over flow when ids overlap', async () => { - const loadTemplateSpy = vi - .spyOn(service as any, 'loadTemplate') - .mockResolvedValue('REL {{focused-relationship-id}}') - vi - .spyOn((service as any).modelService, 'readModelAsync') - .mockResolvedValue({ - relationships: [{ 'unique-id': 'shared-id' }], - flows: [{ 'unique-id': 'shared-id' }] - }) - - const result = await service.generateTemplateContent( - 'shared-id', - { nodes: [], edges: [] }, - '/path/to/model.json', - true, - false, - undefined - ) - - expect(loadTemplateSpy).toHaveBeenCalledWith('relationship-focus-template.hbs', true) - expect(result).toBe('REL shared-id') - }) - - it('should return flow-focus template when graph edge type is flow', async () => { - const loadTemplateSpy = vi - .spyOn(service as any, 'loadTemplate') - .mockResolvedValue('FLOW {{focused-flow-id}}') - const readModelSpy = vi.spyOn((service as any).modelService, 'readModelAsync') - - const result = await service.generateTemplateContent( - 'flow-edge-1', - { nodes: [], edges: [{ id: 'flow-edge-1', type: 'flow' }] }, - '/path/to/model.json', - true, - false, - undefined - ) - - expect(loadTemplateSpy).toHaveBeenCalledWith('flow-focus-template.hbs', true) - expect(readModelSpy).not.toHaveBeenCalled() - expect(result).toBe('FLOW flow-edge-1') - }) - - it('should return flow-focus template when flow unique-id matches model', async () => { - const loadTemplateSpy = vi - .spyOn(service as any, 'loadTemplate') - .mockResolvedValue('FLOW {{focused-flow-id}}') - vi - .spyOn((service as any).modelService, 'readModelAsync') - .mockResolvedValue({ - relationships: [], - flows: [{ 'unique-id': 'flow-1' }] - }) - - const result = await service.generateTemplateContent( - 'flow-1', - { nodes: [], edges: [] }, - '/path/to/model.json', - true, - false, - undefined - ) - - expect(loadTemplateSpy).toHaveBeenCalledWith('flow-focus-template.hbs', true) - expect(result).toBe('FLOW flow-1') - }) -}) diff --git a/calm-plugins/vscode/src/cli/template-service.ts b/calm-plugins/vscode/src/cli/template-service.ts deleted file mode 100644 index 1d2b5b78b..000000000 --- a/calm-plugins/vscode/src/cli/template-service.ts +++ /dev/null @@ -1,118 +0,0 @@ -import * as fs from 'fs' -import * as path from 'path' -import * as vscode from 'vscode' -import { Logger } from '../core/ports/logger' -import { TemplateProcessor } from './template-processor' -import { ModelService } from '../core/services/model-service' -import { Config } from '../core/ports/config' -import { ConfigService } from '../core/services/config-service' - -/** - * TemplateService - VSCode-specific template file loading service - * Handles file system operations for templates while delegating logic to TemplateProcessor - */ -export class TemplateService { - private processor = new TemplateProcessor() - private modelService = new ModelService() - - constructor(private context: vscode.ExtensionContext, private log: Logger) { } - - /** - * Process template content for label display settings - */ - processTemplateForLabels(content: string, showLabels: boolean): string { - return this.processor.processTemplateForLabels(content, showLabels) - } - - /** - * Load template file from extension templates directory - */ - async loadTemplate(name: string, showLabels: boolean): Promise { - try { - const templatePath = path.join(this.context.extensionUri.fsPath, 'templates', name) - let content = await fs.promises.readFile(templatePath, 'utf8') - content = this.processor.processTemplateForLabels(content, showLabels) - const configService: Config = new ConfigService(); - const docifyTheme = configService.docifyTheme(); - const layoutEngine = configService.previewLayout(); - return this.processor.processTemplateForTheme(content, docifyTheme, layoutEngine); - } catch { - this.log.info(`[preview] loadTemplate: using fallback template for ${name}`) - return this.processor.generateFallbackTemplate(showLabels) - } - } - - /** - * Get template name based on selection - */ - getTemplateNameForSelection(selectedId: string | undefined, graph: any): string { - return this.processor.getTemplateNameForSelection(selectedId, graph) - } - - /** - * Generate template content based on selection and context - */ - async generateTemplateContent( - selectedId: string | undefined, - graph: any, - currentModelPath: string | undefined, - showLabels: boolean, - isTemplateMode: boolean, - architectureFilePath: string | undefined - ): Promise { - if (!selectedId || selectedId.startsWith('group:')) { - return this.loadTemplate('default-template.hbs', showLabels) - } - - if (graph) { - const isNode = graph.nodes?.some((n: any) => n.id === selectedId) - if (isNode) { - const template = await this.loadTemplate('node-focus-template.hbs', showLabels) - return this.processor.replacePlaceholders(template, { - 'focused-node-id': selectedId - }) - } - - const edge = graph.edges?.find((x: any) => x.id === selectedId) - if (edge) { - if (edge.type === 'flow') { - const template = await this.loadTemplate('flow-focus-template.hbs', showLabels) - return this.processor.replacePlaceholders(template, { - 'focused-flow-id': selectedId - }) - } - - const template = await this.loadTemplate('relationship-focus-template.hbs', showLabels) - return this.processor.replacePlaceholders(template, { - 'focused-relationship-id': selectedId - }) - } - } - - const modelFile = isTemplateMode && architectureFilePath ? architectureFilePath : currentModelPath - if (modelFile) { - try { - const data = await this.modelService.readModelAsync(modelFile) - - // Check for relationships by original unique-id (handles all relationship types: connects, interacts, deployed-in, composed-of) - if (data?.relationships?.find((r: any) => r['unique-id'] === selectedId)) { - const template = await this.loadTemplate('relationship-focus-template.hbs', showLabels) - return this.processor.replacePlaceholders(template, { - 'focused-relationship-id': selectedId - }) - } - - if (data?.flows?.find((f: any) => f['unique-id'] === selectedId)) { - const template = await this.loadTemplate('flow-focus-template.hbs', showLabels) - return this.processor.replacePlaceholders(template, { - 'focused-flow-id': selectedId - }) - } - } catch (e) { - this.log.info(`[preview] generateTemplateContent: error reading modelFile ${String(e)}`) - } - } - - return this.loadTemplate('default-template.hbs', showLabels) - } -} diff --git a/calm-plugins/vscode/src/commands/clear-tree-view-search-command.ts b/calm-plugins/vscode/src/commands/clear-tree-view-search-command.ts deleted file mode 100644 index d4372140a..000000000 --- a/calm-plugins/vscode/src/commands/clear-tree-view-search-command.ts +++ /dev/null @@ -1,8 +0,0 @@ -import * as vscode from 'vscode' -import type { ApplicationStoreApi } from '../application-store' - -export function createClearTreeViewSearchCommand(store: ApplicationStoreApi) { - return vscode.commands.registerCommand('calm.clearTreeViewSearch', () => { - store.getState().setSearchFilter('') - }) -} diff --git a/calm-plugins/vscode/src/commands/command-registrar.ts b/calm-plugins/vscode/src/commands/command-registrar.ts deleted file mode 100644 index fb1ad8098..000000000 --- a/calm-plugins/vscode/src/commands/command-registrar.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { ApplicationStoreApi } from '../application-store' -import type { NavigationService } from '../core/services/navigation-service' -import { createOpenPreviewCommand } from './open-preview-command' -import { createSearchTreeViewCommand } from './search-tree-view-command' -import { createClearTreeViewSearchCommand } from './clear-tree-view-search-command' -import { createCreateWebsiteCommand } from './create-website/create-website-command' -import { createNavigateToArchitectureCommand } from './navigate-to-architecture-command' -import * as vscode from 'vscode' - -export class CommandRegistrar { - constructor( - private context: vscode.ExtensionContext, - private store: ApplicationStoreApi, - private navigation: NavigationService - ) {} - - registerAll() { - const commands = [ - createOpenPreviewCommand(this.store), - createSearchTreeViewCommand(this.store), - createClearTreeViewSearchCommand(this.store), - createCreateWebsiteCommand(this.context), - createNavigateToArchitectureCommand(this.navigation) - ] - - commands.forEach(disposable => { - this.context.subscriptions.push(disposable) - }) - } -} diff --git a/calm-plugins/vscode/src/commands/create-website/architecture-path-resolver.spec.ts b/calm-plugins/vscode/src/commands/create-website/architecture-path-resolver.spec.ts deleted file mode 100644 index 8a4579913..000000000 --- a/calm-plugins/vscode/src/commands/create-website/architecture-path-resolver.spec.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { describe, it, expect, vi, beforeEach, Mock } from 'vitest' -import * as vscode from 'vscode' -import { ArchitecturePathResolver } from './architecture-path-resolver' -import { createMockWindow } from './test-utils' - -// Mock @finos/calm-shared -vi.mock('@finos/calm-shared', () => ({ - hasArchitectureExtension: vi.fn(function (filePath: string) { return /\.json$/i.test(filePath); }) -})) - -// Mock vscode module -vi.mock('vscode', () => ({ - commands: { - registerCommand: vi.fn(function (command: string, callback: Function) { - return { command, callback, dispose: vi.fn() } - }), - executeCommand: vi.fn() - }, - window: { - activeTextEditor: undefined, - showOpenDialog: vi.fn(), - showInputBox: vi.fn(), - showInformationMessage: vi.fn(), - showErrorMessage: vi.fn(), - createQuickPick: vi.fn(), - withProgress: vi.fn(), - createTerminal: vi.fn() - }, - Uri: { - file: (path: string) => ({ fsPath: path, scheme: 'file' }) - }, - ProgressLocation: { - Notification: 15 - } -})) - -describe('ArchitecturePathResolver', () => { - let mockWindow: typeof vscode.window - let resolver: ArchitecturePathResolver - - beforeEach(() => { - mockWindow = createMockWindow() - resolver = new ArchitecturePathResolver(mockWindow) - }) - - it('should return fsPath when URI is provided', async () => { - const uri = { fsPath: '/test/arch.json', scheme: 'file' } as vscode.Uri - - const result = await resolver.resolve(uri) - - expect(result).toBe('/test/arch.json') - }) - - it('should return active editor path for valid architecture file', async () => { - (mockWindow as any).activeTextEditor = { - document: { - uri: { fsPath: '/test/arch.json', scheme: 'file' } - } - } - - const result = await resolver.resolve() - - expect(result).toBe('/test/arch.json') - }) - - it('should not return active editor path for non-architecture file', async () => { - (mockWindow as any).activeTextEditor = { - document: { - uri: { fsPath: '/test/file.txt', scheme: 'file' } - } - } - ;(mockWindow.showOpenDialog as Mock).mockResolvedValue(undefined) - - const result = await resolver.resolve() - - expect(result).toBeUndefined() - expect(mockWindow.showOpenDialog).toHaveBeenCalled() - }) - - it('should show open dialog when no active editor', async () => { - const selectedFile = { fsPath: '/selected/arch.json' } - ;(mockWindow.showOpenDialog as Mock).mockResolvedValue([selectedFile]) - - const result = await resolver.resolve() - - expect(result).toBe('/selected/arch.json') - expect(mockWindow.showOpenDialog).toHaveBeenCalledWith({ - canSelectFiles: true, - canSelectFolders: false, - canSelectMany: false, - filters: { 'Architecture Files': ['json'] }, - title: 'Select CALM Architecture File' - }) - }) - - it('should return undefined when dialog is cancelled', async () => { - ;(mockWindow.showOpenDialog as Mock).mockResolvedValue(undefined) - - const result = await resolver.resolve() - - expect(result).toBeUndefined() - }) -}) diff --git a/calm-plugins/vscode/src/commands/create-website/architecture-path-resolver.ts b/calm-plugins/vscode/src/commands/create-website/architecture-path-resolver.ts deleted file mode 100644 index e0c119821..000000000 --- a/calm-plugins/vscode/src/commands/create-website/architecture-path-resolver.ts +++ /dev/null @@ -1,27 +0,0 @@ -import * as vscode from 'vscode' -import { hasArchitectureExtension } from '@finos/calm-shared' - -export class ArchitecturePathResolver { - constructor(private readonly window: typeof vscode.window) {} - - async resolve(uri?: vscode.Uri): Promise { - if (uri) { - return uri.fsPath - } - - const activeEditor = this.window.activeTextEditor - if (activeEditor && hasArchitectureExtension(activeEditor.document.uri.fsPath)) { - return activeEditor.document.uri.fsPath - } - - const files = await this.window.showOpenDialog({ - canSelectFiles: true, - canSelectFolders: false, - canSelectMany: false, - filters: { 'Architecture Files': ['json'] }, - title: 'Select CALM Architecture File' - }) - - return files?.[0]?.fsPath - } -} diff --git a/calm-plugins/vscode/src/commands/create-website/create-website-command.spec.ts b/calm-plugins/vscode/src/commands/create-website/create-website-command.spec.ts deleted file mode 100644 index de153f0af..000000000 --- a/calm-plugins/vscode/src/commands/create-website/create-website-command.spec.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { describe, it, expect, vi, beforeEach, Mock } from 'vitest' -import * as vscode from 'vscode' -import { - CreateWebsiteCommandHandler, - createCreateWebsiteCommand -} from './create-website-command' -import { FormQuickPickItem, Dependencies } from './types' -import { - createMockDependencies, - createMockDocifier -} from './test-utils' - -// Mock @finos/calm-shared -vi.mock('@finos/calm-shared', () => ({ - hasArchitectureExtension: vi.fn(function (filePath: string) { return /\.json$/i.test(filePath); }) -})) - -// Mock vscode module -vi.mock('vscode', () => ({ - commands: { - registerCommand: vi.fn(function (command: string, callback: Function) { - return { command, callback, dispose: vi.fn() } - }), - executeCommand: vi.fn() - }, - window: { - activeTextEditor: undefined, - showOpenDialog: vi.fn(), - showInputBox: vi.fn(), - showInformationMessage: vi.fn(), - showErrorMessage: vi.fn(), - createQuickPick: vi.fn(), - withProgress: vi.fn(), - createTerminal: vi.fn() - }, - Uri: { - file: (path: string) => ({ fsPath: path, scheme: 'file' }) - }, - ProgressLocation: { - Notification: 15 - } -})) - -describe('CreateWebsiteCommandHandler', () => { - let deps: Dependencies - let handler: CreateWebsiteCommandHandler - let mockQuickPick: any - let acceptHandler: Function - let hideHandler: Function - - beforeEach(() => { - deps = createMockDependencies() - - // Create a sophisticated mock QuickPick - mockQuickPick = { - title: '', - placeholder: '', - canSelectMany: false, - items: [] as FormQuickPickItem[], - selectedItems: [] as FormQuickPickItem[], - show: vi.fn(), - hide: vi.fn(), - dispose: vi.fn(), - onDidAccept: vi.fn(function (handler: Function) { - acceptHandler = handler - return { dispose: vi.fn() } - }), - onDidHide: vi.fn(function (handler: Function) { - hideHandler = handler - return { dispose: vi.fn() } - }) - } - - ;(deps.window.createQuickPick as Mock).mockReturnValue(mockQuickPick) - handler = new CreateWebsiteCommandHandler(deps) - }) - - it('should exit early when no architecture path resolved', async () => { - ;(deps.window.showOpenDialog as Mock).mockResolvedValue(undefined) - - await handler.execute() - - expect(deps.window.createQuickPick).not.toHaveBeenCalled() - }) - - it('should use URI path when provided', async () => { - const uri = { fsPath: '/test/arch.json', scheme: 'file' } as vscode.Uri - - // Simulate immediate hide (cancel) - setTimeout(() => { - hideHandler() - }, 0) - - await handler.execute(uri) - - expect(deps.window.showOpenDialog).not.toHaveBeenCalled() - expect(deps.window.createQuickPick).toHaveBeenCalled() - }) - - it('should exit early when form is cancelled', async () => { - const uri = { fsPath: '/test/arch.json', scheme: 'file' } as vscode.Uri - - // Simulate cancel - setTimeout(() => { - hideHandler() - }, 0) - - await handler.execute(uri) - - expect(deps.window.withProgress).not.toHaveBeenCalled() - }) - - it('should run full flow when form is completed', async () => { - const uri = { fsPath: '/test/arch.json', scheme: 'file' } as vscode.Uri - const mockDocifier = createMockDocifier() - ;(deps.docifierFactory.create as Mock).mockReturnValue(mockDocifier) - ;(deps.window.withProgress as Mock).mockImplementation(async (options, task) => { - return await task({ report: vi.fn() }) - }) - ;(deps.window.showInformationMessage as Mock).mockResolvedValue(undefined) - - // Simulate selecting create - setTimeout(async () => { - mockQuickPick.selectedItems = [{ id: 'create' }] - await acceptHandler() - }, 0) - - await handler.execute(uri) - - expect(deps.docifierFactory.create).toHaveBeenCalled() - expect(mockDocifier.docify).toHaveBeenCalled() - expect(deps.window.showInformationMessage).toHaveBeenCalledWith( - 'Website scaffold created at: /test/website', - 'Open Folder', - 'Open in Terminal' - ) - }) - - it('should handle Open Folder action after creation', async () => { - const uri = { fsPath: '/test/arch.json', scheme: 'file' } as vscode.Uri - const mockDocifier = createMockDocifier() - ;(deps.docifierFactory.create as Mock).mockReturnValue(mockDocifier) - ;(deps.window.withProgress as Mock).mockImplementation(async (options, task) => { - return await task({ report: vi.fn() }) - }) - ;(deps.window.showInformationMessage as Mock).mockResolvedValue('Open Folder') - - setTimeout(async () => { - mockQuickPick.selectedItems = [{ id: 'create' }] - await acceptHandler() - }, 0) - - await handler.execute(uri) - - expect(deps.commands.executeCommand).toHaveBeenCalledWith( - 'vscode.openFolder', - expect.objectContaining({ fsPath: '/test/website' }), - { forceNewWindow: true } - ) - }) - - it('should handle Open in Terminal action after creation', async () => { - const uri = { fsPath: '/test/arch.json', scheme: 'file' } as vscode.Uri - const mockDocifier = createMockDocifier() - const mockTerminal = { show: vi.fn(), sendText: vi.fn() } - ;(deps.docifierFactory.create as Mock).mockReturnValue(mockDocifier) - ;(deps.window.withProgress as Mock).mockImplementation(async (options, task) => { - return await task({ report: vi.fn() }) - }) - ;(deps.window.showInformationMessage as Mock).mockResolvedValue('Open in Terminal') - ;(deps.window.createTerminal as Mock).mockReturnValue(mockTerminal) - - setTimeout(async () => { - mockQuickPick.selectedItems = [{ id: 'create' }] - await acceptHandler() - }, 0) - - await handler.execute(uri) - - expect(deps.window.createTerminal).toHaveBeenCalledWith({ - name: 'CALM Website', - cwd: '/test/website' - }) - expect(mockTerminal.show).toHaveBeenCalled() - expect(mockTerminal.sendText).toHaveBeenCalledWith('npm install && npm start') - }) -}) - -describe('createCreateWebsiteCommand', () => { - let deps: Dependencies - - beforeEach(() => { - deps = createMockDependencies() - }) - - it('should register calm.createWebsite command when passed Dependencies', () => { - createCreateWebsiteCommand(deps) - - expect(deps.commands.registerCommand).toHaveBeenCalledWith( - 'calm.createWebsite', - expect.any(Function) - ) - }) - - it('should show error message when command throws', async () => { - const mockError = new Error('Test error') - ;(deps.window.showOpenDialog as Mock).mockRejectedValue(mockError) - - createCreateWebsiteCommand(deps) - const callback = (deps.commands.registerCommand as Mock).mock.calls[0][1] - - await callback() - - expect(deps.window.showErrorMessage).toHaveBeenCalledWith('Failed to create website: Test error') - }) - - it('should handle non-Error objects in catch block', async () => { - ;(deps.window.showOpenDialog as Mock).mockRejectedValue('string error') - - createCreateWebsiteCommand(deps) - const callback = (deps.commands.registerCommand as Mock).mock.calls[0][1] - - await callback() - - expect(deps.window.showErrorMessage).toHaveBeenCalledWith('Failed to create website: Unknown error') - }) -}) diff --git a/calm-plugins/vscode/src/commands/create-website/create-website-command.ts b/calm-plugins/vscode/src/commands/create-website/create-website-command.ts deleted file mode 100644 index 82113a03d..000000000 --- a/calm-plugins/vscode/src/commands/create-website/create-website-command.ts +++ /dev/null @@ -1,68 +0,0 @@ -import * as vscode from 'vscode' -import * as fs from 'fs' -import { DocifierFactory } from '../../cli/docifier-factory' -import { Dependencies } from './types' -import { ArchitecturePathResolver } from './architecture-path-resolver' -import { WebsiteFormController } from './website-form-controller' -import { DocifyScaffoldRunner } from './docify-scaffold-runner' -import { PostCreationHandler } from './post-creation-handler' -export * from './types' -export { ArchitecturePathResolver } from './architecture-path-resolver' -export { WebsiteFormController } from './website-form-controller' -export { DocifyScaffoldRunner } from './docify-scaffold-runner' -export { PostCreationHandler } from './post-creation-handler' -export * from './form-item-factory' - -export class CreateWebsiteCommandHandler { - private readonly pathResolver: ArchitecturePathResolver - private readonly formController: WebsiteFormController - private readonly scaffoldRunner: DocifyScaffoldRunner - private readonly postCreationHandler: PostCreationHandler - - constructor(deps: Dependencies) { - this.pathResolver = new ArchitecturePathResolver(deps.window) - this.formController = new WebsiteFormController(deps.window) - this.scaffoldRunner = new DocifyScaffoldRunner(deps.window, deps.fs, deps.docifierFactory, deps.extensionPath) - this.postCreationHandler = new PostCreationHandler(deps.window, deps.commands) - } - - async execute(uri?: vscode.Uri): Promise { - const architecturePath = await this.pathResolver.resolve(uri) - if (!architecturePath) return - - const formData = await this.formController.show(architecturePath) - if (!formData) return - - await this.scaffoldRunner.run(formData) - await this.postCreationHandler.handle(formData.outputDir) - } -} - -function createDefaultDependencies(context: vscode.ExtensionContext): Dependencies { - return { - window: vscode.window, - commands: vscode.commands, - fs: fs, - docifierFactory: new DocifierFactory(), - extensionPath: context.extensionPath - } -} - -export function createCreateWebsiteCommand( - contextOrDeps: vscode.ExtensionContext | Dependencies -): vscode.Disposable { - const deps = 'extensionPath' in contextOrDeps && 'window' in contextOrDeps && 'docifierFactory' in contextOrDeps - ? contextOrDeps as Dependencies - : createDefaultDependencies(contextOrDeps as vscode.ExtensionContext) - - const handler = new CreateWebsiteCommandHandler(deps) - - return deps.commands.registerCommand('calm.createWebsite', async (uri?: vscode.Uri) => { - try { - await handler.execute(uri) - } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error' - deps.window.showErrorMessage(`Failed to create website: ${message}`) - } - }) -} diff --git a/calm-plugins/vscode/src/commands/create-website/docify-scaffold-runner.spec.ts b/calm-plugins/vscode/src/commands/create-website/docify-scaffold-runner.spec.ts deleted file mode 100644 index a71d883a7..000000000 --- a/calm-plugins/vscode/src/commands/create-website/docify-scaffold-runner.spec.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { describe, it, expect, vi, beforeEach, Mock } from 'vitest' -import * as vscode from 'vscode' -import * as fs from 'fs' -import { DocifyScaffoldRunner } from './docify-scaffold-runner' -import { WebsiteFormData } from './types' -import { IDocifierFactory, IDocifier } from '../../cli/docifier-factory' -import { createMockWindow, createMockFs, createMockDocifier, createMockDocifierFactory } from './test-utils' - -// Mock vscode module -vi.mock('vscode', () => ({ - commands: { - registerCommand: vi.fn(function (command: string, callback: Function) { - return { command, callback, dispose: vi.fn() } - }), - executeCommand: vi.fn() - }, - window: { - activeTextEditor: undefined, - showOpenDialog: vi.fn(), - showInputBox: vi.fn(), - showInformationMessage: vi.fn(), - showErrorMessage: vi.fn(), - createQuickPick: vi.fn(), - withProgress: vi.fn(), - createTerminal: vi.fn() - }, - Uri: { - file: (path: string) => ({ fsPath: path, scheme: 'file' }) - }, - ProgressLocation: { - Notification: 15 - } -})) - -describe('DocifyScaffoldRunner', () => { - let mockWindow: typeof vscode.window - let mockFs: typeof fs - let mockDocifierFactory: IDocifierFactory - let mockDocifier: IDocifier - let runner: DocifyScaffoldRunner - - beforeEach(() => { - mockWindow = createMockWindow() - mockFs = createMockFs() - mockDocifier = createMockDocifier() - mockDocifierFactory = createMockDocifierFactory(mockDocifier) - runner = new DocifyScaffoldRunner(mockWindow, mockFs, mockDocifierFactory, '/extension') - }) - - it('should run docify with correct parameters', async () => { - const formData: WebsiteFormData = { - architecturePath: '/test/arch.json', - outputDir: '/test/output', - mappingFilePath: '/test/mapping.json', - templateBundlePath: '/test/template' - } - - ;(mockWindow.withProgress as Mock).mockImplementation(async (options, task) => { - const mockProgress = { report: vi.fn() } - return await task(mockProgress) - }) - - await runner.run(formData) - - expect(mockDocifierFactory.create).toHaveBeenCalledWith({ - mode: 'USER_PROVIDED', - inputPath: '/test/arch.json', - outputPath: '/test/output', - urlMappingPath: '/test/mapping.json', - templateProcessingMode: 'bundle', - templatePath: '/test/template', - clearOutputDirectory: false, - scaffoldOnly: true - }) - expect(mockDocifier.docify).toHaveBeenCalled() - }) - - it('should use default template when not provided', async () => { - const formData: WebsiteFormData = { - architecturePath: '/test/arch.json', - outputDir: '/test/output' - } - - ;(mockWindow.withProgress as Mock).mockImplementation(async (options, task) => { - const mockProgress = { report: vi.fn() } - return await task(mockProgress) - }) - - await runner.run(formData) - - expect(mockDocifierFactory.create).toHaveBeenCalledWith({ - mode: 'USER_PROVIDED', - inputPath: '/test/arch.json', - outputPath: '/test/output', - urlMappingPath: undefined, - templateProcessingMode: 'bundle', - templatePath: '/extension/dist/template-bundles/docusaurus', - clearOutputDirectory: false, - scaffoldOnly: true - }) - }) - - it('should throw error when output directory not created', async () => { - const formData: WebsiteFormData = { - architecturePath: '/test/arch.json', - outputDir: '/test/output' - } - - ;(mockFs.existsSync as Mock).mockReturnValue(false) - ;(mockWindow.withProgress as Mock).mockImplementation(async (options, task) => { - const mockProgress = { report: vi.fn() } - return await task(mockProgress) - }) - - await expect(runner.run(formData)).rejects.toThrow('Output directory was not created') - }) - - it('should report progress during execution', async () => { - const formData: WebsiteFormData = { - architecturePath: '/test/arch.json', - outputDir: '/test/output' - } - - const mockProgress = { report: vi.fn() } - ;(mockWindow.withProgress as Mock).mockImplementation(async (options, task) => { - return await task(mockProgress) - }) - - await runner.run(formData) - - expect(mockProgress.report).toHaveBeenCalledWith({ increment: 0, message: 'Initializing...' }) - expect(mockProgress.report).toHaveBeenCalledWith({ increment: 30, message: 'Running docify scaffold...' }) - expect(mockProgress.report).toHaveBeenCalledWith({ increment: 70, message: 'Finalizing...' }) - expect(mockProgress.report).toHaveBeenCalledWith({ increment: 100, message: 'Done!' }) - }) -}) diff --git a/calm-plugins/vscode/src/commands/create-website/docify-scaffold-runner.ts b/calm-plugins/vscode/src/commands/create-website/docify-scaffold-runner.ts deleted file mode 100644 index 202c9eea1..000000000 --- a/calm-plugins/vscode/src/commands/create-website/docify-scaffold-runner.ts +++ /dev/null @@ -1,52 +0,0 @@ -import * as vscode from 'vscode' -import * as fs from 'fs' -import { IDocifierFactory } from '../../cli/docifier-factory' -import { WebsiteFormData } from './types' -import { getTemplateBundlePath } from './form-item-factory' - -export class DocifyScaffoldRunner { - constructor( - private readonly window: typeof vscode.window, - private readonly fileSystem: typeof fs, - private readonly docifierFactory: IDocifierFactory, - private readonly extensionPath: string - ) {} - - async run(formData: WebsiteFormData): Promise { - await this.window.withProgress( - { - location: vscode.ProgressLocation.Notification, - title: 'Creating website scaffold...', - cancellable: false - }, - async (progress) => { - progress.report({ increment: 0, message: 'Initializing...' }) - - const templateBundlePath = getTemplateBundlePath(formData, this.extensionPath) - - progress.report({ increment: 30, message: 'Running docify scaffold...' }) - - const docifier = this.docifierFactory.create({ - mode: 'USER_PROVIDED', - inputPath: formData.architecturePath, - outputPath: formData.outputDir, - urlMappingPath: formData.mappingFilePath, - templateProcessingMode: 'bundle', - templatePath: templateBundlePath, - clearOutputDirectory: false, - scaffoldOnly: true - }) - - await docifier.docify() - - progress.report({ increment: 70, message: 'Finalizing...' }) - - if (!this.fileSystem.existsSync(formData.outputDir)) { - throw new Error('Output directory was not created') - } - - progress.report({ increment: 100, message: 'Done!' }) - } - ) - } -} diff --git a/calm-plugins/vscode/src/commands/create-website/form-item-factory.spec.ts b/calm-plugins/vscode/src/commands/create-website/form-item-factory.spec.ts deleted file mode 100644 index 65fa79e41..000000000 --- a/calm-plugins/vscode/src/commands/create-website/form-item-factory.spec.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { - getDefaultOutputDir, - createFormItems, - validateOutputDirectory, - getTemplateBundlePath -} from './form-item-factory' -import { WebsiteFormData } from './types' - -describe('form-item-factory', () => { - describe('getDefaultOutputDir', () => { - it('should return website subdirectory of architecture file directory', () => { - const result = getDefaultOutputDir('/project/architecture/arch.json') - expect(result).toBe('/project/architecture/website') - }) - - it('should handle root level files', () => { - const result = getDefaultOutputDir('/arch.json') - expect(result).toBe('/website') - }) - }) - - describe('validateOutputDirectory', () => { - it('should return undefined for valid directory', () => { - expect(validateOutputDirectory('/valid/path')).toBeUndefined() - }) - - it('should return error message for empty string', () => { - expect(validateOutputDirectory('')).toBe('Output directory is required') - }) - - it('should return error message for whitespace only', () => { - expect(validateOutputDirectory(' ')).toBe('Output directory is required') - }) - }) - - describe('getTemplateBundlePath', () => { - it('should return custom template path when provided', () => { - const formData: WebsiteFormData = { - architecturePath: '/test/arch.json', - outputDir: '/test/output', - templateBundlePath: '/custom/template' - } - expect(getTemplateBundlePath(formData, '/extension')).toBe('/custom/template') - }) - - it('should return default template path when not provided', () => { - const formData: WebsiteFormData = { - architecturePath: '/test/arch.json', - outputDir: '/test/output' - } - expect(getTemplateBundlePath(formData, '/extension')).toBe('/extension/dist/template-bundles/docusaurus') - }) - }) - - describe('createFormItems', () => { - it('should create form items with default values', () => { - const formState: WebsiteFormData = { - architecturePath: '/test/arch.json', - outputDir: '/test/website' - } - - const items = createFormItems(formState) - - expect(items).toHaveLength(4) - expect(items[0].id).toBe('outputDir') - expect(items[0].description).toBe('/test/website') - expect(items[1].id).toBe('mappingFile') - expect(items[1].description).toBe('(none)') - expect(items[2].id).toBe('templateBundle') - expect(items[2].description).toBe('(default)') - expect(items[3].id).toBe('create') - }) - - it('should show mapping file name when provided', () => { - const formState: WebsiteFormData = { - architecturePath: '/test/arch.json', - outputDir: '/test/website', - mappingFilePath: '/path/to/mapping.json' - } - - const items = createFormItems(formState) - - expect(items[1].description).toBe('mapping.json') - expect(items[1].detail).toBe('/path/to/mapping.json') - }) - - it('should show template bundle name when provided', () => { - const formState: WebsiteFormData = { - architecturePath: '/test/arch.json', - outputDir: '/test/website', - templateBundlePath: '/path/to/custom-bundle' - } - - const items = createFormItems(formState) - - expect(items[2].description).toBe('custom-bundle') - expect(items[2].detail).toBe('/path/to/custom-bundle') - }) - }) -}) - diff --git a/calm-plugins/vscode/src/commands/create-website/form-item-factory.ts b/calm-plugins/vscode/src/commands/create-website/form-item-factory.ts deleted file mode 100644 index dbf2e45da..000000000 --- a/calm-plugins/vscode/src/commands/create-website/form-item-factory.ts +++ /dev/null @@ -1,44 +0,0 @@ -import * as path from 'path' -import { WebsiteFormData, FormQuickPickItem } from './types' - -export function getDefaultOutputDir(architecturePath: string): string { - return path.join(path.dirname(architecturePath), 'website') -} - -export function createFormItems(formState: WebsiteFormData): FormQuickPickItem[] { - return [ - { - id: 'outputDir', - label: '$(folder) Output Directory', - description: formState.outputDir, - detail: 'Click to change the output directory' - }, - { - id: 'mappingFile', - label: '$(file) URL Mapping File', - description: formState.mappingFilePath ? path.basename(formState.mappingFilePath) : '(none)', - detail: formState.mappingFilePath ?? 'Optional: Click to select a URL mapping file' - }, - { - id: 'templateBundle', - label: '$(package) Template Bundle', - description: formState.templateBundlePath ? path.basename(formState.templateBundlePath) : '(default)', - detail: formState.templateBundlePath ?? 'Optional: Click to select a custom template bundle' - }, - { - id: 'create', - label: '$(check) Create Website', - description: '', - detail: 'Generate the website scaffold with the configured options' - } - ] -} - -export function validateOutputDirectory(value: string): string | undefined { - return value.trim() ? undefined : 'Output directory is required' -} - -export function getTemplateBundlePath(formData: WebsiteFormData, extensionPath: string): string { - return formData.templateBundlePath ?? path.join(extensionPath, 'dist', 'template-bundles', 'docusaurus') -} - diff --git a/calm-plugins/vscode/src/commands/create-website/post-creation-handler.spec.ts b/calm-plugins/vscode/src/commands/create-website/post-creation-handler.spec.ts deleted file mode 100644 index 5f1b32a2e..000000000 --- a/calm-plugins/vscode/src/commands/create-website/post-creation-handler.spec.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { describe, it, expect, vi, beforeEach, Mock } from 'vitest' -import * as vscode from 'vscode' -import { PostCreationHandler } from './post-creation-handler' -import { createMockWindow, createMockCommands } from './test-utils' - -// Mock vscode module -vi.mock('vscode', () => ({ - commands: { - registerCommand: vi.fn(function (command: string, callback: Function) { - return { command, callback, dispose: vi.fn() } - }), - executeCommand: vi.fn() - }, - window: { - activeTextEditor: undefined, - showOpenDialog: vi.fn(), - showInputBox: vi.fn(), - showInformationMessage: vi.fn(), - showErrorMessage: vi.fn(), - createQuickPick: vi.fn(), - withProgress: vi.fn(), - createTerminal: vi.fn() - }, - Uri: { - file: (path: string) => ({ fsPath: path, scheme: 'file' }) - }, - ProgressLocation: { - Notification: 15 - } -})) - -describe('PostCreationHandler', () => { - let mockWindow: typeof vscode.window - let mockCommands: typeof vscode.commands - let handler: PostCreationHandler - - beforeEach(() => { - mockWindow = createMockWindow() - mockCommands = createMockCommands() - handler = new PostCreationHandler(mockWindow, mockCommands) - }) - - it('should show success message with options', async () => { - ;(mockWindow.showInformationMessage as Mock).mockResolvedValue(undefined) - - await handler.handle('/test/output') - - expect(mockWindow.showInformationMessage).toHaveBeenCalledWith( - 'Website scaffold created at: /test/output', - 'Open Folder', - 'Open in Terminal' - ) - }) - - it('should open folder when "Open Folder" is selected', async () => { - ;(mockWindow.showInformationMessage as Mock).mockResolvedValue('Open Folder') - - await handler.handle('/test/output') - - expect(mockCommands.executeCommand).toHaveBeenCalledWith( - 'vscode.openFolder', - expect.objectContaining({ fsPath: '/test/output' }), - { forceNewWindow: true } - ) - }) - - it('should open terminal when "Open in Terminal" is selected', async () => { - const mockTerminal = { show: vi.fn(), sendText: vi.fn() } - ;(mockWindow.showInformationMessage as Mock).mockResolvedValue('Open in Terminal') - ;(mockWindow.createTerminal as Mock).mockReturnValue(mockTerminal) - - await handler.handle('/test/output') - - expect(mockWindow.createTerminal).toHaveBeenCalledWith({ - name: 'CALM Website', - cwd: '/test/output' - }) - expect(mockTerminal.show).toHaveBeenCalled() - expect(mockTerminal.sendText).toHaveBeenCalledWith('npm install && npm start') - }) - - it('should do nothing when dialog is dismissed', async () => { - ;(mockWindow.showInformationMessage as Mock).mockResolvedValue(undefined) - - await handler.handle('/test/output') - - expect(mockCommands.executeCommand).not.toHaveBeenCalled() - expect(mockWindow.createTerminal).not.toHaveBeenCalled() - }) -}) diff --git a/calm-plugins/vscode/src/commands/create-website/post-creation-handler.ts b/calm-plugins/vscode/src/commands/create-website/post-creation-handler.ts deleted file mode 100644 index 88d8d3365..000000000 --- a/calm-plugins/vscode/src/commands/create-website/post-creation-handler.ts +++ /dev/null @@ -1,25 +0,0 @@ -import * as vscode from 'vscode' - -export class PostCreationHandler { - constructor( - private readonly window: typeof vscode.window, - private readonly commands: typeof vscode.commands - ) {} - - async handle(outputDir: string): Promise { - const action = await this.window.showInformationMessage( - `Website scaffold created at: ${outputDir}`, - 'Open Folder', - 'Open in Terminal' - ) - - if (action === 'Open Folder') { - await this.commands.executeCommand('vscode.openFolder', vscode.Uri.file(outputDir), { forceNewWindow: true }) - } else if (action === 'Open in Terminal') { - const terminal = this.window.createTerminal({ name: 'CALM Website', cwd: outputDir }) - terminal.show() - terminal.sendText('npm install && npm start') - } - } -} - diff --git a/calm-plugins/vscode/src/commands/create-website/test-utils.ts b/calm-plugins/vscode/src/commands/create-website/test-utils.ts deleted file mode 100644 index 3191dec76..000000000 --- a/calm-plugins/vscode/src/commands/create-website/test-utils.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { vi } from 'vitest' -import * as vscode from 'vscode' -import * as fs from 'fs' -import { Dependencies } from './types' -import { IDocifierFactory, IDocifier } from '../../cli/docifier-factory' - -export function createMockWindow(): typeof vscode.window { - return { - activeTextEditor: undefined, - showOpenDialog: vi.fn(), - showInputBox: vi.fn(), - showInformationMessage: vi.fn(), - showErrorMessage: vi.fn(), - createQuickPick: vi.fn(), - withProgress: vi.fn(), - createTerminal: vi.fn() - } as unknown as typeof vscode.window -} - -export function createMockCommands(): typeof vscode.commands { - return { - registerCommand: vi.fn((command: string, callback: Function) => ({ - command, - callback, - dispose: vi.fn() - })), - executeCommand: vi.fn() - } as unknown as typeof vscode.commands -} - -export function createMockFs(): typeof fs { - return { - existsSync: vi.fn(() => true), - readFileSync: vi.fn(() => ''), - writeFileSync: vi.fn(), - mkdirSync: vi.fn() - } as unknown as typeof fs -} - -export function createMockDocifier(): IDocifier { - return { - docify: vi.fn().mockResolvedValue(undefined) - } -} - -export function createMockDocifierFactory(mockDocifier?: IDocifier): IDocifierFactory { - return { - create: vi.fn().mockReturnValue(mockDocifier ?? createMockDocifier()) - } -} - -export function createMockDependencies(overrides?: Partial): Dependencies { - return { - window: createMockWindow(), - commands: createMockCommands(), - fs: createMockFs(), - docifierFactory: createMockDocifierFactory(), - extensionPath: '/test/extension', - ...overrides - } -} \ No newline at end of file diff --git a/calm-plugins/vscode/src/commands/create-website/types.ts b/calm-plugins/vscode/src/commands/create-website/types.ts deleted file mode 100644 index 5d85e34d0..000000000 --- a/calm-plugins/vscode/src/commands/create-website/types.ts +++ /dev/null @@ -1,22 +0,0 @@ -import * as vscode from 'vscode' -import * as fs from 'fs' -import { IDocifierFactory } from '../../cli/docifier-factory' - -export interface WebsiteFormData { - architecturePath: string - outputDir: string - mappingFilePath?: string - templateBundlePath?: string -} - -export interface FormQuickPickItem extends vscode.QuickPickItem { - id: 'outputDir' | 'mappingFile' | 'templateBundle' | 'create' -} - -export interface Dependencies { - window: typeof vscode.window - commands: typeof vscode.commands - fs: typeof fs - docifierFactory: IDocifierFactory - extensionPath: string -} diff --git a/calm-plugins/vscode/src/commands/create-website/website-form-controller.spec.ts b/calm-plugins/vscode/src/commands/create-website/website-form-controller.spec.ts deleted file mode 100644 index a4e6ab480..000000000 --- a/calm-plugins/vscode/src/commands/create-website/website-form-controller.spec.ts +++ /dev/null @@ -1,311 +0,0 @@ -import { describe, it, expect, vi, beforeEach, Mock } from 'vitest' -import * as vscode from 'vscode' -import { WebsiteFormController } from './website-form-controller' -import { validateOutputDirectory } from './form-item-factory' -import { FormQuickPickItem } from './types' -import { createMockWindow } from './test-utils' - -// Mock vscode module -vi.mock('vscode', () => ({ - commands: { - registerCommand: vi.fn(function (command: string, callback: Function) { - return { command, callback, dispose: vi.fn() } - }), - executeCommand: vi.fn() - }, - window: { - activeTextEditor: undefined, - showOpenDialog: vi.fn(), - showInputBox: vi.fn(), - showInformationMessage: vi.fn(), - showErrorMessage: vi.fn(), - createQuickPick: vi.fn(), - withProgress: vi.fn(), - createTerminal: vi.fn() - }, - Uri: { - file: (path: string) => ({ fsPath: path, scheme: 'file' }) - }, - ProgressLocation: { - Notification: 15 - } -})) - -describe('WebsiteFormController', () => { - let mockWindow: typeof vscode.window - let controller: WebsiteFormController - let mockQuickPick: any - let acceptHandler: Function - let hideHandler: Function - - beforeEach(() => { - mockWindow = createMockWindow() - - // Create a more sophisticated mock QuickPick that captures handlers - mockQuickPick = { - title: '', - placeholder: '', - canSelectMany: false, - items: [] as FormQuickPickItem[], - selectedItems: [] as FormQuickPickItem[], - show: vi.fn(), - hide: vi.fn(), - dispose: vi.fn(), - onDidAccept: vi.fn(function (handler: Function) { - acceptHandler = handler - return { dispose: vi.fn() } - }), - onDidHide: vi.fn(function (handler: Function) { - hideHandler = handler - return { dispose: vi.fn() } - }) - } - - ;(mockWindow.createQuickPick as Mock).mockReturnValue(mockQuickPick) - controller = new WebsiteFormController(mockWindow) - }) - - it('should initialize form with default values', async () => { - const showPromise = controller.show('/test/arch.json') - - // Trigger hide to cancel - hideHandler() - - await showPromise - - expect(mockQuickPick.title).toBe('Create CALM Website') - expect(mockQuickPick.placeholder).toBe('Configure options and select "Create Website" to proceed') - expect(mockQuickPick.items).toHaveLength(4) - expect(mockQuickPick.show).toHaveBeenCalled() - }) - - it('should return undefined when form is cancelled (hidden)', async () => { - const showPromise = controller.show('/test/arch.json') - - // Simulate user pressing escape (hide without selecting create) - hideHandler() - - const result = await showPromise - - expect(result).toBeUndefined() - expect(mockQuickPick.dispose).toHaveBeenCalled() - }) - - it('should return form data when create is selected', async () => { - const showPromise = controller.show('/test/arch.json') - - // Simulate selecting "create" - mockQuickPick.selectedItems = [{ id: 'create' }] - await acceptHandler() - - const result = await showPromise - - expect(result).toEqual({ - architecturePath: '/test/arch.json', - outputDir: '/test/website', - mappingFilePath: undefined, - templateBundlePath: undefined - }) - }) - - it('should not proceed when no item is selected', async () => { - const showPromise = controller.show('/test/arch.json') - - // Simulate accept with no selection - mockQuickPick.selectedItems = [] - await acceptHandler() - - // Form should still be showing, trigger hide to complete - hideHandler() - - const result = await showPromise - - expect(result).toBeUndefined() - }) - - it('should handle outputDir selection', async () => { - ;(mockWindow.showInputBox as Mock).mockResolvedValue('/new/output/dir') - - const showPromise = controller.show('/test/arch.json') - - // Select outputDir - mockQuickPick.selectedItems = [{ id: 'outputDir' }] - await acceptHandler() - - expect(mockQuickPick.hide).toHaveBeenCalled() - expect(mockWindow.showInputBox).toHaveBeenCalledWith({ - prompt: 'Enter output directory for the website scaffold', - value: '/test/website', - validateInput: validateOutputDirectory - }) - expect(mockQuickPick.show).toHaveBeenCalledTimes(2) // Initial + after input - - // Now select create to complete - mockQuickPick.selectedItems = [{ id: 'create' }] - await acceptHandler() - - const result = await showPromise - - expect(result?.outputDir).toBe('/new/output/dir') - }) - - it('should not update outputDir when input is cancelled', async () => { - ;(mockWindow.showInputBox as Mock).mockResolvedValue(undefined) - - const showPromise = controller.show('/test/arch.json') - - // Select outputDir but cancel input - mockQuickPick.selectedItems = [{ id: 'outputDir' }] - await acceptHandler() - - // Now select create to complete - mockQuickPick.selectedItems = [{ id: 'create' }] - await acceptHandler() - - const result = await showPromise - - expect(result?.outputDir).toBe('/test/website') // Should remain default - }) - - it('should handle mappingFile selection', async () => { - ;(mockWindow.showOpenDialog as Mock).mockResolvedValue([{ fsPath: '/path/to/mapping.json' }]) - - const showPromise = controller.show('/test/arch.json') - - // Select mappingFile - mockQuickPick.selectedItems = [{ id: 'mappingFile' }] - await acceptHandler() - - expect(mockWindow.showOpenDialog).toHaveBeenCalledWith({ - canSelectFiles: true, - canSelectFolders: false, - canSelectMany: false, - filters: { 'Mapping Files': ['json'] }, - title: 'Select URL Mapping File (Cancel to clear)' - }) - - // Now select create to complete - mockQuickPick.selectedItems = [{ id: 'create' }] - await acceptHandler() - - const result = await showPromise - - expect(result?.mappingFilePath).toBe('/path/to/mapping.json') - }) - - it('should clear mappingFile when dialog is cancelled', async () => { - ;(mockWindow.showOpenDialog as Mock).mockResolvedValue(undefined) - - const showPromise = controller.show('/test/arch.json') - - // Select mappingFile but cancel dialog - mockQuickPick.selectedItems = [{ id: 'mappingFile' }] - await acceptHandler() - - // Select create to complete - mockQuickPick.selectedItems = [{ id: 'create' }] - await acceptHandler() - - const result = await showPromise - - expect(result?.mappingFilePath).toBeUndefined() - }) - - it('should handle templateBundle selection', async () => { - ;(mockWindow.showOpenDialog as Mock).mockResolvedValue([{ fsPath: '/path/to/bundle' }]) - - const showPromise = controller.show('/test/arch.json') - - // Select templateBundle - mockQuickPick.selectedItems = [{ id: 'templateBundle' }] - await acceptHandler() - - expect(mockWindow.showOpenDialog).toHaveBeenCalledWith({ - canSelectFiles: false, - canSelectFolders: true, - canSelectMany: false, - title: 'Select Custom Template Bundle Directory (Cancel to use default)' - }) - - // Now select create to complete - mockQuickPick.selectedItems = [{ id: 'create' }] - await acceptHandler() - - const result = await showPromise - - expect(result?.templateBundlePath).toBe('/path/to/bundle') - }) - - it('should clear templateBundle when dialog is cancelled', async () => { - ;(mockWindow.showOpenDialog as Mock).mockResolvedValue(undefined) - - const showPromise = controller.show('/test/arch.json') - - // Select templateBundle but cancel dialog - mockQuickPick.selectedItems = [{ id: 'templateBundle' }] - await acceptHandler() - - // Select create to complete - mockQuickPick.selectedItems = [{ id: 'create' }] - await acceptHandler() - - const result = await showPromise - - expect(result?.templateBundlePath).toBeUndefined() - }) - - it('should not dispose QuickPick when hiding to show file dialog', async () => { - ;(mockWindow.showOpenDialog as Mock).mockResolvedValue([{ fsPath: '/path/to/mapping.json' }]) - - const showPromise = controller.show('/test/arch.json') - - // Select mappingFile - this hides the QuickPick to show file dialog - mockQuickPick.selectedItems = [{ id: 'mappingFile' }] - - // Simulate the hide event firing when QuickPick is hidden for file dialog - const hidePromise = acceptHandler() - hideHandler() // This should NOT dispose the QuickPick - await hidePromise - - // QuickPick should NOT have been disposed yet - expect(mockQuickPick.dispose).not.toHaveBeenCalled() - - // QuickPick should be shown again after file selection - expect(mockQuickPick.show).toHaveBeenCalledTimes(2) - - // Complete the form - mockQuickPick.selectedItems = [{ id: 'create' }] - await acceptHandler() - - const result = await showPromise - - // Should have the selected mapping file - expect(result?.mappingFilePath).toBe('/path/to/mapping.json') - }) - - it('should update QuickPick items to show selected mapping file path', async () => { - ;(mockWindow.showOpenDialog as Mock).mockResolvedValue([{ fsPath: '/path/to/mapping.json' }]) - - const showPromise = controller.show('/test/arch.json') - - // Verify initial items show (none) for mapping file - const initialMappingItem = mockQuickPick.items.find((item: FormQuickPickItem) => item.id === 'mappingFile') - expect(initialMappingItem?.description).toBe('(none)') - - // Select mappingFile - mockQuickPick.selectedItems = [{ id: 'mappingFile' }] - await acceptHandler() - - // After selection, items should be updated to show the selected file - const updatedMappingItem = mockQuickPick.items.find((item: FormQuickPickItem) => item.id === 'mappingFile') - expect(updatedMappingItem?.description).toBe('mapping.json') - expect(updatedMappingItem?.detail).toBe('/path/to/mapping.json') - - // Complete the form - mockQuickPick.selectedItems = [{ id: 'create' }] - await acceptHandler() - - await showPromise - }) -}) - diff --git a/calm-plugins/vscode/src/commands/create-website/website-form-controller.ts b/calm-plugins/vscode/src/commands/create-website/website-form-controller.ts deleted file mode 100644 index 8b5e2f179..000000000 --- a/calm-plugins/vscode/src/commands/create-website/website-form-controller.ts +++ /dev/null @@ -1,99 +0,0 @@ -import * as vscode from 'vscode' -import { WebsiteFormData, FormQuickPickItem } from './types' -import { getDefaultOutputDir, createFormItems, validateOutputDirectory } from './form-item-factory' - -export class WebsiteFormController { - constructor(private readonly window: typeof vscode.window) {} - - async show(architecturePath: string): Promise { - const formState: WebsiteFormData = { - architecturePath, - outputDir: getDefaultOutputDir(architecturePath), - mappingFilePath: undefined, - templateBundlePath: undefined - } - - return new Promise((resolve) => { - const quickPick = this.window.createQuickPick() - quickPick.title = 'Create CALM Website' - quickPick.placeholder = 'Configure options and select "Create Website" to proceed' - quickPick.canSelectMany = false - - let resolved = false - let isShowingDialog = false - const updateItems = () => { quickPick.items = createFormItems(formState) } - - updateItems() - quickPick.show() - - quickPick.onDidAccept(async () => { - const selected = quickPick.selectedItems[0] - if (!selected) return - - if (selected.id === 'create') { - resolved = true - quickPick.hide() - quickPick.dispose() - resolve(formState) - return - } - - isShowingDialog = true - await this.handleFieldSelection(selected.id, formState, quickPick, updateItems) - isShowingDialog = false - }) - - quickPick.onDidHide(() => { - if (!resolved && !isShowingDialog) { - resolve(undefined) - quickPick.dispose() - } - }) - }) - } - - private async handleFieldSelection( - id: 'outputDir' | 'mappingFile' | 'templateBundle', - formState: WebsiteFormData, - quickPick: vscode.QuickPick, - updateItems: () => void - ): Promise { - quickPick.hide() - - switch (id) { - case 'outputDir': { - const newDir = await this.window.showInputBox({ - prompt: 'Enter output directory for the website scaffold', - value: formState.outputDir, - validateInput: validateOutputDirectory - }) - if (newDir) formState.outputDir = newDir - break - } - case 'mappingFile': { - const files = await this.window.showOpenDialog({ - canSelectFiles: true, - canSelectFolders: false, - canSelectMany: false, - filters: { 'Mapping Files': ['json'] }, - title: 'Select URL Mapping File (Cancel to clear)' - }) - formState.mappingFilePath = files?.[0]?.fsPath - break - } - case 'templateBundle': { - const folders = await this.window.showOpenDialog({ - canSelectFiles: false, - canSelectFolders: true, - canSelectMany: false, - title: 'Select Custom Template Bundle Directory (Cancel to use default)' - }) - formState.templateBundlePath = folders?.[0]?.fsPath - break - } - } - - updateItems() - quickPick.show() - } -} diff --git a/calm-plugins/vscode/src/commands/navigate-to-architecture-command.spec.ts b/calm-plugins/vscode/src/commands/navigate-to-architecture-command.spec.ts deleted file mode 100644 index a7c82a99f..000000000 --- a/calm-plugins/vscode/src/commands/navigate-to-architecture-command.spec.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import * as vscode from 'vscode' -import type { NavigationService } from '../core/services/navigation-service' - -// Mock vscode -vi.mock('vscode', () => ({ - commands: { - registerCommand: vi.fn(function (name, callback) { return { dispose: vi.fn(), callback }; }) - }, - window: { - showWarningMessage: vi.fn() - } -})) - -import { createNavigateToArchitectureCommand } from './navigate-to-architecture-command' - -describe('navigate-to-architecture-command', () => { - let mockNavigation: NavigationService - - beforeEach(() => { - vi.clearAllMocks() - mockNavigation = { - navigateToDetailedArchitecture: vi.fn().mockResolvedValue(true), - navigate: vi.fn(), - reset: vi.fn() - } as unknown as NavigationService - }) - - describe('createNavigateToArchitectureCommand', () => { - it('should register calm.navigateToArchitecture command', () => { - const disposable = createNavigateToArchitectureCommand(mockNavigation) - - expect(vscode.commands.registerCommand).toHaveBeenCalledWith( - 'calm.navigateToArchitecture', - expect.any(Function) - ) - expect(disposable).toBeDefined() - }) - - it('should show warning when architectureRef is empty', async () => { - createNavigateToArchitectureCommand(mockNavigation) - - const registerCall = vi.mocked(vscode.commands.registerCommand).mock.calls[0] - const callback = registerCall[1] - - await callback('') - - expect(vscode.window.showWarningMessage).toHaveBeenCalledWith( - 'No architecture reference found for this moment' - ) - expect(mockNavigation.navigateToDetailedArchitecture).not.toHaveBeenCalled() - }) - - it('should show warning when architectureRef is undefined', async () => { - createNavigateToArchitectureCommand(mockNavigation) - - const registerCall = vi.mocked(vscode.commands.registerCommand).mock.calls[0] - const callback = registerCall[1] - - await callback(undefined) - - expect(vscode.window.showWarningMessage).toHaveBeenCalledWith( - 'No architecture reference found for this moment' - ) - expect(mockNavigation.navigateToDetailedArchitecture).not.toHaveBeenCalled() - }) - - it('should call navigateToDetailedArchitecture with architectureRef', async () => { - createNavigateToArchitectureCommand(mockNavigation) - - const registerCall = vi.mocked(vscode.commands.registerCommand).mock.calls[0] - const callback = registerCall[1] - - await callback('https://example.com/arch.json') - - expect(mockNavigation.navigateToDetailedArchitecture).toHaveBeenCalledWith('https://example.com/arch.json') - }) - }) -}) - diff --git a/calm-plugins/vscode/src/commands/navigate-to-architecture-command.ts b/calm-plugins/vscode/src/commands/navigate-to-architecture-command.ts deleted file mode 100644 index 22e4d69aa..000000000 --- a/calm-plugins/vscode/src/commands/navigate-to-architecture-command.ts +++ /dev/null @@ -1,20 +0,0 @@ -import * as vscode from 'vscode' -import type { NavigationService } from '../core/services/navigation-service' - -/** - * Command to navigate to an architecture file from a timeline moment. - * Uses NavigationService to resolve URLs to local files via url mapping. - */ -export function createNavigateToArchitectureCommand(navigation: NavigationService): vscode.Disposable { - return vscode.commands.registerCommand( - 'calm.navigateToArchitecture', - async (architectureRef: string) => { - if (!architectureRef) { - vscode.window.showWarningMessage('No architecture reference found for this moment') - return - } - - await navigation.navigateToDetailedArchitecture(architectureRef) - } - ) -} diff --git a/calm-plugins/vscode/src/commands/open-preview-command.spec.ts b/calm-plugins/vscode/src/commands/open-preview-command.spec.ts deleted file mode 100644 index 373c1b191..000000000 --- a/calm-plugins/vscode/src/commands/open-preview-command.spec.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import * as vscode from 'vscode' -import { createOpenPreviewCommand } from './open-preview-command' -import { FileType } from '../models/file-types' -import type { ApplicationStoreApi, ApplicationStore } from '../application-store' - -// Mock vscode module -vi.mock('vscode', () => ({ - commands: { - registerCommand: vi.fn(function (command: string, callback: Function) { - return { command, callback } - }) - }, - window: { - activeTextEditor: undefined, - showWarningMessage: vi.fn() - }, - Uri: { - file: (path: string) => ({ fsPath: path, scheme: 'file' }) - } -})) - -// Mock file-types module -vi.mock('../models/file-types', () => ({ - detectFileType: vi.fn(), - FileType: { - ArchitectureFile: 'architecture', - TemplateFile: 'template', - Unknown: 'unknown' - } -})) - -import { detectFileType } from '../models/file-types' - -describe('createOpenPreviewCommand', () => { - let mockStore: ApplicationStoreApi - let mockState: ApplicationStore - let commandCallback: Function - - beforeEach(() => { - vi.clearAllMocks() - - mockState = { - currentModelIndex: undefined, - currentDocumentUri: undefined, - isTemplateMode: false, - templateFilePath: undefined, - architectureFilePath: undefined, - selectedElementId: undefined, - searchFilter: '', - showLabels: true, - forceCreatePreview: false, - setModelIndex: vi.fn(), - setCurrentDocument: vi.fn(), - setTemplateMode: vi.fn(), - setSelectedElement: vi.fn(), - setSearchFilter: vi.fn(), - setShowLabels: vi.fn(), - setForceCreatePreview: vi.fn(), - clearSelection: vi.fn(), - resetDocument: vi.fn() - } - - mockStore = { - getState: vi.fn(function () { return mockState; }), - setState: vi.fn(), - subscribe: vi.fn(function () { return vi.fn(); }), - getInitialState: vi.fn(function () { return mockState; }) - } - - const result = createOpenPreviewCommand(mockStore) - commandCallback = (result as any).callback - }) - - describe('elementId parameter handling', () => { - beforeEach(() => { - // Setup valid editor and architecture file - const mockUri = { fsPath: '/test/file.json', scheme: 'file' } - const mockDocument = { uri: mockUri } - ;(vscode.window as any).activeTextEditor = { document: mockDocument } - ;(detectFileType as any).mockReturnValue({ - type: FileType.ArchitectureFile, - isValid: true - }) - }) - - it('should set selected element when elementId is a valid string', async () => { - await commandCallback('node-123') - - expect(mockState.setSelectedElement).toHaveBeenCalledWith('node-123') - }) - - it('should not set selected element when elementId is undefined', async () => { - await commandCallback(undefined) - - expect(mockState.setSelectedElement).not.toHaveBeenCalled() - }) - - it('should not set selected element when elementId is a Uri object (context menu case)', async () => { - // When invoked from context menu, VS Code passes a Uri object - const uriObject = { fsPath: '/some/path', scheme: 'file' } - - await commandCallback(uriObject) - - expect(mockState.setSelectedElement).not.toHaveBeenCalled() - }) - - it('should not set selected element when elementId is an empty string', async () => { - await commandCallback('') - - expect(mockState.setSelectedElement).not.toHaveBeenCalled() - }) - - it('should still open preview even when elementId is invalid type', async () => { - const uriObject = { fsPath: '/some/path', scheme: 'file' } - - await commandCallback(uriObject) - - expect(mockState.setCurrentDocument).toHaveBeenCalled() - expect(mockState.setForceCreatePreview).toHaveBeenCalledWith(true) - }) - }) - - describe('editor validation', () => { - it('should return early when no active editor', async () => { - ;(vscode.window as any).activeTextEditor = undefined - - await commandCallback('element-id') - - expect(mockState.setCurrentDocument).not.toHaveBeenCalled() - }) - }) - - describe('file type validation', () => { - beforeEach(() => { - const mockUri = { fsPath: '/test/file.json', scheme: 'file' } - const mockDocument = { uri: mockUri } - ;(vscode.window as any).activeTextEditor = { document: mockDocument } - }) - - it('should show warning for non-CALM files', async () => { - ;(detectFileType as any).mockReturnValue({ - type: FileType.Unknown, - isValid: false - }) - - await commandCallback() - - expect(vscode.window.showWarningMessage).toHaveBeenCalled() - expect(mockState.setCurrentDocument).not.toHaveBeenCalled() - }) - - it('should process valid architecture files', async () => { - ;(detectFileType as any).mockReturnValue({ - type: FileType.ArchitectureFile, - isValid: true - }) - - await commandCallback() - - expect(mockState.setCurrentDocument).toHaveBeenCalled() - expect(mockState.setTemplateMode).toHaveBeenCalledWith(false) - expect(mockState.setForceCreatePreview).toHaveBeenCalledWith(true) - }) - - it('should process valid template files with architecture reference', async () => { - const mockUri = { fsPath: '/test/template.md', scheme: 'file' } - const mockDocument = { uri: mockUri } - ;(vscode.window as any).activeTextEditor = { document: mockDocument } - ;(detectFileType as any).mockReturnValue({ - type: FileType.TemplateFile, - isValid: true, - architecturePath: '/test/arch.json' - }) - - await commandCallback() - - expect(mockState.setCurrentDocument).toHaveBeenCalled() - expect(mockState.setTemplateMode).toHaveBeenCalledWith(true, '/test/template.md', '/test/arch.json') - expect(mockState.setForceCreatePreview).toHaveBeenCalledWith(true) - }) - }) -}) diff --git a/calm-plugins/vscode/src/commands/open-preview-command.ts b/calm-plugins/vscode/src/commands/open-preview-command.ts deleted file mode 100644 index 99d4d1cc1..000000000 --- a/calm-plugins/vscode/src/commands/open-preview-command.ts +++ /dev/null @@ -1,45 +0,0 @@ -import * as vscode from 'vscode' -import { detectFileType, FileType } from '../models/file-types' -import type { ApplicationStoreApi } from '../application-store' - -export function createOpenPreviewCommand(store: ApplicationStoreApi) { - return vscode.commands.registerCommand('calm.openPreview', async (elementId?: string) => { - const editor = vscode.window.activeTextEditor - if (!editor) return - const doc = editor.document - const fileInfo = detectFileType(doc.uri.fsPath) - - if (fileInfo.type === FileType.ArchitectureFile && fileInfo.isValid) { - // Valid architecture file - } else if (fileInfo.type === FileType.TemplateFile && fileInfo.isValid) { - // Valid template file with architecture reference - } else if (fileInfo.type === FileType.TimelineFile && fileInfo.isValid) { - // Valid timeline file - TreeView will show milestones - } else { - vscode.window.showWarningMessage('This file is not a CALM architecture, timeline, or template file.') - return - } - - const state = store.getState() - - // Set document first, then force flag - order matters because each setter triggers the subscription - // The forceCreatePreview check uses currentDocumentUri, so it must be set first - state.setCurrentDocument(doc.uri) - - if (fileInfo.type === FileType.TemplateFile && fileInfo.isValid) { - state.setTemplateMode(true, doc.uri.fsPath, fileInfo.architecturePath) - } else { - state.setTemplateMode(false) - } - - // If an elementId was provided (from CodeLens), set the selection - // Note: Context menu passes a Uri object, not a string, so we must check the type - if (elementId && typeof elementId === 'string') { - state.setSelectedElement(elementId) - } - - // Set force flag last - this triggers the StoreReactionMediator to create the panel - // with the correct document URI already in place - state.setForceCreatePreview(true) - }) -} diff --git a/calm-plugins/vscode/src/commands/search-tree-view-command.ts b/calm-plugins/vscode/src/commands/search-tree-view-command.ts deleted file mode 100644 index e8dcf8932..000000000 --- a/calm-plugins/vscode/src/commands/search-tree-view-command.ts +++ /dev/null @@ -1,15 +0,0 @@ -import * as vscode from 'vscode' -import type { ApplicationStoreApi } from '../application-store' - -export function createSearchTreeViewCommand(store: ApplicationStoreApi) { - return vscode.commands.registerCommand('calm.searchTreeView', async () => { - const searchText = await vscode.window.showInputBox({ - prompt: 'Search CALM Architecture Elements', - placeHolder: 'Enter text to filter nodes, relationships, and flows...', - value: store.getState().searchFilter - }) - if (searchText !== undefined) { - store.getState().setSearchFilter(searchText) - } - }) -} diff --git a/calm-plugins/vscode/src/core/aigf/catalogue.test.ts b/calm-plugins/vscode/src/core/aigf/catalogue.test.ts new file mode 100644 index 000000000..e1edddb1d --- /dev/null +++ b/calm-plugins/vscode/src/core/aigf/catalogue.test.ts @@ -0,0 +1,61 @@ +// SPDX-FileCopyrightText: 2024 CalmStudio contributors - see NOTICE file +// +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, expect } from 'vitest'; +import { aigfRisks, aigfMitigations, AIGF_CONTROL_KEYS } from './catalogue.js'; + +describe('AIGF Catalogue', () => { + it('aigfRisks has exactly 23 entries', () => { + expect(aigfRisks).toHaveLength(23); + }); + + it('aigfMitigations has exactly 23 entries', () => { + expect(aigfMitigations).toHaveLength(23); + }); + + it('every risk has id, title, type, description, externalRefs', () => { + for (const risk of aigfRisks) { + expect(risk.id).toBeTruthy(); + expect(risk.title).toBeTruthy(); + expect(risk.type).toBeTruthy(); + expect(risk.description).toBeTruthy(); + expect(risk.externalRefs).toBeDefined(); + } + }); + + it('every mitigation has id, title, type, calmControlKey', () => { + for (const mitigation of aigfMitigations) { + expect(mitigation.id).toBeTruthy(); + expect(mitigation.title).toBeTruthy(); + expect(mitigation.type).toBeTruthy(); + expect(mitigation.calmControlKey).toBeTruthy(); + } + }); + + it("risk types are only 'OP', 'SEC', or 'RC'", () => { + const validTypes = new Set(['OP', 'SEC', 'RC']); + for (const risk of aigfRisks) { + expect(validTypes.has(risk.type)).toBe(true); + } + }); + + it('no calmControlKey starts with aigf- (domain-oriented keys per CALM spec)', () => { + for (const mitigation of aigfMitigations) { + expect(mitigation.calmControlKey.startsWith('aigf-')).toBe(false); + } + }); + + it('every mitigation has an airId matching AIR-{PREV|DET}-NNN pattern', () => { + for (const mitigation of aigfMitigations) { + expect(mitigation.airId).toMatch(/^AIR-(PREV|DET)-\d{3}$/); + } + }); + + it('AIGF_CONTROL_KEYS set has 23 entries matching mitigations', () => { + expect(AIGF_CONTROL_KEYS.size).toBe(23); + for (const mitigation of aigfMitigations) { + expect(AIGF_CONTROL_KEYS.has(mitigation.calmControlKey)).toBe(true); + } + }); +}); diff --git a/calm-plugins/vscode/src/core/aigf/catalogue.ts b/calm-plugins/vscode/src/core/aigf/catalogue.ts new file mode 100644 index 000000000..21dcd73db --- /dev/null +++ b/calm-plugins/vscode/src/core/aigf/catalogue.ts @@ -0,0 +1,998 @@ +// SPDX-FileCopyrightText: 2024 CalmStudio contributors - see NOTICE file +// +// SPDX-License-Identifier: Apache-2.0 + +/** + * Static AIGF v2.0 risk and mitigation catalogue. + * Source: docs/AIGF_CATALOGUE.json — FINOS AI Governance Framework + * + * Data is inlined as TypeScript constants (not JSON import) because + * tsconfig.base.json does not set resolveJsonModule:true. + */ + +import type { AIGFRisk, AIGFMitigation } from './types.js'; + +export const aigfRisks: AIGFRisk[] = [ + { + id: 'AIR-RC-001', + sequence: 1, + title: 'Information Leaked To Hosted Model', + type: 'RC', + description: + 'Using third-party hosted LLMs creates a two-way trust boundary where neither inputs nor outputs can be fully trusted. Sensitive financial data sent for inference may be memorized by models, leaked through prompt attacks, or exposed via inadequate provider controls. This risks exposing customer PII, proprietary algorithms, and confidential business information, particularly with free or poorly-governed LLM services.', + externalRefs: { + owaspLlm: ['llm02-2025'], + owaspMl: [], + nistAi600: ['2-4', '2-9'], + ffiec: ['sec-2', 'sec-3', 'ots-2'], + euAiAct: ['c3-s2-a10', 'c3-s2-a13', 'c5-s2-a53'], + nistSp80053r5: [], + iso42001: [], + mitreAtlas: [], + }, + relatedRisks: ['AIR-SEC-002', 'AIR-RC-023'], + }, + { + id: 'AIR-SEC-002', + sequence: 2, + title: 'Information Leaked to Vector Store', + type: 'SEC', + description: + 'LLM applications pose data leakage risks not only through vector stores but across all components handling derived data, such as embeddings, prompt logs, and caches. These representations, while not directly human-readable, can still expose sensitive information via inversion or inference attacks, especially when security controls like access management, encryption, and auditing are lacking. To mitigate these risks, robust enterprise-grade security measures must be applied consistently across all parts of the LLM pipeline.', + externalRefs: { + owaspLlm: ['llm02-2025', 'llm08-2025'], + owaspMl: [], + nistAi600: ['2-4', '2-9'], + ffiec: ['sec-3', 'sec-4', 'aud-4'], + euAiAct: ['c3-s2-a10', 'c3-s2-a15', 'c3-s3-a16'], + nistSp80053r5: [], + iso42001: [], + mitreAtlas: [], + }, + relatedRisks: ['AIR-RC-001', 'AIR-SEC-009'], + }, + { + id: 'AIR-OP-004', + sequence: 4, + title: 'Hallucination and Inaccurate Outputs', + type: 'OP', + description: + 'LLM hallucinations occur when a model generates confident but incorrect or fabricated information due to its reliance on statistical patterns rather than factual understanding. Techniques like Retrieval-Augmented Generation can reduce hallucinations by providing factual context, but they cannot fully prevent the model from introducing errors or mixing in inaccurate internal knowledge. As there is no guaranteed way to constrain outputs to verified facts, hallucinations remain a persistent and unresolved challenge in LLM applications.', + externalRefs: { + owaspLlm: ['llm09-2025'], + owaspMl: ['ml09-2023'], + nistAi600: ['2-2', '2-8'], + ffiec: ['dam-3', 'aud-4', 'mgt-2'], + euAiAct: ['c3-s2-a15', 'c3-s2-a13', 'c3-s2-a9'], + nistSp80053r5: [], + iso42001: [], + mitreAtlas: [], + }, + relatedRisks: ['AIR-OP-014', 'AIR-OP-006', 'AIR-OP-019'], + }, + { + id: 'AIR-OP-005', + sequence: 5, + title: 'Foundation Model Versioning', + type: 'OP', + description: + 'Foundation model instability refers to unpredictable changes in model behavior over time due to external factors like version updates, system prompt modifications, or provider changes. Unlike inherent non-determinism, this instability stems from upstream modifications that alter the model\'s fundamental behavior patterns. Such variability can undermine testing, reliability, and trust when no version control or change notification mechanisms are in place.', + externalRefs: { + owaspLlm: ['llm09-2025'], + owaspMl: ['ml07-2023'], + nistAi600: ['2-8'], + ffiec: ['ots-2', 'dam-7', 'aud-4'], + euAiAct: ['c3-s2-a9', 'c3-s2-a15', 'c5-s2-a53'], + nistSp80053r5: [], + iso42001: [], + mitreAtlas: [], + }, + relatedRisks: ['AIR-OP-007', 'AIR-SEC-008'], + }, + { + id: 'AIR-OP-006', + sequence: 6, + title: 'Non-Deterministic Behaviour', + type: 'OP', + description: + 'LLMs exhibit non-deterministic behaviour, meaning they can generate different outputs for the same input due to probabilistic sampling and internal variability. This unpredictability can lead to inconsistent user experiences, undermine trust, and complicate testing, debugging, and performance evaluation. Inconsistent results may appear as varying answers to identical queries or fluctuating system performance across runs, posing significant challenges for reliable deployment and quality assurance.', + externalRefs: { + owaspLlm: ['llm09-2025'], + owaspMl: [], + nistAi600: [], + ffiec: ['dam-3', 'aud-4'], + euAiAct: ['c3-s2-a9', 'c3-s2-a15', 'c3-s2-a14'], + nistSp80053r5: [], + iso42001: [], + mitreAtlas: [], + }, + relatedRisks: ['AIR-OP-004', 'AIR-OP-014'], + }, + { + id: 'AIR-OP-007', + sequence: 7, + title: 'Availability of Foundational Model', + type: 'OP', + description: + 'Foundation models often rely on GPU-heavy infrastructure hosted by third-party providers, introducing risks related to service availability and performance. Key threats include Denial of Wallet (excessive usage leading to cost spikes or throttling), outages from immature Technology Service Providers, and VRAM exhaustion due to memory leaks or configuration changes. These issues can disrupt operations, limit failover options, and undermine the reliability of LLM-based applications.', + externalRefs: { + owaspLlm: ['llm10-2025'], + owaspMl: [], + nistAi600: [], + ffiec: ['bcm-4', 'bcm-5', 'ots-2', 'aio-6'], + euAiAct: ['c3-s2-a15', 'c3-s3-a26', 'c5-s2-a53'], + nistSp80053r5: [], + iso42001: [], + mitreAtlas: [], + }, + relatedRisks: ['AIR-OP-005', 'AIR-SEC-008'], + }, + { + id: 'AIR-SEC-008', + sequence: 8, + title: 'Tampering With the Foundational Model', + type: 'SEC', + description: + 'Foundational models provided by third-party SaaS vendors are vulnerable to supply chain risks, including tampering with training data, model weights, or infrastructure components such as GPU firmware and ML libraries. Malicious actors may introduce backdoors or adversarial triggers during training or fine-tuning, leading to unsafe or unfiltered behaviour under specific conditions. Without transparency or control over model provenance and update processes, consumers of these models are exposed to upstream compromises that can undermine system integrity and safety.', + externalRefs: { + owaspLlm: ['llm03-2025'], + owaspMl: ['ml05-2023', 'ml06-2023', 'ml10-2023'], + nistAi600: ['2-12'], + ffiec: ['sec-3', 'ots-2', 'dam-3', 'dam-6'], + euAiAct: ['c3-s2-a15', 'c3-s3-a16', 'c5-s2-a53'], + nistSp80053r5: [], + iso42001: [], + mitreAtlas: [], + }, + relatedRisks: ['AIR-SEC-009', 'AIR-OP-007'], + }, + { + id: 'AIR-SEC-009', + sequence: 9, + title: 'Data Poisoning', + type: 'SEC', + description: + 'Data poisoning occurs when adversaries tamper with training or fine-tuning data to manipulate an AI model\'s behaviour, often by injecting misleading or malicious patterns. This can lead to biased decision-making, such as incorrectly approving fraudulent transactions or degrading model performance in subtle ways. The risk is heightened in systems that continuously learn from unvalidated or third-party data, with impacts that may remain hidden until a major failure occurs.', + externalRefs: { + owaspLlm: ['llm03-2025', 'llm04-2025', 'llm05-2025'], + owaspMl: ['ml02-2023', 'ml10-2023'], + nistAi600: ['2-8', '2-12'], + ffiec: ['sec-3', 'dam-3', 'aud-4'], + euAiAct: ['c3-s2-a10', 'c3-s2-a15', 'c5-s2-a53'], + nistSp80053r5: [], + iso42001: [], + mitreAtlas: [], + }, + relatedRisks: ['AIR-SEC-008', 'AIR-OP-019'], + }, + { + id: 'AIR-SEC-010', + sequence: 10, + title: 'Prompt Injection', + type: 'SEC', + description: + 'Prompt injection occurs when attackers craft inputs that manipulate a language model into producing unintended, harmful, or unauthorized outputs. These attacks can be direct -- overriding the model\'s intended behaviour -- or indirect, where malicious instructions are hidden in third-party content and later processed by the model. This threat can lead to misinformation, data leakage, reputational damage, or unsafe automated actions, especially in systems without strong safeguards or human oversight.', + externalRefs: { + owaspLlm: ['llm01-2025', 'llm04-2025', 'llm06-2025', 'llm10-2025'], + owaspMl: [], + nistAi600: [], + ffiec: ['sec-3', 'dam-4', 'dam-5'], + euAiAct: ['c2-a5', 'c3-s2-a15', 'c3-s2-a14'], + nistSp80053r5: [], + iso42001: [], + mitreAtlas: [], + }, + relatedRisks: ['AIR-OP-018', 'AIR-OP-020'], + }, + { + id: 'AIR-OP-014', + sequence: 14, + title: 'Inadequate System Alignment', + type: 'OP', + description: + 'LLM-powered RAG systems may generate responses that diverge from their intended business purpose, producing outputs that appear relevant but contain inaccurate financial advice, biased recommendations, or inappropriate tone for the financial context. Misalignment often occurs when the LLM prioritizes response fluency over accuracy, fails to respect financial compliance constraints, or draws inappropriate conclusions from retrieved documents. This risk is particularly acute in financial services where confident-sounding but incorrect responses can lead to regulatory violations or customer harm.', + externalRefs: { + owaspLlm: ['llm07-2025'], + owaspMl: ['ml08-2023'], + nistAi600: [], + ffiec: ['dam-3', 'dam-5', 'aud-4'], + euAiAct: ['c2-a5', 'c3-s2-a9', 'c3-s2-a14'], + nistSp80053r5: ['sa-11', 'ra-3', 'ca-6'], + iso42001: [], + mitreAtlas: [], + }, + relatedRisks: ['AIR-OP-004', 'AIR-OP-006'], + }, + { + id: 'AIR-OP-016', + sequence: 16, + title: 'Bias and Discrimination', + type: 'OP', + description: + 'AI systems can systematically disadvantage protected groups through biased training data, flawed design, or proxy variables that correlate with sensitive characteristics. In financial services, this manifests as discriminatory credit decisions, unfair fraud detection, or biased customer service, potentially violating fair lending laws and causing significant regulatory and reputational damage.', + externalRefs: { + owaspLlm: [], + owaspMl: [], + nistAi600: ['2-6'], + ffiec: ['mgt-2', 'dam-3', 'aud-4'], + euAiAct: ['c2-a5', 'c3-s2-a9', 'c3-s2-a10', 'c3-s2-a14', 'c3-s3-a27'], + nistSp80053r5: [], + iso42001: [], + mitreAtlas: [], + }, + relatedRisks: ['AIR-OP-019', 'AIR-RC-022'], + }, + { + id: 'AIR-OP-017', + sequence: 17, + title: 'Lack of Explainability', + type: 'OP', + description: + 'AI systems, particularly those using complex foundation models, often lack transparency, making it difficult to interpret how decisions are made. This limits firms\' ability to explain outcomes to regulators, stakeholders, or customers, raising trust and compliance concerns. Without explainability, errors and biases can go undetected, increasing the risk of inappropriate use, regulatory scrutiny, and undiagnosed failures.', + externalRefs: { + owaspLlm: [], + owaspMl: [], + nistAi600: [], + ffiec: ['mgt-2', 'aud-4', 'dam-3'], + euAiAct: ['c3-s2-a13', 'c3-s2-a14', 'c4-a50', 'c9-s4-a86'], + nistSp80053r5: [], + iso42001: [], + mitreAtlas: [], + }, + relatedRisks: ['AIR-RC-022', 'AIR-OP-016', 'AIR-OP-018'], + }, + { + id: 'AIR-OP-018', + sequence: 18, + title: 'Model Overreach / Expanded Use', + type: 'OP', + description: + 'Model overreach occurs when AI systems are used beyond their intended purpose, often due to overconfidence in their capabilities. This can lead to poor-quality, non-compliant, or misleading outputs, especially when users apply AI to high-stakes tasks without proper validation or oversight. Overreliance and misplaced trust (such as treating AI as a human expert) can result in operational errors and regulatory breaches.', + externalRefs: { + owaspLlm: ['llm06-2025'], + owaspMl: [], + nistAi600: [], + ffiec: ['mgt-1', 'mgt-2', 'aud-4'], + euAiAct: ['c3-s1-a6', 'c3-s2-a14', 'c3-s3-a26'], + nistSp80053r5: [], + iso42001: [], + mitreAtlas: [], + }, + relatedRisks: ['AIR-SEC-010', 'AIR-OP-017', 'AIR-RC-022'], + }, + { + id: 'AIR-OP-019', + sequence: 19, + title: 'Data Quality and Drift', + type: 'OP', + description: + 'Generative AI systems rely heavily on the quality and freshness of their training data, and outdated or poor-quality data can lead to inaccurate, biased, or irrelevant outputs. In fast-moving sectors like financial services, stale models may miss market changes or regulatory updates, resulting in flawed risk assessments or compliance failures. Ongoing data integrity and retraining efforts are essential to ensure models remain accurate, relevant, and aligned with current conditions.', + externalRefs: { + owaspLlm: [], + owaspMl: [], + nistAi600: ['2-8'], + ffiec: ['dam-3', 'dam-7', 'aud-4'], + euAiAct: ['c3-s2-a10', 'c3-s2-a9', 'c3-s2-a15'], + nistSp80053r5: [], + iso42001: [], + mitreAtlas: [], + }, + relatedRisks: ['AIR-OP-004', 'AIR-OP-016', 'AIR-SEC-009'], + }, + { + id: 'AIR-OP-020', + sequence: 20, + title: 'Reputational Risk', + type: 'OP', + description: + 'AI failures or misuse -- especially in customer-facing systems -- can quickly escalate into public incidents that damage a firm\'s reputation and erode trust. Inaccurate, offensive, or unfair outputs may lead to regulatory scrutiny, media backlash, or widespread customer dissatisfaction, particularly in high-stakes sectors like finance. Because AI systems can scale errors rapidly, firms must ensure robust oversight, as each AI-driven decision reflects directly on their brand and conduct.', + externalRefs: { + owaspLlm: ['llm09-2025'], + owaspMl: [], + nistAi600: [], + ffiec: ['mgt-2', 'bcm-3', 'aud-4'], + euAiAct: ['c2-a5', 'c3-s2-a9', 'c3-s2-a14'], + nistSp80053r5: [], + iso42001: [], + mitreAtlas: [], + }, + relatedRisks: ['AIR-SEC-010', 'AIR-OP-016', 'AIR-OP-004'], + }, + { + id: 'AIR-RC-022', + sequence: 22, + title: 'Regulatory Compliance and Oversight', + type: 'RC', + description: + 'AI systems in financial services must comply with the same regulatory standards as human-driven processes, including those related to suitability, fairness, record-keeping, and marketing conduct. Failure to supervise or govern AI tools properly can lead to non-compliance, particularly in areas like financial advice, credit decisions, or trading. As regulations evolve -- such as the EU AI Act -- firms face increasing obligations to ensure AI transparency, accountability, and risk management, with non-compliance carrying potential fines or legal consequences.', + externalRefs: { + owaspLlm: [], + owaspMl: [], + nistAi600: ['2-9'], + ffiec: ['mgt-1', 'mgt-2', 'aud-3', 'aud-4'], + euAiAct: ['c3-s2-a8', 'c3-s2-a10', 'c3-s3-a16', 'c3-s3-a21', 'c3-s3-a27'], + nistSp80053r5: [], + iso42001: [], + mitreAtlas: [], + }, + relatedRisks: ['AIR-OP-016', 'AIR-OP-017', 'AIR-OP-018'], + }, + { + id: 'AIR-RC-023', + sequence: 23, + title: 'Intellectual Property (IP) and Copyright', + type: 'RC', + description: + 'Generative AI models may be trained on copyrighted or proprietary material, raising the risk that outputs could unintentionally infringe on intellectual property rights. In financial services, this could lead to legal liability if AI-generated content includes copyrighted text, code, or reveals sensitive business information. Additional risks arise when employees input confidential data into public AI tools, potentially leaking trade secrets or violating licensing terms.', + externalRefs: { + owaspLlm: [], + owaspMl: [], + nistAi600: ['2-10'], + ffiec: ['mgt-1', 'mgt-2', 'ots-2', 'dam-6'], + euAiAct: ['c3-s2-a10', 'c3-s2-a11', 'c5-s2-a53'], + nistSp80053r5: [], + iso42001: [], + mitreAtlas: [], + }, + relatedRisks: ['AIR-RC-001', 'AIR-RC-022'], + }, + { + id: 'AIR-SEC-024', + sequence: 24, + title: 'Agent Action Authorization Bypass', + type: 'SEC', + description: + 'Agent systems may bypass intended authorization controls and perform actions beyond their designated scope, potentially executing unauthorized financial transactions, accessing restricted data, or violating business logic constraints. This occurs when agents exploit API vulnerabilities, escalate privileges through tool chains, or circumvent approval workflows designed to maintain segregation of duties and regulatory compliance.', + externalRefs: { + owaspLlm: ['llm06-2025'], + owaspMl: [], + nistAi600: [], + ffiec: [], + euAiAct: [], + nistSp80053r5: [], + iso42001: [], + mitreAtlas: [], + }, + relatedRisks: ['AIR-SEC-010', 'AIR-OP-018', 'AIR-RC-022'], + }, + { + id: 'AIR-SEC-025', + sequence: 25, + title: 'Tool Chain Manipulation and Injection', + type: 'SEC', + description: + 'Attacks targeting agentic AI systems\' tool selection and execution logic, extending beyond traditional prompt injection to manipulate API calls and system actions. Key vectors include tool selection manipulation, API parameter injection, tool chain sequencing attacks, and cross-tool data injection. In financial contexts, this can lead to unauthorized transactions, data exfiltration, regulatory violations, market manipulation, and operational disruption.', + externalRefs: { + owaspLlm: ['llm01-2025'], + owaspMl: [], + nistAi600: [], + ffiec: [], + euAiAct: [], + nistSp80053r5: [], + iso42001: [], + mitreAtlas: [], + }, + relatedRisks: ['AIR-SEC-010', 'AIR-SEC-024', 'AIR-OP-004'], + }, + { + id: 'AIR-SEC-026', + sequence: 26, + title: 'MCP Server Supply Chain Compromise', + type: 'SEC', + description: + 'Compromised or malicious Model Context Protocol (MCP) servers provide tainted data, capabilities, or execution environments to agentic AI systems, leading to systematic compromise of agent decision-making. This supply chain attack vector allows adversaries to influence agent behavior at scale through corrupted external services that agents rely upon for specialized data and capabilities.', + externalRefs: { + owaspLlm: [], + owaspMl: [], + nistAi600: [], + ffiec: [], + euAiAct: [], + nistSp80053r5: [], + iso42001: [], + mitreAtlas: [], + }, + relatedRisks: ['AIR-SEC-008', 'AIR-SEC-009', 'AIR-RC-001'], + }, + { + id: 'AIR-SEC-027', + sequence: 27, + title: 'Agent State Persistence Poisoning', + type: 'SEC', + description: + 'Agents retain malicious instructions, corrupted reasoning patterns, or compromised decision-making logic across sessions through poisoned persistent state, creating long-term backdoors that systematically affect multiple transactions and user interactions. This persistent compromise can influence agent behavior over extended periods, making detection challenging and amplifying the impact of initial attacks.', + externalRefs: { + owaspLlm: [], + owaspMl: [], + nistAi600: [], + ffiec: [], + euAiAct: [], + nistSp80053r5: [], + iso42001: [], + mitreAtlas: [], + }, + relatedRisks: ['AIR-SEC-010', 'AIR-SEC-024', 'AIR-SEC-009'], + }, + { + id: 'AIR-OP-028', + sequence: 28, + title: 'Multi-Agent Trust Boundary Violations', + type: 'OP', + description: + 'In multi-agent systems, compromised agents affect other agents through shared resources, communication channels, or state corruption, leading to systemic failures and cascading security incidents. Trust boundary violations allow compromise to propagate across agent networks, potentially affecting entire business processes and requiring comprehensive incident response across multiple agent systems.', + externalRefs: { + owaspLlm: [], + owaspMl: [], + nistAi600: [], + ffiec: [], + euAiAct: [], + nistSp80053r5: [], + iso42001: [], + mitreAtlas: [], + }, + relatedRisks: ['AIR-SEC-024', 'AIR-SEC-027', 'AIR-OP-020'], + }, + { + id: 'AIR-SEC-029', + sequence: 29, + title: 'Agent-Mediated Credential Discovery and Harvesting', + type: 'SEC', + description: + 'Malicious actors exploit AI agents\' autonomous capabilities and system access to systematically discover and steal authentication credentials at scale. Key attack vectors include tool chain enumeration of configuration files and environment variables, memory extraction from running processes, database mining for stored credentials, cloud infrastructure harvesting via management APIs, and cross-system correlation to reconstruct credentials from fragments.', + externalRefs: { + owaspLlm: ['llm01-2025', 'llm06-2025'], + owaspMl: [], + nistAi600: [], + ffiec: [], + euAiAct: [], + nistSp80053r5: [], + iso42001: [], + mitreAtlas: [], + }, + relatedRisks: ['AIR-SEC-010', 'AIR-SEC-024', 'AIR-SEC-025', 'AIR-SEC-026'], + }, +]; + +export const aigfMitigations: AIGFMitigation[] = [ + { + id: 'mi-1', + sequence: 1, + title: 'AI Data Leakage Prevention and Detection', + type: 'DET', + description: + 'Data Leakage Prevention and Detection (DLP&D) for AI systems encompasses a combination of proactive measures to prevent sensitive data from unauthorized egress or exposure through these systems, and detective measures to identify such incidents promptly if they occur. This control is critical for safeguarding session data, training data, and model intellectual property, and applies to both internally developed AI systems and scenarios involving third-party service providers.', + externalRefs: { + owaspLlm: [], + owaspMl: [], + nistAi600: [], + ffiec: [], + euAiAct: [], + nistSp80053r5: [ + 'ac-4', 'ac-20', 'au-13', 'ca-3', 'ca-7', 'ir-4', 'ir-9', + 'mp-6', 'sa-9', 'sc-7', 'sc-8', 'sc-28', 'si-4', 'si-20', + ], + iso42001: ['A-7-2', 'A-6-2-6', 'A-5-2'], + mitreAtlas: [], + }, + mitigates: ['AIR-RC-001'], + relatedMitigations: ['mi-2', 'mi-4', 'mi-14'], + calmControlKey: 'data-leakage-prevention', + airId: 'AIR-DET-001', + }, + { + id: 'mi-2', + sequence: 2, + title: 'Data Filtering From External Knowledge Bases', + type: 'PREV', + description: + 'This control addresses the critical need to sanitize, filter, and appropriately manage sensitive information when AI systems ingest data from internal knowledge sources such as wikis, document management systems, databases, or collaboration platforms. The primary objective is to prevent the inadvertent exposure, leakage, or manipulation of confidential organizational knowledge when this data is processed by AI models, converted into embeddings, or used in RAG systems.', + externalRefs: { + owaspLlm: [], + owaspMl: [], + nistAi600: [], + ffiec: [], + euAiAct: [], + nistSp80053r5: ['ac-4', 'ac-22', 'mp-6', 'pt-2', 'si-4', 'si-12', 'si-15', 'si-19'], + iso42001: ['A-7-2', 'A-7-3', 'A-7-4', 'A-7-6'], + mitreAtlas: [], + }, + mitigates: ['AIR-RC-001', 'AIR-SEC-009'], + relatedMitigations: ['mi-1', 'mi-6', 'mi-16'], + calmControlKey: 'data-filtering', + airId: 'AIR-PREV-002', + }, + { + id: 'mi-3', + sequence: 3, + title: 'User/App/Model Firewalling/Filtering', + type: 'PREV', + description: + 'Effective security for AI systems involves monitoring and filtering interactions at multiple points: between the AI model and its users, between different application components, and between the model and its various data sources. Similar to a Web Application Firewall which inspects incoming web traffic, AI firewalling inspects and controls data flows to and from the model, including RAG database interactions and external embedding creation services.', + externalRefs: { + owaspLlm: [], + owaspMl: [], + nistAi600: [], + ffiec: [], + euAiAct: [], + nistSp80053r5: ['ac-4', 'sc-5', 'sc-7', 'si-4', 'si-10', 'si-15'], + iso42001: ['A-6-1-3', 'A-6-2-2', 'A-9-2'], + mitreAtlas: [], + }, + mitigates: ['AIR-OP-007', 'AIR-SEC-010', 'AIR-OP-018', 'AIR-OP-020'], + relatedMitigations: ['mi-17', 'mi-8', 'mi-15'], + calmControlKey: 'edge-protection', + airId: 'AIR-PREV-003', + }, + { + id: 'mi-4', + sequence: 4, + title: 'AI System Observability', + type: 'DET', + description: + 'AI System Observability encompasses the comprehensive collection, analysis, and monitoring of data about AI system behavior, performance, interactions, and outcomes. This control is essential for maintaining operational awareness, detecting anomalies, ensuring performance standards, and supporting incident response for AI-driven applications and services within a financial institution.', + externalRefs: { + owaspLlm: [], + owaspMl: [], + nistAi600: [], + ffiec: [], + euAiAct: [], + nistSp80053r5: [ + 'ac-2', 'au-2', 'au-3', 'au-6', 'au-11', 'au-12', + 'ca-7', 'ir-4', 'ir-5', 'ra-10', 'si-4', 'si-7', + ], + iso42001: ['A-6-2-6', 'A-6-2-8'], + mitreAtlas: [], + }, + mitigates: [ + 'AIR-RC-001', 'AIR-OP-005', 'AIR-OP-006', 'AIR-OP-007', + 'AIR-OP-014', 'AIR-OP-018', 'AIR-OP-019', + ], + relatedMitigations: ['mi-9', 'mi-11', 'mi-1'], + calmControlKey: 'ai-observability', + airId: 'AIR-DET-004', + }, + { + id: 'mi-5', + sequence: 5, + title: 'System Acceptance Testing', + type: 'PREV', + description: + 'System Acceptance Testing (SAT) for AI systems is a crucial validation phase whose primary goal is to confirm that a developed AI solution rigorously meets all agreed-upon business and user requirements, functions as intended from an end-user perspective, and is fit for its designated purpose before being deployed into any live operational environment. This testing focuses on the user\'s viewpoint and verifies the system\'s overall operational readiness.', + externalRefs: { + owaspLlm: [], + owaspMl: [], + nistAi600: [], + ffiec: [], + euAiAct: ['c3-s2-a9', 'c3-s2-a15'], + nistSp80053r5: ['ca-2', 'ca-6', 'cm-4', 'sa-4', 'sa-11', 'si-2', 'si-6'], + iso42001: ['A-6-2-4', 'A-6-2-5'], + mitreAtlas: [], + }, + mitigates: ['AIR-OP-004', 'AIR-OP-005', 'AIR-OP-006', 'AIR-OP-014', 'AIR-OP-016', 'AIR-RC-022'], + relatedMitigations: ['mi-15', 'mi-11', 'mi-10'], + calmControlKey: 'acceptance-testing', + airId: 'AIR-PREV-005', + }, + { + id: 'mi-6', + sequence: 6, + title: 'Data Quality & Classification/Sensitivity', + type: 'PREV', + description: + 'The integrity, security, and effectiveness of any AI system deployed within a financial institution are fundamentally dependent on the quality and appropriate handling of the data it uses. This control establishes the necessity for robust processes to ensure data quality and implement data classification, systematically categorizing data based on its sensitivity to dictate appropriate security measures, access controls, and handling procedures throughout the AI lifecycle.', + externalRefs: { + owaspLlm: [], + owaspMl: [], + nistAi600: [], + ffiec: [], + euAiAct: ['c3-s2-a10'], + nistSp80053r5: [ + 'ac-1', 'ac-4', 'ac-16', 'at-2', 'at-3', 'ca-7', 'cm-13', + 'pm-11', 'pm-22', 'pm-23', 'ra-2', 'si-7', 'si-10', 'si-12', 'si-18', + ], + iso42001: ['A-7-4', 'A-7-2', 'A-4-3'], + mitreAtlas: [], + }, + mitigates: [ + 'AIR-RC-001', 'AIR-SEC-002', 'AIR-OP-004', 'AIR-SEC-009', + 'AIR-OP-016', 'AIR-OP-019', 'AIR-RC-022', 'AIR-RC-023', + ], + relatedMitigations: ['mi-2', 'mi-12', 'mi-14'], + calmControlKey: 'data-governance', + airId: 'AIR-PREV-006', + }, + { + id: 'mi-7', + sequence: 7, + title: 'Legal and Contractual Frameworks for AI Systems', + type: 'PREV', + description: + 'Robust legal and contractual agreements are essential for governing the development, procurement, deployment, and use of AI systems. These agreements manage risks, define responsibilities, protect data, and ensure regulatory compliance when working with AI vendors and partners. Key areas include data governance, IP protection, responsibility allocation, model transparency, and service management.', + externalRefs: { + owaspLlm: [], + owaspMl: [], + nistAi600: [], + ffiec: [], + euAiAct: [], + nistSp80053r5: ['ac-20', 'ca-3', 'ir-6', 'pm-30', 'ps-7', 'sa-4', 'sa-9', 'sr-2', 'sr-3', 'sr-5', 'sr-8'], + iso42001: ['A-2-3', 'A-10-2', 'A-10-3', 'A-8-5'], + mitreAtlas: [], + }, + mitigates: ['AIR-RC-001', 'AIR-SEC-008', 'AIR-OP-020', 'AIR-RC-022', 'AIR-RC-023'], + relatedMitigations: ['mi-1', 'mi-10', 'mi-6'], + calmControlKey: 'legal-contractual-frameworks', + airId: 'AIR-PREV-007', + }, + { + id: 'mi-8', + sequence: 8, + title: 'Quality of Service (QoS) and DDoS Prevention for AI Systems', + type: 'PREV', + description: + 'This control addresses the critical need to ensure Quality of Service and implement robust Distributed Denial of Service prevention measures for AI systems. AI systems exposed via APIs or public interfaces are susceptible to volumetric attacks, prompt flooding, and inference spam that can exhaust computational resources, induce unacceptable latency, or deny legitimate users access to critical AI-driven services.', + externalRefs: { + owaspLlm: [], + owaspMl: [], + nistAi600: [], + ffiec: [], + euAiAct: [], + nistSp80053r5: ['sc-5', 'sc-6', 'sc-7', 'si-4', 'si-10', 'si-13', 'ir-4', 'ca-7'], + iso42001: ['A-6-2-6', 'A-4-5'], + mitreAtlas: [], + }, + mitigates: ['AIR-OP-007'], + relatedMitigations: ['mi-9', 'mi-17', 'mi-4'], + calmControlKey: 'qos-ddos-prevention', + airId: 'AIR-PREV-008', + }, + { + id: 'mi-9', + sequence: 9, + title: 'AI System Alerting and Denial of Wallet (DoW) / Spend Monitoring', + type: 'DET', + description: + 'The consumption-based pricing models common in AI services create unique financial and operational risks. Denial of Wallet attacks specifically target these cost structures by attempting to exhaust an organization\'s AI service budgets through excessive resource consumption. This control establishes comprehensive alerting and spend monitoring mechanisms to detect, prevent, and respond to both malicious and accidental overconsumption of AI resources.', + externalRefs: { + owaspLlm: [], + owaspMl: [], + nistAi600: [], + ffiec: [], + euAiAct: [], + nistSp80053r5: ['ac-2', 'ca-7', 'ir-4', 'sc-5', 'sc-6', 'si-4'], + iso42001: ['A-6-2-6', 'A-4-2'], + mitreAtlas: [], + }, + mitigates: ['AIR-OP-007'], + relatedMitigations: ['mi-8', 'mi-4', 'mi-3'], + calmControlKey: 'cost-alerting', + airId: 'AIR-DET-009', + }, + { + id: 'mi-10', + sequence: 10, + title: 'AI Model Version Pinning', + type: 'PREV', + description: + 'Model Version Pinning is the deliberate practice of selecting and using a specific, fixed version of an AI model within a production environment, rather than automatically adopting the latest available version. The primary goal is to ensure operational stability, maintain predictable AI system behavior, and enable a controlled, risk-managed approach to adopting model updates, preventing unexpected disruptions or the introduction of new vulnerabilities from unvetted changes.', + externalRefs: { + owaspLlm: [], + owaspMl: [], + nistAi600: [], + ffiec: [], + euAiAct: [], + nistSp80053r5: [ + 'cm-2', 'cm-3', 'cm-4', 'cm-8', 'au-2', 'sa-4', + 'sa-10', 'sa-22', 'sr-4', 'sr-8', + ], + iso42001: ['A-6-2-3', 'A-6-2-5', 'A-6-2-6', 'A-4-4'], + mitreAtlas: [], + }, + mitigates: ['AIR-OP-005', 'AIR-OP-006'], + relatedMitigations: ['mi-5', 'mi-4'], + calmControlKey: 'model-version-pinning', + airId: 'AIR-PREV-010', + }, + { + id: 'mi-11', + sequence: 11, + title: 'Human Feedback Loop for AI Systems', + type: 'DET', + description: + 'A Human Feedback Loop is a critical detective and continuous improvement mechanism that involves systematically collecting, analyzing, and acting upon feedback provided by human users, subject matter experts, or reviewers regarding an AI system\'s performance, outputs, or behavior. This feedback is invaluable for monitoring AI system efficacy, identifying issues such as inaccuracies, biases, and unexpected behaviors, enabling continuous improvement, and supporting incident response.', + externalRefs: { + owaspLlm: [], + owaspMl: [], + nistAi600: [], + ffiec: [], + euAiAct: ['c3-s2-a14', 'c9-s1-a72'], + nistSp80053r5: ['ca-7', 'ir-6', 'pm-26', 'ra-5', 'si-2', 'si-4', 'at-2', 'ca-2'], + iso42001: ['A-6-2-6', 'A-8-2', 'A-8-3', 'A-3-3'], + mitreAtlas: [], + }, + mitigates: ['AIR-OP-005', 'AIR-OP-006', 'AIR-OP-014', 'AIR-OP-016', 'AIR-OP-020'], + relatedMitigations: ['mi-15', 'mi-4', 'mi-5'], + calmControlKey: 'human-feedback-loop', + airId: 'AIR-DET-011', + }, + { + id: 'mi-12', + sequence: 12, + title: 'Role-Based Access Control for AI Data', + type: 'PREV', + description: + 'This mitigation establishes systematic controls ensuring that users, AI models, and other systems are granted access only to the specific data assets necessary for their authorized functions. The approach protects data throughout the AI lifecycle -- from sourcing through deployment -- by enforcing principle of least privilege and segregation of duties, with access control applied across data repositories, ML platforms, APIs, and applications.', + externalRefs: { + owaspLlm: [], + owaspMl: [], + nistAi600: [], + ffiec: [], + euAiAct: [], + nistSp80053r5: [ + 'ac-1', 'ac-2', 'ac-3', 'ac-5', 'ac-6', 'ac-16', + 'au-2', 'au-6', 'ia-2', 'ia-4', 'ia-5', 'cm-12', + ], + iso42001: ['A-3-2', 'A-7-2'], + mitreAtlas: [], + }, + mitigates: ['AIR-RC-001', 'AIR-SEC-008', 'AIR-SEC-009'], + relatedMitigations: ['mi-16', 'mi-6', 'mi-14'], + calmControlKey: 'ai-data-access-control', + airId: 'AIR-PREV-012', + }, + { + id: 'mi-13', + sequence: 13, + title: 'Providing Citations and Source Traceability for AI-Generated Information', + type: 'DET', + description: + 'This control outlines the practice of designing AI systems to provide verifiable citations, references, or traceable links back to the original source data or knowledge used to formulate their outputs. The primary purpose is to enhance the transparency, verifiability, and trustworthiness of AI-generated information, enabling users to trace claims to their origins and detect risks associated with misinformation, hallucinations, and lack of accountability.', + externalRefs: { + owaspLlm: [], + owaspMl: [], + nistAi600: [], + ffiec: [], + euAiAct: ['c3-s2-a13', 'c9-s4-a86'], + nistSp80053r5: ['au-10', 'sa-8', 'ac-16', 'si-7', 'at-2'], + iso42001: ['A-8-2', 'A-6-1-2', 'A-6-2-7'], + mitreAtlas: [], + }, + mitigates: ['AIR-OP-004', 'AIR-OP-017', 'AIR-OP-020', 'AIR-RC-022'], + relatedMitigations: ['mi-4', 'mi-6', 'mi-16'], + calmControlKey: 'citations-traceability', + airId: 'AIR-DET-013', + }, + { + id: 'mi-14', + sequence: 14, + title: 'Encryption of AI Data at Rest', + type: 'PREV', + description: + 'Encryption of data at rest is a fundamental security control that involves transforming stored information into a cryptographically secured format using robust encryption algorithms. This renders the data unintelligible and inaccessible to unauthorized parties unless they possess the corresponding decryption key, protecting the confidentiality and integrity of sensitive data associated with AI systems including training datasets, embeddings, model artifacts, and logs.', + externalRefs: { + owaspLlm: [], + owaspMl: [], + nistAi600: [], + ffiec: [], + euAiAct: [], + nistSp80053r5: ['sc-28', 'sc-12', 'sc-13', 'cp-9', 'ac-19', 'sa-9', 'cm-3'], + iso42001: ['A-7-2'], + mitreAtlas: [], + }, + mitigates: ['AIR-SEC-002', 'AIR-RC-022'], + relatedMitigations: ['mi-6', 'mi-12', 'mi-1'], + calmControlKey: 'data-encryption', + airId: 'AIR-PREV-014', + }, + { + id: 'mi-15', + sequence: 15, + title: 'Using Large Language Models for Automated Evaluation (LLM-as-a-Judge)', + type: 'DET', + description: + 'LLM-as-a-Judge is an emerging detective technique where one Large Language Model (the judge or evaluator LLM) is employed to automatically assess the quality, safety, accuracy, adherence to guidelines, or other specific characteristics of outputs generated by another AI system. It aims to automate or augment aspects of AI system verification, validation, and ongoing monitoring, providing a scalable way to detect undesirable outputs, monitor performance, and flag issues for human review.', + externalRefs: { + owaspLlm: [], + owaspMl: [], + nistAi600: [], + ffiec: [], + euAiAct: [], + nistSp80053r5: ['ca-2', 'ca-7', 'au-6', 'ra-10', 'sa-11', 'si-4', 'si-7', 'si-15'], + iso42001: ['A-6-2-4', 'A-6-2-6'], + mitreAtlas: [], + }, + mitigates: [ + 'AIR-RC-001', 'AIR-OP-004', 'AIR-OP-005', 'AIR-OP-006', + 'AIR-OP-014', 'AIR-OP-016', 'AIR-OP-019', + ], + relatedMitigations: ['mi-11', 'mi-5', 'mi-3'], + calmControlKey: 'llm-as-judge-evaluation', + airId: 'AIR-DET-015', + }, + { + id: 'mi-16', + sequence: 16, + title: 'Preserving Source Data Access Controls in AI Systems', + type: 'DET', + description: + 'This control addresses the critical requirement that when an AI system ingests data from various internal or external sources, the original access control permissions, restrictions, and entitlements associated with that source data must be understood, preserved, and effectively enforced when the AI system subsequently uses or presents information derived from that data. It also involves ongoing verification, auditing, and monitoring to ensure access controls are correctly maintained.', + externalRefs: { + owaspLlm: [], + owaspMl: [], + nistAi600: [], + ffiec: [], + euAiAct: [], + nistSp80053r5: [ + 'ac-3', 'ac-4', 'ac-6', 'ac-16', 'ac-21', + 'au-2', 'au-6', 'ca-7', 'ca-8', 'si-4', 'si-7', + ], + iso42001: ['A-7-2', 'A-7-3', 'A-9-2'], + mitreAtlas: [], + }, + mitigates: ['AIR-SEC-002', 'AIR-RC-022'], + relatedMitigations: ['mi-12', 'mi-2', 'mi-4'], + calmControlKey: 'source-data-access-controls', + airId: 'AIR-DET-016', + }, + { + id: 'mi-17', + sequence: 17, + title: 'AI Firewall Implementation and Management', + type: 'PREV', + description: + 'An AI Firewall is a specialized security system designed to protect AI models and applications by inspecting, filtering, and controlling the data and interactions flowing to and from them. It mitigates emerging AI-specific threats including malicious inputs like prompt injection, data exfiltration, model integrity attacks, AI agent misuse, harmful content generation, and unauthorized access, providing deep inspection, real-time monitoring, and enforcement of security and ethical guardrails.', + externalRefs: { + owaspLlm: [], + owaspMl: [], + nistAi600: [], + ffiec: [], + euAiAct: [], + nistSp80053r5: ['ac-4', 'sc-5', 'sc-7', 'si-3', 'si-4', 'si-10', 'si-15'], + iso42001: ['A-6-1-3', 'A-6-2-2', 'A-9-2'], + mitreAtlas: [], + }, + mitigates: ['AIR-OP-007', 'AIR-SEC-010', 'AIR-OP-018', 'AIR-OP-020'], + relatedMitigations: ['mi-3', 'mi-8', 'mi-15'], + calmControlKey: 'ai-firewall', + airId: 'AIR-PREV-017', + }, + { + id: 'mi-18', + sequence: 18, + title: 'Agent Authority Least Privilege Framework', + type: 'PREV', + description: + 'The Agent Authority Least Privilege Framework implements granular access controls ensuring agents can only access APIs, tools, and data strictly necessary for their designated functions. This preventive control establishes dynamic privilege management, contextual access restrictions, and comprehensive authorization enforcement to prevent agents from exceeding their intended operational scope and causing unauthorized actions or regulatory violations.', + externalRefs: { + owaspLlm: [], + owaspMl: [], + nistAi600: [], + ffiec: [], + euAiAct: [], + nistSp80053r5: ['ac-6', 'ac-2', 'ac-3', 'ac-5'], + iso42001: [], + mitreAtlas: [], + }, + mitigates: ['AIR-SEC-024', 'AIR-OP-018'], + relatedMitigations: ['mi-12', 'mi-3'], + calmControlKey: 'agent-least-privilege', + airId: 'AIR-PREV-018', + }, + { + id: 'mi-19', + sequence: 19, + title: 'Tool Chain Validation and Sanitization', + type: 'PREV', + description: + 'Tool Chain Validation and Sanitization implements comprehensive validation mechanisms for agent tool selection decisions, API parameter sanitization, and safe tool execution sequences. This preventive control ensures that agents cannot be manipulated into selecting inappropriate tools, injecting malicious parameters into API calls, or executing dangerous tool combinations that could result in unauthorized actions or system compromise.', + externalRefs: { + owaspLlm: [], + owaspMl: [], + nistAi600: [], + ffiec: [], + euAiAct: [], + nistSp80053r5: ['si-10', 'si-15', 'sc-4'], + iso42001: [], + mitreAtlas: [], + }, + mitigates: ['AIR-SEC-025', 'AIR-SEC-010', 'AIR-SEC-024'], + relatedMitigations: ['mi-3', 'mi-18'], + calmControlKey: 'tool-chain-validation', + airId: 'AIR-PREV-019', + }, + { + id: 'mi-20', + sequence: 20, + title: 'MCP Server Security Governance', + type: 'PREV', + description: + 'MCP Server Security Governance establishes comprehensive security controls for Model Context Protocol servers including supply chain verification, secure communication channels, data integrity validation, and continuous monitoring. This preventive control ensures that MCP servers providing specialized capabilities to agentic AI systems maintain appropriate security standards and cannot be used as vectors for systematic compromise of agent decision-making.', + externalRefs: { + owaspLlm: [], + owaspMl: [], + nistAi600: [], + ffiec: [], + euAiAct: [], + nistSp80053r5: ['sa-9', 'sc-8', 'si-4', 'sa-12'], + iso42001: [], + mitreAtlas: [], + }, + mitigates: ['AIR-SEC-026', 'AIR-SEC-008', 'AIR-RC-001'], + relatedMitigations: ['mi-7', 'mi-4'], + calmControlKey: 'mcp-security', + airId: 'AIR-PREV-020', + }, + { + id: 'mi-21', + sequence: 21, + title: 'Agent Decision Audit and Explainability', + type: 'DET', + description: + 'Agent Decision Audit and Explainability implements comprehensive logging, documentation, and explainability mechanisms for agent decisions to support regulatory compliance, security incident investigation, and decision accountability. This detective control ensures that all agent actions, reasoning processes, and decision factors are captured in sufficient detail to meet regulatory requirements and enable effective forensic analysis when incidents occur.', + externalRefs: { + owaspLlm: [], + owaspMl: [], + nistAi600: [], + ffiec: [], + euAiAct: [], + nistSp80053r5: ['au-2', 'au-3', 'au-6', 'ca-7'], + iso42001: ['A-8-3', 'A-6-2-6'], + mitreAtlas: [], + }, + mitigates: ['AIR-SEC-024', 'AIR-SEC-025', 'AIR-RC-022'], + relatedMitigations: ['mi-4', 'mi-11'], + calmControlKey: 'decision-audit', + airId: 'AIR-DET-021', + }, + { + id: 'mi-22', + sequence: 22, + title: 'Multi-Agent Isolation and Segmentation', + type: 'PREV', + description: + 'Multi-Agent Isolation and Segmentation implements comprehensive security boundaries between agents in multi-agent systems to prevent cross-agent compromise, limit blast radius of security incidents, and maintain appropriate trust boundaries. This preventive control ensures that compromise or malfunction of one agent cannot systematically affect other agents, protecting the integrity of complex multi-agent workflows in financial services.', + externalRefs: { + owaspLlm: [], + owaspMl: [], + nistAi600: [], + ffiec: [], + euAiAct: [], + nistSp80053r5: ['sc-7', 'sc-32', 'ac-4', 'sc-3'], + iso42001: [], + mitreAtlas: [], + }, + mitigates: ['AIR-OP-028', 'AIR-SEC-024', 'AIR-SEC-027'], + relatedMitigations: ['mi-18', 'mi-12'], + calmControlKey: 'agent-isolation', + airId: 'AIR-PREV-022', + }, + { + id: 'mi-23', + sequence: 23, + title: 'Agentic System Credential Protection Framework', + type: 'PREV', + description: + 'The Agentic System Credential Protection Framework implements comprehensive security controls to prevent agents from discovering, accessing, or exfiltrating authentication credentials, API keys, secrets, and other sensitive authentication materials. This preventive control establishes credential isolation, secure credential injection mechanisms, behavioral monitoring, and zero-trust authentication architectures specifically designed to protect against agent-mediated credential harvesting.', + externalRefs: { + owaspLlm: [], + owaspMl: [], + nistAi600: [], + ffiec: [], + euAiAct: [], + nistSp80053r5: ['ia-5', 'sc-28', 'ac-3', 'au-6'], + iso42001: [], + mitreAtlas: [], + }, + mitigates: ['AIR-SEC-029', 'AIR-SEC-024', 'AIR-SEC-026'], + relatedMitigations: ['mi-18', 'mi-14', 'mi-12'], + calmControlKey: 'credential-protection', + airId: 'AIR-PREV-023', + }, +]; + +/** + * Set of all known AIGF governance control keys. + * Use this to check if a control key belongs to the AIGF catalogue + * instead of prefix-matching on 'aigf-'. + */ +export const AIGF_CONTROL_KEYS: ReadonlySet = new Set( + aigfMitigations.map((m) => m.calmControlKey) +); diff --git a/calm-plugins/vscode/src/core/aigf/mappings.test.ts b/calm-plugins/vscode/src/core/aigf/mappings.test.ts new file mode 100644 index 000000000..8e25540b9 --- /dev/null +++ b/calm-plugins/vscode/src/core/aigf/mappings.test.ts @@ -0,0 +1,82 @@ +// SPDX-FileCopyrightText: 2024 CalmStudio contributors - see NOTICE file +// +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, expect } from 'vitest'; +import { getAIGFForNodeType, isAINode } from './mappings.js'; + +describe('AIGF Mappings', () => { + it("getAIGFForNodeType('ai:llm') returns non-empty risks and mitigations", () => { + const result = getAIGFForNodeType('ai:llm'); + expect(result.risks.length).toBeGreaterThan(0); + expect(result.mitigations.length).toBeGreaterThan(0); + }); + + it("getAIGFForNodeType('ai:agent') returns agent-specific risks (AIR-SEC-024, AIR-OP-018)", () => { + const result = getAIGFForNodeType('ai:agent'); + const riskIds = result.risks.map((r) => r.id); + expect(riskIds).toContain('AIR-SEC-024'); + expect(riskIds).toContain('AIR-OP-018'); + }); + + it("getAIGFForNodeType('ai:guardrail') returns empty risks (guardrails ARE the mitigation)", () => { + const result = getAIGFForNodeType('ai:guardrail'); + expect(result.risks).toHaveLength(0); + }); + + it("getAIGFForNodeType('ai:human-in-the-loop') returns empty risks", () => { + const result = getAIGFForNodeType('ai:human-in-the-loop'); + expect(result.risks).toHaveLength(0); + }); + + it("getAIGFForNodeType('ai:eval-monitor') returns empty risks", () => { + const result = getAIGFForNodeType('ai:eval-monitor'); + expect(result.risks).toHaveLength(0); + }); + + it("getAIGFForNodeType('ai:mcp-server') returns AIR-SEC-025 with mi-19 and mi-21", () => { + const result = getAIGFForNodeType('ai:mcp-server'); + const riskIds = result.risks.map((r) => r.id); + const mitigationIds = result.mitigations.map((m) => m.id); + expect(riskIds).toEqual(['AIR-SEC-025']); + expect(mitigationIds).toContain('mi-19'); + expect(mitigationIds).toContain('mi-21'); + }); + + it("getAIGFForNodeType('ai:observability') returns empty risks and mitigations (observability IS the mitigation)", () => { + const result = getAIGFForNodeType('ai:observability'); + expect(result.risks).toHaveLength(0); + expect(result.mitigations).toHaveLength(0); + }); + + it("aigfNodeRiskMappings explicitly contains an entry for ai:observability", async () => { + const { aigfNodeRiskMappings } = await import('./mappings.js'); + const entry = aigfNodeRiskMappings.find((m) => m.nodeTypePattern === 'ai:observability'); + expect(entry).toBeDefined(); + expect(entry?.applicableRisks).toEqual([]); + expect(entry?.recommendedMitigations).toEqual([]); + }); + + it("getAIGFForNodeType('service') returns empty (non-AI node)", () => { + const result = getAIGFForNodeType('service'); + expect(result.risks).toHaveLength(0); + expect(result.mitigations).toHaveLength(0); + }); + + it('isAINode returns true for ai:llm', () => { + expect(isAINode('ai:llm')).toBe(true); + }); + + it('isAINode returns false for service', () => { + expect(isAINode('service')).toBe(false); + }); + + it('isAINode returns false for fluxnova:engine', () => { + expect(isAINode('fluxnova:engine')).toBe(false); + }); + + it('isAINode accepts node object with node-type property', () => { + expect(isAINode({ 'node-type': 'ai:llm' })).toBe(true); + expect(isAINode({ 'node-type': 'service' })).toBe(false); + }); +}); diff --git a/calm-plugins/vscode/src/core/aigf/mappings.ts b/calm-plugins/vscode/src/core/aigf/mappings.ts new file mode 100644 index 000000000..a3eb15d7e --- /dev/null +++ b/calm-plugins/vscode/src/core/aigf/mappings.ts @@ -0,0 +1,130 @@ +// SPDX-FileCopyrightText: 2024 CalmStudio contributors - see NOTICE file +// +// SPDX-License-Identifier: Apache-2.0 + +/** + * Node-type-to-AIGF-risk mappings and lookup functions. + * Maps AI pack node types to applicable FINOS AIGF v2.0 risks and recommended mitigations. + * Used for design-time governance in CalmStudio — surfaces relevant risks when an AI node + * is added to the architecture. + */ + +import type { AIGFRisk, AIGFMitigation, AIGFNodeRiskMapping } from './types.js'; +import { aigfRisks, aigfMitigations } from './catalogue.js'; + +/** + * Mapping table: AI node type -> applicable risk IDs + recommended mitigation IDs. + * Source: PRD B1.3 table from docs/REQ_fluxnova_aigf_integration.md + */ +export const aigfNodeRiskMappings: AIGFNodeRiskMapping[] = [ + { + nodeTypePattern: 'ai:llm', + applicableRisks: ['AIR-OP-004', 'AIR-OP-005', 'AIR-OP-006', 'AIR-RC-001'], + recommendedMitigations: ['mi-10', 'mi-3', 'mi-1', 'mi-15'], + }, + { + nodeTypePattern: 'ai:agent', + applicableRisks: ['AIR-SEC-024', 'AIR-OP-018', 'AIR-OP-028'], + recommendedMitigations: ['mi-18', 'mi-21', 'mi-22'], + }, + { + nodeTypePattern: 'ai:orchestrator', + applicableRisks: ['AIR-OP-028', 'AIR-SEC-025'], + recommendedMitigations: ['mi-22', 'mi-19', 'mi-21'], + }, + { + nodeTypePattern: 'ai:vector-store', + applicableRisks: ['AIR-SEC-002', 'AIR-SEC-009'], + recommendedMitigations: ['mi-2', 'mi-12', 'mi-14', 'mi-6'], + }, + { + nodeTypePattern: 'ai:tool', + applicableRisks: ['AIR-SEC-025'], + recommendedMitigations: ['mi-19'], + }, + { + nodeTypePattern: 'ai:memory', + applicableRisks: ['AIR-SEC-027'], + recommendedMitigations: ['mi-23', 'mi-14'], + }, + { + nodeTypePattern: 'ai:guardrail', + applicableRisks: [], + recommendedMitigations: [], + }, + { + nodeTypePattern: 'ai:rag-pipeline', + applicableRisks: ['AIR-OP-004', 'AIR-SEC-002'], + recommendedMitigations: ['mi-13', 'mi-2', 'mi-6'], + }, + { + nodeTypePattern: 'ai:knowledge-base', + applicableRisks: ['AIR-SEC-009', 'AIR-OP-019'], + recommendedMitigations: ['mi-6', 'mi-16'], + }, + { + nodeTypePattern: 'ai:embedding-model', + applicableRisks: ['AIR-SEC-008', 'AIR-OP-005'], + recommendedMitigations: ['mi-10', 'mi-5'], + }, + { + nodeTypePattern: 'ai:api-gateway', + applicableRisks: ['AIR-SEC-010', 'AIR-OP-007'], + recommendedMitigations: ['mi-3', 'mi-17', 'mi-8'], + }, + { + nodeTypePattern: 'ai:human-in-the-loop', + applicableRisks: [], + recommendedMitigations: [], + }, + { + nodeTypePattern: 'ai:eval-monitor', + applicableRisks: [], + recommendedMitigations: [], + }, + { + nodeTypePattern: 'ai:mcp-server', + applicableRisks: ['AIR-SEC-025'], + recommendedMitigations: ['mi-19', 'mi-21'], + }, + { + nodeTypePattern: 'ai:observability', + applicableRisks: [], + recommendedMitigations: [], + }, +]; + +/** + * Returns true if the given node (or node-type string) is an AI node. + * AI nodes have a node-type that starts with 'ai:'. + */ +export function isAINode(nodeOrType: { 'node-type': string } | string): boolean { + const nodeType = typeof nodeOrType === 'string' ? nodeOrType : nodeOrType['node-type']; + return nodeType.startsWith('ai:'); +} + +/** + * Returns the applicable AIGF risks and recommended mitigations for a given node type. + * Returns empty arrays for non-AI nodes or AI node types with no mapped risks + * (e.g. ai:guardrail, ai:human-in-the-loop, ai:eval-monitor — these ARE mitigations). + */ +export function getAIGFForNodeType(nodeType: string): { + risks: AIGFRisk[]; + mitigations: AIGFMitigation[]; +} { + const mapping = aigfNodeRiskMappings.find((m) => m.nodeTypePattern === nodeType); + + if (!mapping) { + return { risks: [], mitigations: [] }; + } + + const risks = mapping.applicableRisks + .map((id) => aigfRisks.find((r) => r.id === id)) + .filter((r): r is AIGFRisk => r !== undefined); + + const mitigations = mapping.recommendedMitigations + .map((id) => aigfMitigations.find((m) => m.id === id)) + .filter((m): m is AIGFMitigation => m !== undefined); + + return { risks, mitigations }; +} diff --git a/calm-plugins/vscode/src/core/aigf/types.ts b/calm-plugins/vscode/src/core/aigf/types.ts new file mode 100644 index 000000000..5afef8c78 --- /dev/null +++ b/calm-plugins/vscode/src/core/aigf/types.ts @@ -0,0 +1,70 @@ +// SPDX-FileCopyrightText: 2024 CalmStudio contributors - see NOTICE file +// +// SPDX-License-Identifier: Apache-2.0 + +/** + * AIGF Risk types: Operational, Security, Regulatory/Compliance + */ +export type AIGFRiskType = 'OP' | 'SEC' | 'RC'; + +/** + * AIGF Mitigation types: Preventive, Detective + */ +export type AIGFMitigationType = 'PREV' | 'DET'; + +/** + * External framework cross-references for an AIGF risk or mitigation. + * All fields are optional string arrays — frameworks may not cover every item. + */ +export interface AIGFExternalRefs { + owaspLlm?: string[]; + owaspMl?: string[]; + nistAi600?: string[]; + ffiec?: string[]; + euAiAct?: string[]; + nistSp80053r5?: string[]; + iso42001?: string[]; + mitreAtlas?: string[]; +} + +/** + * A single AI Governance Framework risk. + * Source: docs/AIGF_CATALOGUE.json — FINOS AIGF v2.0 + */ +export interface AIGFRisk { + id: string; + sequence: number; + title: string; + type: AIGFRiskType; + description: string; + externalRefs: AIGFExternalRefs; + relatedRisks: string[]; +} + +/** + * A single AI Governance Framework mitigation. + * Source: docs/AIGF_CATALOGUE.json — FINOS AIGF v2.0 + */ +export interface AIGFMitigation { + id: string; + sequence: number; + title: string; + type: AIGFMitigationType; + description: string; + externalRefs: AIGFExternalRefs; + mitigates: string[]; + relatedMitigations: string[]; + calmControlKey: string; + airId: string; +} + +/** + * Maps a node type pattern to applicable AIGF risks and recommended mitigations. + * Used for design-time governance — surfaces relevant risks when an AI node is added + * to the architecture. + */ +export interface AIGFNodeRiskMapping { + nodeTypePattern: string; + applicableRisks: string[]; + recommendedMitigations: string[]; +} diff --git a/calm-plugins/vscode/src/core/async-guard.ts b/calm-plugins/vscode/src/core/async-guard.ts deleted file mode 100644 index cc70642d9..000000000 --- a/calm-plugins/vscode/src/core/async-guard.ts +++ /dev/null @@ -1,9 +0,0 @@ -export class AsyncGuard { - private current?: Promise - async run(fn: () => Promise): Promise { - if (this.current) return this.current as Promise - const p = fn().finally(() => { this.current = undefined }) - this.current = p - return p - } -} diff --git a/calm-plugins/vscode/src/core/canonical-shape.test.ts b/calm-plugins/vscode/src/core/canonical-shape.test.ts new file mode 100644 index 000000000..8712958a2 --- /dev/null +++ b/calm-plugins/vscode/src/core/canonical-shape.test.ts @@ -0,0 +1,47 @@ +// SPDX-FileCopyrightText: 2026 CalmStudio Contributors +// +// SPDX-License-Identifier: Apache-2.0 + +/** + * RED tests for the @finos/calm-models adoption rework on PR #2553. + * + * Each assertion exercises a canonical behavior the current code does + * NOT support. They flip to GREEN as the adoption tasks land. + */ + +import { describe, expect, it } from 'vitest'; +import { validateCalmArchitecture } from './validation.js'; +import type { CalmArchitecture } from './types.js'; + +describe('canonical CALM shape — calm-models adoption (#2553 rework)', () => { + it('accepts connects endpoint with `interfaces?: string[]`', () => { + const arch: CalmArchitecture = { + nodes: [ + { 'unique-id': 'a', 'node-type': 'service', name: 'A', description: 'A' }, + { 'unique-id': 'b', 'node-type': 'service', name: 'B', description: 'B' }, + ], + relationships: [ + { + 'unique-id': 'a-to-b', + 'relationship-type': { + connects: { + source: { node: 'a', interfaces: ['iface-1'] }, + destination: { node: 'b' }, + }, + }, + }, + ], + }; + const issues = validateCalmArchitecture(arch); + const errors = issues.filter((i) => i.severity === 'error'); + expect(errors).toEqual([]); + }); + + // Upstream gap: even the canonical calm/release/1.2/meta/core.json puts + // `additionalProperties: false` *inside* `properties` (a property named + // "additionalProperties") rather than at the schema root. The constraint + // is therefore never enforced. Tracked upstream in #2552. When the + // canonical schema is fixed, flip `it.todo` → `it` and this assertion + // will pass via the symlinked canonical bundle. + it.todo('rejects an unknown field at the architecture root (blocked on upstream #2552)'); +}); diff --git a/calm-plugins/vscode/src/core/emitter.ts b/calm-plugins/vscode/src/core/emitter.ts deleted file mode 100644 index d3dba708e..000000000 --- a/calm-plugins/vscode/src/core/emitter.ts +++ /dev/null @@ -1,24 +0,0 @@ -// Simple event emitter for MVVM without vscode dependencies -export class Emitter { - private listeners: Array<(data: T) => void> = [] - - get event() { - return (listener: (data: T) => void) => { - this.listeners.push(listener) - return { - dispose: () => { - const index = this.listeners.indexOf(listener) - if (index >= 0) this.listeners.splice(index, 1) - } - } - } - } - - fire(data: T) { - this.listeners.forEach(listener => listener(data)) - } - - dispose() { - this.listeners = [] - } -} \ No newline at end of file diff --git a/calm-plugins/vscode/src/core/helpers.ts b/calm-plugins/vscode/src/core/helpers.ts new file mode 100644 index 000000000..b17fd368c --- /dev/null +++ b/calm-plugins/vscode/src/core/helpers.ts @@ -0,0 +1,106 @@ +// SPDX-FileCopyrightText: 2026 CalmStudio Contributors +// +// SPDX-License-Identifier: Apache-2.0 + +/** + * helpers.ts — Variant accessors over the canonical CALM relationship-type. + * + * Operates on the @finos/calm-models nested shape. These helpers are kept + * local to calm-studio because they encode CalmStudio-specific traversal + * conventions (e.g. defensive null handling for partial/malformed input + * that the studio editor may produce mid-keystroke). They are + * intentionally not in calm-models, which models the canonical schema only. + */ + +import type { + CalmRelationship, + CalmRelationshipType, + CalmRelationshipVariant, +} from './types.js'; + +/** + * Return the variant key actually present on a nested relationship-type. + * Useful for switching without exhaustively probing each variant. + */ +export function getRelationshipVariant( + rt: CalmRelationshipType, +): CalmRelationshipVariant { + if (rt.connects !== undefined) return 'connects'; + if (rt['composed-of'] !== undefined) return 'composed-of'; + if (rt.interacts !== undefined) return 'interacts'; + if (rt['deployed-in'] !== undefined) return 'deployed-in'; + return 'options'; +} + +/** + * Best-effort extraction of source/destination node ids from a connects + * variant. Returns null for any other variant. + */ +export function getConnectsEndpoints( + rel: CalmRelationship, +): { source: string; destination: string } | null { + const c = rel['relationship-type'].connects; + if (!c) return null; + return { source: c.source.node, destination: c.destination.node }; +} + +/** Return container + child-node ids for composed-of / deployed-in variants. */ +export function getContainerAndNodes( + rel: CalmRelationship, +): { container: string; nodes: string[] } | null { + const rt = rel['relationship-type']; + if (rt['composed-of']) { + return { container: rt['composed-of'].container, nodes: rt['composed-of'].nodes }; + } + if (rt['deployed-in']) { + return { container: rt['deployed-in'].container, nodes: rt['deployed-in'].nodes }; + } + return null; +} + +/** Return actor + interacted-with node ids for the interacts variant. */ +export function getActorAndNodes( + rel: CalmRelationship, +): { actor: string; nodes: string[] } | null { + const i = rel['relationship-type'].interacts; + if (!i) return null; + return { actor: i.actor, nodes: i.nodes }; +} + +/** + * Flatten any nested relationship to the set of node unique-ids it + * references. Used by validation / graph-traversal code that doesn't + * care about direction. Defensive: returns [] for partial input the + * editor may produce mid-keystroke. + */ +export function getReferencedNodeIds(rel: CalmRelationship): string[] { + const rt = rel['relationship-type']; + if (rt.connects) { + const out: string[] = []; + if (rt.connects.source?.node) out.push(rt.connects.source.node); + if (rt.connects.destination?.node) out.push(rt.connects.destination.node); + return out; + } + if (rt['composed-of']) { + const co = rt['composed-of']; + const out: string[] = []; + if (co.container) out.push(co.container); + if (Array.isArray(co.nodes)) out.push(...co.nodes); + return out; + } + if (rt['deployed-in']) { + const d = rt['deployed-in']; + const out: string[] = []; + if (d.container) out.push(d.container); + if (Array.isArray(d.nodes)) out.push(...d.nodes); + return out; + } + if (rt.interacts) { + const i = rt.interacts; + const out: string[] = []; + if (i.actor) out.push(i.actor); + if (Array.isArray(i.nodes)) out.push(...i.nodes); + return out; + } + return []; +} diff --git a/calm-plugins/vscode/src/core/index.ts b/calm-plugins/vscode/src/core/index.ts new file mode 100644 index 000000000..72487217e --- /dev/null +++ b/calm-plugins/vscode/src/core/index.ts @@ -0,0 +1,10 @@ +// SPDX-FileCopyrightText: 2024 CalmStudio contributors - see NOTICE file +// +// SPDX-License-Identifier: Apache-2.0 + +export * from './types.js'; +export * from './helpers.js'; +export * from './validation.js'; +export * from './aigf/types.js'; +export * from './aigf/catalogue.js'; +export * from './aigf/mappings.js'; diff --git a/calm-plugins/vscode/src/core/mediators/refresh-service.ts b/calm-plugins/vscode/src/core/mediators/refresh-service.ts deleted file mode 100644 index 4bca89fbd..000000000 --- a/calm-plugins/vscode/src/core/mediators/refresh-service.ts +++ /dev/null @@ -1,181 +0,0 @@ -import * as vscode from 'vscode' -import * as fs from 'fs' -import { detectFileType, FileType } from '../../models/file-types' -import { loadCalmModel, loadCalmTimeline, toGraph } from '../../models/model' -import { Config } from '../ports/config' -import type { Logger } from '../ports/logger' -import { ModelIndex } from '../../models/model-index' -import type { ApplicationStoreApi } from '../../application-store' - -export interface RefreshResult { - modelIndex: ModelIndex | undefined - isTemplateMode: boolean - isTimelineMode: boolean -} - -export class RefreshService { - private refreshTimeout: NodeJS.Timeout | undefined - - constructor( - private log: Logger, - private config: Config, // Use port instead of concrete service - private getPreview: () => - | { setData: (data: any) => void; postSelect: (id: string) => void } - | undefined, - private store: ApplicationStoreApi - ) { } - - getModelIndex() { - return this.store.getState().currentModelIndex - } - - async maybeRefresh(uri: vscode.Uri) { - const active = vscode.window.activeTextEditor?.document.uri - if (!active || active.fsPath !== uri.fsPath) return - if (this.refreshTimeout) clearTimeout(this.refreshTimeout) - this.refreshTimeout = setTimeout(() => { - const doc = vscode.window.activeTextEditor?.document - if (doc) void this.refreshForDocument(doc) - }, 250) - } - - async refreshForDocument(doc: vscode.TextDocument): Promise { - try { - this.log.info('[extension] Refreshing for document: ' + doc.uri.fsPath) - const fileInfo = detectFileType(doc.uri.fsPath) - this.log.info(`[extension] File type detected: ${fileInfo.type}, valid: ${fileInfo.isValid}`) - - // Handle timeline files - if (fileInfo.type === FileType.TimelineFile && fileInfo.isValid) { - return this.refreshForTimeline(doc) - } - - let model: any - let text: string - let isTemplateMode = false - - if (fileInfo.type === FileType.TemplateFile && fileInfo.isValid && fileInfo.architecturePath) { - this.log.info(`[extension] Template file detected, reading architecture: ${fileInfo.architecturePath}`) - if (!fs.existsSync(fileInfo.architecturePath)) { - this.log.error?.(`[extension] Architecture file not found: ${fileInfo.architecturePath}`) - return - } - text = fs.readFileSync(fileInfo.architecturePath, 'utf8') - model = loadCalmModel(text) - isTemplateMode = true - } else if (fileInfo.type === FileType.ArchitectureFile && fileInfo.isValid) { - text = doc.getText() - model = loadCalmModel(text) - } else { - this.log.info('[extension] File is not a valid CALM architecture or template file, skipping refresh') - return - } - - let docForIndex = doc - if (fileInfo.type === FileType.TemplateFile && fileInfo.isValid && fileInfo.architecturePath) { - docForIndex = { - getText: () => text, - positionAt: () => doc.positionAt(0), - uri: doc.uri - } as any - } - - const modelIndex = new ModelIndex(docForIndex, model) - - // Update store with new model and state - ViewModels will react automatically - const store = this.store.getState() - store.setModelIndex(modelIndex) - store.setTimeline(undefined) // Clear timeline when viewing architecture - // NOTE: Don't call setCurrentDocument here - it would re-trigger store reactions - // The caller (command or store reaction) is responsible for setting the document - - // Set template mode with proper architecture path - const architecturePath = isTemplateMode && fileInfo.architecturePath ? fileInfo.architecturePath : doc.uri.fsPath - store.setTemplateMode(isTemplateMode, isTemplateMode ? doc.uri.fsPath : undefined, architecturePath) - - const graph = toGraph(model, this.config) - const preview = this.getPreview() - - // Get current selection from store and check if it should be preserved - const currentSelection = this.store.getState().selectedElementId - const currentState = this.store.getState() - - // Determine if we should preserve the selection based on file relationships - let shouldPreserveSelection = false - if (currentSelection && currentState.architectureFilePath) { - // Get the architecture file path for the new document - let newArchitecturePath = doc.uri.fsPath - if (isTemplateMode && fileInfo.architecturePath) { - // For template files, use the referenced architecture file - newArchitecturePath = fileInfo.architecturePath - } - - // Debug logging - this.log.info(`[extension] DEBUG - Current arch path: ${currentState.architectureFilePath}`) - this.log.info(`[extension] DEBUG - New arch path: ${newArchitecturePath}`) - this.log.info(`[extension] DEBUG - Are paths equal: ${currentState.architectureFilePath === newArchitecturePath}`) - this.log.info(`[extension] DEBUG - Current selection: ${currentSelection}`) - - // Preserve selection if we're switching between related files (same architecture) - shouldPreserveSelection = currentState.architectureFilePath === newArchitecturePath - - if (shouldPreserveSelection) { - this.log.info(`[extension] Files are related, preserving selection: ${currentSelection}`) - } else { - this.log.info(`[extension] Files are unrelated (${currentState.architectureFilePath} vs ${newArchitecturePath}), clearing selection`) - } - } else { - this.log.info(`[extension] DEBUG - No current selection (${currentSelection}) or no current arch path (${currentState.architectureFilePath})`) - } - - const selectedId = shouldPreserveSelection ? currentSelection : undefined - preview?.setData({ graph, selectedId, settings: this.getPreviewSettings() }) - - // Update selection in UI - if (preview) { - if (selectedId) { - preview.postSelect(selectedId) - this.log.info(`[extension] Preserved TreeView selection: ${selectedId}`) - } else { - preview.postSelect('') - this.log.info('[extension] Cleared TreeView selection for new/unrelated file') - } - } - - return { modelIndex, isTemplateMode, isTimelineMode: false } - } catch (e: any) { - this.log.error?.(`Failed to refresh preview: ${e?.message || e}`) - if (e?.stack) this.log.error?.(`Stack trace: ${e.stack}`) - } - } - - private async refreshForTimeline(doc: vscode.TextDocument): Promise { - this.log.info('[extension] Processing timeline document') - - const text = doc.getText() - const timeline = loadCalmTimeline(text) - - if (!timeline) { - this.log.error?.('[extension] Failed to parse timeline document') - return - } - - this.log.info(`[extension] Timeline loaded with ${timeline.moments.length} moments`) - - // Update store with timeline - const store = this.store.getState() - store.setTimeline(timeline) - store.setModelIndex(undefined) // Clear architecture model - store.setTemplateMode(false) - - - return { modelIndex: undefined, isTemplateMode: false, isTimelineMode: true } - } - - private getPreviewSettings() { - return { - layout: this.config.previewLayout(), - showLabels: this.config.showLabels() - } - } -} diff --git a/calm-plugins/vscode/src/core/mediators/selection-service.ts b/calm-plugins/vscode/src/core/mediators/selection-service.ts deleted file mode 100644 index 2832c2cb1..000000000 --- a/calm-plugins/vscode/src/core/mediators/selection-service.ts +++ /dev/null @@ -1,100 +0,0 @@ -import * as vscode from 'vscode' -import type { ApplicationStoreApi } from '../../application-store' -import type { PreviewViewModelInterface } from '../../features/preview/preview.view-model' -import { NavigationService } from '../services/navigation-service' - -export interface TreeRevealer { - revealById(id: string): Promise -} - -/** - * Centralises selection propagation between: - * - TreeView (id) → Preview highlight → Editor reveal - * - Editor caret → Preview highlight - * - Preview click → Tree reveal → Editor reveal - * - * Now uses injected Zustand store as single source of truth for selection state - * Uses PreviewViewModelInterface for proper MVVM architecture - */ -export class SelectionService { - constructor( - private store: ApplicationStoreApi, - private getPreview: () => PreviewViewModelInterface | undefined, - private tree: TreeRevealer, - private revealInEditor: (doc: vscode.TextDocument, id: string) => Promise, - private navigation?: NavigationService - ) { } - - /** Tree selection changed */ - async syncFromTree(id: string) { - if (!id) return - - const store = this.store.getState() - if (store.isTemplateMode && id === 'template-mode-message') return - - console.log(`[selection-service] syncFromTree called with id: ${id}`) - - // Update store with new selection - store.setSelectedElement(id) - - const preview = this.getPreview() - console.log(`[selection-service] getPreview() returned:`, preview ? 'found preview' : 'NO PREVIEW') - - if (preview) { - console.log(`[selection-service] calling preview.postSelect(${id})`) - preview.postSelect(id) - } else { - console.log(`[selection-service] ERROR: No preview available to postSelect to!`) - } - - const uriPath = preview?.getCurrentUriPath() - const fallbackDoc = vscode.window.activeTextEditor?.document - const selDoc = uriPath ? (vscode.workspace.textDocuments.find(d => d.uri.fsPath === uriPath) || fallbackDoc) : fallbackDoc - if (selDoc) await this.revealInEditor(selDoc, id) - } - - /** Editor caret moved */ - syncFromEditor(editor: vscode.TextEditor) { - const store = this.store.getState() - const modelIndex = store.currentModelIndex - const preview = this.getPreview() - if (!modelIndex || !preview) return - - const id = modelIndex.idAt(editor.document, editor.selections[0].active) - if (id) { - store.setSelectedElement(id) - preview.postSelect(id) - } - } - - /** Preview node clicked */ - async syncFromPreview(id: string) { - const store = this.store.getState() - store.setSelectedElement(id) - - try { await this.tree.revealById(id) } catch { } - - // Attempt navigation first if available - const model = store.currentModelIndex - const node = model?.getNodes().find((n: any) => n.id === id) - - if (this.navigation && model && node?.raw) { - try { - const navigated = await this.navigation.navigate(id, node.raw) - if (navigated) { - // Navigated to a new file, skip editor reveal for current file - return - } - } catch (error) { - console.error('[selection-service] Error during navigation for id:', id, error) - // Continue with normal selection flow - } - } - - const preview = this.getPreview() - const uriPath = preview?.getCurrentUriPath() - const fallbackDoc = vscode.window.activeTextEditor?.document - const targetDoc = uriPath ? (vscode.workspace.textDocuments.find(d => d.uri.fsPath === uriPath) || fallbackDoc) : fallbackDoc - if (targetDoc) await this.revealInEditor(targetDoc, id) - } -} diff --git a/calm-plugins/vscode/src/core/mediators/store-reaction-mediator.ts b/calm-plugins/vscode/src/core/mediators/store-reaction-mediator.ts deleted file mode 100644 index 90506811e..000000000 --- a/calm-plugins/vscode/src/core/mediators/store-reaction-mediator.ts +++ /dev/null @@ -1,118 +0,0 @@ -import * as vscode from 'vscode' -import type { Logger } from '../ports/logger' -import type { ApplicationStoreApi } from '../../application-store' -import type { PreviewPanelFactory } from '../../features/preview/preview-panel-factory' -import type { RefreshService } from './refresh-service' -import type { SelectionService } from './selection-service' -import {Config} from "../ports/config"; - -/** - * StoreReactionMediator - Handles reactive coordination between store changes and various services - * Decouples the main extension controller from the complex store reaction logic - */ -export class StoreReactionMediator { - private disposables: vscode.Disposable[] = [] - - constructor( - private store: ApplicationStoreApi, - private previewPanelFactory: PreviewPanelFactory, - private refreshService: RefreshService, - private selectionService: SelectionService, - private log: Logger, - private context: vscode.ExtensionContext, - private configService: Config - ) {} - - /** - * Set up reactive relationships - services react to store changes - */ - setupReactions() { - let previousDocument: vscode.Uri | undefined - let previousSelection: string | undefined - - // Subscribe to all store changes and react appropriately - const unsubscribe = this.store.subscribe((state) => { - // React to forceCreatePreview flag (user explicitly requested preview) - // This must be checked first and independently of document changes - if (state.forceCreatePreview && state.currentDocumentUri) { - previousDocument = state.currentDocumentUri - this.handleDocumentChange(state.currentDocumentUri) - return // handleDocumentChange will clear the flag - } - - // React to document changes - if (state.currentDocumentUri !== previousDocument) { - previousDocument = state.currentDocumentUri - if (state.currentDocumentUri) { - this.handleDocumentChange(state.currentDocumentUri) - } - } - - // React to selection changes - if (state.selectedElementId !== previousSelection) { - previousSelection = state.selectedElementId - if (state.selectedElementId) { - this.handleSelectionChange(state.selectedElementId) - } - } - }) - - this.disposables.push({ dispose: unsubscribe }) - } - - private async handleDocumentChange(uri: vscode.Uri) { - this.log.info(`[extension] ========== Document changed to: ${uri.fsPath} ==========`) - - // Check if this is a user-initiated preview opening by checking if we should force-create - const state = this.store.getState() - const shouldForceCreate = state.forceCreatePreview - - if (shouldForceCreate) { - this.log.info('[extension] ✅ Force-creating preview panel for user command') - // Clear the flag immediately - state.setForceCreatePreview(false) - } else { - this.log.info('[extension] ⏭️ Skipping auto-open - checking if panel exists') - } - - let panel: any - if (shouldForceCreate) { - // Create the panel - this.log.info('[extension] 🏗️ Calling previewPanelFactory.createOrShow()...') - panel = this.previewPanelFactory.createOrShow(this.context, uri, this.configService, this.log) - this.log.info('[extension] ✅ Panel created/shown') - } else { - // Only refresh if preview panel is already open - don't auto-create - panel = this.previewPanelFactory.get() - if (!panel) { - this.log.info('[extension] ❌ No preview panel open, skipping auto-refresh') - return - } - this.log.info('[extension] ✅ Preview panel exists, will refresh') - } - - // Set up event handlers - const getCurrentSelection = () => this.store.getState().selectedElementId - panel.setGetCurrentTreeSelection(getCurrentSelection) - panel.onRevealInEditor(async (id: string) => { await this.selectionService.syncFromPreview(id) }) - panel.onDidSelect(async (id: string) => { await this.selectionService.syncFromPreview(id) }) - - // Refresh data for the document - this.log.info('[extension] 📂 Opening text document...') - const doc = await vscode.workspace.openTextDocument(uri) - this.log.info('[extension] 📄 Document opened, calling refreshForDocument()...') - await this.refreshService.refreshForDocument(doc) - this.log.info('[extension] ========== handleDocumentChange complete ==========') - } - - private handleSelectionChange(selectedId: string) { - const panel = this.previewPanelFactory.get() - if (panel) { - panel.postSelect(selectedId) - } - } - - dispose() { - this.disposables.forEach(d => { try { d.dispose() } catch { } }) - } -} \ No newline at end of file diff --git a/calm-plugins/vscode/src/core/mediators/watch-service.ts b/calm-plugins/vscode/src/core/mediators/watch-service.ts deleted file mode 100644 index 16d987575..000000000 --- a/calm-plugins/vscode/src/core/mediators/watch-service.ts +++ /dev/null @@ -1,67 +0,0 @@ -import * as vscode from 'vscode' -import { detectFileType, FileType } from '../../models/file-types' -import { Config } from '../ports/config' -import { RefreshService } from './refresh-service' - -export class WatchService { - private disposables: vscode.Disposable[] = [] - - constructor( - private config: Config, // Use port instead of concrete service - private refresh: RefreshService - ) {} - - registerAll(context: vscode.ExtensionContext) { - this.registerFileSystemWatchers(context) - this.registerDocumentLifecycle() - this.registerActiveEditorChange() - } - - dispose() { - this.disposables.forEach(d => { try { d.dispose() } catch {} }) - this.disposables = [] - } - - private registerFileSystemWatchers(context: vscode.ExtensionContext) { - const globs = this.config.filesGlobs() - const templateGlobs = this.config.templateGlobs() - const allGlobs = [...globs, ...templateGlobs] - const folders = vscode.workspace.workspaceFolders ?? [] - if (!folders.length) return - - for (const folder of folders) { - for (const g of allGlobs) { - const watcher = vscode.workspace.createFileSystemWatcher(new vscode.RelativePattern(folder, g)) - watcher.onDidChange(uri => this.refresh.maybeRefresh(uri)) - watcher.onDidCreate(uri => this.refresh.maybeRefresh(uri)) - watcher.onDidDelete(uri => this.refresh.maybeRefresh(uri)) - context.subscriptions.push(watcher) - } - } - } - - private registerDocumentLifecycle() { - this.disposables.push( - vscode.workspace.onDidSaveTextDocument(doc => this.refresh.maybeRefresh(doc.uri)) - ) - } - - private registerActiveEditorChange() { - this.disposables.push( - vscode.window.onDidChangeActiveTextEditor(editor => { - // The controller still handles reveal + isTemplateMode, so we only trigger refresh here if needed. - if (!editor) return - const doc = editor.document - const ft = detectFileType(doc.uri.fsPath) - if ( - (ft.type === FileType.ArchitectureFile && ft.isValid) || - (ft.type === FileType.TemplateFile && ft.isValid) || - (ft.type === FileType.TimelineFile && ft.isValid) - ) { - // Let the controller handle preview.reveal; we only refresh if the doc is valid - void this.refresh.refreshForDocument(doc) - } - }) - ) - } -} diff --git a/calm-plugins/vscode/src/core/nested-relationship-shape.test.ts b/calm-plugins/vscode/src/core/nested-relationship-shape.test.ts new file mode 100644 index 000000000..2d5aa2464 --- /dev/null +++ b/calm-plugins/vscode/src/core/nested-relationship-shape.test.ts @@ -0,0 +1,221 @@ +// SPDX-FileCopyrightText: 2026 CalmStudio Contributors +// +// SPDX-License-Identifier: Apache-2.0 + +/** + * Drives #2550 — CalmRelationship must be CALM 1.2 nested form, not flat. + * + * RED tests for the refactor: every assertion here demonstrates a current + * non-compliance with the FINOS CALM 1.2 meta-schema. They MUST fail before + * the refactor and pass after. + */ + +import { describe, expect, it } from 'vitest'; +import Ajv2020 from 'ajv/dist/2020.js'; +import addFormats from 'ajv-formats'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { validateCalmArchitecture } from './validation.js'; +import type { CalmArchitecture, CalmRelationship } from './types.js'; + +const here = fileURLToPath(new URL('.', import.meta.url)); +const loadSchema = (name: string) => + JSON.parse(readFileSync(resolve(here, 'schemas', name), 'utf-8')); + +function buildAjv(): Ajv2020 { + const ajv = new Ajv2020({ allErrors: true, strict: false, allowUnionTypes: true }); + addFormats.default(ajv); + for (const f of [ + 'calm.json', + 'core.json', + 'control.json', + 'control-requirement.json', + 'interface.json', + 'flow.json', + 'evidence.json', + 'units.json', + ]) { + ajv.addSchema(loadSchema(f)); + } + return ajv; +} + +describe('CalmRelationship — CALM 1.2 nested form (#2550)', () => { + it('TypeScript: CalmRelationship.relationship-type must be an object, not a string union', () => { + // A nested-form relationship must satisfy the TypeScript type. This is a + // compile-time assertion — if `CalmRelationship['relationship-type']` is still + // the flat string union, this object literal will fail typecheck. + const rel: CalmRelationship = { + 'unique-id': 'r1', + 'relationship-type': { + connects: { + source: { node: 'a' }, + destination: { node: 'b' }, + }, + }, + }; + expect(rel['relationship-type']).toBeTypeOf('object'); + expect(rel['relationship-type']).not.toBeTypeOf('string'); + }); + + it('TypeScript: composed-of variant accepts container + nodes', () => { + const rel: CalmRelationship = { + 'unique-id': 'r-co', + 'relationship-type': { + 'composed-of': { + container: 'system-a', + nodes: ['child-1', 'child-2'], + }, + }, + }; + const rt = rel['relationship-type']; + if (!('composed-of' in rt)) throw new Error('expected composed-of variant'); + expect(rt['composed-of'].container).toBe('system-a'); + expect(rt['composed-of'].nodes).toEqual(['child-1', 'child-2']); + }); + + it('TypeScript: interacts variant accepts actor + nodes', () => { + const rel: CalmRelationship = { + 'unique-id': 'r-int', + 'relationship-type': { + interacts: { + actor: 'user', + nodes: ['application'], + }, + }, + }; + const rt = rel['relationship-type']; + if (!('interacts' in rt)) throw new Error('expected interacts variant'); + expect(rt.interacts.actor).toBe('user'); + }); + + it('TypeScript: deployed-in variant accepts container + nodes', () => { + const rel: CalmRelationship = { + 'unique-id': 'r-dep', + 'relationship-type': { + 'deployed-in': { + container: 'k8s-cluster', + nodes: ['pod-1'], + }, + }, + }; + const rt = rel['relationship-type']; + if (!('deployed-in' in rt)) throw new Error('expected deployed-in variant'); + expect(rt['deployed-in'].container).toBe('k8s-cluster'); + }); + + it('validateCalmArchitecture accepts nested CALM 1.2 form (no errors)', () => { + const arch: CalmArchitecture = { + nodes: [ + { + 'unique-id': 'a', + 'node-type': 'service', + name: 'A', + description: 'A node', + }, + { + 'unique-id': 'b', + 'node-type': 'service', + name: 'B', + description: 'B node', + }, + ], + relationships: [ + { + 'unique-id': 'a-to-b', + 'relationship-type': { + connects: { + source: { node: 'a' }, + destination: { node: 'b' }, + }, + }, + }, + ], + }; + const issues = validateCalmArchitecture(arch); + const errors = issues.filter((i) => i.severity === 'error'); + expect(errors).toEqual([]); + }); + + it('validateCalmArchitecture rejects flat (legacy) form with at least one error', () => { + // Flat form is the bug. After the refactor, the validator must reject it. + // Cast through `unknown` because the new type must NOT accept this shape. + const arch = { + nodes: [ + { 'unique-id': 'a', 'node-type': 'service', name: 'A', description: '' }, + { 'unique-id': 'b', 'node-type': 'service', name: 'B', description: '' }, + ], + relationships: [ + { + 'unique-id': 'a-to-b', + 'relationship-type': 'connects', + source: 'a', + destination: 'b', + }, + ], + } as unknown as CalmArchitecture; + const issues = validateCalmArchitecture(arch); + const errors = issues.filter((i) => i.severity === 'error'); + expect(errors.length).toBeGreaterThan(0); + }); + + it('ajv 2020: nested-form arch validates against vendored calm.json meta-schema', () => { + const ajv = buildAjv(); + const validate = ajv.getSchema('https://calm.finos.org/release/1.2/meta/calm.json'); + expect(validate).toBeDefined(); + const arch = { + nodes: [ + { 'unique-id': 'a', 'node-type': 'service', name: 'A', description: 'A' }, + { 'unique-id': 'b', 'node-type': 'service', name: 'B', description: 'B' }, + { 'unique-id': 'c', 'node-type': 'system', name: 'C', description: 'C' }, + ], + relationships: [ + { + 'unique-id': 'a-to-b', + 'relationship-type': { + connects: { + source: { node: 'a' }, + destination: { node: 'b' }, + }, + }, + }, + { + 'unique-id': 'c-composed-of-a-b', + 'relationship-type': { + 'composed-of': { + container: 'c', + nodes: ['a', 'b'], + }, + }, + }, + ], + }; + const ok = validate!(arch); + if (!ok) { + console.log('ajv errors:', JSON.stringify(validate!.errors, null, 2)); + } + expect(ok).toBe(true); + }); + + it('ajv 2020: flat-form arch FAILS the vendored calm.json meta-schema', () => { + const ajv = buildAjv(); + const validate = ajv.getSchema('https://calm.finos.org/release/1.2/meta/calm.json'); + const arch = { + nodes: [ + { 'unique-id': 'a', 'node-type': 'service', name: 'A', description: 'A' }, + { 'unique-id': 'b', 'node-type': 'service', name: 'B', description: 'B' }, + ], + relationships: [ + { + 'unique-id': 'a-to-b', + 'relationship-type': 'connects', + source: 'a', + destination: 'b', + }, + ], + }; + const ok = validate!(arch); + expect(ok).toBe(false); + }); +}); diff --git a/calm-plugins/vscode/src/core/ports/config.ts b/calm-plugins/vscode/src/core/ports/config.ts deleted file mode 100644 index 3c49a51dc..000000000 --- a/calm-plugins/vscode/src/core/ports/config.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Configuration port - interface for accessing extension configuration - * Part of hexagonal architecture - allows different config implementations - */ -export interface Config { - filesGlobs(): string[] - templateGlobs(): string[] - previewLayout(): string - showLabels(): boolean - urlMapping(): string | undefined - docifyTheme(): string - schemaAdditionalFolders(): string[] -} diff --git a/calm-plugins/vscode/src/core/ports/logger.ts b/calm-plugins/vscode/src/core/ports/logger.ts deleted file mode 100644 index ff1269da8..000000000 --- a/calm-plugins/vscode/src/core/ports/logger.ts +++ /dev/null @@ -1,6 +0,0 @@ -export interface Logger { - info(msg: string): void - warn?(msg: string): void - error?(msg: string): void - debug?(msg: string): void -} diff --git a/calm-plugins/vscode/src/core/schemas/calm.json b/calm-plugins/vscode/src/core/schemas/calm.json new file mode 120000 index 000000000..3c9d6b64f --- /dev/null +++ b/calm-plugins/vscode/src/core/schemas/calm.json @@ -0,0 +1 @@ +../../../../../calm/release/1.2/meta/calm.json \ No newline at end of file diff --git a/calm-plugins/vscode/src/core/schemas/control-requirement.json b/calm-plugins/vscode/src/core/schemas/control-requirement.json new file mode 120000 index 000000000..15f829a98 --- /dev/null +++ b/calm-plugins/vscode/src/core/schemas/control-requirement.json @@ -0,0 +1 @@ +../../../../../calm/release/1.2/meta/control-requirement.json \ No newline at end of file diff --git a/calm-plugins/vscode/src/core/schemas/control.json b/calm-plugins/vscode/src/core/schemas/control.json new file mode 120000 index 000000000..3c83f3305 --- /dev/null +++ b/calm-plugins/vscode/src/core/schemas/control.json @@ -0,0 +1 @@ +../../../../../calm/release/1.2/meta/control.json \ No newline at end of file diff --git a/calm-plugins/vscode/src/core/schemas/core.json b/calm-plugins/vscode/src/core/schemas/core.json new file mode 120000 index 000000000..133bc70e7 --- /dev/null +++ b/calm-plugins/vscode/src/core/schemas/core.json @@ -0,0 +1 @@ +../../../../../calm/release/1.2/meta/core.json \ No newline at end of file diff --git a/calm-plugins/vscode/src/core/schemas/evidence.json b/calm-plugins/vscode/src/core/schemas/evidence.json new file mode 120000 index 000000000..55816402b --- /dev/null +++ b/calm-plugins/vscode/src/core/schemas/evidence.json @@ -0,0 +1 @@ +../../../../../calm/release/1.2/meta/evidence.json \ No newline at end of file diff --git a/calm-plugins/vscode/src/core/schemas/flow.json b/calm-plugins/vscode/src/core/schemas/flow.json new file mode 120000 index 000000000..a9592ee87 --- /dev/null +++ b/calm-plugins/vscode/src/core/schemas/flow.json @@ -0,0 +1 @@ +../../../../../calm/release/1.2/meta/flow.json \ No newline at end of file diff --git a/calm-plugins/vscode/src/core/schemas/interface.json b/calm-plugins/vscode/src/core/schemas/interface.json new file mode 120000 index 000000000..c6ba6044c --- /dev/null +++ b/calm-plugins/vscode/src/core/schemas/interface.json @@ -0,0 +1 @@ +../../../../../calm/release/1.2/meta/interface.json \ No newline at end of file diff --git a/calm-plugins/vscode/src/core/schemas/units.json b/calm-plugins/vscode/src/core/schemas/units.json new file mode 120000 index 000000000..f42cf0b35 --- /dev/null +++ b/calm-plugins/vscode/src/core/schemas/units.json @@ -0,0 +1 @@ +../../../../../calm/release/1.2/meta/units.json \ No newline at end of file diff --git a/calm-plugins/vscode/src/core/services/calm-schema-registry.spec.ts b/calm-plugins/vscode/src/core/services/calm-schema-registry.spec.ts deleted file mode 100644 index 185fbe7ba..000000000 --- a/calm-plugins/vscode/src/core/services/calm-schema-registry.spec.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { CalmSchemaRegistry } from './calm-schema-registry' -import type { Logger } from '../ports/logger' -import type { Config } from '../ports/config' -import { TEST_1_2_SCHEMA_AND_ABOVE, TEST_ALL_SCHEMA } from '../../test/test-utils' - -// Mock vscode module -vi.mock('vscode', () => ({ - workspace: { - workspaceFolders: [{ uri: { fsPath: '/workspace' } }], - fs: { - readDirectory: vi.fn(), - readFile: vi.fn() - } - }, - Uri: { - file: vi.fn(function (path: string) { return { fsPath: path }; }), - joinPath: vi.fn(function (_base: any, ...parts: string[]) { return { fsPath: `${_base.fsPath}/${parts.join('/')}` }; }) - }, - FileType: { - File: 1, - Directory: 2 - } -})) - -describe('CalmSchemaRegistry', () => { - let registry: CalmSchemaRegistry - let mockLogger: Logger - let mockConfig: Config - let mockExtensionUri: any - - beforeEach(() => { - vi.clearAllMocks() - - mockLogger = { - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - debug: vi.fn() - } - - mockConfig = { - filesGlobs: vi.fn(function () { return []; }), - templateGlobs: vi.fn(function () { return []; }), - previewLayout: vi.fn(function () { return 'dagre'; }), - showLabels: vi.fn(function () { return true; }), - urlMapping: vi.fn(function () { return undefined; }), - schemaAdditionalFolders: vi.fn(function () { return []; }) - } - - mockExtensionUri = { fsPath: '/extension' } - - registry = new CalmSchemaRegistry(mockExtensionUri, mockLogger, mockConfig) - }) - - describe('isKnownCalmSchema', () => { - it.each(TEST_ALL_SCHEMA)('should recognize CALM release schema URLs - schema %s', (schema) => { - expect(registry.isKnownCalmSchema(`https://calm.finos.org/release/${schema}/meta/calm.json`)).toBe(true) - }) - - it.each(TEST_1_2_SCHEMA_AND_ABOVE)('should recognize CALM decorator and timeline schema URLs - schema %s', (schema) => { - expect(registry.isKnownCalmSchema(`https://calm.finos.org/release/${schema}/meta/calm-timeline.json`)).toBe(true) - expect(registry.isKnownCalmSchema(`https://calm.finos.org/release/${schema}/meta/timeline.json`)).toBe(true) - expect(registry.isKnownCalmSchema(`https://calm.finos.org/release/${schema}/meta/decorator.json`)).toBe(true) - }) - - it('should recognize CALM draft schema URLs', () => { - expect(registry.isKnownCalmSchema('https://calm.finos.org/draft/2025-03/meta/calm.json')).toBe(true) - }) - - it.each(TEST_ALL_SCHEMA)('should recognize other CALM meta schema files - schema %s', (schema) => { - expect(registry.isKnownCalmSchema(`https://calm.finos.org/release/${schema}/meta/core.json`)).toBe(true) - expect(registry.isKnownCalmSchema(`https://calm.finos.org/release/${schema}/meta/flow.json`)).toBe(true) - expect(registry.isKnownCalmSchema(`https://calm.finos.org/release/${schema}/meta/timeline.json`)).toBe(true) - }) - - it('should not recognize non-CALM URLs', () => { - expect(registry.isKnownCalmSchema('https://json-schema.org/draft/2020-12/schema')).toBe(false) - expect(registry.isKnownCalmSchema('https://example.com/schema.json')).toBe(false) - }) - - it('should not recognize URLs with wrong path structure', () => { - expect(registry.isKnownCalmSchema('https://calm.finos.org/other/path/schema.json')).toBe(false) - }) - }) - - describe('reset', () => { - it('should clear schemas and mark as not initialized', async () => { - // First initialize - const vscode = await import('vscode') - vi.mocked(vscode.workspace.fs.readDirectory).mockResolvedValue([]) - - await registry.initialize() - - // Then reset - registry.reset() - - // getRegisteredSchemaUrls should be empty after reset - expect(registry.getRegisteredSchemaUrls()).toHaveLength(0) - }) - }) - - describe('getSchemaPath', () => { - it('should return undefined for unregistered schemas', () => { - expect(registry.getSchemaPath('https://calm.finos.org/release/any/meta/calm.json')).toBeUndefined() - }) - }) - - describe('getRegisteredSchemaUrls', () => { - it('should return empty array initially', () => { - expect(registry.getRegisteredSchemaUrls()).toEqual([]) - }) - }) - - describe('initialize', () => { - it.each(TEST_ALL_SCHEMA)('should load %s schemas from bundled directory', async (schema) => { - const vscode = await import('vscode') - - // Mock directory structure: dist/calm/release/___/meta/calm.json - vi.mocked(vscode.workspace.fs.readDirectory) - .mockResolvedValueOnce([['release', 2]]) // dist/calm - .mockResolvedValueOnce([[schema, 2]]) // dist/calm/release - .mockResolvedValueOnce([['meta', 2]]) // dist/calm/release/___ - .mockResolvedValueOnce([['calm.json', 1]]) // dist/calm/release/___/meta - - vi.mocked(vscode.workspace.fs.readFile).mockResolvedValue( - Buffer.from(JSON.stringify({ - $id: `https://calm.finos.org/release/${schema}/meta/calm.json`, - title: 'CALM Schema' - })) - ) - - await registry.initialize() - - expect(registry.getSchemaPath(`https://calm.finos.org/release/${schema}/meta/calm.json`)).toBeDefined() - expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining('initialized with')) - }) - - it('should handle missing directories gracefully', async () => { - const vscode = await import('vscode') - - vi.mocked(vscode.workspace.fs.readDirectory).mockRejectedValue(new Error('Directory not found')) - - await registry.initialize() - - // Should not throw - silently handles missing directory - expect(registry.getRegisteredSchemaUrls()).toHaveLength(0) - }) - - it('should only initialize once', async () => { - const vscode = await import('vscode') - vi.mocked(vscode.workspace.fs.readDirectory).mockResolvedValue([]) - - await registry.initialize() - await registry.initialize() - - // readDirectory should only be called for the first initialization - expect(vscode.workspace.fs.readDirectory).toHaveBeenCalledTimes(1) - }) - - it('should load schemas from additional folders', async () => { - const vscode = await import('vscode') - - // Configure additional folders - vi.mocked(mockConfig.schemaAdditionalFolders).mockReturnValue(['my-schemas']) - - // Mock bundled schemas directory (empty) - vi.mocked(vscode.workspace.fs.readDirectory) - .mockResolvedValueOnce([]) // dist/calm (empty) - .mockResolvedValueOnce([['custom.json', 1]]) // my-schemas - - vi.mocked(vscode.workspace.fs.readFile).mockResolvedValue( - Buffer.from(JSON.stringify({ - $id: 'https://my-org.com/schemas/custom.json', - title: 'Custom Schema' - })) - ) - - await registry.initialize() - - expect(registry.getSchemaPath('https://my-org.com/schemas/custom.json')).toBeDefined() - }) - }) -}) diff --git a/calm-plugins/vscode/src/core/services/calm-schema-registry.ts b/calm-plugins/vscode/src/core/services/calm-schema-registry.ts deleted file mode 100644 index 657e2be8d..000000000 --- a/calm-plugins/vscode/src/core/services/calm-schema-registry.ts +++ /dev/null @@ -1,156 +0,0 @@ -import * as vscode from 'vscode' -import type { Logger } from '../ports/logger' -import type { Config } from '../ports/config' - -/** - * Known CALM schema URL pattern. - * Matches URLs like: - * - https://calm.finos.org/release/1.2/meta/calm-timeline.json - * - https://calm.finos.org/release/1.1/meta/calm.json - * - https://calm.finos.org/draft/2025-03/meta/calm.json - */ -const CALM_SCHEMA_URL_PATTERN = /^https:\/\/calm\.finos\.org\/(release|draft)\/([^/]+)\/meta\/(.+\.json)$/ - -/** - * Registry of CALM schemas bundled with the extension and from additional folders. - * Maps schema $id URLs to local file paths for offline validation. - */ -export class CalmSchemaRegistry { - private schemas: Map = new Map() - private initialized = false - - constructor( - private readonly extensionUri: vscode.Uri, - private readonly logger: Logger, - private readonly config: Config - ) { } - - /** - * Initialize the registry by scanning bundled schemas and additional folders. - */ - async initialize(): Promise { - if (this.initialized) { - return - } - - this.schemas.clear() - - // Load bundled schemas from extension dist/calm folder - await this.loadBundledSchemas() - - // Load schemas from additional configured folders - await this.loadAdditionalSchemas() - - this.initialized = true - this.logger.info?.(`CalmSchemaRegistry initialized with ${this.schemas.size} schemas`) - } - - /** - * Reset the registry (e.g., when configuration changes). - */ - reset(): void { - this.initialized = false - this.schemas.clear() - } - - /** - * Check if a $schema URL is a known CALM schema. - */ - isKnownCalmSchema(schemaUrl: string): boolean { - // First check if it's in our registry - if (this.schemas.has(schemaUrl)) { - return true - } - - // Also check if it matches the CALM schema URL pattern - // (even if not bundled, it's still a CALM document) - return CALM_SCHEMA_URL_PATTERN.test(schemaUrl) - } - - /** - * Get the local file path for a schema URL, if available. - */ - getSchemaPath(schemaUrl: string): string | undefined { - return this.schemas.get(schemaUrl) - } - - /** - * Get all registered schema URLs. - */ - getRegisteredSchemaUrls(): string[] { - return Array.from(this.schemas.keys()) - } - - /** - * Load bundled schemas from the extension's dist/calm folder. - */ - private async loadBundledSchemas(): Promise { - const calmDir = vscode.Uri.joinPath(this.extensionUri, 'dist', 'calm') - - try { - await this.loadSchemasFromDirectory(calmDir, 'bundled') - } catch (error) { - this.logger.warn?.(`Could not load bundled schemas: ${error}`) - } - } - - /** - * Load schemas from additional configured folders. - */ - private async loadAdditionalSchemas(): Promise { - const additionalFolders = this.config.schemaAdditionalFolders() - const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri - - if (!workspaceRoot || additionalFolders.length === 0) { - return - } - - for (const folder of additionalFolders) { - const folderUri = vscode.Uri.joinPath(workspaceRoot, folder) - try { - await this.loadSchemasFromDirectory(folderUri, `additional (${folder})`) - } catch (error) { - this.logger.warn?.(`Could not load schemas from ${folder}: ${error}`) - } - } - } - - /** - * Recursively load all JSON schemas from a directory, extracting $id for registration. - */ - private async loadSchemasFromDirectory(dirUri: vscode.Uri, source: string): Promise { - let entries: [string, vscode.FileType][] - try { - entries = await vscode.workspace.fs.readDirectory(dirUri) - } catch { - return // Directory doesn't exist - } - - for (const [name, type] of entries) { - const entryUri = vscode.Uri.joinPath(dirUri, name) - - if (type === vscode.FileType.Directory) { - await this.loadSchemasFromDirectory(entryUri, source) - } else if (type === vscode.FileType.File && name.endsWith('.json')) { - await this.loadSchemaFile(entryUri, source) - } - } - } - - /** - * Load a single schema file and register it by its $id. - */ - private async loadSchemaFile(fileUri: vscode.Uri, source: string): Promise { - try { - const content = await vscode.workspace.fs.readFile(fileUri) - const json = JSON.parse(Buffer.from(content).toString('utf-8')) - - if (json.$id && typeof json.$id === 'string') { - this.schemas.set(json.$id, fileUri.fsPath) - this.logger.debug?.(`Registered schema: ${json.$id} (${source})`) - } - } catch (error) { - this.logger.debug?.(`Could not parse schema ${fileUri.fsPath}: ${error}`) - } - } -} diff --git a/calm-plugins/vscode/src/core/services/config-service.ts b/calm-plugins/vscode/src/core/services/config-service.ts deleted file mode 100644 index bbf90f36d..000000000 --- a/calm-plugins/vscode/src/core/services/config-service.ts +++ /dev/null @@ -1,60 +0,0 @@ -import * as vscode from 'vscode' -import { Config } from '../ports/config' - -const themeMapping: { [key: string]: string } = { - 'Abyss': 'dark', - 'Default High Contrast': 'high-contrast-dark', - 'Default High Contrast Light': 'high-contrast-light', - 'Monokai': 'dark', - 'Monokai Dimmed': 'dark', - 'Red': 'dark', - 'Tomorrow Night Blue': 'dark' -} - -export class ConfigService implements Config { - private get config() { - return vscode.workspace.getConfiguration('calm') - } - - filesGlobs(): string[] { - return this.config.get('files.globs', ["calm/**/*.json", "calm/**/*.y?(a)ml"]) - } - - templateGlobs(): string[] { - return this.config.get('template.globs', ["**/*.md", "**/*.markdown", "**/*.hbs", "**/*.handlebars"]) - } - - previewLayout(): string { - return this.config.get('preview.layout', 'elk') - } - - showLabels(): boolean { - return this.config.get('preview.showLabels', true) - } - - urlMapping(): string | undefined { - return this.config.get('urlMapping') - } - - docifyTheme(): string { - const themeSetting = this.config.get('docify.theme', 'auto') - if (themeSetting === 'auto') { - const vscodeTheme: string = vscode.workspace.getConfiguration('workbench').get('colorTheme') || 'Default Light'; - - // Default to a heuristic that themes are 'Dark' if they say 'Dark'. - let chosenTheme = vscodeTheme.includes('Dark') ? 'dark' : 'light'; - - // Override with specific mappings - if (themeMapping[vscodeTheme]) { - chosenTheme = themeMapping[vscodeTheme]; - } - - return chosenTheme; - } - return themeSetting - } - - schemaAdditionalFolders(): string[] { - return this.config.get('schemas.additionalFolders', []) - } -} diff --git a/calm-plugins/vscode/src/core/services/diagnostics-service.ts b/calm-plugins/vscode/src/core/services/diagnostics-service.ts deleted file mode 100644 index 58201104b..000000000 --- a/calm-plugins/vscode/src/core/services/diagnostics-service.ts +++ /dev/null @@ -1,40 +0,0 @@ -import * as fs from 'fs/promises' -import * as path from 'path' -import type * as vscode from 'vscode' -import { SchemaDirectory } from '@finos/calm-shared' -import type { Logger } from '../ports/logger' - -export class DiagnosticsService { - constructor(private log: Logger) {} - - async logStartup(context: vscode.ExtensionContext) { - await this.logVersion(context) - this.log.info('Logger from @finos/calm-shared is working!') - await this.checkSchemaDirectory() - } - - private async logVersion(context: vscode.ExtensionContext) { - try { - const pkgPath = path.join(context.extensionUri.fsPath, 'package.json') - const buf = await fs.readFile(pkgPath, 'utf8') - const pj = JSON.parse(buf) - const extVersion = pj?.version ?? 'dev' - this.log.info('CALM extension version: v' + extVersion) - } catch { - this.log.info('CALM extension version: (unknown)') - } - } - - private async checkSchemaDirectory() { - try { - const dummyLoader = { - initialise: async () => {}, - loadMissingDocument: async (_documentId: string, _type: any) => ({}) - } - const dummy = new SchemaDirectory(dummyLoader, false) - this.log.info('SchemaDirectory loaded from @finos/calm-shared: ' + typeof dummy) - } catch (e: any) { - this.log.error?.('Failed to load SchemaDirectory from @finos/calm-shared: ' + (e?.message || e)) - } - } -} diff --git a/calm-plugins/vscode/src/core/services/diagram-export-service.spec.ts b/calm-plugins/vscode/src/core/services/diagram-export-service.spec.ts deleted file mode 100644 index 775eb6764..000000000 --- a/calm-plugins/vscode/src/core/services/diagram-export-service.spec.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { DiagramExportService } from './diagram-export-service' - -describe('DiagramExportService', () => { - const service = new DiagramExportService() - - describe('computeDefaultPath', () => { - it('uses the current file\'s directory and basename', () => { - const result = service.computeDefaultPath('/test/source/arch.json', '/test/workspace', 1, 'svg') - expect(result).toBe('/test/source/arch-diagram-1.svg') - }) - - it('uses the png extension and diagram index for png exports', () => { - const result = service.computeDefaultPath('/test/source/arch.json', '/test/workspace', 2, 'png') - expect(result).toBe('/test/source/arch-diagram-2.png') - }) - - it('falls back to the workspace root and a generic name when no file is open', () => { - const result = service.computeDefaultPath(undefined, '/test/workspace', 3, 'png') - expect(result).toBe('/test/workspace/diagram-diagram-3.png') - }) - - it('falls back to the current directory when neither a file nor a workspace root is open', () => { - const result = service.computeDefaultPath(undefined, undefined, 1, 'svg') - expect(result).toBe('diagram-diagram-1.svg') - }) - }) - - describe('decodeExportData', () => { - it('decodes svg data as a utf8 buffer', () => { - const result = service.decodeExportData('svg', 'diagram') - expect(result).toEqual(Buffer.from('diagram', 'utf8')) - }) - - it('decodes png data as a base64-decoded buffer', () => { - const result = service.decodeExportData('png', 'QkJC') - expect(result).toEqual(Buffer.from('QkJC', 'base64')) - }) - }) -}) diff --git a/calm-plugins/vscode/src/core/services/diagram-export-service.ts b/calm-plugins/vscode/src/core/services/diagram-export-service.ts deleted file mode 100644 index 0b891d661..000000000 --- a/calm-plugins/vscode/src/core/services/diagram-export-service.ts +++ /dev/null @@ -1,31 +0,0 @@ -import * as path from 'path' - -export type DiagramExportFormat = 'svg' | 'png' - -/** - * DiagramExportService - Framework-free service for diagram export logic. - * Handles default save path computation and data decoding; the panel (View) - * only handles VSCode-specific I/O (showSaveDialog, workspace.fs.writeFile). - */ -export class DiagramExportService { - /** - * Default save path: same directory as the open CALM file (or the - * workspace root if none is open), named `-diagram-.`. - */ - computeDefaultPath( - currentFilePath: string | undefined, - workspaceRoot: string | undefined, - diagramIndex: number, - format: DiagramExportFormat - ): string { - const dir = currentFilePath ? path.dirname(currentFilePath) : (workspaceRoot ?? '.') - const baseName = currentFilePath - ? path.basename(currentFilePath, path.extname(currentFilePath)) - : 'diagram' - return path.join(dir, `${baseName}-diagram-${diagramIndex}.${format}`) - } - - decodeExportData(format: DiagramExportFormat, data: string): Buffer { - return format === 'svg' ? Buffer.from(data, 'utf8') : Buffer.from(data, 'base64') - } -} diff --git a/calm-plugins/vscode/src/core/services/logging-service.ts b/calm-plugins/vscode/src/core/services/logging-service.ts deleted file mode 100644 index 31f85bf72..000000000 --- a/calm-plugins/vscode/src/core/services/logging-service.ts +++ /dev/null @@ -1,37 +0,0 @@ -import * as vscode from 'vscode' -import { initLogger } from '@finos/calm-shared' -import type { Logger } from '../ports/logger' - -export class LoggingService implements Logger { - readonly output: vscode.OutputChannel - private shared: any - - constructor(scope: string) { - this.output = vscode.window.createOutputChannel('CALM') - this.shared = initLogger(true, scope) - } - - info(msg: string) { - this.output.appendLine(msg) - if (this.shared?.info) this.shared.info(msg) - } - - warn(msg: string) { - this.output.appendLine('[warn] ' + msg) - if (this.shared?.warn) this.shared.warn(msg) - } - - error(msg: string) { - this.output.appendLine('[error] ' + msg) - if (this.shared?.error) this.shared.error(msg) - } - - debug(msg: string) { - this.output.appendLine('[debug] ' + msg) - if (this.shared?.debug) this.shared.debug(msg) - } - - dispose() { - this.output.dispose() - } -} diff --git a/calm-plugins/vscode/src/core/services/model-service.spec.ts b/calm-plugins/vscode/src/core/services/model-service.spec.ts deleted file mode 100644 index ce4508c78..000000000 --- a/calm-plugins/vscode/src/core/services/model-service.spec.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { ModelService } from './model-service' -import * as fs from 'fs' - -// Mock fs module -vi.mock('fs', () => ({ - readFileSync: vi.fn(), - promises: { - readFile: vi.fn() - } -})) - -describe('ModelService', () => { - let modelService: ModelService - - beforeEach(() => { - modelService = new ModelService() - vi.clearAllMocks() - }) - - afterEach(() => { - vi.resetAllMocks() - }) - - describe('readModel (sync)', () => { - it('should parse JSON files', () => { - const mockJson = '{"nodes": [], "relationships": []}' - vi.mocked(fs.readFileSync).mockReturnValue(mockJson) - - const result = modelService.readModel('/path/to/file.json') - - expect(fs.readFileSync).toHaveBeenCalledWith('/path/to/file.json', 'utf8') - expect(result).toEqual({ nodes: [], relationships: [] }) - }) - - it('should parse YAML files', () => { - const mockYaml = 'nodes: []\nrelationships: []' - vi.mocked(fs.readFileSync).mockReturnValue(mockYaml) - - const result = modelService.readModel('/path/to/file.yaml') - - expect(fs.readFileSync).toHaveBeenCalledWith('/path/to/file.yaml', 'utf8') - expect(result).toEqual({ nodes: [], relationships: [] }) - }) - - it('should parse YML files', () => { - const mockYaml = 'nodes: []\nrelationships: []' - vi.mocked(fs.readFileSync).mockReturnValue(mockYaml) - - const result = modelService.readModel('/path/to/file.yml') - - expect(fs.readFileSync).toHaveBeenCalledWith('/path/to/file.yml', 'utf8') - expect(result).toEqual({ nodes: [], relationships: [] }) - }) - - it('should return raw content for unknown file types', () => { - const mockContent = 'some unknown content' - vi.mocked(fs.readFileSync).mockReturnValue(mockContent) - - const result = modelService.readModel('/path/to/file.txt') - - expect(result).toEqual({ raw: mockContent, format: 'unknown' }) - }) - }) - - describe('readModelAsync', () => { - it('should parse JSON files asynchronously', async () => { - const mockJson = '{"nodes": [], "relationships": []}' - vi.mocked(fs.promises.readFile).mockResolvedValue(mockJson) - - const result = await modelService.readModelAsync('/path/to/file.json') - - expect(fs.promises.readFile).toHaveBeenCalledWith('/path/to/file.json', 'utf8') - expect(result).toEqual({ nodes: [], relationships: [] }) - }) - - it('should parse YAML files asynchronously', async () => { - const mockYaml = 'nodes: []\nrelationships: []' - vi.mocked(fs.promises.readFile).mockResolvedValue(mockYaml) - - const result = await modelService.readModelAsync('/path/to/file.yaml') - - expect(fs.promises.readFile).toHaveBeenCalledWith('/path/to/file.yaml', 'utf8') - expect(result).toEqual({ nodes: [], relationships: [] }) - }) - - it('should return raw content for unknown file types asynchronously', async () => { - const mockContent = 'some unknown content' - vi.mocked(fs.promises.readFile).mockResolvedValue(mockContent) - - const result = await modelService.readModelAsync('/path/to/file.txt') - - expect(result).toEqual({ raw: mockContent, format: 'unknown' }) - }) - - it('should not block the event loop for large files', async () => { - const largeJson = JSON.stringify({ nodes: Array(1000).fill({ id: 'test' }) }) - vi.mocked(fs.promises.readFile).mockResolvedValue(largeJson) - - const startTime = Date.now() - await modelService.readModelAsync('/path/to/large.json') - const endTime = Date.now() - - // Async read should complete quickly (mocked) - expect(endTime - startTime).toBeLessThan(100) - }) - }) - - describe('filterBySelection', () => { - const mockModelData = { - nodes: [ - { 'unique-id': 'node-1', name: 'Node 1' }, - { 'unique-id': 'node-2', name: 'Node 2' } - ], - relationships: [ - { 'unique-id': 'rel-1', source: 'node-1', target: 'node-2' } - ], - flows: [ - { 'unique-id': 'flow-1', name: 'Flow 1' } - ] - } - - it('should return full model when no selection', () => { - const result = modelService.filterBySelection(mockModelData, undefined) - expect(result).toEqual(mockModelData) - }) - - it('should return full model when selection starts with group:', () => { - const result = modelService.filterBySelection(mockModelData, 'group:nodes') - expect(result).toEqual(mockModelData) - }) - - it('should return specific node when node is selected', () => { - const result = modelService.filterBySelection(mockModelData, 'node-1') - expect(result).toEqual({ 'unique-id': 'node-1', name: 'Node 1' }) - }) - - it('should return specific relationship when relationship is selected', () => { - const result = modelService.filterBySelection(mockModelData, 'rel-1') - expect(result).toEqual({ 'unique-id': 'rel-1', source: 'node-1', target: 'node-2' }) - }) - - it('should return specific flow when flow is selected', () => { - const result = modelService.filterBySelection(mockModelData, 'flow-1') - expect(result).toEqual({ 'unique-id': 'flow-1', name: 'Flow 1' }) - }) - - it('should return full model when selection not found', () => { - const result = modelService.filterBySelection(mockModelData, 'unknown-id') - expect(result).toEqual(mockModelData) - }) - }) -}) diff --git a/calm-plugins/vscode/src/core/services/model-service.ts b/calm-plugins/vscode/src/core/services/model-service.ts deleted file mode 100644 index a93b2bc8f..000000000 --- a/calm-plugins/vscode/src/core/services/model-service.ts +++ /dev/null @@ -1,76 +0,0 @@ -import * as fs from 'fs' - -/** - * ModelService - Pure domain service for model file operations - * Framework-free service for reading and processing CALM model files - */ -export class ModelService { - constructor() { } - - /** - * Read and parse a model file asynchronously - * Prevents blocking the extension host for large files - */ - async readModelAsync(filePath: string): Promise { - const content = await fs.promises.readFile(filePath, 'utf8') - return this.parseContent(filePath, content) - } - - /** - * Read and parse a model file synchronously - * @deprecated Use readModelAsync for better performance with large files - */ - readModel(filePath: string): any { - const content = fs.readFileSync(filePath, 'utf8') - return this.parseContent(filePath, content) - } - - /** - * Parse file content based on file extension - */ - private parseContent(filePath: string, content: string): any { - if (filePath.endsWith('.json')) { - return JSON.parse(content) - } - - if (filePath.endsWith('.yml') || filePath.endsWith('.yaml')) { - try { - const yaml = require('yaml') - return yaml.parse(content) - } catch { - return { raw: content, format: 'yaml' } - } - } - - return { raw: content, format: 'unknown' } - } - - /** - * Filter model data by selected element ID - */ - filterBySelection(fullModelData: any, selectedId?: string): any { - if (!selectedId || selectedId.startsWith('group:')) { - return fullModelData - } - - // Check nodes - if (fullModelData?.nodes) { - const node = fullModelData.nodes.find((x: any) => x['unique-id'] === selectedId) - if (node) return node - } - - // Check relationships - if (fullModelData?.relationships) { - const relationship = fullModelData.relationships.find((x: any) => x['unique-id'] === selectedId) - if (relationship) return relationship - } - - // Check flows - if (fullModelData?.flows) { - const flow = fullModelData.flows.find((x: any) => x['unique-id'] === selectedId) - if (flow) return flow - } - - return fullModelData - } -} \ No newline at end of file diff --git a/calm-plugins/vscode/src/core/services/navigation-service.spec.ts b/calm-plugins/vscode/src/core/services/navigation-service.spec.ts deleted file mode 100644 index ac850934c..000000000 --- a/calm-plugins/vscode/src/core/services/navigation-service.spec.ts +++ /dev/null @@ -1,239 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { NavigationService } from './navigation-service' -import * as fs from 'fs' -import * as path from 'path' -import * as vscode from 'vscode' -import { buildDocumentLoader } from '@finos/calm-shared' - -// Mock vscode -vi.mock('vscode', () => ({ - workspace: { - workspaceFolders: undefined as { uri: { fsPath: string } }[] | undefined, - openTextDocument: vi.fn(), - }, - window: { - activeTextEditor: undefined, - showTextDocument: vi.fn(), - showInformationMessage: vi.fn(), - showWarningMessage: vi.fn().mockResolvedValue(undefined), - showErrorMessage: vi.fn(), - }, - Uri: { - file: vi.fn(function (f) { return { fsPath: f }; }), - }, - ViewColumn: { - One: 1, - }, - commands: { - executeCommand: vi.fn(), - }, -})) - -// Mock fs -vi.mock('fs', () => ({ - existsSync: vi.fn(), - readFileSync: vi.fn(), - promises: { - readFile: vi.fn() - } -})) - -// Mock document loader -vi.mock('@finos/calm-shared', () => ({ - buildDocumentLoader: vi.fn(), -})) - -describe('NavigationService', () => { - let navigationService: NavigationService - let mockLogger: any - let mockConfig: any - let mockDocLoader: any - - beforeEach(() => { - mockLogger = { - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } - - mockConfig = { - urlMapping: vi.fn(), - } - - mockDocLoader = { - resolvePath: vi.fn(), - } - - vi.mocked(buildDocumentLoader).mockReturnValue(mockDocLoader) - - // Reset vscode mocks - // @ts-ignore - vscode.workspace.workspaceFolders = undefined - - navigationService = new NavigationService(mockLogger, mockConfig) - }) - - afterEach(() => { - vi.clearAllMocks() - }) - - describe('reset', () => { - it('should clear internal state', () => { - navigationService.reset() - expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining('Configuration reset')) - }) - }) - - describe('navigate', () => { - it('should return false if node has no detailed-architecture', async () => { - const result = await navigationService.navigate('node1', { id: 'node1' }) - expect(result).toBe(false) - expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining('has no detailed-architecture')) - }) - - it('should return false if workspace is not open (loader not initialized)', async () => { - // Ensure no workspace folders - // @ts-ignore - vscode.workspace.workspaceFolders = undefined - - const result = await navigationService.navigate('node1', { - 'detailed-architecture': 'detail.json' - }) - - expect(result).toBe(false) - expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining('DocumentLoader not initialized')) - }) - - it('should initialize loader and navigate successfully', async () => { - // Setup workspace - const workspacePath = '/workspace' - // @ts-ignore - vscode.workspace.workspaceFolders = [{ uri: { fsPath: workspacePath } }] - - // Setup file existence - const targetPath = '/workspace/detail.json' - mockDocLoader.resolvePath.mockReturnValue(targetPath) - vi.mocked(fs.existsSync).mockReturnValue(true) - - // Setup open document - const mockDoc = { uri: { fsPath: targetPath } } - vi.mocked(vscode.workspace.openTextDocument).mockResolvedValue(mockDoc as any) - - // Use absolute path to avoid relative path handling - const result = await navigationService.navigate('node1', { - 'detailed-architecture': '/workspace/detail.json' - }) - - expect(buildDocumentLoader).toHaveBeenCalledWith(expect.objectContaining({ - basePath: workspacePath - })) - expect(mockDocLoader.resolvePath).toHaveBeenCalledWith('/workspace/detail.json') - expect(vscode.workspace.openTextDocument).toHaveBeenCalledWith(expect.objectContaining({ fsPath: targetPath })) - expect(vscode.window.showTextDocument).toHaveBeenCalledWith(mockDoc, expect.objectContaining({ - viewColumn: 1, - preview: false - })) - expect(result).toBe(true) - }) - - it('should handle missing target file', async () => { - // Setup workspace - const workspacePath = '/workspace' - // @ts-ignore - vscode.workspace.workspaceFolders = [{ uri: { fsPath: workspacePath } }] - - // Setup file missing - const targetPath = '/workspace/detail.json' - mockDocLoader.resolvePath.mockReturnValue(targetPath) - vi.mocked(fs.existsSync).mockReturnValue(false) - - const result = await navigationService.navigate('node1', { - 'detailed-architecture': 'detail.json' - }) - - expect(vscode.window.showWarningMessage).toHaveBeenCalledWith(expect.stringContaining('File not found')) - expect(result).toBe(false) - }) - - it('should suggest mapping for HTTP URLs when file not found', async () => { - // Setup workspace - const workspacePath = '/workspace' - // @ts-ignore - vscode.workspace.workspaceFolders = [{ uri: { fsPath: workspacePath } }] - - const httpUrl = 'http://example.com/arch.json' - mockDocLoader.resolvePath.mockReturnValue(undefined) // or whatever resolvePath returns for unresolvable - - const result = await navigationService.navigate('node1', { - 'detailed-architecture': httpUrl - }) - - expect(vscode.window.showWarningMessage).toHaveBeenCalledWith( - expect.stringContaining('Cannot open'), - expect.anything() - ) - expect(result).toBe(false) - }) - }) - - describe('URL Mapping', () => { - it('should load URL mapping from config', async () => { - // Setup workspace - const workspacePath = '/workspace' - // @ts-ignore - vscode.workspace.workspaceFolders = [{ uri: { fsPath: workspacePath } }] - - // Setup config - const mappingFile = 'mapping.json' - mockConfig.urlMapping.mockReturnValue(mappingFile) - - // Setup mapping file - const resolvedMappingPath = path.join(workspacePath, mappingFile) - vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify({ - 'http://example.com': 'local/path' - })) - - // Trigger initialization via navigate - await navigationService.navigate('node1', { 'detailed-architecture': 'test.json' }) - - expect(fs.promises.readFile).toHaveBeenCalledWith(resolvedMappingPath, 'utf-8') - expect(buildDocumentLoader).toHaveBeenCalledWith(expect.objectContaining({ - urlToLocalMap: expect.any(Map) - })) - - // Verify map content passed to loader - const callArgs = vi.mocked(buildDocumentLoader).mock.calls[0][0] - expect(callArgs.urlToLocalMap?.get('http://example.com')).toContain('local/path') - }) - - it('should handle missing mapping file', async () => { - // Setup workspace - const workspacePath = '/workspace' - // @ts-ignore - vscode.workspace.workspaceFolders = [{ uri: { fsPath: workspacePath } }] - - mockConfig.urlMapping.mockReturnValue('missing.json') - const error: any = new Error('ENOENT') - error.code = 'ENOENT' - vi.mocked(fs.promises.readFile).mockRejectedValue(error) - - await navigationService.navigate('node1', { 'detailed-architecture': 'test.json' }) - - expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('URL mapping file not found')) - }) - - it('should handle invalid mapping JSON', async () => { - // Setup workspace - const workspacePath = '/workspace' - // @ts-ignore - vscode.workspace.workspaceFolders = [{ uri: { fsPath: workspacePath } }] - - mockConfig.urlMapping.mockReturnValue('invalid.json') - vi.mocked(fs.promises.readFile).mockResolvedValue('{ invalid json') - - await navigationService.navigate('node1', { 'detailed-architecture': 'test.json' }) - - expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining('Invalid JSON in URL mapping file')) - }) - }) -}) diff --git a/calm-plugins/vscode/src/core/services/navigation-service.ts b/calm-plugins/vscode/src/core/services/navigation-service.ts deleted file mode 100644 index 5739947ff..000000000 --- a/calm-plugins/vscode/src/core/services/navigation-service.ts +++ /dev/null @@ -1,209 +0,0 @@ -import * as vscode from 'vscode' -import * as path from 'path' -import * as fs from 'fs' -import { - buildDocumentLoader, - DocumentLoader, - DocumentLoaderOptions -} from '@finos/calm-shared' -import { Config } from '../ports/config' -import type { Logger } from '../ports/logger' - -export class NavigationService { - private docLoader: DocumentLoader | undefined - private urlToLocalMap: Map = new Map() - - constructor( - private logger: Logger, - private config: Config - ) { } - - - - /** - * Reset service state to force re-initialization on next use - * (e.g. when configuration changes) - */ - reset() { - this.docLoader = undefined - this.urlToLocalMap.clear() - this.logger.info?.('[navigation] Configuration reset - will reload on next navigation') - } - - private async initializeLoader(basePath: string) { - const mappingPath = this.config.urlMapping() - let urlToLocalMap: Map | undefined - - // Load mapping if configured - if (mappingPath) { - try { - // Resolve mapping path relative to workspace root if needed - let resolvedMappingPath = mappingPath - if (!path.isAbsolute(mappingPath) && vscode.workspace.workspaceFolders?.[0]) { - resolvedMappingPath = path.join(vscode.workspace.workspaceFolders[0].uri.fsPath, mappingPath) - } - - try { - const content = await fs.promises.readFile(resolvedMappingPath, 'utf-8') - this.logger.info(`[navigation] Loading URL mapping from: ${resolvedMappingPath}`) - const mappingJson = JSON.parse(content) - urlToLocalMap = new Map( - Object.entries(mappingJson).map(([url, relativePath]) => [ - url, - path.resolve(path.dirname(resolvedMappingPath), String(relativePath)) - ]) - ) - this.urlToLocalMap = urlToLocalMap - } catch (err: any) { - if (err.code === 'ENOENT') { - this.logger.warn?.(`[navigation] URL mapping file not found: ${resolvedMappingPath}`) - } else { - throw err - } - } - } catch (err: any) { - if (err instanceof SyntaxError) { - this.logger.error?.( - `[navigation] Invalid JSON in URL mapping file "${mappingPath}": ${err.message}` - ) - } else { - this.logger.error?.( - `[navigation] Failed to load URL mapping from "${mappingPath}": ${err.message}` - ) - } - } - } - - const opts: DocumentLoaderOptions = { - basePath, - urlToLocalMap, - debug: true // Enable debug logging for troubleshooting - } - - this.logger.info('[navigation] Initializing DocumentLoader') - this.docLoader = buildDocumentLoader(opts) - } - - async navigate(nodeId: string, nodeRaw: any): Promise { - // Check for detailed-architecture property (direct or nested in details) - const detailedArch = nodeRaw?.['detailed-architecture'] || nodeRaw?.details?.['detailed-architecture'] - - if (!detailedArch) { - this.logger.info(`[navigation] Node ${nodeId} has no detailed-architecture property`) - return false - } - - return this.navigateToDetailedArchitecture(detailedArch) - } - - /** - * Navigate to a detailed-architecture reference, resolving via url mapping if configured - * For relative paths, resolves from the current active document's directory - */ - async navigateToDetailedArchitecture(detailedArch: string): Promise { - this.logger.info(`[navigation] Attempting to navigate to detailed-architecture: ${detailedArch}`) - - // For relative paths (not URLs), resolve from current document's directory - if (!detailedArch.startsWith('http') && !path.isAbsolute(detailedArch)) { - const activeEditor = vscode.window.activeTextEditor - if (activeEditor) { - const currentDir = path.dirname(activeEditor.document.uri.fsPath) - const resolvedPath = path.resolve(currentDir, detailedArch) - this.logger.info(`[navigation] Resolved relative path to: ${resolvedPath}`) - - if (fs.existsSync(resolvedPath)) { - try { - const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(resolvedPath)) - await vscode.window.showTextDocument(doc, { - viewColumn: vscode.ViewColumn.One, - preview: false - }) - return true - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - this.logger.error?.(`[navigation] Failed to open file ${resolvedPath}: ${message}`) - vscode.window.showErrorMessage(`Failed to open file: ${resolvedPath}. ${message}`) - return false - } - } else { - vscode.window.showWarningMessage(`File not found: ${resolvedPath}`) - return false - } - } - } - - // Initialize loader with current workspace context if needed - if (!this.docLoader && vscode.workspace.workspaceFolders?.[0]) { - await this.initializeLoader(vscode.workspace.workspaceFolders[0].uri.fsPath) - } - - if (!this.docLoader) { - this.logger.error?.('[navigation] DocumentLoader not initialized (no workspace open?)') - return false - } - - // Use DocumentLoader to resolve the path (encapsulates mapping and relative path logic) - const targetPath = this.docLoader.resolvePath(detailedArch) - - if (targetPath && fs.existsSync(targetPath)) { - this.logger.info(`[navigation] Resolved to local file: ${targetPath}`) - try { - const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(targetPath)) - // Open in a new tab (preview: false) in Column 1, don't replace existing tabs - await vscode.window.showTextDocument(doc, { - viewColumn: vscode.ViewColumn.One, - preview: false // Opens as a permanent tab, not a preview that gets replaced - }) - return true - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - this.logger.error?.(`[navigation] Failed to open file ${targetPath}: ${message}`) - vscode.window.showErrorMessage(`Failed to open file: ${targetPath}. ${message}`) - return false - } - } else { - this.logger.warn?.(`[navigation] Could not resolve local file for: ${detailedArch}`) - - const mappingPath = this.config.urlMapping() - - if (detailedArch.startsWith('http')) { - if (!mappingPath) { - vscode.window.showWarningMessage( - `Cannot open "${detailedArch}". No URL mapping configured.\n\nSet "calm.urlMapping" in settings to point to a JSON file that maps URLs to local paths.`, - 'Open Settings' - ).then(selection => { - if (selection === 'Open Settings') { - vscode.commands.executeCommand('workbench.action.openSettings', 'calm.urlMapping') - } - }) - } else { - vscode.window.showWarningMessage( - `Cannot open "${detailedArch}".\n\nAdd a mapping for this URL in your calm-mapping.json file:\n"${detailedArch}": "./path/to/local/file.json"`, - 'Open Mapping File' - ).then(async selection => { - if (selection === 'Open Mapping File') { - const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath - if (!workspaceRoot && !path.isAbsolute(mappingPath)) { - vscode.window.showErrorMessage('Cannot open mapping file: no workspace folder open and path is relative') - return - } - try { - const resolvedPath = path.isAbsolute(mappingPath) - ? mappingPath - : path.join(workspaceRoot!, mappingPath) - const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(resolvedPath)) - await vscode.window.showTextDocument(doc) - } catch { - vscode.window.showErrorMessage(`Could not open mapping file: ${mappingPath}`) - } - } - }) - } - } else { - vscode.window.showWarningMessage(`File not found: ${detailedArch}`) - } - - return false - } - } -} diff --git a/calm-plugins/vscode/src/core/types.test.ts b/calm-plugins/vscode/src/core/types.test.ts new file mode 100644 index 000000000..a730ee337 --- /dev/null +++ b/calm-plugins/vscode/src/core/types.test.ts @@ -0,0 +1,259 @@ +// SPDX-FileCopyrightText: 2024 CalmStudio contributors - see NOTICE file +// +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, expect, expectTypeOf } from 'vitest'; +import type { + CalmNode, + CalmRelationship, + CalmArchitecture, + CalmDecorator, + CalmEvidence, + CalmControls, + CalmControl, + CalmControlRequirement, + CalmFlow, + CalmTransition, +} from './types.js'; + +describe('CalmFlow and CalmTransition types', () => { + it('CalmArchitecture accepts optional flows field with CalmFlow array', () => { + const arch: CalmArchitecture = { + nodes: [], + relationships: [], + flows: [ + { + 'unique-id': 'flow-1', + name: 'Auth Flow', + description: 'User authentication sequence', + transitions: [ + { + 'relationship-unique-id': 'rel-1', + 'sequence-number': 1, + description: 'User submits credentials', + }, + ], + }, + ], + }; + expect(arch.flows).toHaveLength(1); + expect(arch.flows?.[0]?.name).toBe('Auth Flow'); + }); + + it('CalmFlow has required fields: unique-id, name, description, transitions', () => { + const flow: CalmFlow = { + 'unique-id': 'flow-2', + name: 'Data Flow', + description: 'Data pipeline sequence', + transitions: [], + }; + expect(flow['unique-id']).toBe('flow-2'); + expect(flow.name).toBe('Data Flow'); + expect(flow.description).toBe('Data pipeline sequence'); + expect(flow.transitions).toEqual([]); + }); + + it('CalmTransition has required fields: relationship-unique-id, sequence-number, description', () => { + const transition: CalmTransition = { + 'relationship-unique-id': 'rel-2', + 'sequence-number': 3, + description: 'Service calls DB', + }; + expect(transition['relationship-unique-id']).toBe('rel-2'); + expect(transition['sequence-number']).toBe(3); + expect(transition.description).toBe('Service calls DB'); + expect(transition.direction).toBeUndefined(); + }); + + it('CalmTransition accepts optional direction field', () => { + const transition: CalmTransition = { + 'relationship-unique-id': 'rel-3', + 'sequence-number': 1, + description: 'Forward request', + direction: 'source-to-destination', + }; + expect(transition.direction).toBe('source-to-destination'); + }); +}); + +describe('CALM 1.2 type definitions', () => { + it('CalmNode accepts controls property without TypeScript error', () => { + const controls: CalmControls = { + 'edge-protection': { + description: 'Firewall for LLM inputs/outputs', + requirements: [ + { + 'requirement-url': 'https://example.com/req/fw-01', + 'config-url': 'https://example.com/config/fw-01', + }, + ], + }, + }; + + const node: CalmNode = { + 'unique-id': 'n1', + 'node-type': 'ai:llm', + name: 'LLM Node', + description: 'LLM node', + controls, + }; + + expect(node.controls).toBeDefined(); + expect(node.controls?.['edge-protection']).toBeDefined(); + }); + + it('CalmNode accepts data-classification property without TypeScript error', () => { + const node: CalmNode = { + 'unique-id': 'n2', + 'node-type': 'database', + name: 'Customer DB', + description: 'Customer database', + 'data-classification': 'PII', + }; + + expect(node['data-classification']).toBe('PII'); + }); + + it('CalmNode accepts metadata property without TypeScript error', () => { + const node: CalmNode = { + 'unique-id': 'n3', + 'node-type': 'service', + name: 'API Service', + description: 'API service', + metadata: { tier: 'critical', owner: 'team-alpha' }, + }; + + const meta = node.metadata as Record | undefined; + expect(meta?.['tier']).toBe('critical'); + }); + + it('CalmRelationship accepts controls property without TypeScript error', () => { + const rel: CalmRelationship = { + 'unique-id': 'r1', + 'relationship-type': { + connects: { + source: { node: 'n1' }, + destination: { node: 'n2' }, + }, + }, + controls: { + 'data-encryption': { + description: 'Encrypt data in transit', + requirements: [ + { + 'requirement-url': 'https://example.com/req/enc-01', + 'config-url': 'https://example.com/config/tls', + }, + ], + }, + }, + }; + + expect(rel.controls).toBeDefined(); + }); + + it('CalmRelationship accepts metadata property without TypeScript error', () => { + const rel: CalmRelationship = { + 'unique-id': 'r2', + 'relationship-type': { + interacts: { + actor: 'n1', + nodes: ['n3'], + }, + }, + metadata: { latency: 'low', protocol: 'gRPC' }, + }; + + const meta = rel.metadata as Record | undefined; + expect(meta?.['latency']).toBe('low'); + }); + + it('CalmArchitecture accepts decorators array without TypeScript error', () => { + const arch: CalmArchitecture = { + nodes: [], + relationships: [], + decorators: [ + { + 'unique-id': 'd1', + type: 'aigf-governance', + target: ['n1'], + 'applies-to': ['controls'], + data: { score: 85, unmitigated: 2 }, + }, + ], + }; + + expect(arch.decorators).toHaveLength(1); + }); + + it('CalmDecorator has required fields (unique-id, type, target, applies-to, data)', () => { + const decorator: CalmDecorator = { + 'unique-id': 'd1', + type: 'aigf-governance', + target: ['n1', 'n2'], + 'applies-to': ['controls', 'risks'], + data: { score: 75 }, + }; + + expect(decorator['unique-id']).toBe('d1'); + expect(decorator.type).toBe('aigf-governance'); + expect(decorator.target).toEqual(['n1', 'n2']); + expect(decorator['applies-to']).toEqual(['controls', 'risks']); + expect(decorator.data).toEqual({ score: 75 }); + }); + + it('CalmEvidence has required fields (unique-id, evidence-paths, control-config-url)', () => { + const evidence: CalmEvidence = { + 'unique-id': 'ev1', + 'evidence-paths': ['/evidence/scan-results.json', '/evidence/audit-log.txt'], + 'control-config-url': 'https://example.com/controls/aigf-fw-01', + }; + + expect(evidence['unique-id']).toBe('ev1'); + expect(evidence['evidence-paths']).toHaveLength(2); + expect(evidence['control-config-url']).toBe('https://example.com/controls/aigf-fw-01'); + }); + + it('CalmControlRequirement: config-url variant', () => { + const req: CalmControlRequirement = { + 'requirement-url': 'https://example.com/req/001', + 'config-url': 'https://example.com/config/001', + }; + + expect(req['requirement-url']).toBeDefined(); + if ('config-url' in req) { + expect(req['config-url']).toBe('https://example.com/config/001'); + } + }); + + it('CalmControlRequirement: inline config variant', () => { + const req: CalmControlRequirement = { + 'requirement-url': 'https://example.com/req/002', + config: { scanLevel: 'deep' }, + }; + + expect(req['requirement-url']).toBeDefined(); + if ('config' in req) { + expect(req.config['scanLevel']).toBe('deep'); + } + }); + + it('CalmControl has description and requirements array', () => { + const control: CalmControl = { + description: 'Prevent data leakage from AI systems', + requirements: [ + { + 'requirement-url': 'https://example.com/req/dlp-01', + config: { scanLevel: 'deep', piiPatterns: ['credit-card', 'ssn'] }, + }, + ], + }; + + expect(control.description).toBe('Prevent data leakage from AI systems'); + expect(control.requirements).toHaveLength(1); + const first = control.requirements[0]!; + if ('config' in first) { + expect(first.config['scanLevel']).toBe('deep'); + } + }); +}); diff --git a/calm-plugins/vscode/src/core/types.ts b/calm-plugins/vscode/src/core/types.ts new file mode 100644 index 000000000..13fa175b8 --- /dev/null +++ b/calm-plugins/vscode/src/core/types.ts @@ -0,0 +1,119 @@ +// SPDX-FileCopyrightText: 2024 CalmStudio contributors - see NOTICE file +// +// SPDX-License-Identifier: Apache-2.0 + +/** + * types.ts — Canonical CALM type re-exports from @finos/calm-models with + * backwards-compatible aliases for CalmStudio consumers. + * + * Per https://github.com/finos/architecture-as-code/pull/2553 review: + * calm-studio reuses the canonical types maintained in calm-models rather + * than maintaining a parallel vendored copy. Schema drift and feature drift + * (e.g. interface-level endpoint refs on connects relationships) are + * eliminated by depending on calm-models directly. + * + * Calm-studio-specific extensions (decorators, evidence) stay local because + * calm-models does not define them yet. Variant accessor helpers live in + * helpers.ts. + */ + +export type { + CalmNodeSchema as CalmNode, + CalmNodeTypeSchema as CalmNodeType, + CalmInterfaceSchema as CalmInterface, + CalmRelationshipSchema as CalmRelationship, + CalmRelationshipTypeSchema as CalmRelationshipType, + CalmConnectsRelationshipSchema as CalmConnectsRelationship, + CalmComposedOfRelationshipSchema as CalmComposedOfRelationship, + CalmInteractsRelationshipSchema as CalmInteractsRelationship, + CalmDeployedInRelationshipSchema as CalmDeployedInRelationship, + CalmOptionsRelationshipSchema as CalmOptionsRelationship, + CalmDecisionSchema as CalmDecision, + CalmProtocolSchema as CalmProtocol, + CalmCoreSchema as CalmCore, + CalmControlsSchema as CalmControls, + CalmControlSchema as CalmControl, + CalmControlDetailSchema as CalmControlRequirement, + CalmMetadataSchema as CalmMetadata, + CalmFlowSchema as CalmFlow, + CalmFlowTransitionSchema as CalmTransition, + CalmFlowTransitionDirectionSchema, + CalmNodeDetailsSchema as CalmNodeDetails, +} from '@finos/calm-models/types'; + +import type { + CalmArchitectureSchema, + CalmNodeSchema, + CalmRelationshipSchema, +} from '@finos/calm-models/types'; + +import type { CalmNodeInterfaceSchema } from '@finos/calm-models/types'; + +/** + * Backwards-compatible alias. Per calm-models the connects-endpoint shape + * is `{ node: string; interfaces?: string[] }` (interfaces refer to + * unique-ids of CalmInterface{Type,Definition}Schema). PR #2553 originally + * dropped the `interfaces` field; the alias restores it. + */ +export type CalmConnectsEndpoint = CalmNodeInterfaceSchema; + +/** + * Variant key alias — string literal union for routing/UI code that + * doesn't need the full discriminated payload. + */ +export type CalmRelationshipVariant = + | 'connects' + | 'interacts' + | 'deployed-in' + | 'composed-of' + | 'options'; + +// ─── CalmStudio-only extensions (not in calm-models) ──────────────────────── + +/** + * A CALM 1.2 decorator — architecture-wide overlay for cross-cutting concerns + * such as AIGF governance summaries, regulatory mappings, threat models, or + * security posture. Not yet in @finos/calm-models; lives locally until + * upstreamed. + */ +export interface CalmDecorator { + 'unique-id': string; + type: string; + target: string[]; + 'applies-to': string[]; + data: Record; +} + +/** + * CalmStudio architecture model: the canonical CALM 1.2 core schema with + * two studio-specific tightenings: + * + * - `nodes` and `relationships` are required (never undefined). The + * canonical `CalmCoreSchema` makes both optional because architectures + * mid-construction may have neither; CalmStudio's producers always + * populate both arrays (empty if the diagram is blank), so promoting + * them to required keeps `exactOptionalPropertyTypes: true` happy and + * removes a flood of `arch.nodes is possibly undefined` errors. + * + * - `decorators?: CalmDecorator[]` extension used by the AIGF + * governance overlay and the threat-model overlay (#2551). + * `@finos/calm-models` does not model decorators yet. + */ +export type CalmArchitecture = Omit< + CalmArchitectureSchema, + 'nodes' | 'relationships' +> & { + nodes: CalmNodeSchema[]; + relationships: CalmRelationshipSchema[]; + decorators?: CalmDecorator[]; +}; + +/** + * CALM 1.2 evidence — links a control to evidence of compliance. Studio + * supports the type for round-trip completeness only. + */ +export interface CalmEvidence { + 'unique-id': string; + 'evidence-paths': string[]; + 'control-config-url': string; +} diff --git a/calm-plugins/vscode/src/core/validation.test.ts b/calm-plugins/vscode/src/core/validation.test.ts new file mode 100644 index 000000000..a927349ec --- /dev/null +++ b/calm-plugins/vscode/src/core/validation.test.ts @@ -0,0 +1,535 @@ +// SPDX-FileCopyrightText: 2024 CalmStudio contributors - see NOTICE file +// +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, expect } from 'vitest'; +import { validateCalmArchitecture, type ValidationIssue } from './validation.js'; +import type { CalmArchitecture, CalmRelationship } from './types.js'; + +// Helper: make a minimal valid node +function makeNode(id: string, name: string, description?: string) { + return { + 'unique-id': id, + 'node-type': 'service' as const, + name, + description: description ?? `Description for ${name}` + }; +} + +// Helper: make a minimal valid relationship in CALM 1.2 nested `connects` form. +function makeRel(id: string, sourceNode: string, destNode: string): CalmRelationship { + return { + 'unique-id': id, + 'relationship-type': { + connects: { + source: { node: sourceNode }, + destination: { node: destNode } + } + } + }; +} + +describe('validateCalmArchitecture', () => { + it('valid architecture with 2 nodes + 1 relationship returns empty issues', () => { + const arch: CalmArchitecture = { + nodes: [makeNode('node-a', 'Node A'), makeNode('node-b', 'Node B')], + relationships: [makeRel('rel-1', 'node-a', 'node-b')] + }; + const issues = validateCalmArchitecture(arch); + const errors = issues.filter((i) => i.severity === 'error'); + expect(errors).toHaveLength(0); + }); + + it('empty architecture returns no errors', () => { + const arch: CalmArchitecture = { nodes: [], relationships: [] }; + const issues = validateCalmArchitecture(arch); + const errors = issues.filter((i) => i.severity === 'error'); + expect(errors).toHaveLength(0); + }); + + it('node missing unique-id returns error', () => { + const arch = { + nodes: [{ 'node-type': 'service', name: 'Missing ID', description: 'desc' }], + relationships: [] + } as unknown as CalmArchitecture; + const issues = validateCalmArchitecture(arch); + const errors = issues.filter((i) => i.severity === 'error'); + expect(errors.length).toBeGreaterThan(0); + }); + + it('node missing name returns error', () => { + const arch = { + nodes: [{ 'unique-id': 'node-1', 'node-type': 'service', description: 'desc' }], + relationships: [] + } as unknown as CalmArchitecture; + const issues = validateCalmArchitecture(arch); + const errors = issues.filter((i) => i.severity === 'error'); + expect(errors.length).toBeGreaterThan(0); + }); + + it('relationship with dangling source ref returns error with relationshipId', () => { + const arch: CalmArchitecture = { + nodes: [makeNode('node-b', 'Node B')], + relationships: [makeRel('rel-1', 'unknown-node', 'node-b')] + }; + const issues = validateCalmArchitecture(arch); + const errors = issues.filter((i) => i.severity === 'error' && i.relationshipId === 'rel-1'); + expect(errors.length).toBeGreaterThan(0); + }); + + it('duplicate node unique-ids returns error', () => { + const arch: CalmArchitecture = { + nodes: [makeNode('node-1', 'Node One'), makeNode('node-1', 'Node One Duplicate')], + relationships: [] + }; + const issues = validateCalmArchitecture(arch); + const errors = issues.filter((i) => i.severity === 'error' && i.nodeId === 'node-1'); + expect(errors.length).toBeGreaterThan(0); + }); + + it('orphan node (no relationships) returns warning with nodeId', () => { + const arch: CalmArchitecture = { + nodes: [makeNode('orphan', 'Orphan Node')], + relationships: [] + }; + const issues = validateCalmArchitecture(arch); + const warnings = issues.filter((i) => i.severity === 'warning' && i.nodeId === 'orphan'); + expect(warnings.length).toBeGreaterThan(0); + }); + + it('self-loop relationship returns warning with relationshipId', () => { + const arch: CalmArchitecture = { + nodes: [makeNode('node-a', 'Node A'), makeNode('node-b', 'Node B')], + relationships: [makeRel('rel-loop', 'node-a', 'node-a')] + }; + const issues = validateCalmArchitecture(arch); + const warnings = issues.filter( + (i) => i.severity === 'warning' && i.relationshipId === 'rel-loop' + ); + expect(warnings.length).toBeGreaterThan(0); + }); + + it('node missing description returns info issue', () => { + const arch = { + nodes: [ + { + 'unique-id': 'node-1', + 'node-type': 'service' as const, + name: 'No Desc Node' + // no description field + } + ], + relationships: [] + } as unknown as CalmArchitecture; + const issues = validateCalmArchitecture(arch); + const infos = issues.filter((i) => i.severity === 'info' && i.nodeId === 'node-1'); + expect(infos.length).toBeGreaterThan(0); + }); + + it('Ajv rejects architecture with wrong types (nodes is string not array)', () => { + const arch = { nodes: 'not-an-array', relationships: [] } as unknown as CalmArchitecture; + const issues = validateCalmArchitecture(arch); + const errors = issues.filter((i) => i.severity === 'error'); + expect(errors.length).toBeGreaterThan(0); + }); + + it('skips semantic rules when nodes is not an array', () => { + const arch = { nodes: 'bad', relationships: [] } as unknown as CalmArchitecture; + const issues = validateCalmArchitecture(arch); + const warnings = issues.filter((i) => i.severity === 'warning'); + expect(warnings).toHaveLength(0); + }); + + it('skips semantic rules when relationships is not an array', () => { + const arch = { nodes: [], relationships: 'bad' } as unknown as CalmArchitecture; + const issues = validateCalmArchitecture(arch); + const warnings = issues.filter((i) => i.severity === 'warning'); + expect(warnings).toHaveLength(0); + }); + + it('schema error on relationship extracts relationshipId', () => { + // A relationship with no relationship-type object at all is a schema violation. + const arch = { + nodes: [makeNode('node-a', 'Node A')], + relationships: [ + { + 'unique-id': 'rel-bad' + // missing relationship-type entirely + } + ] + } as unknown as CalmArchitecture; + const issues = validateCalmArchitecture(arch); + const schemaErrors = issues.filter( + (i) => i.severity === 'error' && i.path?.startsWith('/relationships/') + ); + expect(schemaErrors.length).toBeGreaterThan(0); + expect(schemaErrors[0]!.relationshipId).toBe('rel-bad'); + }); + + it('schema error on node without unique-id does not set nodeId', () => { + const arch = { + nodes: [{ 'node-type': '', name: 'X' }], // missing unique-id, empty node-type + relationships: [] + } as unknown as CalmArchitecture; + const issues = validateCalmArchitecture(arch); + const nodeSchemaErrors = issues.filter( + (i) => i.severity === 'error' && i.path?.startsWith('/nodes/') + ); + expect(nodeSchemaErrors.length).toBeGreaterThan(0); + // nodeId should be undefined since the node has no unique-id + expect(nodeSchemaErrors[0]!.nodeId).toBeUndefined(); + }); + + it('connects relationship missing source.node returns error', () => { + const arch = { + nodes: [makeNode('node-a', 'Node A')], + relationships: [ + { + 'unique-id': 'rel-1', + 'relationship-type': { + connects: { destination: { node: 'node-a' } } + } + } + ] + } as unknown as CalmArchitecture; + const issues = validateCalmArchitecture(arch); + const errors = issues.filter( + (i) => i.severity === 'error' && i.relationshipId === 'rel-1' + ); + expect(errors.length).toBeGreaterThan(0); + }); + + it('connects relationship missing destination.node returns error', () => { + const arch = { + nodes: [makeNode('node-a', 'Node A')], + relationships: [ + { + 'unique-id': 'rel-1', + 'relationship-type': { + connects: { source: { node: 'node-a' } } + } + } + ] + } as unknown as CalmArchitecture; + const issues = validateCalmArchitecture(arch); + const errors = issues.filter( + (i) => i.severity === 'error' && i.relationshipId === 'rel-1' + ); + expect(errors.length).toBeGreaterThan(0); + }); + + it('dangling destination reference returns error', () => { + const arch: CalmArchitecture = { + nodes: [makeNode('node-a', 'Node A')], + relationships: [makeRel('rel-1', 'node-a', 'ghost-node')] + }; + const issues = validateCalmArchitecture(arch); + const errors = issues.filter( + (i) => + i.severity === 'error' && + i.message.includes('ghost-node') && + i.relationshipId === 'rel-1' + ); + expect(errors.length).toBeGreaterThan(0); + }); + + it('duplicate relationship unique-ids returns error', () => { + const arch: CalmArchitecture = { + nodes: [makeNode('node-a', 'Node A'), makeNode('node-b', 'Node B')], + relationships: [ + makeRel('rel-dup', 'node-a', 'node-b'), + makeRel('rel-dup', 'node-b', 'node-a') + ] + }; + const issues = validateCalmArchitecture(arch); + const errors = issues.filter( + (i) => i.severity === 'error' && i.message.includes('Duplicate relationship') + ); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0]!.relationshipId).toBe('rel-dup'); + }); + + it('relationship without unique-id still surfaces dangling-ref error using "?"', () => { + const arch = { + nodes: [makeNode('node-a', 'Node A')], + relationships: [ + { + 'relationship-type': { + connects: { source: { node: 'node-a' }, destination: { node: 'ghost' } } + } + } + ] + } as unknown as CalmArchitecture; + const issues = validateCalmArchitecture(arch); + const danglingErrors = issues.filter( + (i) => i.severity === 'error' && i.message.includes('"?"') + ); + expect(danglingErrors.length).toBeGreaterThan(0); + expect(danglingErrors[0]!.relationshipId).toBeUndefined(); + }); + + it('node without unique-id is skipped in duplicate/semantic checks', () => { + const arch = { + nodes: [ + { 'node-type': 'service', name: 'No ID', description: 'desc' }, + makeNode('node-b', 'Node B') + ], + relationships: [] + } as unknown as CalmArchitecture; + const issues = validateCalmArchitecture(arch); + const dupErrors = issues.filter((i) => i.message.includes('Duplicate node')); + expect(dupErrors).toHaveLength(0); + }); + + it('node with empty/whitespace description returns info', () => { + const arch: CalmArchitecture = { + nodes: [ + { 'unique-id': 'node-1', 'node-type': 'service' as const, name: 'Test', description: ' ' } + ], + relationships: [] + }; + const issues = validateCalmArchitecture(arch); + const infos = issues.filter( + (i) => i.severity === 'info' && i.nodeId === 'node-1' && i.message.includes('no description') + ); + expect(infos.length).toBeGreaterThan(0); + }); + + it('dangling ref with zero nodes does not report dangling ref', () => { + const arch = { + nodes: [], + relationships: [ + { + 'unique-id': 'rel-1', + 'relationship-type': { + connects: { source: { node: 'x' }, destination: { node: 'y' } } + } + } + ] + } as unknown as CalmArchitecture; + const issues = validateCalmArchitecture(arch); + const danglingErrors = issues.filter( + (i) => i.severity === 'error' && i.message.includes('references unknown node') + ); + expect(danglingErrors).toHaveLength(0); + }); + + it('self-loop without relId uses "?" and omits relationshipId', () => { + const arch = { + nodes: [makeNode('node-a', 'Node A')], + relationships: [ + { + 'relationship-type': { + connects: { source: { node: 'node-a' }, destination: { node: 'node-a' } } + } + } + ] + } as unknown as CalmArchitecture; + const issues = validateCalmArchitecture(arch); + const selfLoop = issues.filter( + (i) => i.severity === 'warning' && i.message.includes('connects a node to itself') + ); + expect(selfLoop.length).toBeGreaterThan(0); + expect(selfLoop[0]!.message).toContain('"?"'); + expect(selfLoop[0]!.relationshipId).toBeUndefined(); + }); + + it('issues are sorted by severity: errors first, then warnings, then info', () => { + const arch: CalmArchitecture = { + nodes: [ + { 'unique-id': 'node-a', 'node-type': 'service' as const, name: 'A', description: '' }, + { 'unique-id': 'node-b', 'node-type': 'service' as const, name: 'B', description: '' } + ], + relationships: [makeRel('rel-1', 'node-a', 'ghost')] // error: dangling + }; + const issues = validateCalmArchitecture(arch); + expect(issues.length).toBeGreaterThanOrEqual(3); + const severities = issues.map((i) => i.severity); + const errorIdx = severities.indexOf('error'); + const warnIdx = severities.indexOf('warning'); + const infoIdx = severities.indexOf('info'); + if (errorIdx >= 0 && warnIdx >= 0) expect(errorIdx).toBeLessThan(warnIdx); + if (warnIdx >= 0 && infoIdx >= 0) expect(warnIdx).toBeLessThan(infoIdx); + }); + + it('schema error on relationship without unique-id does not set relationshipId', () => { + const arch = { + nodes: [], + relationships: [ + { 'relationship-type': {} } // empty variant object: schema violation, no unique-id + ] + } as unknown as CalmArchitecture; + const issues = validateCalmArchitecture(arch); + const relSchemaErrors = issues.filter( + (i) => i.severity === 'error' && i.path?.startsWith('/relationships/') + ); + expect(relSchemaErrors.length).toBeGreaterThan(0); + expect(relSchemaErrors[0]!.relationshipId).toBeUndefined(); + }); + + it('dangling source ref without relId uses "?" and omits relationshipId', () => { + const arch = { + nodes: [makeNode('node-a', 'Node A')], + relationships: [ + { + 'relationship-type': { + connects: { source: { node: 'ghost' }, destination: { node: 'node-a' } } + } + } + ] + } as unknown as CalmArchitecture; + const issues = validateCalmArchitecture(arch); + const dangling = issues.filter( + (i) => i.severity === 'error' && i.message.includes('ghost') + ); + expect(dangling.length).toBeGreaterThan(0); + expect(dangling[0]!.message).toContain('"?"'); + expect(dangling[0]!.relationshipId).toBeUndefined(); + }); + + it('dangling destination ref without relId uses "?" and omits relationshipId', () => { + const arch = { + nodes: [makeNode('node-a', 'Node A')], + relationships: [ + { + 'relationship-type': { + connects: { source: { node: 'node-a' }, destination: { node: 'ghost' } } + } + } + ] + } as unknown as CalmArchitecture; + const issues = validateCalmArchitecture(arch); + const dangling = issues.filter( + (i) => i.severity === 'error' && i.message.includes('ghost') + ); + expect(dangling.length).toBeGreaterThan(0); + expect(dangling[0]!.message).toContain('"?"'); + expect(dangling[0]!.relationshipId).toBeUndefined(); + }); + + it('ValidationIssue includes optional path, nodeId, relationshipId fields', () => { + const issue: ValidationIssue = { + severity: 'info', + message: 'test message', + nodeId: 'n1', + relationshipId: 'r1', + path: '/nodes/0' + }; + expect(issue.severity).toBe('info'); + expect(issue.path).toBe('/nodes/0'); + }); + + // ─── New tests covering CALM 1.2 nested variants ───────────────────────── + + it('composed-of variant: container + nodes are resolved as referenced nodes', () => { + const arch: CalmArchitecture = { + nodes: [ + makeNode('container', 'Container'), + makeNode('child-a', 'Child A'), + makeNode('child-b', 'Child B') + ], + relationships: [ + { + 'unique-id': 'co-1', + 'relationship-type': { + 'composed-of': { + container: 'container', + nodes: ['child-a', 'child-b'] + } + } + } + ] + }; + const issues = validateCalmArchitecture(arch); + const errors = issues.filter((i) => i.severity === 'error'); + expect(errors).toHaveLength(0); + const orphans = issues.filter( + (i) => + i.severity === 'warning' && + i.message.includes('not referenced by any relationship') + ); + expect(orphans).toHaveLength(0); + }); + + it('composed-of variant: dangling child node returns error', () => { + const arch: CalmArchitecture = { + nodes: [makeNode('container', 'Container'), makeNode('child-a', 'Child A')], + relationships: [ + { + 'unique-id': 'co-dangling', + 'relationship-type': { + 'composed-of': { + container: 'container', + nodes: ['child-a', 'ghost-child'] + } + } + } + ] + }; + const issues = validateCalmArchitecture(arch); + const errors = issues.filter( + (i) => i.severity === 'error' && i.message.includes('ghost-child') + ); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0]!.relationshipId).toBe('co-dangling'); + }); + + it('interacts variant: actor + nodes are resolved as referenced nodes', () => { + const arch: CalmArchitecture = { + nodes: [makeNode('actor', 'Actor'), makeNode('app', 'Application')], + relationships: [ + { + 'unique-id': 'i-1', + 'relationship-type': { + interacts: { + actor: 'actor', + nodes: ['app'] + } + } + } + ] + }; + const issues = validateCalmArchitecture(arch); + const errors = issues.filter((i) => i.severity === 'error'); + expect(errors).toHaveLength(0); + }); + + it('deployed-in variant: container + nodes are resolved as referenced nodes', () => { + const arch: CalmArchitecture = { + nodes: [makeNode('cluster', 'Cluster'), makeNode('pod-1', 'Pod 1')], + relationships: [ + { + 'unique-id': 'd-1', + 'relationship-type': { + 'deployed-in': { + container: 'cluster', + nodes: ['pod-1'] + } + } + } + ] + }; + const issues = validateCalmArchitecture(arch); + const errors = issues.filter((i) => i.severity === 'error'); + expect(errors).toHaveLength(0); + }); + + it('composed-of with empty nodes array returns error', () => { + const arch = { + nodes: [makeNode('container', 'Container')], + relationships: [ + { + 'unique-id': 'co-empty', + 'relationship-type': { + 'composed-of': { container: 'container', nodes: [] } + } + } + ] + } as unknown as CalmArchitecture; + const issues = validateCalmArchitecture(arch); + const errors = issues.filter( + (i) => i.severity === 'error' && i.relationshipId === 'co-empty' + ); + expect(errors.length).toBeGreaterThan(0); + }); +}); diff --git a/calm-plugins/vscode/src/core/validation.ts b/calm-plugins/vscode/src/core/validation.ts new file mode 100644 index 000000000..833f038fd --- /dev/null +++ b/calm-plugins/vscode/src/core/validation.ts @@ -0,0 +1,319 @@ +// SPDX-FileCopyrightText: 2026 CalmStudio Contributors +// +// SPDX-License-Identifier: Apache-2.0 + +/** + * validation.ts — Shared CALM 1.2 architecture validation engine. + * + * Validates a CalmArchitecture object against: + * 1. The FINOS CALM 1.2 meta-schema (vendored under `./schemas`) + * via Ajv (draft 2020-12). + * 2. Semantic rules: dangling refs, duplicates, orphan nodes, self-loops. + * 3. Info-level rules: nodes missing description. + * + * Shared between the studio (reactive store) and the MCP server. No Svelte + * or browser dependencies — pure TypeScript. + */ + +import Ajv2020 from 'ajv/dist/2020.js'; +import addFormats from 'ajv-formats'; +import type { CalmArchitecture, CalmRelationship } from './types.js'; +import { getRelationshipVariant, getReferencedNodeIds } from './helpers.js'; + +import calmSchema from './schemas/calm.json' with { type: 'json' }; +import coreSchema from './schemas/core.json' with { type: 'json' }; +import controlSchema from './schemas/control.json' with { type: 'json' }; +import controlRequirementSchema from './schemas/control-requirement.json' with { type: 'json' }; +import interfaceSchema from './schemas/interface.json' with { type: 'json' }; +import flowSchema from './schemas/flow.json' with { type: 'json' }; +import evidenceSchema from './schemas/evidence.json' with { type: 'json' }; +import unitsSchema from './schemas/units.json' with { type: 'json' }; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface ValidationIssue { + severity: 'error' | 'warning' | 'info'; + message: string; + /** unique-id of the node involved, if applicable */ + nodeId?: string; + /** unique-id of the relationship involved, if applicable */ + relationshipId?: string; + /** JSON path or schema path context */ + path?: string; +} + +// ─── Ajv setup (vendored CALM 1.2 meta-schemas) ────────────────────────────── + +const ajv = new Ajv2020({ allErrors: true, strict: false, allowUnionTypes: true }); +addFormats.default(ajv); +for (const s of [ + coreSchema, + controlSchema, + controlRequirementSchema, + interfaceSchema, + flowSchema, + evidenceSchema, + unitsSchema, + calmSchema, +]) { + ajv.addSchema(s as object); +} + +/** + * Returns the compiled CALM 1.2 root validator (`calm.json`). + * Compiled lazily so dynamic-import + tree-shaking remain friendly. + */ +function getCalmValidator() { + const v = ajv.getSchema((calmSchema as { $id?: string }).$id ?? ''); + if (!v) throw new Error('CALM 1.2 root schema not registered with Ajv'); + return v; +} + +const validateSchema = getCalmValidator(); + +// ─── Main validation function ───────────────────────────────────────────────── + +/** + * Validate a CalmArchitecture and return all issues found. + * Issues are sorted by severity: errors first, then warnings, then info. + */ +export function validateCalmArchitecture(arch: CalmArchitecture): ValidationIssue[] { + const issues: ValidationIssue[] = []; + + // 1. JSON Schema validation against CALM 1.2 meta-schema + const valid = validateSchema(arch); + if (!valid && validateSchema.errors) { + for (const err of validateSchema.errors) { + issues.push(schemaErrorToIssue(err, arch)); + } + } + + // 2. Semantic validation (only if basic structure is valid enough to traverse) + if (Array.isArray(arch.nodes) && Array.isArray(arch.relationships)) { + issues.push(...runSemanticRules(arch)); + } + + // Sort: errors first, then warnings, then info + const severityOrder = { error: 0, warning: 1, info: 2 }; + issues.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]); + + return issues; +} + +// ─── Schema error → ValidationIssue ────────────────────────────────────────── + +function schemaErrorToIssue( + err: { instancePath: string; message?: string; keyword?: string }, + arch: CalmArchitecture, +): ValidationIssue { + const path = err.instancePath; + const message = err.message ?? 'Schema validation error'; + + const nodeMatch = path.match(/^\/nodes\/(\d+)/); + const relMatch = path.match(/^\/relationships\/(\d+)/); + + let nodeId: string | undefined; + let relationshipId: string | undefined; + + if (nodeMatch) { + const idx = parseInt(nodeMatch[1]!, 10); + const node = arch.nodes?.[idx]; + if (node && node['unique-id']) nodeId = node['unique-id']; + } else if (relMatch) { + const idx = parseInt(relMatch[1]!, 10); + const rel = arch.relationships?.[idx]; + if (rel && rel['unique-id']) relationshipId = rel['unique-id']; + } + + return { + severity: 'error', + message: path ? `${path}: ${message}` : message, + ...(nodeId !== undefined ? { nodeId } : {}), + ...(relationshipId !== undefined ? { relationshipId } : {}), + path, + }; +} + +// ─── Semantic rules ─────────────────────────────────────────────────────────── + +function runSemanticRules(arch: CalmArchitecture): ValidationIssue[] { + const issues: ValidationIssue[] = []; + const nodeIds = new Set(); + + const archNodes = arch.nodes ?? []; + const archRelationships = arch.relationships ?? []; + + // Duplicate node unique-ids + const seenNodeIds = new Set(); + for (const node of archNodes) { + if (!node['unique-id']) continue; + if (seenNodeIds.has(node['unique-id'])) { + issues.push({ + severity: 'error', + message: `Duplicate node unique-id: "${node['unique-id']}"`, + nodeId: node['unique-id'], + }); + } else { + seenNodeIds.add(node['unique-id']); + nodeIds.add(node['unique-id']); + } + } + + // Duplicate relationship unique-ids + const seenRelIds = new Set(); + for (const rel of archRelationships) { + if (!rel['unique-id']) continue; + if (seenRelIds.has(rel['unique-id'])) { + issues.push({ + severity: 'error', + message: `Duplicate relationship unique-id: "${rel['unique-id']}"`, + relationshipId: rel['unique-id'], + }); + } else { + seenRelIds.add(rel['unique-id']); + } + } + + // Per-relationship rules + const connectedNodeIds = new Set(); + for (const rel of archRelationships) { + issues.push(...validateRelationshipReferences(rel, nodeIds, connectedNodeIds)); + } + + // Per-node rules + for (const node of archNodes) { + const nodeId = node['unique-id']; + if (nodeId && !connectedNodeIds.has(nodeId)) { + issues.push({ + severity: 'warning', + message: `Node "${nodeId}" is not referenced by any relationship`, + nodeId, + }); + } + if (nodeId && (!node.description || node.description.trim() === '')) { + issues.push({ + severity: 'info', + message: `Node "${nodeId}" has no description`, + nodeId, + }); + } + } + + return issues; +} + +/** + * Per-relationship semantic checks. Handles all five nested variants. + * Accumulates referenced node ids into `connectedNodeIds` for the + * orphan-node check downstream. + */ +function validateRelationshipReferences( + rel: CalmRelationship, + nodeIds: Set, + connectedNodeIds: Set, +): ValidationIssue[] { + const issues: ValidationIssue[] = []; + const relId = rel['unique-id']; + const rt = rel['relationship-type']; + if (!rt || typeof rt !== 'object') { + issues.push({ + severity: 'error', + message: `Relationship "${relId ?? '?'}" is missing relationship-type`, + ...(relId ? { relationshipId: relId } : {}), + }); + return issues; + } + + const variant = getRelationshipVariant(rt); + const refs = getReferencedNodeIds(rel); + for (const id of refs) connectedNodeIds.add(id); + + // Dangling-ref check (uniform across variants) + if (nodeIds.size > 0) { + for (const id of refs) { + if (!nodeIds.has(id)) { + issues.push({ + severity: 'error', + message: `Relationship "${relId ?? '?'}" (${variant}) references unknown node "${id}"`, + ...(relId ? { relationshipId: relId } : {}), + }); + } + } + } + + // Variant-specific structural checks + if ('connects' in rt) { + const c = rt.connects; + if (!c.source?.node) { + issues.push({ + severity: 'error', + message: `Relationship "${relId ?? '?'}" (connects) is missing source.node`, + ...(relId ? { relationshipId: relId } : {}), + }); + } + if (!c.destination?.node) { + issues.push({ + severity: 'error', + message: `Relationship "${relId ?? '?'}" (connects) is missing destination.node`, + ...(relId ? { relationshipId: relId } : {}), + }); + } + if (c.source?.node && c.destination?.node && c.source.node === c.destination.node) { + issues.push({ + severity: 'warning', + message: `Relationship "${relId ?? '?'}" (connects) connects a node to itself`, + ...(relId ? { relationshipId: relId } : {}), + }); + } + } else if ('composed-of' in rt) { + const co = rt['composed-of']; + if (!co.container) { + issues.push({ + severity: 'error', + message: `Relationship "${relId ?? '?'}" (composed-of) is missing container`, + ...(relId ? { relationshipId: relId } : {}), + }); + } + if (!Array.isArray(co.nodes) || co.nodes.length === 0) { + issues.push({ + severity: 'error', + message: `Relationship "${relId ?? '?'}" (composed-of) must list at least one child node`, + ...(relId ? { relationshipId: relId } : {}), + }); + } + } else if ('interacts' in rt) { + const i = rt.interacts; + if (!i.actor) { + issues.push({ + severity: 'error', + message: `Relationship "${relId ?? '?'}" (interacts) is missing actor`, + ...(relId ? { relationshipId: relId } : {}), + }); + } + if (!Array.isArray(i.nodes) || i.nodes.length === 0) { + issues.push({ + severity: 'error', + message: `Relationship "${relId ?? '?'}" (interacts) must list at least one interacted node`, + ...(relId ? { relationshipId: relId } : {}), + }); + } + } else if ('deployed-in' in rt) { + const d = rt['deployed-in']; + if (!d.container) { + issues.push({ + severity: 'error', + message: `Relationship "${relId ?? '?'}" (deployed-in) is missing container`, + ...(relId ? { relationshipId: relId } : {}), + }); + } + if (!Array.isArray(d.nodes) || d.nodes.length === 0) { + issues.push({ + severity: 'error', + message: `Relationship "${relId ?? '?'}" (deployed-in) must list at least one deployed node`, + ...(relId ? { relationshipId: relId } : {}), + }); + } + } + + return issues; +} diff --git a/calm-plugins/vscode/src/extension.ts b/calm-plugins/vscode/src/extension.ts deleted file mode 100644 index 573ee0de9..000000000 --- a/calm-plugins/vscode/src/extension.ts +++ /dev/null @@ -1,16 +0,0 @@ -import * as vscode from 'vscode' -import { CalmExtensionController } from './calm-extension-controller' -import type { CalmExtensionTestApi } from './test-api' - - -let controller: CalmExtensionController | undefined - -export async function activate(context: vscode.ExtensionContext): Promise { - controller = new CalmExtensionController() - await controller.start(context) - return controller.getTestApi() -} - -export function deactivate() { - controller?.dispose() -} \ No newline at end of file diff --git a/calm-plugins/vscode/src/extension/extension.ts b/calm-plugins/vscode/src/extension/extension.ts new file mode 100644 index 000000000..667972d49 --- /dev/null +++ b/calm-plugins/vscode/src/extension/extension.ts @@ -0,0 +1,91 @@ +import * as vscode from 'vscode'; +import { CanvasPanel } from './webview/canvas-panel'; +import { CalmCanvasCodeLensProvider } from './services/codelens-provider'; + +let canvasPanel: CanvasPanel | undefined; +let outputChannel: vscode.OutputChannel; + +const CALM_FILE_SUFFIXES = [ + '.calm.json', + '.architecture.json', + '.template.json', + '.solution.json', + '.standard.json', + '.guideline.json', +]; + +function isCalmFile(fsPath: string): boolean { + return CALM_FILE_SUFFIXES.some((suffix) => fsPath.endsWith(suffix)); +} + +export function activate(context: vscode.ExtensionContext): void { + outputChannel = vscode.window.createOutputChannel('CALM Canvas'); + outputChannel.appendLine('[INFO] CALM Canvas extension activated'); + + const roots = vscode.workspace.workspaceFolders ?? []; + outputChannel.appendLine( + `[INFO] Workspace folders: ${roots.map((f) => f.uri.fsPath).join(', ') || 'NONE'}` + ); + + const externalPath = vscode.workspace + .getConfiguration('calm') + .get('externalAssetsPath'); + if (externalPath) { + outputChannel.appendLine( + `[INFO] External assets path: ${externalPath}` + ); + } + + const openCanvas = vscode.commands.registerCommand( + 'calm.openCanvas', + async (uri?: vscode.Uri) => { + let document: vscode.TextDocument | undefined; + + if (uri && isCalmFile(uri.fsPath)) { + document = await vscode.workspace.openTextDocument(uri); + } else { + const editor = vscode.window.activeTextEditor; + if (editor && isCalmFile(editor.document.uri.fsPath)) { + document = editor.document; + } + } + + if (!document) { + vscode.window.showWarningMessage( + 'Open a .calm.json file first.' + ); + return; + } + + if (!canvasPanel) { + canvasPanel = new CanvasPanel(context, outputChannel); + canvasPanel.onDispose(() => { + canvasPanel = undefined; + }); + } + canvasPanel.reveal(document); + } + ); + + context.subscriptions.push(openCanvas); + + // Inline "View in CALM Canvas" affordances on CALM documents. + const calmSelector: vscode.DocumentSelector = [ + { pattern: '**/*.calm.json' }, + { pattern: '**/*.architecture.json' }, + { pattern: '**/*.template.json' }, + { pattern: '**/*.solution.json' }, + { pattern: '**/*.standard.json' }, + { pattern: '**/*.guideline.json' }, + ]; + context.subscriptions.push( + vscode.languages.registerCodeLensProvider( + calmSelector, + new CalmCanvasCodeLensProvider() + ) + ); +} + +export function deactivate(): void { + canvasPanel?.dispose(); +} diff --git a/calm-plugins/vscode/src/extension/services/codelens-provider.test.ts b/calm-plugins/vscode/src/extension/services/codelens-provider.test.ts new file mode 100644 index 000000000..fabb501f3 --- /dev/null +++ b/calm-plugins/vscode/src/extension/services/codelens-provider.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from 'vitest'; +import * as vscode from 'vscode'; +import { CalmCanvasCodeLensProvider } from './codelens-provider'; + +function fakeDoc(text: string): vscode.TextDocument { + return { + getText: () => text, + positionAt: (offset: number) => ({ + line: text.slice(0, offset).split('\n').length - 1, + character: 0, + }), + uri: vscode.Uri.file('/ws/arch.calm.json'), + } as unknown as vscode.TextDocument; +} + +describe('CalmCanvasCodeLensProvider', () => { + const provider = new CalmCanvasCodeLensProvider(); + + it('returns no lenses for invalid JSON', () => { + expect(provider.provideCodeLenses(fakeDoc('{ not json'))).toEqual([]); + }); + + it('returns no lenses for JSON without nodes or relationships', () => { + expect(provider.provideCodeLenses(fakeDoc('{"foo":1}'))).toEqual([]); + }); + + it('adds a top-level "View in Canvas" lens for a CALM document', () => { + const doc = fakeDoc(JSON.stringify({ nodes: [] }, null, 2)); + const lenses = provider.provideCodeLenses(doc); + + expect(lenses).toHaveLength(1); + expect(lenses[0].command?.command).toBe('calm.openCanvas'); + expect(lenses[0].command?.title).toContain('View in CALM Canvas'); + expect(lenses[0].command?.arguments?.[0]).toBe(doc.uri); + }); + + it('adds one node-level lens per node with a unique-id', () => { + const text = JSON.stringify( + { + nodes: [ + { 'unique-id': 'svc-a' }, + { 'unique-id': 'svc-b' }, + { name: 'no-id' }, + ], + }, + null, + 2 + ); + const lenses = provider.provideCodeLenses(fakeDoc(text)); + + // 1 top-level + 2 node lenses (the node without unique-id is skipped) + expect(lenses).toHaveLength(3); + expect( + lenses.every( + (l) => l.command?.command === 'calm.openCanvas' + ) + ).toBe(true); + + // The node lens is anchored on the line containing its unique-id. + const svcALine = + text.slice(0, text.indexOf('"unique-id": "svc-a"')).split('\n') + .length - 1; + const nodeLens = lenses[1] as unknown as { + range: { startLine: number }; + }; + expect(nodeLens.range.startLine).toBe(svcALine); + }); +}); diff --git a/calm-plugins/vscode/src/extension/services/codelens-provider.ts b/calm-plugins/vscode/src/extension/services/codelens-provider.ts new file mode 100644 index 000000000..442554531 --- /dev/null +++ b/calm-plugins/vscode/src/extension/services/codelens-provider.ts @@ -0,0 +1,56 @@ +import * as vscode from 'vscode'; + +/** + * Adds inline "View in CALM Canvas" CodeLens entries above CALM + * documents (and above each node) so users can open the canvas without hunting + * for the editor-title action. Mirrors the affordance provided by the Svelte + * calm-canvas, adapted to the canvas' single command surface. + */ +export class CalmCanvasCodeLensProvider implements vscode.CodeLensProvider { + provideCodeLenses(document: vscode.TextDocument): vscode.CodeLens[] { + const text = document.getText(); + + let parsed: { + nodes?: Array>; + relationships?: unknown[]; + }; + try { + parsed = JSON.parse(text); + } catch { + return []; + } + + if (!parsed.nodes && !parsed.relationships) return []; + + const lenses: vscode.CodeLens[] = []; + const topRange = new vscode.Range(0, 0, 0, 0); + + lenses.push( + new vscode.CodeLens(topRange, { + title: '$(symbol-structure) View in CALM Canvas', + command: 'calm.openCanvas', + arguments: [document.uri], + }) + ); + + if (parsed.nodes) { + for (const node of parsed.nodes) { + const uniqueId = node['unique-id'] as string | undefined; + if (!uniqueId) continue; + const match = text.indexOf(`"unique-id": "${uniqueId}"`); + if (match < 0) continue; + const pos = document.positionAt(match); + const range = new vscode.Range(pos.line, 0, pos.line, 0); + lenses.push( + new vscode.CodeLens(range, { + title: '$(symbol-structure) View in CALM Canvas', + command: 'calm.openCanvas', + arguments: [document.uri], + }) + ); + } + } + + return lenses; + } +} diff --git a/calm-plugins/vscode/src/extension/services/diagram-export-service.ts b/calm-plugins/vscode/src/extension/services/diagram-export-service.ts new file mode 100644 index 000000000..56f5d437e --- /dev/null +++ b/calm-plugins/vscode/src/extension/services/diagram-export-service.ts @@ -0,0 +1,42 @@ +import * as vscode from 'vscode'; +import * as path from 'path'; + +export class DiagramExportService { + async exportDiagram( + format: 'svg' | 'png', + data: string, + currentDocumentUri?: vscode.Uri, + ): Promise { + const defaultName = this.computeDefaultName(currentDocumentUri, format); + const defaultUri = currentDocumentUri + ? vscode.Uri.file(path.join(path.dirname(currentDocumentUri.fsPath), defaultName)) + : undefined; + + const uri = await vscode.window.showSaveDialog({ + defaultUri, + filters: format === 'svg' + ? { 'SVG Image': ['svg'] } + : { 'PNG Image': ['png'] }, + title: `Export Diagram as ${format.toUpperCase()}`, + }); + + if (!uri) return; + + const buffer = this.decodeData(format, data); + await vscode.workspace.fs.writeFile(uri, buffer); + vscode.window.showInformationMessage(`Diagram exported to ${path.basename(uri.fsPath)}`); + } + + private computeDefaultName(uri: vscode.Uri | undefined, format: string): string { + if (!uri) return `architecture.${format}`; + const stem = path.basename(uri.fsPath, path.extname(uri.fsPath)); + return `${stem}-diagram.${format}`; + } + + private decodeData(format: string, data: string): Uint8Array { + if (format === 'svg') { + return Buffer.from(data, 'utf-8'); + } + return Buffer.from(data, 'base64'); + } +} diff --git a/calm-plugins/vscode/src/extension/services/sync-coordinator.ts b/calm-plugins/vscode/src/extension/services/sync-coordinator.ts new file mode 100644 index 000000000..ab5c22920 --- /dev/null +++ b/calm-plugins/vscode/src/extension/services/sync-coordinator.ts @@ -0,0 +1,37 @@ +/** + * Prevents infinite sync loops between canvas edits and file changes. + * + * The only dangerous loop: canvas writes file → file watcher fires → sends back to canvas. + * We block file→canvas propagation for a short window after canvas→file writes. + * Canvas→file writes are NEVER blocked — user edits must always persist. + */ +export class SyncCoordinator { + private suppressFileEvents = false; + private resetTimer: ReturnType | null = null; + private readonly SUPPRESS_MS = 1000; + + fileChanged(): boolean { + if (this.suppressFileEvents) { + return false; + } + return true; + } + + canvasChanged(): boolean { + this.suppressFileEvents = true; + if (this.resetTimer) { + clearTimeout(this.resetTimer); + } + this.resetTimer = setTimeout(() => { + this.suppressFileEvents = false; + this.resetTimer = null; + }, this.SUPPRESS_MS); + return true; + } + + dispose(): void { + if (this.resetTimer) { + clearTimeout(this.resetTimer); + } + } +} diff --git a/calm-plugins/vscode/src/extension/services/workspace-asset-service.test.ts b/calm-plugins/vscode/src/extension/services/workspace-asset-service.test.ts new file mode 100644 index 000000000..025731bb2 --- /dev/null +++ b/calm-plugins/vscode/src/extension/services/workspace-asset-service.test.ts @@ -0,0 +1,170 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import * as vscode from 'vscode'; +import { + WorkspaceAssetService, + frontMatterControlsToMap, +} from './workspace-asset-service'; + +const encode = (s: string) => new TextEncoder().encode(s); + +describe('frontMatterControlsToMap', () => { + it('converts a front-matter controls array into the CALM control map', () => { + const map = frontMatterControlsToMap( + [ + { + id: 'app-id', + name: 'Application ID', + metadata: { + validation: { + pattern: '^AP\\d+$', + example: 'AP187183', + }, + }, + }, + ], + 'standards/application-software-delivery/STD100002.md' + ); + expect(Object.keys(map)).toEqual(['app-id']); + expect(map['app-id']).toEqual({ + description: 'Application ID', + requirements: [ + { + 'requirement-url': + 'standards/application-software-delivery/STD100002.md', + config: {}, + }, + ], + metadata: { + validation: { pattern: '^AP\\d+$', example: 'AP187183' }, + }, + }); + }); + + it('falls back to the control name as id and omits metadata when absent', () => { + const map = frontMatterControlsToMap( + [{ name: 'Encryption' }], + 'standards/x.md' + ); + expect(map['Encryption']).toEqual({ + description: 'Encryption', + requirements: [{ 'requirement-url': 'standards/x.md', config: {} }], + }); + }); + + it('returns an empty map for non-array / missing controls', () => { + expect(frontMatterControlsToMap(undefined, 'x')).toEqual({}); + expect(frontMatterControlsToMap({}, 'x')).toEqual({}); + }); +}); + +describe('WorkspaceAssetService.resolveStandardProse', () => { + beforeEach(() => { + (vscode.workspace as any).workspaceFolders = [ + { uri: vscode.Uri.file('/ws') }, + ]; + (vscode.workspace as any).getConfiguration = () => ({ + get: () => undefined, + }); + (vscode.workspace as any).fs = { + readFile: vi.fn(async () => { + throw new Error('ENOENT'); + }), + }; + }); + + it('returns the markdown contents from a workspace root', async () => { + (vscode.workspace as any).fs.readFile = vi.fn( + async (uri: { fsPath: string }) => { + if (uri.fsPath === '/ws/standards/tls-policy.md') + return encode('# TLS Policy'); + throw new Error('ENOENT'); + } + ); + + const svc = new WorkspaceAssetService('/ws'); + expect(await svc.resolveStandardProse('standards/tls-policy.md')).toBe( + '# TLS Policy' + ); + }); + + it('falls through to the configured external assets path', async () => { + (vscode.workspace as any).getConfiguration = () => ({ + get: () => '/ext', + }); + (vscode.workspace as any).fs.readFile = vi.fn( + async (uri: { fsPath: string }) => { + if (uri.fsPath === '/ext/standards/x.md') + return encode('external prose'); + throw new Error('ENOENT'); + } + ); + + const svc = new WorkspaceAssetService('/ws'); + expect(await svc.resolveStandardProse('standards/x.md')).toBe( + 'external prose' + ); + }); + + it('returns null when no root contains the file', async () => { + const svc = new WorkspaceAssetService('/ws'); + expect( + await svc.resolveStandardProse('standards/missing.md') + ).toBeNull(); + }); +}); + +describe('WorkspaceAssetService.scanPatterns', () => { + beforeEach(() => { + (vscode.workspace as any).workspaceFolders = [ + { uri: vscode.Uri.file('/ws') }, + ]; + }); + + it('discovers .pattern.json files from the configured external assets path', async () => { + (vscode.workspace as any).getConfiguration = () => ({ + get: (key: string) => + key === 'externalAssetsPath' ? '/ext' : undefined, + }); + // Only the external root contains a pattern file — the workspace root has none. + (vscode.workspace as any).findFiles = vi.fn( + async (glob: { base: { fsPath: string }; pattern: string }) => { + if ( + glob?.base?.fsPath === '/ext' && + glob.pattern.startsWith('patterns/') + ) { + return [ + vscode.Uri.file( + '/ext/patterns/microservice.pattern.json' + ), + ]; + } + return []; + } + ); + (vscode.workspace as any).fs = { + readFile: vi.fn(async (uri: { fsPath: string }) => { + if (uri.fsPath === '/ext/patterns/microservice.pattern.json') { + return encode( + JSON.stringify({ + title: 'Microservice', + description: 'A microservice pattern', + category: 'core', + }) + ); + } + throw new Error('ENOENT'); + }), + }; + + const svc = new WorkspaceAssetService('/ws'); + await (svc as any).scanPatterns(); + const patterns = svc.getPatterns(); + expect(patterns).toHaveLength(1); + expect(patterns[0]).toMatchObject({ + id: 'microservice', + name: 'Microservice', + description: 'A microservice pattern', + category: 'core', + }); + }); +}); diff --git a/calm-plugins/vscode/src/extension/services/workspace-asset-service.ts b/calm-plugins/vscode/src/extension/services/workspace-asset-service.ts new file mode 100644 index 000000000..2975f3c4f --- /dev/null +++ b/calm-plugins/vscode/src/extension/services/workspace-asset-service.ts @@ -0,0 +1,432 @@ +import * as vscode from 'vscode'; +import * as path from 'path'; +import * as YAML from 'yaml'; + +export interface BuildingBlockDef { + id: string; + name: string; + behaviour: string; + controls: Record; + category?: string; + nodeType?: string; +} + +export interface PatternEntry { + id: string; + name: string; + description: string; + category: string; + schema: unknown; +} + +export interface CalmTemplate { + id: string; + name: string; + description: string; + category: string; + content: unknown; +} + +export interface StandardDef { + id: string; + name: string; + filePath: string; +} + +/** + * Convert a markdown front-matter `controls` array into the CALM control-map + * shape used on nodes: `{ [id]: { description, requirements:[{requirement-url,config}], metadata } }`. + * Pure and exported so it can be unit-tested without the VS Code API. + */ +export function frontMatterControlsToMap( + controls: unknown, + requirementUrl: string +): Record { + if (!Array.isArray(controls)) return {}; + const map: Record = {}; + for (const raw of controls) { + if (!raw || typeof raw !== 'object') continue; + const ctrl = raw as Record; + const idVal = + typeof ctrl.id === 'string' + ? ctrl.id + : typeof ctrl.name === 'string' + ? ctrl.name + : ''; + if (!idVal) continue; + const entry: Record = { + description: + (typeof ctrl.description === 'string' + ? ctrl.description + : undefined) ?? + (typeof ctrl.name === 'string' ? ctrl.name : ''), + requirements: [{ 'requirement-url': requirementUrl, config: {} }], + }; + if (ctrl.metadata && typeof ctrl.metadata === 'object') { + entry.metadata = ctrl.metadata; + } + map[idVal] = entry; + } + return map; +} + +export class WorkspaceAssetService { + private buildingBlocks: BuildingBlockDef[] = []; + private patterns: PatternEntry[] = []; + private templates: CalmTemplate[] = []; + private standards: StandardDef[] = []; + private watchers: vscode.FileSystemWatcher[] = []; + private debounceTimer: ReturnType | null = null; + + constructor(private readonly workspaceRoot: string) {} + + private getRoots(): vscode.Uri[] { + const roots: vscode.Uri[] = []; + for (const folder of vscode.workspace.workspaceFolders ?? []) { + roots.push(folder.uri); + } + const externalPath = vscode.workspace + .getConfiguration('calm') + .get('externalAssetsPath'); + if (externalPath?.trim()) { + roots.push(vscode.Uri.file(externalPath.trim())); + } + return roots; + } + + async scanAll(): Promise { + await Promise.all([ + this.scanBuildingBlocks(), + this.scanPatterns(), + this.scanTemplates(), + this.scanStandards(), + ]); + } + + getBuildingBlocks(): BuildingBlockDef[] { + return this.buildingBlocks; + } + getPatterns(): PatternEntry[] { + return this.patterns; + } + getTemplates(): CalmTemplate[] { + return this.templates; + } + getStandards(): StandardDef[] { + return this.standards; + } + + registerWatchers( + context: vscode.ExtensionContext, + onRescan: () => void + ): void { + const globs = [ + 'building-blocks/**/*.{calm.json,architecture.json}', + 'building-blocks/**/*.{calm.json,architecture.json}', + 'patterns/**/*.pattern.json', + 'templates/**/*.template.json', + 'standards/**/*.md', + 'guidelines/**/*.md', + ]; + + const roots = this.getRoots(); + for (const root of roots) { + for (const glob of globs) { + const watcher = vscode.workspace.createFileSystemWatcher( + new vscode.RelativePattern(root, glob) + ); + watcher.onDidChange(() => this.debouncedRescan(onRescan)); + watcher.onDidCreate(() => this.debouncedRescan(onRescan)); + watcher.onDidDelete(() => this.debouncedRescan(onRescan)); + this.watchers.push(watcher); + context.subscriptions.push(watcher); + } + } + } + + private debouncedRescan(onRescan: () => void): void { + if (this.debounceTimer) clearTimeout(this.debounceTimer); + this.debounceTimer = setTimeout(async () => { + await this.scanAll(); + onRescan(); + }, 500); + } + + private async scanBuildingBlocks(): Promise { + const nodes: BuildingBlockDef[] = []; + const seen = new Set(); + const roots = this.getRoots(); + + for (const root of roots) { + const globs = [ + new vscode.RelativePattern( + root, + 'building-blocks/**/*.{calm.json,architecture.json}' + ), + new vscode.RelativePattern( + root, + 'building-blocks/**/*.{calm.json,architecture.json}' + ), + ]; + + for (const pattern of globs) { + const files = await vscode.workspace.findFiles(pattern); + for (const file of files) { + try { + const content = + await vscode.workspace.fs.readFile(file); + const json = JSON.parse( + Buffer.from(content).toString('utf-8') + ); + if ( + json.nodes && + Array.isArray(json.nodes) && + json.nodes.length > 0 + ) { + const node = json.nodes[0]; + const metadata = (node.metadata ?? {}) as Record< + string, + unknown + >; + const id = this.stem(file); + if (seen.has(id)) continue; + seen.add(id); + + const category = + this.extractCategory(file, 'building-blocks') || + this.extractCategory(file, 'building-blocks') || + 'General'; + + nodes.push({ + id, + name: node.name ?? id, + behaviour: + metadata['building-block-behaviour'] === + 'apply-controls-on-drop' + ? 'apply-controls-on-drop' + : 'create-node', + controls: node.controls ?? {}, + category, + nodeType: node['node-type'] ?? 'system', + }); + } + } catch { + /* skip invalid files */ + } + } + } + } + + // Also convert standards/guidelines markdown to palette items + for (const root of roots) { + await this.addStandardsPaletteItems(nodes, root, 'standards'); + await this.addStandardsPaletteItems(nodes, root, 'guidelines'); + } + + this.buildingBlocks = nodes; + } + + private async addStandardsPaletteItems( + nodes: BuildingBlockDef[], + root: vscode.Uri, + folder: 'standards' | 'guidelines' + ): Promise { + const pattern = new vscode.RelativePattern(root, `${folder}/**/*.md`); + const files = await vscode.workspace.findFiles(pattern); + + for (const file of files) { + const stem = this.stem(file); + if (stem === 'README') continue; + const id = `${folder}:${stem}`; + const category = this.extractCategory(file, folder) || 'General'; + const requirementUrl = this.toRelativeUrl(root, file); + const fm = await this.readFrontMatter(file, requirementUrl); + const name = + fm?.name ?? + stem + .replace(/-/g, ' ') + .replace(/\b\w/g, (c) => c.toUpperCase()); + + nodes.push({ + id, + name, + behaviour: 'apply-controls-on-drop', + controls: fm?.controls ?? {}, + category, + nodeType: 'standard', + }); + } + } + + /** + * Parse a standard/guideline markdown's YAML front matter to extract its + * display name and control definitions, so dropping it applies validatable + * controls onto the target node. + */ + private async readFrontMatter( + file: vscode.Uri, + requirementUrl: string + ): Promise<{ name?: string; controls: Record } | null> { + try { + const bytes = await vscode.workspace.fs.readFile(file); + const text = Buffer.from(bytes).toString('utf-8'); + const match = /^---\s*\r?\n([\s\S]*?)\r?\n---/.exec(text); + if (!match) return null; + const fm = YAML.parse(match[1]) as Record | null; + if (!fm || typeof fm !== 'object') return null; + return { + name: typeof fm.name === 'string' ? fm.name : undefined, + controls: frontMatterControlsToMap(fm.controls, requirementUrl), + }; + } catch { + return null; + } + } + + private toRelativeUrl(root: vscode.Uri, file: vscode.Uri): string { + return path + .relative(root.fsPath, file.fsPath) + .split(path.sep) + .join('/'); + } + + private stem(uri: vscode.Uri): string { + const base = path.basename(uri.fsPath); + return base.replace(/\.calm\.json$/, '').replace(/\.md$/, ''); + } + + private extractCategory(uri: vscode.Uri, baseFolder: string): string { + const parts = uri.path.split('/'); + const baseIdx = parts.findIndex((p) => p === baseFolder); + if (baseIdx >= 0 && baseIdx + 1 < parts.length - 1) { + return parts[baseIdx + 1] + .replace(/-/g, ' ') + .replace(/\b\w/g, (c) => c.toUpperCase()); + } + return ''; + } + + private async scanPatterns(): Promise { + const patterns: PatternEntry[] = []; + const seen = new Set(); + + for (const root of this.getRoots()) { + const glob = new vscode.RelativePattern( + root, + 'patterns/**/*.pattern.json' + ); + const files = await vscode.workspace.findFiles(glob); + for (const file of files) { + try { + const content = await vscode.workspace.fs.readFile(file); + const json = JSON.parse( + Buffer.from(content).toString('utf-8') + ); + const id = path + .basename(file.fsPath) + .replace(/\.pattern\.json$/, ''); + if (seen.has(id)) continue; + seen.add(id); + patterns.push({ + id, + name: json.title ?? id, + description: json.description ?? '', + category: + json.category ?? json['x-category'] ?? 'general', + schema: json, + }); + } catch { + /* skip invalid pattern files */ + } + } + } + + this.patterns = patterns; + } + + private async scanTemplates(): Promise { + const templates: CalmTemplate[] = []; + const seen = new Set(); + + for (const root of this.getRoots()) { + const glob = new vscode.RelativePattern( + root, + 'templates/**/*.template.json' + ); + const files = await vscode.workspace.findFiles(glob); + for (const file of files) { + try { + const content = await vscode.workspace.fs.readFile(file); + const json = JSON.parse( + Buffer.from(content).toString('utf-8') + ); + const meta = json._template ?? {}; + const id = path + .basename(file.fsPath) + .replace(/\.template\.json$/, ''); + if (seen.has(id)) continue; + seen.add(id); + templates.push({ + id, + name: meta.name ?? id, + description: meta.description ?? '', + category: meta.category ?? 'general', + content: json, + }); + } catch { + /* skip invalid template files */ + } + } + } + + this.templates = templates; + } + + private async scanStandards(): Promise { + const standards: StandardDef[] = []; + const dirs = ['standards', 'guidelines']; + + for (const dir of dirs) { + const dirPath = path.join(this.workspaceRoot, dir); + try { + const uri = vscode.Uri.file(dirPath); + const entries = await vscode.workspace.fs.readDirectory(uri); + for (const [name, type] of entries) { + if (type !== vscode.FileType.File || !name.endsWith('.md')) + continue; + standards.push({ + id: name.replace('.md', ''), + name: name.replace('.md', '').replace(/-/g, ' '), + filePath: path.join(dirPath, name), + }); + } + } catch { + /* directory doesn't exist */ + } + } + + this.standards = standards; + } + + /** + * Resolve the raw markdown prose for a standard/guideline referenced by a + * control requirement URL (e.g. `standards/tls-policy.md`). Searches every + * workspace root plus the configured external assets path. + */ + async resolveStandardProse(requirementUrl: string): Promise { + for (const root of this.getRoots()) { + const uri = vscode.Uri.joinPath(root, requirementUrl); + try { + const bytes = await vscode.workspace.fs.readFile(uri); + return Buffer.from(bytes).toString('utf-8'); + } catch { + /* try next root */ + } + } + return null; + } + + dispose(): void { + if (this.debounceTimer) clearTimeout(this.debounceTimer); + } +} diff --git a/calm-plugins/vscode/src/extension/types/messages.ts b/calm-plugins/vscode/src/extension/types/messages.ts new file mode 100644 index 000000000..e600c2777 --- /dev/null +++ b/calm-plugins/vscode/src/extension/types/messages.ts @@ -0,0 +1,47 @@ +/** + * Solution metadata surfaced when drilling into a governed building block that + * links to a CALM solution. Shape matches calm-hub-ui's + * `visualizer/contracts/editor-contracts.ts` `SolutionMetadata` so the two + * editors stay protocol-compatible. + */ +export interface SolutionMetadata { + id?: string; + name: string; + disposition?: string; + dispositionGuidance?: string; + coreAndCommon?: string; + capabilities?: string; + reuseModel?: string; + provider?: string; + resources?: string; +} + +export type ExtToWebviewMessage = + | { + type: 'modelUpdated'; + json: string; + source: 'file' | 'ai' | 'text-editor'; + } + | { type: 'templatesLoaded'; templates: unknown[] } + | { type: 'patternsLoaded'; patterns: unknown[] } + | { type: 'buildingBlocksLoaded'; nodes: unknown[] } + | { type: 'standardsLoaded'; standards: unknown[] } + | { type: 'standardProse'; url: string; prose: string } + | { + type: 'drillResult'; + json: string; + label: string; + filePath: string; + readonly?: boolean; + solution?: SolutionMetadata; + }; + +export type WebviewToExtMessage = + | { type: 'ready' } + | { type: 'canvasChanged'; json: string } + | { type: 'drillInto'; label: string; path: string; calmType: string } + | { type: 'drillUp'; index: number; filePath?: string; readonly?: boolean } + | { type: 'requestStandardProse'; url: string } + | { type: 'requestGenerateSpec' } + | { type: 'saveBuildingBlock'; filename: string; content: string } + | { type: 'exportDiagram'; format: 'svg' | 'png'; data: string }; diff --git a/calm-plugins/vscode/src/extension/webview/canvas-panel.ts b/calm-plugins/vscode/src/extension/webview/canvas-panel.ts new file mode 100644 index 000000000..b0040186c --- /dev/null +++ b/calm-plugins/vscode/src/extension/webview/canvas-panel.ts @@ -0,0 +1,534 @@ +import * as vscode from 'vscode'; +import { getWebviewHtml } from './html-provider'; +import { SyncCoordinator } from '../services/sync-coordinator'; +import { WorkspaceAssetService } from '../services/workspace-asset-service'; +import { DiagramExportService } from '../services/diagram-export-service'; +import type { + ExtToWebviewMessage, + WebviewToExtMessage, +} from '../types/messages'; + +export class CanvasPanel { + private panel: vscode.WebviewPanel | undefined; + private disposables: vscode.Disposable[] = []; + private disposeCallbacks: Array<() => void> = []; + private currentDocument: vscode.TextDocument | undefined; + private syncCoordinator = new SyncCoordinator(); + private assetService: WorkspaceAssetService | undefined; + private exportService = new DiagramExportService(); + private fileWatcher: vscode.FileSystemWatcher | undefined; + private log: vscode.OutputChannel; + + private scanReady = false; + private webviewReady = false; + + constructor( + private readonly context: vscode.ExtensionContext, + outputChannel: vscode.OutputChannel + ) { + this.log = outputChannel; + const workspaceRoot = + vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? ''; + this.log.appendLine( + `[CanvasPanel] constructor, workspaceRoot: ${workspaceRoot}` + ); + this.assetService = new WorkspaceAssetService(workspaceRoot); + void this.assetService.scanAll().then(() => { + const fn = this.assetService!.getBuildingBlocks(); + const p = this.assetService!.getPatterns(); + const t = this.assetService!.getTemplates(); + const s = this.assetService!.getStandards(); + this.log.appendLine( + `[CanvasPanel] Scan complete: ${fn.length} building-blocks, ${p.length} patterns, ${t.length} templates, ${s.length} standards` + ); + this.scanReady = true; + // If webview was already waiting, send now + if (this.webviewReady) { + this.log.appendLine( + '[CanvasPanel] Webview was waiting — sending assets now' + ); + this.sendAssets(); + } + }); + this.assetService.registerWatchers(context, () => this.sendAssets()); + } + + reveal(document: vscode.TextDocument): void { + this.currentDocument = document; + + if (!this.panel) { + this.panel = vscode.window.createWebviewPanel( + 'calmCanvas', + 'CALM Canvas', + vscode.ViewColumn.Beside, + { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [ + vscode.Uri.joinPath( + this.context.extensionUri, + 'dist', + 'webview' + ), + ], + } + ); + + this.panel.onDidDispose( + () => this.dispose(), + null, + this.disposables + ); + this.panel.webview.onDidReceiveMessage( + (msg: WebviewToExtMessage) => this.handleMessage(msg), + null, + this.disposables + ); + + this.registerFileWatcher(document); + } + + this.panel.webview.html = getWebviewHtml( + this.panel.webview, + this.context, + document + ); + this.panel.reveal(vscode.ViewColumn.Beside); + } + + onDispose(callback: () => void): void { + this.disposeCallbacks.push(callback); + } + + dispose(): void { + this.panel?.dispose(); + this.panel = undefined; + this.fileWatcher?.dispose(); + this.syncCoordinator.dispose(); + this.assetService?.dispose(); + for (const d of this.disposables) d.dispose(); + this.disposables = []; + for (const cb of this.disposeCallbacks) cb(); + } + + private postMessage(message: ExtToWebviewMessage): void { + this.panel?.webview.postMessage(message); + } + + private handleMessage(message: WebviewToExtMessage): void { + switch (message.type) { + case 'ready': + this.webviewReady = true; + this.log.appendLine( + `[CanvasPanel] Webview ready. scanReady=${this.scanReady}` + ); + this.sendInitialData(); + if (this.scanReady) { + this.sendAssets(); + } + break; + case 'canvasChanged': + this.handleCanvasChanged(message.json); + break; + case 'exportDiagram': + void this.exportService.exportDiagram( + message.format, + message.data, + this.currentDocument?.uri + ); + break; + case 'drillInto': + void this.handleDrillInto( + message.label, + message.path, + message.calmType + ); + break; + case 'drillUp': + void this.handleDrillUp(message.index, message.filePath); + break; + case 'requestStandardProse': + void this.handleRequestStandardProse(message.url); + break; + case 'requestGenerateSpec': + void this.handleGenerateSpec(); + break; + case 'saveBuildingBlock': + void this.handleSaveBuildingBlock( + message.filename, + message.content + ); + break; + } + } + + private sendInitialData(): void { + if (!this.currentDocument) return; + this.postMessage({ + type: 'modelUpdated', + json: this.currentDocument.getText(), + source: 'file', + }); + } + + private sendAssets(): void { + if (!this.assetService) return; + const fn = this.assetService.getBuildingBlocks(); + const p = this.assetService.getPatterns(); + const t = this.assetService.getTemplates(); + const s = this.assetService.getStandards(); + this.log.appendLine( + `[CanvasPanel] Sending assets to webview: ${fn.length} nodes, ${p.length} patterns, ${t.length} templates, ${s.length} standards` + ); + this.postMessage({ type: 'buildingBlocksLoaded', nodes: fn }); + this.postMessage({ type: 'patternsLoaded', patterns: p }); + this.postMessage({ type: 'templatesLoaded', templates: t }); + this.postMessage({ type: 'standardsLoaded', standards: s }); + } + + /** + * Restore a breadcrumb level. `index === 0` (or a missing filePath) returns + * to the root document; any deeper index reloads the building block at + * `filePath`. Uses `modelUpdated` (not `drillResult`) so the webview does not + * re-push onto its own drill stack — it has already truncated on navigate. + */ + private async handleDrillUp( + index: number, + filePath?: string + ): Promise { + if (index <= 0 || !filePath) { + if (!this.currentDocument) return; + this.log.appendLine( + '[CanvasPanel] drillUp — reloading root document' + ); + this.postMessage({ + type: 'modelUpdated', + json: this.currentDocument.getText(), + source: 'file', + }); + return; + } + + try { + const uri = vscode.Uri.file(filePath); + const content = await vscode.workspace.fs.readFile(uri); + const json = Buffer.from(content).toString('utf-8'); + this.log.appendLine( + `[CanvasPanel] drillUp — reloading level ${index}: ${filePath}` + ); + this.postMessage({ type: 'modelUpdated', json, source: 'file' }); + } catch { + this.log.appendLine( + `[CanvasPanel] drillUp FAILED to reload ${filePath}; falling back to root` + ); + if (this.currentDocument) { + this.postMessage({ + type: 'modelUpdated', + json: this.currentDocument.getText(), + source: 'file', + }); + } + } + } + + private async handleDrillInto( + label: string, + filePath: string, + calmType: string + ): Promise { + this.log.appendLine( + `[CanvasPanel] drillInto: label="${label}", path="${filePath}", type="${calmType}"` + ); + + const path = await import('path'); + const isReadonly = + calmType.startsWith('building-block:') || + filePath.includes('building-blocks/'); + + // Strategy: try multiple resolution paths + const candidates: string[] = []; + + // 1. Try workspace roots (building-blocks are at workspace root level) + for (const folder of vscode.workspace.workspaceFolders ?? []) { + candidates.push(path.join(folder.uri.fsPath, filePath)); + } + + // 2. Try relative to current document + if (this.currentDocument) { + candidates.push( + path.resolve( + path.dirname(this.currentDocument.uri.fsPath), + filePath + ) + ); + } + + // 3. For building blocks, also search recursively with glob + const stem = path.basename(filePath, '.calm.json'); + if (filePath.includes('building-blocks/')) { + for (const folder of vscode.workspace.workspaceFolders ?? []) { + const pattern = new vscode.RelativePattern( + folder, + `building-blocks/**/${stem}.calm.json` + ); + const files = await vscode.workspace.findFiles( + pattern, + null, + 1 + ); + if (files.length > 0) { + candidates.unshift(files[0].fsPath); + } + } + } + + // Try each candidate + for (const resolvedPath of candidates) { + try { + const uri = vscode.Uri.file(resolvedPath); + const content = await vscode.workspace.fs.readFile(uri); + const json = Buffer.from(content).toString('utf-8'); + + this.postMessage({ + type: 'drillResult', + json, + label, + filePath: resolvedPath, + readonly: isReadonly, + }); + this.log.appendLine( + `[CanvasPanel] drillInto resolved: ${resolvedPath} (readonly=${isReadonly})` + ); + return; + } catch { + // Try next candidate + } + } + + this.log.appendLine( + `[CanvasPanel] drillInto FAILED: tried ${candidates.length} paths, none found` + ); + vscode.window.showWarningMessage(`Cannot find: ${filePath}`); + } + + private async handleRequestStandardProse(url: string): Promise { + if (!this.assetService) return; + const prose = await this.assetService.resolveStandardProse(url); + if (prose) { + this.postMessage({ type: 'standardProse', url, prose }); + } else { + this.log.appendLine( + `[CanvasPanel] Could not resolve standard prose: ${url}` + ); + } + } + + private async handleGenerateSpec(): Promise { + if (!this.currentDocument) return; + + const path = await import('path'); + const filePath = this.currentDocument.uri.fsPath; + const fileName = path.basename(filePath); + const baseName = fileName.replace(/\.(calm\.)?json$/, ''); + const sdFileName = `${baseName}-solution-design.md`; + const sdPath = path.resolve(path.dirname(filePath), sdFileName); + + const standardsContext = await this.collectStandardsContext( + this.currentDocument.getText() + ); + const standardsSection = + standardsContext.length > 0 + ? [ + ``, + `Standards and guidelines that apply (read these for requirements):`, + ...standardsContext.map((s) => `---\n${s}\n---`), + ] + : []; + + const prompt = [ + `@CALM Generate a Solution Design document for the architecture at: ${filePath}`, + ``, + `Write the output to: ${sdPath}`, + ``, + `Instructions:`, + `- Read the architecture file at the path above`, + `- Follow the 13-section structure from .github/agents/calm-prompts/solution-design-creation.md`, + `- ALL diagrams MUST be Mermaid syntax`, + `- Include ALL 13 sections`, + ...standardsSection, + ].join('\n'); + + const commands = await vscode.commands.getCommands(true); + if (commands.includes('workbench.action.chat.open')) { + await vscode.commands.executeCommand('workbench.action.chat.open', { + query: prompt, + isPartialQuery: false, + newChat: true, + }); + } else { + vscode.window.showWarningMessage( + 'Copilot Chat is not available in this VS Code version.' + ); + } + } + + private async collectStandardsContext(archJson: string): Promise { + if (!this.assetService) return []; + + const referencedUrls = new Set(); + try { + const arch = JSON.parse(archJson) as { + nodes?: Array<{ controls?: Record }>; + controls?: Record; + }; + this.extractStandardUrls(arch.nodes ?? [], referencedUrls); + if (arch.controls) { + this.extractStandardUrls( + [{ controls: arch.controls }], + referencedUrls + ); + } + } catch { + /* malformed JSON */ + } + + const prose: string[] = []; + for (const url of referencedUrls) { + const resolved = await this.assetService.resolveStandardProse(url); + if (resolved) prose.push(resolved); + } + return prose; + } + + private extractStandardUrls( + nodes: Array<{ controls?: Record }>, + urls: Set + ): void { + for (const node of nodes) { + if (!node?.controls) continue; + for (const control of Object.values(node.controls)) { + const requirements = + ( + control as { + requirements?: Array>; + } + )?.requirements ?? []; + for (const req of requirements) { + const url = req['requirement-url']; + if (typeof url === 'string' && url.endsWith('.md')) + urls.add(url); + } + } + } + } + + private async handleSaveBuildingBlock( + filename: string, + content: string + ): Promise { + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + if (!workspaceFolder) { + vscode.window.showErrorMessage('No workspace folder open.'); + return; + } + + const buildingBlocksDir = vscode.Uri.joinPath( + workspaceFolder.uri, + 'building-blocks' + ); + try { + await vscode.workspace.fs.stat(buildingBlocksDir); + } catch { + await vscode.workspace.fs.createDirectory(buildingBlocksDir); + } + + const fileUri = vscode.Uri.joinPath(buildingBlocksDir, filename); + try { + await vscode.workspace.fs.stat(fileUri); + const overwrite = await vscode.window.showWarningMessage( + `${filename} already exists. Overwrite?`, + 'Overwrite', + 'Cancel' + ); + if (overwrite !== 'Overwrite') return; + } catch { + /* doesn't exist — good */ + } + + await vscode.workspace.fs.writeFile( + fileUri, + Buffer.from(content, 'utf-8') + ); + vscode.window.showInformationMessage( + `Building block saved: building-blocks/${filename}` + ); + + const doc = await vscode.workspace.openTextDocument(fileUri); + await vscode.window.showTextDocument(doc, vscode.ViewColumn.One); + } + + private handleCanvasChanged(json: string): void { + if (!this.currentDocument) return; + if (!this.syncCoordinator.canvasChanged()) return; + + const edit = new vscode.WorkspaceEdit(); + const fullRange = new vscode.Range( + this.currentDocument.positionAt(0), + this.currentDocument.positionAt( + this.currentDocument.getText().length + ) + ); + edit.replace(this.currentDocument.uri, fullRange, json); + void vscode.workspace.applyEdit(edit); + } + + private registerFileWatcher(_document: vscode.TextDocument): void { + this.fileWatcher?.dispose(); + // Watch every CALM document type across the workspace; pushFileToWebview filters to the + // file this panel is showing. A workspace-wide watcher (rather than one bound to a single + // file, which VS Code's RelativePattern can't express with a file as its base) keeps + // working even if the panel is later revealed for a different document. + this.fileWatcher = vscode.workspace.createFileSystemWatcher( + '**/*.{calm.json,architecture.json,solution.json,pattern.json,template.json}' + ); + this.fileWatcher.onDidChange( + (uri) => void this.pushFileToWebview(uri), + null, + this.disposables + ); + this.fileWatcher.onDidCreate( + (uri) => void this.pushFileToWebview(uri), + null, + this.disposables + ); + + // In-editor saves are the primary trigger and fire reliably even for documents that live + // outside the workspace folders (which the file-system watcher above would miss). + vscode.workspace.onDidSaveTextDocument( + (doc) => void this.pushFileToWebview(doc.uri), + null, + this.disposables + ); + } + + /** + * Push the on-disk contents of `uri` to the webview as a file-sourced model update — but only + * when it is the document this panel is showing and we are not echoing our own canvas write + * (guarded by the sync coordinator's suppression window). + */ + private async pushFileToWebview(uri: vscode.Uri): Promise { + if (!this.currentDocument) return; + if (uri.fsPath !== this.currentDocument.uri.fsPath) return; + if (!this.syncCoordinator.fileChanged()) return; + try { + const bytes = await vscode.workspace.fs.readFile(uri); + this.postMessage({ + type: 'modelUpdated', + json: Buffer.from(bytes).toString('utf-8'), + source: 'file', + }); + } catch { + /* file removed or unreadable — nothing to sync */ + } + } +} diff --git a/calm-plugins/vscode/src/extension/webview/html-provider.ts b/calm-plugins/vscode/src/extension/webview/html-provider.ts new file mode 100644 index 000000000..c9eb0e263 --- /dev/null +++ b/calm-plugins/vscode/src/extension/webview/html-provider.ts @@ -0,0 +1,55 @@ +import * as vscode from 'vscode'; +import * as crypto from 'crypto'; + +export function getWebviewHtml( + webview: vscode.Webview, + context: vscode.ExtensionContext, + document: vscode.TextDocument +): string { + const nonce = crypto.randomBytes(16).toString('hex'); + const scriptUri = webview.asWebviewUri( + vscode.Uri.joinPath(context.extensionUri, 'dist', 'webview', 'index.js') + ); + const styleUri = webview.asWebviewUri( + vscode.Uri.joinPath( + context.extensionUri, + 'dist', + 'webview', + 'index.css' + ) + ); + const cspSource = webview.cspSource; + const initialJson = JSON.stringify(document.getText()); + const calmConfig = vscode.workspace.getConfiguration('calm'); + const enabledPacks = calmConfig.get('packs.enabled', []); + const excludeNodes = calmConfig.get('packs.excludeNodes', []); + const packsJson = JSON.stringify(enabledPacks); + const excludeNodesJson = JSON.stringify(excludeNodes); + + return ` + + + + + + + CALM Canvas + + +
+ + + +`; +} diff --git a/calm-plugins/vscode/src/extensions/icons/ai.ts b/calm-plugins/vscode/src/extensions/icons/ai.ts new file mode 100644 index 000000000..4bc390b18 --- /dev/null +++ b/calm-plugins/vscode/src/extensions/icons/ai.ts @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: 2024 CalmStudio contributors - see NOTICE file +// +// SPDX-License-Identifier: Apache-2.0 +// +// Hand-crafted abstract SVG icons for AI/Agentic node types (16x16 viewBox, stroke-based). + +/** SVG icon strings for AI/Agentic node types. */ +export const aiIcons: Record = { + llm: ``, + + agent: ``, + + orchestrator: ``, + + 'vector-store': ``, + + tool: ``, + + memory: ``, + + guardrail: ``, + + 'embedding-model': ``, + + 'rag-pipeline': ``, + + 'prompt-template': ``, + + 'api-gateway': ``, + + 'human-in-the-loop': ``, + + 'knowledge-base': ``, + + 'eval-monitor': ``, + + 'mcp-server': ``, + + observability: ``, +}; diff --git a/calm-plugins/vscode/src/extensions/icons/aws.ts b/calm-plugins/vscode/src/extensions/icons/aws.ts new file mode 100644 index 000000000..6989cdd5b --- /dev/null +++ b/calm-plugins/vscode/src/extensions/icons/aws.ts @@ -0,0 +1,77 @@ +// SPDX-FileCopyrightText: 2024 CalmStudio contributors - see NOTICE file +// +// SPDX-License-Identifier: Apache-2.0 +// +// Hand-crafted abstract SVG icons for AWS services (16x16 viewBox, stroke-based). +// These are original creative works, NOT copies of official AWS icons. + +/** SVG icon strings for AWS service node types. */ +export const awsIcons: Record = { + lambda: ``, + + s3: ``, + + dynamodb: ``, + + ecs: ``, + + eks: ``, + + sqs: ``, + + sns: ``, + + 'api-gateway': ``, + + rds: ``, + + aurora: ``, + + cloudfront: ``, + + route53: ``, + + iam: ``, + + vpc: ``, + + ec2: ``, + + fargate: ``, + + eventbridge: ``, + + 'step-functions': ``, + + cognito: ``, + + elasticache: ``, + + kinesis: ``, + + redshift: ``, + + sagemaker: ``, + + glue: ``, + + 'secrets-manager': ``, + + cloudwatch: ``, + + waf: ``, + + kms: ``, + + elb: ``, + + efs: ``, + + subnet: ``, + + 'internet-gateway': ``, + + 'nat-gateway': ``, + + 'route-table': ``, +}; diff --git a/calm-plugins/vscode/src/extensions/icons/azure.ts b/calm-plugins/vscode/src/extensions/icons/azure.ts new file mode 100644 index 000000000..93c5f9c8a --- /dev/null +++ b/calm-plugins/vscode/src/extensions/icons/azure.ts @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: 2024 CalmStudio contributors - see NOTICE file +// +// SPDX-License-Identifier: Apache-2.0 +// +// Hand-crafted ABSTRACT SVG icons for Azure services (16x16 viewBox, stroke-based). +// These are ENTIRELY ORIGINAL creative works — NOT Microsoft official Azure icons. +// Per licensing research: Microsoft Azure icons require license agreement. +// All icons here are abstract geometric representations only. + +/** SVG icon strings for Azure service node types. */ +export const azureIcons: Record = { + functions: ``, + + 'app-service': ``, + + aks: ``, + + 'sql-database': ``, + + 'cosmos-db': ``, + + 'service-bus': ``, + + 'blob-storage': ``, + + 'front-door': ``, + + 'api-management': ``, + + 'key-vault': ``, + + 'active-directory': ``, + + 'cognitive-services': ``, + + 'event-hub': ``, + + 'redis-cache': ``, + + 'container-instances': ``, +}; diff --git a/calm-plugins/vscode/src/extensions/icons/fluxnova.ts b/calm-plugins/vscode/src/extensions/icons/fluxnova.ts new file mode 100644 index 000000000..74c6ce234 --- /dev/null +++ b/calm-plugins/vscode/src/extensions/icons/fluxnova.ts @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: 2026 CalmStudio Contributors +// +// SPDX-License-Identifier: Apache-2.0 +// +// Hand-crafted abstract SVG icons for FluxNova node types (16x16 viewBox, stroke-based). + +/** SVG icon strings for FluxNova node types. */ +export const fluxnovaIcons: Record = { + engine: ``, + + 'rest-api': ``, + + cockpit: ``, + + admin: ``, + + tasklist: ``, + + modeler: ``, + + 'external-task-worker': ``, + + 'dmn-engine': ``, + + 'process-db': ``, + + platform: ``, +}; diff --git a/calm-plugins/vscode/src/extensions/icons/gcp.ts b/calm-plugins/vscode/src/extensions/icons/gcp.ts new file mode 100644 index 000000000..1ede1866f --- /dev/null +++ b/calm-plugins/vscode/src/extensions/icons/gcp.ts @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: 2024 CalmStudio contributors - see NOTICE file +// +// SPDX-License-Identifier: Apache-2.0 +// +// Hand-crafted abstract SVG icons for GCP services (16x16 viewBox, stroke-based). +// These are original creative works, NOT copies of official GCP icons. + +/** SVG icon strings for GCP service node types. */ +export const gcpIcons: Record = { + 'cloud-run': ``, + + 'cloud-functions': ``, + + gke: ``, + + 'cloud-sql': ``, + + bigquery: ``, + + 'pub-sub': ``, + + 'cloud-storage': ``, + + firestore: ``, + + spanner: ``, + + 'cloud-cdn': ``, + + 'cloud-dns': ``, + + 'cloud-armor': ``, + + 'vertex-ai': ``, + + 'cloud-endpoints': ``, + + memorystore: ``, +}; diff --git a/calm-plugins/vscode/src/extensions/icons/identity.ts b/calm-plugins/vscode/src/extensions/icons/identity.ts new file mode 100644 index 000000000..bd9ea7e1d --- /dev/null +++ b/calm-plugins/vscode/src/extensions/icons/identity.ts @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: 2026 CalmStudio Contributors +// +// SPDX-License-Identifier: Apache-2.0 +// +// Hand-crafted abstract SVG icons for Identity & Access node types (16x16 viewBox, stroke-based). + +/** SVG icon strings for Identity & Access node types. */ +export const identityIcons: Record = { + 'identity-provider': ``, + + 'oauth-server': ``, + + 'oidc-provider': ``, + + 'saml-provider': ``, + + 'certificate-authority': ``, + + 'token-service': ``, + + 'mfa-service': ``, + + 'policy-engine': ``, +}; diff --git a/calm-plugins/vscode/src/extensions/icons/k8s.ts b/calm-plugins/vscode/src/extensions/icons/k8s.ts new file mode 100644 index 000000000..b110f10ed --- /dev/null +++ b/calm-plugins/vscode/src/extensions/icons/k8s.ts @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: 2024 CalmStudio contributors - see NOTICE file +// +// SPDX-License-Identifier: Apache-2.0 +// +// Hand-crafted abstract SVG icons for Kubernetes resource types (16x16 viewBox, stroke-based). + +/** SVG icon strings for Kubernetes resource node types. */ +export const k8sIcons: Record = { + pod: ``, + + deployment: ``, + + statefulset: ``, + + daemonset: ``, + + job: ``, + + cronjob: ``, + + service: ``, + + ingress: ``, + + configmap: ``, + + secret: ``, + + 'persistent-volume': ``, + + pvc: ``, + + namespace: ``, + + hpa: ``, +}; diff --git a/calm-plugins/vscode/src/extensions/icons/messaging.ts b/calm-plugins/vscode/src/extensions/icons/messaging.ts new file mode 100644 index 000000000..a06847221 --- /dev/null +++ b/calm-plugins/vscode/src/extensions/icons/messaging.ts @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: 2026 CalmStudio Contributors +// +// SPDX-License-Identifier: Apache-2.0 +// +// Hand-crafted abstract SVG icons for Messaging node types (16x16 viewBox, stroke-based). + +/** SVG icon strings for Messaging node types. */ +export const messagingIcons: Record = { + 'message-broker': ``, + + 'event-stream': ``, + + 'message-queue': ``, + + 'pub-sub': ``, + + 'event-bus': ``, + + 'stream-processor': ``, + + 'schema-registry': ``, + + 'notification-service': ``, +}; diff --git a/calm-plugins/vscode/src/extensions/icons/opengris.ts b/calm-plugins/vscode/src/extensions/icons/opengris.ts new file mode 100644 index 000000000..7ce1a3b66 --- /dev/null +++ b/calm-plugins/vscode/src/extensions/icons/opengris.ts @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: 2026 CalmStudio Contributors +// +// SPDX-License-Identifier: Apache-2.0 +// +// Hand-crafted abstract SVG icons for OpenGRIS node types (16x16 viewBox, stroke-based). + +/** SVG icon strings for OpenGRIS node types. */ +export const opengrisIcons: Record = { + scheduler: ``, + + worker: ``, + + 'worker-manager': ``, + + client: ``, + + 'object-storage': ``, + + cluster: ``, + + 'task-graph': ``, + + 'parallel-function': ``, +}; diff --git a/calm-plugins/vscode/src/extensions/index.ts b/calm-plugins/vscode/src/extensions/index.ts new file mode 100644 index 000000000..6418f5d68 --- /dev/null +++ b/calm-plugins/vscode/src/extensions/index.ts @@ -0,0 +1,51 @@ +// SPDX-FileCopyrightText: 2024 CalmStudio contributors - see NOTICE file +// +// SPDX-License-Identifier: Apache-2.0 + +export type { PackDefinition, NodeTypeEntry, PackColor } from './types.js'; +export { + registerPack, + resolvePackNode, + getAllPacks, + getPacksForTypes, + resetRegistry, +} from './registry.js'; +export { corePack } from './packs/core.js'; +export { awsPack } from './packs/aws.js'; +export { gcpPack } from './packs/gcp.js'; +export { azurePack } from './packs/azure.js'; +export { kubernetesPack } from './packs/kubernetes.js'; +export { aiPack } from './packs/ai.js'; +export { fluxnovaPack } from './packs/fluxnova.js'; +export { messagingPack } from './packs/messaging.js'; +export { identityPack } from './packs/identity.js'; +export { openGrisPack } from './packs/opengris.js'; + +import { registerPack } from './registry.js'; +import { corePack } from './packs/core.js'; +import { awsPack } from './packs/aws.js'; +import { gcpPack } from './packs/gcp.js'; +import { azurePack } from './packs/azure.js'; +import { kubernetesPack } from './packs/kubernetes.js'; +import { aiPack } from './packs/ai.js'; +import { fluxnovaPack } from './packs/fluxnova.js'; +import { messagingPack } from './packs/messaging.js'; +import { identityPack } from './packs/identity.js'; +import { openGrisPack } from './packs/opengris.js'; + +/** + * Register all built-in packs (core + 9 extension packs). + * Call once at application startup before resolving any pack nodes. + */ +export function initAllPacks(): void { + registerPack(corePack); + registerPack(fluxnovaPack); + registerPack(aiPack); + registerPack(awsPack); + registerPack(gcpPack); + registerPack(azurePack); + registerPack(kubernetesPack); + registerPack(messagingPack); + registerPack(identityPack); + registerPack(openGrisPack); +} diff --git a/calm-plugins/vscode/src/extensions/packs/ai.test.ts b/calm-plugins/vscode/src/extensions/packs/ai.test.ts new file mode 100644 index 000000000..184989e18 --- /dev/null +++ b/calm-plugins/vscode/src/extensions/packs/ai.test.ts @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: 2026 CalmStudio Contributors +// +// SPDX-License-Identifier: Apache-2.0 +import { describe, it, expect, beforeEach } from 'vitest'; +import { aiPack } from './ai.js'; +import { initAllPacks } from '../index.js'; +import { resolvePackNode, resetRegistry } from '../registry.js'; + +describe('aiPack', () => { + it('aiPack has id "ai"', () => { + expect(aiPack.id).toBe('ai'); + }); + + it('every node entry has non-empty typeId, label, icon, color, description', () => { + for (const node of aiPack.nodes) { + expect(node.typeId.trim().length, `${node.typeId} typeId empty`).toBeGreaterThan(0); + expect(node.label.trim().length, `${node.typeId} label empty`).toBeGreaterThan(0); + expect(node.icon.trim().length, `${node.typeId} icon empty`).toBeGreaterThan(0); + expect(node.color.bg.trim().length, `${node.typeId} color.bg empty`).toBeGreaterThan(0); + expect(node.color.border.trim().length, `${node.typeId} color.border empty`).toBeGreaterThan(0); + expect(node.color.stroke.trim().length, `${node.typeId} color.stroke empty`).toBeGreaterThan(0); + expect(node.description?.trim().length ?? 0, `${node.typeId} description empty`).toBeGreaterThan(0); + } + }); + + it('all typeIds start with "ai:" prefix', () => { + for (const node of aiPack.nodes) { + expect(node.typeId, `${node.typeId} missing ai: prefix`).toMatch(/^ai:/); + } + }); + + it('aiPack contains ai:mcp-server entry', () => { + const entry = aiPack.nodes.find((n) => n.typeId === 'ai:mcp-server'); + expect(entry).toBeDefined(); + expect(entry?.label).toBe('MCP Server'); + expect(entry?.icon.trim().length).toBeGreaterThan(0); + }); + + it('aiPack contains ai:observability entry', () => { + const entry = aiPack.nodes.find((n) => n.typeId === 'ai:observability'); + expect(entry).toBeDefined(); + expect(entry?.label).toBe('Observability'); + expect(entry?.icon.trim().length).toBeGreaterThan(0); + }); + + describe('after initAllPacks()', () => { + beforeEach(() => { + resetRegistry(); + initAllPacks(); + }); + + it('resolvePackNode("ai:mcp-server") returns the entry from aiPack', () => { + const entry = resolvePackNode('ai:mcp-server'); + expect(entry).not.toBeNull(); + expect(entry?.typeId).toBe('ai:mcp-server'); + }); + + it('resolvePackNode("ai:observability") returns the entry from aiPack', () => { + const entry = resolvePackNode('ai:observability'); + expect(entry).not.toBeNull(); + expect(entry?.typeId).toBe('ai:observability'); + }); + }); +}); diff --git a/calm-plugins/vscode/src/extensions/packs/ai.ts b/calm-plugins/vscode/src/extensions/packs/ai.ts new file mode 100644 index 000000000..a6eac960b --- /dev/null +++ b/calm-plugins/vscode/src/extensions/packs/ai.ts @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: 2024 CalmStudio contributors - see NOTICE file +// +// SPDX-License-Identifier: Apache-2.0 + +import type { PackDefinition, PackColor } from '../types.js'; +import { aiIcons } from '../icons/ai.js'; + +const aiColor: PackColor = { + bg: '#f5f0ff', + border: '#8b5cf6', + stroke: '#7c3aed', + badge: '[AI]', +}; + +function node( + typeId: string, + label: string, + iconKey: string, + description: string, +): PackDefinition['nodes'][number] { + return { + typeId, + label, + icon: aiIcons[iconKey] ?? aiIcons['llm']!, + color: aiColor, + description, + }; +} + +export const aiPack: PackDefinition = { + id: 'ai', + label: 'AI / Agentic', + version: '1.0.0', + color: aiColor, + nodes: [ + node('ai:llm', 'LLM', 'llm', 'Large Language Model inference endpoint'), + node('ai:agent', 'Agent', 'agent', 'An autonomous AI agent that takes actions'), + node('ai:orchestrator', 'Orchestrator', 'orchestrator', 'Coordinates multiple agents or pipeline steps'), + node('ai:vector-store', 'Vector Store', 'vector-store', 'Stores and retrieves vector embeddings for semantic search'), + node('ai:tool', 'Tool', 'tool', 'A callable function or API exposed to an AI agent'), + node('ai:memory', 'Memory', 'memory', 'Persistent or working memory for agent context'), + node('ai:guardrail', 'Guardrail', 'guardrail', 'Safety filter for validating agent inputs and outputs'), + node('ai:embedding-model', 'Embedding Model', 'embedding-model', 'Converts text into vector embeddings'), + node('ai:rag-pipeline', 'RAG Pipeline', 'rag-pipeline', 'Retrieval-Augmented Generation processing pipeline'), + node('ai:prompt-template', 'Prompt Template', 'prompt-template', 'Reusable structured prompt with variable slots'), + node('ai:api-gateway', 'API Gateway', 'api-gateway', 'Entry point gateway for AI API requests'), + node('ai:human-in-the-loop', 'Human in the Loop', 'human-in-the-loop', 'Human review or approval step in an AI pipeline'), + node('ai:knowledge-base', 'Knowledge Base', 'knowledge-base', 'Structured domain knowledge repository for AI'), + node('ai:eval-monitor', 'Eval Monitor', 'eval-monitor', 'Evaluation and quality monitoring for AI outputs'), + node('ai:mcp-server', 'MCP Server', 'mcp-server', 'Model Context Protocol server exposing tools and resources to AI agents'), + node('ai:observability', 'Observability', 'observability', 'Telemetry, tracing, and metrics for AI workloads'), + ], +}; diff --git a/calm-plugins/vscode/src/extensions/packs/aws.ts b/calm-plugins/vscode/src/extensions/packs/aws.ts new file mode 100644 index 000000000..25e39a7e3 --- /dev/null +++ b/calm-plugins/vscode/src/extensions/packs/aws.ts @@ -0,0 +1,90 @@ +// SPDX-FileCopyrightText: 2024 CalmStudio contributors - see NOTICE file +// +// SPDX-License-Identifier: Apache-2.0 + +import type { PackDefinition, PackColor } from '../types.js'; +import { awsIcons } from '../icons/aws.js'; + +// AWS service category colors — matching official AWS Architecture Icon color families +const compute: PackColor = { bg: '#fff3e0', border: '#ec7211', stroke: '#d45b07', badge: '[AWS]' }; +const storage: PackColor = { bg: '#e8f5e9', border: '#3f8624', stroke: '#2d6a1a', badge: '[AWS]' }; +const database: PackColor = { bg: '#e3f2fd', border: '#2e73b8', stroke: '#1a5a9e', badge: '[AWS]' }; +const networking: PackColor = { bg: '#f3e5f5', border: '#8c4fff', stroke: '#6b3bbf', badge: '[AWS]' }; +const security: PackColor = { bg: '#fce4ec', border: '#dd344c', stroke: '#b22a3e', badge: '[AWS]' }; +const appIntegration: PackColor = { bg: '#fce4ec', border: '#e7157b', stroke: '#c01067', badge: '[AWS]' }; +const analytics: PackColor = { bg: '#e3f2fd', border: '#4464e3', stroke: '#2f4cc7', badge: '[AWS]' }; +const ml: PackColor = { bg: '#e8f5e9', border: '#01a88d', stroke: '#008070', badge: '[AWS]' }; +const mgmt: PackColor = { bg: '#fce4ec', border: '#e7157b', stroke: '#c01067', badge: '[AWS]' }; + +// Pack-level default (AWS orange) +const awsDefault: PackColor = { bg: '#fff3e0', border: '#ff9900', stroke: '#e67e00', badge: '[AWS]' }; + +function node( + typeId: string, + label: string, + iconKey: string, + description: string, + color: PackColor = compute, + isContainer = false, +): PackDefinition['nodes'][number] { + return { + typeId, + label, + icon: awsIcons[iconKey] ?? awsIcons['ec2']!, + color, + description, + ...(isContainer ? { isContainer: true } : {}), + }; +} + +export const awsPack: PackDefinition = { + id: 'aws', + label: 'AWS', + version: '1.0.0', + color: awsDefault, + nodes: [ + // Compute + node('aws:lambda', 'Lambda', 'lambda', 'Serverless function compute service', compute), + node('aws:ec2', 'EC2', 'ec2', 'Elastic Compute Cloud virtual machine service', compute), + node('aws:ecs', 'ECS', 'ecs', 'Elastic Container Service for Docker workloads', compute), + node('aws:eks', 'EKS', 'eks', 'Managed Kubernetes service', compute), + // node('aws:fargate', 'Fargate', 'fargate', 'Serverless compute engine for containers', compute), + // Storage + node('aws:s3', 'S3', 's3', 'Scalable object storage service', storage), + node('aws:efs', 'EFS', 'efs', 'Elastic File System for shared network storage', storage), + // Database + node('aws:dynamodb', 'DynamoDB', 'dynamodb', 'Managed NoSQL database service', database), + node('aws:rds', 'RDS', 'rds', 'Relational Database Service for managed SQL databases', database), + node('aws:aurora', 'Aurora', 'aurora', 'High-performance managed relational database', database), + node('aws:elasticache', 'ElastiCache', 'elasticache', 'Managed in-memory caching service (Redis/Memcached)', database), + // node('aws:redshift', 'Redshift', 'redshift', 'Managed petabyte-scale data warehouse', database), + // Networking & CDN + node('aws:vpc', 'VPC', 'vpc', 'Virtual Private Cloud network isolation', networking, true), + node('aws:subnet', 'Subnet', 'subnet', 'VPC subnet (public or private)', networking, true), + node('aws:internet-gateway', 'Internet Gateway', 'internet-gateway', 'VPC internet gateway for public access', networking), + node('aws:nat-gateway', 'NAT Gateway', 'nat-gateway', 'Network address translation for private subnets', networking), + node('aws:route-table', 'Route Table', 'route-table', 'VPC route table for network routing', networking), + node('aws:cloudfront', 'CloudFront', 'cloudfront', 'Content Delivery Network service', networking), + node('aws:route53', 'Route 53', 'route53', 'Scalable DNS and domain registration service', networking), + node('aws:elb', 'ELB', 'elb', 'Elastic Load Balancer for traffic distribution', networking), + node('aws:api-gateway', 'API Gateway', 'api-gateway', 'Managed API Gateway for REST and WebSocket APIs', networking), + // Security & Identity + node('aws:iam', 'IAM', 'iam', 'Identity and Access Management for AWS resources', security), + // node('aws:cognito', 'Cognito', 'cognito', 'User authentication and identity management', security), + node('aws:waf', 'WAF', 'waf', 'Web Application Firewall for traffic filtering', security), + node('aws:kms', 'KMS', 'kms', 'Key Management Service for encryption keys', security), + node('aws:secrets-manager', 'Secrets Manager', 'secrets-manager', 'Managed service for storing application secrets', security), + // App Integration + // node('aws:sqs', 'SQS', 'sqs', 'Simple Queue Service for message queuing', appIntegration), + // node('aws:sns', 'SNS', 'sns', 'Simple Notification Service for pub/sub messaging', appIntegration), + // node('aws:eventbridge', 'EventBridge', 'eventbridge', 'Serverless event bus for application integration', appIntegration), + // node('aws:step-functions', 'Step Functions', 'step-functions', 'Serverless workflow orchestration service', appIntegration), + // Analytics + // node('aws:kinesis', 'Kinesis', 'kinesis', 'Real-time data streaming and processing', analytics), + node('aws:glue', 'Glue', 'glue', 'Serverless ETL and data integration service', analytics), + // ML + node('aws:sagemaker', 'SageMaker', 'sagemaker', 'Managed machine learning platform', ml), + // Management + node('aws:cloudwatch', 'CloudWatch', 'cloudwatch', 'Monitoring and observability service', mgmt), + ], +}; diff --git a/calm-plugins/vscode/src/extensions/packs/azure.ts b/calm-plugins/vscode/src/extensions/packs/azure.ts new file mode 100644 index 000000000..a6d6aa6dd --- /dev/null +++ b/calm-plugins/vscode/src/extensions/packs/azure.ts @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: 2024 CalmStudio contributors - see NOTICE file +// +// SPDX-License-Identifier: Apache-2.0 +// +// Icons are hand-crafted abstract designs — NOT Microsoft Azure official icons. +// See src/icons/azure.ts for licensing notes. + +import type { PackDefinition, PackColor } from '../types.js'; +import { azureIcons } from '../icons/azure.js'; + +const azureColor: PackColor = { + bg: '#f0f5ff', + border: '#0078d4', + stroke: '#005a9e', + badge: '[Azure]', +}; + +function node( + typeId: string, + label: string, + iconKey: string, + description: string, +): PackDefinition['nodes'][number] { + return { + typeId, + label, + icon: azureIcons[iconKey] ?? azureIcons['functions']!, + color: azureColor, + description, + }; +} + +export const azurePack: PackDefinition = { + id: 'azure', + label: 'Azure', + version: '1.0.0', + color: azureColor, + nodes: [ + node('azure:functions', 'Functions', 'functions', 'Serverless event-driven compute service'), + node('azure:app-service', 'App Service', 'app-service', 'Fully managed platform for web applications'), + node('azure:aks', 'AKS', 'aks', 'Azure Kubernetes Service managed cluster'), + node('azure:sql-database', 'SQL Database', 'sql-database', 'Fully managed relational database as a service'), + node('azure:cosmos-db', 'Cosmos DB', 'cosmos-db', 'Globally distributed multi-model NoSQL database'), + node('azure:service-bus', 'Service Bus', 'service-bus', 'Enterprise message broker with queues and topics'), + node('azure:blob-storage', 'Blob Storage', 'blob-storage', 'Massively scalable object storage for unstructured data'), + node('azure:front-door', 'Front Door', 'front-door', 'Global CDN and application delivery network'), + node('azure:api-management', 'API Management', 'api-management', 'Full lifecycle API management platform'), + node('azure:key-vault', 'Key Vault', 'key-vault', 'Managed secrets and cryptographic key storage'), + node('azure:active-directory', 'Active Directory', 'active-directory', 'Cloud identity and access management'), + node('azure:cognitive-services', 'Cognitive Services', 'cognitive-services', 'AI APIs for vision, speech, language, and decision'), + node('azure:event-hub', 'Event Hub', 'event-hub', 'Big data streaming platform and event ingestion'), + node('azure:redis-cache', 'Redis Cache', 'redis-cache', 'Managed Redis in-memory data store'), + node('azure:container-instances', 'Container Instances', 'container-instances', 'On-demand serverless container execution'), + ], +}; diff --git a/calm-plugins/vscode/src/extensions/packs/core.ts b/calm-plugins/vscode/src/extensions/packs/core.ts new file mode 100644 index 000000000..78f5afbe0 --- /dev/null +++ b/calm-plugins/vscode/src/extensions/packs/core.ts @@ -0,0 +1,84 @@ +// SPDX-FileCopyrightText: 2024 CalmStudio contributors - see NOTICE file +// +// SPDX-License-Identifier: Apache-2.0 + +import type { PackDefinition, PackColor } from '../types.js'; + +const coreColor: PackColor = { + bg: '#f8f9fa', + border: '#6366f1', + stroke: '#4f46e5', + badge: '[CALM]', +}; + +export const corePack: PackDefinition = { + id: 'core', + label: 'CALM Core', + version: '1.0.0', + color: coreColor, + nodes: [ + { + typeId: 'actor', + label: 'Actor', + icon: ``, + color: { bg: '#f0f0ff', border: '#6366f1', stroke: '#4f46e5' }, + description: 'A human or external entity that interacts with the system', + }, + { + typeId: 'system', + label: 'System', + icon: ``, + color: { bg: '#f0f0ff', border: '#6366f1', stroke: '#4f46e5' }, + description: 'A bounded software system within the architecture', + }, + { + typeId: 'service', + label: 'Service', + icon: ``, + color: { bg: '#f0f0ff', border: '#6366f1', stroke: '#4f46e5' }, + description: 'An independently deployable unit of functionality', + }, + { + typeId: 'database', + label: 'Database', + icon: ``, + color: { bg: '#f0f0ff', border: '#6366f1', stroke: '#4f46e5' }, + description: 'A persistent data store', + }, + { + typeId: 'network', + label: 'Network', + icon: ``, + color: { bg: '#f0f0ff', border: '#6366f1', stroke: '#4f46e5' }, + description: 'A network boundary or zone', + }, + { + typeId: 'webclient', + label: 'Web Client', + icon: ``, + color: { bg: '#f0f0ff', border: '#6366f1', stroke: '#4f46e5' }, + description: 'A browser-based frontend client', + }, + { + typeId: 'ecosystem', + label: 'Ecosystem', + icon: ``, + color: { bg: '#f0f0ff', border: '#6366f1', stroke: '#4f46e5' }, + description: 'A logical grouping of systems and services', + }, + { + typeId: 'ldap', + label: 'LDAP', + icon: ``, + color: { bg: '#f0f0ff', border: '#6366f1', stroke: '#4f46e5' }, + description: 'An LDAP directory service for identity management', + }, + { + typeId: 'data-asset', + label: 'Data Asset', + icon: ``, + color: { bg: '#f0f0ff', border: '#6366f1', stroke: '#4f46e5' }, + description: 'A named data asset or data flow in the architecture', + }, + ], +}; diff --git a/calm-plugins/vscode/src/extensions/packs/fluxnova.test.ts b/calm-plugins/vscode/src/extensions/packs/fluxnova.test.ts new file mode 100644 index 000000000..22de4b71d --- /dev/null +++ b/calm-plugins/vscode/src/extensions/packs/fluxnova.test.ts @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: 2026 CalmStudio Contributors +// +// SPDX-License-Identifier: Apache-2.0 +import { describe, it, expect, beforeEach } from 'vitest'; +import { fluxnovaPack } from './fluxnova.js'; +import { initAllPacks } from '../index.js'; +import { getAllPacks, resolvePackNode, resetRegistry } from '../registry.js'; + +describe('fluxnovaPack', () => { + it('fluxnovaPack has id "fluxnova"', () => { + expect(fluxnovaPack.id).toBe('fluxnova'); + }); + + it('fluxnovaPack has 10 node type entries', () => { + expect(fluxnovaPack.nodes).toHaveLength(10); + }); + + it('every node entry has non-empty typeId, label, icon, color, description', () => { + for (const node of fluxnovaPack.nodes) { + expect(node.typeId.trim().length, `${node.typeId} typeId empty`).toBeGreaterThan(0); + expect(node.label.trim().length, `${node.typeId} label empty`).toBeGreaterThan(0); + expect(node.icon.trim().length, `${node.typeId} icon empty`).toBeGreaterThan(0); + expect(node.color.bg.trim().length, `${node.typeId} color.bg empty`).toBeGreaterThan(0); + expect(node.color.border.trim().length, `${node.typeId} color.border empty`).toBeGreaterThan(0); + expect(node.color.stroke.trim().length, `${node.typeId} color.stroke empty`).toBeGreaterThan(0); + expect(node.description?.trim().length ?? 0, `${node.typeId} description empty`).toBeGreaterThan(0); + } + }); + + it('all typeIds start with "fluxnova:" prefix', () => { + for (const node of fluxnovaPack.nodes) { + expect(node.typeId, `${node.typeId} missing fluxnova: prefix`).toMatch(/^fluxnova:/); + } + }); + + it('fluxnova:platform has isContainer=true', () => { + const platform = fluxnovaPack.nodes.find((n) => n.typeId === 'fluxnova:platform'); + expect(platform).toBeDefined(); + expect(platform!.isContainer).toBe(true); + }); + + it('no other FluxNova type has isContainer=true', () => { + const containerNodes = fluxnovaPack.nodes.filter( + (n) => n.typeId !== 'fluxnova:platform' && n.isContainer === true, + ); + expect(containerNodes).toHaveLength(0); + }); + + it('fluxnovaPack.color.bg is "#fff7ed" (orange/amber family)', () => { + expect(fluxnovaPack.color.bg).toBe('#fff7ed'); + }); +}); + +describe('FluxNova integration via initAllPacks', () => { + beforeEach(() => { + resetRegistry(); + }); + + it('initAllPacks() registers 10 packs total', () => { + initAllPacks(); + expect(getAllPacks()).toHaveLength(10); + }); + + it('resolvePackNode("fluxnova:engine") returns non-null after initAllPacks()', () => { + initAllPacks(); + expect(resolvePackNode('fluxnova:engine')).not.toBeNull(); + }); +}); diff --git a/calm-plugins/vscode/src/extensions/packs/fluxnova.ts b/calm-plugins/vscode/src/extensions/packs/fluxnova.ts new file mode 100644 index 000000000..495aa9e80 --- /dev/null +++ b/calm-plugins/vscode/src/extensions/packs/fluxnova.ts @@ -0,0 +1,94 @@ +// SPDX-FileCopyrightText: 2026 CalmStudio Contributors +// +// SPDX-License-Identifier: Apache-2.0 + +import type { PackDefinition, PackColor } from '../types.js'; +import { fluxnovaIcons } from '../icons/fluxnova.js'; + +const fluxnovaColor: PackColor = { + bg: '#fff7ed', + border: '#f97316', + stroke: '#ea580c', + badge: '[FN]', +}; + +function node( + typeId: string, + label: string, + iconKey: string, + description: string, + opts?: { isContainer?: boolean; defaultChildren?: string[] }, +): PackDefinition['nodes'][number] { + return { + typeId, + label, + icon: fluxnovaIcons[iconKey] ?? fluxnovaIcons['engine']!, + color: fluxnovaColor, + description, + ...(opts?.isContainer ? { isContainer: true } : {}), + ...(opts?.defaultChildren ? { defaultChildren: opts.defaultChildren } : {}), + }; +} + +export const fluxnovaPack: PackDefinition = { + id: 'fluxnova', + label: 'FluxNova', + version: '1.0.0', + color: fluxnovaColor, + nodes: [ + node('fluxnova:engine', 'BPM Engine', 'engine', 'FluxNova BPMN 2.0 process execution engine'), + node( + 'fluxnova:rest-api', + 'REST API', + 'rest-api', + 'FluxNova REST API layer (200+ endpoints, OpenAPI)', + ), + node('fluxnova:cockpit', 'Cockpit', 'cockpit', 'Process monitoring and operations dashboard'), + node( + 'fluxnova:admin', + 'Admin', + 'admin', + 'User/group/tenant management and authorization console', + ), + node( + 'fluxnova:tasklist', + 'Tasklist', + 'tasklist', + 'Task assignment and lifecycle management UI', + ), + node('fluxnova:modeler', 'Modeler', 'modeler', 'BPMN/DMN visual modeling tool'), + node( + 'fluxnova:external-task-worker', + 'External Task Worker', + 'external-task-worker', + 'Polyglot service that polls and executes external tasks', + ), + node( + 'fluxnova:dmn-engine', + 'DMN Engine', + 'dmn-engine', + 'Decision Model and Notation rules engine', + ), + node( + 'fluxnova:process-db', + 'Process Database', + 'process-db', + 'Persistent store for process state, history, and audit logs', + ), + node( + 'fluxnova:platform', + 'FluxNova Platform', + 'platform', + 'Container for the full FluxNova deployment', + { + isContainer: true, + defaultChildren: [ + 'fluxnova:engine', + 'fluxnova:rest-api', + 'fluxnova:cockpit', + 'fluxnova:admin', + ], + }, + ), + ], +}; diff --git a/calm-plugins/vscode/src/extensions/packs/gcp.ts b/calm-plugins/vscode/src/extensions/packs/gcp.ts new file mode 100644 index 000000000..0bf00474e --- /dev/null +++ b/calm-plugins/vscode/src/extensions/packs/gcp.ts @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: 2024 CalmStudio contributors - see NOTICE file +// +// SPDX-License-Identifier: Apache-2.0 + +import type { PackDefinition, PackColor } from '../types.js'; +import { gcpIcons } from '../icons/gcp.js'; + +const gcpColor: PackColor = { + bg: '#f0f9f4', + border: '#34a853', + stroke: '#1e8e3e', + badge: '[GCP]', +}; + +function node( + typeId: string, + label: string, + iconKey: string, + description: string, +): PackDefinition['nodes'][number] { + return { + typeId, + label, + icon: gcpIcons[iconKey] ?? gcpIcons['cloud-run']!, + color: gcpColor, + description, + }; +} + +export const gcpPack: PackDefinition = { + id: 'gcp', + label: 'GCP', + version: '1.0.0', + color: gcpColor, + nodes: [ + node('gcp:cloud-run', 'Cloud Run', 'cloud-run', 'Fully managed serverless container platform'), + node('gcp:cloud-functions', 'Cloud Functions', 'cloud-functions', 'Event-driven serverless function execution'), + node('gcp:gke', 'GKE', 'gke', 'Google Kubernetes Engine managed cluster'), + node('gcp:cloud-sql', 'Cloud SQL', 'cloud-sql', 'Fully managed relational database service'), + node('gcp:bigquery', 'BigQuery', 'bigquery', 'Serverless multi-petabyte data warehouse'), + node('gcp:pub-sub', 'Pub/Sub', 'pub-sub', 'Asynchronous messaging and event streaming'), + node('gcp:cloud-storage', 'Cloud Storage', 'cloud-storage', 'Scalable unified object storage'), + node('gcp:firestore', 'Firestore', 'firestore', 'Serverless NoSQL document database'), + node('gcp:spanner', 'Spanner', 'spanner', 'Globally distributed strongly consistent database'), + node('gcp:cloud-cdn', 'Cloud CDN', 'cloud-cdn', 'Content delivery network for fast content serving'), + node('gcp:cloud-dns', 'Cloud DNS', 'cloud-dns', 'Scalable reliable Domain Name System service'), + node('gcp:cloud-armor', 'Cloud Armor', 'cloud-armor', 'DDoS protection and web application firewall'), + node('gcp:vertex-ai', 'Vertex AI', 'vertex-ai', 'Unified AI platform for ML model training and serving'), + node('gcp:cloud-endpoints', 'Cloud Endpoints', 'cloud-endpoints', 'API management and gateway service'), + node('gcp:memorystore', 'Memorystore', 'memorystore', 'Managed in-memory data store for Redis/Memcached'), + ], +}; diff --git a/calm-plugins/vscode/src/extensions/packs/identity.ts b/calm-plugins/vscode/src/extensions/packs/identity.ts new file mode 100644 index 000000000..647517183 --- /dev/null +++ b/calm-plugins/vscode/src/extensions/packs/identity.ts @@ -0,0 +1,85 @@ +// SPDX-FileCopyrightText: 2026 CalmStudio Contributors +// +// SPDX-License-Identifier: Apache-2.0 + +import type { PackDefinition, PackColor } from '../types.js'; +import { identityIcons } from '../icons/identity.js'; + +const identityColor: PackColor = { + bg: '#fff1f2', + border: '#e11d48', + stroke: '#be123c', + badge: '[IAM]', +}; + +function node( + typeId: string, + label: string, + iconKey: string, + description: string, +): PackDefinition['nodes'][number] { + return { + typeId, + label, + icon: identityIcons[iconKey] ?? identityIcons['identity-provider']!, + color: identityColor, + description, + }; +} + +export const identityPack: PackDefinition = { + id: 'identity', + label: 'Identity & Access', + version: '1.0.0', + color: identityColor, + nodes: [ + node( + 'identity:identity-provider', + 'Identity Provider', + 'identity-provider', + 'Central identity provider (IdP) for authentication', + ), + node( + 'identity:oauth-server', + 'OAuth Server', + 'oauth-server', + 'OAuth 2.0 authorization server', + ), + node( + 'identity:oidc-provider', + 'OIDC Provider', + 'oidc-provider', + 'OpenID Connect identity provider', + ), + node( + 'identity:saml-provider', + 'SAML Provider', + 'saml-provider', + 'SAML federation identity/service provider', + ), + node( + 'identity:certificate-authority', + 'Certificate Authority', + 'certificate-authority', + 'PKI certificate authority for certificate lifecycle', + ), + node( + 'identity:token-service', + 'Token Service', + 'token-service', + 'Security Token Service (STS) for token issuance', + ), + node( + 'identity:mfa-service', + 'MFA Service', + 'mfa-service', + 'Multi-factor authentication service', + ), + node( + 'identity:policy-engine', + 'Policy Engine', + 'policy-engine', + 'Access policy decision engine (OPA-style)', + ), + ], +}; diff --git a/calm-plugins/vscode/src/extensions/packs/kubernetes.ts b/calm-plugins/vscode/src/extensions/packs/kubernetes.ts new file mode 100644 index 000000000..de71d5fce --- /dev/null +++ b/calm-plugins/vscode/src/extensions/packs/kubernetes.ts @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: 2024 CalmStudio contributors - see NOTICE file +// +// SPDX-License-Identifier: Apache-2.0 + +import type { PackDefinition, PackColor } from '../types.js'; +import { k8sIcons } from '../icons/k8s.js'; + +const k8sColor: PackColor = { + bg: '#f0f4ff', + border: '#326ce5', + stroke: '#2054c8', + badge: '[K8s]', +}; + +function node( + typeId: string, + label: string, + iconKey: string, + description: string, + isContainer = false, +): PackDefinition['nodes'][number] { + return { + typeId, + label, + icon: k8sIcons[iconKey] ?? k8sIcons['pod']!, + color: k8sColor, + description, + ...(isContainer ? { isContainer: true } : {}), + }; +} + +export const kubernetesPack: PackDefinition = { + id: 'k8s', + label: 'Kubernetes', + version: '1.0.0', + color: k8sColor, + nodes: [ + node('k8s:pod', 'Pod', 'pod', 'The smallest deployable unit in Kubernetes'), + node('k8s:deployment', 'Deployment', 'deployment', 'Manages a set of identical pods with rolling updates'), + node('k8s:statefulset', 'StatefulSet', 'statefulset', 'Manages stateful applications with stable storage'), + node('k8s:daemonset', 'DaemonSet', 'daemonset', 'Ensures a pod copy runs on each cluster node'), + node('k8s:job', 'Job', 'job', 'Runs pods to completion for batch workloads'), + node('k8s:cronjob', 'CronJob', 'cronjob', 'Schedules jobs on a recurring time basis'), + node('k8s:service', 'Service', 'service', 'Exposes a set of pods as a stable network endpoint'), + node('k8s:ingress', 'Ingress', 'ingress', 'Manages external HTTP/S access to cluster services'), + node('k8s:configmap', 'ConfigMap', 'configmap', 'Stores non-sensitive configuration data'), + node('k8s:secret', 'Secret', 'secret', 'Stores sensitive configuration such as credentials'), + node('k8s:persistent-volume', 'Persistent Volume', 'persistent-volume', 'Cluster-wide storage resource provisioning'), + node('k8s:pvc', 'PVC', 'pvc', 'Persistent Volume Claim — a pod request for storage'), + node('k8s:namespace', 'Namespace', 'namespace', 'Virtual cluster partition for resource isolation', true), + node('k8s:hpa', 'HPA', 'hpa', 'Horizontal Pod Autoscaler — scales pods based on load'), + ], +}; diff --git a/calm-plugins/vscode/src/extensions/packs/messaging.ts b/calm-plugins/vscode/src/extensions/packs/messaging.ts new file mode 100644 index 000000000..288560f1a --- /dev/null +++ b/calm-plugins/vscode/src/extensions/packs/messaging.ts @@ -0,0 +1,85 @@ +// SPDX-FileCopyrightText: 2026 CalmStudio Contributors +// +// SPDX-License-Identifier: Apache-2.0 + +import type { PackDefinition, PackColor } from '../types.js'; +import { messagingIcons } from '../icons/messaging.js'; + +const messagingColor: PackColor = { + bg: '#f0fdfa', + border: '#0d9488', + stroke: '#0f766e', + badge: '[MSG]', +}; + +function node( + typeId: string, + label: string, + iconKey: string, + description: string, +): PackDefinition['nodes'][number] { + return { + typeId, + label, + icon: messagingIcons[iconKey] ?? messagingIcons['message-broker']!, + color: messagingColor, + description, + }; +} + +export const messagingPack: PackDefinition = { + id: 'messaging', + label: 'Messaging', + version: '1.0.0', + color: messagingColor, + nodes: [ + node( + 'messaging:message-broker', + 'Message Broker', + 'message-broker', + 'Message broker for routing and delivering messages between systems', + ), + node( + 'messaging:event-stream', + 'Event Stream', + 'event-stream', + 'Distributed event streaming platform for high-throughput data pipelines', + ), + node( + 'messaging:message-queue', + 'Message Queue', + 'message-queue', + 'Point-to-point asynchronous message queue', + ), + node( + 'messaging:pub-sub', + 'Pub/Sub', + 'pub-sub', + 'Publish-subscribe messaging system for fan-out delivery', + ), + node( + 'messaging:event-bus', + 'Event Bus', + 'event-bus', + 'Application-level event bus for decoupled communication', + ), + node( + 'messaging:stream-processor', + 'Stream Processor', + 'stream-processor', + 'Real-time event stream processing engine', + ), + node( + 'messaging:schema-registry', + 'Schema Registry', + 'schema-registry', + 'Message schema registry and compatibility service', + ), + node( + 'messaging:notification-service', + 'Notification Service', + 'notification-service', + 'Push notification and alerting service', + ), + ], +}; diff --git a/calm-plugins/vscode/src/extensions/packs/opengris.test.ts b/calm-plugins/vscode/src/extensions/packs/opengris.test.ts new file mode 100644 index 000000000..61bf1a292 --- /dev/null +++ b/calm-plugins/vscode/src/extensions/packs/opengris.test.ts @@ -0,0 +1,83 @@ +// SPDX-FileCopyrightText: 2026 CalmStudio Contributors +// +// SPDX-License-Identifier: Apache-2.0 +import { describe, it, expect, beforeEach } from 'vitest'; +import { openGrisPack } from './opengris.js'; +import { initAllPacks } from '../index.js'; +import { getAllPacks, resolvePackNode, resetRegistry } from '../registry.js'; + +describe('openGrisPack', () => { + it('openGrisPack has id "opengris"', () => { + expect(openGrisPack.id).toBe('opengris'); + }); + + it('openGrisPack has 8 node type entries', () => { + expect(openGrisPack.nodes).toHaveLength(8); + }); + + it('every node entry has non-empty typeId, label, icon, color, description', () => { + for (const node of openGrisPack.nodes) { + expect(node.typeId.trim().length, `${node.typeId} typeId empty`).toBeGreaterThan(0); + expect(node.label.trim().length, `${node.typeId} label empty`).toBeGreaterThan(0); + expect(node.icon.trim().length, `${node.typeId} icon empty`).toBeGreaterThan(0); + expect(node.color.bg.trim().length, `${node.typeId} color.bg empty`).toBeGreaterThan(0); + expect(node.color.border.trim().length, `${node.typeId} color.border empty`).toBeGreaterThan(0); + expect(node.color.stroke.trim().length, `${node.typeId} color.stroke empty`).toBeGreaterThan(0); + expect(node.description?.trim().length ?? 0, `${node.typeId} description empty`).toBeGreaterThan(0); + } + }); + + it('all typeIds start with "opengris:" prefix', () => { + for (const node of openGrisPack.nodes) { + expect(node.typeId, `${node.typeId} missing opengris: prefix`).toMatch(/^opengris:/); + } + }); + + it('opengris:worker-manager has isContainer=true', () => { + const workerManager = openGrisPack.nodes.find((n) => n.typeId === 'opengris:worker-manager'); + expect(workerManager).toBeDefined(); + expect(workerManager!.isContainer).toBe(true); + }); + + it('opengris:cluster has isContainer=true', () => { + const cluster = openGrisPack.nodes.find((n) => n.typeId === 'opengris:cluster'); + expect(cluster).toBeDefined(); + expect(cluster!.isContainer).toBe(true); + }); + + it('no other OpenGRIS type has isContainer=true', () => { + const containerNodes = openGrisPack.nodes.filter( + (n) => + n.typeId !== 'opengris:worker-manager' && + n.typeId !== 'opengris:cluster' && + n.isContainer === true, + ); + expect(containerNodes).toHaveLength(0); + }); + + it('opengris:cluster defaultChildren includes opengris:scheduler', () => { + const cluster = openGrisPack.nodes.find((n) => n.typeId === 'opengris:cluster'); + expect(cluster).toBeDefined(); + expect(cluster!.defaultChildren).toContain('opengris:scheduler'); + }); + + it('openGrisPack.color.bg is "#f0fdf4" (green family)', () => { + expect(openGrisPack.color.bg).toBe('#f0fdf4'); + }); +}); + +describe('OpenGRIS integration via initAllPacks', () => { + beforeEach(() => { + resetRegistry(); + }); + + it('initAllPacks() registers 10 packs total', () => { + initAllPacks(); + expect(getAllPacks()).toHaveLength(10); + }); + + it('resolvePackNode("opengris:scheduler") returns non-null after initAllPacks()', () => { + initAllPacks(); + expect(resolvePackNode('opengris:scheduler')).not.toBeNull(); + }); +}); diff --git a/calm-plugins/vscode/src/extensions/packs/opengris.ts b/calm-plugins/vscode/src/extensions/packs/opengris.ts new file mode 100644 index 000000000..0afdb1eef --- /dev/null +++ b/calm-plugins/vscode/src/extensions/packs/opengris.ts @@ -0,0 +1,93 @@ +// SPDX-FileCopyrightText: 2026 CalmStudio Contributors +// +// SPDX-License-Identifier: Apache-2.0 + +import type { PackDefinition, PackColor } from '../types.js'; +import { opengrisIcons } from '../icons/opengris.js'; + +const opengrisColor: PackColor = { + bg: '#f0fdf4', + border: '#16a34a', + stroke: '#15803d', + badge: '[OGRIS]', +}; + +function node( + typeId: string, + label: string, + iconKey: string, + description: string, + opts?: { isContainer?: boolean; defaultChildren?: string[] }, +): PackDefinition['nodes'][number] { + return { + typeId, + label, + icon: opengrisIcons[iconKey] ?? opengrisIcons['scheduler']!, + color: opengrisColor, + description, + ...(opts?.isContainer ? { isContainer: true } : {}), + ...(opts?.defaultChildren ? { defaultChildren: opts.defaultChildren } : {}), + }; +} + +export const openGrisPack: PackDefinition = { + id: 'opengris', + label: 'OpenGRIS', + version: '1.0.0', + color: opengrisColor, + nodes: [ + node( + 'opengris:scheduler', + 'Scheduler', + 'scheduler', + 'Central hub that routes tasks from clients to available workers via Cap\'n Proto/ZeroMQ', + ), + node( + 'opengris:worker', + 'Worker', + 'worker', + 'Executes distributed tasks assigned by the scheduler; runs on GNU/Linux', + ), + node( + 'opengris:worker-manager', + 'Worker Manager', + 'worker-manager', + 'Provisions and terminates workers on demand via adapters (Baremetal, AWS Batch, AWS ECS, IBM Symphony)', + { isContainer: true }, + ), + node( + 'opengris:client', + 'Client', + 'client', + 'Submits tasks to the scheduler and retrieves results; cross-platform (Windows/Linux)', + ), + node( + 'opengris:object-storage', + 'Object Storage', + 'object-storage', + 'Stores serialized task arguments and results; C++ implementation for performance', + ), + node( + 'opengris:cluster', + 'Cluster', + 'cluster', + 'Container grouping a full Scaler deployment (scheduler, workers, object storage)', + { + isContainer: true, + defaultChildren: ['opengris:scheduler', 'opengris:worker', 'opengris:object-storage'], + }, + ), + node( + 'opengris:task-graph', + 'Task Graph', + 'task-graph', + 'DAG-based task dependency graph from opengris-pargraph; nodes are functions, edges are data dependencies', + ), + node( + 'opengris:parallel-function', + 'Parallel Function', + 'parallel-function', + 'Function decorated with @parallel from opengris-parfun executing via map-reduce across worker pool', + ), + ], +}; diff --git a/calm-plugins/vscode/src/extensions/registry.test.ts b/calm-plugins/vscode/src/extensions/registry.test.ts new file mode 100644 index 000000000..6726b3ad4 --- /dev/null +++ b/calm-plugins/vscode/src/extensions/registry.test.ts @@ -0,0 +1,209 @@ +// SPDX-FileCopyrightText: 2024 CalmStudio contributors - see NOTICE file +// +// SPDX-License-Identifier: Apache-2.0 +import { describe, it, expect, beforeEach } from 'vitest'; +import { + registerPack, + resolvePackNode, + getAllPacks, + getPacksForTypes, + resetRegistry, +} from './registry.js'; +import { corePack } from './packs/core.js'; +import { initAllPacks } from './index.js'; +import type { PackDefinition } from './types.js'; + +describe('PackRegistry', () => { + beforeEach(() => { + resetRegistry(); + }); + + it('getAllPacks() returns empty array before any registration', () => { + expect(getAllPacks()).toEqual([]); + }); + + it('registerPack(corePack) makes corePack retrievable via getAllPacks()', () => { + registerPack(corePack); + expect(getAllPacks()).toContain(corePack); + }); + + it('resolvePackNode("actor") returns null (core types are unprefixed)', () => { + registerPack(corePack); + expect(resolvePackNode('actor')).toBeNull(); + }); + + it('resolvePackNode("aws:lambda") returns null when no AWS pack registered', () => { + expect(resolvePackNode('aws:lambda')).toBeNull(); + }); + + it('resolvePackNode("test:foo") returns the entry after registering a pack with that typeId', () => { + const testPack: PackDefinition = { + id: 'test', + label: 'Test Pack', + version: '1.0.0', + color: { bg: '#fff', border: '#000', stroke: '#000' }, + nodes: [ + { + typeId: 'test:foo', + label: 'Foo', + icon: '', + color: { bg: '#fff', border: '#000', stroke: '#000' }, + }, + ], + }; + registerPack(testPack); + const result = resolvePackNode('test:foo'); + expect(result).not.toBeNull(); + expect(result?.typeId).toBe('test:foo'); + }); + + it('getPacksForTypes returns unique pack IDs from colon-prefixed types, ignoring unprefixed', () => { + const packs = getPacksForTypes(['aws:lambda', 'actor', 'k8s:pod']); + expect(packs).toContain('aws'); + expect(packs).toContain('k8s'); + expect(packs).not.toContain('actor'); + expect(packs.length).toBe(2); + }); + + it('resetRegistry() clears all registered packs', () => { + registerPack(corePack); + expect(getAllPacks().length).toBeGreaterThan(0); + resetRegistry(); + expect(getAllPacks()).toEqual([]); + }); +}); + +describe('corePack', () => { + it('corePack.id === "core"', () => { + expect(corePack.id).toBe('core'); + }); + + it('corePack.nodes.length === 9', () => { + expect(corePack.nodes.length).toBe(9); + }); + + it('corePack.nodes includes entries for all 9 CALM types', () => { + const typeIds = corePack.nodes.map((n) => n.typeId); + const expectedTypes = [ + 'actor', + 'system', + 'service', + 'database', + 'network', + 'webclient', + 'ecosystem', + 'ldap', + 'data-asset', + ]; + for (const t of expectedTypes) { + expect(typeIds).toContain(t); + } + }); +}); + +describe('initAllPacks', () => { + beforeEach(() => { + resetRegistry(); + }); + + it('initAllPacks() registers core pack', () => { + initAllPacks(); + const packs = getAllPacks(); + expect(packs.some((p) => p.id === 'core')).toBe(true); + }); + + it('getAllPacks() returns 10 packs after initAllPacks()', () => { + initAllPacks(); + expect(getAllPacks()).toHaveLength(10); + }); + + it('AWS pack has >= 30 node entries', () => { + initAllPacks(); + const aws = getAllPacks().find((p) => p.id === 'aws'); + expect(aws).toBeDefined(); + expect(aws!.nodes.length).toBeGreaterThanOrEqual(25); + }); + + it('GCP pack has >= 15 node entries', () => { + initAllPacks(); + const gcp = getAllPacks().find((p) => p.id === 'gcp'); + expect(gcp).toBeDefined(); + expect(gcp!.nodes.length).toBeGreaterThanOrEqual(15); + }); + + it('Azure pack has >= 15 node entries', () => { + initAllPacks(); + const azure = getAllPacks().find((p) => p.id === 'azure'); + expect(azure).toBeDefined(); + expect(azure!.nodes.length).toBeGreaterThanOrEqual(15); + }); + + it('K8s pack has >= 14 node entries', () => { + initAllPacks(); + const k8s = getAllPacks().find((p) => p.id === 'k8s'); + expect(k8s).toBeDefined(); + expect(k8s!.nodes.length).toBeGreaterThanOrEqual(14); + }); + + it('AI pack has >= 14 node entries', () => { + initAllPacks(); + const ai = getAllPacks().find((p) => p.id === 'ai'); + expect(ai).toBeDefined(); + expect(ai!.nodes.length).toBeGreaterThanOrEqual(14); + }); + + it('resolvePackNode("aws:lambda") returns entry with label "Lambda"', () => { + initAllPacks(); + const entry = resolvePackNode('aws:lambda'); + expect(entry).not.toBeNull(); + expect(entry!.label).toBe('Lambda'); + }); + + it('resolvePackNode("k8s:pod") returns entry with label "Pod"', () => { + initAllPacks(); + const entry = resolvePackNode('k8s:pod'); + expect(entry).not.toBeNull(); + expect(entry!.label).toBe('Pod'); + }); + + it('resolvePackNode("ai:agent") returns entry with label "Agent"', () => { + initAllPacks(); + const entry = resolvePackNode('ai:agent'); + expect(entry).not.toBeNull(); + expect(entry!.label).toBe('Agent'); + }); + + it('resolvePackNode("gcp:cloud-run") returns entry with label "Cloud Run"', () => { + initAllPacks(); + const entry = resolvePackNode('gcp:cloud-run'); + expect(entry).not.toBeNull(); + expect(entry!.label).toBe('Cloud Run'); + }); + + it('resolvePackNode("azure:functions") returns entry with label "Functions"', () => { + initAllPacks(); + const entry = resolvePackNode('azure:functions'); + expect(entry).not.toBeNull(); + expect(entry!.label).toBe('Functions'); + }); + + it('every pack node has non-empty icon string', () => { + initAllPacks(); + for (const pack of getAllPacks()) { + for (const node of pack.nodes) { + expect(node.icon.trim().length, `${node.typeId} icon is empty`).toBeGreaterThan(0); + } + } + }); + + it('every pack node has a valid PackColor (bg, border, stroke all non-empty)', () => { + initAllPacks(); + for (const pack of getAllPacks()) { + for (const node of pack.nodes) { + expect(node.color.bg.trim().length, `${node.typeId} color.bg is empty`).toBeGreaterThan(0); + expect(node.color.border.trim().length, `${node.typeId} color.border is empty`).toBeGreaterThan(0); + expect(node.color.stroke.trim().length, `${node.typeId} color.stroke is empty`).toBeGreaterThan(0); + } + } + }); +}); diff --git a/calm-plugins/vscode/src/extensions/registry.ts b/calm-plugins/vscode/src/extensions/registry.ts new file mode 100644 index 000000000..c63f3f8f4 --- /dev/null +++ b/calm-plugins/vscode/src/extensions/registry.ts @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: 2024 CalmStudio contributors - see NOTICE file +// +// SPDX-License-Identifier: Apache-2.0 + +import type { PackDefinition, NodeTypeEntry } from './types.js'; + +/** Module-level registry map: pack id -> PackDefinition */ +const registry = new Map(); + +/** + * Register a pack in the registry. Re-registration with the same id overwrites. + */ +export function registerPack(pack: PackDefinition): void { + registry.set(pack.id, pack); +} + +/** + * Resolve a colon-prefixed CALM type string to its NodeTypeEntry. + * e.g. 'aws:lambda' -> looks up 'aws' pack, finds node with typeId 'aws:lambda'. + * Returns null if the pack is not registered or the type is not found. + * Unprefixed types (e.g. 'actor') always return null — core types are resolved by the canvas. + */ +export function resolvePackNode(calmType: string): NodeTypeEntry | null { + const colonIdx = calmType.indexOf(':'); + if (colonIdx === -1) return null; + const packId = calmType.slice(0, colonIdx); + const pack = registry.get(packId); + if (!pack) return null; + return pack.nodes.find((n) => n.typeId === calmType) ?? null; +} + +/** + * Returns all currently registered packs as an array. + */ +export function getAllPacks(): PackDefinition[] { + return [...registry.values()]; +} + +/** + * Given a list of CALM type strings, returns unique pack IDs for those that are + * colon-prefixed and whose pack is registered. + * e.g. ['aws:lambda', 'actor', 'k8s:pod'] -> ['aws', 'k8s'] + */ +export function getPacksForTypes(types: string[]): string[] { + const seen = new Set(); + for (const t of types) { + const colonIdx = t.indexOf(':'); + if (colonIdx !== -1) { + seen.add(t.slice(0, colonIdx)); + } + } + return [...seen]; +} + +/** + * Clears all registered packs. Intended for use in tests. + */ +export function resetRegistry(): void { + registry.clear(); +} diff --git a/calm-plugins/vscode/src/extensions/types.ts b/calm-plugins/vscode/src/extensions/types.ts new file mode 100644 index 000000000..a5805eb24 --- /dev/null +++ b/calm-plugins/vscode/src/extensions/types.ts @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: 2024 CalmStudio contributors - see NOTICE file +// +// SPDX-License-Identifier: Apache-2.0 + +/** Color family for a pack or an individual node type entry. */ +export interface PackColor { + bg: string; + border: string; + stroke: string; + badge?: string; +} + +/** A single node type entry within a pack. */ +export interface NodeTypeEntry { + /** Colon-prefixed for extension packs (e.g. 'aws:lambda'); unprefixed for core (e.g. 'actor'). */ + typeId: string; + /** Human-readable label, e.g. 'Lambda'. */ + label: string; + /** Inline SVG string (hand-crafted, 16x16 viewBox, stroke-based). */ + icon: string; + /** Color overrides — typically matches pack default. */ + color: PackColor; + /** One-line description of what this node type represents. */ + description?: string; + /** If true, this node renders as a container (large box that accepts children). */ + isContainer?: boolean; + /** For containers: auto-populate with these child type IDs when placed on the canvas. */ + defaultChildren?: string[]; +} + +/** A complete pack definition containing metadata and all node type entries. */ +export interface PackDefinition { + /** Short identifier: 'core', 'aws', 'gcp', 'azure', 'k8s', 'ai'. */ + id: string; + /** Display name, e.g. 'AWS'. */ + label: string; + /** Semantic version string. */ + version: string; + /** Pack-level default color family. */ + color: PackColor; + /** All node type entries in this pack. */ + nodes: NodeTypeEntry[]; +} diff --git a/calm-plugins/vscode/src/features/editor/editor-factory.ts b/calm-plugins/vscode/src/features/editor/editor-factory.ts deleted file mode 100644 index 995c0537b..000000000 --- a/calm-plugins/vscode/src/features/editor/editor-factory.ts +++ /dev/null @@ -1,94 +0,0 @@ -import * as vscode from 'vscode' -import { EditorViewModel } from './view-model/editor-view-model' -import { EditorView, type EditorViewEvents } from './view/editor-view' -import type { ApplicationStoreApi } from '../../application-store' -import type { SelectionService } from '../../core/mediators/selection-service' -import type { RefreshService } from '../../core/mediators/refresh-service' -import type { PreviewPanelFactory } from '../preview/preview-panel-factory' -import type { Logger } from '../../core/ports/logger' - -/** - * EditorFactory - MVVM Controller/Factory for editor features - * Creates and wires up ViewModel and EditorView (which includes language features) - */ -export class EditorFactory implements vscode.Disposable { - private disposables: vscode.Disposable[] = [] - - // MVVM Components - private readonly viewModel: EditorViewModel - private readonly editorView: EditorView - - constructor(private store: ApplicationStoreApi) { - // Create ViewModel (framework-free) - this.viewModel = new EditorViewModel(this.store) - this.disposables.push(this.viewModel) - - // Create event handlers for View → external service communication - const events: EditorViewEvents = { - onActiveEditorChanged: (doc: vscode.TextDocument) => { - // This will be handled by external services via binding - this.onActiveEditorChanged?.(doc) - } - } - - // Create EditorView (includes language features) - this.editorView = new EditorView(this.viewModel, events) - this.disposables.push(this.editorView) - } - - // Event handler for active editor changes (set by external binding) - private onActiveEditorChanged?: (doc: vscode.TextDocument) => void - - /** - * Get the editor view for reveal operations - */ - getEditorView(): EditorView { - return this.editorView - } - - /** - * Reveal a specific ID in the text editor - */ - async revealById(doc: vscode.TextDocument, id: string): Promise { - await this.editorView.revealById(doc, id) - } - - /** - * Bind selection service for editor selection changes - */ - bindSelectionService(selection: SelectionService): void { - // Replace the default onSelectionChanged handler with one that calls SelectionService - this.disposables.push( - vscode.window.onDidChangeTextEditorSelection(ev => { - selection.syncFromEditor(ev.textEditor) - }) - ) - } - - /** - * Bind active editor watcher for preview and refresh integration - */ - bindActiveEditorWatcher( - preview: PreviewPanelFactory, - refresh: RefreshService, - setTemplateMode: (enabled: boolean) => void, - log: Logger - ): void { - this.onActiveEditorChanged = (doc: vscode.TextDocument) => { - const panel = preview.get() - if (!panel) return - - log.info('[extension] Detected file switch, updating preview: ' + doc.uri.fsPath) - panel.reveal(doc.uri) - - const resultP = refresh.refreshForDocument(doc) - resultP?.then(r => { - if (r) setTemplateMode(r.isTemplateMode) - }) - } - } - - dispose(): void { - this.disposables.forEach(d => d.dispose()) - } -} \ No newline at end of file diff --git a/calm-plugins/vscode/src/features/editor/view-model/editor-view-model.spec.ts b/calm-plugins/vscode/src/features/editor/view-model/editor-view-model.spec.ts deleted file mode 100644 index f7224671d..000000000 --- a/calm-plugins/vscode/src/features/editor/view-model/editor-view-model.spec.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { EditorViewModel } from './editor-view-model' -import type { ApplicationStoreApi, ApplicationStore } from '../../../application-store' - -describe('EditorViewModel', () => { - let editorViewModel: EditorViewModel - let mockStore: ApplicationStoreApi - let mockState: ApplicationStore - - beforeEach(() => { - // Mock state object - mockState = { - currentModelIndex: undefined, - currentDocumentUri: undefined, - isTemplateMode: false, - templateFilePath: undefined, - architectureFilePath: undefined, - selectedElementId: undefined, - searchFilter: '', - showLabels: true, - setModelIndex: vi.fn(), - setCurrentDocument: vi.fn(), - setTemplateMode: vi.fn(), - setSelectedElement: vi.fn(), - setSearchFilter: vi.fn(), - setShowLabels: vi.fn(), - clearSelection: vi.fn(), - resetDocument: vi.fn() - } - - // Mock store API - mockStore = { - getState: vi.fn(function () { return mockState; }), - setState: vi.fn(), - subscribe: vi.fn(function () { return vi.fn(); }), // Return unsubscribe function - getInitialState: vi.fn(function () { return mockState; }) - } - }) - - describe('initialization', () => { - it('should create editor view model', () => { - editorViewModel = new EditorViewModel(mockStore) - - expect(editorViewModel).toBeDefined() - expect(editorViewModel instanceof EditorViewModel).toBe(true) - }) - }) - - describe('model index operations', () => { - beforeEach(() => { - const mockModelIndex = { - rangeOf: vi.fn(function (_id: string) { return { line: 1, character: 0 }; }), - idAt: vi.fn(function (_doc: any, _position: any) { return 'test-id'; }), - idToRange: new Map(), - doc: { getText: () => '' }, - model: { nodes: [], relationships: [], flows: [] }, - indexDocument: vi.fn(), - findIdRange: vi.fn() - } as any - mockState.currentModelIndex = mockModelIndex - }) - - it('should get range for ID when model index exists', () => { - editorViewModel = new EditorViewModel(mockStore) - - const range = editorViewModel.getRangeForId('test-id') - - expect(range).toEqual({ line: 1, character: 0 }) - expect((mockState.currentModelIndex as any).rangeOf).toHaveBeenCalledWith('test-id') - }) - - it('should return undefined for range when no model index', () => { - mockState.currentModelIndex = undefined - editorViewModel = new EditorViewModel(mockStore) - - const range = editorViewModel.getRangeForId('test-id') - - expect(range).toBeUndefined() - }) - - it('should get current model index', () => { - editorViewModel = new EditorViewModel(mockStore) - - const modelIndex = editorViewModel.getCurrentModelIndex() - - expect(modelIndex).toBe(mockState.currentModelIndex) - }) - - it('should get ID at position when model index exists', () => { - editorViewModel = new EditorViewModel(mockStore) - const mockDoc = { getText: () => 'test content' } - const mockPosition = { line: 1, character: 5 } - - const id = editorViewModel.getIdAtPosition(mockDoc, mockPosition) - - expect(id).toBe('test-id') - expect((mockState.currentModelIndex as any).idAt).toHaveBeenCalledWith(mockDoc, mockPosition) - }) - - it('should return undefined for ID at position when no model index', () => { - mockState.currentModelIndex = undefined - editorViewModel = new EditorViewModel(mockStore) - - const id = editorViewModel.getIdAtPosition({}, {}) - - expect(id).toBeUndefined() - }) - }) - - describe('template mode', () => { - it('should return false for template mode by default', () => { - editorViewModel = new EditorViewModel(mockStore) - - const isTemplateMode = editorViewModel.isTemplateMode() - - expect(isTemplateMode).toBe(false) - }) - - it('should return true when in template mode', () => { - mockState.isTemplateMode = true - editorViewModel = new EditorViewModel(mockStore) - - const isTemplateMode = editorViewModel.isTemplateMode() - - expect(isTemplateMode).toBe(true) - }) - }) - - describe('element selection', () => { - it('should set selected element', () => { - editorViewModel = new EditorViewModel(mockStore) - - editorViewModel.setSelectedElement('element-123') - - expect(mockState.setSelectedElement).toHaveBeenCalledWith('element-123') - }) - - it('should get selected element', () => { - mockState.selectedElementId = 'selected-element' - editorViewModel = new EditorViewModel(mockStore) - - const selectedElement = editorViewModel.getSelectedElement() - - expect(selectedElement).toBe('selected-element') - }) - - it('should return empty string when no selected element', () => { - mockState.selectedElementId = undefined - editorViewModel = new EditorViewModel(mockStore) - - const selectedElement = editorViewModel.getSelectedElement() - - expect(selectedElement).toBe('') - }) - }) - - describe('disposal', () => { - it('should dispose without errors', () => { - editorViewModel = new EditorViewModel(mockStore) - - expect(() => editorViewModel.dispose()).not.toThrow() - }) - - it('should call unsubscribe functions on disposal', () => { - const mockUnsubscribe = vi.fn() - mockStore.subscribe = vi.fn(function () { return mockUnsubscribe; }) - - editorViewModel = new EditorViewModel(mockStore) - editorViewModel.dispose() - - // Currently EditorViewModel doesn't subscribe to anything, but test the pattern - expect(() => editorViewModel.dispose()).not.toThrow() - }) - }) -}) \ No newline at end of file diff --git a/calm-plugins/vscode/src/features/editor/view-model/editor-view-model.ts b/calm-plugins/vscode/src/features/editor/view-model/editor-view-model.ts deleted file mode 100644 index 84160a565..000000000 --- a/calm-plugins/vscode/src/features/editor/view-model/editor-view-model.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { ApplicationStoreApi } from '../../../application-store' - -/** - * Framework-free ViewModel for editor features - * Handles editor-related presentation logic without VSCode dependencies - */ -export class EditorViewModel { - private unsubscribers: Array<() => void> = [] - - constructor(private store: ApplicationStoreApi) { - // Subscribe to store changes if needed - // Currently editor features are mostly reactive to user actions - } - - /** - * Get the range for a given ID in the current model - */ - getRangeForId(id: string): any { - const state = this.store.getState() - const modelIndex = state.currentModelIndex - if (!modelIndex) return undefined - return (modelIndex as any).rangeOf(id) - } - - /** - * Get the current model index - */ - getCurrentModelIndex(): any { - return this.store.getState().currentModelIndex - } - - /** - * Check if we're in template mode - */ - isTemplateMode(): boolean { - return this.store.getState().isTemplateMode - } - - /** - * Get ID at a specific document position - */ - getIdAtPosition(doc: any, position: any): string | undefined { - const modelIndex = this.getCurrentModelIndex() - if (!modelIndex) return undefined - return (modelIndex as any).idAt?.(doc, position) - } - - /** - * Update selected element in store - */ - setSelectedElement(id: string): void { - this.store.getState().setSelectedElement(id) - } - - /** - * Get current selected element - */ - getSelectedElement(): string { - return this.store.getState().selectedElementId || '' - } - - dispose(): void { - this.unsubscribers.forEach(unsub => unsub()) - this.unsubscribers = [] - } -} \ No newline at end of file diff --git a/calm-plugins/vscode/src/features/editor/view/editor-view.ts b/calm-plugins/vscode/src/features/editor/view/editor-view.ts deleted file mode 100644 index 24b493801..000000000 --- a/calm-plugins/vscode/src/features/editor/view/editor-view.ts +++ /dev/null @@ -1,146 +0,0 @@ -import * as vscode from 'vscode' -import type { EditorViewModel } from '../view-model/editor-view-model' -import { detectFileType, FileType } from '../../../models/file-types' - -export interface EditorViewEvents { - onActiveEditorChanged: (doc: vscode.TextDocument) => void -} - -/** - * EditorView - handles VSCode text editor interactions and language features - * Pure View layer that uses ViewModel for data and emits events for actions - */ -export class EditorView implements vscode.Disposable { - private disposables: vscode.Disposable[] = [] - - constructor( - private viewModel: EditorViewModel, - private events: EditorViewEvents - ) { - this.registerEventHandlers() - this.registerLanguageFeatures() - } - - private registerEventHandlers(): void { - // Handle active editor changes - this.disposables.push( - vscode.window.onDidChangeActiveTextEditor(editor => { - if (!editor) return - - const doc = editor.document - const ft = detectFileType(doc.uri.fsPath) - - if ( - (ft.type === FileType.ArchitectureFile && ft.isValid) || - (ft.type === FileType.TemplateFile && ft.isValid) - ) { - this.events.onActiveEditorChanged(doc) - } - }) - ) - } - - private registerLanguageFeatures(): void { - this.disposables.push( - vscode.languages.registerHoverProvider( - [{ language: 'json' }, { language: 'yaml' }], - this.createHoverProvider() - ), - vscode.languages.registerCodeLensProvider( - [{ language: 'json' }, { language: 'yaml' }], - this.createCodeLensProvider() - ) - ) - } - - private createHoverProvider(): vscode.HoverProvider { - return { - provideHover: (doc: vscode.TextDocument, pos: vscode.Position) => { - const modelIndex = this.viewModel.getCurrentModelIndex() - if (!modelIndex) return undefined - - // Get word under cursor - const word = doc.getText(doc.getWordRangeAtPosition(pos, /[A-Za-z0-9_-]+/)) - if (!word) return undefined - - // Check if this word has a range in the model - const range = this.viewModel.getRangeForId(word) - if (!range) return undefined - - return new vscode.Hover(`CALM id: ${word}`) - } - } - } - - private createCodeLensProvider(): vscode.CodeLensProvider { - return { - provideCodeLenses: (doc: vscode.TextDocument) => { - const text = doc.getText() - const lenses: vscode.CodeLens[] = [] - const rx = /"(?:id|unique-id)"\s*:\s*"([^"]+)"/g - let m: RegExpExecArray | null - - while ((m = rx.exec(text))) { - const id = m[1] - const range = new vscode.Range( - doc.positionAt(m.index), - doc.positionAt(m.index + m[0].length) - ) - lenses.push(new vscode.CodeLens(range, { - command: 'calm.openPreview', - title: 'Reveal in Graph', - arguments: [id] - })) - } - - return lenses - } - } - } - - /** - * Reveal a specific ID in the text editor - */ - async revealById(doc: vscode.TextDocument, id: string): Promise { - const range = this.viewModel.getRangeForId(id) - if (!range) return - - // Find the appropriate view column - const byVisible = vscode.window.visibleTextEditors.find(e => - e.document.uri.fsPath === doc.uri.fsPath - ) - let targetColumn: vscode.ViewColumn | undefined = byVisible?.viewColumn - - if (!targetColumn) { - try { - for (const group of vscode.window.tabGroups.all) { - const col = (group as any).viewColumn as vscode.ViewColumn | undefined - if (!col) continue - - for (const tab of group.tabs) { - const input: any = (tab as any).input - const uri: vscode.Uri | undefined = input?.uri || input?.primary || input?.original - if (uri && uri.fsPath === doc.uri.fsPath) { - targetColumn = col - break - } - } - if (targetColumn) break - } - } catch { } - } - - // Show the document and reveal the range - const editor = await vscode.window.showTextDocument( - doc, - targetColumn ? { viewColumn: targetColumn, preserveFocus: false } : { preserveFocus: false } - ) - - editor.selection = new vscode.Selection(range.start, range.end) - editor.revealRange(range, vscode.TextEditorRevealType.InCenterIfOutsideViewport) - } - - dispose(): void { - this.disposables.forEach(d => d.dispose()) - } -} \ No newline at end of file diff --git a/calm-plugins/vscode/src/features/preview/commands.spec.ts b/calm-plugins/vscode/src/features/preview/commands.spec.ts deleted file mode 100644 index 9659d0477..000000000 --- a/calm-plugins/vscode/src/features/preview/commands.spec.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { CommandRegistry, WebviewCommand, InMsg } from './commands' - -function fakeCommand(type: InMsg['type'], execute: (msg: any) => void | Promise): WebviewCommand { - return { type, execute } as WebviewCommand -} - -describe('CommandRegistry', () => { - let consoleErrorSpy: ReturnType - - beforeEach(() => { - consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => { }) - }) - - afterEach(() => { - consoleErrorSpy.mockRestore() - }) - - it('dispatches to the registered command matching the message type', () => { - const execute = vi.fn() - const registry = new CommandRegistry() - registry.register(fakeCommand('ready', execute)) - - const msg: InMsg = { type: 'ready' } - registry.dispatch(msg) - - expect(execute).toHaveBeenCalledWith(msg) - }) - - it('does nothing when no command is registered for the message type', () => { - const registry = new CommandRegistry() - expect(() => registry.dispatch({ type: 'ready' })).not.toThrow() - }) - - it('does not log anything when an async command resolves', async () => { - const registry = new CommandRegistry() - registry.register(fakeCommand('ready', async () => { })) - - registry.dispatch({ type: 'ready' }) - await new Promise(resolve => setTimeout(resolve, 0)) - - expect(consoleErrorSpy).not.toHaveBeenCalled() - }) - - it('catches a rejection from an async command instead of letting it go unhandled', async () => { - const registry = new CommandRegistry() - registry.register(fakeCommand('exportDiagram', async () => { - throw new Error('disposed panel') - })) - - registry.dispatch({ type: 'exportDiagram', format: 'png', data: '', diagramIndex: 1 }) - await new Promise(resolve => setTimeout(resolve, 0)) - - expect(consoleErrorSpy).toHaveBeenCalledWith( - expect.stringContaining('"exportDiagram"'), - expect.any(Error) - ) - }) -}) diff --git a/calm-plugins/vscode/src/features/preview/commands.ts b/calm-plugins/vscode/src/features/preview/commands.ts deleted file mode 100644 index 74e2c4c48..000000000 --- a/calm-plugins/vscode/src/features/preview/commands.ts +++ /dev/null @@ -1,118 +0,0 @@ -// Message typing -export type InMsg = - | { type: 'revealInEditor'; id: string } - | { type: 'selected'; id: string } - | { type: 'ready' } - | { type: 'rendered' } - | { type: 'runDocify'; templatePath?: string } - | { type: 'requestModelData' } - | { type: 'requestTemplateData' } - | { type: 'refreshAll' } - | { type: 'toggleLabels'; showLabels: boolean } - | { type: 'exportDiagram'; format: 'svg' | 'png'; data: string; diagramIndex: number } - | { type: 'log'; message: string } - | { type: 'error'; message: string; stack?: string } - -export function isInMsg(x: unknown): x is InMsg { - return typeof x === 'object' && x !== null && 'type' in x -} - -// Target interface that the PreviewPanel must implement. Keeps this module free of circular imports. -export interface PreviewCommandTarget { - handleRevealInEditor(id: string): void - handleSelected(id: string): void - handleReady(): void - handleRendered(): void - handleRunDocify(): void - handleRequestModelData(): void - handleRequestTemplateData(): void - handleRefreshAll(): void - handleToggleLabels(showLabels: boolean): void - handleExportDiagram(format: 'svg' | 'png', data: string, diagramIndex: number): void | Promise - handleLog(message: string): void - handleError(message: string, stack?: string): void -} - -// Command interface and registry -export interface WebviewCommand { - readonly type: T['type'] - execute(msg: T): void | Promise -} - -export class CommandRegistry { - private map = new Map() - register(cmd: WebviewCommand) { this.map.set(cmd.type, cmd) } - dispatch(msg: InMsg) { - // execute() may return a Promise for async commands (e.g. ExportDiagramCmd) - catch any - // rejection here so a failure outside a command's own try/catch (e.g. a disposed panel - // racing the write) doesn't surface as an unhandled promise rejection. - this.map.get(msg.type)?.execute(msg as any)?.catch?.((err: unknown) => { - console.error(`[CommandRegistry] Unhandled error executing command "${msg.type}":`, err) - }) - } -} - -// Concrete command implementations - each operates on the PreviewCommandTarget -export class RevealInEditorCmd implements WebviewCommand<{ type: 'revealInEditor'; id: string }> { - readonly type = 'revealInEditor' as const - constructor(private p: PreviewCommandTarget) { } - execute(m: { type: 'revealInEditor'; id: string }) { this.p.handleRevealInEditor(m.id) } -} -export class SelectedCmd implements WebviewCommand<{ type: 'selected'; id: string }> { - readonly type = 'selected' as const - constructor(private p: PreviewCommandTarget) { } - execute(m: { type: 'selected'; id: string }) { this.p.handleSelected(m.id) } -} -export class ReadyCmd implements WebviewCommand<{ type: 'ready' }> { - readonly type = 'ready' as const - constructor(private p: PreviewCommandTarget) { } - execute() { this.p.handleReady() } -} -export class RenderedCmd implements WebviewCommand<{ type: 'rendered' }> { - readonly type = 'rendered' as const - constructor(private p: PreviewCommandTarget) { } - execute() { this.p.handleRendered() } -} -export class RunDocifyCmd implements WebviewCommand<{ type: 'runDocify'; templatePath?: string }> { - readonly type = 'runDocify' as const - constructor(private p: PreviewCommandTarget) { } - execute() { this.p.handleRunDocify() } -} -export class RequestModelDataCmd implements WebviewCommand<{ type: 'requestModelData' }> { - readonly type = 'requestModelData' as const - constructor(private p: PreviewCommandTarget) { } - execute() { this.p.handleRequestModelData() } -} -export class RequestTemplateDataCmd implements WebviewCommand<{ type: 'requestTemplateData' }> { - readonly type = 'requestTemplateData' as const - constructor(private p: PreviewCommandTarget) { } - execute() { this.p.handleRequestTemplateData() } -} -export class RefreshAllCmd implements WebviewCommand<{ type: 'refreshAll' }> { - readonly type = 'refreshAll' as const - constructor(private p: PreviewCommandTarget) { } - execute() { this.p.handleRefreshAll() } -} -export class ToggleLabelsCmd implements WebviewCommand<{ type: 'toggleLabels'; showLabels: boolean }> { - readonly type = 'toggleLabels' as const - constructor(private p: PreviewCommandTarget) { } - execute(m: { type: 'toggleLabels'; showLabels: boolean }) { this.p.handleToggleLabels(!!m.showLabels) } -} -export class ExportDiagramCmd implements WebviewCommand<{ type: 'exportDiagram'; format: 'svg' | 'png'; data: string; diagramIndex: number }> { - readonly type = 'exportDiagram' as const - constructor(private p: PreviewCommandTarget) { } - execute(m: { type: 'exportDiagram'; format: 'svg' | 'png'; data: string; diagramIndex: number }) { - return this.p.handleExportDiagram(m.format, m.data, m.diagramIndex) - } -} -export class LogCmd implements WebviewCommand<{ type: 'log'; message: string }> { - readonly type = 'log' as const - constructor(private p: PreviewCommandTarget) { } - execute(m: { type: 'log'; message: string }) { this.p.handleLog(m.message) } -} -export class ErrorCmd implements WebviewCommand<{ type: 'error'; message: string; stack?: string }> { - readonly type = 'error' as const - constructor(private p: PreviewCommandTarget) { } - execute(m: { type: 'error'; message: string; stack?: string }) { this.p.handleError(m.message, m.stack) } -} - diff --git a/calm-plugins/vscode/src/features/preview/docify-tab/view-model/docify.view-model.spec.ts b/calm-plugins/vscode/src/features/preview/docify-tab/view-model/docify.view-model.spec.ts deleted file mode 100644 index e856abd99..000000000 --- a/calm-plugins/vscode/src/features/preview/docify-tab/view-model/docify.view-model.spec.ts +++ /dev/null @@ -1,450 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { DocifyViewModel } from './docify.view-model' - -// Mock timers for testing -vi.useFakeTimers() - -describe('DocifyViewModel', () => { - let docifyViewModel: DocifyViewModel - - beforeEach(() => { - docifyViewModel = new DocifyViewModel() - }) - - afterEach(() => { - docifyViewModel.dispose() - vi.clearAllTimers() - }) - - describe('initialization', () => { - it('should create docify view model with default state', () => { - expect(docifyViewModel).toBeDefined() - expect(docifyViewModel instanceof DocifyViewModel).toBe(true) - expect(docifyViewModel.getDocifyContent()).toBe('') - expect(docifyViewModel.getDocifyFormat()).toBe('markdown') - expect(docifyViewModel.getSourceFile()).toBe('') - expect(docifyViewModel.getIsRunning()).toBe(false) - expect(docifyViewModel.getIsLiveMode()).toBe(false) - expect(docifyViewModel.getLastError()).toBeUndefined() - }) - - it('should have no content or errors initially', () => { - expect(docifyViewModel.hasContent()).toBe(false) - expect(docifyViewModel.hasError()).toBe(false) - }) - }) - - describe('docify content management', () => { - it('should set and get docify result', () => { - const mockContent = '# Test Content' - const mockFormat = 'markdown' as const - const mockSourceFile = '/path/to/test.calm' - - docifyViewModel.setDocifyResult(mockContent, mockFormat, mockSourceFile) - - expect(docifyViewModel.getDocifyContent()).toBe(mockContent) - expect(docifyViewModel.getDocifyFormat()).toBe(mockFormat) - expect(docifyViewModel.getSourceFile()).toBe(mockSourceFile) - expect(docifyViewModel.hasContent()).toBe(true) - expect(docifyViewModel.getIsRunning()).toBe(false) - expect(docifyViewModel.getLastError()).toBeUndefined() - }) - - it('should set and get docify result with HTML format', () => { - const mockContent = '

Test Content

' - const mockFormat = 'html' as const - const mockSourceFile = '/path/to/test.calm' - - docifyViewModel.setDocifyResult(mockContent, mockFormat, mockSourceFile) - - expect(docifyViewModel.getDocifyFormat()).toBe(mockFormat) - expect(docifyViewModel.hasContent()).toBe(true) - }) - - it('should emit result event when setting docify result', () => { - const resultSpy = vi.fn() - docifyViewModel.onDocifyResult(resultSpy) - - const mockContent = '# Test' - const mockFormat = 'markdown' as const - const mockSourceFile = '/path/to/test.calm' - - docifyViewModel.setDocifyResult(mockContent, mockFormat, mockSourceFile) - - expect(resultSpy).toHaveBeenCalledWith({ - content: mockContent, - format: mockFormat, - sourceFile: mockSourceFile - }) - }) - - it('should clear content and reset state', () => { - // Set some content first - docifyViewModel.setDocifyResult('# Test', 'markdown', '/test.calm') - expect(docifyViewModel.hasContent()).toBe(true) - - const resultSpy = vi.fn() - docifyViewModel.onDocifyResult(resultSpy) - - docifyViewModel.clear() - - expect(docifyViewModel.getDocifyContent()).toBe('') - expect(docifyViewModel.getDocifyFormat()).toBe('markdown') - expect(docifyViewModel.getSourceFile()).toBe('') - expect(docifyViewModel.hasContent()).toBe(false) - expect(docifyViewModel.getIsRunning()).toBe(false) - expect(resultSpy).toHaveBeenCalledWith({ - content: '', - format: 'markdown', - sourceFile: '' - }) - }) - }) - - describe('error handling', () => { - it('should set and get docify error', () => { - const mockError = 'Test error message' - - docifyViewModel.setDocifyError(mockError) - - expect(docifyViewModel.getLastError()).toBe(mockError) - expect(docifyViewModel.hasError()).toBe(true) - expect(docifyViewModel.getIsRunning()).toBe(false) - }) - - it('should emit error event when setting docify error', () => { - const errorSpy = vi.fn() - docifyViewModel.onDocifyError(errorSpy) - - const mockError = 'Test error message' - docifyViewModel.setDocifyError(mockError) - - expect(errorSpy).toHaveBeenCalledWith(mockError) - }) - - it('should clear error when setting successful result', () => { - // Set error first - docifyViewModel.setDocifyError('Test error') - expect(docifyViewModel.hasError()).toBe(true) - - // Set successful result - docifyViewModel.setDocifyResult('Success!', 'markdown', '/test.calm') - - expect(docifyViewModel.getLastError()).toBeUndefined() - expect(docifyViewModel.hasError()).toBe(false) - }) - }) - - describe('running state management', () => { - it('should set and get running state', () => { - expect(docifyViewModel.getIsRunning()).toBe(false) - - docifyViewModel.setRunning(true) - expect(docifyViewModel.getIsRunning()).toBe(true) - - docifyViewModel.setRunning(false) - expect(docifyViewModel.getIsRunning()).toBe(false) - }) - - it('should emit status changed event when running state changes', () => { - const statusSpy = vi.fn() - docifyViewModel.onDocifyStatusChanged(statusSpy) - - docifyViewModel.setRunning(true) - - expect(statusSpy).toHaveBeenCalledWith({ - isRunning: true, - isLiveMode: false - }) - }) - - it('should not emit status changed event when running state is the same', () => { - docifyViewModel.setRunning(false) // Already false - - const statusSpy = vi.fn() - docifyViewModel.onDocifyStatusChanged(statusSpy) - - docifyViewModel.setRunning(false) // Same value - - expect(statusSpy).not.toHaveBeenCalled() - }) - }) - - describe('live mode', () => { - it('should set and get live mode state', () => { - expect(docifyViewModel.getIsLiveMode()).toBe(false) - - docifyViewModel.setLiveMode(true) - expect(docifyViewModel.getIsLiveMode()).toBe(true) - - docifyViewModel.setLiveMode(false) - expect(docifyViewModel.getIsLiveMode()).toBe(false) - }) - - it('should toggle live mode', () => { - expect(docifyViewModel.getIsLiveMode()).toBe(false) - - docifyViewModel.toggleLiveMode() - expect(docifyViewModel.getIsLiveMode()).toBe(true) - - docifyViewModel.toggleLiveMode() - expect(docifyViewModel.getIsLiveMode()).toBe(false) - }) - - it('should emit live mode changed event', () => { - const liveModeSpy = vi.fn() - docifyViewModel.onLiveModeChanged(liveModeSpy) - - docifyViewModel.setLiveMode(true) - - expect(liveModeSpy).toHaveBeenCalledWith(true) - }) - - it('should emit status changed event when live mode changes', () => { - const statusSpy = vi.fn() - docifyViewModel.onDocifyStatusChanged(statusSpy) - - docifyViewModel.setLiveMode(true) - - expect(statusSpy).toHaveBeenCalledWith({ - isRunning: false, - isLiveMode: true - }) - }) - - it('should not emit events when live mode is the same', () => { - docifyViewModel.setLiveMode(false) // Already false - - const liveModeSpy = vi.fn() - const statusSpy = vi.fn() - docifyViewModel.onLiveModeChanged(liveModeSpy) - docifyViewModel.onDocifyStatusChanged(statusSpy) - - docifyViewModel.setLiveMode(false) // Same value - - expect(liveModeSpy).not.toHaveBeenCalled() - expect(statusSpy).not.toHaveBeenCalled() - }) - }) - - describe('auto-refresh in live mode', () => { - it('should start auto-refresh when live mode is enabled', () => { - const requestSpy = vi.fn() - docifyViewModel.onDocifyRequest(requestSpy) - - docifyViewModel.setLiveMode(true) - - // Fast-forward timer by 2 seconds + debounce delay (300ms) - vi.advanceTimersByTime(2300) - - expect(requestSpy).toHaveBeenCalledOnce() - }) - - it('should continue auto-refresh every 2 seconds in live mode', () => { - const requestSpy = vi.fn() - docifyViewModel.onDocifyRequest(requestSpy) - - docifyViewModel.setLiveMode(true) - - // First interval (2s) + debounce (300ms) - vi.advanceTimersByTime(2300) - expect(requestSpy).toHaveBeenCalledTimes(1) - - // Simulate completing the first request - docifyViewModel.setRunning(false) - - // Second interval (2s) + debounce (300ms) - vi.advanceTimersByTime(2300) - expect(requestSpy).toHaveBeenCalledTimes(2) - - // Simulate completing the second request - docifyViewModel.setRunning(false) - - // Third interval (2s) + debounce (300ms) - vi.advanceTimersByTime(2300) - expect(requestSpy).toHaveBeenCalledTimes(3) - }) - - it('should stop auto-refresh when live mode is disabled', () => { - const requestSpy = vi.fn() - docifyViewModel.onDocifyRequest(requestSpy) - - // Enable live mode - docifyViewModel.setLiveMode(true) - vi.advanceTimersByTime(2300) // 2s auto-refresh + 300ms debounce - expect(requestSpy).toHaveBeenCalledOnce() - - // Disable live mode - docifyViewModel.setLiveMode(false) - requestSpy.mockClear() - - // Fast-forward more time - vi.advanceTimersByTime(4600) // 2 intervals worth of time - - expect(requestSpy).not.toHaveBeenCalled() - }) - - it('should not auto-refresh when docify is already running', () => { - const requestSpy = vi.fn() - docifyViewModel.onDocifyRequest(requestSpy) - - docifyViewModel.setLiveMode(true) - docifyViewModel.setRunning(true) // Set running state - - // Fast-forward timer (2s auto-refresh + 300ms debounce) - vi.advanceTimersByTime(2300) - - expect(requestSpy).not.toHaveBeenCalled() - }) - }) - - describe('docify request handling', () => { - it('should request docify execution when not running (after debounce)', () => { - const requestSpy = vi.fn() - docifyViewModel.onDocifyRequest(requestSpy) - - docifyViewModel.requestDocify() - - // Advance past debounce delay (300ms) - vi.advanceTimersByTime(300) - - expect(requestSpy).toHaveBeenCalledOnce() - expect(docifyViewModel.getIsRunning()).toBe(true) - }) - - it('should not request docify execution when already running', () => { - const requestSpy = vi.fn() - docifyViewModel.onDocifyRequest(requestSpy) - - docifyViewModel.setRunning(true) - docifyViewModel.requestDocify() - - // Advance past debounce delay - vi.advanceTimersByTime(300) - - expect(requestSpy).not.toHaveBeenCalled() - }) - - it('should coalesce multiple rapid requests into one', () => { - const requestSpy = vi.fn() - docifyViewModel.onDocifyRequest(requestSpy) - - // Rapidly call requestDocify multiple times - docifyViewModel.requestDocify() - vi.advanceTimersByTime(100) - docifyViewModel.requestDocify() - vi.advanceTimersByTime(100) - docifyViewModel.requestDocify() - - // Advance past debounce delay from last call - vi.advanceTimersByTime(300) - - // Should only fire once despite multiple calls - expect(requestSpy).toHaveBeenCalledOnce() - }) - - it('should execute immediately when using requestDocifyImmediate', () => { - const requestSpy = vi.fn() - docifyViewModel.onDocifyRequest(requestSpy) - - docifyViewModel.requestDocifyImmediate() - - // Should fire immediately without waiting for debounce - expect(requestSpy).toHaveBeenCalledOnce() - expect(docifyViewModel.getIsRunning()).toBe(true) - }) - }) - - describe('state management', () => { - it('should get complete state for debugging', () => { - docifyViewModel.setDocifyResult('# Test', 'html', '/test.calm') - docifyViewModel.setLiveMode(true) - - const state = docifyViewModel.getState() - - expect(state).toEqual({ - hasContent: true, - hasError: false, - isRunning: false, - isLiveMode: true, - format: 'html', - contentLength: 6, - lastError: undefined - }) - }) - - it('should reset all state', () => { - // Set some state - docifyViewModel.setDocifyResult('# Test', 'html', '/test.calm') - docifyViewModel.setLiveMode(true) - docifyViewModel.setDocifyError('Some error') - - const resultSpy = vi.fn() - docifyViewModel.onDocifyResult(resultSpy) - - docifyViewModel.reset() - - expect(docifyViewModel.getDocifyContent()).toBe('') - expect(docifyViewModel.getDocifyFormat()).toBe('markdown') - expect(docifyViewModel.getSourceFile()).toBe('') - expect(docifyViewModel.getIsLiveMode()).toBe(false) - expect(docifyViewModel.getIsRunning()).toBe(false) - expect(docifyViewModel.getLastError()).toBeUndefined() - expect(resultSpy).toHaveBeenCalledWith({ - content: '', - format: 'markdown', - sourceFile: '' - }) - }) - }) - - describe('disposal', () => { - it('should dispose without errors', () => { - expect(() => docifyViewModel.dispose()).not.toThrow() - }) - - it('should stop auto-refresh timer on disposal', () => { - const requestSpy = vi.fn() - docifyViewModel.onDocifyRequest(requestSpy) - - // Enable live mode to start timer - docifyViewModel.setLiveMode(true) - - // Dispose the view model - docifyViewModel.dispose() - - // Fast-forward time - vi.advanceTimersByTime(4000) - - // Timer should be stopped - expect(requestSpy).not.toHaveBeenCalled() - }) - - it('should dispose all emitters', () => { - // This test ensures dispose doesn't throw and cleans up properly - const requestSpy = vi.fn() - const resultSpy = vi.fn() - const errorSpy = vi.fn() - const statusSpy = vi.fn() - const liveModeSpy = vi.fn() - - docifyViewModel.onDocifyRequest(requestSpy) - docifyViewModel.onDocifyResult(resultSpy) - docifyViewModel.onDocifyError(errorSpy) - docifyViewModel.onDocifyStatusChanged(statusSpy) - docifyViewModel.onLiveModeChanged(liveModeSpy) - - expect(() => docifyViewModel.dispose()).not.toThrow() - - // After disposal, events should not be fired - docifyViewModel.setDocifyResult('test', 'markdown', 'file') - docifyViewModel.setDocifyError('error') - docifyViewModel.setRunning(true) - docifyViewModel.setLiveMode(true) - docifyViewModel.requestDocify() - - // Events should not be called after disposal (emitters disposed) - expect(requestSpy).not.toHaveBeenCalled() - }) - }) -}) \ No newline at end of file diff --git a/calm-plugins/vscode/src/features/preview/docify-tab/view-model/docify.view-model.ts b/calm-plugins/vscode/src/features/preview/docify-tab/view-model/docify.view-model.ts deleted file mode 100644 index 10df1c420..000000000 --- a/calm-plugins/vscode/src/features/preview/docify-tab/view-model/docify.view-model.ts +++ /dev/null @@ -1,246 +0,0 @@ -import { debounce } from 'lodash' -import { Emitter } from '../../../../core/emitter' - -/** Debounce delay for docify requests to prevent excessive processing */ -const DOCIFY_DEBOUNCE_MS = 300 - -/** - * DocifyViewModel - Framework-free ViewModel for docify tab - * Manages docify execution, results, and live mode - */ -export class DocifyViewModel { - private docifyRequestEmitter = new Emitter() - private docifyResultEmitter = new Emitter<{ content: string; format: 'html' | 'markdown'; sourceFile: string }>() - private docifyErrorEmitter = new Emitter() - private docifyStatusChangedEmitter = new Emitter<{ isRunning: boolean; isLiveMode: boolean }>() - private liveModeChangedEmitter = new Emitter() - - private docifyContent: string = '' - private docifyFormat: 'html' | 'markdown' = 'markdown' - private sourceFile: string = '' - private isRunning: boolean = false - private isLiveMode: boolean = false - private lastError: string | undefined - private autoRefreshTimer: NodeJS.Timeout | undefined - - /** Debounced docify execution to prevent rapid successive requests */ - private debouncedDocifyRequest = debounce(() => { - if (!this.isRunning) { - this.setRunning(true) - this.docifyRequestEmitter.fire() - } - }, DOCIFY_DEBOUNCE_MS) - - // Events - onDocifyRequest = this.docifyRequestEmitter.event - onDocifyResult = this.docifyResultEmitter.event - onDocifyError = this.docifyErrorEmitter.event - onDocifyStatusChanged = this.docifyStatusChangedEmitter.event - onLiveModeChanged = this.liveModeChangedEmitter.event - - /** - * Set docify result content - */ - setDocifyResult(content: string, format: 'html' | 'markdown', sourceFile: string): void { - this.docifyContent = content - this.docifyFormat = format - this.sourceFile = sourceFile - this.lastError = undefined - - this.setRunning(false) - this.docifyResultEmitter.fire({ content, format, sourceFile }) - } - - /** - * Get current docify content - */ - getDocifyContent(): string { - return this.docifyContent - } - - /** - * Get current docify format - */ - getDocifyFormat(): 'html' | 'markdown' { - return this.docifyFormat - } - - /** - * Get source file path - */ - getSourceFile(): string { - return this.sourceFile - } - - /** - * Set docify error - */ - setDocifyError(error: string): void { - this.lastError = error - this.setRunning(false) - this.docifyErrorEmitter.fire(error) - } - - /** - * Get last docify error - */ - getLastError(): string | undefined { - return this.lastError - } - - /** - * Set running status - */ - setRunning(isRunning: boolean): void { - if (this.isRunning !== isRunning) { - this.isRunning = isRunning - this.docifyStatusChangedEmitter.fire({ isRunning: this.isRunning, isLiveMode: this.isLiveMode }) - } - } - - /** - * Check if docify is currently running - */ - getIsRunning(): boolean { - return this.isRunning - } - - /** - * Set live mode status - */ - setLiveMode(isLiveMode: boolean): void { - if (this.isLiveMode !== isLiveMode) { - this.isLiveMode = isLiveMode - this.liveModeChangedEmitter.fire(isLiveMode) - this.docifyStatusChangedEmitter.fire({ isRunning: this.isRunning, isLiveMode: this.isLiveMode }) - - if (isLiveMode) { - this.startAutoRefresh() - } else { - this.stopAutoRefresh() - } - } - } - - /** - * Check if in live mode - */ - getIsLiveMode(): boolean { - return this.isLiveMode - } - - /** - * Request docify execution (debounced to prevent rapid successive requests) - * This improves performance when selection changes quickly or during typing - */ - requestDocify(): void { - this.debouncedDocifyRequest() - } - - /** - * Request immediate docify execution (bypasses debounce) - * Use sparingly - only when immediate feedback is required - */ - requestDocifyImmediate(): void { - this.debouncedDocifyRequest.cancel() - if (!this.isRunning) { - this.setRunning(true) - this.docifyRequestEmitter.fire() - } - } - - /** - * Start auto-refresh timer for live mode - */ - private startAutoRefresh(): void { - this.stopAutoRefresh() // Clear any existing timer - - // Auto-refresh every 2 seconds in live mode - this.autoRefreshTimer = setInterval(() => { - if (this.isLiveMode && !this.isRunning) { - this.requestDocify() - } - }, 2000) - } - - /** - * Stop auto-refresh timer - */ - private stopAutoRefresh(): void { - if (this.autoRefreshTimer) { - clearInterval(this.autoRefreshTimer) - this.autoRefreshTimer = undefined - } - } - - /** - * Toggle live mode - */ - toggleLiveMode(): void { - this.setLiveMode(!this.isLiveMode) - } - - /** - * Check if docify has content - */ - hasContent(): boolean { - return !!this.docifyContent - } - - /** - * Check if there was an error - */ - hasError(): boolean { - return !!this.lastError - } - - /** - * Get complete state for debugging - */ - getState() { - return { - hasContent: this.hasContent(), - hasError: this.hasError(), - isRunning: this.isRunning, - isLiveMode: this.isLiveMode, - format: this.docifyFormat, - contentLength: this.docifyContent.length, - lastError: this.lastError - } - } - - /** - * Clear docify content and errors - */ - clear(): void { - this.docifyContent = '' - this.docifyFormat = 'markdown' - this.sourceFile = '' - this.lastError = undefined - this.setRunning(false) - - this.docifyResultEmitter.fire({ content: '', format: 'markdown', sourceFile: '' }) - } - - /** - * Reset all docify state - */ - reset(): void { - this.debouncedDocifyRequest.cancel() - this.clear() - this.setLiveMode(false) - } - - /** - * Dispose all emitters and stop timers - */ - dispose(): void { - this.debouncedDocifyRequest.cancel() - this.stopAutoRefresh() - this.docifyRequestEmitter.dispose() - this.docifyResultEmitter.dispose() - this.docifyErrorEmitter.dispose() - this.docifyStatusChangedEmitter.dispose() - this.liveModeChangedEmitter.dispose() - } -} \ No newline at end of file diff --git a/calm-plugins/vscode/src/features/preview/docify-tab/view/docify-tab.view.ts b/calm-plugins/vscode/src/features/preview/docify-tab/view/docify-tab.view.ts deleted file mode 100644 index c9e91a290..000000000 --- a/calm-plugins/vscode/src/features/preview/docify-tab/view/docify-tab.view.ts +++ /dev/null @@ -1,333 +0,0 @@ -import type { DocifyViewModel } from '../view-model/docify.view-model' -import type { VsCodeApi } from '../../webview/panel.view-model' -import MermaidRenderer from '../../webview/mermaid-renderer' -import { DiagramControls } from '../../webview/diagram-controls' -import { DiagramExportControl } from '../../webview/diagram-export-control' -import { exportDiagram } from '../../webview/diagram-export' - -const DOM_SETTLE_DELAY_MS = 150 -const MIN_CLICKABLE_STROKE_WIDTH = 8 -const HOVER_STROKE_WIDTH = 12 - -/** - * DocifyTabView - Manages the DOM for the docify tab in the webview - * Keeps it simple like the original - just displays docify results - */ -export class DocifyTabView { - private viewModel: DocifyViewModel - private container: HTMLElement - private markdownRenderer = new MermaidRenderer() - private diagramControls: Map = new Map() - private diagramExportControls: Map = new Map() - private vscode: VsCodeApi - - constructor(viewModel: DocifyViewModel, container: HTMLElement, vscode: VsCodeApi) { - this.viewModel = viewModel - this.container = container - this.vscode = vscode - this.bindViewModel() - this.initialize() - } - - private bindViewModel(): void { - // Listen for docify results - this.viewModel.onDocifyResult((result: { content: string; format: 'html' | 'markdown'; sourceFile: string }) => { - this.renderResult(result).catch(error => { - console.error('Failed to render docify result:', error) - this.renderError('Failed to render result') - }) - }) - - // Listen for docify errors - this.viewModel.onDocifyError((error: string) => { - this.renderError(error) - }) - } - - /** - * Initialize with default state - */ - public initialize(): void { - (this.container as any).innerHTML = 'Initializing...' - } - - /** - * Render docify result - keep it simple but render markdown properly - */ - private async renderResult(result: { content: string; format: 'html' | 'markdown'; sourceFile: string }): Promise { - const { content, format, sourceFile } = result - - // Clean up old diagram controls - this.cleanupDiagramControls() - - if (format === 'html') { - (this.container as any).innerHTML = content - } else { - // For markdown, render it through MermaidRenderer with source file path for image resolution - const renderedHtml = await this.markdownRenderer.render(content, sourceFile); - (this.container as any).innerHTML = renderedHtml - - // Initialize pan/zoom on all rendered diagrams - this.initializePanZoomForDiagrams() - } - } - - /** - * Initialize pan/zoom controls for all Mermaid diagrams in the content - */ - private initializePanZoomForDiagrams(): void { - // Wait a bit for DOM to settle - setTimeout(() => { - const diagramContainers = this.container.querySelectorAll('.mermaid-diagram-container') - - diagramContainers.forEach((container, index) => { - const diagramId = container.getAttribute('data-diagram-id') - if (!diagramId) return - - // Initialize pan/zoom for this diagram - const panZoomManager = this.markdownRenderer.initializePanZoom(diagramId, { - minZoom: 0.1, - maxZoom: 10, - zoomScaleSensitivity: 0.2, - mouseWheelZoomEnabled: true, - }) - - if (panZoomManager) { - // Create zoom/pan controls for this diagram - const controls = new DiagramControls(panZoomManager) - const toolbar = controls.createControls(container as HTMLElement) - this.diagramControls.set(diagramId, controls) - - // Compose the export dropdown into the same toolbar - const exportControl = new DiagramExportControl({ - onExportSvg: () => this.handleExportDiagram(container as HTMLElement, 'svg', index + 1), - onExportPng: () => this.handleExportDiagram(container as HTMLElement, 'png', index + 1), - }) - const exportElement = exportControl.createControl() - if (exportElement) { - toolbar.appendChild(exportElement) - } - this.diagramExportControls.set(diagramId, exportControl) - } - - // Add click event listeners to Mermaid diagram nodes - this.addClickHandlersToMermaidDiagram(container as HTMLElement) - }) - }, DOM_SETTLE_DELAY_MS) - } - - /** - * Export a diagram as SVG or PNG and send it to the extension host for saving - */ - private async handleExportDiagram(container: HTMLElement, format: 'svg' | 'png', diagramIndex: number): Promise { - try { - const message = await exportDiagram(container, format, diagramIndex) - this.vscode.postMessage(message) - } catch (error) { - console.error(`[docify-tab] Failed to export diagram as ${format}:`, error) - } - } - - /** - * Add click event listeners to Mermaid diagram nodes to enable selection - */ - private addClickHandlersToMermaidDiagram(container: HTMLElement): void { - const svg = container.querySelector('svg') - if (!svg) { - console.warn('[docify-tab] No SVG found in container') - return - } - - console.log('[docify-tab] Setting up click handlers for Mermaid diagram') - const nodeGroups = svg.querySelectorAll('g.node') - console.log(`[docify-tab] Found ${nodeGroups.length} node groups in diagram`) - - nodeGroups.forEach(nodeGroup => { - // Extract the node ID from the group's ID attribute - // Mermaid generates IDs like "flowchart-conference-website-123" for node "conference-website" - const fullId = nodeGroup.getAttribute('id') - if (!fullId) return - - // Extract the actual node ID by removing the diagram prefix and suffix - const nodeId = this.extractNodeIdFromMermaidElement(fullId) - if (!nodeId) return - - console.log(`[docify-tab] Processing node: ${fullId} -> ${nodeId}`); - - // Make the entire node group clickable (includes shape + label) - (nodeGroup as SVGElement).style.cursor = 'pointer'; - (nodeGroup as SVGElement).style.pointerEvents = 'all' - - // Prevent text selection cursor on labels - const labels = nodeGroup.querySelectorAll('text, tspan, foreignObject') - labels.forEach(label => { - (label as SVGElement).style.cursor = 'pointer'; - (label as SVGElement).style.userSelect = 'none'; - (label as SVGElement).style.pointerEvents = 'none' // Let clicks bubble to parent group - }) - - // Add click event listener to the entire node group - nodeGroup.addEventListener('click', (event) => { - event.stopPropagation() - event.preventDefault() - console.log(`[docify-tab] Clicked on node: ${nodeId}`) - // Send selection message to the extension - this.vscode.postMessage({ type: 'selected', id: nodeId }) - }) - }) - - // Also handle edge clicks (relationships) - const edgePaths = svg.querySelectorAll('g.edgePath') - console.log(`[docify-tab] Found ${edgePaths.length} edge paths in diagram`) - - edgePaths.forEach(edgePath => { - const fullId = edgePath.getAttribute('id') - if (!fullId) return - - // Edge IDs are typically formatted differently, extract relationship ID - const relationshipId = this.extractRelationshipIdFromMermaidElement(fullId) - if (!relationshipId) return - - console.log(`[docify-tab] Processing edge: ${fullId} -> ${relationshipId}`); - - // Find the path element within the edge group - const path = edgePath.querySelector('path.path') - if (!path) { - console.warn(`[docify-tab] No path found for edge ${relationshipId}`) - return - } - - // Make the path clickable - increase stroke width for easier clicking - path.classList.add('clickable-edge'); - (path as SVGElement).style.cursor = 'pointer'; - (path as SVGElement).style.pointerEvents = 'visibleStroke' // Make the visible stroke area clickable - - // Store original stroke width and increase for clickability - const originalStrokeWidth = window.getComputedStyle(path as Element).strokeWidth; - path.setAttribute('data-original-stroke-width', originalStrokeWidth) - - // Increase stroke width for better clickability - const currentWidth = parseFloat(originalStrokeWidth) || 2; - (path as SVGElement).style.strokeWidth = `${Math.max(currentWidth, MIN_CLICKABLE_STROKE_WIDTH)}px` - - // Add hover effect via event listeners instead of CSS (more reliable for SVG) - path.addEventListener('mouseenter', () => { - (path as SVGElement).style.strokeWidth = `${HOVER_STROKE_WIDTH}px` - }) - path.addEventListener('mouseleave', () => { - const baseWidth = parseFloat(path.getAttribute('data-original-stroke-width') || '2'); - (path as SVGElement).style.strokeWidth = `${Math.max(baseWidth, MIN_CLICKABLE_STROKE_WIDTH)}px` - }) - - // Add click event listener to the path - path.addEventListener('click', (event) => { - event.stopPropagation() - event.preventDefault() - console.log(`[docify-tab] Clicked on relationship: ${relationshipId}`) - this.vscode.postMessage({ type: 'selected', id: relationshipId }) - }) - }) - - console.log('[docify-tab] Click handlers attached successfully') - } - - /** - * Extract CALM node ID from Mermaid-generated element ID. - * - * Expected input format: Mermaid typically generates IDs like "flowchart-conference-website-123". - * This function removes the "flowchart-" prefix and the trailing numeric suffix. - * - * For nodes with reserved words, the ID may be prefixed with "node_" to avoid Mermaid conflicts. - * This function removes that prefix to get the original CALM node ID. - * - * Example: - * Input: "flowchart-conference-website-123" - * Output: "conference-website" - * - * Input: "flowchart-node_end-user-456" - * Output: "end-user" - * - * @param mermaidId The Mermaid-generated element ID string. - * @returns The extracted node ID, or null if extraction fails. - */ - private extractNodeIdFromMermaidElement(mermaidId: string): string | null { - // Remove common Mermaid prefixes - let cleaned = mermaidId.replace(/^flowchart-/, '') - - // Remove trailing numbers (Mermaid appends random numbers) - // Match everything except the last segment if it's purely numeric - const match = cleaned.match(/^(.+?)-\d+$/) - if (match) { - cleaned = match[1] - } - - // Remove the node_ prefix if it was added to avoid Mermaid reserved words - cleaned = cleaned.replace(/^node_/, '') - - // If no numeric suffix, return the cleaned ID - return cleaned || null - } - - /** - * Extract CALM relationship ID from Mermaid-generated edge element ID - * Mermaid edge IDs are formatted like "L-node1-node2-0" or similar - */ - private extractRelationshipIdFromMermaidElement(mermaidId: string): string | null { - // Mermaid edge IDs often start with "L-" or "LE-" - let cleaned = mermaidId.replace(/^L[E]?-/, '') - - // Remove trailing numbers - const match = cleaned.match(/^(.+?)-\d+$/) - if (match) { - return match[1] - } - - return cleaned || null - } - - /** - * Clean up diagram controls - */ - private cleanupDiagramControls(): void { - this.diagramControls.forEach(controls => controls.destroy()) - this.diagramControls.clear() - this.diagramExportControls.forEach(exportControl => exportControl.destroy()) - this.diagramExportControls.clear() - this.markdownRenderer.destroyAllPanZoom() - } - - /** - * Render docify error - */ - private renderError(error: string): void { - (this.container as any).innerHTML = `
Error: ${this.escapeHtml(error)}
` - } - - /** - * Update the view when external selection changes - */ - public updateSelection(_selectedId?: string): void { - // Just update the internal state, don't auto-trigger docify - // The TabsViewModel will handle triggering docify when appropriate - } - - /** - * Escape HTML to prevent XSS - */ - private escapeHtml(str: string): string { - return str - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') - } - - /** - * Cleanup event listeners - */ - public dispose(): void { - this.cleanupDiagramControls(); - (this.container as any).innerHTML = '' - } -} \ No newline at end of file diff --git a/calm-plugins/vscode/src/features/preview/model-tab/view-model/calm-model.view-model.spec.ts b/calm-plugins/vscode/src/features/preview/model-tab/view-model/calm-model.view-model.spec.ts deleted file mode 100644 index 0f2c3e513..000000000 --- a/calm-plugins/vscode/src/features/preview/model-tab/view-model/calm-model.view-model.spec.ts +++ /dev/null @@ -1,331 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { CalmModelViewModel } from './calm-model.view-model' - -describe('CalmModelViewModel', () => { - let calmModelViewModel: CalmModelViewModel - - beforeEach(() => { - calmModelViewModel = new CalmModelViewModel() - }) - - afterEach(() => { - calmModelViewModel.dispose() - }) - - describe('initialization', () => { - it('should create calm model view model with default state', () => { - expect(calmModelViewModel).toBeDefined() - expect(calmModelViewModel instanceof CalmModelViewModel).toBe(true) - expect(calmModelViewModel.getModelData()).toBeNull() - expect(calmModelViewModel.getSelectedId()).toBeUndefined() - expect(calmModelViewModel.hasData()).toBe(false) - }) - - it('should have proper initial state', () => { - const state = calmModelViewModel.getState() - - expect(state).toEqual({ - hasData: false, - selectedId: undefined - }) - }) - }) - - describe('model data management', () => { - it('should set and get model data', () => { - const mockModelData = { - nodes: [ - { id: 'node1', name: 'Service A', type: 'service' }, - { id: 'node2', name: 'Database B', type: 'database' } - ], - relationships: [ - { id: 'rel1', source: 'node1', target: 'node2' } - ] - } - - calmModelViewModel.setModelData(mockModelData) - - expect(calmModelViewModel.getModelData()).toBe(mockModelData) - expect(calmModelViewModel.hasData()).toBe(true) - }) - - it('should emit data changed event when setting model data', () => { - const dataChangedSpy = vi.fn() - calmModelViewModel.onDataChanged(dataChangedSpy) - - const mockModelData = { nodes: [], relationships: [] } - calmModelViewModel.setModelData(mockModelData) - - expect(dataChangedSpy).toHaveBeenCalledWith({ - modelData: mockModelData, - selectedId: undefined - }) - }) - - it('should handle setting null model data', () => { - // Set some data first - calmModelViewModel.setModelData({ test: 'data' }) - expect(calmModelViewModel.hasData()).toBe(true) - - // Set to null - calmModelViewModel.setModelData(null) - - expect(calmModelViewModel.getModelData()).toBeNull() - expect(calmModelViewModel.hasData()).toBe(false) - }) - - it('should handle setting undefined model data', () => { - calmModelViewModel.setModelData(undefined) - - expect(calmModelViewModel.getModelData()).toBeUndefined() - expect(calmModelViewModel.hasData()).toBe(false) - }) - - it('should emit data changed event with current selection when setting model data', () => { - const dataChangedSpy = vi.fn() - - // Set selection first - calmModelViewModel.setSelectedId('test-selection') - calmModelViewModel.onDataChanged(dataChangedSpy) - - const mockModelData = { test: 'data' } - calmModelViewModel.setModelData(mockModelData) - - expect(dataChangedSpy).toHaveBeenCalledWith({ - modelData: mockModelData, - selectedId: 'test-selection' - }) - }) - }) - - describe('selection management', () => { - it('should set and get selected ID', () => { - expect(calmModelViewModel.getSelectedId()).toBeUndefined() - - calmModelViewModel.setSelectedId('element-123') - expect(calmModelViewModel.getSelectedId()).toBe('element-123') - - calmModelViewModel.setSelectedId('element-456') - expect(calmModelViewModel.getSelectedId()).toBe('element-456') - }) - - it('should emit selection changed event when setting selected ID', () => { - const selectionChangedSpy = vi.fn() - calmModelViewModel.onSelectionChanged(selectionChangedSpy) - - calmModelViewModel.setSelectedId('element-123') - - expect(selectionChangedSpy).toHaveBeenCalledWith('element-123') - }) - - it('should emit data changed event when setting selected ID', () => { - const dataChangedSpy = vi.fn() - const mockModelData = { test: 'data' } - - calmModelViewModel.setModelData(mockModelData) - calmModelViewModel.onDataChanged(dataChangedSpy) - - calmModelViewModel.setSelectedId('element-123') - - expect(dataChangedSpy).toHaveBeenCalledWith({ - modelData: mockModelData, - selectedId: 'element-123' - }) - }) - - it('should not emit events when setting same selected ID', () => { - calmModelViewModel.setSelectedId('element-123') - - const selectionChangedSpy = vi.fn() - const dataChangedSpy = vi.fn() - calmModelViewModel.onSelectionChanged(selectionChangedSpy) - calmModelViewModel.onDataChanged(dataChangedSpy) - - // Set same ID again - calmModelViewModel.setSelectedId('element-123') - - expect(selectionChangedSpy).not.toHaveBeenCalled() - expect(dataChangedSpy).not.toHaveBeenCalled() - }) - - it('should handle setting undefined selected ID', () => { - // Set a selection first - calmModelViewModel.setSelectedId('element-123') - expect(calmModelViewModel.getSelectedId()).toBe('element-123') - - const selectionChangedSpy = vi.fn() - calmModelViewModel.onSelectionChanged(selectionChangedSpy) - - // Clear selection - calmModelViewModel.setSelectedId(undefined) - - expect(calmModelViewModel.getSelectedId()).toBeUndefined() - expect(selectionChangedSpy).toHaveBeenCalledWith(undefined) - }) - - it('should emit events when changing from undefined to defined selection', () => { - expect(calmModelViewModel.getSelectedId()).toBeUndefined() - - const selectionChangedSpy = vi.fn() - calmModelViewModel.onSelectionChanged(selectionChangedSpy) - - calmModelViewModel.setSelectedId('element-123') - - expect(selectionChangedSpy).toHaveBeenCalledWith('element-123') - }) - }) - - describe('editor reveal functionality', () => { - it('should emit reveal in editor request', () => { - const revealSpy = vi.fn() - calmModelViewModel.onRevealInEditorRequest(revealSpy) - - calmModelViewModel.revealInEditor('element-123') - - expect(revealSpy).toHaveBeenCalledWith('element-123') - }) - - it('should handle multiple reveal requests', () => { - const revealSpy = vi.fn() - calmModelViewModel.onRevealInEditorRequest(revealSpy) - - calmModelViewModel.revealInEditor('element-123') - calmModelViewModel.revealInEditor('element-456') - calmModelViewModel.revealInEditor('element-789') - - expect(revealSpy).toHaveBeenCalledTimes(3) - expect(revealSpy).toHaveBeenNthCalledWith(1, 'element-123') - expect(revealSpy).toHaveBeenNthCalledWith(2, 'element-456') - expect(revealSpy).toHaveBeenNthCalledWith(3, 'element-789') - }) - }) - - describe('state management', () => { - it('should get complete state for debugging', () => { - const mockModelData = { nodes: [], relationships: [] } - calmModelViewModel.setModelData(mockModelData) - calmModelViewModel.setSelectedId('element-123') - - const state = calmModelViewModel.getState() - - expect(state).toEqual({ - hasData: true, - selectedId: 'element-123' - }) - }) - - it('should reset all state', () => { - const mockModelData = { test: 'data' } - calmModelViewModel.setModelData(mockModelData) - calmModelViewModel.setSelectedId('element-123') - - const dataChangedSpy = vi.fn() - const selectionChangedSpy = vi.fn() - calmModelViewModel.onDataChanged(dataChangedSpy) - calmModelViewModel.onSelectionChanged(selectionChangedSpy) - - calmModelViewModel.reset() - - expect(calmModelViewModel.getModelData()).toBeNull() - expect(calmModelViewModel.getSelectedId()).toBeUndefined() - expect(calmModelViewModel.hasData()).toBe(false) - - expect(dataChangedSpy).toHaveBeenCalledWith({ - modelData: null, - selectedId: undefined - }) - expect(selectionChangedSpy).toHaveBeenCalledWith(undefined) - }) - - it('should have proper state after reset', () => { - // Set some state first - calmModelViewModel.setModelData({ test: 'data' }) - calmModelViewModel.setSelectedId('element-123') - - calmModelViewModel.reset() - - const state = calmModelViewModel.getState() - expect(state).toEqual({ - hasData: false, - selectedId: undefined - }) - }) - }) - - describe('data presence checks', () => { - it('should return false for hasData with null data', () => { - calmModelViewModel.setModelData(null) - expect(calmModelViewModel.hasData()).toBe(false) - }) - - it('should return false for hasData with undefined data', () => { - calmModelViewModel.setModelData(undefined) - expect(calmModelViewModel.hasData()).toBe(false) - }) - - it('should return true for hasData with empty object', () => { - calmModelViewModel.setModelData({}) - expect(calmModelViewModel.hasData()).toBe(true) - }) - - it('should return true for hasData with empty array', () => { - calmModelViewModel.setModelData([]) - expect(calmModelViewModel.hasData()).toBe(true) - }) - - it('should return true for hasData with string data', () => { - calmModelViewModel.setModelData('test') - expect(calmModelViewModel.hasData()).toBe(true) - }) - - it('should return false for hasData with empty string', () => { - calmModelViewModel.setModelData('') - expect(calmModelViewModel.hasData()).toBe(false) - }) - - it('should return true for hasData with number data', () => { - calmModelViewModel.setModelData(42) - expect(calmModelViewModel.hasData()).toBe(true) - }) - - it('should return false for hasData with zero', () => { - calmModelViewModel.setModelData(0) - expect(calmModelViewModel.hasData()).toBe(false) - }) - }) - - describe('disposal', () => { - it('should dispose without errors', () => { - expect(() => calmModelViewModel.dispose()).not.toThrow() - }) - - it('should dispose all emitters', () => { - // Set up event listeners - const dataChangedSpy = vi.fn() - const selectionChangedSpy = vi.fn() - const revealSpy = vi.fn() - - calmModelViewModel.onDataChanged(dataChangedSpy) - calmModelViewModel.onSelectionChanged(selectionChangedSpy) - calmModelViewModel.onRevealInEditorRequest(revealSpy) - - // Dispose - calmModelViewModel.dispose() - - // Try to trigger events after disposal - they should not fire - calmModelViewModel.setModelData({ test: 'data' }) - calmModelViewModel.setSelectedId('element-123') - calmModelViewModel.revealInEditor('element-456') - - // Events should not be called after disposal - expect(dataChangedSpy).not.toHaveBeenCalled() - expect(selectionChangedSpy).not.toHaveBeenCalled() - expect(revealSpy).not.toHaveBeenCalled() - }) - - it('should allow multiple dispose calls', () => { - calmModelViewModel.dispose() - expect(() => calmModelViewModel.dispose()).not.toThrow() - }) - }) -}) \ No newline at end of file diff --git a/calm-plugins/vscode/src/features/preview/model-tab/view-model/calm-model.view-model.ts b/calm-plugins/vscode/src/features/preview/model-tab/view-model/calm-model.view-model.ts deleted file mode 100644 index 8a67e8582..000000000 --- a/calm-plugins/vscode/src/features/preview/model-tab/view-model/calm-model.view-model.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { Emitter } from '../../../../core/emitter' - -/** - * CalmModelViewModel - Framework-free ViewModel for CALM model tab - * Manages model data display and filtering for JSON rendering - */ -export class CalmModelViewModel { - private dataChangedEmitter = new Emitter<{ modelData: any; selectedId?: string }>() - private selectionChangedEmitter = new Emitter() - private revealInEditorRequestEmitter = new Emitter() - - private modelData: any = null - private selectedId: string | undefined - - // Events - onDataChanged = this.dataChangedEmitter.event - onSelectionChanged = this.selectionChangedEmitter.event - onRevealInEditorRequest = this.revealInEditorRequestEmitter.event - - /** - * Set the model data to display - */ - setModelData(data: any): void { - this.modelData = data - this.dataChangedEmitter.fire({ modelData: this.modelData, selectedId: this.selectedId }) - } - - /** - * Get current model data - */ - getModelData(): any { - return this.modelData - } - - /** - * Set selected element ID - */ - setSelectedId(id: string | undefined): void { - if (this.selectedId !== id) { - this.selectedId = id - this.selectionChangedEmitter.fire(id) - this.dataChangedEmitter.fire({ modelData: this.modelData, selectedId: this.selectedId }) - } - } - - /** - * Get selected element ID - */ - getSelectedId(): string | undefined { - return this.selectedId - } - - /** - * Request to reveal element in editor - */ - revealInEditor(id: string): void { - this.revealInEditorRequestEmitter.fire(id) - } - - /** - * Check if model has data - */ - hasData(): boolean { - return !!this.modelData - } - - /** - * Get state for debugging - */ - getState() { - return { - hasData: this.hasData(), - selectedId: this.selectedId - } - } - - /** - * Reset all state - */ - reset(): void { - this.modelData = null - this.selectedId = undefined - - this.dataChangedEmitter.fire({ modelData: null, selectedId: undefined }) - this.selectionChangedEmitter.fire(undefined) - } - - /** - * Dispose all emitters - */ - dispose(): void { - this.dataChangedEmitter.dispose() - this.selectionChangedEmitter.dispose() - this.revealInEditorRequestEmitter.dispose() - } -} \ No newline at end of file diff --git a/calm-plugins/vscode/src/features/preview/model-tab/view/model-tab.view.ts b/calm-plugins/vscode/src/features/preview/model-tab/view/model-tab.view.ts deleted file mode 100644 index ea1989253..000000000 --- a/calm-plugins/vscode/src/features/preview/model-tab/view/model-tab.view.ts +++ /dev/null @@ -1,134 +0,0 @@ -import type { CalmModelViewModel } from '../view-model/calm-model.view-model' - -/** - * ModelTabView - Manages the DOM for the model tab in the webview - * Handles rendering model data and user interactions for the model tab - */ -export class ModelTabView { - private viewModel: CalmModelViewModel - private container: HTMLElement - - constructor(viewModel: CalmModelViewModel, container: HTMLElement) { - this.viewModel = viewModel - this.container = container - this.bindViewModel() - } - - private bindViewModel(): void { - // Listen for data changes and re-render - this.viewModel.onDataChanged(({ modelData, selectedId }) => { - this.render(modelData, selectedId) - }) - - // Listen for selection changes to highlight elements - this.viewModel.onSelectionChanged((selectedId) => { - this.highlightSelection(selectedId) - }) - } - - /** - * Render the model data in the tab - */ - private render(modelData: any, selectedId?: string): void { - if (!modelData) { - (this.container as any).innerHTML = 'No model data available' - return - } - - // Filter data based on selection if provided - const displayData = selectedId && selectedId !== 'none' - ? this.filterDataBySelection(modelData, selectedId) - : modelData - - // Create formatted JSON display - keep it simple like the original - const jsonStr = JSON.stringify(displayData, null, 2) - const escapedJson = this.escapeHtml(jsonStr) - - ;(this.container as any).innerHTML = `
${escapedJson}
` - } - - /** - * Filter model data to show only the selected element and related data - */ - private filterDataBySelection(modelData: any, selectedId: string): any { - if (!modelData.nodes) return modelData - - // Find the selected node - const selectedNode = modelData.nodes.find((node: any) => node['unique-id'] === selectedId) - if (!selectedNode) return modelData - - // Return focused view with selected node and related connections - const relatedInterfaces = modelData.interfaces?.filter((iface: any) => - iface.from === selectedId || iface.to === selectedId - ) || [] - - const relatedNodeIds = new Set() - relatedInterfaces.forEach((iface: any) => { - relatedNodeIds.add(iface.from) - relatedNodeIds.add(iface.to) - }) - - const relatedNodes = modelData.nodes.filter((node: any) => - relatedNodeIds.has(node['unique-id']) - ) - - return { - ...modelData, - nodes: [selectedNode, ...relatedNodes.filter((n: any) => n['unique-id'] !== selectedId)], - interfaces: relatedInterfaces, - _focusedOn: selectedId, - _totalNodes: modelData.nodes.length, - _totalInterfaces: modelData.interfaces?.length || 0 - } - } - - /** - * Highlight the selected element in the display - */ - private highlightSelection(selectedId?: string): void { - // Remove existing highlights - const existing = (this.container as any).querySelectorAll('.highlighted') - existing.forEach((el: any) => el.classList.remove('highlighted')) - - if (!selectedId || selectedId === 'none') return - - // Add highlight to selected element (this would need more sophisticated JSON highlighting) - const pre = (this.container as any).querySelector('.model-json') - if (pre && pre.textContent?.includes(`"unique-id": "${selectedId}"`)) { - // Simple highlighting - in a real implementation you'd parse and highlight JSON properly - pre.innerHTML = pre.innerHTML.replace( - new RegExp(`("unique-id":\\s*"${selectedId}")`, 'g'), - '$1' - ) - } - } - - - - /** - * Escape HTML to prevent XSS - */ - private escapeHtml(str: string): string { - return str - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') - } - - /** - * Update the view when external selection changes - */ - public updateSelection(selectedId?: string): void { - this.viewModel.setSelectedId(selectedId) - } - - /** - * Cleanup event listeners - */ - public dispose(): void { - // ViewModels handle their own disposal - ; (this.container as any).innerHTML = '' - } -} \ No newline at end of file diff --git a/calm-plugins/vscode/src/features/preview/preview-panel-factory.ts b/calm-plugins/vscode/src/features/preview/preview-panel-factory.ts deleted file mode 100644 index f17498d9e..000000000 --- a/calm-plugins/vscode/src/features/preview/preview-panel-factory.ts +++ /dev/null @@ -1,70 +0,0 @@ -import * as vscode from 'vscode' -import { CalmPreviewPanel } from './preview-panel' -import { PreviewViewModel, PreviewViewModelInterface } from './preview.view-model' -import { Logger } from '../../core/ports/logger' - -// Legacy interface for compatibility - should be replaced with PreviewViewModelInterface -export interface PreviewLike { - setData(data: any): void - postSelect(id: string): void - getCurrentUri(): vscode.Uri | undefined - reveal(uri: vscode.Uri): void - onDidDispose(handler: () => void): void - onRevealInEditor(handler: (id: string) => void): void - onDidSelect(handler: (id: string) => void): void - setGetCurrentTreeSelection(fn: () => string | undefined): void -} - -/** - * PreviewPanelFactory - Factory for preview panel components - * Creates ViewModel and Panel, following the same pattern as TreeViewFactory - * Works with CalmPreviewPanel singleton but manages ViewModel lifecycle - */ -export class PreviewPanelFactory implements vscode.Disposable { - private disposables: vscode.Disposable[] = [] - - // MVVM Components - private readonly vm: PreviewViewModel - - constructor() { - // Create ViewModel (framework-agnostic) - this.vm = new PreviewViewModel() - this.disposables.push(this.vm) - } - - /** - * Get the PreviewViewModel directly (preferred for new code) - */ - getViewModel(): PreviewViewModelInterface { - return this.vm - } - - /** - * Get the panel (legacy compatibility) - */ - get(): PreviewLike | undefined { - return CalmPreviewPanel.currentPanel - } - - /** - * Create or show the preview panel - * Uses our managed ViewModel to ensure proper selection synchronization - */ - createOrShow(ctx: vscode.ExtensionContext, uri: vscode.Uri, configService: any, log: Logger): CalmPreviewPanel { - // Use the new method that accepts our external ViewModel - const panel = CalmPreviewPanel.createOrShowWithViewModel(ctx, uri, configService, log, this.vm) - return panel - } - - /** - * Clear on dispose (legacy compatibility) - */ - clearOnDispose() { - // Panel cleanup is handled by CalmPreviewPanel singleton - } - - dispose() { - this.vm.dispose() - this.disposables.forEach(d => d.dispose()) - } -} \ No newline at end of file diff --git a/calm-plugins/vscode/src/features/preview/preview-panel.spec.ts b/calm-plugins/vscode/src/features/preview/preview-panel.spec.ts deleted file mode 100644 index 2dc1e25a8..000000000 --- a/calm-plugins/vscode/src/features/preview/preview-panel.spec.ts +++ /dev/null @@ -1,586 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import * as vscode from 'vscode' -import { CalmPreviewPanel } from './preview-panel' - -// Mock VS Code API -vi.mock('vscode', () => ({ - ViewColumn: { - Beside: 2, - }, - Uri: { - file: vi.fn(function (path: string) { return { - fsPath: path, - toString: () => `file://${path}`, - }; }), - joinPath: vi.fn(function (base, ...paths) { return { - fsPath: `${base.fsPath}/${paths.join('/')}`, - toString: () => `file://${base.fsPath}/${paths.join('/')}`, - }; }), - }, - window: { - createWebviewPanel: vi.fn(function () { return { - webview: { - html: '', - postMessage: vi.fn(), - onDidReceiveMessage: vi.fn(), - asWebviewUri: vi.fn(function (uri) { return { - toString: () => `vscode-webview://${uri.fsPath}`, - }; }), - }, - onDidDispose: vi.fn(), - reveal: vi.fn(), - }; }), - showSaveDialog: vi.fn(), - showInformationMessage: vi.fn(), - showErrorMessage: vi.fn(), - }, - workspace: { - workspaceFolders: [ - { - uri: { - fsPath: '/test/workspace', - toString: () => 'file:///test/workspace', - }, - }, - ], - fs: { - writeFile: vi.fn(), - }, - }, - Disposable: { - from: vi.fn(function () { return { dispose: vi.fn() }; }), - }, -})) - -// Mock other dependencies -vi.mock('../../models/file-types', () => ({ - detectFileType: vi.fn(function () { return { - type: 'architecture', - isValid: true, - architecturePath: undefined, - }; }), - FileType: { - TemplateFile: 'template', - ArchitectureFile: 'architecture', - }, -})) - -vi.mock('@finos/calm-shared', () => ({ - parseFrontMatter: vi.fn(function () { return null; }), - parseFrontMatterFromContent: vi.fn(function (content) { return { frontMatter: {}, content }; }), -})) - -vi.mock('../../core/services/model-service', () => ({ - ModelService: vi.fn().mockImplementation(function () { return { - readModel: vi.fn(function () { return {}; }), - filterBySelection: vi.fn(function () { return {}; }), - }; }), -})) - -vi.mock('../../cli/template-service', () => ({ - TemplateService: vi.fn().mockImplementation(function () { return { - processTemplateForLabels: vi.fn(function (content) { return content; }), - generateTemplateContent: vi.fn(function () { return Promise.resolve('generated content'); }), - getTemplateNameForSelection: vi.fn(function () { return 'test-template'; }), - }; }), -})) - -vi.mock('../../cli/html-builder', () => ({ - HtmlBuilder: vi.fn().mockImplementation(function () { return { - getHtml: vi.fn(function () { return ''; }), - }; }), -})) - -vi.mock('../../cli/docify-service', () => ({ - DocifyService: vi.fn().mockImplementation(function () { return { - run: vi.fn(function () { return Promise.resolve({ - content: '# Test Content\n![Test Image](./test.png)', - format: 'markdown', - sourceFile: '/test/source/file.md', - }); }), - }; }), -})) - -describe('CalmPreviewPanel', () => { - let mockContext: any - let mockConfig: any - let mockLogger: any - let mockPanel: any - - beforeEach(() => { - // Reset all mocks - vi.clearAllMocks() - - mockContext = { - extensionUri: { - fsPath: '/test/extension', - toString: () => 'file:///test/extension', - }, - } - - mockConfig = {} - - mockLogger = { - info: vi.fn(), - error: vi.fn(), - } - - mockPanel = { - webview: { - html: '', - postMessage: vi.fn(), - onDidReceiveMessage: vi.fn(), - asWebviewUri: vi.fn(function (uri) { return { - toString: () => `vscode-webview://${uri.fsPath}`, - }; }), - }, - onDidDispose: vi.fn(), - reveal: vi.fn(), - } - - // Mock the static createWebviewPanel call - vi.mocked(vscode.window.createWebviewPanel).mockReturnValue(mockPanel) - }) - - afterEach(() => { - // Clean up any existing panels - if (CalmPreviewPanel.currentPanel) { - CalmPreviewPanel.currentPanel.dispose() - } - }) - - describe('createOrShow panel lifecycle', () => { - // Regression guard for issue #2361: on VSCode 1.116+, calling panel.reveal() - // on a panel that createWebviewPanel just created causes a blank first paint. - // createWebviewPanel already shows the panel, so reveal() must only fire on reuse. - - const fakeUri = { fsPath: '/test/arch.json' } as any - - it('does NOT call panel.reveal() when creating a brand-new panel', () => { - expect(CalmPreviewPanel.currentPanel).toBeUndefined() - - CalmPreviewPanel.createOrShow(mockContext, fakeUri, mockConfig, mockLogger) - - expect(vscode.window.createWebviewPanel).toHaveBeenCalledTimes(1) - expect(mockPanel.reveal).not.toHaveBeenCalled() - }) - - it('DOES call panel.reveal() when reusing an existing panel', () => { - CalmPreviewPanel.createOrShow(mockContext, fakeUri, mockConfig, mockLogger) - expect(mockPanel.reveal).not.toHaveBeenCalled() // sanity: brand-new path did not reveal - - CalmPreviewPanel.createOrShow(mockContext, fakeUri, mockConfig, mockLogger) - - expect(vscode.window.createWebviewPanel).toHaveBeenCalledTimes(1) // not recreated - expect(mockPanel.reveal).toHaveBeenCalled() - }) - }) - - describe('isRelativePath method', () => { - let panel: CalmPreviewPanel - - beforeEach(() => { - panel = new (CalmPreviewPanel as any)(mockPanel, mockContext, mockConfig, mockLogger) - }) - - afterEach(() => { - panel.dispose() - }) - - it('should return true for relative paths starting with ./', () => { - const result = (panel as any).isRelativePath('./image.png') - expect(result).toBe(true) - }) - - it('should return true for relative paths starting with ../', () => { - const result = (panel as any).isRelativePath('../image.png') - expect(result).toBe(true) - }) - - it('should return true for relative paths without prefix', () => { - const result = (panel as any).isRelativePath('image.png') - expect(result).toBe(true) - }) - - it('should return true for relative paths with subdirectories', () => { - const result = (panel as any).isRelativePath('assets/images/logo.png') - expect(result).toBe(true) - }) - - it('should return false for absolute URLs with http://', () => { - const result = (panel as any).isRelativePath('http://example.com/image.png') - expect(result).toBe(false) - }) - - it('should return false for absolute URLs with https://', () => { - const result = (panel as any).isRelativePath('https://example.com/image.png') - expect(result).toBe(false) - }) - - it('should return false for data URLs', () => { - const result = (panel as any).isRelativePath('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==') - expect(result).toBe(false) - }) - - it('should return false for blob URLs', () => { - const result = (panel as any).isRelativePath('blob:https://example.com/550e8400-e29b-41d4-a716-446655440000') - expect(result).toBe(false) - }) - - it('should return false for absolute file paths starting with /', () => { - const result = (panel as any).isRelativePath('/absolute/path/to/image.png') - expect(result).toBe(false) - }) - - it('should return false for Windows absolute paths', () => { - const result = (panel as any).isRelativePath('C:\\path\\to\\image.png') - expect(result).toBe(false) - }) - - it('should return false for VS Code resource URIs', () => { - const result = (panel as any).isRelativePath('vscode-resource://file///path/to/image.png') - expect(result).toBe(false) - }) - - it('should return false for VS Code webview URIs', () => { - const result = (panel as any).isRelativePath('vscode-webview://file///path/to/image.png') - expect(result).toBe(false) - }) - - it('should handle empty strings', () => { - const result = (panel as any).isRelativePath('') - expect(result).toBe(true) - }) - - it('should handle special characters in relative paths', () => { - const result = (panel as any).isRelativePath('./images/logo-with-special chars & symbols.png') - expect(result).toBe(true) - }) - }) - - describe('preprocessMarkdownImages method', () => { - let panel: CalmPreviewPanel - - beforeEach(() => { - panel = new (CalmPreviewPanel as any)(mockPanel, mockContext, mockConfig, mockLogger) - }) - - afterEach(() => { - panel.dispose() - }) - - it('should convert relative image paths to webview URIs', () => { - const markdown = '# Test\n![Logo](./logo.png)\nSome text' - const sourceFile = '/test/source/demo.md' - - const result = (panel as any).preprocessMarkdownImages(markdown, sourceFile) - - expect(result).toContain('![Logo](vscode-webview:///test/source/logo.png)') - expect(mockPanel.webview.asWebviewUri).toHaveBeenCalled() - }) - - it('should convert multiple image references', () => { - const markdown = '![First](./first.png)\n![Second](./images/second.jpg)' - const sourceFile = '/test/source/demo.md' - - const result = (panel as any).preprocessMarkdownImages(markdown, sourceFile) - - expect(result).toContain('![First](vscode-webview:///test/source/first.png)') - expect(result).toContain('![Second](vscode-webview:///test/source/images/second.jpg)') - expect(mockPanel.webview.asWebviewUri).toHaveBeenCalledTimes(2) - }) - - it('should handle ../ relative paths', () => { - const markdown = '![Parent](../parent.png)' - const sourceFile = '/test/source/subfolder/demo.md' - - const result = (panel as any).preprocessMarkdownImages(markdown, sourceFile) - - expect(result).toContain('![Parent](vscode-webview:///test/source/parent.png)') - }) - - it('should handle relative paths without ./ prefix', () => { - const markdown = '![Simple](image.png)' - const sourceFile = '/test/source/demo.md' - - const result = (panel as any).preprocessMarkdownImages(markdown, sourceFile) - - expect(result).toContain('![Simple](vscode-webview:///test/source/image.png)') - }) - - it('should leave absolute URLs unchanged', () => { - const markdown = '![Remote](https://example.com/image.png)' - const sourceFile = '/test/source/demo.md' - - const result = (panel as any).preprocessMarkdownImages(markdown, sourceFile) - - expect(result).toBe(markdown) // Should be unchanged - expect(mockPanel.webview.asWebviewUri).not.toHaveBeenCalled() - }) - - it('should leave data URLs unchanged', () => { - const markdown = '![Data](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==)' - const sourceFile = '/test/source/demo.md' - - const result = (panel as any).preprocessMarkdownImages(markdown, sourceFile) - - expect(result).toBe(markdown) // Should be unchanged - expect(mockPanel.webview.asWebviewUri).not.toHaveBeenCalled() - }) - - it('should leave absolute file paths unchanged', () => { - const markdown = '![Absolute](/absolute/path/image.png)' - const sourceFile = '/test/source/demo.md' - - const result = (panel as any).preprocessMarkdownImages(markdown, sourceFile) - - expect(result).toBe(markdown) // Should be unchanged - expect(mockPanel.webview.asWebviewUri).not.toHaveBeenCalled() - }) - - it('should handle images with complex alt text', () => { - const markdown = '![Complex alt text with "quotes" and symbols](./image.png)' - const sourceFile = '/test/source/demo.md' - - const result = (panel as any).preprocessMarkdownImages(markdown, sourceFile) - - expect(result).toContain('![Complex alt text with "quotes" and symbols](vscode-webview:///test/source/image.png)') - }) - - it('should handle images with titles using double quotes', () => { - const markdown = '![Logo](./logo.png "This is the logo")' - const sourceFile = '/test/source/demo.md' - - const result = (panel as any).preprocessMarkdownImages(markdown, sourceFile) - - expect(result).toContain('![Logo](vscode-webview:///test/source/logo.png "This is the logo")') - expect(mockPanel.webview.asWebviewUri).toHaveBeenCalled() - }) - - it('should handle images with titles using single quotes', () => { - const markdown = "![Logo](./logo.png 'This is the logo')" - const sourceFile = '/test/source/demo.md' - - const result = (panel as any).preprocessMarkdownImages(markdown, sourceFile) - - expect(result).toContain("![Logo](vscode-webview:///test/source/logo.png 'This is the logo')") - expect(mockPanel.webview.asWebviewUri).toHaveBeenCalled() - }) - - it('should handle reference-style images by leaving them unchanged', () => { - const markdown = '![Logo][logo-ref]' - const sourceFile = '/test/source/demo.md' - - const result = (panel as any).preprocessMarkdownImages(markdown, sourceFile) - - expect(result).toBe(markdown) // Should be unchanged - expect(mockPanel.webview.asWebviewUri).not.toHaveBeenCalled() - }) - - it('should handle mixed inline and reference-style images', () => { - const markdown = '![Inline](./inline.png)\n![Reference][ref]\n![Another](./another.jpg "Title")' - const sourceFile = '/test/source/demo.md' - - const result = (panel as any).preprocessMarkdownImages(markdown, sourceFile) - - expect(result).toContain('![Inline](vscode-webview:///test/source/inline.png)') - expect(result).toContain('![Reference][ref]') // Unchanged - expect(result).toContain('![Another](vscode-webview:///test/source/another.jpg "Title")') - }) - - it('should handle images with empty alt text', () => { - const markdown = '![](./image.png)' - const sourceFile = '/test/source/demo.md' - - const result = (panel as any).preprocessMarkdownImages(markdown, sourceFile) - - expect(result).toContain('![](vscode-webview:///test/source/image.png)') - }) - - it('should handle mixed content with text before and after images', () => { - const markdown = `# Title - -Some text before the image. - -![Logo](./logo.png) - -More text after the image. - -![Another](./other.jpg) - -End of document.` - const sourceFile = '/test/source/demo.md' - - const result = (panel as any).preprocessMarkdownImages(markdown, sourceFile) - - expect(result).toContain('![Logo](vscode-webview:///test/source/logo.png)') - expect(result).toContain('![Another](vscode-webview:///test/source/other.jpg)') - expect(result).toContain('# Title') - expect(result).toContain('Some text before') - expect(result).toContain('End of document.') - }) - - it('should handle edge case with no images', () => { - const markdown = '# Just text\nNo images here.' - const sourceFile = '/test/source/demo.md' - - const result = (panel as any).preprocessMarkdownImages(markdown, sourceFile) - - expect(result).toBe(markdown) // Should be unchanged - expect(mockPanel.webview.asWebviewUri).not.toHaveBeenCalled() - }) - - it('should handle malformed image syntax gracefully', () => { - const markdown = '![Incomplete markdown image' - const sourceFile = '/test/source/demo.md' - - const result = (panel as any).preprocessMarkdownImages(markdown, sourceFile) - - expect(result).toBe(markdown) // Should be unchanged - expect(mockPanel.webview.asWebviewUri).not.toHaveBeenCalled() - }) - - it('should return original content if preprocessing fails', () => { - // Mock the webview.asWebviewUri to throw an error - mockPanel.webview.asWebviewUri = vi.fn(function () { - throw new Error('Mock webview error') - }) - - const result = (panel as any).preprocessMarkdownImages('![Test](./image.png)', '/test/source/demo.md') - - expect(result).toContain('![Test](./image.png)') // Should return original on error - expect(mockLogger.error).toHaveBeenCalled() - }) - }) - - describe('handleRunDocifyImpl integration', () => { - let panel: CalmPreviewPanel - - beforeEach(() => { - panel = new (CalmPreviewPanel as any)(mockPanel, mockContext, mockConfig, mockLogger) - // Set up minimal state for docify to work - panel.viewModel.setCurrentUri('/test/demo.md') - panel.viewModel.handleReady() - }) - - afterEach(() => { - panel.dispose() - }) - - it('should preprocess markdown images when docify returns markdown format', async () => { - // Mock docify service to return markdown with images - const mockDocifyService = panel['docifyService'] - vi.mocked(mockDocifyService.run).mockResolvedValue({ - content: '# Test\n![Logo](./logo.png)', - format: 'markdown', - sourceFile: '/test/source/demo.md', - }) - - // Trigger docify - await panel['handleRunDocifyImpl']() - - // Verify that preprocessing was applied - expect(mockLogger.info).toHaveBeenCalledWith('[preview] Preprocessing markdown images from source: /test/source/demo.md') - }) - - it('should not preprocess non-markdown content', async () => { - // Mock docify service to return HTML - const mockDocifyService = panel['docifyService'] - vi.mocked(mockDocifyService.run).mockResolvedValue({ - content: '

Test

Logo', - format: 'html', - sourceFile: '/test/source/demo.md', - }) - - // Trigger docify - await panel['handleRunDocifyImpl']() - - // Verify that preprocessing was NOT applied (no markdown preprocessing logs) - const preprocessingLogs = mockLogger.info.mock.calls.filter((call: any) => - call[0] && call[0].includes('Preprocessing markdown images') - ) - expect(preprocessingLogs).toHaveLength(0) - }) - }) - - describe('handleExportDiagram', () => { - let panel: CalmPreviewPanel - - beforeEach(() => { - panel = new (CalmPreviewPanel as any)(mockPanel, mockContext, mockConfig, mockLogger) - panel.viewModel.setCurrentUri('/test/source/arch.json') - }) - - afterEach(() => { - panel.dispose() - }) - - it('computes a default save location from the current file and shows the save dialog', async () => { - vi.mocked(vscode.window.showSaveDialog).mockResolvedValue(undefined) - - await panel.handleExportDiagram('svg', '', 1) - - const callArgs = vi.mocked(vscode.window.showSaveDialog).mock.calls[0][0] as any - expect(callArgs.defaultUri.fsPath).toBe('/test/source/arch-diagram-1.svg') - expect(callArgs.filters).toEqual({ 'SVG Image': ['svg'] }) - }) - - it('uses a PNG filter and the diagram index in the default filename for PNG exports', async () => { - vi.mocked(vscode.window.showSaveDialog).mockResolvedValue(undefined) - - await panel.handleExportDiagram('png', 'QkJC', 2) - - const callArgs = vi.mocked(vscode.window.showSaveDialog).mock.calls[0][0] as any - expect(callArgs.defaultUri.fsPath).toBe('/test/source/arch-diagram-2.png') - expect(callArgs.filters).toEqual({ 'PNG Image': ['png'] }) - }) - - it('does not write a file when the user cancels the save dialog', async () => { - vi.mocked(vscode.window.showSaveDialog).mockResolvedValue(undefined) - - await panel.handleExportDiagram('svg', '', 1) - - expect(vscode.workspace.fs.writeFile).not.toHaveBeenCalled() - }) - - it('writes SVG data as a utf8 buffer and shows a confirmation message', async () => { - const saveUri = { fsPath: '/test/source/arch-diagram-1.svg' } as any - vi.mocked(vscode.window.showSaveDialog).mockResolvedValue(saveUri) - - await panel.handleExportDiagram('svg', 'diagram', 1) - - expect(vscode.workspace.fs.writeFile).toHaveBeenCalledWith(saveUri, Buffer.from('diagram', 'utf8')) - expect(vscode.window.showInformationMessage).toHaveBeenCalledWith('Diagram exported to /test/source/arch-diagram-1.svg') - }) - - it('writes PNG data as a base64-decoded buffer', async () => { - const saveUri = { fsPath: '/test/source/arch-diagram-1.png' } as any - vi.mocked(vscode.window.showSaveDialog).mockResolvedValue(saveUri) - - await panel.handleExportDiagram('png', 'QkJC', 1) - - expect(vscode.workspace.fs.writeFile).toHaveBeenCalledWith(saveUri, Buffer.from('QkJC', 'base64')) - }) - - it('shows an error message and logs when writing the file fails', async () => { - const saveUri = { fsPath: '/test/source/arch-diagram-1.svg' } as any - vi.mocked(vscode.window.showSaveDialog).mockResolvedValue(saveUri) - vi.mocked(vscode.workspace.fs.writeFile).mockRejectedValue(new Error('disk full')) - - await panel.handleExportDiagram('svg', '', 1) - - expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(expect.stringContaining('disk full')) - expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining('disk full')) - }) - - it('falls back to the workspace root and a generic name when no file is open', async () => { - panel.viewModel.clearCurrentUri() - vi.mocked(vscode.window.showSaveDialog).mockResolvedValue(undefined) - - await panel.handleExportDiagram('png', 'QkJC', 3) - - const callArgs = vi.mocked(vscode.window.showSaveDialog).mock.calls[0][0] as any - expect(callArgs.defaultUri.fsPath).toBe('/test/workspace/diagram-diagram-3.png') - }) - }) -}) \ No newline at end of file diff --git a/calm-plugins/vscode/src/features/preview/preview-panel.ts b/calm-plugins/vscode/src/features/preview/preview-panel.ts deleted file mode 100644 index 9b3c681f4..000000000 --- a/calm-plugins/vscode/src/features/preview/preview-panel.ts +++ /dev/null @@ -1,580 +0,0 @@ -import * as vscode from 'vscode' -import * as fs from 'fs' -import * as path from 'path' -import { detectFileType, FileType } from '../../models/file-types' -import { parseFrontMatter, parseFrontMatterFromContent } from '@finos/calm-shared' -import { ModelService } from '../../core/services/model-service' -import { DiagramExportService } from '../../core/services/diagram-export-service' -import { TemplateService } from '../../cli/template-service' -import { HtmlBuilder } from '../../cli/html-builder' -import { DocifyService } from '../../cli/docify-service' -import { AsyncGuard } from '../../core/async-guard' -import { - isInMsg, - InMsg, - CommandRegistry, - RevealInEditorCmd, - SelectedCmd, - ReadyCmd, - RenderedCmd, - RunDocifyCmd, - RequestModelDataCmd, - RequestTemplateDataCmd, - RefreshAllCmd, - ToggleLabelsCmd, - ExportDiagramCmd, - LogCmd, - ErrorCmd, -} from './commands' -import { PreviewViewModel } from './preview.view-model' -import { Logger } from '../../core/ports/logger' -import { GraphData } from "../../models/model"; - -/** ---------- main panel ---------- */ -export class CalmPreviewPanel { - public static currentPanel: CalmPreviewPanel | undefined - private readonly panel: vscode.WebviewPanel - private disposables: vscode.Disposable[] = [] - private revealInEditorHandlers: Array<(id: string) => void> = [] - private selectHandlers: Array<(id: string) => void> = [] - - private getCurrentTreeSelection: (() => string | undefined) | undefined = undefined - - private modelService: ModelService - private templateService: TemplateService - private htmlBuilder: HtmlBuilder - private docifyService: DocifyService - private diagramExportService: DiagramExportService - public readonly viewModel: PreviewViewModel // Made public for external access - - private runDocifyGuard = new AsyncGuard() - private commands = new CommandRegistry() - - static createOrShow( - context: vscode.ExtensionContext, - uri: vscode.Uri, - config: vscode.WorkspaceConfiguration, - log: Logger - ) { - const column = vscode.ViewColumn.Beside - if (CalmPreviewPanel.currentPanel) { - // Ensure the existing panel is still valid before reusing - try { - CalmPreviewPanel.currentPanel.reveal(uri) - return CalmPreviewPanel.currentPanel - } catch { - // Panel may have been disposed externally, clean up the reference - log.info('[preview] Existing panel was invalid, creating new one') - CalmPreviewPanel.currentPanel = undefined - } - } - const panel = vscode.window.createWebviewPanel('calmPreview', 'CALM Preview', column, { - enableScripts: true, - retainContextWhenHidden: true, - localResourceRoots: [ - vscode.Uri.joinPath(context.extensionUri, 'dist'), - vscode.Uri.joinPath(context.extensionUri, 'media'), - vscode.Uri.joinPath(context.extensionUri, 'templates'), - // Add workspace folders to allow access to local images - ...(vscode.workspace.workspaceFolders || []).map(folder => folder.uri), - ], - }) - CalmPreviewPanel.currentPanel = new CalmPreviewPanel(panel, context, config, log) - // createWebviewPanel already shows the panel; calling reveal() here causes a - // blank first paint on VSCode 1.116+. Initialize state without re-revealing. - CalmPreviewPanel.currentPanel.reveal(uri, { revealPanel: false }) - return CalmPreviewPanel.currentPanel - } - - static createOrShowWithViewModel( - context: vscode.ExtensionContext, - uri: vscode.Uri, - config: vscode.WorkspaceConfiguration, - log: Logger, - viewModel: PreviewViewModel - ) { - const column = vscode.ViewColumn.Beside - if (CalmPreviewPanel.currentPanel) { - // Ensure the existing panel is still valid before reusing - try { - CalmPreviewPanel.currentPanel.reveal(uri) - return CalmPreviewPanel.currentPanel - } catch { - // Panel may have been disposed externally, clean up the reference - log.info('[preview] Existing panel was invalid, creating new one') - CalmPreviewPanel.currentPanel = undefined - } - } - const panel = vscode.window.createWebviewPanel('calmPreview', 'CALM Preview', column, { - enableScripts: true, - retainContextWhenHidden: true, - localResourceRoots: [ - vscode.Uri.joinPath(context.extensionUri, 'dist'), - vscode.Uri.joinPath(context.extensionUri, 'media'), - vscode.Uri.joinPath(context.extensionUri, 'templates'), - // Add workspace folders to allow access to local images - ...(vscode.workspace.workspaceFolders || []).map(folder => folder.uri), - ], - }) - CalmPreviewPanel.currentPanel = new CalmPreviewPanel(panel, context, config, log, viewModel) - // See note in createOrShow: skip panel.reveal() on brand-new panels. - CalmPreviewPanel.currentPanel.reveal(uri, { revealPanel: false }) - return CalmPreviewPanel.currentPanel - } - - constructor( - panel: vscode.WebviewPanel, - private context: vscode.ExtensionContext, - private cfg: vscode.WorkspaceConfiguration, - private log: Logger, - externalViewModel?: PreviewViewModel - ) { - this.panel = panel - this.modelService = new ModelService() - this.templateService = new TemplateService(context, log) - this.htmlBuilder = new HtmlBuilder(context) - this.docifyService = new DocifyService(log, this.templateService) - this.diagramExportService = new DiagramExportService() - - // Use external ViewModel if provided, otherwise create new one - this.viewModel = externalViewModel || new PreviewViewModel() - - // Bind ViewModel events to service operations - this.bindViewModelEvents() - - // register commands - this.commands.register(new RevealInEditorCmd(this)) - this.commands.register(new SelectedCmd(this)) - this.commands.register(new ReadyCmd(this)) - this.commands.register(new RenderedCmd(this)) - this.commands.register(new RunDocifyCmd(this)) - this.commands.register(new RequestModelDataCmd(this)) - this.commands.register(new RequestTemplateDataCmd(this)) - this.commands.register(new RefreshAllCmd(this)) - this.commands.register(new ToggleLabelsCmd(this)) - this.commands.register(new ExportDiagramCmd(this)) - this.commands.register(new LogCmd(this)) - this.commands.register(new ErrorCmd(this)) - - // route messages to commands - this.panel.webview.onDidReceiveMessage( - (raw: unknown) => { - if (!isInMsg(raw)) return - const msg: InMsg = raw - try { this.log.info('[preview][rawMsg] ' + JSON.stringify(msg)) } catch { } - this.commands.dispatch(msg) - }, - undefined, - this.disposables - ) - - this.panel.webview.html = this.htmlBuilder.getHtml(this.panel) - this.panel.onDidDispose(() => this.dispose(), null, this.disposables) - } - - private bindViewModelEvents(): void { - // Bind ViewModel events to actual service operations - this.viewModel.onVisibilityChanged((visible) => { - if (visible) { - this.panel.reveal(vscode.ViewColumn.Beside) - } - }) - - this.viewModel.onStateChanged(() => { - const state = this.viewModel.getPreviewState() - this.log.info(`[preview] onStateChanged - ready: ${state.ready}, hasData: ${state.hasData}, selectedId: ${state.selectedId}`) - if (state.ready && state.hasData) { - this.post({ type: 'setData', ...this.viewModel.getData() }) - if (state.selectedId) { - this.log.info(`[preview] Posting select message for: ${state.selectedId}`) - this.post({ type: 'select', id: state.selectedId }) - } - } - }) - - this.viewModel.onModelDataRequest(() => { - this.handleRequestModelDataImpl() - }) - - this.viewModel.onTemplateDataRequest(() => { - this.handleRequestTemplateDataImpl() - }) - - this.viewModel.onDocifyRequest(() => { - this.handleRunDocifyImpl() - }) - - // Listen for docify results and post them to webview - this.viewModel.docify.onDocifyResult((result) => { - // Strip front-matter from the content before displaying in preview - const parsed = parseFrontMatterFromContent(result.content) - this.post({ type: 'docifyResult', content: parsed.content, format: result.format, sourceFile: result.sourceFile }) - }) - - // Listen for docify errors and post them to webview - this.viewModel.docify.onDocifyError((error) => { - this.post({ type: 'docifyError', message: error }) - }) - } - - private post(msg: unknown) { - try { this.panel.webview.postMessage(msg) } catch { } - } - - /** --------- public API - now delegates to ViewModel --------- */ - reveal(uri: vscode.Uri, options: { revealPanel?: boolean } = {}) { - const revealPanel = options.revealPanel !== false - this.viewModel.setCurrentUri(uri.fsPath) - const fileInfo = detectFileType(uri.fsPath) - const isTemplateMode = fileInfo.type === FileType.TemplateFile && fileInfo.isValid - - this.log.info(`[preview] reveal() - File: ${uri.fsPath}`) - this.log.info(`[preview] reveal() - fileInfo: type=${fileInfo.type}, isValid=${fileInfo.isValid}, architecturePath=${fileInfo.architecturePath}`) - this.log.info(`[preview] reveal() - isTemplateMode set to: ${isTemplateMode}`) - - if (isTemplateMode) { - this.viewModel.setTemplateMode(true, uri.fsPath, fileInfo.architecturePath) - this.log.info(`[preview] Template mode activated: ${uri.fsPath} -> ${fileInfo.architecturePath}`) - - // Send template mode to webview - this.post({ - type: 'templateMode', - isTemplateMode: true, - templatePath: uri.fsPath, - architecturePath: fileInfo.architecturePath - }) - } else { - this.viewModel.setTemplateMode(false, undefined, uri.fsPath) - this.log.info(`[preview] Architecture mode: ${uri.fsPath}`) - - // Send template mode to webview - this.post({ - type: 'templateMode', - isTemplateMode: false - }) - } - - // Don't trigger docify immediately here - let refreshForDocument handle it after selection is determined - - if (revealPanel) { - this.panel.reveal(vscode.ViewColumn.Beside) - } - } - - getCurrentUri(): vscode.Uri | undefined { - const uriString = this.viewModel.getCurrentUriString() - return uriString ? vscode.Uri.file(uriString) : undefined - } - - onDidDispose(handler: () => void) { this.panel.onDidDispose(handler) } - onRevealInEditor(handler: (id: string) => void) { this.revealInEditorHandlers.push(handler) } - onDidSelect(handler: (id: string) => void) { this.selectHandlers.push(handler) } - - setData(payload: { graph: GraphData; selectedId?: string }) { - this.viewModel.setData(payload) - } - - postSelect(id: string) { - this.viewModel.setSelectedId(id) - this.log.info(`[preview] TreeView selection changed to: ${id || 'none'}`) - } - - setGetCurrentTreeSelection(fn: () => string | undefined) { - this.getCurrentTreeSelection = fn - } - - dispose() { - this.viewModel.setVisible(false) - this.viewModel.setReady(false) // Reset ready state so new panel can trigger state changes - this.viewModel.setRendered(false) // Reset rendered probe so next panel gets a fresh paint check - this.viewModel.clearCurrentUri() // Clear URI so reopening will trigger proper data loading - CalmPreviewPanel.currentPanel = undefined - while (this.disposables.length) { - const d = this.disposables.pop() - try { d?.dispose() } catch { } - } - } - - /** --------- Command handlers using ViewModel --------- */ - public handleRevealInEditor(id: string) { - this.revealInEditorHandlers.forEach(h => h(id)) - this.viewModel.handleRevealInEditor(id) - } - - public handleSelected(id: string) { - this.selectHandlers.forEach(h => h(id)) - this.viewModel.handleSelected(id) - } - - public handleReady() { - this.log.info('[preview] handleReady() called - webview is ready') - this.viewModel.handleReady() - } - - public handleRendered() { - this.log.info('[preview] handleRendered() called - webview compositor produced a frame') - this.viewModel.handleRendered() - } - - public handleRunDocify() { - this.viewModel.handleRunDocify() - } - - public handleRequestModelData() { - this.viewModel.handleRequestModelData() - } - - public handleRequestTemplateData() { - this.viewModel.handleRequestTemplateData() - } - - public handleRefreshAll() { - this.log.info('[preview] handleRefreshAll() called - refreshing all tabs') - this.handleRequestModelData() - this.handleRequestTemplateData() - this.handleRunDocify() - } - - public async handleToggleLabels(showLabels: boolean) { - this.viewModel.handleToggleLabels(showLabels) - } - - public async handleExportDiagram(format: 'svg' | 'png', data: string, diagramIndex: number): Promise { - const currentUri = this.getCurrentUri() - const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath - const defaultPath = this.diagramExportService.computeDefaultPath(currentUri?.fsPath, workspaceRoot, diagramIndex, format) - const defaultUri = vscode.Uri.file(defaultPath) - - const filters = format === 'svg' ? { 'SVG Image': ['svg'] } : { 'PNG Image': ['png'] } - const saveUri = await vscode.window.showSaveDialog({ defaultUri, filters }) - if (!saveUri) return - - try { - const buffer = this.diagramExportService.decodeExportData(format, data) - await vscode.workspace.fs.writeFile(saveUri, buffer) - vscode.window.showInformationMessage(`Diagram exported to ${saveUri.fsPath}`) - } catch (error) { - this.log.error?.(`[preview] Failed to export diagram: ${String(error)}`) - vscode.window.showErrorMessage(`Failed to export diagram: ${String(error)}`) - } - } - - public handleLog(message: string) { - this.log.info(`[webview] ${message}`) - } - - public handleError(message: string, stack?: string) { - this.log.error?.(`[webview][error] ${message}`) - if (stack) this.log.error?.(String(stack)) - } - - // Implementation methods triggered by ViewModel - private async handleRequestModelDataImpl() { - const uri = this.getCurrentUri() - if (!uri) { this.post({ type: 'modelData', data: null }); return } - - try { - const state = this.viewModel.getPreviewState() - this.log.info(`[preview] handleRequestModelData - isTemplateMode: ${state.isTemplateMode}`) - - const fileInfo = detectFileType(uri.fsPath) - const isTemplate = fileInfo.type === FileType.TemplateFile && fileInfo.isValid - const fileToRead = isTemplate && fileInfo.architecturePath ? fileInfo.architecturePath : uri.fsPath - - this.log.info(`[preview] Reading ${isTemplate ? 'architecture file for template mode' : 'current file'}: ${fileToRead}`) - - // Use async file reading for better performance with large files - const fullModelData = await this.modelService.readModelAsync(fileToRead) - const filteredData = this.modelService.filterBySelection(fullModelData, state.selectedId) - this.post({ type: 'modelData', data: filteredData }) - this.log.info(`[preview] Sent filtered model data for selection: ${state.selectedId || 'none'}`) - } catch (error) { - this.log.error?.('[preview] Error reading model data: ' + String(error)) - this.post({ type: 'modelData', data: null }) - } - } - - private async handleRequestTemplateDataImpl() { - try { - const state = this.viewModel.getPreviewState() - let templateContent: string - let templateName: string - - if (state.isTemplateMode && state.templateFilePath) { - const parsed = parseFrontMatter(state.templateFilePath) - if (parsed) { - templateContent = this.templateService.processTemplateForLabels(parsed.content, state.showLabels) - templateName = path.basename(state.templateFilePath) - } else { - templateContent = fs.readFileSync(state.templateFilePath, 'utf8') - templateContent = this.templateService.processTemplateForLabels(templateContent, state.showLabels) - templateName = path.basename(state.templateFilePath) - } - } else { - templateContent = await this.templateService.generateTemplateContent( - state.selectedId, - this.viewModel.getData()?.graph, - state.currentUri, - state.showLabels, - state.isTemplateMode, - state.architectureFilePath - ) - templateName = this.templateService.getTemplateNameForSelection(state.selectedId, this.viewModel.getData()?.graph) - } - - this.post({ - type: 'templateData', - data: { content: templateContent, name: templateName, selectedId: state.selectedId || 'none', isTemplateMode: state.isTemplateMode } - }) - } catch (error) { - this.log.error?.('[preview] Error reading template data: ' + String(error)) - this.post({ type: 'templateData', data: null }) - } - } - - private handleRunDocifyImpl() { - const state = this.viewModel.getPreviewState() - const treeSelection = this.getCurrentTreeSelection ? this.getCurrentTreeSelection() : undefined - this.log.info('[preview] runDocify requested') - this.log.info(`[preview] runDocify - state.selectedId: ${state.selectedId}`) - this.log.info(`[preview] runDocify - tree selection: ${treeSelection}`) - this.log.info(`[preview] runDocify - isTemplateMode: ${state.isTemplateMode}`) - this.log.info(`[preview] runDocify - currentUri: ${state.currentUri}`) - this.log.info(`[preview] runDocify - architectureFilePath: ${state.architectureFilePath}`) - this.runDocifyGuard - .run(async () => { - const res = await this.docifyService.run({ - currentFilePath: state.currentUri, - isTemplateMode: state.isTemplateMode, - templateFilePath: state.templateFilePath, - architectureFilePath: state.architectureFilePath, - selectedId: state.selectedId, - getCurrentTreeSelection: this.getCurrentTreeSelection, - lastData: this.viewModel.getData(), - showLabels: state.showLabels, - }) - // Preprocess the content to convert image paths before sending to webview - const processedContent = res.format === 'markdown' ? this.preprocessMarkdownImages(res.content, res.sourceFile) : res.content - - // Use MVVM pattern - set result in DocifyViewModel instead of posting directly - this.viewModel.docify.setDocifyResult(processedContent, res.format, res.sourceFile) - this.log.info('[preview] Docify finished') - this.log.info(`[preview] Docify result format: ${res.format}, source: ${res.sourceFile}`) - }) - .catch(e => { - // Use MVVM pattern - set error in DocifyViewModel instead of posting directly - this.viewModel.docify.setDocifyError(String(e?.message || e)) - }) - } - - /** - * Preprocess markdown content to convert relative image paths to webview URIs - */ - private preprocessMarkdownImages(markdownContent: string, sourceFile: string): string { - this.log.info(`[preview] Preprocessing markdown images from source: ${sourceFile}`) - - try { - const sourceDir = path.dirname(sourceFile) - - // Regex to match markdown image syntax: - // - Inline images: ![alt](path), ![alt](path "title"), ![alt](path 'title') - // - Reference-style images: ![alt][ref] - const imageRegex = /!\[([^\]]*)\]\(([^)\s]+)(?:\s+(['"])(.*?)\3)?\)|!\[([^\]]*)\]\[([^\]]+)\]/g - - this.log.info(`[preview] Searching for images with regex: ${imageRegex.source}`) - - // Test if the regex finds any matches - const testMatches = markdownContent.match(imageRegex) - this.log.info(`[preview] Found ${testMatches ? testMatches.length : 0} potential image matches: ${testMatches ? JSON.stringify(testMatches) : 'none'}`) - - const processedContent = markdownContent.replace(imageRegex, (match, alt1, imagePath, quote, title, alt2, ref) => { - // Handle inline images: ![alt](path "title") or ![alt](path) - if (alt1 !== undefined) { - this.log.info(`[preview] Found image: alt="${alt1}", path="${imagePath}"${title ? `, title="${title}"` : ''}`) - - if (this.isRelativePath(imagePath)) { - try { - let absolutePath: string - - if (imagePath.startsWith('./')) { - absolutePath = path.resolve(sourceDir, imagePath.substring(2)) - } else if (imagePath.startsWith('../')) { - absolutePath = path.resolve(sourceDir, imagePath) - } else if (!imagePath.startsWith('/')) { - absolutePath = path.resolve(sourceDir, imagePath) - } else { - return match // Skip absolute paths - } - - this.log.info(`[preview] Resolved ${imagePath} to ${absolutePath}`) - - // Convert to webview URI - this.log.info(`[preview] Creating vscode.Uri.file from: ${absolutePath}`) - const fileUri = vscode.Uri.file(absolutePath) - this.log.info(`[preview] Created file URI: ${fileUri.toString()}`) - const webviewUri = this.panel.webview.asWebviewUri(fileUri) - const convertedPath = webviewUri.toString() - this.log.info(`[preview] Converted to webview URI: ${convertedPath}`) - - // Preserve title if present - if (title) { - return `![${alt1}](${convertedPath} ${quote}${title}${quote})` - } else { - return `![${alt1}](${convertedPath})` - } - } catch (error) { - this.log.error?.(`[preview] Error converting image path ${imagePath}: ${String(error)}`) - return match // Return original if conversion fails - } - } else { - this.log.info(`[preview] Skipping non-relative image path: ${imagePath}`) - return match // Return original for non-relative paths - } - } - // Handle reference-style images: ![alt][ref] - else if (alt2 !== undefined && ref !== undefined) { - this.log.info(`[preview] Found reference-style image: alt="${alt2}", ref="${ref}"`) - // For reference-style images, we would need to parse the reference definitions - // elsewhere in the document. For now, we'll leave them unchanged. - this.log.info(`[preview] Reference-style images not yet supported, leaving unchanged`) - return match - } - - return match - }) - - this.log.info(`[preview] Markdown preprocessing completed`) - this.log.info(`[preview] Processed content length: ${processedContent.length}`) - this.log.info(`[preview] Processed content preview: ${processedContent.substring(0, 500)}...`) - return processedContent - } catch (error) { - this.log.error?.(`[preview] Error preprocessing markdown images: ${String(error)}`) - this.log.error?.(`[preview] Stack trace: ${error instanceof Error ? error.stack : 'No stack trace'}`) - return markdownContent // Return original content if preprocessing fails - } - } - - private isRelativePath(imagePath: string): boolean { - // Check for absolute URLs - if (imagePath.startsWith('http://') || imagePath.startsWith('https://') || imagePath.startsWith('data:') || imagePath.startsWith('blob:')) { - return false - } - - // Check for absolute file paths - if (imagePath.startsWith('/')) { - return false // Unix/macOS absolute path - } - - // Check for Windows absolute paths (C:\, D:\, etc.) - if (imagePath.match(/^[a-zA-Z]:\\/)) { - return false // Windows absolute path - } - - // Check for special VS Code URIs - if (imagePath.startsWith('vscode-')) { - return false - } - - return true - } -} diff --git a/calm-plugins/vscode/src/features/preview/preview.view-model.spec.ts b/calm-plugins/vscode/src/features/preview/preview.view-model.spec.ts deleted file mode 100644 index 2c778da78..000000000 --- a/calm-plugins/vscode/src/features/preview/preview.view-model.spec.ts +++ /dev/null @@ -1,255 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { PreviewViewModel } from './preview.view-model' - -// Mock vscode API (required by preview.view-model.ts for legacy compatibility methods) -vi.mock('vscode', () => ({ - Uri: { - file: vi.fn(function (path: string) { return { - fsPath: path, - toString: () => `file://${path}`, - }; }), - }, -})) - -// Mock timers for testing debounced operations -vi.useFakeTimers() - -describe('PreviewViewModel', () => { - let previewViewModel: PreviewViewModel - - beforeEach(() => { - previewViewModel = new PreviewViewModel() - }) - - afterEach(() => { - previewViewModel.dispose() - vi.clearAllTimers() - }) - - describe('initialization', () => { - it('should create preview view model with default state', () => { - expect(previewViewModel).toBeDefined() - expect(previewViewModel instanceof PreviewViewModel).toBe(true) - - const state = previewViewModel.getPreviewState() - expect(state.isVisible).toBe(false) - expect(state.isReady).toBe(false) - expect(state.activeTab).toBe('model') - }) - - it('should initialize child view models', () => { - expect(previewViewModel.calmModel).toBeDefined() - expect(previewViewModel.template).toBeDefined() - expect(previewViewModel.docify).toBeDefined() - }) - }) - - describe('configurationChanged', () => { - it('should not trigger docify refresh when model tab is active', async () => { - const docifyRequestSpy = vi.fn() - previewViewModel.docify.onDocifyRequest(docifyRequestSpy) - - // Ensure we're on model tab - previewViewModel.setActiveTab('model') - - // Call configuration changed - previewViewModel.configurationChanged() - - // Wait for any debounced operations - await vi.runAllTimersAsync() - - // Should not trigger docify - expect(docifyRequestSpy).not.toHaveBeenCalled() - }) - - it('should not trigger docify refresh when template tab is active', async () => { - const docifyRequestSpy = vi.fn() - previewViewModel.docify.onDocifyRequest(docifyRequestSpy) - - // Switch to template tab - previewViewModel.setActiveTab('template') - - // Call configuration changed - previewViewModel.configurationChanged() - - // Wait for any debounced operations - await vi.runAllTimersAsync() - - // Should not trigger docify - expect(docifyRequestSpy).not.toHaveBeenCalled() - }) - - it('should trigger docify refresh when docify tab is active', async () => { - const docifyRequestSpy = vi.fn() - previewViewModel.docify.onDocifyRequest(docifyRequestSpy) - - // Switch to docify tab - previewViewModel.setActiveTab('docify') - - // Call configuration changed - previewViewModel.configurationChanged() - - // Wait for debounced docify request - await vi.runAllTimersAsync() - - // Should trigger docify refresh - expect(docifyRequestSpy).toHaveBeenCalledTimes(1) - }) - - it('should only refresh docify once per call after debounce', async () => { - const docifyRequestSpy = vi.fn() - previewViewModel.docify.onDocifyRequest(docifyRequestSpy) - - // Switch to docify tab - previewViewModel.setActiveTab('docify') - - // Call configuration changed multiple times - previewViewModel.configurationChanged() - previewViewModel.configurationChanged() - - // Wait for debounced operations - await vi.runAllTimersAsync() - - // Should trigger docify refresh only once due to debouncing - expect(docifyRequestSpy).toHaveBeenCalledTimes(1) - }) - }) - - describe('active tab management', () => { - it('should start with model tab active', () => { - const state = previewViewModel.getPreviewState() - expect(state.activeTab).toBe('model') - }) - - it('should switch to template tab', () => { - previewViewModel.setActiveTab('template') - const state = previewViewModel.getPreviewState() - expect(state.activeTab).toBe('template') - }) - - it('should switch to docify tab', () => { - previewViewModel.setActiveTab('docify') - const state = previewViewModel.getPreviewState() - expect(state.activeTab).toBe('docify') - }) - - it('should emit active tab changed event', () => { - const tabChangedSpy = vi.fn() - previewViewModel.onActiveTabChanged(tabChangedSpy) - - previewViewModel.setActiveTab('docify') - - expect(tabChangedSpy).toHaveBeenCalledWith('docify') - }) - }) - - describe('visibility management', () => { - it('should start as not visible', () => { - const state = previewViewModel.getPreviewState() - expect(state.isVisible).toBe(false) - }) - - it('should set visibility state', () => { - previewViewModel.setVisible(true) - let state = previewViewModel.getPreviewState() - expect(state.isVisible).toBe(true) - - previewViewModel.setVisible(false) - state = previewViewModel.getPreviewState() - expect(state.isVisible).toBe(false) - }) - - it('should emit visibility changed event', () => { - const visibilitySpy = vi.fn() - previewViewModel.onVisibilityChanged(visibilitySpy) - - previewViewModel.setVisible(true) - expect(visibilitySpy).toHaveBeenCalledWith(true) - - previewViewModel.setVisible(false) - expect(visibilitySpy).toHaveBeenCalledWith(false) - }) - }) - - describe('ready state management', () => { - it('should start as not ready', () => { - const state = previewViewModel.getPreviewState() - expect(state.isReady).toBe(false) - }) - - it('should set ready state', () => { - previewViewModel.setReady(true) - let state = previewViewModel.getPreviewState() - expect(state.isReady).toBe(true) - - previewViewModel.setReady(false) - state = previewViewModel.getPreviewState() - expect(state.isReady).toBe(false) - }) - - it('should emit ready state changed event', () => { - const readySpy = vi.fn() - previewViewModel.onReadyStateChanged(readySpy) - - previewViewModel.setReady(true) - expect(readySpy).toHaveBeenCalledWith(true) - }) - }) - - describe('PreviewViewModelInterface implementation', () => { - it('should implement setData method', () => { - const mockData = { - graph: { nodes: [], edges: [] }, - selectedId: 'test-id' - } - - previewViewModel.setData(mockData) - - const state = previewViewModel.getPreviewState() - expect(state.selectedId).toBe('test-id') - }) - - it('should implement postSelect method', () => { - const selectSpy = vi.fn() - previewViewModel.onDidSelect(selectSpy) - - previewViewModel.postSelect('test-id') - - expect(selectSpy).toHaveBeenCalledWith('test-id') - }) - - it('should implement getCurrentUriPath method', () => { - const testPath = '/test/path/file.calm' - previewViewModel.setCurrentUri(testPath) - - const uri = previewViewModel.getCurrentUriPath() - expect(uri).toBe(testPath) - }) - - it('should implement revealFile method', () => { - const testPath = '/test/path/file.calm' - previewViewModel.revealFile(testPath) - - const uri = previewViewModel.getCurrentUriPath() - expect(uri).toBe(testPath) - }) - - it('should register reveal in editor handler', () => { - const revealSpy = vi.fn() - previewViewModel.onRevealInEditor(revealSpy) - - // Verify handler was registered - expect(revealSpy).not.toHaveBeenCalled() - }) - - it('should implement setGetCurrentTreeSelection method', () => { - const mockFn = vi.fn(function () { return 'tree-selection-id'; }) - previewViewModel.setGetCurrentTreeSelection(mockFn) - - const result = previewViewModel.getCurrentTreeSelection() - - expect(result).toBe('tree-selection-id') - expect(mockFn).toHaveBeenCalled() - }) - }) -}) diff --git a/calm-plugins/vscode/src/features/preview/preview.view-model.ts b/calm-plugins/vscode/src/features/preview/preview.view-model.ts deleted file mode 100644 index d644e4824..000000000 --- a/calm-plugins/vscode/src/features/preview/preview.view-model.ts +++ /dev/null @@ -1,502 +0,0 @@ -import { Emitter } from '../../core/emitter' -import { CalmModelViewModel } from './model-tab/view-model/calm-model.view-model' -import { TemplateViewModel } from './template-tab/view-model/template.view-model' -import { DocifyViewModel } from './docify-tab/view-model/docify.view-model' - -// Legacy compatibility import - only used for backward compatibility methods -// TODO: Remove when all consumers use framework-agnostic interface -import * as vscode from 'vscode' -import {GraphData, LastData} from "../../models/model"; - -/** - * Interface that external services should use to interact with preview - * This replaces the PreviewLike interface that was in PreviewPanelFactory - * Framework-agnostic - uses strings instead of VS Code types - */ -export interface PreviewViewModelInterface { - setData(data: { graph: GraphData; selectedId?: string }): void - postSelect(id: string): void - getCurrentUriPath(): string | undefined - revealFile(filePath: string): void - onRevealInEditor(handler: (id: string) => void): void - onDidSelect(handler: (id: string) => void): void - setGetCurrentTreeSelection(fn: () => string | undefined): void - configurationChanged(): void -} - -/** - * PreviewViewModel - Main orchestrator for preview panel MVVM - * Manages tab selection, version announcements, and coordinates child ViewModels - * Now implements PreviewViewModelInterface for external service interactions - */ -export class PreviewViewModel implements PreviewViewModelInterface { - // Child ViewModels - public readonly calmModel = new CalmModelViewModel() - public readonly template = new TemplateViewModel() - public readonly docify = new DocifyViewModel() - - // Main orchestration emitters - private activeTabChangedEmitter = new Emitter<'model' | 'template' | 'docify'>() - private readyStateChangedEmitter = new Emitter() - private renderedStateChangedEmitter = new Emitter() - private visibilityChangedEmitter = new Emitter() - private versionAnnouncementEmitter = new Emitter<{ version: string; message: string }>() - private stateChangedEmitter = new Emitter() - private modelDataRequestEmitter = new Emitter() - private templateDataRequestEmitter = new Emitter() - private docifyRequestEmitter = new Emitter() - - // Event handlers for external services - private revealInEditorHandlers: Array<(id: string) => void> = [] - private selectHandlers: Array<(id: string) => void> = [] - private getCurrentTreeSelectionFn: (() => string | undefined) | undefined - - // Core state - private activeTab: 'model' | 'template' | 'docify' = 'model' - private isVisible = false - private isReady = false - private isRendered = false - private currentUri: string | undefined - private extensionVersion: string = '' - - // Events - onActiveTabChanged = this.activeTabChangedEmitter.event - onReadyStateChanged = this.readyStateChangedEmitter.event - onRenderedStateChanged = this.renderedStateChangedEmitter.event - onVisibilityChanged = this.visibilityChangedEmitter.event - onVersionAnnouncement = this.versionAnnouncementEmitter.event - onStateChanged = this.stateChangedEmitter.event - onModelDataRequest = this.modelDataRequestEmitter.event - onTemplateDataRequest = this.templateDataRequestEmitter.event - onDocifyRequest = this.docifyRequestEmitter.event - - constructor() { - this.bindChildViewModels() - } - - /** - * Notify that preview state has changed - */ - private notifyStateChanged(): void { - this.stateChangedEmitter.fire() - } - - /** - * Bind child ViewModel events to coordinate between tabs - */ - private bindChildViewModels(): void { - // When model selection changes, update template and potentially trigger docify - this.calmModel.onSelectionChanged((selectedId) => { - this.template.setSelectedId(selectedId || 'none') - this.notifyStateChanged() - - // Auto-trigger docify in live mode - if (this.docify.getIsLiveMode()) { - this.docify.requestDocify() - } - }) - - // When template mode changes, coordinate with child ViewModels - this.template.onTemplateModeChanged((modeData) => { - this.notifyStateChanged() - // Template mode affects model data loading - if (modeData.isTemplateMode && this.calmModel.hasData()) { - // Might need to reload model data for template architecture file - } - }) - - // When labels toggle, may affect docify output - this.template.onShowLabelsChanged((_showLabels) => { - this.notifyStateChanged() - if (this.docify.getIsLiveMode()) { - this.docify.requestDocify() - } - }) - - // Auto-switch to docify tab when docify completes - this.docify.onDocifyResult(() => { - if (this.activeTab !== 'docify') { - this.setActiveTab('docify') - } - }) - - // Show error and switch to docify tab on error - this.docify.onDocifyError(() => { - if (this.activeTab !== 'docify') { - this.setActiveTab('docify') - } - }) - - // Forward child ViewModel requests to main emitters - this.template.onTemplateDataRequest(() => { - this.templateDataRequestEmitter.fire() - }) - - this.docify.onDocifyRequest(() => { - this.docifyRequestEmitter.fire() - }) - - // Listen for data changes to trigger state notifications - this.calmModel.onDataChanged(() => { - this.notifyStateChanged() - }) - } - - /** - * Set the active tab - */ - setActiveTab(tab: 'model' | 'template' | 'docify'): void { - if (this.activeTab !== tab) { - this.activeTab = tab - this.activeTabChangedEmitter.fire(tab) - } - } - - /** - * Get the active tab - */ - getActiveTab(): 'model' | 'template' | 'docify' { - return this.activeTab - } - - /** - * Set panel visibility - */ - setVisible(visible: boolean): void { - if (this.isVisible !== visible) { - this.isVisible = visible - this.visibilityChangedEmitter.fire(visible) - } - } - - /** - * Get panel visibility - */ - getVisible(): boolean { - return this.isVisible - } - - /** - * Set ready state - */ - setReady(ready: boolean): void { - if (this.isReady !== ready) { - this.isReady = ready - this.readyStateChangedEmitter.fire(ready) - this.notifyStateChanged() // Notify that state changed when ready state changes - } - } - - /** - * Get ready state - */ - getIsReady(): boolean { - return this.isReady - } - - /** - * Set current URI - */ - setCurrentUri(uri: string): void { - const previousUri = this.currentUri - this.currentUri = uri - - if (previousUri && previousUri !== uri) { - // Reset all child ViewModels when file changes - this.calmModel.reset() - this.template.reset() - this.docify.reset() - } - } - - /** - * Clear current URI (called when panel is disposed) - * This ensures that reopening the preview will trigger proper data loading - */ - clearCurrentUri(): void { - this.currentUri = undefined - } - - /** - * Set extension version for announcements - */ - setExtensionVersion(version: string): void { - this.extensionVersion = version - } - - /** - * Show version announcement - */ - announceVersion(message: string): void { - this.versionAnnouncementEmitter.fire({ - version: this.extensionVersion, - message - }) - } - - /** - * Set template mode across relevant ViewModels - */ - setTemplateMode(isTemplateMode: boolean, templatePath?: string, architecturePath?: string): void { - this.template.setTemplateMode(isTemplateMode, templatePath, architecturePath) - - // Auto-switch to template tab when entering template mode - if (isTemplateMode && this.activeTab === 'model') { - this.setActiveTab('template') - } - } - - /** - * Set data across relevant ViewModels - */ - setData(data: { graph: GraphData; selectedId?: string }): void { - // Set model data - this.calmModel.setModelData(data.graph) - - if (data.selectedId) { - this.calmModel.setSelectedId(data.selectedId) - } - - // Clear docify content when switching documents - this.docify.clear() - - // If we're currently on the docify tab, automatically trigger a fresh docify run - // This ensures the content updates when switching documents while viewing docify - if (this.activeTab === 'docify') { - this.docify.requestDocify() - } - } - - /** - * Get combined data from child ViewModels - */ - getData(): LastData | undefined { - if (!this.calmModel.hasData()) { - return undefined - } - - return { - graph: this.calmModel.getModelData(), - selectedId: this.calmModel.getSelectedId() - } - } - - /** - * Get complete preview state for debugging - */ - getPreviewState() { - return { - isVisible: this.isVisible, - isReady: this.isReady, - activeTab: this.activeTab, - currentUri: this.currentUri, - extensionVersion: this.extensionVersion, - - // Legacy properties expected by preview panel - ready: this.isReady, - hasData: this.calmModel.hasData(), - selectedId: this.calmModel.getSelectedId(), - - // Template-related legacy properties - isTemplateMode: this.template.getIsTemplateMode(), - templateFilePath: this.template.getTemplateFilePath(), - architectureFilePath: this.template.getArchitectureFilePath(), - showLabels: this.template.getShowLabels(), - - // Child ViewModel states - model: this.calmModel.getState(), - template: this.template.getState(), - docify: this.docify.getState() - } - } - - /** - * Legacy compatibility methods for existing preview panel - */ - - // Legacy template mode methods - getTemplateMode(): boolean { - return this.template.getIsTemplateMode() - } - - getTemplateFilePath(): string | undefined { - return this.template.getTemplateFilePath() - } - - getArchitectureFilePath(): string | undefined { - return this.template.getArchitectureFilePath() - } - - getShowLabels(): boolean { - return this.template.getShowLabels() - } - - getSelectedId(): string | undefined { - return this.calmModel.getSelectedId() - } - - // Legacy setter method for selected ID - setSelectedId(id: string | undefined): void { - this.calmModel.setSelectedId(id) - } - - // Legacy event handlers - handleRevealInEditor(id: string): void { - this.calmModel.revealInEditor(id) - } - - handleSelected(id: string): void { - this.calmModel.setSelectedId(id) - } - - handleReady(): void { - this.setReady(true) - } - - /** - * Handle webview 'rendered' message — posted after 2 rAF ticks, proving - * the compositor is producing frames. Used as a paint-level probe for - * regressions like issue #2361 where the paint pipeline stalls. - */ - handleRendered(): void { - this.setRendered(true) - } - - /** - * Set rendered state. Must be reset to false when the webview is disposed - * so the next panel instance gets a fresh probe — otherwise a stale - * rendered=true from a previous panel would mask a new blank-paint bug. - */ - setRendered(rendered: boolean): void { - if (this.isRendered !== rendered) { - this.isRendered = rendered - this.renderedStateChangedEmitter.fire(rendered) - } - } - - getIsRendered(): boolean { - return this.isRendered - } - - handleToggleLabels(showLabels: boolean): void { - this.template.setShowLabels(showLabels) - } - - handleRunDocify(): void { - this.docify.requestDocify() - } - - handleRequestModelData(): void { - this.modelDataRequestEmitter.fire() - } - - handleRequestTemplateData(): void { - this.template.requestTemplateData() - } - - /** - * Handle configuration changes (e.g., theme changes) - * Refreshes the docify view if the docify tab is currently active - */ - configurationChanged(): void { - if (this.activeTab === 'docify') { - this.docify.requestDocify() - } - } - - /** - * Dispose all ViewModels and emitters - */ - dispose(): void { - this.calmModel.dispose() - this.template.dispose() - this.docify.dispose() - this.activeTabChangedEmitter.dispose() - this.readyStateChangedEmitter.dispose() - this.renderedStateChangedEmitter.dispose() - this.visibilityChangedEmitter.dispose() - this.versionAnnouncementEmitter.dispose() - this.stateChangedEmitter.dispose() - this.modelDataRequestEmitter.dispose() - this.templateDataRequestEmitter.dispose() - this.docifyRequestEmitter.dispose() - } - - // ======== PreviewViewModelInterface Implementation (Framework-Agnostic) ======== - - /** - * Handle selection from external services (tree, etc.) - */ - postSelect(id: string): void { - this.setSelectedId(id) - this.selectHandlers.forEach(h => h(id)) - } - - /** - * Reveal a file in the preview (framework-agnostic) - */ - revealFile(filePath: string): void { - this.setCurrentUri(filePath) - // Notify state changed to trigger appropriate data loading - this.notifyStateChanged() - } - - /** - * Get current URI path as string (framework-agnostic) - */ - getCurrentUriPath(): string | undefined { - return this.currentUri - } - - /** - * Get current URI as string (for internal use - alias for getCurrentUriPath) - */ - getCurrentUriString(): string | undefined { - return this.currentUri - } - - /** - * Register handler for reveal in editor events - */ - onRevealInEditor(handler: (id: string) => void): void { - this.revealInEditorHandlers.push(handler) - } - - /** - * Register handler for selection events - */ - onDidSelect(handler: (id: string) => void): void { - this.selectHandlers.push(handler) - } - - /** - * Set tree selection getter function - */ - setGetCurrentTreeSelection(fn: () => string | undefined): void { - this.getCurrentTreeSelectionFn = fn - } - - /** - * Get current tree selection (for docify service) - */ - getCurrentTreeSelection(): string | undefined { - return this.getCurrentTreeSelectionFn?.() - } - - // ======== Legacy Compatibility Methods (VS Code Specific) ======== - // TODO: Remove these when all consumers migrate to framework-agnostic interface - - /** - * Get current URI as vscode.Uri (for legacy CalmPreviewPanel compatibility) - */ - getCurrentUri(): vscode.Uri | undefined { - return this.currentUri ? vscode.Uri.file(this.currentUri) : undefined - } - - /** - * Reveal a file using vscode.Uri (legacy compatibility for CalmPreviewPanel) - */ - reveal(uri: vscode.Uri): void { - this.revealFile(uri.fsPath) - } -} diff --git a/calm-plugins/vscode/src/features/preview/template-tab/view-model/template.view-model.spec.ts b/calm-plugins/vscode/src/features/preview/template-tab/view-model/template.view-model.spec.ts deleted file mode 100644 index c3b39562f..000000000 --- a/calm-plugins/vscode/src/features/preview/template-tab/view-model/template.view-model.spec.ts +++ /dev/null @@ -1,405 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { TemplateViewModel } from './template.view-model' - -describe('TemplateViewModel', () => { - let templateViewModel: TemplateViewModel - - beforeEach(() => { - templateViewModel = new TemplateViewModel() - }) - - afterEach(() => { - templateViewModel.dispose() - }) - - describe('initialization', () => { - it('should create template view model with default state', () => { - expect(templateViewModel).toBeDefined() - expect(templateViewModel instanceof TemplateViewModel).toBe(true) - expect(templateViewModel.getTemplateContent()).toBe('') - expect(templateViewModel.getTemplateName()).toBe('') - expect(templateViewModel.getIsTemplateMode()).toBe(false) - expect(templateViewModel.getTemplateFilePath()).toBeUndefined() - expect(templateViewModel.getArchitectureFilePath()).toBeUndefined() - expect(templateViewModel.getShowLabels()).toBe(true) - expect(templateViewModel.getSelectedId()).toBe('none') - expect(templateViewModel.hasContent()).toBe(false) - }) - - it('should have proper initial state', () => { - const state = templateViewModel.getState() - - expect(state).toEqual({ - hasContent: false, - templateName: '', - isTemplateMode: false, - selectedId: 'none', - showLabels: true, - hasTemplatePath: false, - hasArchitecturePath: false - }) - }) - }) - - describe('template content management', () => { - it('should set and get template content with metadata', () => { - const mockContent = '' - const mockName = 'service-template' - const mockSelectedId = 'element-123' - const mockIsTemplateMode = true - - templateViewModel.setTemplateContent(mockContent, mockName, mockSelectedId, mockIsTemplateMode) - - expect(templateViewModel.getTemplateContent()).toBe(mockContent) - expect(templateViewModel.getTemplateName()).toBe(mockName) - expect(templateViewModel.getSelectedId()).toBe(mockSelectedId) - expect(templateViewModel.getIsTemplateMode()).toBe(mockIsTemplateMode) - expect(templateViewModel.hasContent()).toBe(true) - }) - - it('should emit template content changed event', () => { - const contentChangedSpy = vi.fn() - templateViewModel.onTemplateContentChanged(contentChangedSpy) - - const mockContent = '' - const mockName = 'test-template' - const mockSelectedId = 'element-123' - const mockIsTemplateMode = true - - templateViewModel.setTemplateContent(mockContent, mockName, mockSelectedId, mockIsTemplateMode) - - expect(contentChangedSpy).toHaveBeenCalledWith({ - content: mockContent, - name: mockName, - selectedId: mockSelectedId, - isTemplateMode: mockIsTemplateMode - }) - }) - - it('should handle empty template content', () => { - templateViewModel.setTemplateContent('', '', '', false) - - expect(templateViewModel.getTemplateContent()).toBe('') - expect(templateViewModel.getTemplateName()).toBe('') - expect(templateViewModel.hasContent()).toBe(false) - }) - }) - - describe('template mode management', () => { - it('should set and get template mode with paths', () => { - const mockTemplatePath = '/path/to/template.hbs' - const mockArchitecturePath = '/path/to/arch.calm' - - templateViewModel.setTemplateMode(true, mockTemplatePath, mockArchitecturePath) - - expect(templateViewModel.getIsTemplateMode()).toBe(true) - expect(templateViewModel.getTemplateFilePath()).toBe(mockTemplatePath) - expect(templateViewModel.getArchitectureFilePath()).toBe(mockArchitecturePath) - }) - - it('should emit template mode changed event', () => { - const modeChangedSpy = vi.fn() - templateViewModel.onTemplateModeChanged(modeChangedSpy) - - const mockTemplatePath = '/path/to/template.hbs' - const mockArchitecturePath = '/path/to/arch.calm' - - templateViewModel.setTemplateMode(true, mockTemplatePath, mockArchitecturePath) - - expect(modeChangedSpy).toHaveBeenCalledWith({ - isTemplateMode: true, - templatePath: mockTemplatePath, - architecturePath: mockArchitecturePath - }) - }) - - it('should auto-request template data when entering template mode', () => { - const dataRequestSpy = vi.fn() - templateViewModel.onTemplateDataRequest(dataRequestSpy) - - templateViewModel.setTemplateMode(true, '/template.hbs', '/arch.calm') - - expect(dataRequestSpy).toHaveBeenCalledOnce() - }) - - it('should not auto-request template data when exiting template mode', () => { - // Enter template mode first - templateViewModel.setTemplateMode(true, '/template.hbs', '/arch.calm') - - const dataRequestSpy = vi.fn() - templateViewModel.onTemplateDataRequest(dataRequestSpy) - - // Exit template mode - templateViewModel.setTemplateMode(false) - - expect(dataRequestSpy).not.toHaveBeenCalled() - }) - - it('should handle template mode without paths', () => { - templateViewModel.setTemplateMode(true) - - expect(templateViewModel.getIsTemplateMode()).toBe(true) - expect(templateViewModel.getTemplateFilePath()).toBeUndefined() - expect(templateViewModel.getArchitectureFilePath()).toBeUndefined() - }) - }) - - describe('show labels preference', () => { - it('should set and get show labels preference', () => { - expect(templateViewModel.getShowLabels()).toBe(true) - - templateViewModel.setShowLabels(false) - expect(templateViewModel.getShowLabels()).toBe(false) - - templateViewModel.setShowLabels(true) - expect(templateViewModel.getShowLabels()).toBe(true) - }) - - it('should emit show labels changed event', () => { - const showLabelsChangedSpy = vi.fn() - templateViewModel.onShowLabelsChanged(showLabelsChangedSpy) - - templateViewModel.setShowLabels(false) - - expect(showLabelsChangedSpy).toHaveBeenCalledWith(false) - }) - - it('should auto-request template data when labels toggle', () => { - const dataRequestSpy = vi.fn() - templateViewModel.onTemplateDataRequest(dataRequestSpy) - - templateViewModel.setShowLabels(false) - - expect(dataRequestSpy).toHaveBeenCalledOnce() - }) - }) - - describe('selected element management', () => { - it('should set and get selected element ID', () => { - templateViewModel.setSelectedId('element-123') - expect(templateViewModel.getSelectedId()).toBe('element-123') - - templateViewModel.setSelectedId('element-456') - expect(templateViewModel.getSelectedId()).toBe('element-456') - }) - - it('should default to "none" when setting null or undefined', () => { - templateViewModel.setSelectedId('') - expect(templateViewModel.getSelectedId()).toBe('none') - - templateViewModel.setSelectedId('element-123') - expect(templateViewModel.getSelectedId()).toBe('element-123') - - // @ts-ignore - testing runtime behavior - templateViewModel.setSelectedId(null) - expect(templateViewModel.getSelectedId()).toBe('none') - - // @ts-ignore - testing runtime behavior - templateViewModel.setSelectedId(undefined) - expect(templateViewModel.getSelectedId()).toBe('none') - }) - - it('should auto-request template data when selection changes', () => { - const dataRequestSpy = vi.fn() - templateViewModel.onTemplateDataRequest(dataRequestSpy) - - templateViewModel.setSelectedId('element-123') - - expect(dataRequestSpy).toHaveBeenCalledOnce() - }) - }) - - describe('template data requests', () => { - it('should manually request template data', () => { - const dataRequestSpy = vi.fn() - templateViewModel.onTemplateDataRequest(dataRequestSpy) - - templateViewModel.requestTemplateData() - - expect(dataRequestSpy).toHaveBeenCalledOnce() - }) - - it('should handle multiple template data requests', () => { - const dataRequestSpy = vi.fn() - templateViewModel.onTemplateDataRequest(dataRequestSpy) - - templateViewModel.requestTemplateData() - templateViewModel.requestTemplateData() - templateViewModel.requestTemplateData() - - expect(dataRequestSpy).toHaveBeenCalledTimes(3) - }) - }) - - describe('auto-refresh triggers', () => { - it('should auto-refresh on template mode entry', () => { - const dataRequestSpy = vi.fn() - templateViewModel.onTemplateDataRequest(dataRequestSpy) - - templateViewModel.setTemplateMode(true, '/template.hbs') - - expect(dataRequestSpy).toHaveBeenCalledOnce() - }) - - it('should auto-refresh on show labels change', () => { - const dataRequestSpy = vi.fn() - templateViewModel.onTemplateDataRequest(dataRequestSpy) - - templateViewModel.setShowLabels(false) - - expect(dataRequestSpy).toHaveBeenCalledOnce() - }) - - it('should auto-refresh on selected ID change', () => { - const dataRequestSpy = vi.fn() - templateViewModel.onTemplateDataRequest(dataRequestSpy) - - templateViewModel.setSelectedId('element-123') - - expect(dataRequestSpy).toHaveBeenCalledOnce() - }) - - it('should handle multiple auto-refresh triggers', () => { - const dataRequestSpy = vi.fn() - templateViewModel.onTemplateDataRequest(dataRequestSpy) - - // Multiple triggers should each fire a request - templateViewModel.setTemplateMode(true) - templateViewModel.setShowLabels(false) - templateViewModel.setSelectedId('element-123') - - expect(dataRequestSpy).toHaveBeenCalledTimes(3) - }) - }) - - describe('content presence checks', () => { - it('should return false for hasContent with empty string', () => { - templateViewModel.setTemplateContent('', 'test', 'none', false) - expect(templateViewModel.hasContent()).toBe(false) - }) - - it('should return true for hasContent with non-empty string', () => { - templateViewModel.setTemplateContent('', 'test', 'none', false) - expect(templateViewModel.hasContent()).toBe(true) - }) - - it('should return true for hasContent with whitespace', () => { - templateViewModel.setTemplateContent(' ', 'test', 'none', false) - expect(templateViewModel.hasContent()).toBe(true) - }) - }) - - describe('state management', () => { - it('should get complete state for debugging', () => { - templateViewModel.setTemplateContent('', 'my-template', 'element-123', true) - templateViewModel.setTemplateMode(true, '/template.hbs', '/arch.calm') - templateViewModel.setShowLabels(false) - - const state = templateViewModel.getState() - - expect(state).toEqual({ - hasContent: true, - templateName: 'my-template', - isTemplateMode: true, - selectedId: 'element-123', - showLabels: false, - hasTemplatePath: true, - hasArchitecturePath: true - }) - }) - - it('should reset all template state', () => { - // Set some state first - templateViewModel.setTemplateContent('', 'my-template', 'element-123', true) - templateViewModel.setTemplateMode(true, '/template.hbs', '/arch.calm') - templateViewModel.setShowLabels(false) - - const contentChangedSpy = vi.fn() - const modeChangedSpy = vi.fn() - templateViewModel.onTemplateContentChanged(contentChangedSpy) - templateViewModel.onTemplateModeChanged(modeChangedSpy) - - templateViewModel.reset() - - expect(templateViewModel.getTemplateContent()).toBe('') - expect(templateViewModel.getTemplateName()).toBe('') - expect(templateViewModel.getIsTemplateMode()).toBe(false) - expect(templateViewModel.getTemplateFilePath()).toBeUndefined() - expect(templateViewModel.getArchitectureFilePath()).toBeUndefined() - expect(templateViewModel.getSelectedId()).toBe('none') - expect(templateViewModel.hasContent()).toBe(false) - - expect(contentChangedSpy).toHaveBeenCalledWith({ - content: '', - name: '', - selectedId: 'none', - isTemplateMode: false - }) - - expect(modeChangedSpy).toHaveBeenCalledWith({ - isTemplateMode: false, - templatePath: undefined, - architecturePath: undefined - }) - }) - - it('should have proper state after reset', () => { - // Set complex state first - templateViewModel.setTemplateContent('', 'my-template', 'element-123', true) - templateViewModel.setTemplateMode(true, '/template.hbs', '/arch.calm') - templateViewModel.setShowLabels(false) - - templateViewModel.reset() - - const state = templateViewModel.getState() - expect(state).toEqual({ - hasContent: false, - templateName: '', - isTemplateMode: false, - selectedId: 'none', - showLabels: false, // Note: reset doesn't affect showLabels, so it stays false - hasTemplatePath: false, - hasArchitecturePath: false - }) - }) - }) - - describe('disposal', () => { - it('should dispose without errors', () => { - expect(() => templateViewModel.dispose()).not.toThrow() - }) - - it('should dispose all emitters', () => { - // Set up event listeners - const contentChangedSpy = vi.fn() - const modeChangedSpy = vi.fn() - const dataRequestSpy = vi.fn() - const showLabelsChangedSpy = vi.fn() - - templateViewModel.onTemplateContentChanged(contentChangedSpy) - templateViewModel.onTemplateModeChanged(modeChangedSpy) - templateViewModel.onTemplateDataRequest(dataRequestSpy) - templateViewModel.onShowLabelsChanged(showLabelsChangedSpy) - - // Dispose - templateViewModel.dispose() - - // Try to trigger events after disposal - they should not fire - templateViewModel.setTemplateContent('', 'test', 'none', false) - templateViewModel.setTemplateMode(true, '/template.hbs', '/arch.calm') - templateViewModel.setShowLabels(false) - templateViewModel.requestTemplateData() - - // Events should not be called after disposal - expect(contentChangedSpy).not.toHaveBeenCalled() - expect(modeChangedSpy).not.toHaveBeenCalled() - expect(dataRequestSpy).not.toHaveBeenCalled() - expect(showLabelsChangedSpy).not.toHaveBeenCalled() - }) - - it('should allow multiple dispose calls', () => { - templateViewModel.dispose() - expect(() => templateViewModel.dispose()).not.toThrow() - }) - }) -}) \ No newline at end of file diff --git a/calm-plugins/vscode/src/features/preview/template-tab/view-model/template.view-model.ts b/calm-plugins/vscode/src/features/preview/template-tab/view-model/template.view-model.ts deleted file mode 100644 index 7b55c412a..000000000 --- a/calm-plugins/vscode/src/features/preview/template-tab/view-model/template.view-model.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { Emitter } from '../../../../core/emitter' - -/** - * TemplateViewModel - Framework-free ViewModel for template tab - * Manages template content, template mode, and template-specific operations - */ -export class TemplateViewModel { - private templateContentChangedEmitter = new Emitter<{ content: string; name: string; selectedId: string; isTemplateMode: boolean }>() - private templateModeChangedEmitter = new Emitter<{ isTemplateMode: boolean; templatePath?: string; architecturePath?: string }>() - private templateDataRequestEmitter = new Emitter() - private showLabelsChangedEmitter = new Emitter() - - private templateContent: string = '' - private templateName: string = '' - private isTemplateMode: boolean = false - private templateFilePath: string | undefined - private architectureFilePath: string | undefined - private showLabels: boolean = true - private selectedId: string = 'none' - - // Events - onTemplateContentChanged = this.templateContentChangedEmitter.event - onTemplateModeChanged = this.templateModeChangedEmitter.event - onTemplateDataRequest = this.templateDataRequestEmitter.event - onShowLabelsChanged = this.showLabelsChangedEmitter.event - - /** - * Set template content and metadata - */ - setTemplateContent(content: string, name: string, selectedId: string, isTemplateMode: boolean): void { - this.templateContent = content - this.templateName = name - this.selectedId = selectedId - this.isTemplateMode = isTemplateMode - - this.templateContentChangedEmitter.fire({ - content: this.templateContent, - name: this.templateName, - selectedId: this.selectedId, - isTemplateMode: this.isTemplateMode - }) - } - - /** - * Get current template content - */ - getTemplateContent(): string { - return this.templateContent - } - - /** - * Get current template name - */ - getTemplateName(): string { - return this.templateName - } - - /** - * Set template mode state - */ - setTemplateMode(isTemplateMode: boolean, templatePath?: string, architecturePath?: string): void { - this.isTemplateMode = isTemplateMode - this.templateFilePath = templatePath - this.architectureFilePath = architecturePath - - this.templateModeChangedEmitter.fire({ - isTemplateMode: this.isTemplateMode, - templatePath: this.templateFilePath, - architecturePath: this.architectureFilePath - }) - - // Auto-request template data when mode changes - if (isTemplateMode) { - this.requestTemplateData() - } - } - - /** - * Check if currently in template mode - */ - getIsTemplateMode(): boolean { - return this.isTemplateMode - } - - /** - * Get template file path - */ - getTemplateFilePath(): string | undefined { - return this.templateFilePath - } - - /** - * Get architecture file path for template mode - */ - getArchitectureFilePath(): string | undefined { - return this.architectureFilePath - } - - /** - * Set show labels preference - */ - setShowLabels(showLabels: boolean): void { - this.showLabels = showLabels - this.showLabelsChangedEmitter.fire(showLabels) - - // Auto-refresh template when labels toggle - this.requestTemplateData() - } - - /** - * Get show labels preference - */ - getShowLabels(): boolean { - return this.showLabels - } - - /** - * Set selected element ID for template context - */ - setSelectedId(selectedId: string): void { - this.selectedId = selectedId || 'none' - // Auto-refresh template when selection changes - this.requestTemplateData() - } - - /** - * Get selected element ID - */ - getSelectedId(): string { - return this.selectedId - } - - /** - * Request template data refresh - */ - requestTemplateData(): void { - this.templateDataRequestEmitter.fire() - } - - /** - * Check if template has content - */ - hasContent(): boolean { - return !!this.templateContent - } - - /** - * Get complete state for debugging - */ - getState() { - return { - hasContent: this.hasContent(), - templateName: this.templateName, - isTemplateMode: this.isTemplateMode, - selectedId: this.selectedId, - showLabels: this.showLabels, - hasTemplatePath: !!this.templateFilePath, - hasArchitecturePath: !!this.architectureFilePath - } - } - - /** - * Reset all template state - */ - reset(): void { - this.templateContent = '' - this.templateName = '' - this.isTemplateMode = false - this.templateFilePath = undefined - this.architectureFilePath = undefined - this.selectedId = 'none' - - this.templateContentChangedEmitter.fire({ - content: '', - name: '', - selectedId: 'none', - isTemplateMode: false - }) - - this.templateModeChangedEmitter.fire({ - isTemplateMode: false, - templatePath: undefined, - architecturePath: undefined - }) - } - - /** - * Dispose all emitters - */ - dispose(): void { - this.templateContentChangedEmitter.dispose() - this.templateModeChangedEmitter.dispose() - this.templateDataRequestEmitter.dispose() - this.showLabelsChangedEmitter.dispose() - } -} \ No newline at end of file diff --git a/calm-plugins/vscode/src/features/preview/template-tab/view/template-tab.view.ts b/calm-plugins/vscode/src/features/preview/template-tab/view/template-tab.view.ts deleted file mode 100644 index a2ee88815..000000000 --- a/calm-plugins/vscode/src/features/preview/template-tab/view/template-tab.view.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { TemplateViewModel } from '../view-model/template.view-model' - -/** - * TemplateTabView - Manages the DOM for the template tab in the webview - * Keeps it simple like the original - just displays template content - */ -export class TemplateTabView { - private viewModel: TemplateViewModel - private container: HTMLElement - - constructor(viewModel: TemplateViewModel, container: HTMLElement) { - this.viewModel = viewModel - this.container = container - this.bindViewModel() - } - - private bindViewModel(): void { - // Listen for template content changes - this.viewModel.onTemplateContentChanged((data: { content: string; name: string; selectedId: string; isTemplateMode: boolean }) => { - this.render(data.content) - }) - } - - /** - * Render the template content in the tab - keep it simple - */ - private render(content: string): void { - const displayContent = content - ? `
${this.escapeHtml(content)}
` - : 'No template content available' - - ;(this.container as any).innerHTML = displayContent - } - - /** - * Update the view when external selection changes - */ - public updateSelection(selectedId?: string): void { - this.viewModel.setSelectedId(selectedId || 'none') - } - - /** - * Escape HTML to prevent XSS - */ - private escapeHtml(str: string): string { - return str - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') - } - - /** - * Cleanup event listeners - */ - public dispose(): void { - ;(this.container as any).innerHTML = '' - } -} \ No newline at end of file diff --git a/calm-plugins/vscode/src/features/preview/tsconfig.json b/calm-plugins/vscode/src/features/preview/tsconfig.json deleted file mode 100644 index 324bc88fb..000000000 --- a/calm-plugins/vscode/src/features/preview/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2020", - "module": "ESNext", - "moduleResolution": "Bundler", - "lib": ["ES2020", "DOM"], - "strict": true, - "types": [] - } -} diff --git a/calm-plugins/vscode/src/features/preview/webview/diagram-controls.spec.ts b/calm-plugins/vscode/src/features/preview/webview/diagram-controls.spec.ts deleted file mode 100644 index 06649eded..000000000 --- a/calm-plugins/vscode/src/features/preview/webview/diagram-controls.spec.ts +++ /dev/null @@ -1,88 +0,0 @@ -// @vitest-environment jsdom -import { describe, it, expect, beforeEach, vi } from 'vitest' -import { DiagramControls } from './diagram-controls' -import { PanZoomManager } from './pan-zoom-manager' - -vi.mock('svg-pan-zoom', () => ({ - default: vi.fn(function () { return { - zoom: vi.fn(), - getZoom: vi.fn(function () { return 1; }), - getPan: vi.fn(function () { return { x: 0, y: 0 }; }), - pan: vi.fn(), - resetZoom: vi.fn(), - resetPan: vi.fn(), - fit: vi.fn(), - center: vi.fn(), - resize: vi.fn(), - updateBBox: vi.fn(), - enablePan: vi.fn(), - enableZoom: vi.fn(), - disablePan: vi.fn(), - disableZoom: vi.fn(), - destroy: vi.fn(), - }; }) -})) - -function createManager(): PanZoomManager { - const manager = new PanZoomManager() - manager.initialize({} as SVGSVGElement) - return manager -} - -function getButtons(parent: HTMLElement): HTMLButtonElement[] { - return Array.from(parent.querySelectorAll('button.diagram-control-btn')) -} - -describe('DiagramControls', () => { - let parent: HTMLElement - - beforeEach(() => { - parent = document.createElement('div') - }) - - it('renders zoom in, zoom out and fit controls', () => { - const controls = new DiagramControls(createManager()) - controls.createControls(parent) - - const labels = getButtons(parent).map(btn => btn.textContent) - expect(labels).toEqual(['➕', '➖', '⊡']) - }) - - it('calls the pan/zoom manager and the matching callback when a button is clicked', () => { - const manager = createManager() - const zoomInSpy = vi.spyOn(manager, 'zoomIn') - const onZoomIn = vi.fn() - const controls = new DiagramControls(manager, { onZoomIn }) - controls.createControls(parent) - - getButtons(parent)[0].dispatchEvent(new Event('click')) - - expect(zoomInSpy).toHaveBeenCalledTimes(1) - expect(onZoomIn).toHaveBeenCalledTimes(1) - }) - - it('does not render any export control - that is DiagramExportControl\'s responsibility', () => { - const controls = new DiagramControls(createManager()) - controls.createControls(parent) - - expect(parent.querySelector('.diagram-export-control')).toBeNull() - }) - - it('returns the toolbar container so callers can compose additional controls into it', () => { - const controls = new DiagramControls(createManager()) - const toolbar = controls.createControls(parent) - - expect(toolbar.className).toBe('diagram-controls') - expect(parent.contains(toolbar)).toBe(true) - }) - - it('removes the controls from the DOM on destroy', () => { - const controls = new DiagramControls(createManager()) - controls.createControls(parent) - expect(parent.querySelector('.diagram-controls')).not.toBeNull() - - controls.destroy() - - expect(parent.querySelector('.diagram-controls')).toBeNull() - }) -}) diff --git a/calm-plugins/vscode/src/features/preview/webview/diagram-controls.ts b/calm-plugins/vscode/src/features/preview/webview/diagram-controls.ts deleted file mode 100644 index e2df9628b..000000000 --- a/calm-plugins/vscode/src/features/preview/webview/diagram-controls.ts +++ /dev/null @@ -1,109 +0,0 @@ -/** - * DiagramControls - UI controls for diagram zoom and pan - */ - -import { PanZoomManager } from './pan-zoom-manager'; - -export interface DiagramControlsOptions { - onZoomIn?: () => void; - onZoomOut?: () => void; - onReset?: () => void; - onFit?: () => void; -} - -/** - * Manages the diagram control UI and interactions - */ -export class DiagramControls { - private container: HTMLElement | null = null; - private panZoomManager: PanZoomManager; - private options: DiagramControlsOptions; - - constructor(panZoomManager: PanZoomManager, options: DiagramControlsOptions = {}) { - this.panZoomManager = panZoomManager; - this.options = options; - } - - /** - * Create and inject control UI into a container - */ - public createControls(parentElement: HTMLElement): HTMLElement { - // Create controls container - const controls = document.createElement('div'); - controls.className = 'diagram-controls'; - controls.setAttribute('role', 'toolbar'); - controls.setAttribute('aria-label', 'Diagram zoom and pan controls'); - - // Zoom in button - const zoomInBtn = this.createButton('➕', 'Zoom in', () => { - this.panZoomManager.zoomIn(); - this.options.onZoomIn?.(); - }); - controls.appendChild(zoomInBtn); - - // Zoom out button - const zoomOutBtn = this.createButton('➖', 'Zoom out', () => { - this.panZoomManager.zoomOut(); - this.options.onZoomOut?.(); - }); - controls.appendChild(zoomOutBtn); - - // Fit to view button - const fitBtn = this.createButton('⊡', 'Fit to view', () => { - this.panZoomManager.fit(); - this.options.onFit?.(); - }); - controls.appendChild(fitBtn); - - // Store reference and inject into parent - this.container = controls; - parentElement.appendChild(controls); - - return controls; - } - - /** - * Create a control button - */ - private createButton( - label: string, - title: string, - onClick: () => void - ): HTMLButtonElement { - const button = document.createElement('button'); - button.className = 'diagram-control-btn'; - button.textContent = label; - button.title = title; - button.setAttribute('aria-label', title); - button.addEventListener('click', onClick); - return button; - } - - /** - * Show controls - */ - public show(): void { - if (this.container) { - this.container.style.display = 'flex'; - } - } - - /** - * Hide controls - */ - public hide(): void { - if (this.container) { - this.container.style.display = 'none'; - } - } - - /** - * Remove controls from DOM - */ - public destroy(): void { - if (this.container && this.container.parentElement) { - this.container.parentElement.removeChild(this.container); - } - this.container = null; - } -} diff --git a/calm-plugins/vscode/src/features/preview/webview/diagram-export-control.spec.ts b/calm-plugins/vscode/src/features/preview/webview/diagram-export-control.spec.ts deleted file mode 100644 index 067268637..000000000 --- a/calm-plugins/vscode/src/features/preview/webview/diagram-export-control.spec.ts +++ /dev/null @@ -1,206 +0,0 @@ -// @vitest-environment jsdom -import { describe, it, expect, beforeEach, afterEach } from 'vitest' -import { vi } from 'vitest' -import { DiagramExportControl } from './diagram-export-control' - -describe('DiagramExportControl', () => { - let parent: HTMLElement - - beforeEach(() => { - parent = document.createElement('div') - }) - - it('returns null when no export callbacks are provided', () => { - const control = new DiagramExportControl() - expect(control.createControl()).toBeNull() - }) - - it('renders a plain button trigger styled like the other toolbar controls', () => { - const control = new DiagramExportControl({ onExportSvg: vi.fn() }) - const element = control.createControl() - parent.appendChild(element as HTMLElement) - - const trigger = parent.querySelector('.diagram-export-trigger') - expect(trigger?.tagName).toBe('BUTTON') - expect(trigger?.classList.contains('diagram-control-btn')).toBe(true) - }) - - it('renders only the SVG menu item when only onExportSvg is provided, and invokes it on click', () => { - const onExportSvg = vi.fn() - const control = new DiagramExportControl({ onExportSvg }) - parent.appendChild(control.createControl() as HTMLElement) - - const items = Array.from(parent.querySelectorAll('.diagram-export-menu-item')) - expect(items.map(i => i.textContent)).toEqual(['Export as SVG']) - - items[0].dispatchEvent(new Event('click')) - expect(onExportSvg).toHaveBeenCalledTimes(1) - }) - - it('renders SVG and PNG menu items and invokes the matching callback', () => { - const onExportSvg = vi.fn() - const onExportPng = vi.fn() - const control = new DiagramExportControl({ onExportSvg, onExportPng }) - parent.appendChild(control.createControl() as HTMLElement) - - const items = Array.from(parent.querySelectorAll('.diagram-export-menu-item')) - expect(items.map(i => i.textContent)).toEqual(['Export as SVG', 'Export as PNG']) - - items[1].dispatchEvent(new Event('click')) - expect(onExportPng).toHaveBeenCalledTimes(1) - expect(onExportSvg).not.toHaveBeenCalled() - }) - - it('toggles the menu when the trigger is clicked', () => { - const control = new DiagramExportControl({ onExportSvg: vi.fn() }) - parent.appendChild(control.createControl() as HTMLElement) - - const trigger = parent.querySelector('.diagram-export-trigger') as HTMLButtonElement - const menu = parent.querySelector('.diagram-export-menu') as HTMLElement - expect(menu.hidden).toBe(true) - expect(trigger.getAttribute('aria-expanded')).toBe('false') - - trigger.dispatchEvent(new Event('click', { bubbles: true })) - expect(menu.hidden).toBe(false) - expect(trigger.getAttribute('aria-expanded')).toBe('true') - - trigger.dispatchEvent(new Event('click', { bubbles: true })) - expect(menu.hidden).toBe(true) - expect(trigger.getAttribute('aria-expanded')).toBe('false') - }) - - it('closes the menu when selecting an item', () => { - const control = new DiagramExportControl({ onExportSvg: vi.fn() }) - parent.appendChild(control.createControl() as HTMLElement) - - const trigger = parent.querySelector('.diagram-export-trigger') as HTMLButtonElement - const menu = parent.querySelector('.diagram-export-menu') as HTMLElement - const item = parent.querySelector('.diagram-export-menu-item') as HTMLElement - - trigger.dispatchEvent(new Event('click', { bubbles: true })) - expect(menu.hidden).toBe(false) - - item.dispatchEvent(new Event('click')) - expect(menu.hidden).toBe(true) - }) - - it('closes the menu when clicking outside the control', () => { - document.body.appendChild(parent) - const control = new DiagramExportControl({ onExportSvg: vi.fn() }) - parent.appendChild(control.createControl() as HTMLElement) - - const trigger = parent.querySelector('.diagram-export-trigger') as HTMLButtonElement - const menu = parent.querySelector('.diagram-export-menu') as HTMLElement - - trigger.dispatchEvent(new Event('click', { bubbles: true })) - expect(menu.hidden).toBe(false) - - document.body.dispatchEvent(new Event('click', { bubbles: true })) - expect(menu.hidden).toBe(true) - - document.body.removeChild(parent) - }) - - it('removes the control from the DOM and stops listening for outside clicks on destroy', () => { - document.body.appendChild(parent) - const control = new DiagramExportControl({ onExportSvg: vi.fn() }) - parent.appendChild(control.createControl() as HTMLElement) - expect(parent.querySelector('.diagram-export-control')).not.toBeNull() - - control.destroy() - - expect(parent.querySelector('.diagram-export-control')).toBeNull() - document.body.removeChild(parent) - }) - - describe('keyboard navigation', () => { - afterEach(() => { - parent.parentElement?.removeChild(parent) - }) - - function keydown(target: HTMLElement, key: string): void { - target.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true })) - } - - function setUp(): { trigger: HTMLButtonElement; menu: HTMLElement; items: HTMLButtonElement[] } { - document.body.appendChild(parent) - const control = new DiagramExportControl({ onExportSvg: vi.fn(), onExportPng: vi.fn() }) - parent.appendChild(control.createControl() as HTMLElement) - return { - trigger: parent.querySelector('.diagram-export-trigger') as HTMLButtonElement, - menu: parent.querySelector('.diagram-export-menu') as HTMLElement, - items: Array.from(parent.querySelectorAll('.diagram-export-menu-item')), - } - } - - it('opens the menu and focuses the first item when ArrowDown is pressed on the trigger', () => { - const { trigger, menu, items } = setUp() - - keydown(trigger, 'ArrowDown') - - expect(menu.hidden).toBe(false) - expect(document.activeElement).toBe(items[0]) - }) - - it('opens the menu and focuses the last item when ArrowUp is pressed on the trigger', () => { - const { trigger, menu, items } = setUp() - - keydown(trigger, 'ArrowUp') - - expect(menu.hidden).toBe(false) - expect(document.activeElement).toBe(items[items.length - 1]) - }) - - it('moves focus to the next item with ArrowDown and wraps from the last item to the first', () => { - const { trigger, items } = setUp() - keydown(trigger, 'ArrowDown') - expect(document.activeElement).toBe(items[0]) - - keydown(items[0], 'ArrowDown') - expect(document.activeElement).toBe(items[1]) - - keydown(items[1], 'ArrowDown') - expect(document.activeElement).toBe(items[0]) - }) - - it('moves focus to the previous item with ArrowUp and wraps from the first item to the last', () => { - const { trigger, items } = setUp() - keydown(trigger, 'ArrowDown') - expect(document.activeElement).toBe(items[0]) - - keydown(items[0], 'ArrowUp') - expect(document.activeElement).toBe(items[items.length - 1]) - }) - - it('closes the menu and returns focus to the trigger when Escape is pressed', () => { - const { trigger, menu, items } = setUp() - keydown(trigger, 'ArrowDown') - expect(menu.hidden).toBe(false) - - keydown(items[0], 'Escape') - - expect(menu.hidden).toBe(true) - expect(document.activeElement).toBe(trigger) - }) - - it('does nothing on Escape when the menu is already closed', () => { - const { trigger, menu } = setUp() - - expect(() => keydown(trigger, 'Escape')).not.toThrow() - expect(menu.hidden).toBe(true) - }) - - it('closes the menu when focus moves to an element outside the control', () => { - const outside = document.createElement('button') - document.body.appendChild(outside) - const { trigger, menu, items } = setUp() - keydown(trigger, 'ArrowDown') - expect(menu.hidden).toBe(false) - - items[0].dispatchEvent(new FocusEvent('focusout', { relatedTarget: outside, bubbles: true })) - - expect(menu.hidden).toBe(true) - document.body.removeChild(outside) - }) - }) -}) diff --git a/calm-plugins/vscode/src/features/preview/webview/diagram-export-control.ts b/calm-plugins/vscode/src/features/preview/webview/diagram-export-control.ts deleted file mode 100644 index dff418615..000000000 --- a/calm-plugins/vscode/src/features/preview/webview/diagram-export-control.ts +++ /dev/null @@ -1,170 +0,0 @@ -/** - * DiagramExportControl - "Export" button with an SVG/PNG dropdown menu. - * Built from plain elements styled via `.diagram-control-btn` so it matches the - * existing zoom/pan controls, rather than pulling in a separate UI component library. - */ - -export interface DiagramExportControlOptions { - onExportSvg?: () => void - onExportPng?: () => void -} - -type ExportFormat = 'svg' | 'png' - -export class DiagramExportControl { - private container: HTMLElement | null = null - private trigger: HTMLButtonElement | null = null - private menu: HTMLElement | null = null - private isOpen = false - - private readonly handleOutsideClick = (event: MouseEvent): void => { - if (this.container && !this.container.contains(event.target as Node)) { - this.close() - } - } - - private readonly handleFocusOut = (event: FocusEvent): void => { - const next = event.relatedTarget as Node | null - if (this.container && !this.container.contains(next)) { - this.close() - } - } - - private readonly handleKeydown = (event: KeyboardEvent): void => { - if (event.key === 'Escape') { - if (!this.isOpen) return - event.preventDefault() - this.close() - this.trigger?.focus() - return - } - - if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return - - const items = this.getMenuItems() - if (items.length === 0) return - - if (!this.isOpen) { - event.preventDefault() - this.open() - this.focusItemAt(event.key === 'ArrowDown' ? 0 : items.length - 1) - return - } - - const currentIndex = items.indexOf(document.activeElement as HTMLButtonElement) - if (currentIndex === -1) return - event.preventDefault() - const delta = event.key === 'ArrowDown' ? 1 : -1 - this.focusItemAt((currentIndex + delta + items.length) % items.length) - } - - constructor(private options: DiagramExportControlOptions = {}) {} - - private getMenuItems(): HTMLButtonElement[] { - return this.menu ? Array.from(this.menu.querySelectorAll('.diagram-export-menu-item')) : [] - } - - private focusItemAt(index: number): void { - this.getMenuItems()[index]?.focus() - } - - /** - * Builds the "Export" trigger and its dropdown menu. - * Returns null if neither export callback was provided. - */ - public createControl(): HTMLElement | null { - const items: { label: string; value: ExportFormat }[] = [] - if (this.options.onExportSvg) { - items.push({ label: 'Export as SVG', value: 'svg' }) - } - if (this.options.onExportPng) { - items.push({ label: 'Export as PNG', value: 'png' }) - } - if (items.length === 0) { - return null - } - - const wrapper = document.createElement('div') - wrapper.className = 'diagram-export-control diagram-control-group-start' - - const trigger = document.createElement('button') - trigger.className = 'diagram-control-btn diagram-export-trigger' - trigger.textContent = 'Export ▾' - trigger.title = 'Export diagram' - trigger.setAttribute('aria-label', 'Export diagram') - trigger.setAttribute('aria-haspopup', 'menu') - trigger.setAttribute('aria-expanded', 'false') - trigger.addEventListener('click', (event) => { - event.stopPropagation() - this.toggle() - }) - - const menu = document.createElement('div') - menu.className = 'diagram-export-menu' - menu.setAttribute('role', 'menu') - menu.hidden = true - - items.forEach(item => { - const menuItem = document.createElement('button') - menuItem.className = 'diagram-export-menu-item' - menuItem.setAttribute('role', 'menuitem') - menuItem.textContent = item.label - menuItem.addEventListener('click', () => { - this.close() - if (item.value === 'svg') { - this.options.onExportSvg?.() - } else { - this.options.onExportPng?.() - } - }) - menu.appendChild(menuItem) - }) - - wrapper.appendChild(trigger) - wrapper.appendChild(menu) - wrapper.addEventListener('keydown', this.handleKeydown) - wrapper.addEventListener('focusout', this.handleFocusOut) - - this.container = wrapper - this.trigger = trigger - this.menu = menu - return wrapper - } - - private toggle(): void { - if (this.isOpen) { - this.close() - } else { - this.open() - } - } - - private open(): void { - if (!this.menu || !this.trigger) return - this.isOpen = true - this.menu.hidden = false - this.trigger.setAttribute('aria-expanded', 'true') - document.addEventListener('click', this.handleOutsideClick) - } - - private close(): void { - if (!this.menu || !this.trigger) return - this.isOpen = false - this.menu.hidden = true - this.trigger.setAttribute('aria-expanded', 'false') - document.removeEventListener('click', this.handleOutsideClick) - } - - /** - * Remove the control from the DOM and detach its listeners. - */ - public destroy(): void { - this.close() - if (this.container?.parentElement) { - this.container.parentElement.removeChild(this.container) - } - this.container = null - this.trigger = null - this.menu = null - } -} diff --git a/calm-plugins/vscode/src/features/preview/webview/diagram-export.spec.ts b/calm-plugins/vscode/src/features/preview/webview/diagram-export.spec.ts deleted file mode 100644 index a7a3564eb..000000000 --- a/calm-plugins/vscode/src/features/preview/webview/diagram-export.spec.ts +++ /dev/null @@ -1,296 +0,0 @@ -// @vitest-environment jsdom -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { serializeSvgElement, rasterizeSvgElementToPng, exportDiagram } from './diagram-export' - -class FakeImage { - onload: (() => void) | null = null - onerror: (() => void) | null = null - private _src = '' - - get src() { - return this._src - } - - set src(value: string) { - this._src = value - queueMicrotask(() => this.onload?.()) - } -} - -function createSvgElement(): SVGSVGElement { - const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg') as unknown as SVGSVGElement - const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect') - rect.setAttribute('width', '10') - rect.setAttribute('height', '10') - svg.appendChild(rect) - return svg -} - -/** - * Mimics the DOM shape svg-pan-zoom leaves behind: a 100%-sized SVG with no viewBox, - * wrapping the diagram content in a `.svg-pan-zoom_viewport` group carrying a pan/zoom - * transform. `initializePanZoom` stashes Mermaid's own layout-computed viewBox as - * `data-original-viewbox` before svg-pan-zoom strips it. - */ -function createPanZoomedSvgElement(originalViewBox: string | null): SVGSVGElement { - const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg') as unknown as SVGSVGElement - svg.setAttribute('width', '100%') - svg.style.width = '100%' - svg.style.height = '100%' - svg.style.overflow = 'hidden' - - if (originalViewBox) { - svg.setAttribute('data-original-viewbox', originalViewBox) - } - - const viewport = document.createElementNS('http://www.w3.org/2000/svg', 'g') - viewport.setAttribute('class', 'svg-pan-zoom_viewport') - viewport.setAttribute('transform', 'matrix(0.23,0,0,0.23,0,223.88)') - viewport.style.transform = 'matrix(0.23,0,0,0.23,0,223.88)' - - const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect') - rect.setAttribute('width', '10') - rect.setAttribute('height', '10') - viewport.appendChild(rect) - svg.appendChild(viewport) - - return svg -} - -class FakeFileReader { - onload: (() => void) | null = null - onerror: (() => void) | null = null - result: string | null = null - - readAsDataURL(blob: Blob): void { - this.result = `data:${blob.type};base64,FAKE` - queueMicrotask(() => this.onload?.()) - } -} - -describe('diagram-export', () => { - let drawImage: ReturnType - let fillRect: ReturnType - let fakeCtx: { drawImage: typeof drawImage; fillRect: typeof fillRect; fillStyle: string } - - beforeEach(() => { - vi.clearAllMocks() - vi.stubGlobal('Image', FakeImage) - vi.stubGlobal('FileReader', FakeFileReader) - - drawImage = vi.fn() - fillRect = vi.fn() - fakeCtx = { drawImage, fillRect, fillStyle: '' } - vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(fakeCtx as unknown as CanvasRenderingContext2D) - vi.spyOn(HTMLCanvasElement.prototype, 'toDataURL').mockReturnValue('data:image/png;base64,QkJC') - }) - - describe('serializeSvgElement', () => { - it('serializes the SVG including its children', () => { - const svg = createSvgElement() - const result = serializeSvgElement(svg) - - expect(result).toContain(' { - const svg = createSvgElement() - expect(svg.getAttribute('xmlns')).toBeNull() - - const result = serializeSvgElement(svg) - - expect(result).toContain('xmlns="http://www.w3.org/2000/svg"') - }) - - it('does not duplicate an existing xmlns attribute', () => { - const svg = createSvgElement() - svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg') - - const result = serializeSvgElement(svg) - - expect(result.match(/xmlns="http:\/\/www\.w3\.org\/2000\/svg"/g)).toHaveLength(1) - }) - - it('sets the resolved font-family as a presentation attribute so text renders correctly standalone', () => { - const svg = createSvgElement() - svg.style.fontFamily = '-apple-system, BlinkMacSystemFont, Arial, sans-serif' - - const result = serializeSvgElement(svg) - - // Set as a presentation attribute (lowest CSS specificity), not via the style - // attribute - the latter would override Mermaid's own font-family rules for - // node/edge labels and change the text metrics they were sized for. - expect(result).toContain('font-family="-apple-system, BlinkMacSystemFont, Arial, sans-serif"') - }) - - it('does not add a font-family attribute when none can be resolved', () => { - const svg = createSvgElement() - - const result = serializeSvgElement(svg) - - expect(result).not.toContain('font-family') - }) - - it('restores Mermaid\'s original viewBox and drops the pan/zoom transform', () => { - const svg = createPanZoomedSvgElement('4 4 1223.7734375 715') - - const result = serializeSvgElement(svg) - - expect(result).toContain('viewBox="4 4 1223.7734375 715"') - expect(result).toContain('width="1223.7734375"') - expect(result).toContain('height="715"') - expect(result).not.toContain('transform="matrix') - expect(result).not.toContain('data-original-viewbox') - expect(result).not.toMatch(/style="[^"]*width:\s*100%/) - expect(result).not.toMatch(/style="[^"]*height:\s*100%/) - }) - - it('does not add a viewBox when there is no pan-zoom viewport group', () => { - const svg = createSvgElement() - - const result = serializeSvgElement(svg) - - expect(result).not.toContain('viewBox') - }) - - it('does not add a viewBox when there is no stashed original viewBox', () => { - const svg = createPanZoomedSvgElement(null) - - const result = serializeSvgElement(svg) - - expect(result).not.toContain('viewBox') - expect(result).toContain('transform="matrix') - }) - - it('sets overflow: visible on foreignObject elements so labels are not clipped', () => { - const svg = createSvgElement() - const foreignObject = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject') - foreignObject.setAttribute('width', '100') - foreignObject.setAttribute('height', '20') - svg.appendChild(foreignObject) - - const result = serializeSvgElement(svg) - - expect(result).toContain('') - }) - - it('does nothing when there are no foreignObject elements', () => { - const svg = createSvgElement() - - const result = serializeSvgElement(svg) - - expect(result).not.toContain('overflow') - }) - }) - - describe('rasterizeSvgElementToPng', () => { - it('strips the data URL prefix from the rasterized result', async () => { - const result = await rasterizeSvgElementToPng(createSvgElement()) - - expect(result).toBe('QkJC') - }) - - it('loads the serialized clone via a base64 data URI, never fetching anything over the network', async () => { - const readAsDataURLSpy = vi.spyOn(FakeFileReader.prototype, 'readAsDataURL') - - await rasterizeSvgElementToPng(createSvgElement()) - - expect(readAsDataURLSpy).toHaveBeenCalledWith(expect.any(Blob)) - const [blob] = readAsDataURLSpy.mock.calls[0] - expect(blob.type).toBe('image/svg+xml;charset=utf-8') - }) - - it('sizes the canvas from the restored viewBox dimensions at the given pixel ratio', async () => { - const svg = createPanZoomedSvgElement('4 4 1223.7734375 715') - - await rasterizeSvgElementToPng(svg, 2) - - // canvas.width/height are integers per the HTML spec, so fractional viewBox - // dimensions get truncated - assert against what the canvas actually receives. - expect(drawImage).toHaveBeenCalledWith(expect.anything(), 0, 0, Math.trunc(1223.7734375 * 2), 715 * 2) - }) - - it('fills the canvas with the VS Code editor background before drawing, so the PNG is not transparent', async () => { - document.body.style.setProperty('--vscode-editor-background', '#1e1e1e') - - try { - await rasterizeSvgElementToPng(createSvgElement()) - - expect(fakeCtx.fillStyle).toBe('#1e1e1e') - expect(fillRect).toHaveBeenCalledWith(0, 0, expect.anything(), expect.anything()) - expect(fillRect.mock.invocationCallOrder[0]).toBeLessThan(drawImage.mock.invocationCallOrder[0]) - } finally { - document.body.style.removeProperty('--vscode-editor-background') - } - }) - - it('falls back to white when no VS Code background variable is set', async () => { - await rasterizeSvgElementToPng(createSvgElement()) - - expect(fakeCtx.fillStyle).toBe('#ffffff') - }) - - it('throws when the canvas 2D context is unavailable', async () => { - vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null) - - await expect(rasterizeSvgElementToPng(createSvgElement())).rejects.toThrow('Canvas 2D context unavailable') - }) - - it('rejects when the SVG cannot be encoded to a data URI', async () => { - class FailingFileReader extends FakeFileReader { - readAsDataURL(): void { - queueMicrotask(() => this.onerror?.()) - } - } - vi.stubGlobal('FileReader', FailingFileReader) - - await expect(rasterizeSvgElementToPng(createSvgElement())).rejects.toThrow('Failed to encode SVG for rasterization') - }) - - it('rejects when the image fails to load', async () => { - class FailingImage { - onload: (() => void) | null = null - onerror: (() => void) | null = null - set src(_value: string) { - queueMicrotask(() => this.onerror?.()) - } - } - vi.stubGlobal('Image', FailingImage) - - await expect(rasterizeSvgElementToPng(createSvgElement())).rejects.toThrow('Failed to load SVG for rasterization') - }) - }) - - describe('exportDiagram', () => { - it('returns an svg export message', async () => { - const container = document.createElement('div') - container.appendChild(createSvgElement()) - - const message = await exportDiagram(container, 'svg', 1) - - expect(message.type).toBe('exportDiagram') - expect(message.format).toBe('svg') - expect(message.diagramIndex).toBe(1) - expect(message.data).toContain(' { - const container = document.createElement('div') - container.appendChild(createSvgElement()) - - const message = await exportDiagram(container, 'png', 2) - - expect(message.format).toBe('png') - expect(message.diagramIndex).toBe(2) - expect(message.data).toBe('QkJC') - }) - - it('throws when the container has no svg element', async () => { - const container = document.createElement('div') - - await expect(exportDiagram(container, 'svg', 1)).rejects.toThrow('No SVG element found in diagram container') - }) - }) -}) diff --git a/calm-plugins/vscode/src/features/preview/webview/diagram-export.ts b/calm-plugins/vscode/src/features/preview/webview/diagram-export.ts deleted file mode 100644 index cf002446d..000000000 --- a/calm-plugins/vscode/src/features/preview/webview/diagram-export.ts +++ /dev/null @@ -1,142 +0,0 @@ -export type DiagramExportFormat = 'svg' | 'png' - -export interface DiagramExportMessage { - type: 'exportDiagram' - format: DiagramExportFormat - data: string - diagramIndex: number -} - -export function serializeSvgElement(svg: SVGSVGElement): string { - const clone = buildExportClone(svg) - return new XMLSerializer().serializeToString(clone) -} - -/** - * Produces a detached clone of `svg` ready for export, with three fixes applied: - * - * Font: some text (e.g. edge labels) inherits the webview body font rather than getting - * an explicit font-family from Mermaid's styles. That inheritance breaks in a standalone - * file, so the live resolved font-family is inlined as a presentation attribute (lowest - * CSS specificity, so it doesn't override Mermaid's own per-element font rules). - * - * ViewBox: svg-pan-zoom strips the viewBox and wraps content in a pan/zoom transform. - * `initializePanZoom` stashes Mermaid's original layout viewBox as `data-original-viewbox` - * before svg-pan-zoom removes it; restore that here along with explicit width/height so - * the file has correct intrinsic dimensions and shows the whole diagram. - * - * Clipping: the inlined font may render glyphs wider than the font Mermaid used when - * measuring foreignObject sizes, causing labels to overflow. foreignObjects clip by - * default; set overflow:visible so any overflow spills out rather than being cut off. - */ -function buildExportClone(svg: SVGSVGElement): SVGSVGElement { - const clone = svg.cloneNode(true) as SVGSVGElement - // Remove any explicit xmlns - the serializer adds its own, and keeping both - // produces a duplicated attribute, making the output invalid XML. - clone.removeAttribute('xmlns') - - // Font - const fontFamily = getComputedStyle(svg).fontFamily - if (fontFamily) { - clone.setAttribute('font-family', fontFamily) - } - - // ViewBox - const originalViewBox = clone.getAttribute('data-original-viewbox') - const viewport = clone.querySelector('.svg-pan-zoom_viewport') as SVGElement | null - if (originalViewBox && viewport) { - const [, , width, height] = originalViewBox.split(/\s+/) - clone.setAttribute('viewBox', originalViewBox) - clone.setAttribute('width', width) - clone.setAttribute('height', height) - clone.removeAttribute('data-original-viewbox') - clone.style.removeProperty('width') - clone.style.removeProperty('height') - viewport.removeAttribute('transform') - viewport.style.removeProperty('transform') - } - - // Clipping - clone.querySelectorAll('foreignObject').forEach((fo) => { - fo.style.overflow = 'visible' - }) - - return clone -} - -/** - * Rasterizes the fixed SVG clone using the browser's native SVG image decoder rather - * than an external library. The clone is loaded as a `data:` URI rather than a `blob:` - * URL: Chromium currently taints the canvas when rasterizing an SVG containing a - * (which Mermaid uses for labels) loaded via a `blob:` URL, but `data:` - * URIs have never tainted in any browser, independent of foreignObject content. The - * clone is already self-contained - font-family is inlined as a literal value and - * Mermaid's own