diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c7e1d0f --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +out/ +node_modules/ +*.vsix +examples/ +coverage/ diff --git a/.vscodeignore b/.vscodeignore new file mode 100644 index 0000000..4b0057a --- /dev/null +++ b/.vscodeignore @@ -0,0 +1,47 @@ +# Source files - only compiled output needed +src/ +*.ts + +# Exclude test output and source maps from out/ +out/__tests__/ +out/**/*.map + +# Dependencies +node_modules/ +package-lock.json + +# Development config +tsconfig.json +vitest.config.ts +.vscode/ +.gitignore +.envrc + +# Test and coverage +coverage/ +.nyc_output/ + +# Documentation and BMAD working files +docs/ +_bmad/ +_bmad-output/ +CLAUDE.md +GEMINI.md +AGENTS.md +STATUS.md + +# Version control +.git/ +.github/ + +# AI tool configs +.claude/ +.codex/ +.gemini/ + +# Archive and scratch +archive/ + +# Build artifacts +*.vsix +*.png diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..71e5384 --- /dev/null +++ b/LICENSE @@ -0,0 +1,26 @@ +MIT License + +Copyright (c) 2026 BMad Code, LLC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +TRADEMARK NOTICE: +BMad™ , BMAD-CORE™ and BMAD-METHOD™ are trademarks of BMad Code, LLC. The use of these +trademarks in this software does not grant any rights to use the trademarks +for any other purpose. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..46300a8 --- /dev/null +++ b/NOTICE @@ -0,0 +1,4 @@ +This software contains original code developed and contributed by: + +Copyright (c) 2026 Wendy Smoak +Contributed to BMad Code, LLC in January 2026 under the MIT License diff --git a/README.md b/README.md new file mode 100644 index 0000000..5f3eba7 --- /dev/null +++ b/README.md @@ -0,0 +1,121 @@ +# BMad Method for VS Code + +A VS Code extension for the BMad Method that displays CodeLens actions above story headers in epic markdown files, enabling one-click story creation and development via Claude Code CLI. + +## Features + +- Detects BMAD epic files by configurable filename pattern (default: `*epic*.md`) +- Displays CodeLens actions above story headers matching `### Story N.N: Title` +- Context-aware actions based on story status and file existence: + - **Create Story**: When status is `ready` but no story file exists + - **Start Developing Story**: When status is `ready` and story file exists + - No CodeLens for stories with other statuses (`in-progress`, `completed`, etc.) + +## Requirements + +- VS Code 1.80.0 or higher +- [Claude Code CLI](https://claude.ai/claude-code) installed and authenticated (or configure `bmad.cliTool`) +- BMAD Method workflows configured in your project + +## Story File Detection + +The extension looks for story files in `_bmad-output/implementation-artifacts/` with the naming pattern `{story-number}*.md` where dots in the story number are converted to dashes (e.g., story `1.1` looks for files matching `1-1*.md`). + +## Configuration + +| Setting | Default | Description | +|---------|---------|-------------| +| `bmad.enableCodeLens` | `true` | Enable/disable CodeLens for BMAD story files | +| `bmad.epicFilePattern` | `**/*epic*.md` | Glob pattern for BMAD epic files | +| `bmad.techSpecFilePattern` | `**/tech-spec-*.md` | Glob pattern for BMAD tech spec files | +| `bmad.cliTool` | `claude` | CLI tool binary used to run BMAD story workflows | + +## Compatibility / Known Limitations + +- **Codex is not supported.** The `codex` CLI has known issues and is incompatible with the extension's workflow; do not set `bmad.cliTool` to `codex`. See https://github.com/openai/codex/issues/3641 for details. + +## Usage + +1. Open an epic file (e.g., `epic-1-authentication.md`) +2. Look for stories with `**Status:** ready` +3. Click the CodeLens action above the story header: + - "Create Story" runs `/bmad:bmm:workflows:create-story {storyNumber}` + - "Start Developing Story" runs `/bmad:bmm:workflows:dev-story {storyNumber}` + +## Expected Story Format + +```markdown +### Story 1.1: Implement User Login + +**Status:** ready +**Priority:** P0 +**Estimated Effort:** 3 points +``` + +## Development + +```bash +# Install dependencies +npm install + +# Compile TypeScript +npm run compile + +# Watch for changes +npm run watch + +# Test in VS Code +# Open src/extension.ts and press F5 to launch Extension Development Host +``` + +## Testing + +The project uses [Vitest](https://vitest.dev/) for unit and integration testing. + +```bash +# Run all tests +npm test + +# Run tests in watch mode (re-runs on file changes) +npm run test:watch + +# Run tests with coverage report +npm run test:coverage +``` + +Test files are located in `src/__tests__/` and follow the `*.test.ts` naming convention. + +Coverage reports showing which lines of code were not executed during the tests are generated in `coverage/`: + +- HTML report: open `coverage/index.html` +- LCOV report: `coverage/lcov.info` (for CI or uploading to coverage services) + +## Local Installation + +To package and install the extension locally: + +1. Install the VS Code Extension Manager: + ```bash + npm install -g @vscode/vsce + ``` + +2. Package the extension: + ```bash + vsce package + ``` + This creates a `.vsix` file in the project directory. + +3. Install in VS Code: + - Open Extensions view (Cmd+Shift+X) + - Click the `...` menu at the top of the sidebar + - Select "Install from VSIX..." + - Choose the generated `.vsix` file + + Or from the command line: + ```bash + code --install-extension bmad-method-0.0.1-pre.vsix + ``` + +## License + +MIT diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..68eeedb --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2521 @@ +{ + "name": "bmad-method", + "version": "0.0.1-pre", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "bmad-method", + "version": "0.0.1-pre", + "license": "MIT", + "devDependencies": { + "@types/node": "^18.0.0", + "@types/vscode": "^1.80.0", + "@vitest/coverage-v8": "^3.2.4", + "typescript": "^5.0.0", + "vitest": "^3.2.4" + }, + "engines": { + "vscode": "^1.80.0" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz", + "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.6" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz", + "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "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/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz", + "integrity": "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz", + "integrity": "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz", + "integrity": "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz", + "integrity": "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz", + "integrity": "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz", + "integrity": "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz", + "integrity": "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz", + "integrity": "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz", + "integrity": "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz", + "integrity": "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz", + "integrity": "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz", + "integrity": "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz", + "integrity": "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz", + "integrity": "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz", + "integrity": "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz", + "integrity": "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz", + "integrity": "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz", + "integrity": "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz", + "integrity": "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz", + "integrity": "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz", + "integrity": "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz", + "integrity": "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz", + "integrity": "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz", + "integrity": "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz", + "integrity": "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "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.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/vscode": { + "version": "1.108.1", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.108.1.tgz", + "integrity": "sha512-DerV0BbSzt87TbrqmZ7lRDIYaMiqvP8tmJTzW2p49ZBVtGUnGAu2RGQd1Wv4XMzEVUpaHbsemVM5nfuQJj7H6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/coverage-v8": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz", + "integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^1.0.2", + "ast-v8-to-istanbul": "^0.3.3", + "debug": "^4.4.1", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.17", + "magicast": "^0.3.5", + "std-env": "^3.9.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "3.2.4", + "vitest": "3.2.4" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "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": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "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/ast-v8-to-istanbul": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.10.tgz", + "integrity": "sha512-p4K7vMz2ZSk3wN8l5o3y2bJAoZXT3VuJI5OLTATY/01CYWumWvwkUw0SqDBnNq6IiTO3qDa1eSQDibAV8g7XOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^9.0.1" + } + }, + "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/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "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/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/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-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "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/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/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/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "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/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "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.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "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/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "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-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "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.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "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.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz", + "integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.55.1", + "@rollup/rollup-android-arm64": "4.55.1", + "@rollup/rollup-darwin-arm64": "4.55.1", + "@rollup/rollup-darwin-x64": "4.55.1", + "@rollup/rollup-freebsd-arm64": "4.55.1", + "@rollup/rollup-freebsd-x64": "4.55.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.55.1", + "@rollup/rollup-linux-arm-musleabihf": "4.55.1", + "@rollup/rollup-linux-arm64-gnu": "4.55.1", + "@rollup/rollup-linux-arm64-musl": "4.55.1", + "@rollup/rollup-linux-loong64-gnu": "4.55.1", + "@rollup/rollup-linux-loong64-musl": "4.55.1", + "@rollup/rollup-linux-ppc64-gnu": "4.55.1", + "@rollup/rollup-linux-ppc64-musl": "4.55.1", + "@rollup/rollup-linux-riscv64-gnu": "4.55.1", + "@rollup/rollup-linux-riscv64-musl": "4.55.1", + "@rollup/rollup-linux-s390x-gnu": "4.55.1", + "@rollup/rollup-linux-x64-gnu": "4.55.1", + "@rollup/rollup-linux-x64-musl": "4.55.1", + "@rollup/rollup-openbsd-x64": "4.55.1", + "@rollup/rollup-openharmony-arm64": "4.55.1", + "@rollup/rollup-win32-arm64-msvc": "4.55.1", + "@rollup/rollup-win32-ia32-msvc": "4.55.1", + "@rollup/rollup-win32-x64-gnu": "4.55.1", + "@rollup/rollup-win32-x64-msvc": "4.55.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "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/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": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "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/test-exclude": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz", + "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^9.0.4" + }, + "engines": { + "node": ">=18" + } + }, + "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": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "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/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "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", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.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 + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "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/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "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", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.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 + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "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/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/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/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/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/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..cb9bf05 --- /dev/null +++ b/package.json @@ -0,0 +1,135 @@ +{ + "name": "bmad-method", + "displayName": "BMad Method for VS Code", + "description": "VS Code extension for the BMad Method", + "version": "0.0.1-pre", + "publisher": "bmad-code-llc", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/bmad-code-org/bmad-method-vscode" + }, + "engines": { + "vscode": "^1.80.0" + }, + "categories": [ + "Other" + ], + "activationEvents": [ + "onLanguage:markdown", + "onView:bmadStories", + "onView:bmadTechSpecs" + ], + "main": "./out/extension.js", + "contributes": { + "commands": [ + { + "command": "bmadMethod.createStory", + "title": "BMad Method: Create Story" + }, + { + "command": "bmadMethod.developStory", + "title": "BMad Method: Develop Story" + }, + { + "command": "bmadMethod.refreshStories", + "title": "Refresh", + "icon": "$(refresh)" + }, + { + "command": "bmadMethod.refreshTechSpecs", + "title": "Refresh", + "icon": "$(refresh)" + }, + { + "command": "bmadMethod.revealStory", + "title": "Reveal Story" + }, + { + "command": "bmadMethod.revealTask", + "title": "Reveal Task" + } + ], + "views": { + "explorer": [ + { + "id": "bmadStories", + "name": "BMad Epics", + "icon": "$(list-tree)", + "contextualTitle": "BMad Epics" + }, + { + "id": "bmadTechSpecs", + "name": "BMad Tech Specs", + "icon": "$(list-tree)", + "contextualTitle": "BMad Tech Specs" + } + ] + }, + "menus": { + "view/title": [ + { + "command": "bmadMethod.refreshStories", + "when": "view == bmadStories", + "group": "navigation" + }, + { + "command": "bmadMethod.refreshTechSpecs", + "when": "view == bmadTechSpecs", + "group": "navigation" + } + ] + }, + "viewsWelcome": [ + { + "view": "bmadStories", + "contents": "No epics files found matching the configured pattern.\n[Configure Pattern](command:workbench.action.openSettings?%5B%22bmad.epicFilePattern%22%5D)" + }, + { + "view": "bmadTechSpecs", + "contents": "No tech spec files found matching the configured pattern.\n[Configure Pattern](command:workbench.action.openSettings?%5B%22bmad.techSpecFilePattern%22%5D)" + } + ], + "configuration": { + "title": "BMad Method", + "properties": { + "bmad.enableCodeLens": { + "type": "boolean", + "default": true, + "description": "Enable CodeLens for BMAD story files" + }, + "bmad.epicFilePattern": { + "type": "string", + "default": "**/*epic*.md", + "description": "Glob pattern for BMAD epic files containing stories" + }, + "bmad.techSpecFilePattern": { + "type": "string", + "default": "**/tech-spec-*.md", + "description": "Glob pattern for BMAD tech spec files" + }, + "bmad.cliTool": { + "type": "string", + "default": "claude", + "minLength": 1, + "description": "CLI tool binary used to run BMAD story workflows" + } + } + } + }, + "scripts": { + "compile": "tsc -p ./", + "watch": "tsc -watch -p ./", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "test:legacy": "npm run compile && node ./scripts/test-cli-tool.js" + }, + "devDependencies": { + "@types/node": "^18.0.0", + "@types/vscode": "^1.80.0", + "@vitest/coverage-v8": "^3.2.4", + "typescript": "^5.0.0", + "vitest": "^3.2.4" + } +} diff --git a/src/__tests__/bmadConfig.test.ts b/src/__tests__/bmadConfig.test.ts new file mode 100644 index 0000000..80df97b --- /dev/null +++ b/src/__tests__/bmadConfig.test.ts @@ -0,0 +1,396 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { workspace, Uri, resetAllMocks } from './mocks/vscode'; +import { + getPlanningArtifactsPath, + getImplementationArtifactsPath +} from '../bmadConfig'; + +describe('bmadConfig', () => { + beforeEach(() => { + resetAllMocks(); + }); + + describe('4.1.1 - YAML field extraction for known fields', () => { + it('extracts planning_artifacts path', async () => { + workspace.workspaceFolders = [ + { uri: Uri.file('/workspace'), name: 'workspace', index: 0 } + ]; + + const configContent = `project_name: test-project +planning_artifacts: "_bmad-output/planning-artifacts" +implementation_artifacts: "_bmad-output/implementation-artifacts" +`; + vi.mocked(workspace.fs.readFile).mockResolvedValue( + new TextEncoder().encode(configContent) + ); + + const result = await getPlanningArtifactsPath(); + + expect(result).toBe('_bmad-output/planning-artifacts'); + }); + + it('extracts implementation_artifacts path', async () => { + workspace.workspaceFolders = [ + { uri: Uri.file('/workspace'), name: 'workspace', index: 0 } + ]; + + const configContent = `project_name: test-project +planning_artifacts: "_bmad-output/planning-artifacts" +implementation_artifacts: "_bmad-output/implementation-artifacts" +`; + vi.mocked(workspace.fs.readFile).mockResolvedValue( + new TextEncoder().encode(configContent) + ); + + const result = await getImplementationArtifactsPath(); + + expect(result).toBe('_bmad-output/implementation-artifacts'); + }); + + it('extracts path with single quotes', async () => { + workspace.workspaceFolders = [ + { uri: Uri.file('/workspace'), name: 'workspace', index: 0 } + ]; + + const configContent = `planning_artifacts: '_bmad-output/planning-artifacts' +`; + vi.mocked(workspace.fs.readFile).mockResolvedValue( + new TextEncoder().encode(configContent) + ); + + const result = await getPlanningArtifactsPath(); + + expect(result).toBe('_bmad-output/planning-artifacts'); + }); + + it('extracts path without quotes', async () => { + workspace.workspaceFolders = [ + { uri: Uri.file('/workspace'), name: 'workspace', index: 0 } + ]; + + const configContent = `planning_artifacts: _bmad-output/planning-artifacts +`; + vi.mocked(workspace.fs.readFile).mockResolvedValue( + new TextEncoder().encode(configContent) + ); + + const result = await getPlanningArtifactsPath(); + + expect(result).toBe('_bmad-output/planning-artifacts'); + }); + + it('handles extra whitespace around value', async () => { + workspace.workspaceFolders = [ + { uri: Uri.file('/workspace'), name: 'workspace', index: 0 } + ]; + + const configContent = `planning_artifacts: "_bmad-output/planning-artifacts" +`; + vi.mocked(workspace.fs.readFile).mockResolvedValue( + new TextEncoder().encode(configContent) + ); + + const result = await getPlanningArtifactsPath(); + + expect(result).toBe('_bmad-output/planning-artifacts'); + }); + }); + + describe('4.1.2 - {project-root} path resolution', () => { + it('strips {project-root}/ prefix from path', async () => { + workspace.workspaceFolders = [ + { uri: Uri.file('/workspace'), name: 'workspace', index: 0 } + ]; + + const configContent = `planning_artifacts: "{project-root}/_bmad-output/planning-artifacts" +`; + vi.mocked(workspace.fs.readFile).mockResolvedValue( + new TextEncoder().encode(configContent) + ); + + const result = await getPlanningArtifactsPath(); + + expect(result).toBe('_bmad-output/planning-artifacts'); + }); + + it('strips {project-root}/ prefix with single quotes', async () => { + workspace.workspaceFolders = [ + { uri: Uri.file('/workspace'), name: 'workspace', index: 0 } + ]; + + const configContent = `implementation_artifacts: '{project-root}/_bmad-output/impl' +`; + vi.mocked(workspace.fs.readFile).mockResolvedValue( + new TextEncoder().encode(configContent) + ); + + const result = await getImplementationArtifactsPath(); + + expect(result).toBe('_bmad-output/impl'); + }); + + it('handles path without {project-root} prefix', async () => { + workspace.workspaceFolders = [ + { uri: Uri.file('/workspace'), name: 'workspace', index: 0 } + ]; + + const configContent = `planning_artifacts: "docs/artifacts" +`; + vi.mocked(workspace.fs.readFile).mockResolvedValue( + new TextEncoder().encode(configContent) + ); + + const result = await getPlanningArtifactsPath(); + + expect(result).toBe('docs/artifacts'); + }); + + it('only strips {project-root}/ at the beginning', async () => { + workspace.workspaceFolders = [ + { uri: Uri.file('/workspace'), name: 'workspace', index: 0 } + ]; + + // Path that has {project-root} in the middle (shouldn't happen but test the behavior) + const configContent = `planning_artifacts: "some/{project-root}/path" +`; + vi.mocked(workspace.fs.readFile).mockResolvedValue( + new TextEncoder().encode(configContent) + ); + + const result = await getPlanningArtifactsPath(); + + // Should not strip it since it's not at the beginning + expect(result).toBe('some/{project-root}/path'); + }); + }); + + describe('4.1.3 - graceful fallback when config.yaml missing', () => { + it('returns null when no workspace folders', async () => { + workspace.workspaceFolders = undefined; + + const result = await getPlanningArtifactsPath(); + + expect(result).toBeNull(); + }); + + it('returns null when workspace folders array is empty', async () => { + workspace.workspaceFolders = []; + + const result = await getPlanningArtifactsPath(); + + expect(result).toBeNull(); + }); + + it('returns null when config.yaml does not exist', async () => { + workspace.workspaceFolders = [ + { uri: Uri.file('/workspace'), name: 'workspace', index: 0 } + ]; + + vi.mocked(workspace.fs.readFile).mockRejectedValue( + new Error('File not found') + ); + + const result = await getPlanningArtifactsPath(); + + expect(result).toBeNull(); + }); + + it('returns null when field is not found in config', async () => { + workspace.workspaceFolders = [ + { uri: Uri.file('/workspace'), name: 'workspace', index: 0 } + ]; + + const configContent = `project_name: test-project +some_other_field: "value" +`; + vi.mocked(workspace.fs.readFile).mockResolvedValue( + new TextEncoder().encode(configContent) + ); + + const result = await getPlanningArtifactsPath(); + + expect(result).toBeNull(); + }); + + it('returns null for implementation_artifacts when field not found', async () => { + workspace.workspaceFolders = [ + { uri: Uri.file('/workspace'), name: 'workspace', index: 0 } + ]; + + const configContent = `project_name: test-project +planning_artifacts: "path/to/planning" +`; + vi.mocked(workspace.fs.readFile).mockResolvedValue( + new TextEncoder().encode(configContent) + ); + + const result = await getImplementationArtifactsPath(); + + expect(result).toBeNull(); + }); + }); + + describe('4.1.4 - handling of malformed YAML content', () => { + it('returns null for empty config file', async () => { + workspace.workspaceFolders = [ + { uri: Uri.file('/workspace'), name: 'workspace', index: 0 } + ]; + + vi.mocked(workspace.fs.readFile).mockResolvedValue( + new TextEncoder().encode('') + ); + + const result = await getPlanningArtifactsPath(); + + expect(result).toBeNull(); + }); + + it('returns null for config with only comments', async () => { + workspace.workspaceFolders = [ + { uri: Uri.file('/workspace'), name: 'workspace', index: 0 } + ]; + + const configContent = `# This is a comment +# Another comment +`; + vi.mocked(workspace.fs.readFile).mockResolvedValue( + new TextEncoder().encode(configContent) + ); + + const result = await getPlanningArtifactsPath(); + + expect(result).toBeNull(); + }); + + it('handles config with invalid YAML syntax gracefully', async () => { + workspace.workspaceFolders = [ + { uri: Uri.file('/workspace'), name: 'workspace', index: 0 } + ]; + + // Invalid YAML with unmatched quotes - regex is line-based so it captures + // everything after the colon up to end of line, including the unclosed quote + const configContent = `planning_artifacts: "unclosed quote +other_field: value +`; + vi.mocked(workspace.fs.readFile).mockResolvedValue( + new TextEncoder().encode(configContent) + ); + + const result = await getPlanningArtifactsPath(); + + // The regex pattern captures the content including unclosed quote + // This documents the current behavior - not a YAML parser, just regex matching + expect(result).toBe('unclosed quote'); + }); + + it('handles field with empty value on same line', async () => { + workspace.workspaceFolders = [ + { uri: Uri.file('/workspace'), name: 'workspace', index: 0 } + ]; + + // Field with truly empty value (nothing after colon on same line) + const configContent = `planning_artifacts: +`; + vi.mocked(workspace.fs.readFile).mockResolvedValue( + new TextEncoder().encode(configContent) + ); + + const result = await getPlanningArtifactsPath(); + + // Empty value won't match the regex pattern which requires (.+) + expect(result).toBeNull(); + }); + + it('does not match field followed by another field on next line', async () => { + workspace.workspaceFolders = [ + { uri: Uri.file('/workspace'), name: 'workspace', index: 0 } + ]; + + // The regex is multiline and matches planning_artifacts: followed by next line content + // This is a quirk of the current implementation + const configContent = `planning_artifacts: +other_field: value +`; + vi.mocked(workspace.fs.readFile).mockResolvedValue( + new TextEncoder().encode(configContent) + ); + + const result = await getPlanningArtifactsPath(); + + // Current behavior: regex with 'm' flag matches across lines in some cases + // The (.+) captures the next line - this documents actual behavior + expect(result).toBe('other_field: value'); + }); + + it('extracts correct field when multiple similar field names exist', async () => { + workspace.workspaceFolders = [ + { uri: Uri.file('/workspace'), name: 'workspace', index: 0 } + ]; + + const configContent = `planning_artifacts_backup: "backup/path" +planning_artifacts: "correct/path" +planning_artifacts_old: "old/path" +`; + vi.mocked(workspace.fs.readFile).mockResolvedValue( + new TextEncoder().encode(configContent) + ); + + const result = await getPlanningArtifactsPath(); + + expect(result).toBe('correct/path'); + }); + + it('handles field value with special characters', async () => { + workspace.workspaceFolders = [ + { uri: Uri.file('/workspace'), name: 'workspace', index: 0 } + ]; + + const configContent = `planning_artifacts: "path/with-dashes_and_underscores/v1.0" +`; + vi.mocked(workspace.fs.readFile).mockResolvedValue( + new TextEncoder().encode(configContent) + ); + + const result = await getPlanningArtifactsPath(); + + expect(result).toBe('path/with-dashes_and_underscores/v1.0'); + }); + + it('handles Windows-style paths', async () => { + workspace.workspaceFolders = [ + { uri: Uri.file('/workspace'), name: 'workspace', index: 0 } + ]; + + const configContent = `planning_artifacts: "path\\to\\artifacts" +`; + vi.mocked(workspace.fs.readFile).mockResolvedValue( + new TextEncoder().encode(configContent) + ); + + const result = await getPlanningArtifactsPath(); + + expect(result).toBe('path\\to\\artifacts'); + }); + }); + + describe('reads correct config file path', () => { + it('reads from _bmad/bmm/config.yaml', async () => { + workspace.workspaceFolders = [ + { uri: Uri.file('/my/workspace'), name: 'workspace', index: 0 } + ]; + + const configContent = `planning_artifacts: "artifacts" +`; + vi.mocked(workspace.fs.readFile).mockResolvedValue( + new TextEncoder().encode(configContent) + ); + + await getPlanningArtifactsPath(); + + // Verify readFile was called with the correct path + expect(workspace.fs.readFile).toHaveBeenCalledTimes(1); + const calledUri = vi.mocked(workspace.fs.readFile).mock.calls[0][0] as Uri; + expect(calledUri.path).toContain('_bmad/bmm/config.yaml'); + }); + }); +}); diff --git a/src/__tests__/cliTool.test.ts b/src/__tests__/cliTool.test.ts new file mode 100644 index 0000000..b91cce1 --- /dev/null +++ b/src/__tests__/cliTool.test.ts @@ -0,0 +1,188 @@ +import { describe, it, expect } from 'vitest'; +import { + normalizeCliTool, + isSafeCliTool, + buildCliCommand, + getWhichCommand, + quoteCliTool, + DEFAULT_CLI_TOOL +} from '../cliTool'; + +describe('cliTool', () => { + describe('normalizeCliTool', () => { + it('returns default tool when undefined', () => { + expect(normalizeCliTool(undefined)).toBe(DEFAULT_CLI_TOOL); + }); + + it('returns default tool when empty string', () => { + expect(normalizeCliTool('')).toBe(DEFAULT_CLI_TOOL); + }); + + it('returns default tool when only whitespace', () => { + expect(normalizeCliTool(' ')).toBe(DEFAULT_CLI_TOOL); + }); + + it('returns trimmed tool name', () => { + expect(normalizeCliTool(' cursor ')).toBe('cursor'); + }); + + it('returns tool name as-is when valid', () => { + expect(normalizeCliTool('aider')).toBe('aider'); + }); + }); + + describe('isSafeCliTool', () => { + describe('valid tool names', () => { + it('accepts simple tool names', () => { + expect(isSafeCliTool('claude')).toBe(true); + expect(isSafeCliTool('cursor')).toBe(true); + expect(isSafeCliTool('aider')).toBe(true); + }); + + it('accepts tool names with dots', () => { + expect(isSafeCliTool('tool.exe')).toBe(true); + }); + + it('accepts tool names with underscores', () => { + expect(isSafeCliTool('my_tool')).toBe(true); + }); + + it('accepts tool names with hyphens', () => { + expect(isSafeCliTool('my-tool')).toBe(true); + }); + + it('accepts Unix absolute paths', () => { + expect(isSafeCliTool('/usr/bin/claude')).toBe(true); + expect(isSafeCliTool('/opt/tools/my-cli')).toBe(true); + }); + + it('accepts Windows absolute paths', () => { + expect(isSafeCliTool('C:/Program Files/tool.exe')).toBe(true); + expect(isSafeCliTool('C:\\Program Files\\tool.exe')).toBe(true); + }); + + it('accepts paths with spaces', () => { + expect(isSafeCliTool('/path/with spaces/tool')).toBe(true); + expect(isSafeCliTool('C:/Program Files/My Tool/tool.exe')).toBe(true); + }); + }); + + describe('command injection attempts', () => { + it('rejects semicolon (command chaining)', () => { + expect(isSafeCliTool('tool; rm -rf /')).toBe(false); + }); + + it('rejects pipe (command piping)', () => { + expect(isSafeCliTool('tool | cat /etc/passwd')).toBe(false); + }); + + it('rejects && (command chaining)', () => { + expect(isSafeCliTool('tool && malicious')).toBe(false); + }); + + it('rejects || (command chaining)', () => { + expect(isSafeCliTool('tool || malicious')).toBe(false); + }); + + it('rejects $() (command substitution)', () => { + expect(isSafeCliTool('$(whoami)')).toBe(false); + expect(isSafeCliTool('tool $(echo evil)')).toBe(false); + }); + + it('rejects backticks (command substitution)', () => { + expect(isSafeCliTool('`whoami`')).toBe(false); + expect(isSafeCliTool('tool `evil`')).toBe(false); + }); + + it('rejects > (output redirection)', () => { + expect(isSafeCliTool('tool > /etc/passwd')).toBe(false); + }); + + it('rejects < (input redirection)', () => { + expect(isSafeCliTool('tool < /etc/passwd')).toBe(false); + }); + + it('rejects newlines', () => { + expect(isSafeCliTool('tool\nmalicious')).toBe(false); + }); + + it('rejects single quotes', () => { + expect(isSafeCliTool("tool'injection")).toBe(false); + }); + + it('rejects double quotes', () => { + expect(isSafeCliTool('tool"injection')).toBe(false); + }); + + it('rejects ampersand alone (background execution)', () => { + expect(isSafeCliTool('tool &')).toBe(false); + }); + }); + }); + + describe('quoteCliTool', () => { + it('returns tool as-is when no spaces or quotes', () => { + expect(quoteCliTool('claude')).toBe('claude'); + expect(quoteCliTool('/usr/bin/claude')).toBe('/usr/bin/claude'); + }); + + it('quotes tool with spaces', () => { + expect(quoteCliTool('/path/with spaces/tool')).toBe('"/path/with spaces/tool"'); + }); + + it('escapes and quotes tool with double quotes', () => { + expect(quoteCliTool('tool"name')).toBe('"tool\\"name"'); + }); + + it('escapes backslashes when quoting', () => { + expect(quoteCliTool('path with\\slash')).toBe('"path with\\\\slash"'); + }); + }); + + describe('buildCliCommand', () => { + it('builds create-story command with story number', () => { + const cmd = buildCliCommand('claude', 'create-story', '1.1'); + expect(cmd).toBe('claude "/bmad:bmm:workflows:create-story 1.1"'); + }); + + it('builds dev-story command with story number', () => { + const cmd = buildCliCommand('claude', 'dev-story', '2.3'); + expect(cmd).toBe('claude "/bmad:bmm:workflows:dev-story 2.3"'); + }); + + it('builds command without story number', () => { + const cmd = buildCliCommand('claude', 'create-story'); + expect(cmd).toBe('claude "/bmad:bmm:workflows:create-story"'); + }); + + it('handles tool with spaces', () => { + const cmd = buildCliCommand('/path/with spaces/tool', 'dev-story', '1.1'); + expect(cmd).toBe('"/path/with spaces/tool" "/bmad:bmm:workflows:dev-story 1.1"'); + }); + + it('handles different tool names', () => { + expect(buildCliCommand('cursor', 'create-story', '1.1')).toBe( + 'cursor "/bmad:bmm:workflows:create-story 1.1"' + ); + expect(buildCliCommand('aider', 'dev-story', '1.1')).toBe( + 'aider "/bmad:bmm:workflows:dev-story 1.1"' + ); + }); + }); + + describe('getWhichCommand', () => { + it('returns "which" on Unix platforms', () => { + expect(getWhichCommand('linux', 'claude')).toEqual({ cmd: 'which', args: ['claude'] }); + expect(getWhichCommand('darwin', 'claude')).toEqual({ cmd: 'which', args: ['claude'] }); + }); + + it('returns "where" on Windows', () => { + expect(getWhichCommand('win32', 'claude')).toEqual({ cmd: 'where', args: ['claude'] }); + }); + + it('passes tool name as argument', () => { + const result = getWhichCommand('linux', 'my-custom-tool'); + expect(result.args).toEqual(['my-custom-tool']); + }); + }); +}); diff --git a/src/__tests__/epicTreeProvider.test.ts b/src/__tests__/epicTreeProvider.test.ts new file mode 100644 index 0000000..ab5945c --- /dev/null +++ b/src/__tests__/epicTreeProvider.test.ts @@ -0,0 +1,655 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { + workspace, + Uri, + TreeItemCollapsibleState, + ThemeIcon, + ThemeColor, + resetAllMocks +} from './mocks/vscode'; +import { EpicTreeProvider } from '../epicTreeProvider'; + +// Mock the bmadConfig module +vi.mock('../bmadConfig', () => ({ + getPlanningArtifactsPath: vi.fn() +})); + +import { getPlanningArtifactsPath } from '../bmadConfig'; + +describe('epicTreeProvider', () => { + let provider: EpicTreeProvider; + + beforeEach(() => { + resetAllMocks(); + provider = new EpicTreeProvider(); + + // Default workspace setup + workspace.workspaceFolders = [ + { uri: Uri.file('/workspace'), name: 'workspace', index: 0 } + ]; + }); + + afterEach(() => { + provider.dispose(); + }); + + describe('5.1.1 - tree item creation from parsed epics', () => { + it('creates file tree items from parsed files', async () => { + const epicContent = `## Epic 1: Test Epic + +### Story 1.1: Test Story +**Status:** ready +`; + vi.mocked(getPlanningArtifactsPath).mockResolvedValue('_bmad-output/planning'); + vi.mocked(workspace.findFiles).mockResolvedValue([ + Uri.file('/workspace/_bmad-output/planning/epics.md') + ]); + vi.mocked(workspace.fs.readFile).mockResolvedValue( + new TextEncoder().encode(epicContent) + ); + + const children = await provider.getChildren(); + + expect(children).toHaveLength(1); + expect(children[0].type).toBe('file'); + expect(children[0].filePath).toBe('/workspace/_bmad-output/planning/epics.md'); + }); + + it('creates epic tree items from file children', async () => { + const epicContent = `## Epic 1: First Epic + +### Story 1.1: Story One +**Status:** ready + +## Epic 2: Second Epic + +### Story 2.1: Story Two +**Status:** done +`; + vi.mocked(getPlanningArtifactsPath).mockResolvedValue('_bmad-output/planning'); + vi.mocked(workspace.findFiles).mockResolvedValue([ + Uri.file('/workspace/_bmad-output/planning/epics.md') + ]); + vi.mocked(workspace.fs.readFile).mockResolvedValue( + new TextEncoder().encode(epicContent) + ); + + const files = await provider.getChildren(); + const epics = await provider.getChildren(files[0]); + + expect(epics).toHaveLength(2); + expect(epics[0].type).toBe('epic'); + expect(epics[1].type).toBe('epic'); + }); + + it('creates story tree items from epic children', async () => { + const epicContent = `## Epic 1: Test Epic + +### Story 1.1: First Story +**Status:** ready + +### Story 1.2: Second Story +**Status:** in-progress +`; + vi.mocked(getPlanningArtifactsPath).mockResolvedValue('_bmad-output/planning'); + vi.mocked(workspace.findFiles).mockResolvedValue([ + Uri.file('/workspace/_bmad-output/planning/epics.md') + ]); + vi.mocked(workspace.fs.readFile).mockResolvedValue( + new TextEncoder().encode(epicContent) + ); + + const files = await provider.getChildren(); + const epics = await provider.getChildren(files[0]); + const stories = await provider.getChildren(epics[0]); + + expect(stories).toHaveLength(2); + expect(stories[0].type).toBe('story'); + expect(stories[1].type).toBe('story'); + }); + + it('returns empty array when no workspace folders', async () => { + // Create a fresh provider for this test to avoid cached state + const freshProvider = new EpicTreeProvider(); + workspace.workspaceFolders = undefined; + vi.mocked(getPlanningArtifactsPath).mockResolvedValue(null); + vi.mocked(workspace.findFiles).mockResolvedValue([]); + + const children = await freshProvider.getChildren(); + + expect(children).toHaveLength(0); + freshProvider.dispose(); + }); + + it('returns empty array when no epic files found', async () => { + vi.mocked(getPlanningArtifactsPath).mockResolvedValue('_bmad-output/planning'); + vi.mocked(workspace.findFiles).mockResolvedValue([]); + + const children = await provider.getChildren(); + + expect(children).toHaveLength(0); + }); + }); + + describe('5.1.2 - hierarchical structure (file -> epic -> story)', () => { + it('file items are expanded by default', async () => { + const epicContent = `## Epic 1: Test Epic + +### Story 1.1: Test Story +**Status:** ready +`; + vi.mocked(getPlanningArtifactsPath).mockResolvedValue('_bmad-output/planning'); + vi.mocked(workspace.findFiles).mockResolvedValue([ + Uri.file('/workspace/epics.md') + ]); + vi.mocked(workspace.fs.readFile).mockResolvedValue( + new TextEncoder().encode(epicContent) + ); + + const files = await provider.getChildren(); + + expect(files[0].collapsibleState).toBe(TreeItemCollapsibleState.Expanded); + }); + + it('epic items are collapsed by default', async () => { + const epicContent = `## Epic 1: Test Epic + +### Story 1.1: Test Story +**Status:** ready +`; + vi.mocked(getPlanningArtifactsPath).mockResolvedValue('_bmad-output/planning'); + vi.mocked(workspace.findFiles).mockResolvedValue([ + Uri.file('/workspace/epics.md') + ]); + vi.mocked(workspace.fs.readFile).mockResolvedValue( + new TextEncoder().encode(epicContent) + ); + + const files = await provider.getChildren(); + const epics = await provider.getChildren(files[0]); + + expect(epics[0].collapsibleState).toBe(TreeItemCollapsibleState.Collapsed); + }); + + it('story items are not collapsible', async () => { + const epicContent = `## Epic 1: Test Epic + +### Story 1.1: Test Story +**Status:** ready +`; + vi.mocked(getPlanningArtifactsPath).mockResolvedValue('_bmad-output/planning'); + vi.mocked(workspace.findFiles).mockResolvedValue([ + Uri.file('/workspace/epics.md') + ]); + vi.mocked(workspace.fs.readFile).mockResolvedValue( + new TextEncoder().encode(epicContent) + ); + + const files = await provider.getChildren(); + const epics = await provider.getChildren(files[0]); + const stories = await provider.getChildren(epics[0]); + + expect(stories[0].collapsibleState).toBe(TreeItemCollapsibleState.None); + }); + + it('returns empty array for story children', async () => { + const epicContent = `## Epic 1: Test Epic + +### Story 1.1: Test Story +**Status:** ready +`; + vi.mocked(getPlanningArtifactsPath).mockResolvedValue('_bmad-output/planning'); + vi.mocked(workspace.findFiles).mockResolvedValue([ + Uri.file('/workspace/epics.md') + ]); + vi.mocked(workspace.fs.readFile).mockResolvedValue( + new TextEncoder().encode(epicContent) + ); + + const files = await provider.getChildren(); + const epics = await provider.getChildren(files[0]); + const stories = await provider.getChildren(epics[0]); + const storyChildren = await provider.getChildren(stories[0]); + + expect(storyChildren).toHaveLength(0); + }); + + it('preserves file path through hierarchy', async () => { + const epicContent = `## Epic 1: Test Epic + +### Story 1.1: Test Story +**Status:** ready +`; + const filePath = '/workspace/_bmad-output/epics.md'; + vi.mocked(getPlanningArtifactsPath).mockResolvedValue('_bmad-output'); + vi.mocked(workspace.findFiles).mockResolvedValue([ + Uri.file(filePath) + ]); + vi.mocked(workspace.fs.readFile).mockResolvedValue( + new TextEncoder().encode(epicContent) + ); + + const files = await provider.getChildren(); + const epics = await provider.getChildren(files[0]); + const stories = await provider.getChildren(epics[0]); + + expect(files[0].filePath).toBe(filePath); + expect(epics[0].filePath).toBe(filePath); + expect(stories[0].filePath).toBe(filePath); + }); + }); + + describe('5.1.3 - status icon assignment', () => { + it('assigns green new-file icon for ready status', async () => { + const epicContent = `## Epic 1: Test Epic + +### Story 1.1: Ready Story +**Status:** ready +`; + vi.mocked(getPlanningArtifactsPath).mockResolvedValue('_bmad-output/planning'); + vi.mocked(workspace.findFiles).mockResolvedValue([ + Uri.file('/workspace/epics.md') + ]); + vi.mocked(workspace.fs.readFile).mockResolvedValue( + new TextEncoder().encode(epicContent) + ); + + const files = await provider.getChildren(); + const epics = await provider.getChildren(files[0]); + const stories = await provider.getChildren(epics[0]); + const treeItem = provider.getTreeItem(stories[0]); + + expect(treeItem.iconPath).toBeInstanceOf(ThemeIcon); + const icon = treeItem.iconPath as ThemeIcon; + expect(icon.id).toBe('new-file'); + expect(icon.color).toBeInstanceOf(ThemeColor); + expect((icon.color as ThemeColor).id).toBe('charts.green'); + }); + + it('assigns blue arrow icon for in-progress status', async () => { + const epicContent = `## Epic 1: Test Epic + +### Story 1.1: In Progress Story +**Status:** in-progress +`; + vi.mocked(getPlanningArtifactsPath).mockResolvedValue('_bmad-output/planning'); + vi.mocked(workspace.findFiles).mockResolvedValue([ + Uri.file('/workspace/epics.md') + ]); + vi.mocked(workspace.fs.readFile).mockResolvedValue( + new TextEncoder().encode(epicContent) + ); + + const files = await provider.getChildren(); + const epics = await provider.getChildren(files[0]); + const stories = await provider.getChildren(epics[0]); + const treeItem = provider.getTreeItem(stories[0]); + + const icon = treeItem.iconPath as ThemeIcon; + expect(icon.id).toBe('arrow-right'); + expect((icon.color as ThemeColor).id).toBe('charts.blue'); + }); + + it('assigns green check icon for done status', async () => { + const epicContent = `## Epic 1: Test Epic + +### Story 1.1: Done Story +**Status:** done +`; + vi.mocked(getPlanningArtifactsPath).mockResolvedValue('_bmad-output/planning'); + vi.mocked(workspace.findFiles).mockResolvedValue([ + Uri.file('/workspace/epics.md') + ]); + vi.mocked(workspace.fs.readFile).mockResolvedValue( + new TextEncoder().encode(epicContent) + ); + + const files = await provider.getChildren(); + const epics = await provider.getChildren(files[0]); + const stories = await provider.getChildren(epics[0]); + const treeItem = provider.getTreeItem(stories[0]); + + const icon = treeItem.iconPath as ThemeIcon; + expect(icon.id).toBe('check'); + expect((icon.color as ThemeColor).id).toBe('charts.green'); + }); + + it('assigns red error icon for blocked status', async () => { + const epicContent = `## Epic 1: Test Epic + +### Story 1.1: Blocked Story +**Status:** blocked +`; + vi.mocked(getPlanningArtifactsPath).mockResolvedValue('_bmad-output/planning'); + vi.mocked(workspace.findFiles).mockResolvedValue([ + Uri.file('/workspace/epics.md') + ]); + vi.mocked(workspace.fs.readFile).mockResolvedValue( + new TextEncoder().encode(epicContent) + ); + + const files = await provider.getChildren(); + const epics = await provider.getChildren(files[0]); + const stories = await provider.getChildren(epics[0]); + const treeItem = provider.getTreeItem(stories[0]); + + const icon = treeItem.iconPath as ThemeIcon; + expect(icon.id).toBe('error'); + expect((icon.color as ThemeColor).id).toBe('charts.red'); + }); + + it('assigns question icon for unknown status', async () => { + const epicContent = `## Epic 1: Test Epic + +### Story 1.1: Unknown Story +**Status:** something-else +`; + vi.mocked(getPlanningArtifactsPath).mockResolvedValue('_bmad-output/planning'); + vi.mocked(workspace.findFiles).mockResolvedValue([ + Uri.file('/workspace/epics.md') + ]); + vi.mocked(workspace.fs.readFile).mockResolvedValue( + new TextEncoder().encode(epicContent) + ); + + const files = await provider.getChildren(); + const epics = await provider.getChildren(files[0]); + const stories = await provider.getChildren(epics[0]); + const treeItem = provider.getTreeItem(stories[0]); + + const icon = treeItem.iconPath as ThemeIcon; + expect(icon.id).toBe('question'); + }); + + it('assigns file-text icon for file items', async () => { + const epicContent = `## Epic 1: Test Epic + +### Story 1.1: Test Story +**Status:** ready +`; + vi.mocked(getPlanningArtifactsPath).mockResolvedValue('_bmad-output/planning'); + vi.mocked(workspace.findFiles).mockResolvedValue([ + Uri.file('/workspace/epics.md') + ]); + vi.mocked(workspace.fs.readFile).mockResolvedValue( + new TextEncoder().encode(epicContent) + ); + + const files = await provider.getChildren(); + const treeItem = provider.getTreeItem(files[0]); + + const icon = treeItem.iconPath as ThemeIcon; + expect(icon.id).toBe('file-text'); + }); + + it('assigns symbol-class icon for epic items', async () => { + const epicContent = `## Epic 1: Test Epic + +### Story 1.1: Test Story +**Status:** ready +`; + vi.mocked(getPlanningArtifactsPath).mockResolvedValue('_bmad-output/planning'); + vi.mocked(workspace.findFiles).mockResolvedValue([ + Uri.file('/workspace/epics.md') + ]); + vi.mocked(workspace.fs.readFile).mockResolvedValue( + new TextEncoder().encode(epicContent) + ); + + const files = await provider.getChildren(); + const epics = await provider.getChildren(files[0]); + const treeItem = provider.getTreeItem(epics[0]); + + const icon = treeItem.iconPath as ThemeIcon; + expect(icon.id).toBe('symbol-class'); + }); + }); + + describe('5.1.4 - race condition handling', () => { + it('handles concurrent loadFiles calls without error', async () => { + // This test verifies that concurrent calls don't crash + // The race condition handling sets pendingRefresh internally + const epicContent = `## Epic 1: Test Epic + +### Story 1.1: Test Story +**Status:** ready +`; + // Use a fresh provider to avoid cached state + const freshProvider = new EpicTreeProvider(); + + vi.mocked(getPlanningArtifactsPath).mockResolvedValue('_bmad-output/planning'); + vi.mocked(workspace.findFiles).mockResolvedValue([ + Uri.file('/workspace/epics.md') + ]); + vi.mocked(workspace.fs.readFile).mockResolvedValue( + new TextEncoder().encode(epicContent) + ); + + // Start multiple concurrent loads + const load1 = freshProvider.getChildren(); + const load2 = freshProvider.getChildren(); + const load3 = freshProvider.getChildren(); + + // All should complete without error + const results = await Promise.all([load1, load2, load3]); + + // After all loads complete, we should have consistent results + // The actual number depends on timing, but should not throw + results.forEach(result => { + expect(Array.isArray(result)).toBe(true); + }); + + freshProvider.dispose(); + }); + + it('debounces refresh calls', async () => { + vi.useFakeTimers(); + + const epicContent = `## Epic 1: Test Epic + +### Story 1.1: Test Story +**Status:** ready +`; + vi.mocked(getPlanningArtifactsPath).mockResolvedValue('_bmad-output/planning'); + vi.mocked(workspace.findFiles).mockResolvedValue([ + Uri.file('/workspace/epics.md') + ]); + vi.mocked(workspace.fs.readFile).mockResolvedValue( + new TextEncoder().encode(epicContent) + ); + + // Trigger multiple rapid refreshes + provider.refresh(); + provider.refresh(); + provider.refresh(); + + // Advance timer past debounce period + vi.advanceTimersByTime(400); + + vi.useRealTimers(); + }); + }); + + describe('5.1.5 - file watcher integration', () => { + // Note: File watcher is not directly on the provider, it's typically + // set up in extension.ts. These tests verify the refresh mechanism works. + + it('refresh method triggers tree data change event', async () => { + vi.useFakeTimers(); + + const eventFired = vi.fn(); + provider.onDidChangeTreeData(eventFired); + + provider.refresh(); + + // Advance past debounce + vi.advanceTimersByTime(400); + + expect(eventFired).toHaveBeenCalled(); + + vi.useRealTimers(); + }); + + it('dispose clears refresh timeout', () => { + vi.useFakeTimers(); + + provider.refresh(); + + // Dispose before timeout fires + provider.dispose(); + + // Advance timer - should not throw + vi.advanceTimersByTime(400); + + vi.useRealTimers(); + }); + }); + + describe('5.1.6 - error handling for unreadable files', () => { + it('continues processing when one file fails to read', async () => { + const epicContent = `## Epic 1: Test Epic + +### Story 1.1: Test Story +**Status:** ready +`; + vi.mocked(getPlanningArtifactsPath).mockResolvedValue('_bmad-output/planning'); + vi.mocked(workspace.findFiles).mockResolvedValue([ + Uri.file('/workspace/bad-file.md'), + Uri.file('/workspace/good-file.md') + ]); + + // First file fails, second succeeds + vi.mocked(workspace.fs.readFile) + .mockRejectedValueOnce(new Error('Permission denied')) + .mockResolvedValueOnce(new TextEncoder().encode(epicContent)); + + const children = await provider.getChildren(); + + // Should still have one file (the good one) + expect(children).toHaveLength(1); + }); + + it('returns empty array when all files fail to read', async () => { + vi.mocked(getPlanningArtifactsPath).mockResolvedValue('_bmad-output/planning'); + vi.mocked(workspace.findFiles).mockResolvedValue([ + Uri.file('/workspace/bad-file1.md'), + Uri.file('/workspace/bad-file2.md') + ]); + + vi.mocked(workspace.fs.readFile) + .mockRejectedValue(new Error('Permission denied')); + + const children = await provider.getChildren(); + + expect(children).toHaveLength(0); + }); + + it('returns empty array when findFiles throws', async () => { + vi.mocked(getPlanningArtifactsPath).mockResolvedValue('_bmad-output/planning'); + vi.mocked(workspace.findFiles).mockRejectedValue(new Error('Workspace error')); + + const children = await provider.getChildren(); + + expect(children).toHaveLength(0); + }); + + it('falls back to VS Code setting when config not found', async () => { + vi.mocked(getPlanningArtifactsPath).mockResolvedValue(null); + vi.mocked(workspace.findFiles).mockResolvedValue([]); + + await provider.getChildren(); + + // Should have called findFiles with fallback pattern + expect(workspace.findFiles).toHaveBeenCalled(); + }); + }); + + describe('getTreeItem', () => { + it('sets tooltip for file items', async () => { + const epicContent = `## Epic 1: Test Epic + +### Story 1.1: Test Story +**Status:** ready +`; + vi.mocked(getPlanningArtifactsPath).mockResolvedValue('_bmad-output/planning'); + vi.mocked(workspace.findFiles).mockResolvedValue([ + Uri.file('/workspace/epics.md') + ]); + vi.mocked(workspace.fs.readFile).mockResolvedValue( + new TextEncoder().encode(epicContent) + ); + + const files = await provider.getChildren(); + const treeItem = provider.getTreeItem(files[0]); + + expect(treeItem.tooltip).toBe('/workspace/epics.md'); + }); + + it('sets tooltip for epic items', async () => { + const epicContent = `## Epic 1: Test Epic + +### Story 1.1: Test Story +**Status:** ready +`; + vi.mocked(getPlanningArtifactsPath).mockResolvedValue('_bmad-output/planning'); + vi.mocked(workspace.findFiles).mockResolvedValue([ + Uri.file('/workspace/epics.md') + ]); + vi.mocked(workspace.fs.readFile).mockResolvedValue( + new TextEncoder().encode(epicContent) + ); + + const files = await provider.getChildren(); + const epics = await provider.getChildren(files[0]); + const treeItem = provider.getTreeItem(epics[0]); + + expect(treeItem.tooltip).toBe('Epic 1: Test Epic'); + }); + + it('sets tooltip with status for story items', async () => { + const epicContent = `## Epic 1: Test Epic + +### Story 1.1: Test Story +**Status:** ready +`; + vi.mocked(getPlanningArtifactsPath).mockResolvedValue('_bmad-output/planning'); + vi.mocked(workspace.findFiles).mockResolvedValue([ + Uri.file('/workspace/epics.md') + ]); + vi.mocked(workspace.fs.readFile).mockResolvedValue( + new TextEncoder().encode(epicContent) + ); + + const files = await provider.getChildren(); + const epics = await provider.getChildren(files[0]); + const stories = await provider.getChildren(epics[0]); + const treeItem = provider.getTreeItem(stories[0]); + + expect(treeItem.tooltip).toBe('Story 1.1: Test Story (ready)'); + }); + + it('sets command for story items', async () => { + const epicContent = `## Epic 1: Test Epic + +### Story 1.1: Test Story +**Status:** ready +`; + vi.mocked(getPlanningArtifactsPath).mockResolvedValue('_bmad-output/planning'); + vi.mocked(workspace.findFiles).mockResolvedValue([ + Uri.file('/workspace/epics.md') + ]); + vi.mocked(workspace.fs.readFile).mockResolvedValue( + new TextEncoder().encode(epicContent) + ); + + const files = await provider.getChildren(); + const epics = await provider.getChildren(files[0]); + const stories = await provider.getChildren(epics[0]); + const treeItem = provider.getTreeItem(stories[0]); + + expect(treeItem.command).toBeDefined(); + expect(treeItem.command?.command).toBe('bmadMethod.revealStory'); + expect(treeItem.command?.arguments).toContain('/workspace/epics.md'); + }); + }); +}); diff --git a/src/__tests__/epicsParser.test.ts b/src/__tests__/epicsParser.test.ts new file mode 100644 index 0000000..e058671 --- /dev/null +++ b/src/__tests__/epicsParser.test.ts @@ -0,0 +1,679 @@ +import { describe, it, expect } from 'vitest'; +import { + parseEpicsFromText, + EPIC_HEADER_PATTERN, + STORY_HEADER_PATTERN, + STATUS_PATTERN, + ParsedFile, + ParsedEpic, + ParsedStory +} from '../epicsParser'; + +describe('epicsParser', () => { + const TEST_FILE_PATH = '/test/epics.md'; + + describe('regex patterns', () => { + describe('EPIC_HEADER_PATTERN', () => { + it('matches valid epic header', () => { + const match = '## Epic 1: Core Features'.match(EPIC_HEADER_PATTERN); + expect(match).not.toBeNull(); + expect(match![1]).toBe('1'); + expect(match![2]).toBe('Core Features'); + }); + + it('matches epic with multi-digit number', () => { + const match = '## Epic 12: Advanced Features'.match(EPIC_HEADER_PATTERN); + expect(match).not.toBeNull(); + expect(match![1]).toBe('12'); + }); + + it('does not match without Epic keyword', () => { + expect('## 1: Core Features'.match(EPIC_HEADER_PATTERN)).toBeNull(); + }); + + it('does not match with wrong heading level', () => { + expect('# Epic 1: Core Features'.match(EPIC_HEADER_PATTERN)).toBeNull(); + expect('### Epic 1: Core Features'.match(EPIC_HEADER_PATTERN)).toBeNull(); + }); + + it('does not match without colon', () => { + expect('## Epic 1 Core Features'.match(EPIC_HEADER_PATTERN)).toBeNull(); + }); + }); + + describe('STORY_HEADER_PATTERN', () => { + it('matches valid story header', () => { + const match = '### Story 1.1: User Login'.match(STORY_HEADER_PATTERN); + expect(match).not.toBeNull(); + expect(match![1]).toBe('1.1'); + expect(match![2]).toBe('User Login'); + }); + + it('matches story with multi-digit numbers', () => { + const match = '### Story 12.34: Complex Feature'.match(STORY_HEADER_PATTERN); + expect(match).not.toBeNull(); + expect(match![1]).toBe('12.34'); + }); + + it('does not match without Story keyword', () => { + expect('### 1.1: User Login'.match(STORY_HEADER_PATTERN)).toBeNull(); + }); + + it('does not match with wrong heading level', () => { + expect('## Story 1.1: User Login'.match(STORY_HEADER_PATTERN)).toBeNull(); + expect('#### Story 1.1: User Login'.match(STORY_HEADER_PATTERN)).toBeNull(); + }); + + it('does not match story number without dot', () => { + expect('### Story 1: User Login'.match(STORY_HEADER_PATTERN)).toBeNull(); + }); + }); + + describe('STATUS_PATTERN', () => { + it('matches valid status line', () => { + const match = '**Status:** ready'.match(STATUS_PATTERN); + expect(match).not.toBeNull(); + expect(match![1]).toBe('ready'); + }); + + it('matches status with different values', () => { + expect('**Status:** in-progress'.match(STATUS_PATTERN)![1]).toBe('in-progress'); + expect('**Status:** done'.match(STATUS_PATTERN)![1]).toBe('done'); + expect('**Status:** blocked'.match(STATUS_PATTERN)![1]).toBe('blocked'); + }); + + it('does not match without bold markers', () => { + expect('Status: ready'.match(STATUS_PATTERN)).toBeNull(); + }); + }); + }); + + describe('parseEpicsFromText', () => { + describe('2.1.1 - valid epic/story structure parsing', () => { + it('parses single epic with single story', () => { + const text = `## Epic 1: Core Features + +### Story 1.1: User Login +**Status:** ready +`; + const result = parseEpicsFromText(text, TEST_FILE_PATH); + + expect(result.filePath).toBe(TEST_FILE_PATH); + expect(result.epics).toHaveLength(1); + expect(result.epics[0].epicNumber).toBe(1); + expect(result.epics[0].epicTitle).toBe('Core Features'); + expect(result.epics[0].stories).toHaveLength(1); + expect(result.epics[0].stories[0].storyNumber).toBe('1.1'); + expect(result.epics[0].stories[0].storyTitle).toBe('User Login'); + expect(result.epics[0].stories[0].status).toBe('ready'); + }); + + it('parses single epic with multiple stories', () => { + const text = `## Epic 1: Core Features + +### Story 1.1: User Login +**Status:** done + +### Story 1.2: User Registration +**Status:** in-progress + +### Story 1.3: Password Reset +**Status:** ready +`; + const result = parseEpicsFromText(text, TEST_FILE_PATH); + + expect(result.epics).toHaveLength(1); + expect(result.epics[0].stories).toHaveLength(3); + expect(result.epics[0].stories[0].storyNumber).toBe('1.1'); + expect(result.epics[0].stories[1].storyNumber).toBe('1.2'); + expect(result.epics[0].stories[2].storyNumber).toBe('1.3'); + }); + + it('parses multiple epics with multiple stories', () => { + const text = `## Epic 1: Authentication + +### Story 1.1: Login +**Status:** done + +### Story 1.2: Logout +**Status:** done + +## Epic 2: Dashboard + +### Story 2.1: Overview Page +**Status:** in-progress + +### Story 2.2: Analytics +**Status:** ready +`; + const result = parseEpicsFromText(text, TEST_FILE_PATH); + + expect(result.epics).toHaveLength(2); + + expect(result.epics[0].epicNumber).toBe(1); + expect(result.epics[0].epicTitle).toBe('Authentication'); + expect(result.epics[0].stories).toHaveLength(2); + + expect(result.epics[1].epicNumber).toBe(2); + expect(result.epics[1].epicTitle).toBe('Dashboard'); + expect(result.epics[1].stories).toHaveLength(2); + }); + + it('parses epic with title containing special characters', () => { + const text = `## Epic 1: User Auth & Session (v2.0) + +### Story 1.1: OAuth 2.0 Integration +**Status:** ready +`; + const result = parseEpicsFromText(text, TEST_FILE_PATH); + + expect(result.epics[0].epicTitle).toBe('User Auth & Session (v2.0)'); + expect(result.epics[0].stories[0].storyTitle).toBe('OAuth 2.0 Integration'); + }); + + it('trims whitespace from titles', () => { + const text = `## Epic 1: Spaced Title + +### Story 1.1: Story With Spaces +**Status:** ready +`; + const result = parseEpicsFromText(text, TEST_FILE_PATH); + + expect(result.epics[0].epicTitle).toBe('Spaced Title'); + expect(result.epics[0].stories[0].storyTitle).toBe('Story With Spaces'); + }); + }); + + describe('2.1.2 - invalid epic numbers', () => { + it('skips epic with non-numeric number in header', () => { + // Note: The regex requires \d+ so non-numeric won't match at all + const text = `## Epic ABC: Invalid Epic + +### Story ABC.1: Some Story +**Status:** ready + +## Epic 1: Valid Epic + +### Story 1.1: Valid Story +**Status:** ready +`; + const result = parseEpicsFromText(text, TEST_FILE_PATH); + + // Only the valid epic should be parsed + expect(result.epics).toHaveLength(1); + expect(result.epics[0].epicNumber).toBe(1); + }); + + it('handles epic with zero as number', () => { + const text = `## Epic 0: Zero Epic + +### Story 0.1: Zero Story +**Status:** ready +`; + const result = parseEpicsFromText(text, TEST_FILE_PATH); + + // Epic 0 should be rejected (epicNumber < 0 check catches === 0 incorrectly, + // but the code uses < 0 so 0 is actually accepted) + // Looking at the code: if (isNaN(epicNumber) || epicNumber < 0) + // So epicNumber 0 will pass the check + expect(result.epics).toHaveLength(1); + expect(result.epics[0].epicNumber).toBe(0); + }); + + it('parses epic with very large number', () => { + const text = `## Epic 9999: Large Number Epic + +### Story 9999.1: Story +**Status:** ready +`; + const result = parseEpicsFromText(text, TEST_FILE_PATH); + + expect(result.epics).toHaveLength(1); + expect(result.epics[0].epicNumber).toBe(9999); + }); + }); + + describe('2.1.3 - stories without parent epic', () => { + it('handles story appearing before any epic', () => { + const text = `### Story 1.1: Orphan Story +**Status:** ready + +## Epic 1: First Epic + +### Story 1.2: Valid Story +**Status:** ready +`; + const result = parseEpicsFromText(text, TEST_FILE_PATH); + + // The orphan story should be logged but not crash + // Only the epic should be in results, with its one valid story + expect(result.epics).toHaveLength(1); + expect(result.epics[0].stories).toHaveLength(1); + expect(result.epics[0].stories[0].storyNumber).toBe('1.2'); + }); + + it('handles file with only stories and no epics', () => { + const text = `### Story 1.1: First Story +**Status:** ready + +### Story 1.2: Second Story +**Status:** done +`; + const result = parseEpicsFromText(text, TEST_FILE_PATH); + + // No epics should be parsed - stories without epics are orphans + expect(result.epics).toHaveLength(0); + }); + }); + + describe('2.1.4 - status variations', () => { + it('parses "ready" status', () => { + const text = `## Epic 1: Test + +### Story 1.1: Test +**Status:** ready +`; + const result = parseEpicsFromText(text, TEST_FILE_PATH); + expect(result.epics[0].stories[0].status).toBe('ready'); + }); + + it('parses "in-progress" status', () => { + const text = `## Epic 1: Test + +### Story 1.1: Test +**Status:** in-progress +`; + const result = parseEpicsFromText(text, TEST_FILE_PATH); + expect(result.epics[0].stories[0].status).toBe('in-progress'); + }); + + it('parses "done" status', () => { + const text = `## Epic 1: Test + +### Story 1.1: Test +**Status:** done +`; + const result = parseEpicsFromText(text, TEST_FILE_PATH); + expect(result.epics[0].stories[0].status).toBe('done'); + }); + + it('parses "blocked" status', () => { + const text = `## Epic 1: Test + +### Story 1.1: Test +**Status:** blocked +`; + const result = parseEpicsFromText(text, TEST_FILE_PATH); + expect(result.epics[0].stories[0].status).toBe('blocked'); + }); + + it('parses "draft" status', () => { + const text = `## Epic 1: Test + +### Story 1.1: Test +**Status:** draft +`; + const result = parseEpicsFromText(text, TEST_FILE_PATH); + expect(result.epics[0].stories[0].status).toBe('draft'); + }); + + it('converts status to lowercase', () => { + const text = `## Epic 1: Test + +### Story 1.1: Test +**Status:** READY +`; + const result = parseEpicsFromText(text, TEST_FILE_PATH); + expect(result.epics[0].stories[0].status).toBe('ready'); + }); + + it('converts mixed case status to lowercase', () => { + const text = `## Epic 1: Test + +### Story 1.1: Test +**Status:** In-Progress +`; + const result = parseEpicsFromText(text, TEST_FILE_PATH); + expect(result.epics[0].stories[0].status).toBe('in-progress'); + }); + + it('defaults to "unknown" when status line is missing', () => { + const text = `## Epic 1: Test + +### Story 1.1: Test Without Status + +### Story 1.2: Next Story +**Status:** ready +`; + const result = parseEpicsFromText(text, TEST_FILE_PATH); + expect(result.epics[0].stories[0].status).toBe('unknown'); + expect(result.epics[0].stories[1].status).toBe('ready'); + }); + + it('handles custom/unknown status values', () => { + const text = `## Epic 1: Test + +### Story 1.1: Test +**Status:** custom-status +`; + const result = parseEpicsFromText(text, TEST_FILE_PATH); + expect(result.epics[0].stories[0].status).toBe('custom-status'); + }); + }); + + describe('2.1.5 - malformed markdown', () => { + it('handles missing header markers', () => { + const text = `Epic 1: No Hash Marks + +Story 1.1: Also No Hash +**Status:** ready + +## Epic 2: Valid Epic + +### Story 2.1: Valid Story +**Status:** ready +`; + const result = parseEpicsFromText(text, TEST_FILE_PATH); + + // Only valid epic should be parsed + expect(result.epics).toHaveLength(1); + expect(result.epics[0].epicNumber).toBe(2); + }); + + it('handles broken status format', () => { + const text = `## Epic 1: Test + +### Story 1.1: Test +Status: ready + +### Story 1.2: Another +**Status:** done +`; + const result = parseEpicsFromText(text, TEST_FILE_PATH); + + // First story should have unknown status (no bold markers) + expect(result.epics[0].stories[0].status).toBe('unknown'); + expect(result.epics[0].stories[1].status).toBe('done'); + }); + + it('handles status on same line as story (should not match)', () => { + const text = `## Epic 1: Test + +### Story 1.1: Test **Status:** ready +`; + const result = parseEpicsFromText(text, TEST_FILE_PATH); + + // Status embedded in title line won't be parsed as status + expect(result.epics[0].stories[0].status).toBe('unknown'); + expect(result.epics[0].stories[0].storyTitle).toBe('Test **Status:** ready'); + }); + + it('handles extra whitespace in headers', () => { + // The regex /^##\s+Epic\s+(\d+):\s*(.+)$/ uses \s+ which matches multiple spaces + // So extra whitespace IS allowed and will match + const text = `## Epic 1: Test + +### Story 1.1: Test +**Status:** ready +`; + const result = parseEpicsFromText(text, TEST_FILE_PATH); + + // Extra whitespace is allowed by the regex pattern + expect(result.epics).toHaveLength(1); + expect(result.epics[0].epicNumber).toBe(1); + }); + + it('handles content between headers', () => { + const text = `## Epic 1: Test + +Some description text here. +More description. + +### Story 1.1: Test + +Story description here. +With multiple lines. + +**Status:** ready + +More content after status. +`; + const result = parseEpicsFromText(text, TEST_FILE_PATH); + + expect(result.epics).toHaveLength(1); + expect(result.epics[0].stories).toHaveLength(1); + expect(result.epics[0].stories[0].status).toBe('ready'); + }); + }); + + describe('2.1.6 - edge cases', () => { + it('handles empty file', () => { + const result = parseEpicsFromText('', TEST_FILE_PATH); + + expect(result.filePath).toBe(TEST_FILE_PATH); + expect(result.epics).toHaveLength(0); + }); + + it('handles file with only whitespace', () => { + const result = parseEpicsFromText(' \n\n \t\n ', TEST_FILE_PATH); + + expect(result.epics).toHaveLength(0); + }); + + it('handles file with no epics but other content', () => { + const text = `# Project Documentation + +This is a markdown file but has no epics. + +## Some Other Section + +Content here. +`; + const result = parseEpicsFromText(text, TEST_FILE_PATH); + + expect(result.epics).toHaveLength(0); + }); + + it('handles epic with no stories', () => { + const text = `## Epic 1: Empty Epic + +Some description but no stories. + +## Epic 2: Also Empty + +More description. +`; + const result = parseEpicsFromText(text, TEST_FILE_PATH); + + expect(result.epics).toHaveLength(2); + expect(result.epics[0].stories).toHaveLength(0); + expect(result.epics[1].stories).toHaveLength(0); + }); + + it('handles very long file', () => { + let text = ''; + for (let i = 1; i <= 100; i++) { + text += `## Epic ${i}: Epic Number ${i}\n\n`; + for (let j = 1; j <= 10; j++) { + text += `### Story ${i}.${j}: Story ${i}.${j}\n`; + text += `**Status:** ready\n\n`; + } + } + + const result = parseEpicsFromText(text, TEST_FILE_PATH); + + expect(result.epics).toHaveLength(100); + expect(result.epics[0].stories).toHaveLength(10); + expect(result.epics[99].stories).toHaveLength(10); + }); + + it('does not parse Windows line endings (CRLF) - known limitation', () => { + // The parser splits on \n but \r remains at end of line + // This breaks regex matching since patterns end with $ which won't match before \r + // This documents the current behavior - CRLF files won't parse correctly + const text = '## Epic 1: Test\r\n\r\n### Story 1.1: Test\r\n**Status:** ready\r\n'; + const result = parseEpicsFromText(text, TEST_FILE_PATH); + + // Current behavior: CRLF breaks parsing (the \r at line end prevents regex match) + expect(result.epics).toHaveLength(0); + }); + + it('handles file ending without newline', () => { + const text = `## Epic 1: Test + +### Story 1.1: Test +**Status:** ready`; + const result = parseEpicsFromText(text, TEST_FILE_PATH); + + expect(result.epics).toHaveLength(1); + expect(result.epics[0].stories[0].status).toBe('ready'); + }); + }); + + describe('2.1.7 - line number tracking accuracy', () => { + it('tracks epic line numbers correctly (0-based)', () => { + // Line 0: ## Epic 1: First Epic + // Line 1: (empty) + // Line 2: ### Story 1.1: First Story + // Line 3: **Status:** ready + // Line 4: (empty) + // Line 5: ## Epic 2: Second Epic + // Line 6: (empty) + // Line 7: ### Story 2.1: Second Story + // Line 8: **Status:** ready + const text = `## Epic 1: First Epic + +### Story 1.1: First Story +**Status:** ready + +## Epic 2: Second Epic + +### Story 2.1: Second Story +**Status:** ready +`; + const result = parseEpicsFromText(text, TEST_FILE_PATH); + + expect(result.epics[0].lineNumber).toBe(0); // Line 1 = index 0 + expect(result.epics[1].lineNumber).toBe(5); // Line 6 = index 5 + }); + + it('tracks story line numbers correctly (0-based)', () => { + const text = `## Epic 1: Test + +### Story 1.1: First +**Status:** ready + +### Story 1.2: Second +**Status:** ready + +### Story 1.3: Third +**Status:** ready +`; + const result = parseEpicsFromText(text, TEST_FILE_PATH); + + expect(result.epics[0].stories[0].lineNumber).toBe(2); // Line 3 = index 2 + expect(result.epics[0].stories[1].lineNumber).toBe(5); // Line 6 = index 5 + expect(result.epics[0].stories[2].lineNumber).toBe(8); // Line 9 = index 8 + }); + + it('tracks line numbers with blank lines correctly', () => { + const text = ` + +## Epic 1: Test + + + +### Story 1.1: Test + + +**Status:** ready +`; + const result = parseEpicsFromText(text, TEST_FILE_PATH); + + expect(result.epics[0].lineNumber).toBe(2); // After 2 blank lines + expect(result.epics[0].stories[0].lineNumber).toBe(6); // After 3 more blank lines + }); + + it('tracks line numbers in complex document', () => { + const text = `# Document Header + +Some preamble text. + +## Epic 1: First + +Description of epic. + +### Story 1.1: First Story + +Description of story. + +**Status:** ready + +## Epic 2: Second + +### Story 2.1: Second Story +**Status:** done +`; + const result = parseEpicsFromText(text, TEST_FILE_PATH); + + expect(result.epics[0].lineNumber).toBe(4); // "## Epic 1: First" + expect(result.epics[0].stories[0].lineNumber).toBe(8); // "### Story 1.1: First Story" + expect(result.epics[1].lineNumber).toBe(14); // "## Epic 2: Second" + expect(result.epics[1].stories[0].lineNumber).toBe(16); // "### Story 2.1: Second Story" + }); + }); + + describe('return structure', () => { + it('returns correct ParsedFile structure', () => { + const text = `## Epic 1: Test + +### Story 1.1: Test +**Status:** ready +`; + const result: ParsedFile = parseEpicsFromText(text, TEST_FILE_PATH); + + expect(result).toHaveProperty('filePath'); + expect(result).toHaveProperty('epics'); + expect(Array.isArray(result.epics)).toBe(true); + }); + + it('returns correct ParsedEpic structure', () => { + const text = `## Epic 1: Test + +### Story 1.1: Test +**Status:** ready +`; + const result = parseEpicsFromText(text, TEST_FILE_PATH); + const epic: ParsedEpic = result.epics[0]; + + expect(epic).toHaveProperty('epicNumber'); + expect(epic).toHaveProperty('epicTitle'); + expect(epic).toHaveProperty('lineNumber'); + expect(epic).toHaveProperty('stories'); + expect(typeof epic.epicNumber).toBe('number'); + expect(typeof epic.epicTitle).toBe('string'); + expect(typeof epic.lineNumber).toBe('number'); + expect(Array.isArray(epic.stories)).toBe(true); + }); + + it('returns correct ParsedStory structure', () => { + const text = `## Epic 1: Test + +### Story 1.1: Test +**Status:** ready +`; + const result = parseEpicsFromText(text, TEST_FILE_PATH); + const story: ParsedStory = result.epics[0].stories[0]; + + expect(story).toHaveProperty('storyNumber'); + expect(story).toHaveProperty('storyTitle'); + expect(story).toHaveProperty('status'); + expect(story).toHaveProperty('lineNumber'); + expect(typeof story.storyNumber).toBe('string'); + expect(typeof story.storyTitle).toBe('string'); + expect(typeof story.status).toBe('string'); + expect(typeof story.lineNumber).toBe('number'); + }); + }); + }); +}); diff --git a/src/__tests__/extension.test.ts b/src/__tests__/extension.test.ts new file mode 100644 index 0000000..b6c3911 --- /dev/null +++ b/src/__tests__/extension.test.ts @@ -0,0 +1,795 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import type { TextDocument } from 'vscode'; +import { + workspace, + window, + commands, + languages, + Uri, + Range, + Selection, + TextEditorRevealType, + createMockTextDocument, + resetAllMocks +} from './mocks/vscode'; + +// Helper to create typed mock documents +const mockDoc = (content: string, path?: string) => createMockTextDocument(content, path) as TextDocument; + +// Mock the providers +vi.mock('../storyCodeLensProvider', () => ({ + StoryCodeLensProvider: vi.fn().mockImplementation(() => ({ + refresh: vi.fn(), + dispose: vi.fn() + })) +})); + +vi.mock('../epicTreeProvider', () => ({ + EpicTreeProvider: vi.fn().mockImplementation(() => ({ + refresh: vi.fn(), + dispose: vi.fn() + })) +})); + +vi.mock('../techSpecTreeProvider', () => ({ + TechSpecTreeProvider: vi.fn().mockImplementation(() => ({ + refresh: vi.fn(), + dispose: vi.fn() + })) +})); + +vi.mock('../bmadConfig', () => ({ + getImplementationArtifactsPath: vi.fn().mockResolvedValue('_bmad-output/implementation') +})); + +vi.mock('../cliTool', () => ({ + normalizeCliTool: vi.fn((tool) => tool || 'claude'), + isSafeCliTool: vi.fn(() => true), + buildCliCommand: vi.fn((tool, workflow, storyNumber) => + storyNumber ? `${tool} /bmad:bmm:workflows:${workflow} ${storyNumber}` : `${tool} /bmad:bmm:workflows:${workflow}` + ), + getWhichCommand: vi.fn((platform, tool) => ({ + cmd: platform === 'win32' ? 'where' : 'which', + args: [tool] + })) +})); + +vi.mock('child_process', () => ({ + execFile: vi.fn((cmd, args, callback) => { + // Simulate CLI tool being available + callback(null); + }) +})); + +import { activate, deactivate } from '../extension'; +import { StoryCodeLensProvider } from '../storyCodeLensProvider'; +import { EpicTreeProvider } from '../epicTreeProvider'; +import { TechSpecTreeProvider } from '../techSpecTreeProvider'; +import { normalizeCliTool, isSafeCliTool, buildCliCommand } from '../cliTool'; + +describe('Extension', () => { + let mockContext: { + subscriptions: { dispose: () => void }[]; + }; + + beforeEach(() => { + resetAllMocks(); + + // Reset module state by clearing mock implementations + vi.mocked(StoryCodeLensProvider).mockClear(); + vi.mocked(EpicTreeProvider).mockClear(); + vi.mocked(TechSpecTreeProvider).mockClear(); + + // Reset cliTool mocks to default behavior + vi.mocked(normalizeCliTool).mockImplementation((tool) => tool || 'claude'); + vi.mocked(isSafeCliTool).mockReturnValue(true); + vi.mocked(buildCliCommand).mockImplementation((tool, workflow, storyNumber) => + storyNumber ? `${tool} /bmad:bmm:workflows:${workflow} ${storyNumber}` : `${tool} /bmad:bmm:workflows:${workflow}` + ); + + mockContext = { + subscriptions: [] + }; + + // Setup default workspace + workspace.workspaceFolders = [ + { uri: Uri.file('/workspace'), name: 'workspace', index: 0 } + ]; + + // Setup default config + vi.mocked(workspace.getConfiguration).mockReturnValue({ + get: vi.fn((key: string, defaultValue?: unknown) => { + if (key === 'epicFilePattern') return '**/*epic*.md'; + if (key === 'cliTool') return 'claude'; + return defaultValue; + }), + has: vi.fn(), + inspect: vi.fn(), + update: vi.fn() + } as unknown as ReturnType); + + // Setup mock terminal + const mockTerminal = { + name: 'Test Terminal', + show: vi.fn(), + sendText: vi.fn(), + dispose: vi.fn() + }; + vi.mocked(window.createTerminal).mockReturnValue(mockTerminal as unknown as ReturnType); + window.terminals = []; + }); + + afterEach(() => { + // Dispose all subscriptions + mockContext.subscriptions.forEach(sub => sub.dispose?.()); + }); + + describe('7.1.1 - All commands registered on activation', () => { + it('registers all expected commands', () => { + activate(mockContext as unknown as Parameters[0]); + + // Check that registerCommand was called for each command + const registerCalls = vi.mocked(commands.registerCommand).mock.calls; + const registeredCommands = registerCalls.map(call => call[0]); + + expect(registeredCommands).toContain('bmadMethod.createStory'); + expect(registeredCommands).toContain('bmadMethod.developStory'); + expect(registeredCommands).toContain('bmadMethod.refreshStories'); + expect(registeredCommands).toContain('bmadMethod.refreshTechSpecs'); + expect(registeredCommands).toContain('bmadMethod.revealStory'); + expect(registeredCommands).toContain('bmadMethod.revealTask'); + }); + + it('registers CodeLens provider for markdown files', () => { + activate(mockContext as unknown as Parameters[0]); + + expect(languages.registerCodeLensProvider).toHaveBeenCalledWith( + { language: 'markdown', scheme: 'file' }, + expect.any(Object) + ); + }); + + it('creates tree views for stories and tech specs', () => { + activate(mockContext as unknown as Parameters[0]); + + expect(window.createTreeView).toHaveBeenCalledWith('bmadStories', expect.any(Object)); + expect(window.createTreeView).toHaveBeenCalledWith('bmadTechSpecs', expect.any(Object)); + }); + + it('adds all disposables to context subscriptions', () => { + activate(mockContext as unknown as Parameters[0]); + + // Should have multiple subscriptions (providers, commands, watchers, etc.) + expect(mockContext.subscriptions.length).toBeGreaterThan(5); + }); + + it('creates file system watchers', () => { + activate(mockContext as unknown as Parameters[0]); + + // Should create watchers for epic files, tech specs, and config + expect(workspace.createFileSystemWatcher).toHaveBeenCalled(); + }); + }); + + describe('7.1.2 - bmadMethod.createStory terminal command construction', () => { + it('executes create-story command with story number', async () => { + activate(mockContext as unknown as Parameters[0]); + + // Find the createStory command handler + const registerCalls = vi.mocked(commands.registerCommand).mock.calls; + const createStoryCall = registerCalls.find(call => call[0] === 'bmadMethod.createStory'); + expect(createStoryCall).toBeDefined(); + + const handler = createStoryCall![1]; + await handler('1.1'); + + expect(buildCliCommand).toHaveBeenCalledWith('claude', 'create-story', '1.1'); + expect(window.createTerminal).toHaveBeenCalled(); + }); + + it('creates terminal with story number in name', async () => { + activate(mockContext as unknown as Parameters[0]); + + const registerCalls = vi.mocked(commands.registerCommand).mock.calls; + const createStoryCall = registerCalls.find(call => call[0] === 'bmadMethod.createStory'); + const handler = createStoryCall![1]; + await handler('2.5'); + + expect(window.createTerminal).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Story 2.5' + }) + ); + }); + + it('shows error when no workspace folder open', async () => { + workspace.workspaceFolders = undefined; + activate(mockContext as unknown as Parameters[0]); + + const registerCalls = vi.mocked(commands.registerCommand).mock.calls; + const createStoryCall = registerCalls.find(call => call[0] === 'bmadMethod.createStory'); + const handler = createStoryCall![1]; + await handler('1.1'); + + expect(window.showErrorMessage).toHaveBeenCalledWith( + expect.stringContaining('No workspace folder open') + ); + }); + + it('shows error for invalid CLI tool name', async () => { + vi.mocked(isSafeCliTool).mockReturnValue(false); + activate(mockContext as unknown as Parameters[0]); + + const registerCalls = vi.mocked(commands.registerCommand).mock.calls; + const createStoryCall = registerCalls.find(call => call[0] === 'bmadMethod.createStory'); + const handler = createStoryCall![1]; + await handler('1.1'); + + expect(window.showErrorMessage).toHaveBeenCalledWith( + expect.stringContaining('Invalid CLI tool name') + ); + }); + + it('reuses existing terminal with same name', async () => { + const existingTerminal = { + name: 'Story 1.1', + show: vi.fn(), + sendText: vi.fn(), + dispose: vi.fn() + }; + window.terminals = [existingTerminal as unknown as typeof window.terminals[0]]; + + activate(mockContext as unknown as Parameters[0]); + + const registerCalls = vi.mocked(commands.registerCommand).mock.calls; + const createStoryCall = registerCalls.find(call => call[0] === 'bmadMethod.createStory'); + const handler = createStoryCall![1]; + await handler('1.1'); + + // Should not create a new terminal + expect(window.createTerminal).not.toHaveBeenCalled(); + expect(existingTerminal.show).toHaveBeenCalled(); + expect(existingTerminal.sendText).toHaveBeenCalled(); + }); + }); + + describe('7.1.3 - bmadMethod.developStory terminal command construction', () => { + it('executes dev-story command with story number', async () => { + activate(mockContext as unknown as Parameters[0]); + + const registerCalls = vi.mocked(commands.registerCommand).mock.calls; + const developStoryCall = registerCalls.find(call => call[0] === 'bmadMethod.developStory'); + expect(developStoryCall).toBeDefined(); + + const handler = developStoryCall![1]; + await handler('3.2'); + + expect(buildCliCommand).toHaveBeenCalledWith('claude', 'dev-story', '3.2'); + }); + + it('creates terminal with story number in name', async () => { + activate(mockContext as unknown as Parameters[0]); + + const registerCalls = vi.mocked(commands.registerCommand).mock.calls; + const developStoryCall = registerCalls.find(call => call[0] === 'bmadMethod.developStory'); + const handler = developStoryCall![1]; + await handler('4.1'); + + expect(window.createTerminal).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Story 4.1' + }) + ); + }); + + it('uses configured CLI tool', async () => { + vi.mocked(workspace.getConfiguration).mockReturnValue({ + get: vi.fn((key: string, defaultValue?: unknown) => { + if (key === 'cliTool') return 'cursor'; + return defaultValue; + }), + has: vi.fn(), + inspect: vi.fn(), + update: vi.fn() + } as unknown as ReturnType); + vi.mocked(normalizeCliTool).mockReturnValue('cursor'); + + activate(mockContext as unknown as Parameters[0]); + + const registerCalls = vi.mocked(commands.registerCommand).mock.calls; + const developStoryCall = registerCalls.find(call => call[0] === 'bmadMethod.developStory'); + const handler = developStoryCall![1]; + await handler('1.1'); + + expect(buildCliCommand).toHaveBeenCalledWith('cursor', 'dev-story', '1.1'); + }); + }); + + describe('7.1.4 - bmadMethod.revealStory navigation with bounds checking', () => { + it('opens document and reveals line at specified position', async () => { + const mockDocument = mockDoc('Line 0\nLine 1\nLine 2\nLine 3\n', '/workspace/epics.md'); + vi.mocked(workspace.openTextDocument).mockResolvedValue(mockDocument as unknown as Awaited>); + + const mockEditor = { + selection: null as Selection | null, + revealRange: vi.fn() + }; + vi.mocked(window.showTextDocument).mockResolvedValue(mockEditor as unknown as Awaited>); + + activate(mockContext as unknown as Parameters[0]); + + const registerCalls = vi.mocked(commands.registerCommand).mock.calls; + const revealStoryCall = registerCalls.find(call => call[0] === 'bmadMethod.revealStory'); + const handler = revealStoryCall![1]; + await handler('/workspace/epics.md', 2); + + expect(workspace.openTextDocument).toHaveBeenCalledWith('/workspace/epics.md'); + expect(window.showTextDocument).toHaveBeenCalled(); + expect(mockEditor.revealRange).toHaveBeenCalledWith( + expect.any(Range), + TextEditorRevealType.InCenter + ); + }); + + it('shows warning for line number beyond document bounds', async () => { + const mockDocument = mockDoc('Line 0\nLine 1\n', '/workspace/epics.md'); + vi.mocked(workspace.openTextDocument).mockResolvedValue(mockDocument as unknown as Awaited>); + + const mockEditor = { + selection: null as Selection | null, + revealRange: vi.fn() + }; + vi.mocked(window.showTextDocument).mockResolvedValue(mockEditor as unknown as Awaited>); + + activate(mockContext as unknown as Parameters[0]); + + const registerCalls = vi.mocked(commands.registerCommand).mock.calls; + const revealStoryCall = registerCalls.find(call => call[0] === 'bmadMethod.revealStory'); + const handler = revealStoryCall![1]; + + // Line 100 is beyond the 2-line document + await handler('/workspace/epics.md', 100); + + expect(window.showWarningMessage).toHaveBeenCalledWith( + expect.stringContaining('not found in file') + ); + expect(mockEditor.revealRange).not.toHaveBeenCalled(); + }); + + it('shows warning for negative line number', async () => { + const mockDocument = mockDoc('Line 0\nLine 1\n', '/workspace/epics.md'); + vi.mocked(workspace.openTextDocument).mockResolvedValue(mockDocument as unknown as Awaited>); + + const mockEditor = { + selection: null as Selection | null, + revealRange: vi.fn() + }; + vi.mocked(window.showTextDocument).mockResolvedValue(mockEditor as unknown as Awaited>); + + activate(mockContext as unknown as Parameters[0]); + + const registerCalls = vi.mocked(commands.registerCommand).mock.calls; + const revealStoryCall = registerCalls.find(call => call[0] === 'bmadMethod.revealStory'); + const handler = revealStoryCall![1]; + + await handler('/workspace/epics.md', -1); + + expect(window.showWarningMessage).toHaveBeenCalledWith( + expect.stringContaining('not found in file') + ); + }); + + it('shows error when document cannot be opened', async () => { + vi.mocked(workspace.openTextDocument).mockRejectedValue(new Error('File not found')); + + activate(mockContext as unknown as Parameters[0]); + + const registerCalls = vi.mocked(commands.registerCommand).mock.calls; + const revealStoryCall = registerCalls.find(call => call[0] === 'bmadMethod.revealStory'); + const handler = revealStoryCall![1]; + + await handler('/nonexistent/file.md', 0); + + expect(window.showErrorMessage).toHaveBeenCalledWith( + expect.stringContaining('Failed to open story') + ); + }); + + it('sets editor selection to the revealed line', async () => { + const mockDocument = mockDoc('Line 0\nLine 1\nLine 2\n', '/workspace/epics.md'); + vi.mocked(workspace.openTextDocument).mockResolvedValue(mockDocument as unknown as Awaited>); + + const mockEditor = { + selection: null as Selection | null, + revealRange: vi.fn() + }; + vi.mocked(window.showTextDocument).mockResolvedValue(mockEditor as unknown as Awaited>); + + activate(mockContext as unknown as Parameters[0]); + + const registerCalls = vi.mocked(commands.registerCommand).mock.calls; + const revealStoryCall = registerCalls.find(call => call[0] === 'bmadMethod.revealStory'); + const handler = revealStoryCall![1]; + await handler('/workspace/epics.md', 1); + + expect(mockEditor.selection).not.toBeNull(); + }); + }); + + describe('7.1.5 - bmadMethod.revealTask navigation', () => { + it('opens document and reveals task at specified position', async () => { + const mockDocument = mockDoc('Task 0\nTask 1\nTask 2\n', '/workspace/tech-spec.md'); + vi.mocked(workspace.openTextDocument).mockResolvedValue(mockDocument as unknown as Awaited>); + + const mockEditor = { + selection: null as Selection | null, + revealRange: vi.fn() + }; + vi.mocked(window.showTextDocument).mockResolvedValue(mockEditor as unknown as Awaited>); + + activate(mockContext as unknown as Parameters[0]); + + const registerCalls = vi.mocked(commands.registerCommand).mock.calls; + const revealTaskCall = registerCalls.find(call => call[0] === 'bmadMethod.revealTask'); + const handler = revealTaskCall![1]; + await handler('/workspace/tech-spec.md', 1); + + expect(workspace.openTextDocument).toHaveBeenCalledWith('/workspace/tech-spec.md'); + expect(window.showTextDocument).toHaveBeenCalled(); + expect(mockEditor.revealRange).toHaveBeenCalledWith( + expect.any(Range), + TextEditorRevealType.InCenter + ); + }); + + it('shows warning for task line beyond document bounds', async () => { + const mockDocument = mockDoc('Task 0\n', '/workspace/tech-spec.md'); + vi.mocked(workspace.openTextDocument).mockResolvedValue(mockDocument as unknown as Awaited>); + + const mockEditor = { + selection: null as Selection | null, + revealRange: vi.fn() + }; + vi.mocked(window.showTextDocument).mockResolvedValue(mockEditor as unknown as Awaited>); + + activate(mockContext as unknown as Parameters[0]); + + const registerCalls = vi.mocked(commands.registerCommand).mock.calls; + const revealTaskCall = registerCalls.find(call => call[0] === 'bmadMethod.revealTask'); + const handler = revealTaskCall![1]; + + await handler('/workspace/tech-spec.md', 50); + + expect(window.showWarningMessage).toHaveBeenCalledWith( + expect.stringContaining('Task line') + ); + expect(mockEditor.revealRange).not.toHaveBeenCalled(); + }); + + it('shows error when task file cannot be opened', async () => { + vi.mocked(workspace.openTextDocument).mockRejectedValue(new Error('Permission denied')); + + activate(mockContext as unknown as Parameters[0]); + + const registerCalls = vi.mocked(commands.registerCommand).mock.calls; + const revealTaskCall = registerCalls.find(call => call[0] === 'bmadMethod.revealTask'); + const handler = revealTaskCall![1]; + + await handler('/protected/file.md', 0); + + expect(window.showErrorMessage).toHaveBeenCalledWith( + expect.stringContaining('Failed to open task') + ); + }); + }); + + describe('Refresh commands', () => { + it('refreshStories command triggers tree provider refresh', () => { + const mockTreeProvider = { + refresh: vi.fn(), + dispose: vi.fn() + }; + vi.mocked(EpicTreeProvider).mockImplementation(() => mockTreeProvider as unknown as InstanceType); + + activate(mockContext as unknown as Parameters[0]); + + const registerCalls = vi.mocked(commands.registerCommand).mock.calls; + const refreshCall = registerCalls.find(call => call[0] === 'bmadMethod.refreshStories'); + const handler = refreshCall![1]; + handler(); + + expect(mockTreeProvider.refresh).toHaveBeenCalled(); + }); + + it('refreshTechSpecs command triggers tech spec provider refresh', () => { + const mockTechSpecProvider = { + refresh: vi.fn(), + dispose: vi.fn() + }; + vi.mocked(TechSpecTreeProvider).mockImplementation(() => mockTechSpecProvider as unknown as InstanceType); + + activate(mockContext as unknown as Parameters[0]); + + const registerCalls = vi.mocked(commands.registerCommand).mock.calls; + const refreshCall = registerCalls.find(call => call[0] === 'bmadMethod.refreshTechSpecs'); + const handler = refreshCall![1]; + handler(); + + expect(mockTechSpecProvider.refresh).toHaveBeenCalled(); + }); + }); + + describe('Configuration change handling', () => { + it('refreshes providers when bmad configuration changes', () => { + const mockCodeLensProvider = { + refresh: vi.fn(), + dispose: vi.fn() + }; + const mockTreeProvider = { + refresh: vi.fn(), + dispose: vi.fn() + }; + const mockTechSpecProvider = { + refresh: vi.fn(), + dispose: vi.fn() + }; + + vi.mocked(StoryCodeLensProvider).mockImplementation(() => mockCodeLensProvider as unknown as InstanceType); + vi.mocked(EpicTreeProvider).mockImplementation(() => mockTreeProvider as unknown as InstanceType); + vi.mocked(TechSpecTreeProvider).mockImplementation(() => mockTechSpecProvider as unknown as InstanceType); + + activate(mockContext as unknown as Parameters[0]); + + // Get the configuration change handler + const onConfigChangeCalls = vi.mocked(workspace.onDidChangeConfiguration).mock.calls; + expect(onConfigChangeCalls.length).toBeGreaterThan(0); + + const configChangeHandler = onConfigChangeCalls[0][0]; + + // Simulate configuration change + const mockEvent = { + affectsConfiguration: vi.fn((section: string) => section === 'bmad') + }; + configChangeHandler(mockEvent as unknown as Parameters[0]); + + expect(mockCodeLensProvider.refresh).toHaveBeenCalled(); + expect(mockTreeProvider.refresh).toHaveBeenCalled(); + expect(mockTechSpecProvider.refresh).toHaveBeenCalled(); + }); + + it('recreates watcher when epicFilePattern changes', () => { + activate(mockContext as unknown as Parameters[0]); + + const initialWatcherCalls = vi.mocked(workspace.createFileSystemWatcher).mock.calls.length; + + // Get the configuration change handler + const onConfigChangeCalls = vi.mocked(workspace.onDidChangeConfiguration).mock.calls; + const configChangeHandler = onConfigChangeCalls[0][0]; + + // Simulate epicFilePattern configuration change + const mockEvent = { + affectsConfiguration: vi.fn((section: string) => + section === 'bmad' || section === 'bmad.epicFilePattern' + ) + }; + configChangeHandler(mockEvent as unknown as Parameters[0]); + + // Should have created a new watcher + expect(vi.mocked(workspace.createFileSystemWatcher).mock.calls.length).toBeGreaterThan(initialWatcherCalls); + }); + }); + + describe('deactivate', () => { + it('deactivate function exists and is callable', () => { + expect(deactivate).toBeDefined(); + expect(() => deactivate()).not.toThrow(); + }); + }); + + describe('7.2 Resource Cleanup', () => { + describe('7.2.1 - deactivate() disposes all watchers', () => { + it('all subscriptions have dispose methods', () => { + activate(mockContext as unknown as Parameters[0]); + + // All items in subscriptions should be disposable + mockContext.subscriptions.forEach((sub, index) => { + expect(sub).toBeDefined(); + // Subscriptions from VS Code API should have dispose + // Our mock returns objects with dispose methods + }); + + // Should have subscriptions for: CodeLens provider, tree views (x2), + // providers (x2), commands (x6), watchers (x3+), config listener + expect(mockContext.subscriptions.length).toBeGreaterThan(10); + }); + + it('file system watchers are added to subscriptions', () => { + activate(mockContext as unknown as Parameters[0]); + + // createFileSystemWatcher should have been called + expect(workspace.createFileSystemWatcher).toHaveBeenCalled(); + + // The watchers should be in subscriptions + const watcherCalls = vi.mocked(workspace.createFileSystemWatcher).mock.calls; + expect(watcherCalls.length).toBeGreaterThan(0); + }); + + it('providers are added to subscriptions for disposal', () => { + const mockTreeProvider = { + refresh: vi.fn(), + dispose: vi.fn() + }; + const mockTechSpecProvider = { + refresh: vi.fn(), + dispose: vi.fn() + }; + + vi.mocked(EpicTreeProvider).mockImplementation(() => mockTreeProvider as unknown as InstanceType); + vi.mocked(TechSpecTreeProvider).mockImplementation(() => mockTechSpecProvider as unknown as InstanceType); + + activate(mockContext as unknown as Parameters[0]); + + // Providers should be in subscriptions + // When context.subscriptions are disposed, provider.dispose() will be called + expect(mockContext.subscriptions.length).toBeGreaterThan(0); + }); + + it('disposing subscriptions cleans up resources', () => { + const mockWatcher = { + onDidChange: vi.fn(() => ({ dispose: vi.fn() })), + onDidCreate: vi.fn(() => ({ dispose: vi.fn() })), + onDidDelete: vi.fn(() => ({ dispose: vi.fn() })), + dispose: vi.fn() + }; + vi.mocked(workspace.createFileSystemWatcher).mockReturnValue(mockWatcher as unknown as ReturnType); + + activate(mockContext as unknown as Parameters[0]); + + // Simulate VS Code disposing all subscriptions (like when extension deactivates) + mockContext.subscriptions.forEach(sub => { + if (typeof sub?.dispose === 'function') { + sub.dispose(); + } + }); + + // Watcher dispose should have been called + expect(mockWatcher.dispose).toHaveBeenCalled(); + }); + }); + + describe('7.2.2 - deactivate() clears all timeouts', () => { + it('providers with debounced refresh are disposed properly', () => { + const mockTreeProvider = { + refresh: vi.fn(), + dispose: vi.fn() + }; + const mockTechSpecProvider = { + refresh: vi.fn(), + dispose: vi.fn() + }; + + vi.mocked(EpicTreeProvider).mockImplementation(() => mockTreeProvider as unknown as InstanceType); + vi.mocked(TechSpecTreeProvider).mockImplementation(() => mockTechSpecProvider as unknown as InstanceType); + + activate(mockContext as unknown as Parameters[0]); + + // Simulate disposal + mockContext.subscriptions.forEach(sub => { + if (typeof sub?.dispose === 'function') { + sub.dispose(); + } + }); + + // Provider dispose should have been called (which clears timeouts internally) + expect(mockTreeProvider.dispose).toHaveBeenCalled(); + expect(mockTechSpecProvider.dispose).toHaveBeenCalled(); + }); + + it('configuration change listener is disposed', () => { + const mockDispose = vi.fn(); + vi.mocked(workspace.onDidChangeConfiguration).mockReturnValue({ dispose: mockDispose }); + + activate(mockContext as unknown as Parameters[0]); + + // Simulate disposal + mockContext.subscriptions.forEach(sub => { + if (typeof sub?.dispose === 'function') { + sub.dispose(); + } + }); + + expect(mockDispose).toHaveBeenCalled(); + }); + }); + + describe('7.2.3 - No memory leaks on repeated activate/deactivate cycles', () => { + it('multiple activation cycles do not accumulate subscriptions', () => { + // First activation + const context1 = { subscriptions: [] as { dispose: () => void }[] }; + activate(context1 as unknown as Parameters[0]); + const count1 = context1.subscriptions.length; + + // Simulate deactivation by disposing + context1.subscriptions.forEach(sub => sub?.dispose?.()); + + // Reset mocks for clean state + vi.mocked(commands.registerCommand).mockClear(); + vi.mocked(languages.registerCodeLensProvider).mockClear(); + vi.mocked(window.createTreeView).mockClear(); + vi.mocked(workspace.createFileSystemWatcher).mockClear(); + + // Second activation with new context + const context2 = { subscriptions: [] as { dispose: () => void }[] }; + activate(context2 as unknown as Parameters[0]); + const count2 = context2.subscriptions.length; + + // Subscription counts should be similar (not growing) + expect(count2).toBeLessThanOrEqual(count1 + 2); // Allow small variance + }); + + it('command handlers are re-registered on each activation', () => { + // First activation + const context1 = { subscriptions: [] as { dispose: () => void }[] }; + activate(context1 as unknown as Parameters[0]); + + const firstCallCount = vi.mocked(commands.registerCommand).mock.calls.length; + + // Reset for second activation + vi.mocked(commands.registerCommand).mockClear(); + + // Second activation + const context2 = { subscriptions: [] as { dispose: () => void }[] }; + activate(context2 as unknown as Parameters[0]); + + const secondCallCount = vi.mocked(commands.registerCommand).mock.calls.length; + + // Should register same number of commands + expect(secondCallCount).toBe(firstCallCount); + }); + + it('tree views are recreated on each activation', () => { + // First activation + const context1 = { subscriptions: [] as { dispose: () => void }[] }; + activate(context1 as unknown as Parameters[0]); + + expect(window.createTreeView).toHaveBeenCalledTimes(2); // bmadStories and bmadTechSpecs + + // Reset + vi.mocked(window.createTreeView).mockClear(); + + // Second activation + const context2 = { subscriptions: [] as { dispose: () => void }[] }; + activate(context2 as unknown as Parameters[0]); + + expect(window.createTreeView).toHaveBeenCalledTimes(2); + }); + + it('watchers are recreated when pattern changes during activation cycle', () => { + const mockWatcher = { + onDidChange: vi.fn(() => ({ dispose: vi.fn() })), + onDidCreate: vi.fn(() => ({ dispose: vi.fn() })), + onDidDelete: vi.fn(() => ({ dispose: vi.fn() })), + dispose: vi.fn() + }; + vi.mocked(workspace.createFileSystemWatcher).mockReturnValue(mockWatcher as unknown as ReturnType); + + activate(mockContext as unknown as Parameters[0]); + + // Get config change handler + const onConfigChangeCalls = vi.mocked(workspace.onDidChangeConfiguration).mock.calls; + const configChangeHandler = onConfigChangeCalls[0][0]; + + // Simulate pattern change - this should dispose old watcher and create new one + const mockEvent = { + affectsConfiguration: vi.fn((section: string) => + section === 'bmad' || section === 'bmad.epicFilePattern' + ) + }; + configChangeHandler(mockEvent as unknown as Parameters[0]); + + // Old watcher should be disposed when new one is created + expect(mockWatcher.dispose).toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/src/__tests__/mocks/vscode.ts b/src/__tests__/mocks/vscode.ts new file mode 100644 index 0000000..c85c36e --- /dev/null +++ b/src/__tests__/mocks/vscode.ts @@ -0,0 +1,566 @@ +/** + * VS Code API mocks for unit testing + * + * This file provides mock implementations of the VS Code API types + * used by this extension. Import this in tests that need VS Code mocks. + */ + +import { vi } from 'vitest'; + +// Mock Uri class +export class Uri { + readonly scheme: string; + readonly authority: string; + readonly path: string; + readonly query: string; + readonly fragment: string; + readonly fsPath: string; + + private constructor(scheme: string, authority: string, path: string, query: string, fragment: string) { + this.scheme = scheme; + this.authority = authority; + this.path = path; + this.query = query; + this.fragment = fragment; + this.fsPath = path; + } + + static file(path: string): Uri { + return new Uri('file', '', path, '', ''); + } + + static parse(value: string): Uri { + // Simple parse - just handle file:// URIs for now + if (value.startsWith('file://')) { + return Uri.file(value.slice(7)); + } + return new Uri('', '', value, '', ''); + } + + static joinPath(base: Uri, ...pathSegments: string[]): Uri { + // Simple path joining - combine base path with segments + let path = base.path; + for (const segment of pathSegments) { + if (path.endsWith('/')) { + path += segment; + } else { + path += '/' + segment; + } + } + return new Uri(base.scheme, base.authority, path, base.query, base.fragment); + } + + toString(): string { + return `${this.scheme}://${this.path}`; + } + + with(change: { scheme?: string; authority?: string; path?: string; query?: string; fragment?: string }): Uri { + return new Uri( + change.scheme ?? this.scheme, + change.authority ?? this.authority, + change.path ?? this.path, + change.query ?? this.query, + change.fragment ?? this.fragment + ); + } + + toJSON(): unknown { + return { + scheme: this.scheme, + authority: this.authority, + path: this.path, + query: this.query, + fragment: this.fragment, + fsPath: this.fsPath + }; + } +} + +// Mock Range class +export class Range { + readonly start: Position; + readonly end: Position; + + constructor(startLine: number, startChar: number, endLine: number, endChar: number); + constructor(start: Position, end: Position); + constructor(startLineOrPos: number | Position, startCharOrEnd: number | Position, endLine?: number, endChar?: number) { + if (typeof startLineOrPos === 'number') { + this.start = new Position(startLineOrPos, startCharOrEnd as number); + this.end = new Position(endLine!, endChar!); + } else { + this.start = startLineOrPos; + this.end = startCharOrEnd as Position; + } + } + + get isEmpty(): boolean { + return this.start.isEqual(this.end); + } + + get isSingleLine(): boolean { + return this.start.line === this.end.line; + } + + contains(positionOrRange: Position | Range): boolean { + if (positionOrRange instanceof Position) { + return positionOrRange.line >= this.start.line && positionOrRange.line <= this.end.line; + } + return this.contains(positionOrRange.start) && this.contains(positionOrRange.end); + } + + isEqual(other: Range): boolean { + return this.start.isEqual(other.start) && this.end.isEqual(other.end); + } + + intersection(other: Range): Range | undefined { + const start = this.start.isAfter(other.start) ? this.start : other.start; + const end = this.end.isBefore(other.end) ? this.end : other.end; + if (start.isAfter(end)) { + return undefined; + } + return new Range(start, end); + } + + union(other: Range): Range { + const start = this.start.isBefore(other.start) ? this.start : other.start; + const end = this.end.isAfter(other.end) ? this.end : other.end; + return new Range(start, end); + } + + with(startOrChange?: Position | { start?: Position; end?: Position }, end?: Position): Range { + if (startOrChange && typeof startOrChange === 'object' && !('line' in startOrChange)) { + return new Range(startOrChange.start ?? this.start, startOrChange.end ?? this.end); + } + return new Range((startOrChange as Position | undefined) ?? this.start, end ?? this.end); + } +} + +// Mock Position class +export class Position { + readonly line: number; + readonly character: number; + + constructor(line: number, character: number) { + this.line = line; + this.character = character; + } + + isEqual(other: Position): boolean { + return this.line === other.line && this.character === other.character; + } + + isBefore(other: Position): boolean { + return this.line < other.line || (this.line === other.line && this.character < other.character); + } + + isAfter(other: Position): boolean { + return this.line > other.line || (this.line === other.line && this.character > other.character); + } + + isBeforeOrEqual(other: Position): boolean { + return this.isBefore(other) || this.isEqual(other); + } + + isAfterOrEqual(other: Position): boolean { + return this.isAfter(other) || this.isEqual(other); + } + + compareTo(other: Position): number { + if (this.isBefore(other)) return -1; + if (this.isAfter(other)) return 1; + return 0; + } + + translate(lineDeltaOrChange?: number | { lineDelta?: number; characterDelta?: number }, characterDelta?: number): Position { + if (typeof lineDeltaOrChange === 'object') { + return new Position( + this.line + (lineDeltaOrChange.lineDelta ?? 0), + this.character + (lineDeltaOrChange.characterDelta ?? 0) + ); + } + return new Position(this.line + (lineDeltaOrChange ?? 0), this.character + (characterDelta ?? 0)); + } + + with(lineOrChange?: number | { line?: number; character?: number }, character?: number): Position { + if (typeof lineOrChange === 'object') { + return new Position(lineOrChange.line ?? this.line, lineOrChange.character ?? this.character); + } + return new Position(lineOrChange ?? this.line, character ?? this.character); + } +} + +// Mock Selection class +export class Selection extends Range { + readonly anchor: Position; + readonly active: Position; + + constructor(anchorLine: number, anchorChar: number, activeLine: number, activeChar: number); + constructor(anchor: Position, active: Position); + constructor(anchorLineOrPos: number | Position, anchorCharOrActive: number | Position, activeLine?: number, activeChar?: number) { + if (typeof anchorLineOrPos === 'number') { + const anchor = new Position(anchorLineOrPos, anchorCharOrActive as number); + const active = new Position(activeLine!, activeChar!); + super(anchor, active); + this.anchor = anchor; + this.active = active; + } else { + super(anchorLineOrPos, anchorCharOrActive as Position); + this.anchor = anchorLineOrPos; + this.active = anchorCharOrActive as Position; + } + } + + get isReversed(): boolean { + return this.anchor.isAfter(this.active); + } +} + +// Mock CodeLens class +export class CodeLens { + range: Range; + command?: Command; + readonly isResolved: boolean; + + constructor(range: Range, command?: Command) { + this.range = range; + this.command = command; + this.isResolved = !!command; + } +} + +// Command interface +export interface Command { + title: string; + command: string; + tooltip?: string; + arguments?: unknown[]; +} + +// Mock TreeItem class +export class TreeItem { + label?: string | TreeItemLabel; + id?: string; + iconPath?: ThemeIcon | Uri | { light: Uri; dark: Uri }; + description?: string | boolean; + tooltip?: string | MarkdownString; + command?: Command; + collapsibleState?: TreeItemCollapsibleState; + contextValue?: string; + + constructor(label: string | TreeItemLabel, collapsibleState?: TreeItemCollapsibleState); + constructor(resourceUri: Uri, collapsibleState?: TreeItemCollapsibleState); + constructor(labelOrUri: string | TreeItemLabel | Uri, collapsibleState?: TreeItemCollapsibleState) { + if (labelOrUri instanceof Uri) { + this.label = labelOrUri.fsPath; + } else { + this.label = labelOrUri; + } + this.collapsibleState = collapsibleState; + } +} + +export interface TreeItemLabel { + label: string; + highlights?: [number, number][]; +} + +export enum TreeItemCollapsibleState { + None = 0, + Collapsed = 1, + Expanded = 2 +} + +// Mock ThemeIcon class +export class ThemeIcon { + static readonly File = new ThemeIcon('file'); + static readonly Folder = new ThemeIcon('folder'); + + readonly id: string; + readonly color?: ThemeColor; + + constructor(id: string, color?: ThemeColor) { + this.id = id; + this.color = color; + } +} + +// Mock ThemeColor class +export class ThemeColor { + readonly id: string; + + constructor(id: string) { + this.id = id; + } +} + +// Mock MarkdownString class +export class MarkdownString { + value: string; + isTrusted?: boolean | { enabledCommands: readonly string[] }; + supportThemeIcons?: boolean; + + constructor(value?: string, supportThemeIcons?: boolean) { + this.value = value ?? ''; + this.supportThemeIcons = supportThemeIcons; + } + + appendText(value: string): MarkdownString { + this.value += value; + return this; + } + + appendMarkdown(value: string): MarkdownString { + this.value += value; + return this; + } + + appendCodeblock(value: string, language?: string): MarkdownString { + this.value += `\`\`\`${language ?? ''}\n${value}\n\`\`\``; + return this; + } +} + +// Mock EventEmitter class +export class EventEmitter { + private listeners: ((e: T) => void)[] = []; + + readonly event = (listener: (e: T) => void): Disposable => { + this.listeners.push(listener); + return { + dispose: () => { + const index = this.listeners.indexOf(listener); + if (index > -1) { + this.listeners.splice(index, 1); + } + } + }; + }; + + fire(data: T): void { + this.listeners.forEach(listener => listener(data)); + } + + dispose(): void { + this.listeners = []; + } +} + +// Mock Disposable interface +export interface Disposable { + dispose(): void; +} + +// Mock workspace namespace +export const workspace = { + workspaceFolders: undefined as { uri: Uri; name: string; index: number }[] | undefined, + getConfiguration: vi.fn((section?: string) => ({ + get: vi.fn((key: string, defaultValue?: unknown) => defaultValue), + has: vi.fn(() => false), + inspect: vi.fn(), + update: vi.fn() + })), + findFiles: vi.fn(async (_include: unknown, _exclude?: unknown, _maxResults?: number) => [] as Uri[]), + openTextDocument: vi.fn(), + createFileSystemWatcher: vi.fn(() => ({ + onDidChange: vi.fn(() => ({ dispose: vi.fn() })), + onDidCreate: vi.fn(() => ({ dispose: vi.fn() })), + onDidDelete: vi.fn(() => ({ dispose: vi.fn() })), + dispose: vi.fn() + })), + fs: { + readFile: vi.fn(async (_uri: Uri) => new Uint8Array()), + readDirectory: vi.fn(async () => [] as [string, number][]), + stat: vi.fn(), + writeFile: vi.fn(), + delete: vi.fn(), + rename: vi.fn(), + copy: vi.fn(), + createDirectory: vi.fn() + }, + getWorkspaceFolder: vi.fn((uri: Uri) => { + if (workspace.workspaceFolders && workspace.workspaceFolders.length > 0) { + return workspace.workspaceFolders[0]; + } + return undefined; + }), + asRelativePath: vi.fn((pathOrUri: string | Uri) => { + const path = typeof pathOrUri === 'string' ? pathOrUri : pathOrUri.fsPath; + return path; + }), + onDidChangeConfiguration: vi.fn((_listener: (e: { affectsConfiguration: (section: string) => boolean }) => unknown) => ({ dispose: vi.fn() })) +}; + +// Mock window namespace +export const window = { + showInformationMessage: vi.fn(), + showWarningMessage: vi.fn(), + showErrorMessage: vi.fn(), + showTextDocument: vi.fn(), + createTreeView: vi.fn(() => ({ + dispose: vi.fn(), + reveal: vi.fn() + })), + createTerminal: vi.fn(() => ({ + name: 'Mock Terminal', + show: vi.fn(), + sendText: vi.fn(), + dispose: vi.fn() + })), + terminals: [] as { name: string }[], + createOutputChannel: vi.fn(() => ({ + appendLine: vi.fn(), + append: vi.fn(), + clear: vi.fn(), + show: vi.fn(), + hide: vi.fn(), + dispose: vi.fn() + })) +}; + +// Mock languages namespace +export const languages = { + registerCodeLensProvider: vi.fn(() => ({ dispose: vi.fn() })), + createDiagnosticCollection: vi.fn(() => ({ + set: vi.fn(), + delete: vi.fn(), + clear: vi.fn(), + dispose: vi.fn() + })) +}; + +// Mock commands namespace +export const commands = { + registerCommand: vi.fn((_command: string, _callback: (...args: unknown[]) => unknown) => ({ dispose: vi.fn() })), + executeCommand: vi.fn(async (_command: string, ..._args: unknown[]) => undefined) +}; + +// EndOfLine enum +export enum EndOfLine { + LF = 1, + CRLF = 2 +} + +// Mock TextDocument interface +export interface TextDocument { + uri: Uri; + fileName: string; + languageId: string; + version: number; + isDirty: boolean; + isClosed: boolean; + isUntitled: boolean; + encoding: string; + eol: EndOfLine; + lineCount: number; + getText(range?: Range): string; + lineAt(line: number): TextLine; + positionAt(offset: number): Position; + offsetAt(position: Position): number; + save(): Promise; + getWordRangeAtPosition(position: Position, regex?: RegExp): Range | undefined; + validatePosition(position: Position): Position; + validateRange(range: Range): Range; +} + +// Mock TextLine interface +export interface TextLine { + lineNumber: number; + text: string; + range: Range; + rangeIncludingLineBreak: Range; + firstNonWhitespaceCharacterIndex: number; + isEmptyOrWhitespace: boolean; +} + +// Text editor reveal type enum +export enum TextEditorRevealType { + Default = 0, + InCenter = 1, + InCenterIfOutsideViewport = 2, + AtTop = 3 +} + +// Helper to create mock TextDocument +// Returns unknown to allow casting to vscode.TextDocument in tests +export function createMockTextDocument(content: string, uriOrPath?: Uri | string): unknown { + const lines = content.split('\n'); + // Handle both Uri objects and string paths + const resolvedUri = typeof uriOrPath === 'string' ? Uri.file(uriOrPath) : (uriOrPath ?? Uri.file('/mock/document.md')); + return { + uri: resolvedUri, + fileName: resolvedUri.fsPath, + languageId: 'markdown', + version: 1, + isDirty: false, + isClosed: false, + isUntitled: false, + encoding: 'utf8', + eol: EndOfLine.LF, + lineCount: lines.length, + getText: (range?: Range) => { + if (!range) return content; + const startLine = range.start.line; + const endLine = range.end.line; + return lines.slice(startLine, endLine + 1).join('\n'); + }, + lineAt: (line: number) => ({ + lineNumber: line, + text: lines[line] ?? '', + range: new Range(line, 0, line, (lines[line] ?? '').length), + rangeIncludingLineBreak: new Range(line, 0, line + 1, 0), + firstNonWhitespaceCharacterIndex: (lines[line] ?? '').search(/\S/), + isEmptyOrWhitespace: !(lines[line] ?? '').trim() + }), + positionAt: (offset: number) => { + let remaining = offset; + for (let i = 0; i < lines.length; i++) { + if (remaining <= lines[i].length) { + return new Position(i, remaining); + } + remaining -= lines[i].length + 1; // +1 for newline + } + return new Position(lines.length - 1, lines[lines.length - 1].length); + }, + offsetAt: (position: Position) => { + let offset = 0; + for (let i = 0; i < position.line; i++) { + offset += lines[i].length + 1; + } + return offset + position.character; + }, + save: async () => true, + getWordRangeAtPosition: (_position: Position, _regex?: RegExp) => undefined, + validatePosition: (position: Position) => position, + validateRange: (range: Range) => range + }; +} + +// Reset all mocks helper +export function resetAllMocks(): void { + vi.clearAllMocks(); + workspace.workspaceFolders = undefined; + window.terminals = []; +} + +// Default export matching vscode module structure +export default { + Uri, + Range, + Position, + Selection, + CodeLens, + TreeItem, + TreeItemCollapsibleState, + ThemeIcon, + ThemeColor, + MarkdownString, + EventEmitter, + TextEditorRevealType, + EndOfLine, + workspace, + window, + languages, + commands, + createMockTextDocument, + resetAllMocks +}; diff --git a/src/__tests__/storyCodeLensProvider.test.ts b/src/__tests__/storyCodeLensProvider.test.ts new file mode 100644 index 0000000..9c0f371 --- /dev/null +++ b/src/__tests__/storyCodeLensProvider.test.ts @@ -0,0 +1,785 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import type { TextDocument, CodeLens as VsCodeLens } from 'vscode'; +import { + workspace, + Uri, + CodeLens, + Range, + Position, + createMockTextDocument, + resetAllMocks +} from './mocks/vscode'; +import { StoryCodeLensProvider } from '../storyCodeLensProvider'; +import { getImplementationArtifactsPath } from '../bmadConfig'; + +// Helper to create typed mock documents +const mockDoc = (content: string, path?: string) => createMockTextDocument(content, path) as TextDocument; +// Helper to cast mock CodeLens to vscode.CodeLens +const asCodeLens = (lens: CodeLens) => lens as unknown as VsCodeLens; + +// Mock bmadConfig +vi.mock('../bmadConfig', () => ({ + getImplementationArtifactsPath: vi.fn() +})); + +describe('StoryCodeLensProvider', () => { + let provider: StoryCodeLensProvider; + + beforeEach(() => { + resetAllMocks(); + provider = new StoryCodeLensProvider(); + + // Default workspace setup + workspace.workspaceFolders = [ + { uri: Uri.file('/workspace'), name: 'workspace', index: 0 } + ]; + + // Default config - CodeLens enabled, default pattern + vi.mocked(workspace.getConfiguration).mockReturnValue({ + get: vi.fn((key: string, defaultValue?: unknown) => { + if (key === 'enableCodeLens') return true; + if (key === 'epicFilePattern') return '**/*epic*.md'; + return defaultValue; + }), + has: vi.fn(), + inspect: vi.fn(), + update: vi.fn() + } as unknown as ReturnType); + }); + + afterEach(() => { + provider.dispose(); + }); + + describe('6.1.1 - CodeLens creation for ready status stories', () => { + it('creates CodeLens for story with ready status', async () => { + const content = `## Epic 1: Test Epic + +### Story 1.1: First Story +**Status:** ready + +Story description here. +`; + const document = mockDoc(content, '/workspace/epics.md'); + + vi.mocked(getImplementationArtifactsPath).mockResolvedValue('_bmad-output/implementation'); + vi.mocked(workspace.fs.readDirectory).mockResolvedValue([]); + + const codeLenses = await provider.provideCodeLenses(document); + + expect(codeLenses).toHaveLength(1); + expect(codeLenses[0].command?.command).toBe('bmadMethod.createStory'); + expect(codeLenses[0].command?.arguments).toEqual(['1.1']); + }); + + it('creates Start Developing CodeLens when story file exists', async () => { + const content = `## Epic 1: Test Epic + +### Story 1.1: First Story +**Status:** ready +`; + const document = mockDoc(content, '/workspace/epics.md'); + + vi.mocked(getImplementationArtifactsPath).mockResolvedValue('_bmad-output/implementation'); + vi.mocked(workspace.fs.readDirectory).mockResolvedValue([ + ['1-1-first-story.md', 1] // FileType.File = 1 + ]); + + const codeLenses = await provider.provideCodeLenses(document); + + expect(codeLenses).toHaveLength(1); + expect(codeLenses[0].command?.command).toBe('bmadMethod.developStory'); + expect(codeLenses[0].command?.title).toBe('$(play) Start Developing Story'); + }); + + it('creates Create Story CodeLens when story file does not exist', async () => { + const content = `## Epic 1: Test Epic + +### Story 1.1: First Story +**Status:** ready +`; + const document = mockDoc(content, '/workspace/epics.md'); + + vi.mocked(getImplementationArtifactsPath).mockResolvedValue('_bmad-output/implementation'); + vi.mocked(workspace.fs.readDirectory).mockResolvedValue([]); + + const codeLenses = await provider.provideCodeLenses(document); + + expect(codeLenses).toHaveLength(1); + expect(codeLenses[0].command?.command).toBe('bmadMethod.createStory'); + expect(codeLenses[0].command?.title).toBe('$(new-file) Create Story'); + }); + + it('creates multiple CodeLenses for multiple ready stories', async () => { + const content = `## Epic 1: Test Epic + +### Story 1.1: First Story +**Status:** ready + +### Story 1.2: Second Story +**Status:** ready + +### Story 1.3: Third Story +**Status:** ready +`; + const document = mockDoc(content, '/workspace/epics.md'); + + vi.mocked(getImplementationArtifactsPath).mockResolvedValue('_bmad-output/implementation'); + vi.mocked(workspace.fs.readDirectory).mockResolvedValue([]); + + const codeLenses = await provider.provideCodeLenses(document); + + expect(codeLenses).toHaveLength(3); + expect(codeLenses[0].command?.arguments).toEqual(['1.1']); + expect(codeLenses[1].command?.arguments).toEqual(['1.2']); + expect(codeLenses[2].command?.arguments).toEqual(['1.3']); + }); + + it('includes story title in tooltip', async () => { + const content = `## Epic 1: Test Epic + +### Story 1.1: Authentication Flow +**Status:** ready +`; + const document = mockDoc(content, '/workspace/epics.md'); + + vi.mocked(getImplementationArtifactsPath).mockResolvedValue('_bmad-output/implementation'); + vi.mocked(workspace.fs.readDirectory).mockResolvedValue([]); + + const codeLenses = await provider.provideCodeLenses(document); + + expect(codeLenses[0].command?.tooltip).toContain('1.1'); + expect(codeLenses[0].command?.tooltip).toContain('Authentication Flow'); + }); + }); + + describe('6.1.2 - CodeLens NOT created for other statuses', () => { + it('does not create CodeLens for in-progress status', async () => { + const content = `## Epic 1: Test Epic + +### Story 1.1: First Story +**Status:** in-progress +`; + const document = mockDoc(content, '/workspace/epics.md'); + + const codeLenses = await provider.provideCodeLenses(document); + + expect(codeLenses).toHaveLength(0); + }); + + it('does not create CodeLens for done status', async () => { + const content = `## Epic 1: Test Epic + +### Story 1.1: First Story +**Status:** done +`; + const document = mockDoc(content, '/workspace/epics.md'); + + const codeLenses = await provider.provideCodeLenses(document); + + expect(codeLenses).toHaveLength(0); + }); + + it('does not create CodeLens for blocked status', async () => { + const content = `## Epic 1: Test Epic + +### Story 1.1: First Story +**Status:** blocked +`; + const document = mockDoc(content, '/workspace/epics.md'); + + const codeLenses = await provider.provideCodeLenses(document); + + expect(codeLenses).toHaveLength(0); + }); + + it('does not create CodeLens for draft status', async () => { + const content = `## Epic 1: Test Epic + +### Story 1.1: First Story +**Status:** draft +`; + const document = mockDoc(content, '/workspace/epics.md'); + + const codeLenses = await provider.provideCodeLenses(document); + + expect(codeLenses).toHaveLength(0); + }); + + it('does not create CodeLens for unknown status', async () => { + const content = `## Epic 1: Test Epic + +### Story 1.1: First Story +**Status:** pending-review +`; + const document = mockDoc(content, '/workspace/epics.md'); + + const codeLenses = await provider.provideCodeLenses(document); + + expect(codeLenses).toHaveLength(0); + }); + + it('only creates CodeLens for ready stories in mixed status file', async () => { + const content = `## Epic 1: Test Epic + +### Story 1.1: First Story +**Status:** done + +### Story 1.2: Second Story +**Status:** ready + +### Story 1.3: Third Story +**Status:** in-progress + +### Story 1.4: Fourth Story +**Status:** ready +`; + const document = mockDoc(content, '/workspace/epics.md'); + + vi.mocked(getImplementationArtifactsPath).mockResolvedValue('_bmad-output/implementation'); + vi.mocked(workspace.fs.readDirectory).mockResolvedValue([]); + + const codeLenses = await provider.provideCodeLenses(document); + + expect(codeLenses).toHaveLength(2); + expect(codeLenses[0].command?.arguments).toEqual(['1.2']); + expect(codeLenses[1].command?.arguments).toEqual(['1.4']); + }); + }); + + describe('6.1.3 - Story file existence check logic', () => { + it('returns false when no workspace folder', async () => { + const content = `## Epic 1: Test Epic + +### Story 1.1: First Story +**Status:** ready +`; + const document = mockDoc(content, '/workspace/epics.md'); + + // Override getWorkspaceFolder to return undefined + vi.mocked(workspace.getWorkspaceFolder).mockReturnValue(undefined); + + const codeLenses = await provider.provideCodeLenses(document); + + // Should still create CodeLens but with "Create Story" since file check returns false + expect(codeLenses).toHaveLength(1); + expect(codeLenses[0].command?.command).toBe('bmadMethod.createStory'); + }); + + it('returns false when no implementation_artifacts path configured', async () => { + const content = `## Epic 1: Test Epic + +### Story 1.1: First Story +**Status:** ready +`; + const document = mockDoc(content, '/workspace/epics.md'); + + vi.mocked(getImplementationArtifactsPath).mockResolvedValue(null); + + const codeLenses = await provider.provideCodeLenses(document); + + expect(codeLenses).toHaveLength(1); + expect(codeLenses[0].command?.command).toBe('bmadMethod.createStory'); + }); + + it('returns false when directory read fails', async () => { + const content = `## Epic 1: Test Epic + +### Story 1.1: First Story +**Status:** ready +`; + const document = mockDoc(content, '/workspace/epics.md'); + + vi.mocked(getImplementationArtifactsPath).mockResolvedValue('_bmad-output/implementation'); + vi.mocked(workspace.fs.readDirectory).mockRejectedValue(new Error('Directory not found')); + + const codeLenses = await provider.provideCodeLenses(document); + + expect(codeLenses).toHaveLength(1); + expect(codeLenses[0].command?.command).toBe('bmadMethod.createStory'); + }); + + it('matches story file with different suffixes', async () => { + const content = `## Epic 1: Test Epic + +### Story 1.1: First Story +**Status:** ready +`; + const document = mockDoc(content, '/workspace/epics.md'); + + const wsFolder = { uri: Uri.file('/workspace'), name: 'workspace', index: 0 }; + workspace.workspaceFolders = [wsFolder]; + vi.mocked(workspace.getWorkspaceFolder).mockReturnValue(wsFolder); + vi.mocked(getImplementationArtifactsPath).mockResolvedValue('_bmad-output/implementation'); + vi.mocked(workspace.fs.readDirectory).mockResolvedValue([ + ['1-1-user-authentication.md', 1], + ['1-2-other-story.md', 1] + ]); + + const codeLenses = await provider.provideCodeLenses(document); + + expect(codeLenses).toHaveLength(1); + expect(codeLenses[0].command?.command).toBe('bmadMethod.developStory'); + }); + + it('documents startsWith matching behavior - may match similar prefixes', async () => { + // NOTE: Current implementation uses startsWith() which means + // "1-10-story.md" WILL match story 1.1 (prefix "1-1") because "1-10" starts with "1-1" + // This documents the actual behavior, which may be a limitation + const content = `## Epic 1: Test Epic + +### Story 1.1: First Story +**Status:** ready +`; + const document = mockDoc(content, '/workspace/epics.md'); + + const wsFolder = { uri: Uri.file('/workspace'), name: 'workspace', index: 0 }; + workspace.workspaceFolders = [wsFolder]; + vi.mocked(workspace.getWorkspaceFolder).mockReturnValue(wsFolder); + vi.mocked(getImplementationArtifactsPath).mockResolvedValue('_bmad-output/implementation'); + vi.mocked(workspace.fs.readDirectory).mockResolvedValue([ + ['1-10-different-story.md', 1], // "1-10" starts with "1-1" so this WILL match + ]); + + const codeLenses = await provider.provideCodeLenses(document); + + expect(codeLenses).toHaveLength(1); + // This matches because startsWith("1-1") is true for "1-10-different-story.md" + expect(codeLenses[0].command?.command).toBe('bmadMethod.developStory'); + }); + + it('does not match when file prefix is completely different', async () => { + const content = `## Epic 1: Test Epic + +### Story 1.1: First Story +**Status:** ready +`; + const document = mockDoc(content, '/workspace/epics.md'); + + vi.mocked(getImplementationArtifactsPath).mockResolvedValue('_bmad-output/implementation'); + vi.mocked(workspace.fs.readDirectory).mockResolvedValue([ + ['2-1-different-epic.md', 1], // Does NOT start with "1-1" + ['11-1-another-story.md', 1] // Does NOT start with "1-1" + ]); + + const codeLenses = await provider.provideCodeLenses(document); + + expect(codeLenses).toHaveLength(1); + expect(codeLenses[0].command?.command).toBe('bmadMethod.createStory'); + }); + }); + + describe('6.1.4 - Story number transformation (dots to dashes)', () => { + it('converts single dot to dash', async () => { + const content = `## Epic 1: Test Epic + +### Story 1.1: First Story +**Status:** ready +`; + const document = mockDoc(content, '/workspace/epics.md'); + + const wsFolder = { uri: Uri.file('/workspace'), name: 'workspace', index: 0 }; + workspace.workspaceFolders = [wsFolder]; + vi.mocked(workspace.getWorkspaceFolder).mockReturnValue(wsFolder); + vi.mocked(getImplementationArtifactsPath).mockResolvedValue('_bmad-output/implementation'); + vi.mocked(workspace.fs.readDirectory).mockResolvedValue([ + ['1-1-story.md', 1] // 1.1 -> 1-1 + ]); + + const codeLenses = await provider.provideCodeLenses(document); + + expect(codeLenses[0].command?.command).toBe('bmadMethod.developStory'); + }); + + it('handles story numbers with single digit epic and story', async () => { + // Note: The epicsParser only supports X.Y format (one dot), not X.Y.Z + const content = `## Epic 2: Another Epic + +### Story 2.5: Fifth Story +**Status:** ready +`; + const document = mockDoc(content, '/workspace/epics.md'); + + const wsFolder = { uri: Uri.file('/workspace'), name: 'workspace', index: 0 }; + workspace.workspaceFolders = [wsFolder]; + vi.mocked(workspace.getWorkspaceFolder).mockReturnValue(wsFolder); + vi.mocked(getImplementationArtifactsPath).mockResolvedValue('_bmad-output/implementation'); + vi.mocked(workspace.fs.readDirectory).mockResolvedValue([ + ['2-5-fifth-story.md', 1] // 2.5 -> 2-5 + ]); + + const codeLenses = await provider.provideCodeLenses(document); + + expect(codeLenses[0].command?.command).toBe('bmadMethod.developStory'); + }); + + it('handles double-digit story numbers', async () => { + const content = `## Epic 10: Large Epic + +### Story 10.15: Large Story +**Status:** ready +`; + const document = mockDoc(content, '/workspace/epics.md'); + + const wsFolder = { uri: Uri.file('/workspace'), name: 'workspace', index: 0 }; + workspace.workspaceFolders = [wsFolder]; + vi.mocked(workspace.getWorkspaceFolder).mockReturnValue(wsFolder); + vi.mocked(getImplementationArtifactsPath).mockResolvedValue('_bmad-output/implementation'); + vi.mocked(workspace.fs.readDirectory).mockResolvedValue([ + ['10-15-large-story.md', 1] // 10.15 -> 10-15 + ]); + + const codeLenses = await provider.provideCodeLenses(document); + + expect(codeLenses[0].command?.command).toBe('bmadMethod.developStory'); + }); + }); + + describe('6.1.5 - Glob pattern to regex conversion', () => { + it('matches default pattern **/*epic*.md', async () => { + const content = `## Epic 1: Test Epic + +### Story 1.1: First Story +**Status:** ready +`; + // File name contains "epic" - should match + const document = mockDoc(content, '/workspace/my-epic-file.md'); + + vi.mocked(getImplementationArtifactsPath).mockResolvedValue('_bmad-output/implementation'); + vi.mocked(workspace.fs.readDirectory).mockResolvedValue([]); + + const codeLenses = await provider.provideCodeLenses(document); + + expect(codeLenses).toHaveLength(1); + }); + + it('does not match files without epic in name', async () => { + const content = `## Epic 1: Test Epic + +### Story 1.1: First Story +**Status:** ready +`; + // File name does not contain "epic" - should not match + const document = mockDoc(content, '/workspace/stories.md'); + + const codeLenses = await provider.provideCodeLenses(document); + + expect(codeLenses).toHaveLength(0); + }); + + it('matches custom pattern *.stories.md', async () => { + vi.mocked(workspace.getConfiguration).mockReturnValue({ + get: vi.fn((key: string, defaultValue?: unknown) => { + if (key === 'enableCodeLens') return true; + if (key === 'epicFilePattern') return '*.stories.md'; + return defaultValue; + }), + has: vi.fn(), + inspect: vi.fn(), + update: vi.fn() + } as unknown as ReturnType); + + const content = `## Epic 1: Test Epic + +### Story 1.1: First Story +**Status:** ready +`; + const document = mockDoc(content, '/workspace/project.stories.md'); + + vi.mocked(getImplementationArtifactsPath).mockResolvedValue('_bmad-output/implementation'); + vi.mocked(workspace.fs.readDirectory).mockResolvedValue([]); + + const codeLenses = await provider.provideCodeLenses(document); + + expect(codeLenses).toHaveLength(1); + }); + + it('handles pattern with ? wildcard', async () => { + vi.mocked(workspace.getConfiguration).mockReturnValue({ + get: vi.fn((key: string, defaultValue?: unknown) => { + if (key === 'enableCodeLens') return true; + if (key === 'epicFilePattern') return 'epic?.md'; + return defaultValue; + }), + has: vi.fn(), + inspect: vi.fn(), + update: vi.fn() + } as unknown as ReturnType); + + const content = `## Epic 1: Test Epic + +### Story 1.1: First Story +**Status:** ready +`; + // epic1.md matches epic?.md + const document = mockDoc(content, '/workspace/epic1.md'); + + vi.mocked(getImplementationArtifactsPath).mockResolvedValue('_bmad-output/implementation'); + vi.mocked(workspace.fs.readDirectory).mockResolvedValue([]); + + const codeLenses = await provider.provideCodeLenses(document); + + expect(codeLenses).toHaveLength(1); + }); + + it('case insensitive matching', async () => { + const content = `## Epic 1: Test Epic + +### Story 1.1: First Story +**Status:** ready +`; + // EPIC in uppercase should still match *epic*.md pattern + const document = mockDoc(content, '/workspace/MY-EPIC-FILE.MD'); + + vi.mocked(getImplementationArtifactsPath).mockResolvedValue('_bmad-output/implementation'); + vi.mocked(workspace.fs.readDirectory).mockResolvedValue([]); + + const codeLenses = await provider.provideCodeLenses(document); + + expect(codeLenses).toHaveLength(1); + }); + + it('escapes regex special characters in pattern', async () => { + vi.mocked(workspace.getConfiguration).mockReturnValue({ + get: vi.fn((key: string, defaultValue?: unknown) => { + if (key === 'enableCodeLens') return true; + if (key === 'epicFilePattern') return 'epic[1].md'; // [ ] are regex special chars + return defaultValue; + }), + has: vi.fn(), + inspect: vi.fn(), + update: vi.fn() + } as unknown as ReturnType); + + const content = `## Epic 1: Test Epic + +### Story 1.1: First Story +**Status:** ready +`; + const document = mockDoc(content, '/workspace/epic[1].md'); + + vi.mocked(getImplementationArtifactsPath).mockResolvedValue('_bmad-output/implementation'); + vi.mocked(workspace.fs.readDirectory).mockResolvedValue([]); + + const codeLenses = await provider.provideCodeLenses(document); + + expect(codeLenses).toHaveLength(1); + }); + + it('extracts filename pattern from full path pattern', async () => { + vi.mocked(workspace.getConfiguration).mockReturnValue({ + get: vi.fn((key: string, defaultValue?: unknown) => { + if (key === 'enableCodeLens') return true; + if (key === 'epicFilePattern') return 'docs/planning/*epic*.md'; + return defaultValue; + }), + has: vi.fn(), + inspect: vi.fn(), + update: vi.fn() + } as unknown as ReturnType); + + const content = `## Epic 1: Test Epic + +### Story 1.1: First Story +**Status:** ready +`; + // File matches *epic*.md portion + const document = mockDoc(content, '/workspace/my-epic.md'); + + vi.mocked(getImplementationArtifactsPath).mockResolvedValue('_bmad-output/implementation'); + vi.mocked(workspace.fs.readDirectory).mockResolvedValue([]); + + const codeLenses = await provider.provideCodeLenses(document); + + expect(codeLenses).toHaveLength(1); + }); + }); + + describe('6.1.6 - ReDoS protection (backtracking guard)', () => { + it('collapses consecutive asterisks to prevent ReDoS', async () => { + vi.mocked(workspace.getConfiguration).mockReturnValue({ + get: vi.fn((key: string, defaultValue?: unknown) => { + if (key === 'enableCodeLens') return true; + // Pattern with many consecutive asterisks - potential ReDoS + if (key === 'epicFilePattern') return '****epic****.md'; + return defaultValue; + }), + has: vi.fn(), + inspect: vi.fn(), + update: vi.fn() + } as unknown as ReturnType); + + const content = `## Epic 1: Test Epic + +### Story 1.1: First Story +**Status:** ready +`; + const document = mockDoc(content, '/workspace/test-epic-file.md'); + + vi.mocked(getImplementationArtifactsPath).mockResolvedValue('_bmad-output/implementation'); + vi.mocked(workspace.fs.readDirectory).mockResolvedValue([]); + + // This should complete quickly without hanging + const startTime = Date.now(); + const codeLenses = await provider.provideCodeLenses(document); + const elapsed = Date.now() - startTime; + + expect(codeLenses).toHaveLength(1); + // Should complete in reasonable time (under 100ms) + expect(elapsed).toBeLessThan(100); + }); + + it('handles pathological input without hanging', async () => { + vi.mocked(workspace.getConfiguration).mockReturnValue({ + get: vi.fn((key: string, defaultValue?: unknown) => { + if (key === 'enableCodeLens') return true; + if (key === 'epicFilePattern') return '*a*a*a*a*.md'; + return defaultValue; + }), + has: vi.fn(), + inspect: vi.fn(), + update: vi.fn() + } as unknown as ReturnType); + + const content = `## Epic 1: Test Epic + +### Story 1.1: First Story +**Status:** ready +`; + // Input with many 'a's that could cause backtracking + const document = mockDoc(content, '/workspace/aaaaaaaaaaaaaaaa.md'); + + vi.mocked(getImplementationArtifactsPath).mockResolvedValue('_bmad-output/implementation'); + vi.mocked(workspace.fs.readDirectory).mockResolvedValue([]); + + const startTime = Date.now(); + const codeLenses = await provider.provideCodeLenses(document); + const elapsed = Date.now() - startTime; + + // Should complete in reasonable time + expect(elapsed).toBeLessThan(100); + }); + }); + + describe('Configuration handling', () => { + it('returns empty array when CodeLens is disabled', async () => { + vi.mocked(workspace.getConfiguration).mockReturnValue({ + get: vi.fn((key: string, defaultValue?: unknown) => { + if (key === 'enableCodeLens') return false; + if (key === 'epicFilePattern') return '**/*epic*.md'; + return defaultValue; + }), + has: vi.fn(), + inspect: vi.fn(), + update: vi.fn() + } as unknown as ReturnType); + + const content = `## Epic 1: Test Epic + +### Story 1.1: First Story +**Status:** ready +`; + const document = mockDoc(content, '/workspace/epics.md'); + + const codeLenses = await provider.provideCodeLenses(document); + + expect(codeLenses).toHaveLength(0); + }); + + it('uses default value when config not set', async () => { + vi.mocked(workspace.getConfiguration).mockReturnValue({ + get: vi.fn((key: string, defaultValue?: unknown) => defaultValue), + has: vi.fn(), + inspect: vi.fn(), + update: vi.fn() + } as unknown as ReturnType); + + const content = `## Epic 1: Test Epic + +### Story 1.1: First Story +**Status:** ready +`; + // Default pattern is **/*epic*.md + const document = mockDoc(content, '/workspace/test-epic.md'); + + vi.mocked(getImplementationArtifactsPath).mockResolvedValue('_bmad-output/implementation'); + vi.mocked(workspace.fs.readDirectory).mockResolvedValue([]); + + const codeLenses = await provider.provideCodeLenses(document); + + expect(codeLenses).toHaveLength(1); + }); + }); + + describe('Event handling', () => { + it('fires event when refresh is called', () => { + const listener = vi.fn(); + provider.onDidChangeCodeLenses(listener); + + provider.refresh(); + + expect(listener).toHaveBeenCalled(); + }); + + it('can dispose and cleanup', () => { + const listener = vi.fn(); + provider.onDidChangeCodeLenses(listener); + provider.dispose(); + + // After dispose, firing should not call listener + // (or should throw, depending on implementation) + expect(() => provider.refresh()).not.toThrow(); + }); + }); + + describe('resolveCodeLens', () => { + it('returns the same CodeLens unchanged', () => { + const range = new Range(new Position(0, 0), new Position(0, 10)); + const codeLens = new CodeLens(range, { + title: 'Test', + command: 'test.command' + }); + + const resolved = provider.resolveCodeLens(asCodeLens(codeLens)); + + expect(resolved).toBe(asCodeLens(codeLens)); + }); + }); + + describe('Edge cases', () => { + it('handles empty document', async () => { + const document = mockDoc('', '/workspace/epics.md'); + + const codeLenses = await provider.provideCodeLenses(document); + + expect(codeLenses).toHaveLength(0); + }); + + it('handles document with no stories', async () => { + const content = `## Epic 1: Test Epic + +Just some description without any stories. +`; + const document = mockDoc(content, '/workspace/epics.md'); + + const codeLenses = await provider.provideCodeLenses(document); + + expect(codeLenses).toHaveLength(0); + }); + + it('handles document with epics but stories have no status', async () => { + const content = `## Epic 1: Test Epic + +### Story 1.1: First Story + +No status line here. +`; + const document = mockDoc(content, '/workspace/epics.md'); + + const codeLenses = await provider.provideCodeLenses(document); + + // Story without status is not "ready", so no CodeLens + expect(codeLenses).toHaveLength(0); + }); + }); +}); diff --git a/src/__tests__/techSpecParser.test.ts b/src/__tests__/techSpecParser.test.ts new file mode 100644 index 0000000..43223f4 --- /dev/null +++ b/src/__tests__/techSpecParser.test.ts @@ -0,0 +1,521 @@ +import { describe, it, expect } from 'vitest'; +import { + parseTechSpecTasksFromText, + ParsedTechSpecFile, + ParsedTechSpecTask +} from '../techSpecParser'; + +describe('techSpecParser', () => { + const TEST_FILE_PATH = '/test/tech-spec.md'; + + describe('parseTechSpecTasksFromText', () => { + describe('2.2.1 - task parsing with [ ] checkbox format', () => { + it('parses unchecked task with space in checkbox', () => { + const text = `### Tasks + +- [ ] Task 1.1: First task +`; + const result = parseTechSpecTasksFromText(text, TEST_FILE_PATH); + + expect(result.tasks).toHaveLength(1); + expect(result.tasks[0].taskNumber).toBe('1.1'); + expect(result.tasks[0].taskTitle).toBe('First task'); + expect(result.tasks[0].status).toBe('todo'); + }); + + it('parses multiple unchecked tasks', () => { + const text = `### Tasks + +- [ ] Task 1.1: First task +- [ ] Task 1.2: Second task +- [ ] Task 1.3: Third task +`; + const result = parseTechSpecTasksFromText(text, TEST_FILE_PATH); + + expect(result.tasks).toHaveLength(3); + expect(result.tasks[0].status).toBe('todo'); + expect(result.tasks[1].status).toBe('todo'); + expect(result.tasks[2].status).toBe('todo'); + }); + + it('parses task without checkbox as todo', () => { + const text = `### Tasks + +- Task 1.1: Task without checkbox +`; + const result = parseTechSpecTasksFromText(text, TEST_FILE_PATH); + + expect(result.tasks).toHaveLength(1); + expect(result.tasks[0].status).toBe('todo'); + expect(result.tasks[0].taskTitle).toBe('Task without checkbox'); + }); + }); + + describe('2.2.2 - task parsing with [x] and [X] completed formats', () => { + it('parses lowercase [x] as done', () => { + const text = `### Tasks + +- [x] Task 1.1: Completed task +`; + const result = parseTechSpecTasksFromText(text, TEST_FILE_PATH); + + expect(result.tasks).toHaveLength(1); + expect(result.tasks[0].status).toBe('done'); + }); + + it('parses uppercase [X] as done', () => { + const text = `### Tasks + +- [X] Task 1.1: Completed task +`; + const result = parseTechSpecTasksFromText(text, TEST_FILE_PATH); + + expect(result.tasks).toHaveLength(1); + expect(result.tasks[0].status).toBe('done'); + }); + + it('parses mixed checked and unchecked tasks', () => { + const text = `### Tasks + +- [x] Task 1.1: Done task +- [ ] Task 1.2: Pending task +- [X] Task 1.3: Also done +- [ ] Task 1.4: Another pending +`; + const result = parseTechSpecTasksFromText(text, TEST_FILE_PATH); + + expect(result.tasks).toHaveLength(4); + expect(result.tasks[0].status).toBe('done'); + expect(result.tasks[1].status).toBe('todo'); + expect(result.tasks[2].status).toBe('done'); + expect(result.tasks[3].status).toBe('todo'); + }); + }); + + describe('2.2.3 - multiple ### Tasks sections in one file', () => { + it('only parses tasks from the first Tasks section', () => { + const text = `### Tasks + +- [ ] Task 1.1: First section task 1 +- [ ] Task 1.2: First section task 2 + +### Other Section + +Some content here. + +### Tasks + +- [ ] Task 2.1: Second section task (should be ignored) +- [ ] Task 2.2: Another ignored task +`; + const result = parseTechSpecTasksFromText(text, TEST_FILE_PATH); + + expect(result.tasks).toHaveLength(2); + expect(result.tasks[0].taskNumber).toBe('1.1'); + expect(result.tasks[1].taskNumber).toBe('1.2'); + }); + + it('stops parsing at next heading', () => { + const text = `### Tasks + +- [ ] Task 1.1: Task before heading + +### Notes + +- [ ] Task 1.2: This should not be parsed (after Notes heading) +`; + const result = parseTechSpecTasksFromText(text, TEST_FILE_PATH); + + expect(result.tasks).toHaveLength(1); + expect(result.tasks[0].taskNumber).toBe('1.1'); + }); + + it('stops at h1 heading', () => { + const text = `### Tasks + +- [ ] Task 1.1: First task + +# New Section + +- [ ] Task 1.2: Should not be parsed +`; + const result = parseTechSpecTasksFromText(text, TEST_FILE_PATH); + + expect(result.tasks).toHaveLength(1); + }); + + it('stops at h2 heading', () => { + const text = `### Tasks + +- [ ] Task 1.1: First task + +## New Section + +- [ ] Task 1.2: Should not be parsed +`; + const result = parseTechSpecTasksFromText(text, TEST_FILE_PATH); + + expect(result.tasks).toHaveLength(1); + }); + + it('stops at h3 heading', () => { + const text = `### Tasks + +- [ ] Task 1.1: First task + +### New Section + +- [ ] Task 1.2: Should not be parsed +`; + const result = parseTechSpecTasksFromText(text, TEST_FILE_PATH); + + expect(result.tasks).toHaveLength(1); + }); + }); + + describe('2.2.4 - task numbering patterns', () => { + it('parses single digit task numbers', () => { + const text = `### Tasks + +- [ ] Task 1: Single digit +- [ ] Task 2: Another single +`; + const result = parseTechSpecTasksFromText(text, TEST_FILE_PATH); + + expect(result.tasks).toHaveLength(2); + expect(result.tasks[0].taskNumber).toBe('1'); + expect(result.tasks[1].taskNumber).toBe('2'); + }); + + it('parses two-level task numbers (1.1, 1.2)', () => { + const text = `### Tasks + +- [ ] Task 1.1: First subtask +- [ ] Task 1.2: Second subtask +- [ ] Task 2.1: New group +`; + const result = parseTechSpecTasksFromText(text, TEST_FILE_PATH); + + expect(result.tasks).toHaveLength(3); + expect(result.tasks[0].taskNumber).toBe('1.1'); + expect(result.tasks[1].taskNumber).toBe('1.2'); + expect(result.tasks[2].taskNumber).toBe('2.1'); + }); + + it('parses three-level task numbers (1.1.1)', () => { + const text = `### Tasks + +- [ ] Task 1.1.1: Deep subtask +- [ ] Task 1.1.2: Another deep subtask +`; + const result = parseTechSpecTasksFromText(text, TEST_FILE_PATH); + + expect(result.tasks).toHaveLength(2); + expect(result.tasks[0].taskNumber).toBe('1.1.1'); + expect(result.tasks[1].taskNumber).toBe('1.1.2'); + }); + + it('parses multi-digit numbers in each level', () => { + const text = `### Tasks + +- [ ] Task 12.34: Multi-digit numbers +- [ ] Task 100.200.300: Very large numbers +`; + const result = parseTechSpecTasksFromText(text, TEST_FILE_PATH); + + expect(result.tasks).toHaveLength(2); + expect(result.tasks[0].taskNumber).toBe('12.34'); + expect(result.tasks[1].taskNumber).toBe('100.200.300'); + }); + + it('preserves task number order from file', () => { + const text = `### Tasks + +- [ ] Task 3.1: Third group first +- [ ] Task 1.1: First group first +- [ ] Task 2.1: Second group first +`; + const result = parseTechSpecTasksFromText(text, TEST_FILE_PATH); + + expect(result.tasks[0].taskNumber).toBe('3.1'); + expect(result.tasks[1].taskNumber).toBe('1.1'); + expect(result.tasks[2].taskNumber).toBe('2.1'); + }); + }); + + describe('2.2.5 - markdown cleanup', () => { + it('removes bold markers from task title', () => { + const text = `### Tasks + +- [ ] Task 1.1: **Bold title** +`; + const result = parseTechSpecTasksFromText(text, TEST_FILE_PATH); + + expect(result.tasks[0].taskTitle).toBe('Bold title'); + }); + + it('removes inline code backticks from task title', () => { + const text = `### Tasks + +- [ ] Task 1.1: Add \`configHelper\` function +`; + const result = parseTechSpecTasksFromText(text, TEST_FILE_PATH); + + expect(result.tasks[0].taskTitle).toBe('Add configHelper function'); + }); + + it('removes markdown links but keeps text', () => { + const text = `### Tasks + +- [ ] Task 1.1: See [documentation](https://example.com) for details +`; + const result = parseTechSpecTasksFromText(text, TEST_FILE_PATH); + + expect(result.tasks[0].taskTitle).toBe('See documentation for details'); + }); + + it('handles multiple markdown elements in one title', () => { + const text = `### Tasks + +- [ ] Task 1.1: **Bold** with \`code\` and [link](url) +`; + const result = parseTechSpecTasksFromText(text, TEST_FILE_PATH); + + expect(result.tasks[0].taskTitle).toBe('Bold with code and link'); + }); + + it('trims whitespace from task title', () => { + const text = `### Tasks + +- [ ] Task 1.1: Spaced title +`; + const result = parseTechSpecTasksFromText(text, TEST_FILE_PATH); + + expect(result.tasks[0].taskTitle).toBe('Spaced title'); + }); + + it('handles bold Task keyword in line', () => { + const text = `### Tasks + +- [ ] **Task 1.1**: Bold task keyword +`; + const result = parseTechSpecTasksFromText(text, TEST_FILE_PATH); + + expect(result.tasks).toHaveLength(1); + expect(result.tasks[0].taskNumber).toBe('1.1'); + expect(result.tasks[0].taskTitle).toBe('Bold task keyword'); + }); + + it('handles bold around entire task definition', () => { + const text = `### Tasks + +- [ ] **Task 1.1: Entire bold line** +`; + const result = parseTechSpecTasksFromText(text, TEST_FILE_PATH); + + expect(result.tasks).toHaveLength(1); + expect(result.tasks[0].taskTitle).toBe('Entire bold line'); + }); + }); + + describe('2.2.6 - edge cases', () => { + it('returns empty tasks array for empty file', () => { + const result = parseTechSpecTasksFromText('', TEST_FILE_PATH); + + expect(result.filePath).toBe(TEST_FILE_PATH); + expect(result.tasks).toHaveLength(0); + }); + + it('returns empty tasks array for file with only whitespace', () => { + const result = parseTechSpecTasksFromText(' \n\n \t\n', TEST_FILE_PATH); + + expect(result.tasks).toHaveLength(0); + }); + + it('returns empty tasks array when no Tasks section exists', () => { + const text = `# Tech Spec + +## Overview + +Some content here. + +### Implementation + +More content. +`; + const result = parseTechSpecTasksFromText(text, TEST_FILE_PATH); + + expect(result.tasks).toHaveLength(0); + }); + + it('returns empty tasks array for empty Tasks section', () => { + const text = `### Tasks + +### Next Section + +Content here. +`; + const result = parseTechSpecTasksFromText(text, TEST_FILE_PATH); + + expect(result.tasks).toHaveLength(0); + }); + + it('returns empty tasks array for Tasks section with non-task content', () => { + const text = `### Tasks + +Some description text. +- Regular bullet point +- Another bullet + +### Next Section +`; + const result = parseTechSpecTasksFromText(text, TEST_FILE_PATH); + + expect(result.tasks).toHaveLength(0); + }); + + it('handles Tasks section at end of file', () => { + const text = `# Tech Spec + +### Tasks + +- [ ] Task 1.1: Last section task`; + const result = parseTechSpecTasksFromText(text, TEST_FILE_PATH); + + expect(result.tasks).toHaveLength(1); + expect(result.tasks[0].taskNumber).toBe('1.1'); + }); + + it('handles file ending without newline', () => { + const text = `### Tasks + +- [ ] Task 1.1: No trailing newline`; + const result = parseTechSpecTasksFromText(text, TEST_FILE_PATH); + + expect(result.tasks).toHaveLength(1); + }); + + it('handles indented task lines', () => { + const text = `### Tasks + + - [ ] Task 1.1: Indented with spaces + - [ ] Task 1.2: More indented +`; + const result = parseTechSpecTasksFromText(text, TEST_FILE_PATH); + + expect(result.tasks).toHaveLength(2); + }); + + it('ignores lines that almost match task pattern', () => { + const text = `### Tasks + +- [ ] Task: Missing number +- [ ] 1.1: Missing Task keyword +Task 1.1: Missing leading dash +`; + const result = parseTechSpecTasksFromText(text, TEST_FILE_PATH); + + expect(result.tasks).toHaveLength(0); + }); + + it('parses task line without colon after number (title becomes rest of line)', () => { + // The regex pattern is: Task\s+(\d+(?:\.\d+)*)\s*:\s*(.+) + // This requires a colon, so "Task 1.1 Missing colon" should NOT match + const text = `### Tasks + +- [ ] Task 1.1 Missing colon +`; + const result = parseTechSpecTasksFromText(text, TEST_FILE_PATH); + + // Actually testing - the regex requires : so this should fail + expect(result.tasks).toHaveLength(0); + }); + + it('handles Tasks header with extra text', () => { + const text = `### Tasks for Phase 1 + +- [ ] Task 1.1: Should still be parsed +`; + const result = parseTechSpecTasksFromText(text, TEST_FILE_PATH); + + expect(result.tasks).toHaveLength(1); + }); + }); + + describe('line number tracking', () => { + it('tracks task line numbers correctly (0-based)', () => { + // Line 0: ### Tasks + // Line 1: (empty) + // Line 2: - [ ] Task 1.1: First + // Line 3: - [ ] Task 1.2: Second + const text = `### Tasks + +- [ ] Task 1.1: First +- [ ] Task 1.2: Second +`; + const result = parseTechSpecTasksFromText(text, TEST_FILE_PATH); + + expect(result.tasks[0].lineNumber).toBe(2); + expect(result.tasks[1].lineNumber).toBe(3); + }); + + it('tracks line numbers with content before Tasks section', () => { + // Line 0: # Tech Spec + // Line 1: (empty) + // Line 2: ## Overview + // Line 3: (empty) + // Line 4: Some content. + // Line 5: (empty) + // Line 6: ### Tasks + // Line 7: (empty) + // Line 8: - [ ] Task 1.1: First + const text = `# Tech Spec + +## Overview + +Some content. + +### Tasks + +- [ ] Task 1.1: First +`; + const result = parseTechSpecTasksFromText(text, TEST_FILE_PATH); + + expect(result.tasks[0].lineNumber).toBe(8); + }); + }); + + describe('return structure', () => { + it('returns correct ParsedTechSpecFile structure', () => { + const text = `### Tasks + +- [ ] Task 1.1: Test +`; + const result: ParsedTechSpecFile = parseTechSpecTasksFromText(text, TEST_FILE_PATH); + + expect(result).toHaveProperty('filePath'); + expect(result).toHaveProperty('tasks'); + expect(result.filePath).toBe(TEST_FILE_PATH); + expect(Array.isArray(result.tasks)).toBe(true); + }); + + it('returns correct ParsedTechSpecTask structure', () => { + const text = `### Tasks + +- [x] Task 1.1: Test task +`; + const result = parseTechSpecTasksFromText(text, TEST_FILE_PATH); + const task: ParsedTechSpecTask = result.tasks[0]; + + expect(task).toHaveProperty('taskNumber'); + expect(task).toHaveProperty('taskTitle'); + expect(task).toHaveProperty('status'); + expect(task).toHaveProperty('lineNumber'); + expect(typeof task.taskNumber).toBe('string'); + expect(typeof task.taskTitle).toBe('string'); + expect(['done', 'todo']).toContain(task.status); + expect(typeof task.lineNumber).toBe('number'); + }); + }); + }); +}); diff --git a/src/__tests__/techSpecTreeProvider.test.ts b/src/__tests__/techSpecTreeProvider.test.ts new file mode 100644 index 0000000..605dc87 --- /dev/null +++ b/src/__tests__/techSpecTreeProvider.test.ts @@ -0,0 +1,544 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { + workspace, + window, + Uri, + TreeItemCollapsibleState, + ThemeIcon, + ThemeColor, + resetAllMocks +} from './mocks/vscode'; +import { TechSpecTreeProvider } from '../techSpecTreeProvider'; + +// Mock the bmadConfig module +vi.mock('../bmadConfig', () => ({ + getImplementationArtifactsPath: vi.fn() +})); + +import { getImplementationArtifactsPath } from '../bmadConfig'; + +describe('techSpecTreeProvider', () => { + let provider: TechSpecTreeProvider; + + beforeEach(() => { + resetAllMocks(); + provider = new TechSpecTreeProvider(); + + // Default workspace setup + workspace.workspaceFolders = [ + { uri: Uri.file('/workspace'), name: 'workspace', index: 0 } + ]; + }); + + afterEach(() => { + provider.dispose(); + }); + + describe('5.2.1 - tree item creation from parsed tasks', () => { + it('creates file tree items from parsed tech spec files', async () => { + const techSpecContent = `### Tasks + +- [ ] Task 1.1: First task +- [x] Task 1.2: Second task +`; + vi.mocked(getImplementationArtifactsPath).mockResolvedValue('_bmad-output/impl'); + vi.mocked(workspace.findFiles).mockResolvedValue([ + Uri.file('/workspace/_bmad-output/impl/tech-spec-1.md') + ]); + vi.mocked(workspace.fs.readFile).mockResolvedValue( + new TextEncoder().encode(techSpecContent) + ); + + const children = await provider.getChildren(); + + expect(children).toHaveLength(1); + expect(children[0].type).toBe('file'); + expect(children[0].filePath).toBe('/workspace/_bmad-output/impl/tech-spec-1.md'); + }); + + it('creates task tree items from file children', async () => { + const techSpecContent = `### Tasks + +- [ ] Task 1.1: First task +- [x] Task 1.2: Second task +- [ ] Task 1.3: Third task +`; + vi.mocked(getImplementationArtifactsPath).mockResolvedValue('_bmad-output/impl'); + vi.mocked(workspace.findFiles).mockResolvedValue([ + Uri.file('/workspace/_bmad-output/impl/tech-spec-1.md') + ]); + vi.mocked(workspace.fs.readFile).mockResolvedValue( + new TextEncoder().encode(techSpecContent) + ); + + const files = await provider.getChildren(); + const tasks = await provider.getChildren(files[0]); + + expect(tasks).toHaveLength(3); + expect(tasks[0].type).toBe('task'); + expect(tasks[1].type).toBe('task'); + expect(tasks[2].type).toBe('task'); + }); + + it('returns empty array for task children', async () => { + const techSpecContent = `### Tasks + +- [ ] Task 1.1: First task +`; + vi.mocked(getImplementationArtifactsPath).mockResolvedValue('_bmad-output/impl'); + vi.mocked(workspace.findFiles).mockResolvedValue([ + Uri.file('/workspace/_bmad-output/impl/tech-spec-1.md') + ]); + vi.mocked(workspace.fs.readFile).mockResolvedValue( + new TextEncoder().encode(techSpecContent) + ); + + const files = await provider.getChildren(); + const tasks = await provider.getChildren(files[0]); + const taskChildren = await provider.getChildren(tasks[0]); + + expect(taskChildren).toHaveLength(0); + }); + + it('returns empty array when no tech spec files found', async () => { + vi.mocked(getImplementationArtifactsPath).mockResolvedValue('_bmad-output/impl'); + vi.mocked(workspace.findFiles).mockResolvedValue([]); + + const children = await provider.getChildren(); + + expect(children).toHaveLength(0); + }); + + it('handles multiple tech spec files', async () => { + const techSpecContent = `### Tasks + +- [ ] Task 1.1: A task +`; + vi.mocked(getImplementationArtifactsPath).mockResolvedValue('_bmad-output/impl'); + vi.mocked(workspace.findFiles).mockResolvedValue([ + Uri.file('/workspace/_bmad-output/impl/tech-spec-1.md'), + Uri.file('/workspace/_bmad-output/impl/tech-spec-2.md') + ]); + vi.mocked(workspace.fs.readFile).mockResolvedValue( + new TextEncoder().encode(techSpecContent) + ); + + const children = await provider.getChildren(); + + expect(children).toHaveLength(2); + }); + }); + + describe('5.2.2 - config path resolution with fallback', () => { + it('falls back to VS Code setting when config path is null', async () => { + const freshProvider = new TechSpecTreeProvider(); + vi.mocked(getImplementationArtifactsPath).mockResolvedValue(null); + vi.mocked(workspace.getConfiguration).mockReturnValue({ + get: vi.fn(() => '**/custom-tech-spec-*.md'), + has: vi.fn(), + inspect: vi.fn(), + update: vi.fn() + }); + + await freshProvider.getChildren(); + + expect(workspace.findFiles).toHaveBeenCalledWith('**/custom-tech-spec-*.md'); + expect(window.showWarningMessage).not.toHaveBeenCalled(); + + freshProvider.dispose(); + }); + + it('uses default pattern when config setting is missing', async () => { + const freshProvider = new TechSpecTreeProvider(); + vi.mocked(getImplementationArtifactsPath).mockResolvedValue(null); + vi.mocked(workspace.getConfiguration).mockReturnValue({ + get: vi.fn((_key, defaultValue) => defaultValue), + has: vi.fn(), + inspect: vi.fn(), + update: vi.fn() + }); + + await freshProvider.getChildren(); + + expect(workspace.findFiles).toHaveBeenCalledWith('**/tech-spec-*.md'); + + freshProvider.dispose(); + }); + + it('uses implementation_artifacts path from config', async () => { + const techSpecContent = `### Tasks + +- [ ] Task 1.1: A task +`; + vi.mocked(getImplementationArtifactsPath).mockResolvedValue('custom/impl/path'); + vi.mocked(workspace.findFiles).mockResolvedValue([ + Uri.file('/workspace/custom/impl/path/tech-spec-1.md') + ]); + vi.mocked(workspace.fs.readFile).mockResolvedValue( + new TextEncoder().encode(techSpecContent) + ); + + await provider.getChildren(); + + // Verify findFiles was called with the correct pattern + expect(workspace.findFiles).toHaveBeenCalledWith('custom/impl/path/**/tech-spec-*.md'); + }); + + it('handles error when findFiles fails', async () => { + vi.mocked(getImplementationArtifactsPath).mockResolvedValue('_bmad-output/impl'); + vi.mocked(workspace.findFiles).mockRejectedValue(new Error('Workspace error')); + + const children = await provider.getChildren(); + + expect(children).toHaveLength(0); + }); + }); + + describe('5.2.3 - checkbox status display', () => { + it('assigns check icon with green color for done tasks', async () => { + const techSpecContent = `### Tasks + +- [x] Task 1.1: Done task +`; + vi.mocked(getImplementationArtifactsPath).mockResolvedValue('_bmad-output/impl'); + vi.mocked(workspace.findFiles).mockResolvedValue([ + Uri.file('/workspace/tech-spec.md') + ]); + vi.mocked(workspace.fs.readFile).mockResolvedValue( + new TextEncoder().encode(techSpecContent) + ); + + const files = await provider.getChildren(); + const tasks = await provider.getChildren(files[0]); + const treeItem = provider.getTreeItem(tasks[0]); + + const icon = treeItem.iconPath as ThemeIcon; + expect(icon.id).toBe('check'); + expect(icon.color).toBeInstanceOf(ThemeColor); + expect((icon.color as ThemeColor).id).toBe('charts.green'); + }); + + it('assigns play icon for todo tasks', async () => { + const techSpecContent = `### Tasks + +- [ ] Task 1.1: Todo task +`; + vi.mocked(getImplementationArtifactsPath).mockResolvedValue('_bmad-output/impl'); + vi.mocked(workspace.findFiles).mockResolvedValue([ + Uri.file('/workspace/tech-spec.md') + ]); + vi.mocked(workspace.fs.readFile).mockResolvedValue( + new TextEncoder().encode(techSpecContent) + ); + + const files = await provider.getChildren(); + const tasks = await provider.getChildren(files[0]); + const treeItem = provider.getTreeItem(tasks[0]); + + const icon = treeItem.iconPath as ThemeIcon; + expect(icon.id).toBe('play'); + }); + + it('assigns file-text icon for file items', async () => { + const techSpecContent = `### Tasks + +- [ ] Task 1.1: A task +`; + vi.mocked(getImplementationArtifactsPath).mockResolvedValue('_bmad-output/impl'); + vi.mocked(workspace.findFiles).mockResolvedValue([ + Uri.file('/workspace/tech-spec.md') + ]); + vi.mocked(workspace.fs.readFile).mockResolvedValue( + new TextEncoder().encode(techSpecContent) + ); + + const files = await provider.getChildren(); + const treeItem = provider.getTreeItem(files[0]); + + const icon = treeItem.iconPath as ThemeIcon; + expect(icon.id).toBe('file-text'); + }); + }); + + describe('5.2.4 - reveal command arguments', () => { + it('sets revealTask command on task items', async () => { + const techSpecContent = `### Tasks + +- [ ] Task 1.1: Test task +`; + vi.mocked(getImplementationArtifactsPath).mockResolvedValue('_bmad-output/impl'); + vi.mocked(workspace.findFiles).mockResolvedValue([ + Uri.file('/workspace/tech-spec.md') + ]); + vi.mocked(workspace.fs.readFile).mockResolvedValue( + new TextEncoder().encode(techSpecContent) + ); + + const files = await provider.getChildren(); + const tasks = await provider.getChildren(files[0]); + const treeItem = provider.getTreeItem(tasks[0]); + + expect(treeItem.command).toBeDefined(); + expect(treeItem.command?.command).toBe('bmadMethod.revealTask'); + }); + + it('passes file path as first argument', async () => { + const techSpecContent = `### Tasks + +- [ ] Task 1.1: Test task +`; + const filePath = '/workspace/_bmad-output/impl/tech-spec-story.md'; + vi.mocked(getImplementationArtifactsPath).mockResolvedValue('_bmad-output/impl'); + vi.mocked(workspace.findFiles).mockResolvedValue([ + Uri.file(filePath) + ]); + vi.mocked(workspace.fs.readFile).mockResolvedValue( + new TextEncoder().encode(techSpecContent) + ); + + const files = await provider.getChildren(); + const tasks = await provider.getChildren(files[0]); + const treeItem = provider.getTreeItem(tasks[0]); + + expect(treeItem.command?.arguments?.[0]).toBe(filePath); + }); + + it('passes line number as second argument', async () => { + const techSpecContent = `# Tech Spec + +## Overview + +Description here. + +### Tasks + +- [ ] Task 1.1: Test task +`; + vi.mocked(getImplementationArtifactsPath).mockResolvedValue('_bmad-output/impl'); + vi.mocked(workspace.findFiles).mockResolvedValue([ + Uri.file('/workspace/tech-spec.md') + ]); + vi.mocked(workspace.fs.readFile).mockResolvedValue( + new TextEncoder().encode(techSpecContent) + ); + + const files = await provider.getChildren(); + const tasks = await provider.getChildren(files[0]); + const treeItem = provider.getTreeItem(tasks[0]); + + // Line number should be present and be a number + expect(typeof treeItem.command?.arguments?.[1]).toBe('number'); + }); + + it('does not set command on file items', async () => { + const techSpecContent = `### Tasks + +- [ ] Task 1.1: Test task +`; + vi.mocked(getImplementationArtifactsPath).mockResolvedValue('_bmad-output/impl'); + vi.mocked(workspace.findFiles).mockResolvedValue([ + Uri.file('/workspace/tech-spec.md') + ]); + vi.mocked(workspace.fs.readFile).mockResolvedValue( + new TextEncoder().encode(techSpecContent) + ); + + const files = await provider.getChildren(); + const treeItem = provider.getTreeItem(files[0]); + + expect(treeItem.command).toBeUndefined(); + }); + }); + + describe('getTreeItem', () => { + it('sets tooltip for file items', async () => { + const techSpecContent = `### Tasks + +- [ ] Task 1.1: Test task +`; + const filePath = '/workspace/tech-spec.md'; + vi.mocked(getImplementationArtifactsPath).mockResolvedValue('_bmad-output/impl'); + vi.mocked(workspace.findFiles).mockResolvedValue([ + Uri.file(filePath) + ]); + vi.mocked(workspace.fs.readFile).mockResolvedValue( + new TextEncoder().encode(techSpecContent) + ); + + const files = await provider.getChildren(); + const treeItem = provider.getTreeItem(files[0]); + + expect(treeItem.tooltip).toBe(filePath); + }); + + it('sets tooltip with status for task items', async () => { + const techSpecContent = `### Tasks + +- [ ] Task 1.1: Test task +`; + vi.mocked(getImplementationArtifactsPath).mockResolvedValue('_bmad-output/impl'); + vi.mocked(workspace.findFiles).mockResolvedValue([ + Uri.file('/workspace/tech-spec.md') + ]); + vi.mocked(workspace.fs.readFile).mockResolvedValue( + new TextEncoder().encode(techSpecContent) + ); + + const files = await provider.getChildren(); + const tasks = await provider.getChildren(files[0]); + const treeItem = provider.getTreeItem(tasks[0]); + + expect(treeItem.tooltip).toBe('Task 1.1: Test task (todo)'); + }); + + it('sets correct tooltip for done task', async () => { + const techSpecContent = `### Tasks + +- [x] Task 1.1: Completed task +`; + vi.mocked(getImplementationArtifactsPath).mockResolvedValue('_bmad-output/impl'); + vi.mocked(workspace.findFiles).mockResolvedValue([ + Uri.file('/workspace/tech-spec.md') + ]); + vi.mocked(workspace.fs.readFile).mockResolvedValue( + new TextEncoder().encode(techSpecContent) + ); + + const files = await provider.getChildren(); + const tasks = await provider.getChildren(files[0]); + const treeItem = provider.getTreeItem(tasks[0]); + + expect(treeItem.tooltip).toBe('Task 1.1: Completed task (done)'); + }); + }); + + describe('collapsible state', () => { + it('file items are expanded by default', async () => { + const techSpecContent = `### Tasks + +- [ ] Task 1.1: Test task +`; + vi.mocked(getImplementationArtifactsPath).mockResolvedValue('_bmad-output/impl'); + vi.mocked(workspace.findFiles).mockResolvedValue([ + Uri.file('/workspace/tech-spec.md') + ]); + vi.mocked(workspace.fs.readFile).mockResolvedValue( + new TextEncoder().encode(techSpecContent) + ); + + const files = await provider.getChildren(); + + expect(files[0].collapsibleState).toBe(TreeItemCollapsibleState.Expanded); + }); + + it('task items are not collapsible', async () => { + const techSpecContent = `### Tasks + +- [ ] Task 1.1: Test task +`; + vi.mocked(getImplementationArtifactsPath).mockResolvedValue('_bmad-output/impl'); + vi.mocked(workspace.findFiles).mockResolvedValue([ + Uri.file('/workspace/tech-spec.md') + ]); + vi.mocked(workspace.fs.readFile).mockResolvedValue( + new TextEncoder().encode(techSpecContent) + ); + + const files = await provider.getChildren(); + const tasks = await provider.getChildren(files[0]); + + expect(tasks[0].collapsibleState).toBe(TreeItemCollapsibleState.None); + }); + }); + + describe('refresh and dispose', () => { + it('refresh method triggers tree data change event', async () => { + vi.useFakeTimers(); + + const eventFired = vi.fn(); + provider.onDidChangeTreeData(eventFired); + + provider.refresh(); + + // Advance past debounce + vi.advanceTimersByTime(400); + + expect(eventFired).toHaveBeenCalled(); + + vi.useRealTimers(); + }); + + it('debounces refresh calls', async () => { + vi.useFakeTimers(); + + const eventFired = vi.fn(); + provider.onDidChangeTreeData(eventFired); + + // Multiple rapid refreshes + provider.refresh(); + provider.refresh(); + provider.refresh(); + + // Advance past debounce + vi.advanceTimersByTime(400); + + // Should only fire once + expect(eventFired).toHaveBeenCalledTimes(1); + + vi.useRealTimers(); + }); + + it('dispose clears refresh timeout', () => { + vi.useFakeTimers(); + + provider.refresh(); + + // Dispose before timeout fires + provider.dispose(); + + // Advance timer - should not throw + vi.advanceTimersByTime(400); + + vi.useRealTimers(); + }); + }); + + describe('error handling', () => { + it('continues processing when one file fails to read', async () => { + const techSpecContent = `### Tasks + +- [ ] Task 1.1: Test task +`; + vi.mocked(getImplementationArtifactsPath).mockResolvedValue('_bmad-output/impl'); + vi.mocked(workspace.findFiles).mockResolvedValue([ + Uri.file('/workspace/bad-file.md'), + Uri.file('/workspace/good-file.md') + ]); + + // First file fails, second succeeds + vi.mocked(workspace.fs.readFile) + .mockRejectedValueOnce(new Error('Permission denied')) + .mockResolvedValueOnce(new TextEncoder().encode(techSpecContent)); + + const children = await provider.getChildren(); + + // Should still have one file (the good one) + expect(children).toHaveLength(1); + }); + + it('returns empty array when all files fail to read', async () => { + vi.mocked(getImplementationArtifactsPath).mockResolvedValue('_bmad-output/impl'); + vi.mocked(workspace.findFiles).mockResolvedValue([ + Uri.file('/workspace/bad-file1.md'), + Uri.file('/workspace/bad-file2.md') + ]); + + vi.mocked(workspace.fs.readFile) + .mockRejectedValue(new Error('Permission denied')); + + const children = await provider.getChildren(); + + expect(children).toHaveLength(0); + }); + }); +}); diff --git a/src/bmadConfig.ts b/src/bmadConfig.ts new file mode 100644 index 0000000..ff9d0b2 --- /dev/null +++ b/src/bmadConfig.ts @@ -0,0 +1,53 @@ +import * as vscode from 'vscode'; + +const CONFIG_PATH = '_bmad/bmm/config.yaml'; + +/** + * Read a path value from BMAD config.yaml by field name. + * Handles both {project-root}/path and plain path formats. + * Returns the resolved path relative to workspace root, or null if not found. + */ +async function getConfigPath(fieldName: string): Promise { + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders || workspaceFolders.length === 0) { + return null; + } + + const workspaceRoot = workspaceFolders[0].uri; + const configUri = vscode.Uri.joinPath(workspaceRoot, CONFIG_PATH); + + try { + const content = await vscode.workspace.fs.readFile(configUri); + const text = Buffer.from(content).toString('utf8'); + + // Look for: fieldName: "{project-root}/some/path" or fieldName: "some/path" + const pattern = new RegExp(`^${fieldName}:\\s*["']?(.+?)["']?\\s*$`, 'm'); + const match = text.match(pattern); + + if (match) { + // Strip {project-root}/ prefix if present + return match[1].replace(/^\{project-root\}\//, ''); + } + + return null; + } catch (error: unknown) { + console.log(`[BMAD] Could not read config at ${configUri.fsPath}: ${error}`); + return null; + } +} + +/** + * Read the planning_artifacts path from BMAD config.yaml + * Returns the resolved path relative to workspace root, or null if not found + */ +export async function getPlanningArtifactsPath(): Promise { + return getConfigPath('planning_artifacts'); +} + +/** + * Read the implementation_artifacts path from BMAD config.yaml + * Returns the resolved path relative to workspace root, or null if not found + */ +export async function getImplementationArtifactsPath(): Promise { + return getConfigPath('implementation_artifacts'); +} diff --git a/src/cliTool.ts b/src/cliTool.ts new file mode 100644 index 0000000..d0e785d --- /dev/null +++ b/src/cliTool.ts @@ -0,0 +1,36 @@ +export const DEFAULT_CLI_TOOL = 'claude'; + +const SAFE_TOOL_PATTERN = /^[A-Za-z0-9._\\/: -]+$/; + +export function normalizeCliTool(configuredTool: string | undefined): string { + const trimmedTool = configuredTool?.trim(); + + return trimmedTool ? trimmedTool : DEFAULT_CLI_TOOL; +} + +export function isSafeCliTool(tool: string): boolean { + return SAFE_TOOL_PATTERN.test(tool); +} + +export function quoteCliTool(tool: string): string { + if (!/[\s"]/u.test(tool)) { + return tool; + } + + const escaped = tool.replace(/(["\\])/g, '\\$1'); + return `"${escaped}"`; +} + +export function buildCliCommand(tool: string, workflow: 'create-story' | 'dev-story', storyNumber?: string): string { + const prompt = storyNumber + ? `/bmad:bmm:workflows:${workflow} ${storyNumber}` + : `/bmad:bmm:workflows:${workflow}`; + + return `${quoteCliTool(tool)} "${prompt}"`; +} + +export function getWhichCommand(platform: NodeJS.Platform, tool: string): { cmd: string; args: string[] } { + const cmd = platform === 'win32' ? 'where' : 'which'; + + return { cmd, args: [tool] }; +} diff --git a/src/epicTreeProvider.ts b/src/epicTreeProvider.ts new file mode 100644 index 0000000..57e5261 --- /dev/null +++ b/src/epicTreeProvider.ts @@ -0,0 +1,200 @@ +import * as vscode from 'vscode'; +import { ParsedFile, ParsedEpic, ParsedStory, parseEpicsFromText } from './epicsParser'; +import { getPlanningArtifactsPath } from './bmadConfig'; + +type TreeNodeType = 'file' | 'epic' | 'story'; + +class EpicTreeItem extends vscode.TreeItem { + constructor( + public readonly type: TreeNodeType, + public readonly data: ParsedFile | ParsedEpic | ParsedStory, + public readonly filePath: string, + label: string, + collapsibleState: vscode.TreeItemCollapsibleState + ) { + super(label, collapsibleState); + this.contextValue = type; + } +} + +export class EpicTreeProvider implements vscode.TreeDataProvider, vscode.Disposable { + private _onDidChangeTreeData = new vscode.EventEmitter(); + readonly onDidChangeTreeData = this._onDidChangeTreeData.event; + + private parsedFiles: ParsedFile[] = []; + private isLoading = false; + private pendingRefresh = false; + private refreshTimeout: NodeJS.Timeout | undefined; + + dispose(): void { + if (this.refreshTimeout) { + clearTimeout(this.refreshTimeout); + this.refreshTimeout = undefined; + } + this._onDidChangeTreeData.dispose(); + } + + refresh(): void { + // Debounce refresh to prevent rapid re-parsing + if (this.refreshTimeout) { + clearTimeout(this.refreshTimeout); + } + this.refreshTimeout = setTimeout(() => { + this._onDidChangeTreeData.fire(); + this.refreshTimeout = undefined; + }, 300); + } + + async getChildren(element?: EpicTreeItem): Promise { + if (!element) { + // Root level: return files + await this.loadFiles(); + return this.parsedFiles.map(file => { + const fileUri = vscode.Uri.file(file.filePath); + const workspaceFolder = vscode.workspace.getWorkspaceFolder(fileUri); + const relativePath = workspaceFolder + ? vscode.workspace.asRelativePath(fileUri) + : file.filePath; + + return new EpicTreeItem( + 'file', + file, + file.filePath, + relativePath, + vscode.TreeItemCollapsibleState.Expanded + ); + }); + } + + if (element.type === 'file') { + // File level: return epics + const file = element.data as ParsedFile; + return file.epics.map(epic => { + return new EpicTreeItem( + 'epic', + epic, + element.filePath, + `Epic ${epic.epicNumber}: ${epic.epicTitle}`, + vscode.TreeItemCollapsibleState.Collapsed + ); + }); + } + + if (element.type === 'epic') { + // Epic level: return stories + const epic = element.data as ParsedEpic; + return epic.stories.map(story => { + return new EpicTreeItem( + 'story', + story, + element.filePath, + `Story ${story.storyNumber}: ${story.storyTitle}`, + vscode.TreeItemCollapsibleState.None + ); + }); + } + + return []; + } + + getTreeItem(element: EpicTreeItem): vscode.TreeItem { + const item = element; + + // Set icons based on type + if (element.type === 'file') { + item.iconPath = new vscode.ThemeIcon('file-text'); + item.tooltip = element.filePath; + } else if (element.type === 'epic') { + const epic = element.data as ParsedEpic; + item.iconPath = new vscode.ThemeIcon('symbol-class'); + item.tooltip = `Epic ${epic.epicNumber}: ${epic.epicTitle}`; + } else if (element.type === 'story') { + const story = element.data as ParsedStory; + + // Set icon and color based on status + const iconInfo = this.getStoryIcon(story.status); + item.iconPath = iconInfo.icon; + item.tooltip = `Story ${story.storyNumber}: ${story.storyTitle} (${story.status})`; + + // Set command for click-to-reveal + item.command = { + command: 'bmadMethod.revealStory', + title: 'Reveal Story', + arguments: [element.filePath, story.lineNumber] + }; + } + + return item; + } + + private getStoryIcon(status: string): { icon: vscode.ThemeIcon } { + switch (status) { + case 'ready': + return { icon: new vscode.ThemeIcon('new-file', new vscode.ThemeColor('charts.green')) }; + case 'in-progress': + return { icon: new vscode.ThemeIcon('arrow-right', new vscode.ThemeColor('charts.blue')) }; + case 'done': + return { icon: new vscode.ThemeIcon('check', new vscode.ThemeColor('charts.green')) }; + case 'blocked': + return { icon: new vscode.ThemeIcon('error', new vscode.ThemeColor('charts.red')) }; + default: + return { icon: new vscode.ThemeIcon('question') }; + } + } + + private async loadFiles(): Promise { + // If already loading, mark that a refresh is pending + if (this.isLoading) { + this.pendingRefresh = true; + return; + } + + this.isLoading = true; + + try { + // Get planning_artifacts path from BMAD config, fall back to VS Code setting + const planningPath = await getPlanningArtifactsPath(); + let pattern: string; + + if (planningPath) { + // Scope search to the planning artifacts directory + pattern = `${planningPath}/**/*epic*.md`; + console.log(`[BMAD] Using planning_artifacts path from config: ${planningPath}`); + } else { + // Fall back to VS Code setting if config not found + const config = vscode.workspace.getConfiguration('bmad'); + pattern = config.get('epicFilePattern', '**/*epic*.md'); + console.log(`[BMAD] No BMAD config found, using VS Code setting`); + } + + try { + const files = await vscode.workspace.findFiles(pattern); + console.log(`[BMAD] Found ${files.length} epics files matching pattern: ${pattern}`); + + this.parsedFiles = []; + + for (const fileUri of files) { + try { + const content = await vscode.workspace.fs.readFile(fileUri); + const text = Buffer.from(content).toString('utf8'); + const parsed = parseEpicsFromText(text, fileUri.fsPath); + this.parsedFiles.push(parsed); + } catch (error: unknown) { + console.log(`[BMAD] Error parsing file ${fileUri.fsPath}: ${error}`); + // Continue with other files - graceful degradation + } + } + } catch (error: unknown) { + console.log(`[BMAD] Error finding epics files: ${error}`); + this.parsedFiles = []; + } + } finally { + this.isLoading = false; + // If a refresh was requested while loading, trigger another load + if (this.pendingRefresh) { + this.pendingRefresh = false; + await this.loadFiles(); + } + } + } +} diff --git a/src/epicsParser.ts b/src/epicsParser.ts new file mode 100644 index 0000000..ac1d638 --- /dev/null +++ b/src/epicsParser.ts @@ -0,0 +1,116 @@ +// Shared parsing interfaces and functions for epics.md files +// VS Code-agnostic - can be used by both TreeProvider and CodeLensProvider + +export interface ParsedStory { + storyNumber: string; // e.g., "1.1" + storyTitle: string; // e.g., "Scan Workspace for Epics Files" + status: string; // e.g., "ready", "in-progress", "done", "blocked", "unknown" + lineNumber: number; // 0-based line number in file +} + +export interface ParsedEpic { + epicNumber: number; // e.g., 1 + epicTitle: string; // e.g., "Core Story Discovery & Display" + lineNumber: number; // 0-based line number in file + stories: ParsedStory[]; +} + +export interface ParsedFile { + filePath: string; // Absolute path to the file + epics: ParsedEpic[]; +} + +// Regex patterns for parsing +export const EPIC_HEADER_PATTERN = /^##\s+Epic\s+(\d+):\s*(.+)$/; +export const STORY_HEADER_PATTERN = /^###\s+Story\s+(\d+\.\d+):\s*(.+)$/; +export const STATUS_PATTERN = /^\*\*Status:\*\*\s*(\S+)/; + +/** + * Parse an epics.md file and return structured data + * @param text The full text content of the file + * @param filePath The absolute path to the file + * @returns ParsedFile with nested epics and stories + */ +export function parseEpicsFromText(text: string, filePath: string): ParsedFile { + const epics: ParsedEpic[] = []; + + try { + const lines = text.split('\n'); + + let currentEpic: ParsedEpic | null = null; + let currentStory: ParsedStory | null = null; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + // Check for Epic header + const epicMatch = line.match(EPIC_HEADER_PATTERN); + if (epicMatch) { + // Save previous epic if exists + if (currentEpic) { + epics.push(currentEpic); + } + + // Validate epic number + const epicNumber = parseInt(epicMatch[1], 10); + if (isNaN(epicNumber) || epicNumber < 0) { + console.log(`[BMAD] Warning: Invalid epic number '${epicMatch[1]}' at line ${i + 1} in ${filePath}`); + continue; + } + + // Start new epic + currentEpic = { + epicNumber, + epicTitle: epicMatch[2].trim(), + lineNumber: i, + stories: [] + }; + currentStory = null; + continue; + } + + // Check for Story header + const storyMatch = line.match(STORY_HEADER_PATTERN); + if (storyMatch) { + // Create new story + currentStory = { + storyNumber: storyMatch[1], + storyTitle: storyMatch[2].trim(), + status: 'unknown', + lineNumber: i + }; + + // Add to current epic if exists + if (currentEpic) { + currentEpic.stories.push(currentStory); + } else { + // Story without epic - log warning but don't crash + console.log(`[BMAD] Warning: Story ${currentStory.storyNumber} found without an epic at line ${i + 1} in ${filePath}`); + } + continue; + } + + // Check for Status if we're in a story + if (currentStory) { + const statusMatch = line.match(STATUS_PATTERN); + if (statusMatch) { + currentStory.status = statusMatch[1].toLowerCase(); + currentStory = null; // Done parsing this story + } + } + } + + // Save final epic if exists + if (currentEpic) { + epics.push(currentEpic); + } + } catch (error) { + console.log(`[BMAD] Error parsing file ${filePath}: ${error}`); + // Return empty result on parse error - graceful degradation + } + + return { + filePath, + epics + }; +} diff --git a/src/extension.ts b/src/extension.ts new file mode 100644 index 0000000..8c04721 --- /dev/null +++ b/src/extension.ts @@ -0,0 +1,285 @@ +import * as vscode from 'vscode'; +import { StoryCodeLensProvider } from './storyCodeLensProvider'; +import { EpicTreeProvider } from './epicTreeProvider'; +import { TechSpecTreeProvider } from './techSpecTreeProvider'; +import { execFile } from 'child_process'; +import { + buildCliCommand, + getWhichCommand, + isSafeCliTool, + normalizeCliTool +} from './cliTool'; +import { getImplementationArtifactsPath } from './bmadConfig'; + +let cliChecked = false; +let cliAvailable = true; + +export function activate(context: vscode.ExtensionContext) { + const provider = new StoryCodeLensProvider(); + + // Register CodeLens provider for markdown files + context.subscriptions.push( + vscode.languages.registerCodeLensProvider( + { language: 'markdown', scheme: 'file' }, + provider + ) + ); + + // Register TreeView provider + const treeProvider = new EpicTreeProvider(); + const treeView = vscode.window.createTreeView('bmadStories', { + treeDataProvider: treeProvider, + showCollapseAll: true + }); + context.subscriptions.push(treeView, treeProvider); + + // Register Tech Specs TreeView provider + const techSpecProvider = new TechSpecTreeProvider(); + const techSpecView = vscode.window.createTreeView('bmadTechSpecs', { + treeDataProvider: techSpecProvider, + showCollapseAll: true + }); + context.subscriptions.push(techSpecView, techSpecProvider); + + // Register the create story command + context.subscriptions.push( + vscode.commands.registerCommand( + 'bmadMethod.createStory', + (storyNumber: string) => { + executeInTerminal(storyNumber, 'create-story'); + } + ) + ); + + // Register the develop story command + context.subscriptions.push( + vscode.commands.registerCommand( + 'bmadMethod.developStory', + (storyNumber: string) => { + executeInTerminal(storyNumber, 'dev-story'); + } + ) + ); + + // Register the refresh stories command + context.subscriptions.push( + vscode.commands.registerCommand( + 'bmadMethod.refreshStories', + () => { + treeProvider.refresh(); + } + ) + ); + + // Register the refresh tech specs command + context.subscriptions.push( + vscode.commands.registerCommand( + 'bmadMethod.refreshTechSpecs', + () => { + techSpecProvider.refresh(); + } + ) + ); + + // Register the reveal story command + context.subscriptions.push( + vscode.commands.registerCommand( + 'bmadMethod.revealStory', + async (filePath: string, lineNumber: number) => { + try { + const document = await vscode.workspace.openTextDocument(filePath); + const editor = await vscode.window.showTextDocument(document); + + // Validate lineNumber is within document bounds + if (lineNumber < 0 || lineNumber >= document.lineCount) { + vscode.window.showWarningMessage(`Story line ${lineNumber + 1} not found in file (file may have been edited)`); + return; + } + + const range = new vscode.Range(lineNumber, 0, lineNumber, 0); + editor.selection = new vscode.Selection(range.start, range.end); + editor.revealRange(range, vscode.TextEditorRevealType.InCenter); + } catch (error: unknown) { + vscode.window.showErrorMessage(`Failed to open story: ${error}`); + } + } + ) + ); + + // Register the reveal task command + context.subscriptions.push( + vscode.commands.registerCommand( + 'bmadMethod.revealTask', + async (filePath: string, lineNumber: number) => { + try { + const document = await vscode.workspace.openTextDocument(filePath); + const editor = await vscode.window.showTextDocument(document); + + if (lineNumber < 0 || lineNumber >= document.lineCount) { + vscode.window.showWarningMessage(`Task line ${lineNumber + 1} not found in file (file may have been edited)`); + return; + } + + const range = new vscode.Range(lineNumber, 0, lineNumber, 0); + editor.selection = new vscode.Selection(range.start, range.end); + editor.revealRange(range, vscode.TextEditorRevealType.InCenter); + } catch (error: unknown) { + vscode.window.showErrorMessage(`Failed to open task: ${error}`); + } + } + ) + ); + + // FileSystemWatcher for auto-refresh of TreeView + let watcher: vscode.FileSystemWatcher | undefined; + let techSpecWatcher: vscode.FileSystemWatcher | undefined; + let configWatcher: vscode.FileSystemWatcher | undefined; + + const createWatcher = () => { + // Dispose old watcher if exists + if (watcher) { + watcher.dispose(); + } + + const config = vscode.workspace.getConfiguration('bmad'); + const pattern = config.get('epicFilePattern', '**/*epic*.md'); + watcher = vscode.workspace.createFileSystemWatcher(pattern); + + watcher.onDidChange(() => treeProvider.refresh()); + watcher.onDidCreate(() => treeProvider.refresh()); + watcher.onDidDelete(() => treeProvider.refresh()); + + context.subscriptions.push(watcher); + }; + + createWatcher(); + + const createTechSpecWatcher = async () => { + if (techSpecWatcher) { + techSpecWatcher.dispose(); + } + + const implementationPath = await getImplementationArtifactsPath(); + const config = vscode.workspace.getConfiguration('bmad'); + const pattern = implementationPath + ? `${implementationPath}/**/tech-spec-*.md` + : config.get('techSpecFilePattern', '**/tech-spec-*.md'); + + techSpecWatcher = vscode.workspace.createFileSystemWatcher(pattern); + + techSpecWatcher.onDidChange(() => techSpecProvider.refresh()); + techSpecWatcher.onDidCreate(() => techSpecProvider.refresh()); + techSpecWatcher.onDidDelete(() => techSpecProvider.refresh()); + + context.subscriptions.push(techSpecWatcher); + }; + + const createConfigWatcher = () => { + if (configWatcher) { + configWatcher.dispose(); + } + + configWatcher = vscode.workspace.createFileSystemWatcher('_bmad/bmm/config.yaml'); + const onConfigChange = () => { + techSpecProvider.refresh(); + createTechSpecWatcher().catch(() => { + // Silently ignore - watcher is optional enhancement + }); + }; + + configWatcher.onDidChange(onConfigChange); + configWatcher.onDidCreate(onConfigChange); + configWatcher.onDidDelete(onConfigChange); + + context.subscriptions.push(configWatcher); + }; + + createTechSpecWatcher().catch(() => { + // Silently ignore - watcher is optional enhancement + }); + createConfigWatcher(); + + // Listen for configuration changes to refresh CodeLens and TreeView + context.subscriptions.push( + vscode.workspace.onDidChangeConfiguration((event) => { + if (event.affectsConfiguration('bmad')) { + provider.refresh(); + treeProvider.refresh(); + techSpecProvider.refresh(); + + // Recreate watcher if pattern changed + if (event.affectsConfiguration('bmad.epicFilePattern')) { + createWatcher(); + } + if (event.affectsConfiguration('bmad.techSpecFilePattern')) { + createTechSpecWatcher().catch(() => { + // Silently ignore - watcher is optional enhancement + }); + } + + if (event.affectsConfiguration('bmad.cliTool')) { + cliChecked = false; + } + } + }) + ); +} + +async function executeInTerminal(storyNumber: string | undefined, workflow: 'create-story' | 'dev-story'): Promise { + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + const config = vscode.workspace.getConfiguration('bmad'); + const cliTool = normalizeCliTool(config.get('cliTool')); + + if (!workspaceFolder) { + vscode.window.showErrorMessage( + 'No workspace folder open. Please open a folder to execute stories.' + ); + return; + } + + if (!isSafeCliTool(cliTool)) { + vscode.window.showErrorMessage( + `Invalid CLI tool name "${cliTool}". Use a simple tool name or path without shell metacharacters.` + ); + return; + } + + // Check for CLI availability on first invocation + if (!cliChecked) { + cliChecked = true; + cliAvailable = await checkCliAvailable(cliTool); + if (!cliAvailable) { + vscode.window.showWarningMessage( + `CLI tool "${cliTool}" not found on PATH. Update bmad.cliTool or install the tool.` + ); + } + } + + const terminalName = storyNumber ? `Story ${storyNumber}` : `BMAD ${workflow}`; + + // Check if terminal with this name already exists + let terminal = vscode.window.terminals.find(t => t.name === terminalName); + + if (!terminal) { + terminal = vscode.window.createTerminal({ + name: terminalName, + cwd: workspaceFolder + }); + } + + terminal.show(); + const command = buildCliCommand(cliTool, workflow, storyNumber); + terminal.sendText(command); +} + +function checkCliAvailable(toolName: string): Promise { + return new Promise((resolve) => { + const { cmd, args } = getWhichCommand(process.platform, toolName); + + execFile(cmd, args, (error) => { + resolve(!error); + }); + }); +} + +export function deactivate() {} diff --git a/src/storyCodeLensProvider.ts b/src/storyCodeLensProvider.ts new file mode 100644 index 0000000..6804834 --- /dev/null +++ b/src/storyCodeLensProvider.ts @@ -0,0 +1,155 @@ +import * as vscode from 'vscode'; +import * as path from 'path'; +import { parseEpicsFromText, ParsedStory } from './epicsParser'; +import { getImplementationArtifactsPath } from './bmadConfig'; + +interface StoryInfo { + storyNumber: string; + storyTitle: string; + status: string; + line: vscode.TextLine; +} + +export class StoryCodeLensProvider extends vscode.EventEmitter implements vscode.CodeLensProvider { + + onDidChangeCodeLenses: vscode.Event = this.event; + + refresh(): void { + this.fire(); + } + + async provideCodeLenses(document: vscode.TextDocument): Promise { + const config = vscode.workspace.getConfiguration('bmad'); + const enableCodeLens = config.get('enableCodeLens', true); + + if (!enableCodeLens) { + return []; + } + + if (!this.isEpicFile(document)) { + return []; + } + + const codeLenses: vscode.CodeLens[] = []; + const stories = this.parseStories(document); + + for (const story of stories) { + const codeLens = await this.createCodeLensForStory(story, document); + if (codeLens) { + codeLenses.push(codeLens); + } + } + + return codeLenses; + } + + private parseStories(document: vscode.TextDocument): StoryInfo[] { + const text = document.getText(); + const parsed = parseEpicsFromText(text, document.uri.fsPath); + + // Flatten all stories from all epics and convert to StoryInfo with TextLine + const stories: StoryInfo[] = []; + for (const epic of parsed.epics) { + for (const story of epic.stories) { + const line = document.lineAt(story.lineNumber); + stories.push({ + storyNumber: story.storyNumber, + storyTitle: story.storyTitle, + status: story.status, + line + }); + } + } + + return stories; + } + + private async createCodeLensForStory(story: StoryInfo, document: vscode.TextDocument): Promise { + const { storyNumber, storyTitle, status, line } = story; + + // Only show CodeLens for "ready" status + if (status !== 'ready') { + return null; + } + + const storyFileExists = await this.checkStoryFileExists(storyNumber, document); + + if (storyFileExists) { + // Story file exists and status is ready -> Start Developing + return new vscode.CodeLens(line.range, { + title: '$(play) Start Developing Story', + tooltip: `Run dev-story workflow for ${storyNumber}: ${storyTitle}`, + command: 'bmadMethod.developStory', + arguments: [storyNumber] + }); + } else { + // Status is ready but no story file -> Create Story + return new vscode.CodeLens(line.range, { + title: '$(new-file) Create Story', + tooltip: `Create story file for ${storyNumber}: ${storyTitle}`, + command: 'bmadMethod.createStory', + arguments: [storyNumber] + }); + } + } + + private async checkStoryFileExists(storyNumber: string, document: vscode.TextDocument): Promise { + const workspaceFolder = vscode.workspace.getWorkspaceFolder(document.uri); + if (!workspaceFolder) { + console.log(`[BMAD] No workspace folder for document: ${document.uri.fsPath}`); + return false; + } + + const implementationPath = await getImplementationArtifactsPath(); + if (!implementationPath) { + console.log(`[BMAD] No implementation_artifacts path configured`); + return false; + } + + const artifactsUri = vscode.Uri.joinPath(workspaceFolder.uri, implementationPath); + // Story files use dashes (1-1) but headers use dots (1.1) + const storyNumberWithDashes = storyNumber.replace(/\./g, '-'); + const pattern = `${storyNumberWithDashes}*.md`; + + console.log(`[BMAD] Checking for story ${storyNumber} in: ${artifactsUri.fsPath}`); + console.log(`[BMAD] Pattern: ${pattern}`); + + try { + const entries = await vscode.workspace.fs.readDirectory(artifactsUri); + const files = entries.map(([name]) => name); + console.log(`[BMAD] Files found: ${JSON.stringify(files)}`); + // Simple glob match: pattern is "{number}*.md", check if file starts with the number prefix + const prefix = storyNumberWithDashes; + const match = files.some(file => file.startsWith(prefix) && file.endsWith('.md')); + console.log(`[BMAD] Match result: ${match}`); + return match; + } catch (error: unknown) { + console.log(`[BMAD] Error reading directory: ${error}`); + return false; + } + } + + resolveCodeLens(codeLens: vscode.CodeLens): vscode.CodeLens { + return codeLens; + } + + private isEpicFile(document: vscode.TextDocument): boolean { + const config = vscode.workspace.getConfiguration('bmad'); + const pattern = config.get('epicFilePattern', '**/*epic*.md'); + const fileName = path.basename(document.fileName).toLowerCase(); + + // Extract just the filename pattern from the glob (e.g., "*epic*.md" from "**/*epic*.md") + const filePattern = pattern.split('/').pop() || pattern; + + // Convert simple glob pattern to regex + // Supports: * (any chars), ? (single char) + const regexPattern = filePattern + .toLowerCase() + .replace(/\*+/g, '*') // Collapse consecutive * to prevent ReDoS + .replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape regex special chars except * and ? + .replace(/\*/g, '.*') // * -> .* + .replace(/\?/g, '.'); // ? -> . + + return new RegExp(`^${regexPattern}$`).test(fileName); + } +} diff --git a/src/techSpecParser.ts b/src/techSpecParser.ts new file mode 100644 index 0000000..fdf66f8 --- /dev/null +++ b/src/techSpecParser.ts @@ -0,0 +1,85 @@ +export interface ParsedTechSpecTask { + taskNumber: string; // e.g., "1.2" + taskTitle: string; // e.g., "Add config helper" + status: 'done' | 'todo'; + lineNumber: number; // 0-based line number in file +} + +export interface ParsedTechSpecFile { + filePath: string; // Absolute path to the file + tasks: ParsedTechSpecTask[]; +} + +const TASK_SECTION_HEADER_PATTERN = /^###\s+Tasks\b/; +const TASK_LINE_PATTERN = /^\s*-\s*(?:\[( |x|X)\]\s*)?Task\s+(\d+(?:\.\d+)*)\s*:\s*(.+)\s*$/; + +function stripMarkdown(text: string): string { + let cleaned = text; + cleaned = cleaned.replace(/\*\*/g, ''); + cleaned = cleaned.replace(/`([^`]+)`/g, '$1'); + cleaned = cleaned.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1'); + return cleaned.trim(); +} + +/** + * Parse tech spec tasks from a markdown file + * @param text The full text content of the file + * @param filePath The absolute path to the file + * @returns ParsedTechSpecFile with tasks from the first ### Tasks section + */ +export function parseTechSpecTasksFromText(text: string, filePath: string): ParsedTechSpecFile { + const tasks: ParsedTechSpecTask[] = []; + + try { + const lines = text.split('\n'); + const taskSectionIndices: number[] = []; + + for (let i = 0; i < lines.length; i++) { + if (TASK_SECTION_HEADER_PATTERN.test(lines[i])) { + taskSectionIndices.push(i); + } + } + + if (taskSectionIndices.length > 1) { + console.log(`[BMAD] Warning: Multiple ### Tasks sections found in ${filePath}; parsing the first only`); + } + + if (taskSectionIndices.length === 0) { + return { filePath, tasks }; + } + + const startIndex = taskSectionIndices[0] + 1; + + for (let i = startIndex; i < lines.length; i++) { + const line = lines[i]; + + if (i > startIndex && /^#{1,3}\s+/.test(line)) { + break; + } + + const lineForMatch = line.replace(/\*\*/g, ''); + const match = lineForMatch.match(TASK_LINE_PATTERN); + if (!match) { + continue; + } + + const status = match[1] && match[1].toLowerCase() === 'x' ? 'done' : 'todo'; + const taskNumber = match[2]; + const taskTitle = stripMarkdown(match[3]); + + tasks.push({ + taskNumber, + taskTitle, + status, + lineNumber: i + }); + } + } catch (error) { + console.log(`[BMAD] Error parsing file ${filePath}: ${error}`); + } + + return { + filePath, + tasks + }; +} diff --git a/src/techSpecTreeProvider.ts b/src/techSpecTreeProvider.ts new file mode 100644 index 0000000..eb89552 --- /dev/null +++ b/src/techSpecTreeProvider.ts @@ -0,0 +1,158 @@ +import * as vscode from 'vscode'; +import { + ParsedTechSpecFile, + ParsedTechSpecTask, + parseTechSpecTasksFromText +} from './techSpecParser'; +import { getImplementationArtifactsPath } from './bmadConfig'; + +type TreeNodeType = 'file' | 'task'; + +class TechSpecTreeItem extends vscode.TreeItem { + constructor( + public readonly type: TreeNodeType, + public readonly data: ParsedTechSpecFile | ParsedTechSpecTask, + public readonly filePath: string, + label: string, + collapsibleState: vscode.TreeItemCollapsibleState + ) { + super(label, collapsibleState); + this.contextValue = type; + } +} + +export class TechSpecTreeProvider implements vscode.TreeDataProvider, vscode.Disposable { + private _onDidChangeTreeData = new vscode.EventEmitter(); + readonly onDidChangeTreeData = this._onDidChangeTreeData.event; + + private parsedFiles: ParsedTechSpecFile[] = []; + private isLoading = false; + private refreshTimeout: NodeJS.Timeout | undefined; + + dispose(): void { + if (this.refreshTimeout) { + clearTimeout(this.refreshTimeout); + this.refreshTimeout = undefined; + } + this._onDidChangeTreeData.dispose(); + } + + refresh(): void { + if (this.refreshTimeout) { + clearTimeout(this.refreshTimeout); + } + this.refreshTimeout = setTimeout(() => { + this._onDidChangeTreeData.fire(); + this.refreshTimeout = undefined; + }, 300); + } + + async getChildren(element?: TechSpecTreeItem): Promise { + if (!element) { + await this.loadFiles(); + return this.parsedFiles.map(file => { + const fileUri = vscode.Uri.file(file.filePath); + const workspaceFolder = vscode.workspace.getWorkspaceFolder(fileUri); + const relativePath = workspaceFolder + ? vscode.workspace.asRelativePath(fileUri) + : file.filePath; + + return new TechSpecTreeItem( + 'file', + file, + file.filePath, + relativePath, + vscode.TreeItemCollapsibleState.Expanded + ); + }); + } + + if (element.type === 'file') { + const file = element.data as ParsedTechSpecFile; + return file.tasks.map(task => { + return new TechSpecTreeItem( + 'task', + task, + element.filePath, + `Task ${task.taskNumber}: ${task.taskTitle}`, + vscode.TreeItemCollapsibleState.None + ); + }); + } + + return []; + } + + getTreeItem(element: TechSpecTreeItem): vscode.TreeItem { + const item = element; + + if (element.type === 'file') { + item.iconPath = new vscode.ThemeIcon('file-text'); + item.tooltip = element.filePath; + } else if (element.type === 'task') { + const task = element.data as ParsedTechSpecTask; + item.iconPath = this.getTaskIcon(task.status); + item.tooltip = `Task ${task.taskNumber}: ${task.taskTitle} (${task.status})`; + item.command = { + command: 'bmadMethod.revealTask', + title: 'Reveal Task', + arguments: [element.filePath, task.lineNumber] + }; + } + + return item; + } + + private getTaskIcon(status: 'done' | 'todo'): vscode.ThemeIcon { + if (status === 'done') { + return new vscode.ThemeIcon('check', new vscode.ThemeColor('charts.green')); + } + + return new vscode.ThemeIcon('play'); + } + + private async loadFiles(): Promise { + if (this.isLoading) { + return; + } + + this.isLoading = true; + + try { + const implementationPath = await getImplementationArtifactsPath(); + let pattern: string; + + if (implementationPath) { + pattern = `${implementationPath}/**/tech-spec-*.md`; + console.log(`[BMAD] Using implementation_artifacts path from config: ${implementationPath}`); + } else { + const config = vscode.workspace.getConfiguration('bmad'); + pattern = config.get('techSpecFilePattern', '**/tech-spec-*.md'); + console.log(`[BMAD] No BMAD config found, using VS Code setting`); + } + + try { + const files = await vscode.workspace.findFiles(pattern); + console.log(`[BMAD] Found ${files.length} tech spec files matching pattern: ${pattern}`); + + this.parsedFiles = []; + + for (const fileUri of files) { + try { + const content = await vscode.workspace.fs.readFile(fileUri); + const text = Buffer.from(content).toString('utf8'); + const parsed = parseTechSpecTasksFromText(text, fileUri.fsPath); + this.parsedFiles.push(parsed); + } catch (error) { + console.log(`[BMAD] Error parsing file ${fileUri.fsPath}: ${error}`); + } + } + } catch (error) { + console.log(`[BMAD] Error finding tech spec files: ${error}`); + this.parsedFiles = []; + } + } finally { + this.isLoading = false; + } + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..9850bba --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "outDir": "./out", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..ee93a47 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,73 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + // Test file patterns + include: ['src/__tests__/**/*.test.ts'], + + // Exclude patterns + exclude: ['node_modules', 'out', 'src/__tests__/fixtures/**'], + + // Mock the vscode module + alias: { + vscode: new URL('./src/__tests__/mocks/vscode.ts', import.meta.url).pathname + }, + + // Coverage configuration + coverage: { + provider: 'v8', + reporter: ['text', 'html', 'lcov'], + reportsDirectory: './coverage', + + // Include source files + include: ['src/**/*.ts'], + + // Exclude from coverage + exclude: [ + 'src/__tests__/**', + 'src/**/*.d.ts' + ], + + // Coverage thresholds + thresholds: { + // Start with achievable thresholds, increase as coverage improves + lines: 50, + functions: 50, + branches: 40, + statements: 50, + + // Per-file thresholds for critical modules + perFile: true, + 'src/cliTool.ts': { + lines: 90, + functions: 90, + branches: 80, + statements: 90 + }, + 'src/epicsParser.ts': { + lines: 80, + functions: 80, + branches: 70, + statements: 80 + }, + 'src/techSpecParser.ts': { + lines: 80, + functions: 80, + branches: 70, + statements: 80 + } + } + }, + + // Reporter options + reporters: ['verbose'], + + // Global test timeout + testTimeout: 10000, + + // Run tests in sequence for deterministic output + sequence: { + shuffle: false + } + } +});