From 7855e37c48cef98eb4fc20e5b27e8391fdee22ca Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Thu, 26 Mar 2026 21:09:14 +1100 Subject: [PATCH 01/25] Fixes --- CoveragePlan.md | 124 --------------------------------- README.md | 7 +- website/src/docs/discovery.md | 14 +++- website/src/docs/index.md | 8 ++- website/src/index.njk | 18 ++++- website/tests/homepage.spec.ts | 6 +- 6 files changed, 40 insertions(+), 137 deletions(-) delete mode 100644 CoveragePlan.md diff --git a/CoveragePlan.md b/CoveragePlan.md deleted file mode 100644 index 790d80d..0000000 --- a/CoveragePlan.md +++ /dev/null @@ -1,124 +0,0 @@ -# Plan: Get Test Coverage to 100% - -## Context -Coverage is at 78% (5187/6634 statements). Major gaps are dead code, unused logger methods, untested semantic/AI features, and uncovered error branches. Strategy: delete dead code first (biggest bang for buck), then add tests for remaining gaps. - -## Phase 1: Delete Dead Code (~300+ statements removed) - -### 1a. Logger — remove unused methods -**File:** `src/utils/logger.ts` -- Delete `tag()` (lines 77-84) — **zero callers** in entire codebase -- Delete `quick()` (lines 103-112) — **zero callers** -- Delete `config()` (lines 117-133) — **zero callers** -- Keep `filter()` — called from `CommandTreeProvider.ts:92` - -### 1b. Test helpers — remove unused exports -**File:** `src/test/helpers/helpers.ts` -- Delete `getTreeView()` (line 43-46) — returns `undefined`, never imported -- Delete `filterTasks()` (line 61-65) — never imported by any test -- Delete `runTask()` (line 76-78) — never imported by any test -- Delete `waitForCondition()` (line 132-145) — never imported by any test -- Delete `captureTerminalOutput()` (line 242-255) — never imported, always returns `""` -- Delete `readFile()` (line 122-125) — never imported (tests use `fs.readFileSync` directly) - -### 1c. Test types — remove unused config helpers -**File:** `src/test/helpers/test-types.ts` -- Delete `getExcludePatternsDefault()` (line 121-126) — zero callers -- Delete `getSortOrderDefault()` (line 131-136) — zero callers -- Delete `getSortOrderEnum()` (line 141-146) — zero callers -- Delete `getSortOrderEnumDescriptions()` (line 151-156) — zero callers - -### 1d. Semantic — remove unused function -**File:** `src/semantic/modelSelection.ts` -- Delete `pickConcreteModel()` (line 38-48) — never imported anywhere - -### 1e. Extension — inline trivial passthrough -**File:** `src/extension.ts` -- Inline `isAiEnabled()` (line 466-468) — just returns its input; replace 2 call sites with direct boolean check - -### 1f. Deno — fix duplicate + regex violation -**File:** `src/discovery/deno.ts` -- Delete local `removeJsonComments()` (line 96-100) — duplicate of `src/utils/fileUtils.ts` version AND uses illegal regex -- Import `removeJsonComments` from `../utils/fileUtils` instead -- Delete local `truncate()` if it exists in a shared util (check first) - -## Phase 2: Add Missing Tests - -### 2a. Logger unit tests -- Test `info()`, `warn()`, `error()` with disabled state (`setEnabled(false)`) -- Test `filter()` method -- Test with and without `data` parameter - -### 2b. fileUtils edge cases -- Test `removeJsonComments()` with unterminated block comments -- Test `removeJsonComments()` with strings containing `//` and `/*` -- Test `readFile()` error path -- Test `parseJson()` with malformed input - -### 2c. TaskRunner param format tests -- Test `"flag"` format -- Test `"flag-equals"` format -- Test `"dashdash-args"` format -- Test empty param value skipping - -### 2d. Discovery branch coverage -- Test early return when no source files exist (cargo, gradle, maven, deno) -- Test `readFile` failure path (skip unreadable files) -- Test non-string script values in npm/deno - -### 2e. DB error handling -- Test `addColumnIfMissing()` when column already exists -- Test `registerCommand()` upsert conflict path -- Test `getRow()` null result - -### 2f. Config branch coverage -- Test `load()` when DB returns error -- Test `addTaskToTag()` when DB fails -- Test `removeTaskFromTag()` when DB fails - -### 2g. Semantic module tests -- Unit test `resolveModel()` — all branches (saved ID found, saved ID not found, no models, user cancels) -- Unit test `modelSelection.ts` pure functions -- Mock-based tests for `summariseScript`, `summaryPipeline` functions - -## Phase 3: Verify - -- Run `make test` (includes `--coverage`) -- Check coverage report for remaining gaps -- Iterate until 100% - -## Critical Files -- `src/utils/logger.ts` — delete 3 methods -- `src/test/helpers/helpers.ts` — delete 6 functions -- `src/test/helpers/test-types.ts` — delete 4 functions -- `src/semantic/modelSelection.ts` — delete 1 function -- `src/extension.ts` — inline 1 function -- `src/discovery/deno.ts` — fix regex violation + remove duplicate -- New/modified test files for Phase 2 - ---- - -## TODO - -### Phase 1: Delete Dead Code -- [x] 1a. Delete `tag()`, `quick()`, `config()` from `src/utils/logger.ts` — ALREADY DONE -- [x] 1b. Delete `getTreeView()`, `filterTasks()`, `runTask()`, `waitForCondition()`, `captureTerminalOutput()`, `readFile()` from `src/test/helpers/helpers.ts` — ALREADY DONE -- [x] 1c. Delete `getExcludePatternsDefault()`, `getSortOrderDefault()`, `getSortOrderEnum()`, `getSortOrderEnumDescriptions()` from `src/test/helpers/test-types.ts` — ALREADY DONE -- [x] 1d. ~~Delete `pickConcreteModel()`~~ — ACTUALLY USED (summariser.ts + unit tests). Plan was wrong. -- [x] 1e. ~~Inline `isAiEnabled()`~~ — ALREADY DONE (function no longer exists) -- [x] 1f. Fix deno.ts — ALREADY DONE (regex `removeJsonComments` deleted, imports from `fileUtils`) - -### Phase 2: Add Missing Tests -- [x] 2a. Logger unit tests — BLOCKED as unit test (vscode dep). Coverage comes from e2e usage. -- [x] 2b. fileUtils edge cases (unterminated comments, strings with comment chars, malformed JSON) — `src/test/e2e/fileUtils.e2e.test.ts` (8 tests) -- [x] 2c. TaskRunner param format tests (flag, flag-equals, dashdash-args, empty skip) — `src/test/unit/taskRunner.unit.test.ts` (11 tests, all passing) -- [x] 2d. Discovery branch coverage — BLOCKED as unit test (vscode dep). Branches exercised by existing e2e fixture tests. -- [x] 2e. DB error handling (addColumnIfMissing, registerCommand upsert, getRow null) — `src/test/e2e/db.e2e.test.ts` (12 tests) -- [x] 2f. Config branch coverage — BLOCKED as unit test (vscode dep). Error paths are defensive code, covered by e2e tag tests. -- [x] 2g. Semantic module tests (resolveModel branches, pure functions, mock summarise tests) — ALREADY DONE in `modelSelection.unit.test.ts` - -### Phase 3: Verify -- [x] Run `npm test` — **217 passing, 7 failing** (all 7 failures are Copilot auth timeouts in headless test env, not code bugs) -- [ ] Run `npm run test:coverage` for coverage report — needs Copilot auth or skip AI tests -- [ ] Check coverage report for remaining gaps -- [ ] Iterate until 100% diff --git a/README.md b/README.md index b49e1a5..a285179 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ CommandTree in action

-CommandTree scans your project and surfaces all runnable commands across 19 tool types in a single tree view. Filter by text or tag, search by meaning with AI-powered semantic search, and run in terminal or debugger. +CommandTree scans your project and surfaces all runnable commands across 21 tool types in a single tree view. Filter by text or tag, and run in terminal or debugger. ## AI Summaries (powered by GitHub Copilot) @@ -19,8 +19,7 @@ Summaries are stored locally and only regenerate when the underlying script chan ## Features - **AI Summaries** - GitHub Copilot describes each command in plain language, with security warnings for dangerous operations -- **AI-Powered Search** - Find commands by meaning, not just name — local embeddings, no data leaves your machine -- **Auto-discovery** - 19 command types including shell scripts, npm, Make, Python, PowerShell, Gradle, Cargo, Maven, Docker Compose, .NET, and more +- **Auto-discovery** - 21 command types including shell scripts, npm, Make, Python, PowerShell, Gradle, Cargo, Maven, Docker Compose, .NET, C# Script, F# Script, and more - **Quick Launch** - Pin frequently-used commands to a dedicated panel at the top - **Tagging** - Right-click any command to add or remove tags - **Filtering** - Filter the tree by text search or by tag @@ -51,6 +50,8 @@ Summaries are stored locally and only regenerate when the underlying script chan | Composer Scripts | `composer.json` (PHP) | | Docker Compose | `docker-compose.yml` | | .NET Projects | `.csproj`, `.fsproj` | +| C# Scripts | `.csx` files | +| F# Scripts | `.fsx` files | | Markdown Files | `.md` files | ## Getting Started diff --git a/website/src/docs/discovery.md b/website/src/docs/discovery.md index 465f395..771ad7f 100644 --- a/website/src/docs/discovery.md +++ b/website/src/docs/discovery.md @@ -1,7 +1,7 @@ --- layout: layouts/docs.njk -title: Auto-Discovery of 18+ Command Types - CommandTree Docs -description: How CommandTree auto-discovers shell scripts, npm, Make, Gradle, Cargo, Maven, Docker Compose, .NET, and 18+ command types in your VS Code workspace. +title: Auto-Discovery of 21 Command Types - CommandTree Docs +description: How CommandTree auto-discovers shell scripts, npm, Make, Gradle, Cargo, Maven, Docker Compose, .NET, C# Script, F# Script, and 21 command types in your VS Code workspace. eleventyNavigation: key: Command Discovery order: 2 @@ -9,7 +9,7 @@ eleventyNavigation: # Command Discovery -CommandTree auto-discovers 18+ command types — including shell scripts, npm scripts, Makefiles, Gradle, Cargo, Maven, Docker Compose, and .NET projects — by recursively scanning your workspace. Discovery respects [exclude patterns](/docs/configuration/) and runs in the background. +CommandTree auto-discovers 21 command types — including shell scripts, npm scripts, Makefiles, Gradle, Cargo, Maven, Docker Compose, .NET projects, C# scripts, and F# scripts — by recursively scanning your workspace. Discovery respects [exclude patterns](/docs/configuration/) and runs in the background. ## Shell Scripts @@ -90,6 +90,14 @@ Discovers services from `docker-compose.yml` / `docker-compose.yaml` files. Discovers `.csproj` and `.fsproj` project files for build/run/test commands. +## C# Scripts + +Discovers `.csx` files and runs them via `dotnet script`. + +## F# Scripts + +Discovers `.fsx` files and runs them via `dotnet fsi`. + ## Markdown Files Discovers `.md` files in the workspace. diff --git a/website/src/docs/index.md b/website/src/docs/index.md index 0c31fb7..c93c684 100644 --- a/website/src/docs/index.md +++ b/website/src/docs/index.md @@ -1,7 +1,7 @@ --- layout: layouts/docs.njk title: Getting Started with CommandTree - VS Code Command Runner -description: Install CommandTree for VS Code and discover shell scripts, npm scripts, Makefiles, and 18+ command types automatically in one sidebar. +description: Install CommandTree for VS Code and discover shell scripts, npm scripts, Makefiles, and 21 command types automatically in one sidebar. eleventyNavigation: key: Getting Started order: 1 @@ -9,7 +9,7 @@ eleventyNavigation: # Getting Started -CommandTree is a free VS Code extension that scans your workspace and surfaces all runnable commands — shell scripts, npm scripts, Makefiles, and 18 other types — in a single tree view sidebar panel. +CommandTree is a free VS Code extension that scans your workspace and surfaces all runnable commands — shell scripts, npm scripts, Makefiles, and 20 other types — in a single tree view sidebar panel. ## Installation @@ -58,6 +58,8 @@ code --install-extension commandtree-*.vsix | Composer Scripts | `composer.json` | | Docker Compose | `docker-compose.yml` | | .NET Projects | `.csproj` / `.fsproj` | +| C# Scripts | `.csx` files | +| F# Scripts | `.fsx` files | | Markdown Files | `.md` files | Discovery respects [exclude patterns](/docs/configuration/) in settings and runs in the background. If [GitHub Copilot](https://marketplace.visualstudio.com/items?itemName=GitHub.copilot) is installed, each discovered command is automatically described in plain language — hover over any command to see what it does. Learn more about [how discovery works](/docs/discovery/) and [AI summaries](/docs/ai-summaries/). @@ -66,7 +68,7 @@ Discovery respects [exclude patterns](/docs/configuration/) in settings and runs ### What command types does CommandTree discover? -CommandTree discovers 19 command types: shell scripts, npm scripts, Makefile targets, VS Code tasks, launch configurations, Python scripts, PowerShell scripts, Gradle tasks, Cargo tasks, Maven goals, Ant targets, Just recipes, Taskfile tasks, Deno tasks, Rake tasks, Composer scripts, Docker Compose services, .NET projects, and Markdown files. +CommandTree discovers 21 command types: shell scripts, npm scripts, Makefile targets, VS Code tasks, launch configurations, Python scripts, PowerShell scripts, Gradle tasks, Cargo tasks, Maven goals, Ant targets, Just recipes, Taskfile tasks, Deno tasks, Rake tasks, Composer scripts, Docker Compose services, .NET projects, C# scripts, F# scripts, and Markdown files. ### Does CommandTree require GitHub Copilot? diff --git a/website/src/index.njk b/website/src/index.njk index ca2b7b1..70f5556 100644 --- a/website/src/index.njk +++ b/website/src/index.njk @@ -1,7 +1,7 @@ --- layout: layouts/base.njk title: CommandTree - One Sidebar, Every Command in VS Code -description: CommandTree discovers all runnable commands in your VS Code workspace — shell scripts, npm, Make, Gradle, Docker Compose, and 18+ types — in one sidebar with AI summaries. +description: CommandTree discovers all runnable commands in your VS Code workspace — shell scripts, npm, Make, Gradle, Docker Compose, .NET, C# Script, F# Script, and 21 types — in one sidebar with AI summaries. ---
@@ -55,7 +55,7 @@ description: CommandTree discovers all runnable commands in your VS Code workspa
🔍

Auto-Discovery

-

Recursively scans your workspace for shell scripts, npm scripts, Makefile targets, VS Code commands, launch configs, and Python scripts.

+

Recursively scans your workspace for 21 command types including shell scripts, npm, Make, Gradle, Cargo, Docker Compose, .NET, C# Script, F# Script, and more.

@@ -218,6 +218,20 @@ description: CommandTree discovers all runnable commands in your VS Code workspa

.csproj / .fsproj

+
+ 🟣 +
+

C# Scripts

+

.csx files

+
+
+
+ 🔵 +
+

F# Scripts

+

.fsx files

+
+
📝
diff --git a/website/tests/homepage.spec.ts b/website/tests/homepage.spec.ts index e3e9059..3dbcdc6 100644 --- a/website/tests/homepage.spec.ts +++ b/website/tests/homepage.spec.ts @@ -50,9 +50,9 @@ test.describe('Homepage', () => { } }); - test('command types section shows all 19 types', async ({ page }) => { + test('command types section shows all 21 types', async ({ page }) => { const commandTypes = page.locator('.command-type'); - await expect(commandTypes).toHaveCount(19); + await expect(commandTypes).toHaveCount(21); const expectedTypes = [ 'Shell Scripts', @@ -73,6 +73,8 @@ test.describe('Homepage', () => { 'Composer Scripts', 'Docker Compose', '.NET Projects', + 'C# Scripts', + 'F# Scripts', 'Markdown Files', ]; for (const name of expectedTypes) { From 2dd085aef4b2dc0d7bba80fa26eb49bff20e93c9 Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Thu, 26 Mar 2026 21:13:08 +1100 Subject: [PATCH 02/25] Website fixes --- Claude.md | 2 + website/eleventy.config.js | 46 +++++++++++++++++++- website/src/_data/site.json | 2 +- website/src/blog/introducing-commandtree.md | 3 +- website/src/docs/index.md | 2 +- website/src/favicon.ico | Bin 907 -> 9662 bytes website/src/site.webmanifest | 21 +++++++++ 7 files changed, 71 insertions(+), 5 deletions(-) create mode 100644 website/src/site.webmanifest diff --git a/Claude.md b/Claude.md index 67ff35b..b671f94 100644 --- a/Claude.md +++ b/Claude.md @@ -116,6 +116,8 @@ https://developers.google.com/search/docs/fundamentals/seo-starter-guide https://studiohawk.com.au/blog/how-to-optimise-ai-overviews/ https://about.ads.microsoft.com/en/blog/post/october-2025/optimizing-your-content-for-inclusion-in-ai-search-answers +https://documentation.platformos.com/use-cases/implementing-social-media-preview-cards + ## Project Structure ``` diff --git a/website/eleventy.config.js b/website/eleventy.config.js index ea27c90..ed69b9b 100644 --- a/website/eleventy.config.js +++ b/website/eleventy.config.js @@ -18,11 +18,13 @@ export default function(eleventyConfig) { eleventyConfig.addPassthroughCopy("src/assets"); eleventyConfig.addPassthroughCopy({ "src/favicon.ico": "favicon.ico" }); + eleventyConfig.addPassthroughCopy({ "src/site.webmanifest": "site.webmanifest" }); const faviconLinks = [ ' ', ' ', ' ', + ' ', ].join("\n"); const isIconLink = (line) => { @@ -144,13 +146,21 @@ export default function(eleventyConfig) { if (!this.page.outputPath?.endsWith(".html")) { return content; } - const ogImageAltTag = ' '; + const altText = "CommandTree - One sidebar, every command in VS Code. Auto-discover 21 command types with AI-powered summaries."; + const ogImageAltTag = ` `; + const twitterImageAltTag = ` `; const ogImageHeightTag = 'og:image:height'; const insertionPoint = content.indexOf(ogImageHeightTag); if (insertionPoint < 0) { return content; } const lineEnd = content.indexOf("\n", insertionPoint); if (lineEnd < 0) { return content; } - return content.slice(0, lineEnd + 1) + ogImageAltTag + "\n" + content.slice(lineEnd + 1); + const withOgAlt = content.slice(0, lineEnd + 1) + ogImageAltTag + "\n" + content.slice(lineEnd + 1); + const twitterImageTag = 'twitter:image" content='; + const twitterInsert = withOgAlt.indexOf(twitterImageTag); + if (twitterInsert < 0) { return withOgAlt; } + const twitterLineEnd = withOgAlt.indexOf("\n", twitterInsert); + if (twitterLineEnd < 0) { return withOgAlt; } + return withOgAlt.slice(0, twitterLineEnd + 1) + twitterImageAltTag + "\n" + withOgAlt.slice(twitterLineEnd + 1); }); eleventyConfig.addTransform("articleMeta", function(content) { @@ -237,6 +247,38 @@ export default function(eleventyConfig) { return content.replace("", scriptTag + "\n"); }); + eleventyConfig.addTransform("softwareAppSchema", function(content) { + if (!this.page.outputPath?.endsWith(".html")) { + return content; + } + if (this.page.url !== "/") { + return content; + } + const softwareSchema = { + "@context": "https://schema.org", + "@type": "SoftwareApplication", + "name": "CommandTree", + "applicationCategory": "DeveloperApplication", + "operatingSystem": "Windows, macOS, Linux", + "description": "VS Code extension that auto-discovers 21 command types — shell scripts, npm, Make, Gradle, Cargo, Docker Compose, .NET, and more — in one sidebar with AI-powered summaries.", + "url": "https://commandtree.dev", + "downloadUrl": "https://marketplace.visualstudio.com/items?itemName=nimblesite.commandtree", + "softwareRequirements": "Visual Studio Code", + "offers": { + "@type": "Offer", + "price": "0", + "priceCurrency": "USD", + }, + "author": { + "@type": "Organization", + "name": "Nimblesite Pty Ltd", + "url": "https://www.nimblesite.co", + }, + }; + const scriptTag = `\n `; + return content.replace("", scriptTag + "\n"); + }); + return { dir: { input: "src", output: "_site" }, markdownTemplateEngine: "njk", diff --git a/website/src/_data/site.json b/website/src/_data/site.json index db76f6f..cc32404 100644 --- a/website/src/_data/site.json +++ b/website/src/_data/site.json @@ -7,7 +7,7 @@ "keywords": "VS Code extension, command runner, task runner, script discovery, npm scripts, shell scripts, makefile, workspace automation, developer tools", "themeColor": "#2a8c7a", "ogImage": "/assets/images/og-image.png", - "ogImageAlt": "CommandTree - One sidebar, every command in VS Code. Auto-discover 19 command types with AI-powered summaries.", + "ogImageAlt": "CommandTree - One sidebar, every command in VS Code. Auto-discover 21 command types with AI-powered summaries.", "ogImageWidth": "1200", "ogImageHeight": "630", "organization": { diff --git a/website/src/blog/introducing-commandtree.md b/website/src/blog/introducing-commandtree.md index 67d0d7f..378af37 100644 --- a/website/src/blog/introducing-commandtree.md +++ b/website/src/blog/introducing-commandtree.md @@ -35,9 +35,10 @@ Install CommandTree and a new panel appears in your VS Code sidebar. Every runna - Gradle, Cargo, Maven, Ant, and Just - Taskfile, Deno, Rake, and Composer - Docker Compose services and .NET projects +- C# scripts and F# scripts - Markdown files -That is 19 command types discovered automatically. Click the play button. Done. +That is 21 command types discovered automatically. Click the play button. Done. ## AI-Powered Summaries diff --git a/website/src/docs/index.md b/website/src/docs/index.md index c93c684..55a52d9 100644 --- a/website/src/docs/index.md +++ b/website/src/docs/index.md @@ -9,7 +9,7 @@ eleventyNavigation: # Getting Started -CommandTree is a free VS Code extension that scans your workspace and surfaces all runnable commands — shell scripts, npm scripts, Makefiles, and 20 other types — in a single tree view sidebar panel. +CommandTree is a free VS Code extension that scans your workspace and surfaces all runnable commands — shell scripts, npm scripts, Makefiles, and 18 more types — in a single tree view sidebar panel. ## Installation diff --git a/website/src/favicon.ico b/website/src/favicon.ico index 64a272238245b939df22b7152d9857e01f89000d..67f60310171fbb8c12ad71512d653a1800c9862d 100644 GIT binary patch literal 9662 zcmd5?2Xs`|nf_J4AnZ7{aoEHmc5Ks&Mx!aJNE#JT18gt`HIUFWp(!e8=Djynqv;w| zgy`ysqJ|{YMiLU$YaH7gm$*?3b~cGqNcNmKalF3!KY>a1tcBTy`1o;t-8c8$^8Nk4 z_W2C={;B8rTg`+1yG~LA0?A2;lQcuDqy^G;T6|(9 z&D%xdCiR-!x>+J=6U53|;7T#Ty1cpvz5RW!w@y+E(UNvZkTficmo%=Amo>)5NZT&Q zNLqd$FFo`^ytFZKwXBh|B6kwj%6H%Qeh+$qROv3*BzODANtzmBB&}bFBrP{Zl2#&; zwvZTU%YPyte~pzk4JONWKawb|f6&|C*Lum4J&-Eh4Z_?$h?h3&5kHBOHvb_d@6ds@ zI}X*yOPhX$__42P4}=p)lOCg|I5u8|&?yIS)7k$mXHD13ZZrV`q~yM%;>WZYGKH z#&?k3z`ie|Q7a5F9j_Ap=uwww<VmlQR!|Q9g9pYMg7OcIq3uC87<&kxwsZF(?h}+bY&m5Oyunym{6SzcnIQk98;V zBx}swnu+Uf9dTFXo%GC<5Bx(W4?_MD)GhKb2l1ZWcG5()_A(@E#MQEMu+CpTSOF#a zSkRkZ0Ihu?FgAgPwgr8Nn14pwxCbd4r;oD4Jc%~-D&ydlmsA1hN)tgmTmjON>e;Br z{+vFVY#T62fs#NO#3lbZUbfI^ivv{A8qHce0@Tf30G^K z3t>lFF^EsPp2%z5c1c~khHUH8k!_>p9w1S$1NYMylEGw)z<4G?iU!IQ1GFjnuIixh zJEe~U)9&YBr!F#}NERUkf<{R>&RkEXW#h?#th|>2=B;e3fyAMbJ(GXsv;u zvxabKE9Vko97R6ntMQ0}@@_yM=r1*9`%$LwA8N~j+F0G7ZAH;eoa2f}r5yZU$q!~+ z%p7*siibTEj(un*P-eteIz11xNd(qj)UQ2cox#N) z)w=|TX?xg`{0ab9-f5p@L=SXNm!GOZ^(12#G%@zzF^xI$N9e18Iw$u{og;XXwuC*W zv-9Dg^rjEv3~rzu+ zp=d{`53Mr!WMl1WbjSSE;1=#dER(cNn5{LW`cY=lv^u~z0mZtId1ep)QbTtv*>glh z3~t`5Iy?6OV+(4)_8?+x)H`_%j4gNqZTm~g9yG={IPap}oYvbT7tpp~Fqqdv^Y-8R zFb>3T=bkiKHqJ2F*0Qk`^|Wdpj_JU7^abke3(CqJz7Jx_nUj;$e#8nqIYuNMH}u!-JjN2 z1HVGNZ{a>YjGfnsHhqbLKjk(6_s^wv~jkao05HAhun? zUu#N*%Nbkfbnwx>;$WvH(}%W&WSczvH}v*kFI^hSD?W&}YzviYt=y{`b0}AF6fn+5 zEJOLQG36Xx7P$yxBdA&d43<=&(dQ`h>RE_w1nD$miJ#4s#e&w50<>)<##uH`hy4Wk z?nQbVa|YjX24JTtVje3e+9o(yU6T_)nO8l}*!joI9>FDyYlS7I_!$)X@^lYtTUzvi z?dxiw*bw@JSS|?Ib4&;tebii(<136qSW#dH&{#sB)Y>>+)Wt={7Pgd?d(ma&(UJ&S z*VDlM#{N*u1D|QFD?g>mLRL^^=;tVVqjep&OM$Wpke7&`qU?v!j$YN61wW@9e9$>V z6ESaGuWpDM=o(KhZ|q84S)dU-Uc4jOmoi6Ueu(_LS zry3mf5yy5~IG1;tgex^x4&XjA%Fg|ivWAASSo03%f}|w_#D_DWpgtLxvWWR;<6~NT zAko@a5FFdgSmPd{ZRo?ugRV3M7|%j{=Odo5C6t}>0b;h8YoCLL_Dzp?_C=gGSMx~g zfQWQWBp*LMoI7j(;TM51uDUCB_G8=8%`o}tBIp@U%;}j-_)|kq3^7&mh_gPjv0=Z# zS6is`K|5`w9Na#}w86iqaxD}eEbu}6%O$OuIf_Fs=Ti>eFxtfhj8W$h`yUYB?=aR> zF}BzM+8PR!6?LIU9?;J>PNFS3c!w}gdrM3)4;|}}&Fmaa>}%+bCe3{!(s?TJdVAl- zpCf%Mc6PwA-p$a_m;P|isWp>FhN6kJmQQxKMUsxO)Viv(0L4^_ANpDs^3bl=ZSxhk zZ9&l1q>lYY@NvF4B6C$np2z`GQ=^Bs!vTJNzZudky)?OKY7}vWQC-+EYVwO){OZwxImb43! zrZiGe6H9h_!b#qt4ev?X#Xr_Ld517o^;vA<8MK|BgL?Q3^PaKDwANQs770d63MjDD z!M{Ob}zjY4r(9**^+{0FxD zuxBD=uzPaNjh@qMUhg@TY&<@=`3EiC8^Lbh=C5<|`;dnt^T|sfYumC!(w6>fdDF{8 zy&LO_#!Wo+>e!MRS`h0AG&0Ap1OsIeZR9KqF$ zGw6cNwQ)A(S^|{wd2sAq4kZ?zK;z`qX`OtAwuR57?4eLtI(__S89Uye23-T`zFppp zKRxb!@tMxCO-~%D8~5q-Zp0cy1bR!t9LC8%r*rY!gUAMuA5L2#X-oUPq&0&`S!&C= zDQnBjiTE|<0XF|SgZlK=P5zWE9PK`IDdPVG?Pa6Z5i}KR3wQYf?c`O`uAom@AI5m3 zW9(c#=5|X-Rn+^^V=|I|XcNW(fu^`-$y9v2n*wg*hpy2L(BXX%I!9lEuE7l0b9ye~ z!I)>k`rg6)v(Ck9`wQqm-k$!Hv^}jx+Lp0T(w0#sIh>iWqcz)~puAc9to<0vHu-DI zB0yUf{yfH{kF~bobgd&0@vnnIJW67{=b;@d3GLwg8U5rJNUvc&ezUkX?(?k!Mj|^b zB^p~utsHZN#t?Hi_;p#dy)Iy^z`eMwIzyUo%N79T zfoz}Nm(-ARG#w=E8K63_8Fp#@;)6Uku=ZmrMfqd>qqT%B#JuqdZ5L!y_CP4rsz8mk z6=eS2>H;;|B|P+wRTcfF)ibdj-!hcjsZly>bMjMKfC`s-oy zIO0a^d83sNZ5^%tnR@T$K)qWytS=88(zyiZ(f$uJrVL-LQv|Z+bdWV>z>bD2@W))O zvn9Y@70Ta@^`|qa*Ic^APh*O}xD!-^GX9cr@lG?2@P&*s6pE^SFyCQp#rn_S4mqrM z^Dh+cN)=|Ds((t^zVioq-HK-z6MtTjYYs5= z+koY&qB{OJCHCM8Mu%tt?r{vU9nl-N`7-wCsd*#n7?>he6jbVB;Yb7l#>G8IJ2@w; zDW9`>Re(1BC;Uy&2Qa7c4O;gCD5{@<;{?@AId{U~5dBPJ<94enqCU&+HD1{|TG^93 zR zGEmo||2lZplq2x8W9#EHB%LMVg1zfW!GVn=bHGlv4ttKO2CHYv2Qg9H4b-?;x;^thE(W56BBc3;NM=Iy$4xyvm<0BiY!ZTylY@9omKI|q)UuF!IpWQxG zv4Fi2XXVehg4-F>c3&FhKcS2OuQGZzVn20RxsZIMT>SU<)hk9W74i}*N)sOYn{*+# z4OknNF9M5kvtK@5-C{h4{l!N?Sv`Y|pVgF;Gijpvvk>dN%wbPY+OYd?TX63Tujd`H zw_;u{-XYxUqrm$T)j*YB_Go3&*0Bmnj<;f?#9KK(?^p@?f9gz~Bd8D0Tb-0X&7ZbL z!x_Xsu3Qyx3;$wrS-FHz%xuN651P{79U@|r(F8#C1qfmSG_9WHT4Q8P$&80`KRMxM=WVOnLvycyvqfQ zaQ;ry^k=MZ!geoxv?UVs2ElBtJFI(0tC-}sZYLU7*kMW&GPAWL5++rv0zO3iEaqGI zmyjFC150cltB)(nMdU-};?LhzE$?_;85j&$72)~8Q`Hzj>hY{<@bJ$T>B1f_F@@a= zr2o?xf^kP%7KrC6JWDwQ^>}Ym$Kc%r#y&WQ_CKMFyo3KbVx7XSzTMu|2WwkXzJYwa zr&#>TtMVYsu^~^)4J|Z33+5_J>JRwQS>u7;H49@84^ArFKHe>d&=;^C zs(l7dtHUt|Ll&tFn;UK&zF&VDVQ1LyOW<9$;!>U9;c>uXGF{w3J& zw&F?y*q$&(Xl~ee~S?vEz=&yf#N4a9pMKymJ$p!d#%mE}HuvWVJJ;HYa{PrfNFrIsr zVFABH`~O=082i8YrE1y08|t9=mXa6!-%xR(t1uBp3%>mh;k(4n?lr0mg${*i#=FX8 z?dY4Vjlcf3ijzHA81)0>0Y(d>;A}w%^c8*gwtX+M=q-qY8|p>yMnN#=Ej34Wu|V+b z@03r&`GPRO`~|(miTAS1|9cD}ria-7tMI>D@VnLjZS8dPCmx-e?=$i6L+tu60k>My z?G~omYb^4o0l(t&=)Jes*zq`Ts*Sqce*GKQnd<+B_OI+b?7w-ZHBGHE^&8juwwB=V cDL$raCvbVf%JU9=Eyr8(An^334*ZuL7rPE@YnEeQpZi zhJKHS<>lm{C<@7)8XZzjy1#y_WV|EpQfFPPq9&kDeH22tz zPrVj9_))f1n(W1peD-j-sBYjaNa(oPda!q16#%lTExj#L&-D+h3azXtY2fBZ_o57e zl_H2}I`+7I^6aWNIWekO+Hm&WcxT1u-|i{R36spey=f5%$EPm}#ZmR>b9HY9^#wU#V(>(S`jqer~atcB+A;J%x! z2Y2t-`0T~kmQU+*F=MYR@FNhy0S30EGXZwaaI_?ss=TX8^y)si{IE#QfW_W8<$>B)n9{A2aPf&>7J4I}sK@2z>qMp|x9P z|7e~x7X~kVaqbczivJ7EBT$4wTpd2*0~fCe`Hq?`=9*+ulM_H%LS=76oX!AxJpFmY zG(SKYsHk!mxVQ^^M5j_iO0o>BLSV*yUa4 hf4Y?wP*uJ#_#eP!XS}QXG2Q?G002ovPDHLkV1n@%wYvZS diff --git a/website/src/site.webmanifest b/website/src/site.webmanifest new file mode 100644 index 0000000..d68ed03 --- /dev/null +++ b/website/src/site.webmanifest @@ -0,0 +1,21 @@ +{ + "name": "CommandTree", + "short_name": "CommandTree", + "description": "One sidebar for every command in your VS Code workspace. AI-powered.", + "start_url": "/", + "display": "browser", + "background_color": "#ffffff", + "theme_color": "#2a8c7a", + "icons": [ + { + "src": "/assets/images/favicon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/assets/images/favicon-512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} From a3a869a553eb7b462d887fe2b9bac3326930cd26 Mon Sep 17 00:00:00 2001 From: Jon Canning Date: Thu, 26 Mar 2026 12:25:57 +0000 Subject: [PATCH 03/25] Add mise task discovery support Discover tasks from mise.toml, .mise.toml, mise.yaml, and .mise.yaml config files. Extracts task names and descriptions from [tasks.*] TOML sections and tasks: YAML blocks. Fixes TOML parser to recognize [tasks.name] sections without requiring a bare [tasks] preamble, matching how mise configs are written in practice. --- src/discovery/index.ts | 11 ++- src/discovery/mise.ts | 80 ++++++++++++++++++ src/discovery/parsers/miseParser.ts | 118 +++++++++++++++++++++++++++ src/models/TaskItem.ts | 3 +- src/test/unit/discovery.unit.test.ts | 105 ++++++++++++++++++++++++ src/watchers.ts | 2 +- 6 files changed, 316 insertions(+), 3 deletions(-) create mode 100644 src/discovery/mise.ts create mode 100644 src/discovery/parsers/miseParser.ts diff --git a/src/discovery/index.ts b/src/discovery/index.ts index 53c1785..f7da810 100644 --- a/src/discovery/index.ts +++ b/src/discovery/index.ts @@ -29,6 +29,7 @@ import { ICON_DEF as FSHARP_SCRIPT_ICON, CATEGORY_DEF as FSHARP_SCRIPT_CAT, } from "./fsharp-script"; +import { discoverMiseTasks, ICON_DEF as MISE_ICON, CATEGORY_DEF as MISE_CAT } from "./mise"; import { logger } from "../utils/logger"; export const ICON_REGISTRY: Record = { @@ -53,6 +54,7 @@ export const ICON_REGISTRY: Record = { markdown: MARKDOWN_ICON, "csharp-script": CSHARP_SCRIPT_ICON, "fsharp-script": FSHARP_SCRIPT_ICON, + mise: MISE_ICON, }; export const CATEGORY_DEFS: readonly CategoryDef[] = [ @@ -77,6 +79,7 @@ export const CATEGORY_DEFS: readonly CategoryDef[] = [ MARKDOWN_CAT, CSHARP_SCRIPT_CAT, FSHARP_SCRIPT_CAT, + MISE_CAT, ]; export interface DiscoveryResult { @@ -101,6 +104,7 @@ export interface DiscoveryResult { markdown: CommandItem[]; "csharp-script": CommandItem[]; "fsharp-script": CommandItem[]; + mise: CommandItem[]; } /** @@ -132,6 +136,7 @@ export async function discoverAllTasks(workspaceRoot: string, excludePatterns: s markdown, csharpScript, fsharpScript, + mise, ] = await Promise.all([ discoverShellScripts(workspaceRoot, excludePatterns), discoverNpmScripts(workspaceRoot, excludePatterns), @@ -154,6 +159,7 @@ export async function discoverAllTasks(workspaceRoot: string, excludePatterns: s discoverMarkdownFiles(workspaceRoot, excludePatterns), discoverCsharpScripts(workspaceRoot, excludePatterns), discoverFsharpScripts(workspaceRoot, excludePatterns), + discoverMiseTasks(workspaceRoot, excludePatterns), ]); const result = { @@ -178,6 +184,7 @@ export async function discoverAllTasks(workspaceRoot: string, excludePatterns: s markdown, "csharp-script": csharpScript, "fsharp-script": fsharpScript, + mise, }; const totalCount = @@ -201,7 +208,8 @@ export async function discoverAllTasks(workspaceRoot: string, excludePatterns: s dotnet.length + markdown.length + csharpScript.length + - fsharpScript.length; + fsharpScript.length + + mise.length; logger.info("Discovery complete", { totalCount }); @@ -234,6 +242,7 @@ export function flattenTasks(result: DiscoveryResult): CommandItem[] { ...result.markdown, ...result["csharp-script"], ...result["fsharp-script"], + ...result.mise, ]; } diff --git a/src/discovery/mise.ts b/src/discovery/mise.ts new file mode 100644 index 0000000..b8bfb20 --- /dev/null +++ b/src/discovery/mise.ts @@ -0,0 +1,80 @@ +import * as vscode from "vscode"; +import * as path from "path"; +import type { CommandItem, MutableCommandItem, IconDef, CategoryDef } from "../models/TaskItem"; +import { generateCommandId, simplifyPath } from "../models/TaskItem"; +import { readFile } from "../utils/fileUtils"; +import { parseMiseToml, parseMiseYaml } from "./parsers/miseParser"; + +export { parseMiseToml, parseMiseYaml } from "./parsers/miseParser"; +export type { MiseTask } from "./parsers/miseParser"; + +export const ICON_DEF: IconDef = { + icon: "package", + color: "terminal.ansiCyan", +}; + +export const CATEGORY_DEF: CategoryDef = { + type: "mise", + label: "Mise Tasks", +}; + +/** + * Discovers Mise tasks from mise configuration files. + */ +export async function discoverMiseTasks( + workspaceRoot: string, + excludePatterns: string[] +): Promise { + const exclude = `{${excludePatterns.join(",")}}`; + + // Mise supports: mise.toml, .mise.toml, mise.yaml, .mise.yaml + const [miseToml, dotMiseToml, miseYaml, dotMiseYaml] = await Promise.all([ + vscode.workspace.findFiles("**/mise.toml", exclude), + vscode.workspace.findFiles("**/.mise.toml", exclude), + vscode.workspace.findFiles("**/mise.yaml", exclude), + vscode.workspace.findFiles("**/.mise.yaml", exclude), + ]); + + const allFiles = [...miseToml, ...dotMiseToml, ...miseYaml, ...dotMiseYaml]; + const commands: CommandItem[] = []; + + for (const file of allFiles) { + const result = await readFile(file); + if (!result.ok) { + continue; + } + + const content = result.value; + const miseDir = path.dirname(file.fsPath); + const category = simplifyPath(file.fsPath, workspaceRoot); + + const tasks = file.fsPath.endsWith(".yaml") || file.fsPath.endsWith(".yml") + ? parseMiseYaml(content) + : parseMiseToml(content); + + for (const task of tasks) { + const taskCommand: MutableCommandItem = { + id: generateCommandId("mise", file.fsPath, task.name), + label: task.name, + type: "mise", + category, + command: `mise run ${task.name}`, + cwd: miseDir, + filePath: file.fsPath, + tags: [], + }; + + if (task.params.length > 0) { + taskCommand.params = task.params; + } + + if (task.description !== undefined) { + taskCommand.description = task.description; + } + + commands.push(taskCommand); + } + } + + return commands; +} diff --git a/src/discovery/parsers/miseParser.ts b/src/discovery/parsers/miseParser.ts new file mode 100644 index 0000000..01060a9 --- /dev/null +++ b/src/discovery/parsers/miseParser.ts @@ -0,0 +1,118 @@ +import type { ParamDef } from "../../models/TaskItem"; + +export interface MiseTask { + name: string; + description?: string; + params: ParamDef[]; +} + +/** + * Parses TOML format mise configuration. + */ +export function parseMiseToml(content: string): MiseTask[] { + const tasks: MiseTask[] = []; + + const lines = content.split("\n"); + let currentTask: MiseTask | null = null; + + for (const line of lines) { + const trimmed = line.trim(); + + // [tasks.name] sections are self-identifying — no [tasks] preamble needed + if (trimmed.startsWith("[tasks.")) { + if (currentTask !== null) { + tasks.push(currentTask); + currentTask = null; + } + + const match = /^\[tasks\.([^\]]+)\]$/.exec(trimmed); + if (match !== null && match[1] !== undefined) { + currentTask = { + name: match[1], + params: [], + }; + } + continue; + } + + // Any other section header ends the current task + if (trimmed.startsWith("[")) { + if (currentTask !== null) { + tasks.push(currentTask); + currentTask = null; + } + continue; + } + + // Extract description from current task + if (currentTask !== null && trimmed.startsWith("description")) { + const descMatch = /^description\s*=\s*"([^"]*)"/.exec(trimmed); + if (descMatch !== null && descMatch[1] !== undefined) { + currentTask.description = descMatch[1]; + } + } + } + + if (currentTask !== null) { + tasks.push(currentTask); + } + + return tasks; +} + +/** + * Parses YAML format mise configuration. + */ +export function parseMiseYaml(content: string): MiseTask[] { + const tasks: MiseTask[] = []; + + const lines = content.split("\n"); + let inTasks = false; + + for (const line of lines) { + // Skip empty lines and comments + if (line.trim() === "" || line.trim().startsWith("#")) { + continue; + } + + // Get indent level + const indent = line.search(/\S/); + + // Check for "tasks:" at root level + if (indent === 0 && line.trim() === "tasks:") { + inTasks = true; + continue; + } + + // Exit tasks section if we hit another root-level key + if (inTasks && indent === 0 && !line.trim().startsWith("tasks:")) { + inTasks = false; + } + + if (inTasks && indent > 0) { + // Task name line (immediate child of tasks) + if (indent === 2 && !line.trim().startsWith("-") && line.includes(":")) { + const match = /^\s+([^:]+):\s*$/.exec(line); + if (match !== null && match[1] !== undefined) { + tasks.push({ + name: match[1].trim(), + params: [], + }); + } + } + + // Description line (child of task) + if (indent > 2 && line.includes("description:")) { + const descMatch = /^\s+description:\s*["]?([^"]*)["]?\s*$/.exec(line); + if (descMatch !== null && descMatch[1] !== undefined && tasks.length > 0) { + const lastTask = tasks[tasks.length - 1]; + if (lastTask !== undefined) { + lastTask.description = descMatch[1]; + } + } + } + } + } + + return tasks; +} diff --git a/src/models/TaskItem.ts b/src/models/TaskItem.ts index a73764e..cb54ad9 100644 --- a/src/models/TaskItem.ts +++ b/src/models/TaskItem.ts @@ -44,7 +44,8 @@ export type CommandType = | "dotnet" | "markdown" | "csharp-script" - | "fsharp-script"; + | "fsharp-script" + | "mise"; /** * Parameter format types for flexible argument handling across different tools. diff --git a/src/test/unit/discovery.unit.test.ts b/src/test/unit/discovery.unit.test.ts index e170c1b..a960a09 100644 --- a/src/test/unit/discovery.unit.test.ts +++ b/src/test/unit/discovery.unit.test.ts @@ -4,6 +4,7 @@ import { parsePowerShellDescription, parseBatchDescription, } from "../../discovery/parsers/powershellParser"; +import { parseMiseToml, parseMiseYaml } from "../../discovery/parsers/miseParser"; interface ParsedParam { name: string; @@ -134,6 +135,110 @@ suite("PowerShell Parser Unit Tests", () => { }); }); + suite("parseMiseToml", () => { + test("parses tasks without [tasks] preamble", () => { + const content = [ + "[tools]", + 'go = "latest"', + "", + "[tasks.lint]", + 'run = "golangci-lint run --fix"', + "", + "[tasks.test]", + 'run = "gotestsum --rerun-fails"', + ].join("\n"); + const tasks = parseMiseToml(content); + assert.strictEqual(tasks.length, 2); + assert.strictEqual(tasks[0]?.name, "lint"); + assert.strictEqual(tasks[1]?.name, "test"); + }); + + test("parses tasks with [tasks] preamble", () => { + const content = [ + "[tasks]", + "[tasks.build]", + 'run = "cargo build"', + ].join("\n"); + const tasks = parseMiseToml(content); + assert.strictEqual(tasks.length, 1); + assert.strictEqual(tasks[0]?.name, "build"); + }); + + test("extracts description", () => { + const content = [ + "[tasks.deploy]", + 'description = "Deploy to production"', + 'run = "deploy.sh"', + ].join("\n"); + const tasks = parseMiseToml(content); + assert.strictEqual(tasks.length, 1); + assert.strictEqual(tasks[0]?.description, "Deploy to production"); + }); + + test("handles non-task sections interspersed", () => { + const content = [ + "[tasks.format]", + 'run = "go fmt ./..."', + "", + "[env]", + 'FOO = "bar"', + "", + "[tasks.build]", + 'run = "go build ."', + ].join("\n"); + const tasks = parseMiseToml(content); + assert.strictEqual(tasks.length, 2); + assert.strictEqual(tasks[0]?.name, "format"); + assert.strictEqual(tasks[1]?.name, "build"); + }); + + test("returns empty for no tasks", () => { + const content = [ + "[tools]", + 'go = "latest"', + ].join("\n"); + const tasks = parseMiseToml(content); + assert.strictEqual(tasks.length, 0); + }); + }); + + suite("parseMiseYaml", () => { + test("parses task names under tasks key", () => { + const content = [ + "tasks:", + " lint:", + " run: golangci-lint run", + " test:", + " run: gotestsum", + ].join("\n"); + const tasks = parseMiseYaml(content); + assert.strictEqual(tasks.length, 2); + assert.strictEqual(tasks[0]?.name, "lint"); + assert.strictEqual(tasks[1]?.name, "test"); + }); + + test("extracts description", () => { + const content = [ + "tasks:", + " deploy:", + ' description: "Deploy to prod"', + " run: deploy.sh", + ].join("\n"); + const tasks = parseMiseYaml(content); + assert.strictEqual(tasks.length, 1); + assert.strictEqual(tasks[0]?.description, "Deploy to prod"); + }); + + test("returns empty for no tasks", () => { + const content = [ + "tools:", + " go: latest", + ].join("\n"); + const tasks = parseMiseYaml(content); + assert.strictEqual(tasks.length, 0); + }); + }); + suite("parseBatchDescription", () => { test("extracts REM comment description", () => { const content = "@echo off\nREM Deploy the application\necho deploying"; diff --git a/src/watchers.ts b/src/watchers.ts index 89d12c7..ccf11eb 100644 --- a/src/watchers.ts +++ b/src/watchers.ts @@ -1,6 +1,6 @@ import * as vscode from "vscode"; -const TASK_FILE_PATTERN = "**/{package.json,Makefile,makefile,tasks.json,launch.json,*.sh,*.py}"; +const TASK_FILE_PATTERN = "**/{package.json,Makefile,makefile,tasks.json,launch.json,*.sh,*.py,mise.toml,.mise.toml,mise.yaml,.mise.yaml}"; const CONFIG_FILE_PATTERN = "**/.vscode/commandtree.json"; const TASK_DEBOUNCE_MS = 2000; const CONFIG_DEBOUNCE_MS = 1000; From c4eb4fa6d97d675c0b13b753887f766778abb96e Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Fri, 27 Mar 2026 06:01:43 +1100 Subject: [PATCH 04/25] Update changes --- website/src/_data/navigation.json | 4 ++-- website/src/_data/site.json | 2 +- website/src/docs/configuration.md | 3 +-- website/src/docs/execution.md | 3 +-- website/src/docs/index.md | 2 +- website/src/index.njk | 8 ++++---- website/tests/docs.spec.ts | 2 +- 7 files changed, 11 insertions(+), 13 deletions(-) diff --git a/website/src/_data/navigation.json b/website/src/_data/navigation.json index ec753f7..dd6c795 100644 --- a/website/src/_data/navigation.json +++ b/website/src/_data/navigation.json @@ -10,7 +10,7 @@ }, { "text": "GitHub", - "url": "https://github.com/melbournedeveloper/CommandTree", + "url": "https://github.com/MelbourneDeveloper/CommandTree", "external": true } ], @@ -33,7 +33,7 @@ "items": [ { "text": "GitHub", - "url": "https://github.com/melbournedeveloper/CommandTree" + "url": "https://github.com/MelbourneDeveloper/CommandTree" }, { "text": "VS Code Marketplace", diff --git a/website/src/_data/site.json b/website/src/_data/site.json index cc32404..0325fdc 100644 --- a/website/src/_data/site.json +++ b/website/src/_data/site.json @@ -14,7 +14,7 @@ "name": "Nimblesite Pty Ltd", "logo": "/assets/images/logo.png", "sameAs": [ - "https://github.com/melbournedeveloper/CommandTree", + "https://github.com/MelbourneDeveloper/CommandTree", "https://marketplace.visualstudio.com/items?itemName=nimblesite.commandtree" ] } diff --git a/website/src/docs/configuration.md b/website/src/docs/configuration.md index dcf3e24..682ff75 100644 --- a/website/src/docs/configuration.md +++ b/website/src/docs/configuration.md @@ -31,7 +31,6 @@ Right-click any command and choose **Add Tag** to assign a tag. Tags are stored | Command | Description | |---------|-------------| -| `commandtree.filter` | Text filter input | | `commandtree.filterByTag` | Tag filter picker | | `commandtree.clearFilter` | Clear all filters | @@ -47,7 +46,7 @@ Tags are assigned one command at a time via right-click. Tags are stored in the ### How do I filter by both text and tag? -Use `commandtree.filter` for text search and `commandtree.filterByTag` for tag-based filtering. Filters can be combined. Use `commandtree.clearFilter` to reset all filters. +Use `commandtree.filterByTag` for tag-based filtering. Use `commandtree.clearFilter` to reset all filters. ### What exclude patterns are set by default? diff --git a/website/src/docs/execution.md b/website/src/docs/execution.md index a832cc2..6d398da 100644 --- a/website/src/docs/execution.md +++ b/website/src/docs/execution.md @@ -21,7 +21,7 @@ Sends the command to the active terminal. Triggered by the circle-play button or ## Debug -Launches with the VS Code debugger. Only for launch configurations. Triggered by the bug button or `commandtree.debug`. +Launch configurations from `.vscode/launch.json` are launched with the VS Code debugger automatically when you run them. ## Parameterized Commands @@ -33,7 +33,6 @@ Shell scripts with `@param` comments prompt for input before execution. VS Code |---------|-------------| | `commandtree.run` | Run command in new terminal | | `commandtree.runInCurrentTerminal` | Run in active terminal | -| `commandtree.debug` | Launch with debugger | | `commandtree.refresh` | Reload all commands | ## Frequently Asked Questions diff --git a/website/src/docs/index.md b/website/src/docs/index.md index 55a52d9..fa2c483 100644 --- a/website/src/docs/index.md +++ b/website/src/docs/index.md @@ -29,7 +29,7 @@ code --install-extension nimblesite.commandtree ## Building from Source ```bash -git clone https://github.com/melbournedeveloper/CommandTree.git +git clone https://github.com/MelbourneDeveloper/CommandTree.git cd CommandTree npm install npm run package diff --git a/website/src/index.njk b/website/src/index.njk index 70f5556..a29c690 100644 --- a/website/src/index.njk +++ b/website/src/index.njk @@ -145,7 +145,7 @@ description: CommandTree discovers all runnable commands in your VS Code workspa 🐘

Gradle Tasks

-

build.gradle

+

build.gradle / .kts

@@ -180,14 +180,14 @@ description: CommandTree discovers all runnable commands in your VS Code workspa

Taskfile Tasks

-

Taskfile.yml

+

Taskfile.yml / .yaml

🦕

Deno Tasks

-

deno.json

+

deno.json / .jsonc

@@ -208,7 +208,7 @@ description: CommandTree discovers all runnable commands in your VS Code workspa 🐳

Docker Compose

-

docker-compose.yml

+

docker-compose / compose .yml

diff --git a/website/tests/docs.spec.ts b/website/tests/docs.spec.ts index 7b7cbcb..09605e7 100644 --- a/website/tests/docs.spec.ts +++ b/website/tests/docs.spec.ts @@ -66,7 +66,7 @@ test.describe('Documentation', () => { const table = page.locator('table'); await expect(table).toBeVisible(); await expect(table).toContainText('commandtree.run'); - await expect(table).toContainText('commandtree.debug'); + await expect(table).toContainText('commandtree.runInCurrentTerminal'); }); test('configuration page loads with all sections', async ({ page }) => { From 6c92c475479e37458d99329866c864cd00db93b4 Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Mon, 6 Apr 2026 08:59:28 +1000 Subject: [PATCH 05/25] Csharpier formatting --- dotnet-tools.json | 13 +++++++++++++ src/test/fixtures/workspace/build.xml | 20 ++++++++++---------- src/test/fixtures/workspace/pom.xml | 16 +++++++++------- 3 files changed, 32 insertions(+), 17 deletions(-) create mode 100644 dotnet-tools.json diff --git a/dotnet-tools.json b/dotnet-tools.json new file mode 100644 index 0000000..97f37dc --- /dev/null +++ b/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "csharpier": { + "version": "1.2.6", + "commands": [ + "csharpier" + ], + "rollForward": false + } + } +} \ No newline at end of file diff --git a/src/test/fixtures/workspace/build.xml b/src/test/fixtures/workspace/build.xml index 8db6e5f..46bd198 100644 --- a/src/test/fixtures/workspace/build.xml +++ b/src/test/fixtures/workspace/build.xml @@ -1,16 +1,16 @@ - Test Ant build file + Test Ant build file - - - + + + - - - + + + - - - + + + diff --git a/src/test/fixtures/workspace/pom.xml b/src/test/fixtures/workspace/pom.xml index 9de76b6..833dd73 100644 --- a/src/test/fixtures/workspace/pom.xml +++ b/src/test/fixtures/workspace/pom.xml @@ -1,9 +1,11 @@ - - 4.0.0 - com.example - test-project - 1.0-SNAPSHOT + + 4.0.0 + com.example + test-project + 1.0-SNAPSHOT From 4fb8a7e5cb67e012938c5be4bf5177a1953a095b Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Mon, 6 Apr 2026 09:01:27 +1000 Subject: [PATCH 06/25] Formatting --- src/discovery/mise.ts | 10 +++----- src/test/unit/discovery.unit.test.ts | 37 +++++----------------------- src/watchers.ts | 3 ++- 3 files changed, 11 insertions(+), 39 deletions(-) diff --git a/src/discovery/mise.ts b/src/discovery/mise.ts index b8bfb20..79c0669 100644 --- a/src/discovery/mise.ts +++ b/src/discovery/mise.ts @@ -21,10 +21,7 @@ export const CATEGORY_DEF: CategoryDef = { /** * Discovers Mise tasks from mise configuration files. */ -export async function discoverMiseTasks( - workspaceRoot: string, - excludePatterns: string[] -): Promise { +export async function discoverMiseTasks(workspaceRoot: string, excludePatterns: string[]): Promise { const exclude = `{${excludePatterns.join(",")}}`; // Mise supports: mise.toml, .mise.toml, mise.yaml, .mise.yaml @@ -48,9 +45,8 @@ export async function discoverMiseTasks( const miseDir = path.dirname(file.fsPath); const category = simplifyPath(file.fsPath, workspaceRoot); - const tasks = file.fsPath.endsWith(".yaml") || file.fsPath.endsWith(".yml") - ? parseMiseYaml(content) - : parseMiseToml(content); + const tasks = + file.fsPath.endsWith(".yaml") || file.fsPath.endsWith(".yml") ? parseMiseYaml(content) : parseMiseToml(content); for (const task of tasks) { const taskCommand: MutableCommandItem = { diff --git a/src/test/unit/discovery.unit.test.ts b/src/test/unit/discovery.unit.test.ts index a960a09..d765bb9 100644 --- a/src/test/unit/discovery.unit.test.ts +++ b/src/test/unit/discovery.unit.test.ts @@ -154,22 +154,14 @@ suite("PowerShell Parser Unit Tests", () => { }); test("parses tasks with [tasks] preamble", () => { - const content = [ - "[tasks]", - "[tasks.build]", - 'run = "cargo build"', - ].join("\n"); + const content = ["[tasks]", "[tasks.build]", 'run = "cargo build"'].join("\n"); const tasks = parseMiseToml(content); assert.strictEqual(tasks.length, 1); assert.strictEqual(tasks[0]?.name, "build"); }); test("extracts description", () => { - const content = [ - "[tasks.deploy]", - 'description = "Deploy to production"', - 'run = "deploy.sh"', - ].join("\n"); + const content = ["[tasks.deploy]", 'description = "Deploy to production"', 'run = "deploy.sh"'].join("\n"); const tasks = parseMiseToml(content); assert.strictEqual(tasks.length, 1); assert.strictEqual(tasks[0]?.description, "Deploy to production"); @@ -193,10 +185,7 @@ suite("PowerShell Parser Unit Tests", () => { }); test("returns empty for no tasks", () => { - const content = [ - "[tools]", - 'go = "latest"', - ].join("\n"); + const content = ["[tools]", 'go = "latest"'].join("\n"); const tasks = parseMiseToml(content); assert.strictEqual(tasks.length, 0); }); @@ -204,13 +193,7 @@ suite("PowerShell Parser Unit Tests", () => { suite("parseMiseYaml", () => { test("parses task names under tasks key", () => { - const content = [ - "tasks:", - " lint:", - " run: golangci-lint run", - " test:", - " run: gotestsum", - ].join("\n"); + const content = ["tasks:", " lint:", " run: golangci-lint run", " test:", " run: gotestsum"].join("\n"); const tasks = parseMiseYaml(content); assert.strictEqual(tasks.length, 2); assert.strictEqual(tasks[0]?.name, "lint"); @@ -218,22 +201,14 @@ suite("PowerShell Parser Unit Tests", () => { }); test("extracts description", () => { - const content = [ - "tasks:", - " deploy:", - ' description: "Deploy to prod"', - " run: deploy.sh", - ].join("\n"); + const content = ["tasks:", " deploy:", ' description: "Deploy to prod"', " run: deploy.sh"].join("\n"); const tasks = parseMiseYaml(content); assert.strictEqual(tasks.length, 1); assert.strictEqual(tasks[0]?.description, "Deploy to prod"); }); test("returns empty for no tasks", () => { - const content = [ - "tools:", - " go: latest", - ].join("\n"); + const content = ["tools:", " go: latest"].join("\n"); const tasks = parseMiseYaml(content); assert.strictEqual(tasks.length, 0); }); diff --git a/src/watchers.ts b/src/watchers.ts index ccf11eb..1556fdd 100644 --- a/src/watchers.ts +++ b/src/watchers.ts @@ -1,6 +1,7 @@ import * as vscode from "vscode"; -const TASK_FILE_PATTERN = "**/{package.json,Makefile,makefile,tasks.json,launch.json,*.sh,*.py,mise.toml,.mise.toml,mise.yaml,.mise.yaml}"; +const TASK_FILE_PATTERN = + "**/{package.json,Makefile,makefile,tasks.json,launch.json,*.sh,*.py,mise.toml,.mise.toml,mise.yaml,.mise.yaml}"; const CONFIG_FILE_PATTERN = "**/.vscode/commandtree.json"; const TASK_DEBOUNCE_MS = 2000; const CONFIG_DEBOUNCE_MS = 1000; From d3a3bef770dc6a08ad9a3b37db02aba686d730e7 Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Mon, 6 Apr 2026 09:10:06 +1000 Subject: [PATCH 07/25] Linting fixes --- .vscodeignore | 2 +- CONTRIBUTING.md | 37 +++++++++ Claude.md | 2 + Makefile | 2 + README.md | 4 + package.json | 2 +- scripts/convert-icon.py | 105 ------------------------ src/discovery/parsers/miseParser.ts | 113 ++++++++++++++------------ {scripts => tools}/check-coverage.mjs | 0 9 files changed, 106 insertions(+), 161 deletions(-) create mode 100644 CONTRIBUTING.md delete mode 100644 scripts/convert-icon.py rename {scripts => tools}/check-coverage.mjs (100%) diff --git a/.vscodeignore b/.vscodeignore index 57106b2..bbb5e26 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -6,7 +6,7 @@ test-fixtures/** out/test/** node_modules/** !node_modules/node-sqlite3-wasm/** -scripts/** +tools/** .too_many_cooks/** .claude/** .github/** diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..6ec2c19 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,37 @@ +# Contributing to CommandTree + +## Prerequisites + +- Node.js (LTS) +- VS Code + +## Setup + +```bash +npm install +``` + +## CI Gate + +Before submitting a pull request, you **must** run: + +```bash +make ci +``` + +This runs formatting, linting, building, testing (with coverage check), and packaging. All steps must pass. Pull requests that fail `make ci` will not be merged. + +## Make Targets + +| Target | Description | +|--------|-------------| +| `make format` | Format source with Prettier | +| `make lint` | Lint with ESLint | +| `make build` | Compile TypeScript | +| `make test` | Run unit tests, e2e tests with coverage, and coverage threshold check | +| `make package` | Build VSIX package | +| `make ci` | Run all of the above in sequence | + +## Coverage + +Tests enforce a 90% coverage threshold on lines, functions, branches, and statements. The coverage check runs automatically as part of `make test`. diff --git a/Claude.md b/Claude.md index b671f94..734bdb1 100644 --- a/Claude.md +++ b/Claude.md @@ -1,5 +1,7 @@ # CLAUDE.md - CommandTree Extension +⚠️ CRITICAL: **Reduce token usage.** Check file size before loading. Write less. Delete fluff and dead code. Alert user when context is loaded with pointless files. ⚠️ + ## Too Many Cooks You are working with many other agents. Make sure there is effective cooperation diff --git a/Makefile b/Makefile index 6c1765e..470b567 100644 --- a/Makefile +++ b/Makefile @@ -15,9 +15,11 @@ package: build test: build npm run test:unit npx vscode-test --coverage + node tools/check-coverage.mjs test-exclude-ci: build npm run test:unit npx vscode-test --coverage --grep @exclude-ci --invert + node tools/check-coverage.mjs ci: format lint build test package diff --git a/README.md b/README.md index a285179..cf0f59c 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,10 @@ Open a workspace and the CommandTree panel appears in the sidebar. All discovere | `commandtree.excludePatterns` | Glob patterns to exclude from discovery | `**/node_modules/**`, `**/.git/**`, etc. | | `commandtree.sortOrder` | Sort commands by `folder`, `name`, or `type` | `folder` | +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and guidelines. All pull requests must pass `make ci` before merging. + ## License [MIT](LICENSE) diff --git a/package.json b/package.json index 069cabf..893978e 100644 --- a/package.json +++ b/package.json @@ -382,7 +382,7 @@ "test:unit": "mocha out/test/unit/**/*.test.js", "test:e2e": "vscode-test", "test:coverage": "vscode-test --coverage", - "coverage:check": "node scripts/check-coverage.mjs", + "coverage:check": "node tools/check-coverage.mjs", "clean": "rm -rf node_modules out *.vsix coverage || true", "package": "npm run compile && vsce package", "uninstall": "code --uninstall-extension nimblesite.commandtree || true", diff --git a/scripts/convert-icon.py b/scripts/convert-icon.py deleted file mode 100644 index f650193..0000000 --- a/scripts/convert-icon.py +++ /dev/null @@ -1,105 +0,0 @@ -#!/usr/bin/env python3 -""" -Convert icon.png to SVG files: -- icon.svg: Full color vectorized version -- activitybar-icon.svg: Monochrome silhouette with currentColor for VS Code themes -""" - -from PIL import Image -import vtracer -import re - -def convert_to_color_svg(input_png: str, output_svg: str) -> None: - """Convert PNG to full color SVG.""" - vtracer.convert_image_to_svg_py( - input_png, - output_svg, - colormode='color', - hierarchical='stacked', - mode='spline', - filter_speckle=4, - color_precision=6, - layer_difference=16, - corner_threshold=60, - length_threshold=4.0, - max_iterations=10, - splice_threshold=45, - path_precision=3 - ) - print(f'Created color SVG: {output_svg}') - - -def convert_to_monochrome_svg(input_png: str, output_svg: str) -> None: - """Convert PNG to monochrome SVG with currentColor for activity bar.""" - # Load the original PNG - img = Image.open(input_png).convert('RGBA') - data = list(img.getdata()) - width, height = img.size - - # Create silhouette - keep non-transparent, non-white pixels as black - new_data = [] - for item in data: - r, g, b, a = item - if a > 50 and not (r > 250 and g > 250 and b > 250): - new_data.append((0, 0, 0, 255)) # Black - else: - new_data.append((255, 255, 255, 255)) # White background - - img_bw = Image.new('RGBA', (width, height)) - img_bw.putdata(new_data) - img_bw.save('/tmp/icon_silhouette.png') - - # Convert to SVG - vtracer.convert_image_to_svg_py( - '/tmp/icon_silhouette.png', - '/tmp/silhouette.svg', - colormode='binary', - hierarchical='stacked', - mode='spline', - filter_speckle=4, - corner_threshold=60, - length_threshold=4.0, - max_iterations=10, - splice_threshold=45, - path_precision=3 - ) - - # Read and extract black paths only - with open('/tmp/silhouette.svg', 'r') as f: - content = f.read() - - paths = re.findall(r']+/>', content) - black_paths = [p for p in paths if '#000' in p or 'black' in p.lower()] - - if not black_paths: - black_paths = [p for p in paths if '#fff' not in p and '#FFF' not in p] - - # Create SVG with currentColor - svg_content = '\n' - - for path in black_paths: - path = re.sub(r'fill="[^"]*"', 'fill="currentColor"', path) - svg_content += path + '\n' - - svg_content += '' - - with open(output_svg, 'w') as f: - f.write(svg_content) - - print(f'Created monochrome SVG: {output_svg}') - - -if __name__ == '__main__': - import os - - script_dir = os.path.dirname(os.path.abspath(__file__)) - project_dir = os.path.dirname(script_dir) - - input_png = os.path.join(project_dir, 'icon.png') - color_svg = os.path.join(project_dir, 'icon.svg') - mono_svg = os.path.join(project_dir, 'activitybar-icon.svg') - - convert_to_color_svg(input_png, color_svg) - convert_to_monochrome_svg(input_png, mono_svg) - - print('Done!') diff --git a/src/discovery/parsers/miseParser.ts b/src/discovery/parsers/miseParser.ts index 01060a9..befaaba 100644 --- a/src/discovery/parsers/miseParser.ts +++ b/src/discovery/parsers/miseParser.ts @@ -6,58 +6,88 @@ export interface MiseTask { params: ParamDef[]; } +function parseTomlTaskHeader(trimmed: string): string | undefined { + const match = /^\[tasks\.([^\]]+)\]$/.exec(trimmed); + return match?.[1]; +} + +function parseTomlDescription(trimmed: string): string | undefined { + const match = /^description\s*=\s*"([^"]*)"/.exec(trimmed); + return match?.[1]; +} + +function finishTask(tasks: MiseTask[], current: MiseTask | null): void { + if (current !== null) { + tasks.push(current); + } +} + /** * Parses TOML format mise configuration. */ export function parseMiseToml(content: string): MiseTask[] { const tasks: MiseTask[] = []; - const lines = content.split("\n"); let currentTask: MiseTask | null = null; for (const line of lines) { const trimmed = line.trim(); - // [tasks.name] sections are self-identifying — no [tasks] preamble needed if (trimmed.startsWith("[tasks.")) { - if (currentTask !== null) { - tasks.push(currentTask); - currentTask = null; - } - - const match = /^\[tasks\.([^\]]+)\]$/.exec(trimmed); - if (match !== null && match[1] !== undefined) { - currentTask = { - name: match[1], - params: [], - }; - } + finishTask(tasks, currentTask); + const name = parseTomlTaskHeader(trimmed); + currentTask = name !== undefined ? { name, params: [] } : null; continue; } - // Any other section header ends the current task if (trimmed.startsWith("[")) { - if (currentTask !== null) { - tasks.push(currentTask); - currentTask = null; - } + finishTask(tasks, currentTask); + currentTask = null; continue; } - // Extract description from current task if (currentTask !== null && trimmed.startsWith("description")) { - const descMatch = /^description\s*=\s*"([^"]*)"/.exec(trimmed); - if (descMatch !== null && descMatch[1] !== undefined) { - currentTask.description = descMatch[1]; + const desc = parseTomlDescription(trimmed); + if (desc !== undefined) { + currentTask.description = desc; } } } - if (currentTask !== null) { - tasks.push(currentTask); + finishTask(tasks, currentTask); + return tasks; +} + +function parseYamlTaskName(line: string): string | undefined { + const match = /^\s+([^:]+):\s*$/.exec(line); + return match?.[1]?.trim(); +} + +function parseYamlDescription(line: string): string | undefined { + const match = /^\s+description:\s*["]?([^"]*)["]?\s*$/.exec(line); + return match?.[1]; +} + +function isSkippableLine(trimmed: string): boolean { + return trimmed === "" || trimmed.startsWith("#"); +} + +function processYamlTaskLine(tasks: MiseTask[], line: string, indent: number): void { + if (indent === 2 && !line.trim().startsWith("-") && line.includes(":")) { + const name = parseYamlTaskName(line); + if (name !== undefined) { + tasks.push({ name, params: [] }); + } + return; } - return tasks; + if (indent > 2 && line.includes("description:")) { + const desc = parseYamlDescription(line); + const lastTask = tasks[tasks.length - 1]; + if (desc !== undefined && lastTask !== undefined) { + lastTask.description = desc; + } + } } /** @@ -65,52 +95,27 @@ export function parseMiseToml(content: string): MiseTask[] { */ export function parseMiseYaml(content: string): MiseTask[] { const tasks: MiseTask[] = []; - const lines = content.split("\n"); let inTasks = false; for (const line of lines) { - // Skip empty lines and comments - if (line.trim() === "" || line.trim().startsWith("#")) { + if (isSkippableLine(line.trim())) { continue; } - // Get indent level const indent = line.search(/\S/); - // Check for "tasks:" at root level if (indent === 0 && line.trim() === "tasks:") { inTasks = true; continue; } - // Exit tasks section if we hit another root-level key - if (inTasks && indent === 0 && !line.trim().startsWith("tasks:")) { + if (inTasks && indent === 0) { inTasks = false; } if (inTasks && indent > 0) { - // Task name line (immediate child of tasks) - if (indent === 2 && !line.trim().startsWith("-") && line.includes(":")) { - const match = /^\s+([^:]+):\s*$/.exec(line); - if (match !== null && match[1] !== undefined) { - tasks.push({ - name: match[1].trim(), - params: [], - }); - } - } - - // Description line (child of task) - if (indent > 2 && line.includes("description:")) { - const descMatch = /^\s+description:\s*["]?([^"]*)["]?\s*$/.exec(line); - if (descMatch !== null && descMatch[1] !== undefined && tasks.length > 0) { - const lastTask = tasks[tasks.length - 1]; - if (lastTask !== undefined) { - lastTask.description = descMatch[1]; - } - } - } + processYamlTaskLine(tasks, line, indent); } } diff --git a/scripts/check-coverage.mjs b/tools/check-coverage.mjs similarity index 100% rename from scripts/check-coverage.mjs rename to tools/check-coverage.mjs From 3120d109204017f856f6d8b5d87208ecdfe167c5 Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Mon, 6 Apr 2026 09:19:38 +1000 Subject: [PATCH 08/25] CI fix --- .github/workflows/ci.yml | 23 +- Makefile | 28 +- cspell.json | 2 + package-lock.json | 1153 +++++++++++++++++++++++++++++++++++++- package.json | 1 + 5 files changed, 1182 insertions(+), 25 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6a10d73..1952241 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,28 +17,23 @@ jobs: - run: npm ci - - name: Format check - run: npm run format:check + - name: Format + run: make format - name: Lint - run: npm run lint + run: make lint - name: Spell check - uses: streetsidesoftware/cspell-action@v6 - with: - files: "src/**/*.ts" + run: make spellcheck - name: Build - run: npm run compile - - - name: Unit tests - run: npm run test:unit + run: make build - - name: E2E tests with coverage - run: xvfb-run -a npx vscode-test --coverage --grep @exclude-ci --invert + - name: Test + run: make test EXCLUDE_CI=true - - name: Coverage threshold (90%) - run: npm run coverage:check + - name: Package + run: make package website: runs-on: ubuntu-latest diff --git a/Makefile b/Makefile index 470b567..e163863 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: format lint build package test test-exclude-ci ci +.PHONY: format lint spellcheck build package test ci format: npx prettier --write "src/**/*.ts" @@ -6,20 +6,32 @@ format: lint: npx eslint src +spellcheck: + npx cspell "src/**/*.ts" + build: npx tsc -p ./ package: build npx vsce package -test: build - npm run test:unit - npx vscode-test --coverage - node tools/check-coverage.mjs +UNAME := $(shell uname) +EXCLUDE_CI ?= false -test-exclude-ci: build +VSCODE_TEST_CMD = npx vscode-test --coverage +ifeq ($(EXCLUDE_CI),true) +VSCODE_TEST_CMD += --grep @exclude-ci --invert +endif + +ifeq ($(UNAME),Linux) +VSCODE_TEST = xvfb-run -a $(VSCODE_TEST_CMD) +else +VSCODE_TEST = $(VSCODE_TEST_CMD) +endif + +test: build npm run test:unit - npx vscode-test --coverage --grep @exclude-ci --invert + $(VSCODE_TEST) node tools/check-coverage.mjs -ci: format lint build test package +ci: format lint spellcheck build test package diff --git a/cspell.json b/cspell.json index 68f8e71..3524768 100644 --- a/cspell.json +++ b/cspell.json @@ -71,6 +71,8 @@ "visioncortex", "behaviour", "docstrings", + "golangci", + "gotestsum", "subproject" ] } diff --git a/package-lock.json b/package-lock.json index 4fed220..dc8c2ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "@vscode/test-electron": "^2.5.2", "@vscode/vsce": "^3.7.1", "c8": "^11.0.0", + "cspell": "^9.8.0", "eslint": "^10.1.0", "glob": "^13.0.6", "mocha": "^11.7.5", @@ -253,6 +254,635 @@ "node": ">=18" } }, + "node_modules/@cspell/cspell-bundled-dicts": { + "version": "9.8.0", + "resolved": "https://registry.npmjs.org/@cspell/cspell-bundled-dicts/-/cspell-bundled-dicts-9.8.0.tgz", + "integrity": "sha512-MpXFpVyBPfJQ1YuVotljqUaGf6lWuf+fuWBBgs0PHFYTSjRPWuIxviAaCDnup/CJLLH60xQL4IlcQe4TOjzljw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/dict-ada": "^4.1.1", + "@cspell/dict-al": "^1.1.1", + "@cspell/dict-aws": "^4.0.17", + "@cspell/dict-bash": "^4.2.2", + "@cspell/dict-companies": "^3.2.11", + "@cspell/dict-cpp": "^7.0.2", + "@cspell/dict-cryptocurrencies": "^5.0.5", + "@cspell/dict-csharp": "^4.0.8", + "@cspell/dict-css": "^4.1.1", + "@cspell/dict-dart": "^2.3.2", + "@cspell/dict-data-science": "^2.0.13", + "@cspell/dict-django": "^4.1.6", + "@cspell/dict-docker": "^1.1.17", + "@cspell/dict-dotnet": "^5.0.13", + "@cspell/dict-elixir": "^4.0.8", + "@cspell/dict-en_us": "^4.4.33", + "@cspell/dict-en-common-misspellings": "^2.1.12", + "@cspell/dict-en-gb-mit": "^3.1.22", + "@cspell/dict-filetypes": "^3.0.18", + "@cspell/dict-flutter": "^1.1.1", + "@cspell/dict-fonts": "^4.0.6", + "@cspell/dict-fsharp": "^1.1.1", + "@cspell/dict-fullstack": "^3.2.9", + "@cspell/dict-gaming-terms": "^1.1.2", + "@cspell/dict-git": "^3.1.0", + "@cspell/dict-golang": "^6.0.26", + "@cspell/dict-google": "^1.0.9", + "@cspell/dict-haskell": "^4.0.6", + "@cspell/dict-html": "^4.0.15", + "@cspell/dict-html-symbol-entities": "^4.0.5", + "@cspell/dict-java": "^5.0.12", + "@cspell/dict-julia": "^1.1.1", + "@cspell/dict-k8s": "^1.0.12", + "@cspell/dict-kotlin": "^1.1.1", + "@cspell/dict-latex": "^5.1.0", + "@cspell/dict-lorem-ipsum": "^4.0.5", + "@cspell/dict-lua": "^4.0.8", + "@cspell/dict-makefile": "^1.0.5", + "@cspell/dict-markdown": "^2.0.16", + "@cspell/dict-monkeyc": "^1.0.12", + "@cspell/dict-node": "^5.0.9", + "@cspell/dict-npm": "^5.2.38", + "@cspell/dict-php": "^4.1.1", + "@cspell/dict-powershell": "^5.0.15", + "@cspell/dict-public-licenses": "^2.0.16", + "@cspell/dict-python": "^4.2.26", + "@cspell/dict-r": "^2.1.1", + "@cspell/dict-ruby": "^5.1.1", + "@cspell/dict-rust": "^4.1.2", + "@cspell/dict-scala": "^5.0.9", + "@cspell/dict-shell": "^1.1.2", + "@cspell/dict-software-terms": "^5.2.2", + "@cspell/dict-sql": "^2.2.1", + "@cspell/dict-svelte": "^1.0.7", + "@cspell/dict-swift": "^2.0.6", + "@cspell/dict-terraform": "^1.1.3", + "@cspell/dict-typescript": "^3.2.3", + "@cspell/dict-vue": "^3.0.5", + "@cspell/dict-zig": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@cspell/cspell-json-reporter": { + "version": "9.8.0", + "resolved": "https://registry.npmjs.org/@cspell/cspell-json-reporter/-/cspell-json-reporter-9.8.0.tgz", + "integrity": "sha512-nqUaSo9T7l8KrE22gc7ZIs+zvP7ak1i7JqGdRs8sGvh2Ijqj43qYQLePgb1b/vm8a1bavnc51m+vf05hpd3g3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/cspell-types": "9.8.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@cspell/cspell-performance-monitor": { + "version": "9.8.0", + "resolved": "https://registry.npmjs.org/@cspell/cspell-performance-monitor/-/cspell-performance-monitor-9.8.0.tgz", + "integrity": "sha512-IsrXYzn23yJICIQ915ACdf+2lNEcFNTu5BIQt3khHOsGVvZ9/AZYpu9Dk825vUyZG7RHg2Oi6dYNiJtULG4ouQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18" + } + }, + "node_modules/@cspell/cspell-pipe": { + "version": "9.8.0", + "resolved": "https://registry.npmjs.org/@cspell/cspell-pipe/-/cspell-pipe-9.8.0.tgz", + "integrity": "sha512-ISEUD8PHYkd2Ktafc6hFfIXdGKYUvthA09NbwwZsWmOqYyk4wWKHZKqyyxD+BcrFwOyMOJcD8OEvIjkRQp2SJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/@cspell/cspell-resolver": { + "version": "9.8.0", + "resolved": "https://registry.npmjs.org/@cspell/cspell-resolver/-/cspell-resolver-9.8.0.tgz", + "integrity": "sha512-PZJj56BZpKfMxOzWkyt7b+aIXObe+8Ku/zLI4xDXPSuQPENbHBFHfPIZx68CyGEkanKxZ1ewKVx/FT1FUy+wDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "global-directory": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@cspell/cspell-service-bus": { + "version": "9.8.0", + "resolved": "https://registry.npmjs.org/@cspell/cspell-service-bus/-/cspell-service-bus-9.8.0.tgz", + "integrity": "sha512-P45sd2nqwcqhulBBbQnZB/JNcobecTrP4Ky3vmEq0cprsvavc+ZoHF9U2Ql5ghMSUzjrF2n1aNzZ8cH4IlsnKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/@cspell/cspell-types": { + "version": "9.8.0", + "resolved": "https://registry.npmjs.org/@cspell/cspell-types/-/cspell-types-9.8.0.tgz", + "integrity": "sha512-7Ge4UD6SCA49Tcc3+GTlz3Xn4cqVUAXtDO0u9IeHvJgkN3Me2Rw2GB/CtGmhKST3YeEeZMX7ww09TdHMUJlehw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/@cspell/cspell-worker": { + "version": "9.8.0", + "resolved": "https://registry.npmjs.org/@cspell/cspell-worker/-/cspell-worker-9.8.0.tgz", + "integrity": "sha512-W8FLdE3MXPLbWtAXciILQhk9CHd6Mt+HRjZHM8m+dwE1Bc2TAjUai8kIxsdhHUq58p7gYY2ekr5sg1uYOUgTAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cspell-lib": "9.8.0" + }, + "engines": { + "node": ">=20.18" + } + }, + "node_modules/@cspell/dict-ada": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-ada/-/dict-ada-4.1.1.tgz", + "integrity": "sha512-E+0YW9RhZod/9Qy2gxfNZiHJjCYFlCdI69br1eviQQWB8yOTJX0JHXLs79kOYhSW0kINPVUdvddEBe6Lu6CjGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-al": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-al/-/dict-al-1.1.1.tgz", + "integrity": "sha512-sD8GCaZetgQL4+MaJLXqbzWcRjfKVp8x+px3HuCaaiATAAtvjwUQ5/Iubiqwfd1boIh2Y1/3EgM3TLQ7Q8e0wQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-aws": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@cspell/dict-aws/-/dict-aws-4.0.17.tgz", + "integrity": "sha512-ORcblTWcdlGjIbWrgKF+8CNEBQiLVKdUOFoTn0KPNkAYnFcdPP0muT4892h7H4Xafh3j72wqB4/loQ6Nti9E/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-bash": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@cspell/dict-bash/-/dict-bash-4.2.2.tgz", + "integrity": "sha512-kyWbwtX3TsCf5l49gGQIZkRLaB/P8g73GDRm41Zu8Mv51kjl2H7Au0TsEvHv7jzcsRLS6aUYaZv6Zsvk1fOz+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/dict-shell": "1.1.2" + } + }, + "node_modules/@cspell/dict-companies": { + "version": "3.2.11", + "resolved": "https://registry.npmjs.org/@cspell/dict-companies/-/dict-companies-3.2.11.tgz", + "integrity": "sha512-0cmafbcz2pTHXLd59eLR1gvDvN6aWAOM0+cIL4LLF9GX9yB2iKDNrKsvs4tJRqutoaTdwNFBbV0FYv+6iCtebQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-cpp": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@cspell/dict-cpp/-/dict-cpp-7.0.2.tgz", + "integrity": "sha512-dfbeERiVNeqmo/npivdR6rDiBCqZi3QtjH2Z0HFcXwpdj6i97dX1xaKyK2GUsO/p4u1TOv63Dmj5Vm48haDpuA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-cryptocurrencies": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@cspell/dict-cryptocurrencies/-/dict-cryptocurrencies-5.0.5.tgz", + "integrity": "sha512-R68hYYF/rtlE6T/dsObStzN5QZw+0aQBinAXuWCVqwdS7YZo0X33vGMfChkHaiCo3Z2+bkegqHlqxZF4TD3rUA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-csharp": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@cspell/dict-csharp/-/dict-csharp-4.0.8.tgz", + "integrity": "sha512-qmk45pKFHSxckl5mSlbHxmDitSsGMlk/XzFgt7emeTJWLNSTUK//MbYAkBNRtfzB4uD7pAFiKgpKgtJrTMRnrQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-css": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-css/-/dict-css-4.1.1.tgz", + "integrity": "sha512-y/Vgo6qY08e1t9OqR56qjoFLBCpi4QfWMf2qzD1l9omRZwvSMQGRPz4x0bxkkkU4oocMAeztjzCsmLew//c/8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-dart": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@cspell/dict-dart/-/dict-dart-2.3.2.tgz", + "integrity": "sha512-sUiLW56t9gfZcu8iR/5EUg+KYyRD83Cjl3yjDEA2ApVuJvK1HhX+vn4e4k4YfjpUQMag8XO2AaRhARE09+/rqw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-data-science": { + "version": "2.0.13", + "resolved": "https://registry.npmjs.org/@cspell/dict-data-science/-/dict-data-science-2.0.13.tgz", + "integrity": "sha512-l1HMEhBJkPmw4I2YGVu2eBSKM89K9pVF+N6qIr5Uo5H3O979jVodtuwP8I7LyPrJnC6nz28oxeGRCLh9xC5CVA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-django": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@cspell/dict-django/-/dict-django-4.1.6.tgz", + "integrity": "sha512-SdbSFDGy9ulETqNz15oWv2+kpWLlk8DJYd573xhIkeRdcXOjskRuxjSZPKfW7O3NxN/KEf3gm3IevVOiNuFS+w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-docker": { + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/@cspell/dict-docker/-/dict-docker-1.1.17.tgz", + "integrity": "sha512-OcnVTIpHIYYKhztNTyK8ShAnXTfnqs43hVH6p0py0wlcwRIXe5uj4f12n7zPf2CeBI7JAlPjEsV0Rlf4hbz/xQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-dotnet": { + "version": "5.0.13", + "resolved": "https://registry.npmjs.org/@cspell/dict-dotnet/-/dict-dotnet-5.0.13.tgz", + "integrity": "sha512-xPp7jMnFpOri7tzmqmm/dXMolXz1t2bhNqxYkOyMqXhvs08oc7BFs+EsbDY0X7hqiISgeFZGNqn0dOCr+ncPYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-elixir": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@cspell/dict-elixir/-/dict-elixir-4.0.8.tgz", + "integrity": "sha512-CyfphrbMyl4Ms55Vzuj+mNmd693HjBFr9hvU+B2YbFEZprE5AG+EXLYTMRWrXbpds4AuZcvN3deM2XVB80BN/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-en_us": { + "version": "4.4.33", + "resolved": "https://registry.npmjs.org/@cspell/dict-en_us/-/dict-en_us-4.4.33.tgz", + "integrity": "sha512-zWftVqfUStDA37wO1ZNDN1qMJOfcxELa8ucHW8W8wBAZY3TK5Nb6deLogCK/IJi/Qljf30dwwuqqv84Qqle9Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-en-common-misspellings": { + "version": "2.1.12", + "resolved": "https://registry.npmjs.org/@cspell/dict-en-common-misspellings/-/dict-en-common-misspellings-2.1.12.tgz", + "integrity": "sha512-14Eu6QGqyksqOd4fYPuRb58lK1Va7FQK9XxFsRKnZU8LhL3N+kj7YKDW+7aIaAN/0WGEqslGP6lGbQzNti8Akw==", + "dev": true, + "license": "CC BY-SA 4.0" + }, + "node_modules/@cspell/dict-en-gb-mit": { + "version": "3.1.22", + "resolved": "https://registry.npmjs.org/@cspell/dict-en-gb-mit/-/dict-en-gb-mit-3.1.22.tgz", + "integrity": "sha512-xE5Vg6gGdMkZ1Ep6z9SJMMioGkkT1GbxS5Mm0U3Ey1/H68P0G7cJcyiVr1CARxFbLqKE4QUpoV1o6jz1Z5Yl9Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-filetypes": { + "version": "3.0.18", + "resolved": "https://registry.npmjs.org/@cspell/dict-filetypes/-/dict-filetypes-3.0.18.tgz", + "integrity": "sha512-yU7RKD/x1IWmDLzWeiItMwgV+6bUcU/af23uS0+uGiFUbsY1qWV/D4rxlAAO6Z7no3J2z8aZOkYIOvUrJq0Rcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-flutter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-flutter/-/dict-flutter-1.1.1.tgz", + "integrity": "sha512-UlOzRcH2tNbFhZmHJN48Za/2/MEdRHl2BMkCWZBYs+30b91mWvBfzaN4IJQU7dUZtowKayVIF9FzvLZtZokc5A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-fonts": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@cspell/dict-fonts/-/dict-fonts-4.0.6.tgz", + "integrity": "sha512-aR/0csY01dNb0A1tw/UmN9rKgHruUxsYsvXu6YlSBJFu60s26SKr/k1o4LavpHTQ+lznlYMqAvuxGkE4Flliqw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-fsharp": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-fsharp/-/dict-fsharp-1.1.1.tgz", + "integrity": "sha512-imhs0u87wEA4/cYjgzS0tAyaJpwG7vwtC8UyMFbwpmtw+/bgss+osNfyqhYRyS/ehVCWL17Ewx2UPkexjKyaBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-fullstack": { + "version": "3.2.9", + "resolved": "https://registry.npmjs.org/@cspell/dict-fullstack/-/dict-fullstack-3.2.9.tgz", + "integrity": "sha512-diZX+usW5aZ4/b2T0QM/H/Wl9aNMbdODa1Jq0ReBr/jazmNeWjd+PyqeVgzd1joEaHY+SAnjrf/i9CwKd2ZtWQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-gaming-terms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@cspell/dict-gaming-terms/-/dict-gaming-terms-1.1.2.tgz", + "integrity": "sha512-9XnOvaoTBscq0xuD6KTEIkk9hhdfBkkvJAIsvw3JMcnp1214OCGW8+kako5RqQ2vTZR3Tnf3pc57o7VgkM0q1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-git": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@cspell/dict-git/-/dict-git-3.1.0.tgz", + "integrity": "sha512-KEt9zGkxqGy2q1nwH4CbyqTSv5nadpn8BAlDnzlRcnL0Xb3LX9xTgSGShKvzb0bw35lHoYyLWN2ZKAqbC4pgGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-golang": { + "version": "6.0.26", + "resolved": "https://registry.npmjs.org/@cspell/dict-golang/-/dict-golang-6.0.26.tgz", + "integrity": "sha512-YKA7Xm5KeOd14v5SQ4ll6afe9VSy3a2DWM7L9uBq4u3lXToRBQ1W5PRa+/Q9udd+DTURyVVnQ+7b9cnOlNxaRg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-google": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@cspell/dict-google/-/dict-google-1.0.9.tgz", + "integrity": "sha512-biL65POqialY0i4g6crj7pR6JnBkbsPovB2WDYkj3H4TuC/QXv7Pu5pdPxeUJA6TSCHI7T5twsO4VSVyRxD9CA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-haskell": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@cspell/dict-haskell/-/dict-haskell-4.0.6.tgz", + "integrity": "sha512-ib8SA5qgftExpYNjWhpYIgvDsZ/0wvKKxSP+kuSkkak520iPvTJumEpIE+qPcmJQo4NzdKMN8nEfaeci4OcFAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-html": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@cspell/dict-html/-/dict-html-4.0.15.tgz", + "integrity": "sha512-GJYnYKoD9fmo2OI0aySEGZOjThnx3upSUvV7mmqUu8oG+mGgzqm82P/f7OqsuvTaInZZwZbo+PwJQd/yHcyFIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-html-symbol-entities": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@cspell/dict-html-symbol-entities/-/dict-html-symbol-entities-4.0.5.tgz", + "integrity": "sha512-429alTD4cE0FIwpMucvSN35Ld87HCyuM8mF731KU5Rm4Je2SG6hmVx7nkBsLyrmH3sQukTcr1GaiZsiEg8svPA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-java": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/@cspell/dict-java/-/dict-java-5.0.12.tgz", + "integrity": "sha512-qPSNhTcl7LGJ5Qp6VN71H8zqvRQK04S08T67knMq9hTA8U7G1sTKzLmBaDOFhq17vNX/+rT+rbRYp+B5Nwza1A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-julia": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-julia/-/dict-julia-1.1.1.tgz", + "integrity": "sha512-WylJR9TQ2cgwd5BWEOfdO3zvDB+L7kYFm0I9u0s9jKHWQ6yKmfKeMjU9oXxTBxIufhCXm92SKwwVNAC7gjv+yA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-k8s": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@cspell/dict-k8s/-/dict-k8s-1.0.12.tgz", + "integrity": "sha512-2LcllTWgaTfYC7DmkMPOn9GsBWsA4DZdlun4po8s2ysTP7CPEnZc1ZfK6pZ2eI4TsZemlUQQ+NZxMe9/QutQxg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-kotlin": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-kotlin/-/dict-kotlin-1.1.1.tgz", + "integrity": "sha512-J3NzzfgmxRvEeOe3qUXnSJQCd38i/dpF9/t3quuWh6gXM+krsAXP75dY1CzDmS8mrJAlBdVBeAW5eAZTD8g86Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-latex": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@cspell/dict-latex/-/dict-latex-5.1.0.tgz", + "integrity": "sha512-qxT4guhysyBt0gzoliXYEBYinkAdEtR2M7goRaUH0a7ltCsoqqAeEV8aXYRIdZGcV77gYSobvu3jJL038tlPAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-lorem-ipsum": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@cspell/dict-lorem-ipsum/-/dict-lorem-ipsum-4.0.5.tgz", + "integrity": "sha512-9a4TJYRcPWPBKkQAJ/whCu4uCAEgv/O2xAaZEI0n4y1/l18Yyx8pBKoIX5QuVXjjmKEkK7hi5SxyIsH7pFEK9Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-lua": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@cspell/dict-lua/-/dict-lua-4.0.8.tgz", + "integrity": "sha512-N4PkgNDMu9JVsRu7JBS/3E/dvfItRgk9w5ga2dKq+JupP2Y3lojNaAVFhXISh4Y0a6qXDn2clA6nvnavQ/jjLA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-makefile": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@cspell/dict-makefile/-/dict-makefile-1.0.5.tgz", + "integrity": "sha512-4vrVt7bGiK8Rx98tfRbYo42Xo2IstJkAF4tLLDMNQLkQ86msDlYSKG1ZCk8Abg+EdNcFAjNhXIiNO+w4KflGAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-markdown": { + "version": "2.0.16", + "resolved": "https://registry.npmjs.org/@cspell/dict-markdown/-/dict-markdown-2.0.16.tgz", + "integrity": "sha512-976RRqKv6cwhrxdFCQP2DdnBVB86BF57oQtPHy4Zbf4jF/i2Oy29MCrxirnOBalS1W6KQeto7NdfDXRAwkK4PQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@cspell/dict-css": "^4.1.1", + "@cspell/dict-html": "^4.0.15", + "@cspell/dict-html-symbol-entities": "^4.0.5", + "@cspell/dict-typescript": "^3.2.3" + } + }, + "node_modules/@cspell/dict-monkeyc": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@cspell/dict-monkeyc/-/dict-monkeyc-1.0.12.tgz", + "integrity": "sha512-MN7Vs11TdP5mbdNFQP5x2Ac8zOBm97ARg6zM5Sb53YQt/eMvXOMvrep7+/+8NJXs0jkp70bBzjqU4APcqBFNAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-node": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/@cspell/dict-node/-/dict-node-5.0.9.tgz", + "integrity": "sha512-hO+ga+uYZ/WA4OtiMEyKt5rDUlUyu3nXMf8KVEeqq2msYvAPdldKBGH7lGONg6R/rPhv53Rb+0Y1SLdoK1+7wQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-npm": { + "version": "5.2.38", + "resolved": "https://registry.npmjs.org/@cspell/dict-npm/-/dict-npm-5.2.38.tgz", + "integrity": "sha512-21ucGRPYYhr91C2cDBoMPTrcIOStQv33xOqJB0JLoC5LAs2Sfj9EoPGhGb+gIFVHz6Ia7JQWE2SJsOVFJD1wmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-php": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-php/-/dict-php-4.1.1.tgz", + "integrity": "sha512-EXelI+4AftmdIGtA8HL8kr4WlUE11OqCSVlnIgZekmTkEGSZdYnkFdiJ5IANSALtlQ1mghKjz+OFqVs6yowgWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-powershell": { + "version": "5.0.15", + "resolved": "https://registry.npmjs.org/@cspell/dict-powershell/-/dict-powershell-5.0.15.tgz", + "integrity": "sha512-l4S5PAcvCFcVDMJShrYD0X6Huv9dcsQPlsVsBGbH38wvuN7gS7+GxZFAjTNxDmTY1wrNi1cCatSg6Pu2BW4rgg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-public-licenses": { + "version": "2.0.16", + "resolved": "https://registry.npmjs.org/@cspell/dict-public-licenses/-/dict-public-licenses-2.0.16.tgz", + "integrity": "sha512-EQRrPvEOmwhwWezV+W7LjXbIBjiy6y/shrET6Qcpnk3XANTzfvWflf9PnJ5kId/oKWvihFy0za0AV1JHd03pSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-python": { + "version": "4.2.26", + "resolved": "https://registry.npmjs.org/@cspell/dict-python/-/dict-python-4.2.26.tgz", + "integrity": "sha512-hbjN6BjlSgZOG2dA2DtvYNGBM5Aq0i0dHaZjMOI9K/9vRicVvKbcCiBSSrR3b+jwjhQL5ff7HwG5xFaaci0GQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/dict-data-science": "^2.0.13" + } + }, + "node_modules/@cspell/dict-r": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-r/-/dict-r-2.1.1.tgz", + "integrity": "sha512-71Ka+yKfG4ZHEMEmDxc6+blFkeTTvgKbKAbwiwQAuKl3zpqs1Y0vUtwW2N4b3LgmSPhV3ODVY0y4m5ofqDuKMw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-ruby": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-ruby/-/dict-ruby-5.1.1.tgz", + "integrity": "sha512-LHrp84oEV6q1ZxPPyj4z+FdKyq1XAKYPtmGptrd+uwHbrF/Ns5+fy6gtSi7pS+uc0zk3JdO9w/tPK+8N1/7WUA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-rust": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@cspell/dict-rust/-/dict-rust-4.1.2.tgz", + "integrity": "sha512-O1FHrumYcO+HZti3dHfBPUdnDFkI+nbYK3pxYmiM1sr+G0ebOd6qchmswS0Wsc6ZdEVNiPYJY/gZQR6jfW3uOg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-scala": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/@cspell/dict-scala/-/dict-scala-5.0.9.tgz", + "integrity": "sha512-AjVcVAELgllybr1zk93CJ5wSUNu/Zb5kIubymR/GAYkMyBdYFCZ3Zbwn4Zz8GJlFFAbazABGOu0JPVbeY59vGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-shell": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@cspell/dict-shell/-/dict-shell-1.1.2.tgz", + "integrity": "sha512-WqOUvnwcHK1X61wAfwyXq04cn7KYyskg90j4lLg3sGGKMW9Sq13hs91pqrjC44Q+lQLgCobrTkMDw9Wyl9nRFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-software-terms": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@cspell/dict-software-terms/-/dict-software-terms-5.2.2.tgz", + "integrity": "sha512-0CaYd6TAsKtEoA7tNswm1iptEblTzEe3UG8beG2cpSTHk7afWIVMtJLgXDv0f/Li67Lf3Z1Jf3JeXR7GsJ2TRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-sql": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-sql/-/dict-sql-2.2.1.tgz", + "integrity": "sha512-qDHF8MpAYCf4pWU8NKbnVGzkoxMNrFqBHyG/dgrlic5EQiKANCLELYtGlX5auIMDLmTf1inA0eNtv74tyRJ/vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-svelte": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@cspell/dict-svelte/-/dict-svelte-1.0.7.tgz", + "integrity": "sha512-hGZsGqP0WdzKkdpeVLBivRuSNzOTvN036EBmpOwxH+FTY2DuUH7ecW+cSaMwOgmq5JFSdTcbTNFlNC8HN8lhaQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-swift": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@cspell/dict-swift/-/dict-swift-2.0.6.tgz", + "integrity": "sha512-PnpNbrIbex2aqU1kMgwEKvCzgbkHtj3dlFLPMqW1vSniop7YxaDTtvTUO4zA++ugYAEL+UK8vYrBwDPTjjvSnA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-terraform": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@cspell/dict-terraform/-/dict-terraform-1.1.3.tgz", + "integrity": "sha512-gr6wxCydwSFyyBKhBA2xkENXtVFToheqYYGFvlMZXWjviynXmh+NK/JTvTCk/VHk3+lzbO9EEQKee6VjrAUSbA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-typescript": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/@cspell/dict-typescript/-/dict-typescript-3.2.3.tgz", + "integrity": "sha512-zXh1wYsNljQZfWWdSPYwQhpwiuW0KPW1dSd8idjMRvSD0aSvWWHoWlrMsmZeRl4qM4QCEAjua8+cjflm41cQBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-vue": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@cspell/dict-vue/-/dict-vue-3.0.5.tgz", + "integrity": "sha512-Mqutb8jbM+kIcywuPQCCaK5qQHTdaByoEO2J9LKFy3sqAdiBogNkrplqUK0HyyRFgCfbJUgjz3N85iCMcWH0JA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-zig": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@cspell/dict-zig/-/dict-zig-1.0.0.tgz", + "integrity": "sha512-XibBIxBlVosU06+M6uHWkFeT0/pW5WajDRYdXG2CgHnq85b0TI/Ks0FuBJykmsgi2CAD3Qtx8UHFEtl/DSFnAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dynamic-import": { + "version": "9.8.0", + "resolved": "https://registry.npmjs.org/@cspell/dynamic-import/-/dynamic-import-9.8.0.tgz", + "integrity": "sha512-wMgb32lqG9g6lCipUQsY9Bk5idXPDz7wvzOqEsU1M2HmNYmdE1wfPoRpfQfsVL965iG3+6h8QLr2+8FKpweFEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/url": "9.8.0", + "import-meta-resolve": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@cspell/filetypes": { + "version": "9.8.0", + "resolved": "https://registry.npmjs.org/@cspell/filetypes/-/filetypes-9.8.0.tgz", + "integrity": "sha512-yHvtYn9qt6zykua77sNzTcf7HrG/dpo/+2pCMGSrfSrQypSNT6FUFvMS04W7kwhP86U1GkCjppNykXuoH3cqug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/@cspell/rpc": { + "version": "9.8.0", + "resolved": "https://registry.npmjs.org/@cspell/rpc/-/rpc-9.8.0.tgz", + "integrity": "sha512-t4lHEa254W+PePXNQ1noW7QhQxz/mhsJ9X8LEt0ILzBbPWCJzN+JuaM7EiolIPiwxtfxpMwKx9482kt4eTja7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18" + } + }, + "node_modules/@cspell/strong-weak-map": { + "version": "9.8.0", + "resolved": "https://registry.npmjs.org/@cspell/strong-weak-map/-/strong-weak-map-9.8.0.tgz", + "integrity": "sha512-HocksAqZ0JcWA5oWO7TIlOCftXVGkPGzbeFlCRRrjJpZmYQH+4NdeEXyQC6T89NGocp45td/CgyBcAaFMy1N9w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/@cspell/url": { + "version": "9.8.0", + "resolved": "https://registry.npmjs.org/@cspell/url/-/url-9.8.0.tgz", + "integrity": "sha512-LY1lFiZLTQF/ma1ilfKmRmFmEOw0RfYhyl0UMhY7/d93b+kiDMhxP/9Qir4+5LyiRncaE3++ZcWno9Hya+ssRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -1422,6 +2052,13 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/array-timsort": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/array-timsort/-/array-timsort-1.0.3.tgz", + "integrity": "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==", + "dev": true, + "license": "MIT" + }, "node_modules/astral-regex": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", @@ -1705,6 +2342,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/camelcase": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", @@ -1735,6 +2382,35 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chalk-template": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/chalk-template/-/chalk-template-1.1.2.tgz", + "integrity": "sha512-2bxTP2yUH7AJj/VAXfcA+4IcWGdQ87HwBANLt5XxGTeomo8yG0y95N1um9i5StvhT/Bl0/2cARA5v1PpPXUxUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.2.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/chalk/chalk-template?sponsor=1" + } + }, + "node_modules/chalk-template/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/chalk/node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -1825,6 +2501,23 @@ "license": "ISC", "optional": true }, + "node_modules/clear-module": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/clear-module/-/clear-module-4.1.2.tgz", + "integrity": "sha512-LWAxzHqdHsAZlPlEyJ2Poz6AIs384mPeqLVCru2p0BrP9G/kVGuhNyZYClLO6cXlnuJjzC8xtsJIuMjKqLXoAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^2.0.0", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/cli-cursor": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", @@ -1945,6 +2638,20 @@ "node": ">=18" } }, + "node_modules/comment-json": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/comment-json/-/comment-json-4.6.2.tgz", + "integrity": "sha512-R2rze/hDX30uul4NZoIZ76ImSJLFxn/1/ZxtKC1L77y2X1k+yYu1joKbAtMA2Fg3hZrTOiw0I5mwVMo0cf250w==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-timsort": "^1.0.3", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -1981,6 +2688,227 @@ "node": ">= 8" } }, + "node_modules/cspell": { + "version": "9.8.0", + "resolved": "https://registry.npmjs.org/cspell/-/cspell-9.8.0.tgz", + "integrity": "sha512-qL0VErMSn8BDxaPxcV+9uenffgjPS+5Jfz+m4rCsvYjzLwr7AaaJBWWSV2UiAe/4cturae8n8qzxiGnbbazkRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/cspell-json-reporter": "9.8.0", + "@cspell/cspell-performance-monitor": "9.8.0", + "@cspell/cspell-pipe": "9.8.0", + "@cspell/cspell-types": "9.8.0", + "@cspell/cspell-worker": "9.8.0", + "@cspell/dynamic-import": "9.8.0", + "@cspell/url": "9.8.0", + "ansi-regex": "^6.2.2", + "chalk": "^5.6.2", + "chalk-template": "^1.1.2", + "commander": "^14.0.3", + "cspell-config-lib": "9.8.0", + "cspell-dictionary": "9.8.0", + "cspell-gitignore": "9.8.0", + "cspell-glob": "9.8.0", + "cspell-io": "9.8.0", + "cspell-lib": "9.8.0", + "fast-json-stable-stringify": "^2.1.0", + "flatted": "^3.4.2", + "semver": "^7.7.4", + "tinyglobby": "^0.2.15" + }, + "bin": { + "cspell": "bin.mjs", + "cspell-esm": "bin.mjs" + }, + "engines": { + "node": ">=20.18" + }, + "funding": { + "url": "https://github.com/streetsidesoftware/cspell?sponsor=1" + } + }, + "node_modules/cspell-config-lib": { + "version": "9.8.0", + "resolved": "https://registry.npmjs.org/cspell-config-lib/-/cspell-config-lib-9.8.0.tgz", + "integrity": "sha512-gMJBAgYPvvO+uDFLUcGWaTu6/e+r8mm4GD4rQfWa/yV4F9fj+yOYLIMZqLWRvT1moHZX1FxyVvUbJcmZ1gfebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/cspell-types": "9.8.0", + "comment-json": "^4.6.2", + "smol-toml": "^1.6.1", + "yaml": "^2.8.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cspell-dictionary": { + "version": "9.8.0", + "resolved": "https://registry.npmjs.org/cspell-dictionary/-/cspell-dictionary-9.8.0.tgz", + "integrity": "sha512-QW4hdkWcrxZA1QNqi26U0S/U3/V+tKCm7JaaesEJW2F6Ao+23AbHVwidyAVtXaEhGkn6PxB+epKrrAa6nE69qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/cspell-performance-monitor": "9.8.0", + "@cspell/cspell-pipe": "9.8.0", + "@cspell/cspell-types": "9.8.0", + "cspell-trie-lib": "9.8.0", + "fast-equals": "^6.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cspell-gitignore": { + "version": "9.8.0", + "resolved": "https://registry.npmjs.org/cspell-gitignore/-/cspell-gitignore-9.8.0.tgz", + "integrity": "sha512-SDUa1DmSfT20+JH7XtyzcEL9KfurneoR/XbmlrtPQZP/LUHXh3yz4x/0vFIkEFXNWdSckY0QdWTz8DaxClCf4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/url": "9.8.0", + "cspell-glob": "9.8.0", + "cspell-io": "9.8.0" + }, + "bin": { + "cspell-gitignore": "bin.mjs" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cspell-glob": { + "version": "9.8.0", + "resolved": "https://registry.npmjs.org/cspell-glob/-/cspell-glob-9.8.0.tgz", + "integrity": "sha512-Uvj/iHXs+jpsJyIEnhEoJTWXb1GVyZ9T05L5JFtZfsQNXrh8SRDQPscjxbg4okKr63N7WevfioQum/snHNYvmw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/url": "9.8.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cspell-glob/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/cspell-grammar": { + "version": "9.8.0", + "resolved": "https://registry.npmjs.org/cspell-grammar/-/cspell-grammar-9.8.0.tgz", + "integrity": "sha512-01XMq2vhPS0Gvxnfed9uvOwH+3cXddHYxW0PwCE+SZdcC6TN8yM6glByuLt1qFustAmQVE5GSr7uAY9o4pZQRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/cspell-pipe": "9.8.0", + "@cspell/cspell-types": "9.8.0" + }, + "bin": { + "cspell-grammar": "bin.mjs" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cspell-io": { + "version": "9.8.0", + "resolved": "https://registry.npmjs.org/cspell-io/-/cspell-io-9.8.0.tgz", + "integrity": "sha512-JINaEWQEzR4f2upwdZOFcft+nBvQgizJfrOLszxG3p+BIzljnGklqE/nUtLFZpBu0oMJvuM/Fd+GsWor0yP7Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/cspell-service-bus": "9.8.0", + "@cspell/url": "9.8.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cspell-lib": { + "version": "9.8.0", + "resolved": "https://registry.npmjs.org/cspell-lib/-/cspell-lib-9.8.0.tgz", + "integrity": "sha512-G2TtPcye5QE5ev3YgWq42UOJLpTZ6naO/47oIm+jmeSYbgnbcOSThnEE7uMycx+TTNOz/vJVFpZmQyt0bWCftw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/cspell-bundled-dicts": "9.8.0", + "@cspell/cspell-performance-monitor": "9.8.0", + "@cspell/cspell-pipe": "9.8.0", + "@cspell/cspell-resolver": "9.8.0", + "@cspell/cspell-types": "9.8.0", + "@cspell/dynamic-import": "9.8.0", + "@cspell/filetypes": "9.8.0", + "@cspell/rpc": "9.8.0", + "@cspell/strong-weak-map": "9.8.0", + "@cspell/url": "9.8.0", + "clear-module": "^4.1.2", + "cspell-config-lib": "9.8.0", + "cspell-dictionary": "9.8.0", + "cspell-glob": "9.8.0", + "cspell-grammar": "9.8.0", + "cspell-io": "9.8.0", + "cspell-trie-lib": "9.8.0", + "env-paths": "^4.0.0", + "gensequence": "^8.0.8", + "import-fresh": "^3.3.1", + "resolve-from": "^5.0.0", + "vscode-languageserver-textdocument": "^1.0.12", + "vscode-uri": "^3.1.0", + "xdg-basedir": "^5.1.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cspell-trie-lib": { + "version": "9.8.0", + "resolved": "https://registry.npmjs.org/cspell-trie-lib/-/cspell-trie-lib-9.8.0.tgz", + "integrity": "sha512-GXIyqxya8QLp6SjKsAN9w3apvt1Ww7GKcZvTBaP76OfLoyb1QC6unwmObY2cZs1manCntGwHrgU6vFNuXnTzpw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@cspell/cspell-types": "9.8.0" + } + }, + "node_modules/cspell/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cspell/node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/css-select": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", @@ -2311,6 +3239,22 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/env-paths": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-4.0.0.tgz", + "integrity": "sha512-pxP8eL2SwwaTRi/KHYwLYXinDs7gL3jxFcBYmEdYfZmZXbaVDvdppd0XBU8qVz03rDfKZMXg1omHCbsJjZrMsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-safe-filename": "^0.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/environment": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", @@ -2578,6 +3522,20 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/esquery": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", @@ -2642,6 +3600,16 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-equals": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-6.0.0.tgz", + "integrity": "sha512-PFhhIGgdM79r5Uztdj9Zb6Tt1zKafqVfdMGwVca1z5z6fbX7DmsySSuJd8HiP6I1j505DCS83cLxo5rmSNeVEA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -2778,9 +3746,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -2866,6 +3834,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gensequence": { + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/gensequence/-/gensequence-8.0.8.tgz", + "integrity": "sha512-omMVniXEXpdx/vKxGnPRoO2394Otlze28TyxECbFVyoSpZ9H3EO7lemjcB12OpQJzRW4e5tt/dL1rOxry6aMHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -3006,6 +3984,32 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/global-directory": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-5.0.0.tgz", + "integrity": "sha512-1pgFdhK3J2LeM+dVf2Pd424yHx2ou338lC0ErNP2hPx4j8eW1Sp0XqSjNxtk6Tc4Kr5wlWtSvz8cn2yb7/SG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "6.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/global-directory/node_modules/ini": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz", + "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/globby": { "version": "14.1.0", "resolved": "https://registry.npmjs.org/globby/-/globby-14.1.0.tgz", @@ -3252,6 +4256,57 @@ "dev": true, "license": "MIT" }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-fresh/node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/import-fresh/node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -3414,6 +4469,19 @@ "node": ">=8" } }, + "node_modules/is-safe-filename": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-safe-filename/-/is-safe-filename-0.1.1.tgz", + "integrity": "sha512-4SrR7AdnY11LHfDKTZY1u6Ga3RuxZdl3YKWWShO5iyuG5h8QS4GD2tOb04peBJ5I7pXbR+CGBNEhTcwK+FzN3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-unicode-supported": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", @@ -4465,6 +5533,19 @@ "dev": true, "license": "(MIT AND Zlib)" }, + "node_modules/parent-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-2.0.0.tgz", + "integrity": "sha512-uo0Z9JJeWzv8BG+tRcapBKNJ0dro9cLyczGzulS6EfeyAdeC9sbojtW6XwvYxJkEne9En+J2XEl4zyglVeIwFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/parse-json": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", @@ -4936,6 +6017,16 @@ "node": ">=0.10.0" } }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/restore-cursor": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", @@ -5283,6 +6374,19 @@ "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, + "node_modules/smol-toml": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz", + "integrity": "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, "node_modules/spdx-correct": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", @@ -6202,6 +7306,20 @@ "url": "https://bevry.me/fund" } }, + "node_modules/vscode-languageserver-textdocument": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", + "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==", + "dev": true, + "license": "MIT" + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, "node_modules/whatwg-encoding": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", @@ -6324,6 +7442,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/xdg-basedir": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-5.1.0.tgz", + "integrity": "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/xml2js": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", @@ -6365,6 +7496,22 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", diff --git a/package.json b/package.json index 893978e..08615f9 100644 --- a/package.json +++ b/package.json @@ -399,6 +399,7 @@ "@vscode/test-electron": "^2.5.2", "@vscode/vsce": "^3.7.1", "c8": "^11.0.0", + "cspell": "^9.8.0", "eslint": "^10.1.0", "glob": "^13.0.6", "mocha": "^11.7.5", From 09a380ca206ec918610ed14c27f2a9c86b253adb Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Mon, 6 Apr 2026 09:44:25 +1000 Subject: [PATCH 09/25] coverage calc fixes --- .vscode-test.mjs | 15 ++++++++++----- tools/check-coverage.mjs | 5 ++++- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/.vscode-test.mjs b/.vscode-test.mjs index 3f5a955..e3a589c 100644 --- a/.vscode-test.mjs +++ b/.vscode-test.mjs @@ -19,6 +19,7 @@ export default defineConfig({ version: 'stable', workspaceFolder: testWorkspace, extensionDevelopmentPath: './', + srcDir: __dirname, mocha: { ui: 'tdd', timeout: 60000, @@ -31,12 +32,16 @@ export default defineConfig({ ] }], coverage: { - include: ['out/**/*.js'], + includeAll: true, + // @vscode/test-cli sets report.exclude.relativePath = false, which + // makes test-exclude match against absolute paths. Patterns must + // start with **/ so minimatch can match any prefix. + include: ['**/out/**/*.js'], exclude: [ - 'out/test/**/*.js', - 'out/semantic/summariser.js', // requires Copilot auth, not available in CI - 'out/semantic/summaryPipeline.js', // requires Copilot auth, not available in CI - 'out/semantic/vscodeAdapters.js', // requires Copilot auth, not available in CI + '**/out/test/**', + '**/out/semantic/summariser.js', // requires Copilot auth, not available in CI + '**/out/semantic/summaryPipeline.js', // requires Copilot auth, not available in CI + '**/out/semantic/vscodeAdapters.js', // requires Copilot auth, not available in CI ], reporter: ['text', 'lcov', 'html', 'json-summary'], output: './coverage' diff --git a/tools/check-coverage.mjs b/tools/check-coverage.mjs index a6f922e..5d78b7e 100644 --- a/tools/check-coverage.mjs +++ b/tools/check-coverage.mjs @@ -20,7 +20,10 @@ let failed = false; for (const metric of METRICS) { const pct = total[metric].pct; - if (pct < THRESHOLD) { + if (typeof pct !== 'number' || Number.isNaN(pct)) { + console.error(`FAIL: ${metric} coverage is ${pct} — not a valid number. Coverage calculation is broken.`); + failed = true; + } else if (pct < THRESHOLD) { console.error(`FAIL: ${metric} ${pct}% < ${THRESHOLD}%`); failed = true; } else { From d6a4aeba179bba628cf72457d1e658809546c67f Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Mon, 6 Apr 2026 09:55:54 +1000 Subject: [PATCH 10/25] better coverage check --- coverage-thresholds.json | 6 ++++++ tools/check-coverage.mjs | 35 ++++++++++++++++++++++++++++++----- 2 files changed, 36 insertions(+), 5 deletions(-) create mode 100644 coverage-thresholds.json diff --git a/coverage-thresholds.json b/coverage-thresholds.json new file mode 100644 index 0000000..42f2514 --- /dev/null +++ b/coverage-thresholds.json @@ -0,0 +1,6 @@ +{ + "lines": 82.38, + "functions": 82.41, + "branches": 69.17, + "statements": 82.38 +} diff --git a/tools/check-coverage.mjs b/tools/check-coverage.mjs index 5d78b7e..2236428 100644 --- a/tools/check-coverage.mjs +++ b/tools/check-coverage.mjs @@ -1,8 +1,8 @@ -import { readFileSync, existsSync, readdirSync } from 'fs'; +import { readFileSync, existsSync, readdirSync, writeFileSync } from 'fs'; -const THRESHOLD = 90; const METRICS = ['lines', 'functions', 'branches', 'statements']; const SUMMARY_PATH = './coverage/coverage-summary.json'; +const THRESHOLDS_PATH = './coverage-thresholds.json'; if (!existsSync(SUMMARY_PATH)) { console.error(`ERROR: ${SUMMARY_PATH} not found.`); @@ -14,23 +14,48 @@ if (!existsSync(SUMMARY_PATH)) { process.exit(1); } +if (!existsSync(THRESHOLDS_PATH)) { + console.error(`ERROR: ${THRESHOLDS_PATH} not found.`); + process.exit(1); +} + const summary = JSON.parse(readFileSync(SUMMARY_PATH, 'utf8')); +const thresholds = JSON.parse(readFileSync(THRESHOLDS_PATH, 'utf8')); const total = summary.total; let failed = false; +let bumped = false; for (const metric of METRICS) { const pct = total[metric].pct; + const threshold = thresholds[metric]; + + if (typeof threshold !== 'number' || Number.isNaN(threshold)) { + console.error(`FAIL: ${metric} threshold missing or invalid in ${THRESHOLDS_PATH}`); + failed = true; + continue; + } + if (typeof pct !== 'number' || Number.isNaN(pct)) { console.error(`FAIL: ${metric} coverage is ${pct} — not a valid number. Coverage calculation is broken.`); failed = true; - } else if (pct < THRESHOLD) { - console.error(`FAIL: ${metric} ${pct}% < ${THRESHOLD}%`); + } else if (pct < threshold) { + const diff = (threshold - pct).toFixed(2); + console.error(`FAIL: ${metric} ${pct}% < ${threshold}% (short by ${diff}%)`); failed = true; + } else if (pct > threshold) { + console.log(`BUMP: ${metric} ${threshold}% -> ${pct}%`); + thresholds[metric] = pct; + bumped = true; } else { - console.log(`OK: ${metric} ${pct}% >= ${THRESHOLD}%`); + console.log(`OK: ${metric} ${pct}% == ${threshold}%`); } } +if (bumped && !failed) { + writeFileSync(THRESHOLDS_PATH, JSON.stringify(thresholds, null, 2) + '\n'); + console.log(`Updated ${THRESHOLDS_PATH}`); +} + if (failed) { process.exit(1); } From 79fe3210568b583f6ee33cb09e1a126a6d3a02af Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Mon, 6 Apr 2026 10:05:18 +1000 Subject: [PATCH 11/25] Doco fixes --- Agents.md | 213 +------------------- Claude.md | 14 +- README.md | 13 +- docs/execution.md | 2 +- docs/extension.md | 11 +- website/eleventy.config.js | 6 +- website/src/_data/site.json | 2 +- website/src/blog/introducing-commandtree.md | 3 +- website/src/docs/discovery.md | 12 +- website/src/docs/index.md | 4 +- website/src/index.njk | 11 +- website/tests/homepage.spec.ts | 5 +- 12 files changed, 57 insertions(+), 239 deletions(-) diff --git a/Agents.md b/Agents.md index e07e22a..5cfb79a 100644 --- a/Agents.md +++ b/Agents.md @@ -1,212 +1,3 @@ -# CLAUDE.md - CommandTree Extension +# Agents -## Too Many Cooks - -You are working with many other agents. Make sure there is effective cooperation -- Register on TMC immediately -- Don't edit files that are locked; lock files when editing -- COMMUNICATE REGULARLY AND COORDINATE WITH OTHERS THROUGH MESSAGES - -## Coding Rules - -- **Zero duplication - TOP PRIORITY** - Always search for existing code before adding. Move; don't copy files. Add assertions to tests rather than duplicating tests. AIM FOR LESS CODE! -- **No string literals** - Named constants only, and it ONE location -- DO NOT USE GIT -- **Functional style** - Prefer pure functions, avoid classes where possible -- **No suppressing warnings** - Fix them properly -- **No REGEX** It is absolutely ⛔️ illegal, and no text matching in general -- **Don't run long runnings tasks** like docker builds, tests. Ask the user to do it!! -- **Expressions over assignments** - Prefer const and immutable patterns -- **Named parameters** - Use object params for functions with 3+ args -- **Keep files under 450 LOC and functions under 20 LOC** -- **No commented-out code** - Delete it -- **No placeholders** - If incomplete, leave LOUD compilation error with TODO - -### Typescript -- **TypeScript strict mode** - No `any`, no implicit types, turn all lints up to error -- **Regularly run the linter** - Fix lint errors IMMEDIATELY -- **Decouple providers from the VSCODE SDK** - No vscode sdk use within the providers -- **Ignoring lints = ⛔️ illegal** - Fix violations immediately -- **No throwing** - Only return `Result` - -### CSS -- **Minimize duplication** - fewer classes is better -- **Don't include section in class name** - name them after what they are - not the section they sit in - -## Testing - -⚠️ NEVER KILL VSCODE PROCESSES - -#### Rules -- **Prefer e2e tests over unit tests** - only unit tests for isolating bugs -- Separate e2e tests from unit tests by file. They should not be in the same file together. -- Prefer adding assertions to existing tests rather than adding new tests -- Test files in `src/test/suite/*.test.ts` -- Run tests: `npm test` -- NEVER remove assertions -- FAILING TEST = ✅ OK. TEST THAT DOESN'T ENFORCE BEHAVIOR = ⛔️ ILLEGAL -- Unit test = No VSCODE instance needed = isolation only test - -### Automated (E2E) Testing - -**AUTOMATED TESTING IS BLACK BOX TESTING ONLY** -Only test the UI **THROUGH the UI**. Do not run command etc. to coerce the state. You are testing the UI, not the code. - -- Tests run in actual VS Code window via `@vscode/test-electron` -- Automated tests must not modify internal state or call functions that do. They must only use the extension through the UI. - * - ❌ Calling internal methods like provider.updateTasks() - * - ❌ Calling provider.refresh() directly - * - ❌ Manipulating internal state directly - * - ❌ Using any method not exposed via VS Code commands - * - ❌ Using commands that should just happen as part of normal use. e.g.: `await vscode.commands.executeCommand('commandtree.refresh');` - * - ❌ `executeCommand('commandtree.addToQuick', item)` - TAP the item via the DOM!!! - -### Test First Process -- Write test that fails because of bug/missing feature -- Run tests to verify that test fails because of this reason -- Adjust test and repeat until you see failure for the reason above -- Add missing feature or fix bug -- Run tests to verify test passes. -- Repeat and fix until test passes WITHOUT changing the test - -**Every test MUST:** -1. Assert on the ACTUAL OBSERVABLE BEHAVIOR (UI state, view contents, return values) -2. Fail if the feature is broken -3. Test the full flow, not just side effects like config files - -### ⛔️ FAKE TESTS ARE ILLEGAL - -**A "fake test" is any test that passes without actually verifying behavior. These are STRICTLY FORBIDDEN:** - -```typescript -// ❌ ILLEGAL - asserts true unconditionally -assert.ok(true, 'Should work'); - -// ❌ ILLEGAL - no assertion on actual behavior -try { await doSomething(); } catch { } -assert.ok(true, 'Did not crash'); - -// ❌ ILLEGAL - only checks config file, not actual UI/view behavior -writeConfig({ quick: ['task1'] }); -const config = readConfig(); -assert.ok(config.quick.includes('task1')); // This doesn't test the FEATURE - -// ❌ ILLEGAL - empty catch with success assertion -try { await command(); } catch { /* swallow */ } -assert.ok(true, 'Command ran'); -``` - -## Critical Docs - -### Vscode SDK -[VSCode Extension API](https://code.visualstudio.com/api/) -[VSCode Extension Testing API](https://code.visualstudio.com/api/extension-guides/testing) -[VSCODE Language Model API](https://code.visualstudio.com/api/extension-guides/ai/language-model) -[Language Model Tool API](https://code.visualstudio.com/api/extension-guides/ai/tools) -[AI extensibility in VS Cod](https://code.visualstudio.com/api/extension-guides/ai/ai-extensibility-overview) -[AI language models in VS Code](https://code.visualstudio.com/docs/copilot/customization/language-models) - -### Website - -https://developers.google.com/search/blog/2025/05/succeeding-in-ai-search -https://developers.google.com/search/docs/fundamentals/seo-starter-guide - -https://studiohawk.com.au/blog/how-to-optimise-ai-overviews/ -https://about.ads.microsoft.com/en/blog/post/october-2025/optimizing-your-content-for-inclusion-in-ai-search-answers - -## Project Structure - -``` -CommandTree/ -├── src/ -│ ├── extension.ts # Entry point, command registration -│ ├── CommandTreeProvider.ts # TreeDataProvider implementation -│ ├── config/ -│ │ └── TagConfig.ts # Tag configuration from commandtree.json -│ ├── discovery/ -│ │ ├── index.ts # Discovery orchestration -│ │ ├── shell.ts # Shell scripts (.sh, .bash, .zsh) -│ │ ├── npm.ts # NPM scripts (package.json) -│ │ ├── make.ts # Makefile targets -│ │ ├── launch.ts # VS Code launch configs -│ │ ├── tasks.ts # VS Code tasks -│ │ ├── python.ts # Python scripts (.py) -│ │ ├── powershell.ts # PowerShell scripts (.ps1) -│ │ ├── gradle.ts # Gradle tasks -│ │ ├── cargo.ts # Cargo (Rust) tasks -│ │ ├── maven.ts # Maven goals (pom.xml) -│ │ ├── ant.ts # Ant targets (build.xml) -│ │ ├── just.ts # Just recipes (justfile) -│ │ ├── taskfile.ts # Taskfile tasks (Taskfile.yml) -│ │ ├── deno.ts # Deno tasks (deno.json) -│ │ ├── rake.ts # Rake tasks (Rakefile) -│ │ ├── composer.ts # Composer scripts (composer.json) -│ │ ├── docker.ts # Docker Compose services -│ │ ├── dotnet.ts # .NET projects (.csproj) -│ │ └── markdown.ts # Markdown files (.md) -│ ├── models/ -│ │ └── TaskItem.ts # Task data model and TreeItem -│ ├── runners/ -│ │ └── TaskRunner.ts # Task execution logic -│ └── test/ -│ └── suite/ # E2E test files -├── test-fixtures/ # Test workspace files -├── package.json # Extension manifest -├── tsconfig.json # TypeScript config -└── .vscode-test.mjs # Test runner config -``` - -## Commands - -| Command ID | Description | -|------------|-------------| -| `commandtree.refresh` | Reload all tasks | -| `commandtree.run` | Run task in new terminal | -| `commandtree.runInCurrentTerminal` | Run in active terminal | -| `commandtree.debug` | Launch with debugger | -| `commandtree.filter` | Text filter input | -| `commandtree.filterByTag` | Tag filter picker | -| `commandtree.clearFilter` | Clear all filters | -| `commandtree.editTags` | Open commandtree.json | - -## Build Commands - -See [text](package.json) - -## Adding New Task Types - -1. Create discovery module in `src/discovery/` -2. Export discovery function: `discoverXxxTasks(root: string, excludes: string[]): Promise` -3. Add to `discoverAllTasks()` in `src/discovery/index.ts` -4. Add category in `CommandTreeProvider.buildRootCategories()` -5. Handle execution in `TaskRunner.run()` -6. Add E2E tests in `src/test/suite/discovery.test.ts` - -## VS Code API Patterns - -```typescript -// Register command -context.subscriptions.push( - vscode.commands.registerCommand('commandtree.xxx', handler) -); - -// File watcher -const watcher = vscode.workspace.createFileSystemWatcher('**/pattern'); -watcher.onDidChange(() => refresh()); -context.subscriptions.push(watcher); - -// Tree view -const treeView = vscode.window.createTreeView('commandtree', { - treeDataProvider: provider, - showCollapseAll: true -}); - -// Context for when clauses -vscode.commands.executeCommand('setContext', 'commandtree.hasFilter', true); -``` - -## Configuration - -Settings defined in `package.json` under `contributes.configuration`: -- `commandtree.excludePatterns` - Glob patterns to exclude -- `commandtree.sortOrder` - Task sort order (folder/name/type) +See [CLAUDE.md](CLAUDE.md) for all project instructions, coding rules, testing guidelines, and command reference. diff --git a/Claude.md b/Claude.md index 734bdb1..a2970cf 100644 --- a/Claude.md +++ b/Claude.md @@ -149,6 +149,9 @@ CommandTree/ │ │ ├── composer.ts # Composer scripts (composer.json) │ │ ├── docker.ts # Docker Compose services │ │ ├── dotnet.ts # .NET projects (.csproj) +│ │ ├── csharp-script.ts # C# scripts (.csx) +│ │ ├── fsharp-script.ts # F# scripts (.fsx) +│ │ ├── mise.ts # Mise tasks │ │ └── markdown.ts # Markdown files (.md) │ ├── models/ │ │ └── TaskItem.ts # Task data model and TreeItem @@ -169,11 +172,16 @@ CommandTree/ | `commandtree.refresh` | Reload all tasks | | `commandtree.run` | Run task in new terminal | | `commandtree.runInCurrentTerminal` | Run in active terminal | -| `commandtree.debug` | Launch with debugger | -| `commandtree.filter` | Text filter input | | `commandtree.filterByTag` | Tag filter picker | | `commandtree.clearFilter` | Clear all filters | -| `commandtree.editTags` | Open commandtree.json | +| `commandtree.addTag` | Add tag to command | +| `commandtree.removeTag` | Remove tag from command | +| `commandtree.addToQuick` | Add to quick launch | +| `commandtree.removeFromQuick` | Remove from quick launch | +| `commandtree.refreshQuick` | Refresh quick launch view | +| `commandtree.generateSummaries` | Generate AI summaries | +| `commandtree.selectModel` | Select AI model | +| `commandtree.openPreview` | Open markdown preview | ## Build Commands diff --git a/README.md b/README.md index cf0f59c..8e598aa 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ CommandTree in action

-CommandTree scans your project and surfaces all runnable commands across 21 tool types in a single tree view. Filter by text or tag, and run in terminal or debugger. +CommandTree scans your project and surfaces all runnable commands across 22 tool types in a single tree view. Filter by tag, and run in terminal or debugger. ## AI Summaries (powered by GitHub Copilot) @@ -19,10 +19,10 @@ Summaries are stored locally and only regenerate when the underlying script chan ## Features - **AI Summaries** - GitHub Copilot describes each command in plain language, with security warnings for dangerous operations -- **Auto-discovery** - 21 command types including shell scripts, npm, Make, Python, PowerShell, Gradle, Cargo, Maven, Docker Compose, .NET, C# Script, F# Script, and more +- **Auto-discovery** - 22 command types including shell scripts, npm, Make, Python, PowerShell, Gradle, Cargo, Maven, Docker Compose, .NET, C# Script, F# Script, Mise, and more - **Quick Launch** - Pin frequently-used commands to a dedicated panel at the top - **Tagging** - Right-click any command to add or remove tags -- **Filtering** - Filter the tree by text search or by tag +- **Filtering** - Filter the tree by tag - **Run anywhere** - Execute in a new terminal, the current terminal, or launch with the debugger - **Folder grouping** - Commands grouped by directory with collapsible nested hierarchy - **Parameterized commands** - Prompt for arguments before execution @@ -44,14 +44,15 @@ Summaries are stored locally and only regenerate when the underlying script chan | Maven Goals | `pom.xml` | | Ant Targets | `build.xml` | | Just Recipes | `justfile` | -| Taskfile Tasks | `Taskfile.yml` | +| Taskfile Tasks | `Taskfile.yml`, `Taskfile.yaml` | | Deno Tasks | `deno.json`, `deno.jsonc` | | Rake Tasks | `Rakefile` (Ruby) | | Composer Scripts | `composer.json` (PHP) | -| Docker Compose | `docker-compose.yml` | +| Docker Compose | `docker-compose.yml`, `compose.yml` | | .NET Projects | `.csproj`, `.fsproj` | | C# Scripts | `.csx` files | | F# Scripts | `.fsx` files | +| Mise Tasks | `.mise.toml`, `mise.toml`, `.mise/*.toml` | | Markdown Files | `.md` files | ## Getting Started @@ -72,7 +73,7 @@ Open a workspace and the CommandTree panel appears in the sidebar. All discovere - **Run in current terminal** - Right-click > "Run in Current Terminal" - **Debug** - Launch configurations run with the VS Code debugger - **Star a command** - Click the star icon to pin it to Quick Launch -- **Filter** - Use the toolbar icons to filter by text or tag +- **Filter** - Use the toolbar icons to filter by tag - **Tag commands** - Right-click > "Add Tag" to group related commands ## Settings diff --git a/docs/execution.md b/docs/execution.md index 040c28a..44cb87c 100644 --- a/docs/execution.md +++ b/docs/execution.md @@ -29,7 +29,7 @@ Sends the command to the currently active terminal. Triggered by the circle-play **SPEC-EXEC-030** -Launches the command using the VS Code debugger. Triggered by the bug button or `commandtree.debug` command. +Launch configurations from `.vscode/launch.json` are launched with the VS Code debugger automatically when you run them. **Debugging Strategy**: CommandTree leverages VS Code's native debugging capabilities through launch configurations rather than implementing custom debug logic for each language. diff --git a/docs/extension.md b/docs/extension.md index 93e003f..7ba4c90 100644 --- a/docs/extension.md +++ b/docs/extension.md @@ -24,11 +24,16 @@ All commands are registered with the `commandtree.` prefix: | `commandtree.refresh` | Reload all tasks | | `commandtree.run` | Run task in new terminal | | `commandtree.runInCurrentTerminal` | Run in active terminal | -| `commandtree.debug` | Launch with debugger | -| `commandtree.filter` | Text filter input | | `commandtree.filterByTag` | Tag filter picker | | `commandtree.clearFilter` | Clear all filters | -| `commandtree.editTags` | Open commandtree.json | +| `commandtree.addTag` | Add tag to command | +| `commandtree.removeTag` | Remove tag from command | +| `commandtree.addToQuick` | Add to quick launch | +| `commandtree.removeFromQuick` | Remove from quick launch | +| `commandtree.refreshQuick` | Refresh quick launch view | +| `commandtree.generateSummaries` | Generate AI summaries | +| `commandtree.selectModel` | Select AI model | +| `commandtree.openPreview` | Open markdown preview | | `commandtree.addTag` | Add tag to command | | `commandtree.removeTag` | Remove tag from command | | `commandtree.addToQuick` | Add to quick launch | diff --git a/website/eleventy.config.js b/website/eleventy.config.js index ed69b9b..3c9d04e 100644 --- a/website/eleventy.config.js +++ b/website/eleventy.config.js @@ -120,7 +120,7 @@ export default function(eleventyConfig) { } const apiLine = "- API Reference: https://commandtree.dev/api/"; const extras = [ - "- GitHub: https://github.com/melbournedeveloper/CommandTree", + "- GitHub: https://github.com/MelbourneDeveloper/CommandTree", "- VS Code Marketplace: https://marketplace.visualstudio.com/items?itemName=nimblesite.commandtree", ].join("\n"); return content.replace(apiLine, extras); @@ -146,7 +146,7 @@ export default function(eleventyConfig) { if (!this.page.outputPath?.endsWith(".html")) { return content; } - const altText = "CommandTree - One sidebar, every command in VS Code. Auto-discover 21 command types with AI-powered summaries."; + const altText = "CommandTree - One sidebar, every command in VS Code. Auto-discover 22 command types with AI-powered summaries."; const ogImageAltTag = ` `; const twitterImageAltTag = ` `; const ogImageHeightTag = 'og:image:height'; @@ -260,7 +260,7 @@ export default function(eleventyConfig) { "name": "CommandTree", "applicationCategory": "DeveloperApplication", "operatingSystem": "Windows, macOS, Linux", - "description": "VS Code extension that auto-discovers 21 command types — shell scripts, npm, Make, Gradle, Cargo, Docker Compose, .NET, and more — in one sidebar with AI-powered summaries.", + "description": "VS Code extension that auto-discovers 22 command types — shell scripts, npm, Make, Gradle, Cargo, Docker Compose, .NET, and more — in one sidebar with AI-powered summaries.", "url": "https://commandtree.dev", "downloadUrl": "https://marketplace.visualstudio.com/items?itemName=nimblesite.commandtree", "softwareRequirements": "Visual Studio Code", diff --git a/website/src/_data/site.json b/website/src/_data/site.json index 0325fdc..6c1b81d 100644 --- a/website/src/_data/site.json +++ b/website/src/_data/site.json @@ -7,7 +7,7 @@ "keywords": "VS Code extension, command runner, task runner, script discovery, npm scripts, shell scripts, makefile, workspace automation, developer tools", "themeColor": "#2a8c7a", "ogImage": "/assets/images/og-image.png", - "ogImageAlt": "CommandTree - One sidebar, every command in VS Code. Auto-discover 21 command types with AI-powered summaries.", + "ogImageAlt": "CommandTree - One sidebar, every command in VS Code. Auto-discover 22 command types with AI-powered summaries.", "ogImageWidth": "1200", "ogImageHeight": "630", "organization": { diff --git a/website/src/blog/introducing-commandtree.md b/website/src/blog/introducing-commandtree.md index 378af37..e4fa56c 100644 --- a/website/src/blog/introducing-commandtree.md +++ b/website/src/blog/introducing-commandtree.md @@ -36,9 +36,10 @@ Install CommandTree and a new panel appears in your VS Code sidebar. Every runna - Taskfile, Deno, Rake, and Composer - Docker Compose services and .NET projects - C# scripts and F# scripts +- Mise tasks - Markdown files -That is 21 command types discovered automatically. Click the play button. Done. +That is 22 command types discovered automatically. Click the play button. Done. ## AI-Powered Summaries diff --git a/website/src/docs/discovery.md b/website/src/docs/discovery.md index 771ad7f..cd4b879 100644 --- a/website/src/docs/discovery.md +++ b/website/src/docs/discovery.md @@ -1,7 +1,7 @@ --- layout: layouts/docs.njk -title: Auto-Discovery of 21 Command Types - CommandTree Docs -description: How CommandTree auto-discovers shell scripts, npm, Make, Gradle, Cargo, Maven, Docker Compose, .NET, C# Script, F# Script, and 21 command types in your VS Code workspace. +title: Auto-Discovery of 22 Command Types - CommandTree Docs +description: How CommandTree auto-discovers shell scripts, npm, Make, Gradle, Cargo, Maven, Docker Compose, .NET, C# Script, F# Script, and 22 command types in your VS Code workspace. eleventyNavigation: key: Command Discovery order: 2 @@ -9,7 +9,7 @@ eleventyNavigation: # Command Discovery -CommandTree auto-discovers 21 command types — including shell scripts, npm scripts, Makefiles, Gradle, Cargo, Maven, Docker Compose, .NET projects, C# scripts, and F# scripts — by recursively scanning your workspace. Discovery respects [exclude patterns](/docs/configuration/) and runs in the background. +CommandTree auto-discovers 22 command types — including shell scripts, npm scripts, Makefiles, Gradle, Cargo, Maven, Docker Compose, .NET projects, C# scripts, and F# scripts — by recursively scanning your workspace. Discovery respects [exclude patterns](/docs/configuration/) and runs in the background. ## Shell Scripts @@ -84,7 +84,7 @@ Reads scripts from `composer.json` (PHP). ## Docker Compose -Discovers services from `docker-compose.yml` / `docker-compose.yaml` files. +Discovers services from `docker-compose.yml` / `docker-compose.yaml` / `compose.yml` / `compose.yaml` files. ## .NET Projects @@ -98,6 +98,10 @@ Discovers `.csx` files and runs them via `dotnet script`. Discovers `.fsx` files and runs them via `dotnet fsi`. +## Mise Tasks + +Discovers tasks from `.mise.toml`, `mise.toml`, and `.mise/*.toml` files. + ## Markdown Files Discovers `.md` files in the workspace. diff --git a/website/src/docs/index.md b/website/src/docs/index.md index fa2c483..85fb0ed 100644 --- a/website/src/docs/index.md +++ b/website/src/docs/index.md @@ -1,7 +1,7 @@ --- layout: layouts/docs.njk title: Getting Started with CommandTree - VS Code Command Runner -description: Install CommandTree for VS Code and discover shell scripts, npm scripts, Makefiles, and 21 command types automatically in one sidebar. +description: Install CommandTree for VS Code and discover shell scripts, npm scripts, Makefiles, and 22 command types automatically in one sidebar. eleventyNavigation: key: Getting Started order: 1 @@ -68,7 +68,7 @@ Discovery respects [exclude patterns](/docs/configuration/) in settings and runs ### What command types does CommandTree discover? -CommandTree discovers 21 command types: shell scripts, npm scripts, Makefile targets, VS Code tasks, launch configurations, Python scripts, PowerShell scripts, Gradle tasks, Cargo tasks, Maven goals, Ant targets, Just recipes, Taskfile tasks, Deno tasks, Rake tasks, Composer scripts, Docker Compose services, .NET projects, C# scripts, F# scripts, and Markdown files. +CommandTree discovers 22 command types: shell scripts, npm scripts, Makefile targets, VS Code tasks, launch configurations, Python scripts, PowerShell scripts, Gradle tasks, Cargo tasks, Maven goals, Ant targets, Just recipes, Taskfile tasks, Deno tasks, Rake tasks, Composer scripts, Docker Compose services, .NET projects, C# scripts, F# scripts, Mise tasks, and Markdown files. ### Does CommandTree require GitHub Copilot? diff --git a/website/src/index.njk b/website/src/index.njk index a29c690..34543a3 100644 --- a/website/src/index.njk +++ b/website/src/index.njk @@ -1,7 +1,7 @@ --- layout: layouts/base.njk title: CommandTree - One Sidebar, Every Command in VS Code -description: CommandTree discovers all runnable commands in your VS Code workspace — shell scripts, npm, Make, Gradle, Docker Compose, .NET, C# Script, F# Script, and 21 types — in one sidebar with AI summaries. +description: CommandTree discovers all runnable commands in your VS Code workspace — shell scripts, npm, Make, Gradle, Docker Compose, .NET, C# Script, F# Script, and 22 types — in one sidebar with AI summaries. ---
@@ -55,7 +55,7 @@ description: CommandTree discovers all runnable commands in your VS Code workspa
🔍

Auto-Discovery

-

Recursively scans your workspace for 21 command types including shell scripts, npm, Make, Gradle, Cargo, Docker Compose, .NET, C# Script, F# Script, and more.

+

Recursively scans your workspace for 22 command types including shell scripts, npm, Make, Gradle, Cargo, Docker Compose, .NET, C# Script, F# Script, and more.

@@ -232,6 +232,13 @@ description: CommandTree discovers all runnable commands in your VS Code workspa

.fsx files

+
+ 🔧 +
+

Mise Tasks

+

.mise.toml / mise.toml

+
+
📝
diff --git a/website/tests/homepage.spec.ts b/website/tests/homepage.spec.ts index 3dbcdc6..7f1b436 100644 --- a/website/tests/homepage.spec.ts +++ b/website/tests/homepage.spec.ts @@ -50,9 +50,9 @@ test.describe('Homepage', () => { } }); - test('command types section shows all 21 types', async ({ page }) => { + test('command types section shows all 22 types', async ({ page }) => { const commandTypes = page.locator('.command-type'); - await expect(commandTypes).toHaveCount(21); + await expect(commandTypes).toHaveCount(22); const expectedTypes = [ 'Shell Scripts', @@ -75,6 +75,7 @@ test.describe('Homepage', () => { '.NET Projects', 'C# Scripts', 'F# Scripts', + 'Mise Tasks', 'Markdown Files', ]; for (const name of expectedTypes) { From ab65de3706c22398596b17a69943062d7cdfa63e Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Mon, 6 Apr 2026 10:18:17 +1000 Subject: [PATCH 12/25] ci fixes --- .github/workflows/deploy-website.yml | 8 ++++---- .github/workflows/deploy.yml | 5 ++++- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/.github/workflows/deploy-website.yml b/.github/workflows/deploy-website.yml index 56a1c5d..af1a72d 100644 --- a/.github/workflows/deploy-website.yml +++ b/.github/workflows/deploy-website.yml @@ -1,10 +1,9 @@ name: Deploy Website on: - push: - branches: [main] - paths: - - 'website/**' + workflow_run: + workflows: [Deploy] + types: [completed] permissions: contents: read @@ -18,6 +17,7 @@ concurrency: jobs: build: runs-on: ubuntu-latest + if: github.event.workflow_run.conclusion == 'success' steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 74c0958..968f085 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -2,11 +2,14 @@ name: Deploy on: workflow_dispatch: + workflow_run: + workflows: [Release] + types: [completed] jobs: publish: runs-on: ubuntu-latest - if: github.ref == 'refs/heads/main' + if: github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' steps: - uses: actions/checkout@v4 From 8e8084e225dbfad14b3b6b92634c2f421c188512 Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Mon, 6 Apr 2026 10:18:51 +1000 Subject: [PATCH 13/25] Delete dead code etc. --- src/db/db.ts | 287 ++++++++++++++++---------------------------- src/db/lifecycle.ts | 34 ++---- 2 files changed, 112 insertions(+), 209 deletions(-) diff --git a/src/db/db.ts b/src/db/db.ts index 570659c..b4ed562 100644 --- a/src/db/db.ts +++ b/src/db/db.ts @@ -27,7 +27,6 @@ export interface DbHandle { * Opens a SQLite database at the given path. * Enables foreign key constraints on every connection. */ -/* istanbul ignore next -- SQLite engine faults not reproducible in test environment */ export function openDatabase(dbPath: string): Result { try { fs.mkdirSync(path.dirname(dbPath), { recursive: true }); @@ -35,7 +34,7 @@ export function openDatabase(dbPath: string): Result { db.exec("PRAGMA foreign_keys = ON"); return ok({ db, path: dbPath }); } catch (e) { - const msg = e instanceof Error ? e.message : "Failed to open database"; + const msg = (e as Error).message; logger.error("openDatabase FAILED", { dbPath, error: msg }); return err(msg); } @@ -44,15 +43,8 @@ export function openDatabase(dbPath: string): Result { /** * Closes a database connection. */ -/* istanbul ignore next -- SQLite close errors not reproducible in tests */ -export function closeDatabase(handle: DbHandle): Result { - try { - handle.db.close(); - return ok(undefined); - } catch (e) { - const msg = e instanceof Error ? e.message : "Failed to close database"; - return err(msg); - } +export function closeDatabase(handle: DbHandle): void { + handle.db.close(); } export interface CommandRow { @@ -70,7 +62,6 @@ export function computeContentHash(content: string): string { return crypto.createHash("sha256").update(content).digest("hex").substring(0, 16); } -/* istanbul ignore next -- only fires on schema migration from older DB versions, tests use fresh DB */ function addColumnIfMissing(params: { readonly handle: DbHandle; readonly table: string; @@ -88,9 +79,8 @@ function addColumnIfMissing(params: { * SPEC: database-schema, database-schema/tags-table, database-schema/command-tags-junction * Creates the commands, tags, and command_tags tables if they do not exist. */ -export function initSchema(handle: DbHandle): Result { - try { - handle.db.exec(` +export function initSchema(handle: DbHandle): void { + handle.db.exec(` CREATE TABLE IF NOT EXISTS ${COMMAND_TABLE} ( command_id TEXT PRIMARY KEY, content_hash TEXT NOT NULL DEFAULT '', @@ -99,28 +89,28 @@ export function initSchema(handle: DbHandle): Result { last_updated TEXT NOT NULL DEFAULT '' ) `); - addColumnIfMissing({ - handle, - table: COMMAND_TABLE, - column: "content_hash", - definition: "TEXT NOT NULL DEFAULT ''", - }); - addColumnIfMissing({ handle, table: COMMAND_TABLE, column: "summary", definition: "TEXT NOT NULL DEFAULT ''" }); - addColumnIfMissing({ handle, table: COMMAND_TABLE, column: "security_warning", definition: "TEXT" }); - addColumnIfMissing({ - handle, - table: COMMAND_TABLE, - column: "last_updated", - definition: "TEXT NOT NULL DEFAULT ''", - }); - handle.db.exec(` + addColumnIfMissing({ + handle, + table: COMMAND_TABLE, + column: "content_hash", + definition: "TEXT NOT NULL DEFAULT ''", + }); + addColumnIfMissing({ handle, table: COMMAND_TABLE, column: "summary", definition: "TEXT NOT NULL DEFAULT ''" }); + addColumnIfMissing({ handle, table: COMMAND_TABLE, column: "security_warning", definition: "TEXT" }); + addColumnIfMissing({ + handle, + table: COMMAND_TABLE, + column: "last_updated", + definition: "TEXT NOT NULL DEFAULT ''", + }); + handle.db.exec(` CREATE TABLE IF NOT EXISTS ${TAG_TABLE} ( tag_id TEXT PRIMARY KEY, tag_name TEXT NOT NULL UNIQUE, description TEXT ) `); - handle.db.exec(` + handle.db.exec(` CREATE TABLE IF NOT EXISTS ${COMMAND_TAGS_TABLE} ( command_id TEXT NOT NULL, tag_id TEXT NOT NULL, @@ -130,11 +120,6 @@ export function initSchema(handle: DbHandle): Result { FOREIGN KEY (tag_id) REFERENCES ${TAG_TABLE}(tag_id) ON DELETE CASCADE ) `); - return ok(undefined); - } /* istanbul ignore next -- schema creation cannot fail on a fresh DB */ catch (e) { - const msg = e instanceof Error ? e.message : "Failed to init schema"; - return err(msg); - } } type RawRow = Record; @@ -147,23 +132,17 @@ export function registerCommand(params: { readonly handle: DbHandle; readonly commandId: string; readonly contentHash: string; -}): Result { - try { - const now = new Date().toISOString(); - params.handle.db.run( - `INSERT INTO ${COMMAND_TABLE} +}): void { + const now = new Date().toISOString(); + params.handle.db.run( + `INSERT INTO ${COMMAND_TABLE} (command_id, content_hash, summary, security_warning, last_updated) VALUES (?, ?, '', NULL, ?) ON CONFLICT(command_id) DO UPDATE SET content_hash = excluded.content_hash, last_updated = excluded.last_updated`, - [params.commandId, params.contentHash, now] - ); - return ok(undefined); - } /* istanbul ignore next -- SQLite INSERT/UPSERT cannot fail with valid schema */ catch (e) { - const msg = e instanceof Error ? e.message : "Failed to register command"; - return err(msg); - } + [params.commandId, params.contentHash, now] + ); } /** @@ -172,8 +151,8 @@ export function registerCommand(params: { export function ensureCommandExists(params: { readonly handle: DbHandle; readonly commandId: string; -}): Result { - return registerCommand({ +}): void { + registerCommand({ handle: params.handle, commandId: params.commandId, contentHash: "", @@ -190,11 +169,10 @@ export function upsertSummary(params: { readonly contentHash: string; readonly summary: string; readonly securityWarning: string | null; -}): Result { - try { - const now = new Date().toISOString(); - params.handle.db.run( - `INSERT INTO ${COMMAND_TABLE} +}): void { + const now = new Date().toISOString(); + params.handle.db.run( + `INSERT INTO ${COMMAND_TABLE} (command_id, content_hash, summary, security_warning, last_updated) VALUES (?, ?, ?, ?, ?) ON CONFLICT(command_id) DO UPDATE SET @@ -202,13 +180,8 @@ export function upsertSummary(params: { summary = excluded.summary, security_warning = excluded.security_warning, last_updated = excluded.last_updated`, - [params.commandId, params.contentHash, params.summary, params.securityWarning, now] - ); - return ok(undefined); - } /* istanbul ignore next -- SQLite UPSERT cannot fail with valid schema */ catch (e) { - const msg = e instanceof Error ? e.message : "Failed to upsert summary"; - return err(msg); - } + [params.commandId, params.contentHash, params.summary, params.securityWarning, now] + ); } /** @@ -217,43 +190,29 @@ export function upsertSummary(params: { export function getRow(params: { readonly handle: DbHandle; readonly commandId: string; -}): Result { - try { - const row = params.handle.db.get(`SELECT * FROM ${COMMAND_TABLE} WHERE command_id = ?`, [params.commandId]); - if (row === null) { - return ok(undefined); - } - return ok(rawToCommandRow(row as RawRow)); - } /* istanbul ignore next -- SQLite SELECT cannot fail with valid schema */ catch (e) { - const msg = e instanceof Error ? e.message : "Failed to get row"; - return err(msg); +}): CommandRow | undefined { + const row = params.handle.db.get(`SELECT * FROM ${COMMAND_TABLE} WHERE command_id = ?`, [params.commandId]); + if (row === null) { + return undefined; } + return rawToCommandRow(row as RawRow); } /** * Gets all command records from the database. */ -export function getAllRows(handle: DbHandle): Result { - try { - const rows = handle.db.all(`SELECT * FROM ${COMMAND_TABLE}`); - return ok(rows.map((r) => rawToCommandRow(r as RawRow))); - } /* istanbul ignore next -- SQLite SELECT cannot fail with valid schema */ catch (e) { - const msg = e instanceof Error ? e.message : "Failed to get all rows"; - return err(msg); - } +export function getAllRows(handle: DbHandle): CommandRow[] { + const rows = handle.db.all(`SELECT * FROM ${COMMAND_TABLE}`); + return rows.map((r) => rawToCommandRow(r as RawRow)); } function rawToCommandRow(row: RawRow): CommandRow { - const warning = row["security_warning"]; - const hash = row["content_hash"]; - const sum = row["summary"]; - const updated = row["last_updated"]; return { commandId: row["command_id"] as string, - contentHash: typeof hash === "string" ? hash : "", - summary: typeof sum === "string" ? sum : "", - securityWarning: typeof warning === "string" ? warning : null, - lastUpdated: typeof updated === "string" ? updated : "", + contentHash: (row["content_hash"] as string) ?? "", + summary: (row["summary"] as string) ?? "", + securityWarning: (row["security_warning"] as string | null) ?? null, + lastUpdated: (row["last_updated"] as string) ?? "", }; } @@ -267,33 +226,24 @@ export function addTagToCommand(params: { readonly commandId: string; readonly tagName: string; readonly displayOrder?: number; -}): Result { - try { - const cmdResult = ensureCommandExists({ - handle: params.handle, - commandId: params.commandId, - }); - if (!cmdResult.ok) { - return cmdResult; - } - const existing = params.handle.db.get(`SELECT tag_id FROM ${TAG_TABLE} WHERE tag_name = ?`, [params.tagName]); - const tagId = existing !== null ? ((existing as RawRow)["tag_id"] as string) : crypto.randomUUID(); - if (existing === null) { - params.handle.db.run(`INSERT INTO ${TAG_TABLE} (tag_id, tag_name, description) VALUES (?, ?, NULL)`, [ - tagId, - params.tagName, - ]); - } - const order = params.displayOrder ?? 0; - params.handle.db.run( - `INSERT OR IGNORE INTO ${COMMAND_TAGS_TABLE} (command_id, tag_id, display_order) VALUES (?, ?, ?)`, - [params.commandId, tagId, order] - ); - return ok(undefined); - } /* istanbul ignore next -- SQLite tag operations cannot fail with valid schema */ catch (e) { - const msg = e instanceof Error ? e.message : "Failed to add tag to command"; - return err(msg); +}): void { + ensureCommandExists({ + handle: params.handle, + commandId: params.commandId, + }); + const existing = params.handle.db.get(`SELECT tag_id FROM ${TAG_TABLE} WHERE tag_name = ?`, [params.tagName]); + const tagId = existing !== null ? ((existing as RawRow)["tag_id"] as string) : crypto.randomUUID(); + if (existing === null) { + params.handle.db.run(`INSERT INTO ${TAG_TABLE} (tag_id, tag_name, description) VALUES (?, ?, NULL)`, [ + tagId, + params.tagName, + ]); } + const order = params.displayOrder ?? 0; + params.handle.db.run( + `INSERT OR IGNORE INTO ${COMMAND_TAGS_TABLE} (command_id, tag_id, display_order) VALUES (?, ?, ?)`, + [params.commandId, tagId, order] + ); } /** @@ -304,19 +254,13 @@ export function removeTagFromCommand(params: { readonly handle: DbHandle; readonly commandId: string; readonly tagName: string; -}): Result { - try { - params.handle.db.run( - `DELETE FROM ${COMMAND_TAGS_TABLE} +}): void { + params.handle.db.run( + `DELETE FROM ${COMMAND_TAGS_TABLE} WHERE command_id = ? AND tag_id = (SELECT tag_id FROM ${TAG_TABLE} WHERE tag_name = ?)`, - [params.commandId, params.tagName] - ); - return ok(undefined); - } /* istanbul ignore next -- SQLite DELETE cannot fail with valid schema */ catch (e) { - const msg = e instanceof Error ? e.message : "Failed to remove tag from command"; - return err(msg); - } + [params.commandId, params.tagName] + ); } /** @@ -326,21 +270,16 @@ export function removeTagFromCommand(params: { export function getCommandIdsByTag(params: { readonly handle: DbHandle; readonly tagName: string; -}): Result { - try { - const rows = params.handle.db.all( - `SELECT ct.command_id +}): string[] { + const rows = params.handle.db.all( + `SELECT ct.command_id FROM ${COMMAND_TAGS_TABLE} ct JOIN ${TAG_TABLE} t ON ct.tag_id = t.tag_id WHERE t.tag_name = ? ORDER BY ct.display_order`, - [params.tagName] - ); - return ok(rows.map((r) => (r as RawRow)["command_id"] as string)); - } /* istanbul ignore next -- SQLite JOIN query cannot fail with valid schema */ catch (e) { - const msg = e instanceof Error ? e.message : "Failed to get command IDs by tag"; - return err(msg); - } + [params.tagName] + ); + return rows.map((r) => (r as RawRow)["command_id"] as string); } /** @@ -350,34 +289,24 @@ export function getCommandIdsByTag(params: { export function getTagsForCommand(params: { readonly handle: DbHandle; readonly commandId: string; -}): Result { - try { - const rows = params.handle.db.all( - `SELECT t.tag_name +}): string[] { + const rows = params.handle.db.all( + `SELECT t.tag_name FROM ${TAG_TABLE} t JOIN ${COMMAND_TAGS_TABLE} ct ON t.tag_id = ct.tag_id WHERE ct.command_id = ?`, - [params.commandId] - ); - return ok(rows.map((r) => (r as RawRow)["tag_name"] as string)); - } /* istanbul ignore next -- SQLite JOIN query cannot fail with valid schema */ catch (e) { - const msg = e instanceof Error ? e.message : "Failed to get tags for command"; - return err(msg); - } + [params.commandId] + ); + return rows.map((r) => (r as RawRow)["tag_name"] as string); } /** * SPEC: database-schema/tag-operations, tagging/filter * Gets all distinct tag names. */ -export function getAllTagNames(handle: DbHandle): Result { - try { - const rows = handle.db.all(`SELECT tag_name FROM ${TAG_TABLE} ORDER BY tag_name`); - return ok(rows.map((r) => (r as RawRow)["tag_name"] as string)); - } /* istanbul ignore next -- SQLite SELECT cannot fail with valid schema */ catch (e) { - const msg = e instanceof Error ? e.message : "Failed to get all tag names"; - return err(msg); - } +export function getAllTagNames(handle: DbHandle): string[] { + const rows = handle.db.all(`SELECT tag_name FROM ${TAG_TABLE} ORDER BY tag_name`); + return rows.map((r) => (r as RawRow)["tag_name"] as string); } /** @@ -389,18 +318,12 @@ export function updateTagDisplayOrder(params: { readonly commandId: string; readonly tagId: string; readonly newOrder: number; -}): Result { - try { - params.handle.db.run(`UPDATE ${COMMAND_TAGS_TABLE} SET display_order = ? WHERE command_id = ? AND tag_id = ?`, [ - params.newOrder, - params.commandId, - params.tagId, - ]); - return ok(undefined); - } /* istanbul ignore next -- SQLite UPDATE cannot fail with valid schema */ catch (e) { - const msg = e instanceof Error ? e.message : "Failed to update tag display order"; - return err(msg); - } +}): void { + params.handle.db.run(`UPDATE ${COMMAND_TAGS_TABLE} SET display_order = ? WHERE command_id = ? AND tag_id = ?`, [ + params.newOrder, + params.commandId, + params.tagId, + ]); } /** @@ -411,23 +334,17 @@ export function reorderTagCommands(params: { readonly handle: DbHandle; readonly tagName: string; readonly orderedCommandIds: readonly string[]; -}): Result { - try { - const tagRow = params.handle.db.get(`SELECT tag_id FROM ${TAG_TABLE} WHERE tag_name = ?`, [params.tagName]); - if (tagRow === null) { - return err(`Tag "${params.tagName}" not found`); - } - const tagId = (tagRow as RawRow)["tag_id"] as string; - params.orderedCommandIds.forEach((commandId, index) => { - params.handle.db.run(`UPDATE ${COMMAND_TAGS_TABLE} SET display_order = ? WHERE command_id = ? AND tag_id = ?`, [ - index, - commandId, - tagId, - ]); - }); - return ok(undefined); - } /* istanbul ignore next -- SQLite UPDATE cannot fail with valid schema */ catch (e) { - const msg = e instanceof Error ? e.message : "Failed to reorder tag commands"; - return err(msg); +}): void { + const tagRow = params.handle.db.get(`SELECT tag_id FROM ${TAG_TABLE} WHERE tag_name = ?`, [params.tagName]); + if (tagRow === null) { + return; } + const tagId = (tagRow as RawRow)["tag_id"] as string; + params.orderedCommandIds.forEach((commandId, index) => { + params.handle.db.run(`UPDATE ${COMMAND_TAGS_TABLE} SET display_order = ? WHERE command_id = ? AND tag_id = ?`, [ + index, + commandId, + tagId, + ]); + }); } diff --git a/src/db/lifecycle.ts b/src/db/lifecycle.ts index b2be451..73166af 100644 --- a/src/db/lifecycle.ts +++ b/src/db/lifecycle.ts @@ -5,8 +5,6 @@ import * as fs from "fs"; import * as path from "path"; -import type { Result } from "../models/Result"; -import { ok, err } from "../models/Result"; import { logger } from "../utils/logger"; import type { DbHandle } from "./db"; import { openDatabase, initSchema, closeDatabase } from "./db"; @@ -20,49 +18,37 @@ let dbHandle: DbHandle | null = null; * Initialises the SQLite database singleton. * Re-creates if the DB file was deleted externally. */ -export function initDb(workspaceRoot: string): Result { +export function initDb(workspaceRoot: string): DbHandle { if (dbHandle !== null && fs.existsSync(dbHandle.path)) { - return ok(dbHandle); + return dbHandle; } - /* istanbul ignore next -- stale handle only occurs if DB file deleted externally while running */ resetStaleHandle(); const dbDir = path.join(workspaceRoot, COMMANDTREE_DIR); - try { - fs.mkdirSync(dbDir, { recursive: true }); - } /* istanbul ignore next -- filesystem errors creating .commandtree dir not reproducible in tests */ catch (e) { - const msg = e instanceof Error ? e.message : "Failed to create directory"; - return err(msg); - } + fs.mkdirSync(dbDir, { recursive: true }); const dbPath = path.join(dbDir, DB_FILENAME); const openResult = openDatabase(dbPath); if (!openResult.ok) { - return openResult; - } - - const schemaResult = initSchema(openResult.value); - if (!schemaResult.ok) { - closeDatabase(openResult.value); - return err(schemaResult.error); + throw new Error(openResult.error); } + initSchema(openResult.value); dbHandle = openResult.value; logger.info("SQLite database initialised", { path: dbPath }); - return ok(dbHandle); + return dbHandle; } /** * Returns the current database handle. - * Invalidates a stale handle if the DB file was deleted. + * Throws if the database has not been initialised. */ -export function getDb(): Result { +export function getDb(): DbHandle { if (dbHandle !== null && fs.existsSync(dbHandle.path)) { - return ok(dbHandle); + return dbHandle; } - /* istanbul ignore next -- stale handle only occurs if DB file deleted externally while running */ resetStaleHandle(); - return err("Database not initialised. Call initDb first."); + throw new Error("Database not initialised. Call initDb first."); } function resetStaleHandle(): void { From fa4bb768a8086b4723a3d7a2f0b1db8dc10876eb Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Mon, 6 Apr 2026 10:20:33 +1000 Subject: [PATCH 14/25] Test coverage stuff --- src/CommandTreeProvider.ts | 34 +++----- src/QuickTasksProvider.ts | 46 +++-------- src/config/TagConfig.ts | 119 ++++++---------------------- src/extension.ts | 12 +-- src/semantic/summaryPipeline.ts | 34 +++----- src/tags/tagSync.ts | 31 ++------ src/test/e2e/db.e2e.test.ts | 32 +++----- src/test/e2e/quicktasks.e2e.test.ts | 3 +- 8 files changed, 83 insertions(+), 228 deletions(-) diff --git a/src/CommandTreeProvider.ts b/src/CommandTreeProvider.ts index 64791a1..fe3e260 100644 --- a/src/CommandTreeProvider.ts +++ b/src/CommandTreeProvider.ts @@ -1,5 +1,5 @@ import * as vscode from "vscode"; -import type { CommandItem, Result, CategoryDef } from "./models/TaskItem"; +import type { CommandItem, CategoryDef } from "./models/TaskItem"; import type { CommandTreeItem } from "./models/TaskItem"; import type { DiscoveryResult } from "./discovery"; import { discoverAllTasks, flattenTasks, getExcludePatterns, CATEGORY_DEFS } from "./discovery"; @@ -43,18 +43,10 @@ export class CommandTreeProvider implements vscode.TreeDataProvider(); - for (const row of result.value) { + for (const row of rows) { map.set(row.commandId, row); } this.summaries = map; @@ -106,20 +98,14 @@ export class CommandTreeProvider implements vscode.TreeDataProvider> { - const result = this.tagConfig.addTaskToTag(task, tagName); - if (result.ok) { - await this.refresh(); - } - return result; + public async addTaskToTag(task: CommandItem, tagName: string): Promise { + this.tagConfig.addTaskToTag(task, tagName); + await this.refresh(); } - public async removeTaskFromTag(task: CommandItem, tagName: string): Promise> { - const result = this.tagConfig.removeTaskFromTag(task, tagName); - if (result.ok) { - await this.refresh(); - } - return result; + public async removeTaskFromTag(task: CommandItem, tagName: string): Promise { + this.tagConfig.removeTaskFromTag(task, tagName); + await this.refresh(); } public getAllTasks(): CommandItem[] { diff --git a/src/QuickTasksProvider.ts b/src/QuickTasksProvider.ts index 9224377..fd63c55 100644 --- a/src/QuickTasksProvider.ts +++ b/src/QuickTasksProvider.ts @@ -5,7 +5,7 @@ */ import * as vscode from "vscode"; -import type { CommandItem, Result, CommandTreeItem } from "./models/TaskItem"; +import type { CommandItem, CommandTreeItem } from "./models/TaskItem"; import { isCommandItem } from "./models/TaskItem"; import { TagConfig } from "./config/TagConfig"; import { getDb } from "./db/lifecycle"; @@ -50,28 +50,22 @@ export class QuickTasksProvider * SPEC: quick-launch * Adds a command to the quick list. */ - public addToQuick(task: CommandItem): Result { - const result = this.tagConfig.addTaskToTag(task, QUICK_TAG); - if (result.ok) { - this.tagConfig.load(); - this.allTasks = this.tagConfig.applyTags(this.allTasks); - this.onDidChangeTreeDataEmitter.fire(undefined); - } - return result; + public addToQuick(task: CommandItem): void { + this.tagConfig.addTaskToTag(task, QUICK_TAG); + this.tagConfig.load(); + this.allTasks = this.tagConfig.applyTags(this.allTasks); + this.onDidChangeTreeDataEmitter.fire(undefined); } /** * SPEC: quick-launch * Removes a command from the quick list. */ - public removeFromQuick(task: CommandItem): Result { - const result = this.tagConfig.removeTaskFromTag(task, QUICK_TAG); - if (result.ok) { - this.tagConfig.load(); - this.allTasks = this.tagConfig.applyTags(this.allTasks); - this.onDidChangeTreeDataEmitter.fire(undefined); - } - return result; + public removeFromQuick(task: CommandItem): void { + this.tagConfig.removeTaskFromTag(task, QUICK_TAG); + this.tagConfig.load(); + this.allTasks = this.tagConfig.applyTags(this.allTasks); + this.onDidChangeTreeDataEmitter.fire(undefined); } /** @@ -110,22 +104,8 @@ export class QuickTasksProvider * Sorts tasks by display_order from junction table. */ private sortByDisplayOrder(tasks: CommandItem[]): CommandItem[] { - const dbResult = getDb(); - /* istanbul ignore if -- DB is always initialised before tree views render */ - if (!dbResult.ok) { - return tasks.sort((a, b) => a.label.localeCompare(b.label)); - } - - const orderedIdsResult = getCommandIdsByTag({ - handle: dbResult.value, - tagName: QUICK_TAG, - }); - /* istanbul ignore if -- getCommandIdsByTag SELECT cannot fail with valid DB */ - if (!orderedIdsResult.ok) { - return tasks.sort((a, b) => a.label.localeCompare(b.label)); - } - - const orderedIds = orderedIdsResult.value; + const handle = getDb(); + const orderedIds = getCommandIdsByTag({ handle, tagName: QUICK_TAG }); return [...tasks].sort((a, b) => { const indexA = orderedIds.indexOf(a.id); const indexB = orderedIds.indexOf(b.id); diff --git a/src/config/TagConfig.ts b/src/config/TagConfig.ts index b615e28..af1f676 100644 --- a/src/config/TagConfig.ts +++ b/src/config/TagConfig.ts @@ -4,8 +4,7 @@ * All tag data stored in SQLite tags table (junction table design). */ -import type { CommandItem, Result } from "../models/TaskItem"; -import { err } from "../models/TaskItem"; +import type { CommandItem } from "../models/TaskItem"; import { getDb } from "../db/lifecycle"; import { addTagToCommand, @@ -23,32 +22,16 @@ export class TagConfig { * Loads all tag assignments from SQLite junction table. */ public load(): void { - const dbResult = getDb(); - /* istanbul ignore if -- DB is always initialised before tag config loads */ - if (!dbResult.ok) { - this.commandTagsMap = new Map(); - return; - } - - const tagNamesResult = getAllTagNames(dbResult.value); - /* istanbul ignore if -- getAllTagNames SELECT cannot fail with valid DB */ - if (!tagNamesResult.ok) { - this.commandTagsMap = new Map(); - return; - } + const handle = getDb(); + const tagNames = getAllTagNames(handle); const map = new Map(); - for (const tagName of tagNamesResult.value) { - const commandIdsResult = getCommandIdsByTag({ - handle: dbResult.value, - tagName, - }); - if (commandIdsResult.ok) { - for (const commandId of commandIdsResult.value) { - const tags = map.get(commandId) ?? []; - tags.push(tagName); - map.set(commandId, tags); - } + for (const tagName of tagNames) { + const commandIds = getCommandIdsByTag({ handle, tagName }); + for (const commandId of commandIds) { + const tags = map.get(commandId) ?? []; + tags.push(tagName); + map.set(commandId, tags); } } this.commandTagsMap = map; @@ -70,59 +53,28 @@ export class TagConfig { * Gets all tag names. */ public getTagNames(): string[] { - const dbResult = getDb(); - /* istanbul ignore if -- DB is always initialised before tag operations */ - if (!dbResult.ok) { - return []; - } - const result = getAllTagNames(dbResult.value); - return result.ok ? result.value : []; + const handle = getDb(); + return getAllTagNames(handle); } /** * SPEC: tagging/management * Adds a task to a tag by creating junction record with exact command ID. */ - public addTaskToTag(task: CommandItem, tagName: string): Result { - const dbResult = getDb(); - /* istanbul ignore if -- DB is always initialised before tag operations */ - if (!dbResult.ok) { - return err(dbResult.error); - } - - const result = addTagToCommand({ - handle: dbResult.value, - commandId: task.id, - tagName, - }); - - if (result.ok) { - this.load(); - } - return result; + public addTaskToTag(task: CommandItem, tagName: string): void { + const handle = getDb(); + addTagToCommand({ handle, commandId: task.id, tagName }); + this.load(); } /** * SPEC: tagging/management * Removes a task from a tag by deleting junction record. */ - public removeTaskFromTag(task: CommandItem, tagName: string): Result { - const dbResult = getDb(); - /* istanbul ignore if -- DB is always initialised before tag operations */ - if (!dbResult.ok) { - return err(dbResult.error); - } - - const result = removeTagFromCommand({ - handle: dbResult.value, - commandId: task.id, - tagName, - }); - - if (result.ok) { - this.load(); - } - return result; + public removeTaskFromTag(task: CommandItem, tagName: string): void { + const handle = getDb(); + removeTagFromCommand({ handle, commandId: task.id, tagName }); + this.load(); } /** @@ -130,38 +82,17 @@ export class TagConfig { * Gets ordered command IDs for a tag (ordered by display_order). */ public getOrderedCommandIds(tagName: string): string[] { - const dbResult = getDb(); - /* istanbul ignore if -- DB is always initialised before tag operations */ - if (!dbResult.ok) { - return []; - } - const result = getCommandIdsByTag({ - handle: dbResult.value, - tagName, - }); - return result.ok ? result.value : []; + const handle = getDb(); + return getCommandIdsByTag({ handle, tagName }); } /** * SPEC: quick-launch * Reorders commands for a tag by updating display_order in junction table. */ - public reorderCommands(tagName: string, orderedCommandIds: string[]): Result { - const dbResult = getDb(); - /* istanbul ignore if -- DB is always initialised before tag operations */ - if (!dbResult.ok) { - return err(dbResult.error); - } - - const result = reorderTagCommands({ - handle: dbResult.value, - tagName, - orderedCommandIds, - }); - - if (result.ok) { - this.load(); - } - return result; + public reorderCommands(tagName: string, orderedCommandIds: string[]): void { + const handle = getDb(); + reorderTagCommands({ handle, tagName, orderedCommandIds }); + this.load(); } } diff --git a/src/extension.ts b/src/extension.ts index 950cf61..1739718 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -24,7 +24,6 @@ export interface ExtensionExports { export async function activate(context: vscode.ExtensionContext): Promise { const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; logger.info("Extension activating", { workspaceRoot }); - /* istanbul ignore if -- e2e tests always run with a workspace open */ if (workspaceRoot === undefined || workspaceRoot === "") { logger.warn("No workspace root found, extension not activating"); return; @@ -38,7 +37,6 @@ export async function activate(context: vscode.ExtensionContext): Promise { - /* istanbul ignore next -- async Result-based functions do not throw */ syncAndSummarise(workspaceRoot).catch((e: unknown) => { logger.error("Sync failed", { error: e instanceof Error ? e.message : "Unknown", @@ -46,7 +44,6 @@ export async function activate(context: vscode.ExtensionContext): Promise { - /* istanbul ignore next -- async Result-based functions do not throw */ syncTagsFromJson(workspaceRoot).catch((e: unknown) => { logger.error("Config sync failed", { error: e instanceof Error ? e.message : "Unknown", @@ -62,11 +59,7 @@ export async function activate(context: vscode.ExtensionContext): Promise } } -/* istanbul ignore next -- requires Copilot auth, not available in CI */ function initAiSummaries(workspaceRoot: string): void { const aiConfig = vscode.workspace.getConfiguration("commandtree").get("enableAiSummaries"); if (aiConfig === false) { @@ -318,7 +310,6 @@ function initAiSummaries(workspaceRoot: string): void { }); } -/* istanbul ignore next -- requires Copilot auth, not available in CI */ async function runSummarisation(workspaceRoot: string): Promise { const tasks = treeProvider.getAllTasks(); logger.info("[SUMMARY] Starting", { taskCount: tasks.length }); @@ -359,7 +350,6 @@ function updateFilterContext(): void { vscode.commands.executeCommand("setContext", "commandtree.hasFilter", treeProvider.hasFilter()); } -/* istanbul ignore next -- called by VS Code on shutdown, not reachable during tests */ export function deactivate(): void { disposeDb(); } diff --git a/src/semantic/summaryPipeline.ts b/src/semantic/summaryPipeline.ts index 3e94480..e822793 100644 --- a/src/semantic/summaryPipeline.ts +++ b/src/semantic/summaryPipeline.ts @@ -6,8 +6,8 @@ import type * as vscode from "vscode"; import type { CommandItem } from "../models/TaskItem"; -import type { Result } from "../models/Result"; import { ok, err } from "../models/Result"; +import type { Result } from "../models/Result"; import { logger } from "../utils/logger"; import { computeContentHash } from "../db/db"; import type { FileSystemAdapter } from "./adapters"; @@ -50,10 +50,9 @@ async function findPendingSummaries(params: { const hash = computeContentHash(content); const existing = getRow({ handle: params.handle, commandId: task.id }); const needsSummary = - !existing.ok || - existing.value === undefined || - existing.value.summary === "" || - existing.value.contentHash !== hash; + existing === undefined || + existing.summary === "" || + existing.contentHash !== hash; if (needsSummary) { pending.push({ task, content, hash }); } @@ -96,13 +95,14 @@ async function processOneSummary(params: { } const warning = result.securityWarning === "" ? null : result.securityWarning; - return upsertSummary({ + upsertSummary({ handle: params.handle, commandId: params.task.id, contentHash: params.hash, summary: result.summary, securityWarning: warning, }); + return ok(undefined); } /** @@ -114,23 +114,18 @@ export async function registerAllCommands(params: { readonly workspaceRoot: string; readonly fs: FileSystemAdapter; }): Promise> { - const dbInit = initDb(params.workspaceRoot); - if (!dbInit.ok) { - return err(dbInit.error); - } + const handle = initDb(params.workspaceRoot); let registered = 0; for (const task of params.tasks) { const content = await readTaskContent({ task, fs: params.fs }); const hash = computeContentHash(content); - const result = registerCommand({ - handle: dbInit.value, + registerCommand({ + handle, commandId: task.id, contentHash: hash, }); - if (result.ok) { - registered++; - } + registered++; } return ok(registered); } @@ -197,13 +192,10 @@ export async function summariseAllTasks(params: { return err(modelResult.error); } - const dbInit = initDb(params.workspaceRoot); - if (!dbInit.ok) { - return err(dbInit.error); - } + const handle = initDb(params.workspaceRoot); const pending = await findPendingSummaries({ - handle: dbInit.value, + handle, tasks: params.tasks, fs: params.fs, }); @@ -214,7 +206,7 @@ export async function summariseAllTasks(params: { const state: BatchState = { succeeded: 0, failed: 0, aborted: false }; for (const item of pending) { - await processPendingItem({ item, model: modelResult.value, handle: dbInit.value, state }); + await processPendingItem({ item, model: modelResult.value, handle, state }); params.onProgress?.(state.succeeded + state.failed, pending.length, item.task.label); if (state.aborted) { break; diff --git a/src/tags/tagSync.ts b/src/tags/tagSync.ts index e6d8479..2021757 100644 --- a/src/tags/tagSync.ts +++ b/src/tags/tagSync.ts @@ -90,28 +90,13 @@ export function syncTagsFromConfig({ if (config?.tags === undefined) { return false; } - const dbResult = getDb(); - /* istanbul ignore if -- DB is always initialised before tag sync runs */ - if (!dbResult.ok) { - logger.warn("DB not available, skipping tag sync", { - error: dbResult.error, - }); - return false; - } - try { - for (const [tagName, patterns] of Object.entries(config.tags)) { - const existingIds = getCommandIdsByTag({ handle: dbResult.value, tagName }); - const currentIds = existingIds.ok ? new Set(existingIds.value) : new Set(); - const matchedIds = collectMatchedIds(patterns, allTasks); - syncTagDiff({ handle: dbResult.value, tagName, currentIds, matchedIds }); - } - logger.info("Tag sync complete"); - return true; - } /* istanbul ignore next -- DB functions return Result types and never throw in practice */ catch (e) { - logger.error("Tag sync failed", { - error: e instanceof Error ? e.message : "Unknown", - stack: e instanceof Error ? e.stack : undefined, - }); - return false; + const handle = getDb(); + for (const [tagName, patterns] of Object.entries(config.tags)) { + const existingIds = getCommandIdsByTag({ handle, tagName }); + const currentIds = new Set(existingIds); + const matchedIds = collectMatchedIds(patterns, allTasks); + syncTagDiff({ handle, tagName, currentIds, matchedIds }); } + logger.info("Tag sync complete"); + return true; } diff --git a/src/test/e2e/db.e2e.test.ts b/src/test/e2e/db.e2e.test.ts index 3840d8e..3b227a2 100644 --- a/src/test/e2e/db.e2e.test.ts +++ b/src/test/e2e/db.e2e.test.ts @@ -29,8 +29,7 @@ suite("DB Unit Tests", () => { const openResult = openDatabase(dbPath); assert.ok(openResult.ok, "Failed to open database"); handle = openResult.value; - const schemaResult = initSchema(handle); - assert.ok(schemaResult.ok, "Failed to init schema"); + initSchema(handle); }); teardown(() => { @@ -46,25 +45,22 @@ suite("DB Unit Tests", () => { suite("addColumnIfMissing", () => { test("initSchema is idempotent — calling twice succeeds", () => { - const result = initSchema(handle); - assert.ok(result.ok, "Second initSchema call should succeed"); + initSchema(handle); }); }); suite("registerCommand", () => { test("inserts new command", () => { - const result = registerCommand({ + registerCommand({ handle, commandId: "test-cmd-1", contentHash: "hash1", }); - assert.ok(result.ok); const row = getRow({ handle, commandId: "test-cmd-1" }); - assert.ok(row.ok); - assert.ok(row.value !== undefined); - assert.strictEqual(row.value.commandId, "test-cmd-1"); - assert.strictEqual(row.value.contentHash, "hash1"); + assert.ok(row !== undefined); + assert.strictEqual(row.commandId, "test-cmd-1"); + assert.strictEqual(row.contentHash, "hash1"); }); test("upsert updates content hash on conflict", () => { @@ -72,34 +68,30 @@ suite("DB Unit Tests", () => { registerCommand({ handle, commandId: "test-cmd-2", contentHash: "hash-new" }); const row = getRow({ handle, commandId: "test-cmd-2" }); - assert.ok(row.ok); - assert.ok(row.value !== undefined); - assert.strictEqual(row.value.contentHash, "hash-new"); + assert.ok(row !== undefined); + assert.strictEqual(row.contentHash, "hash-new"); }); }); suite("getRow", () => { test("returns undefined for non-existent command", () => { const result = getRow({ handle, commandId: "nonexistent" }); - assert.ok(result.ok); - assert.strictEqual(result.value, undefined); + assert.strictEqual(result, undefined); }); }); suite("tag operations", () => { test("addTagToCommand creates tag and junction record", () => { registerCommand({ handle, commandId: "cmd-tag-1", contentHash: "h1" }); - const result = addTagToCommand({ + addTagToCommand({ handle, commandId: "cmd-tag-1", tagName: "build", }); - assert.ok(result.ok); const ids = getCommandIdsByTag({ handle, tagName: "build" }); - assert.ok(ids.ok); - assert.ok(ids.value.length > 0); - assert.ok(ids.value.includes("cmd-tag-1")); + assert.ok(ids.length > 0); + assert.ok(ids.includes("cmd-tag-1")); }); test("addTagToCommand is idempotent", () => { diff --git a/src/test/e2e/quicktasks.e2e.test.ts b/src/test/e2e/quicktasks.e2e.test.ts index 820c81c..d422034 100644 --- a/src/test/e2e/quicktasks.e2e.test.ts +++ b/src/test/e2e/quicktasks.e2e.test.ts @@ -302,8 +302,7 @@ suite("Quick Launch E2E Tests (SQLite Junction Table)", () => { const configOrderedIds = tagConfig.getOrderedCommandIds(QUICK_TAG); assert.ok(configOrderedIds.length >= 3, "getOrderedCommandIds should return at least 3 IDs"); const reversed = [...configOrderedIds].reverse(); - const reorderResult = tagConfig.reorderCommands(QUICK_TAG, reversed); - assert.ok(reorderResult.ok, "reorderCommands should succeed"); + tagConfig.reorderCommands(QUICK_TAG, reversed); const newOrderedIds = tagConfig.getOrderedCommandIds(QUICK_TAG); const firstReversed = reversed[0]; const lastReversed = reversed[reversed.length - 1]; From 579a9a16840ebbf8bf4f075926c63ac192ee13cf Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Mon, 6 Apr 2026 10:24:07 +1000 Subject: [PATCH 15/25] Coverage etc. --- src/QuickTasksProvider.ts | 27 ++-------- src/discovery/make.ts | 34 +++++++------ src/models/TaskItem.ts | 2 + src/runners/TaskRunner.ts | 11 ++--- src/test/e2e/db.e2e.test.ts | 20 +++----- src/test/e2e/quicktasks.e2e.test.ts | 62 +++++++++-------------- src/test/e2e/tagconfig.e2e.test.ts | 76 ++++++++++++----------------- src/test/e2e/treeview.e2e.test.ts | 66 ++++++++++++++++++++++++- src/tree/nodeFactory.ts | 5 +- src/utils/fileUtils.ts | 5 +- src/utils/logger.ts | 25 ---------- 11 files changed, 165 insertions(+), 168 deletions(-) diff --git a/src/QuickTasksProvider.ts b/src/QuickTasksProvider.ts index fd63c55..31108d7 100644 --- a/src/QuickTasksProvider.ts +++ b/src/QuickTasksProvider.ts @@ -144,10 +144,6 @@ export class QuickTasksProvider } const orderedIds = this.fetchOrderedQuickIds(); - if (orderedIds === undefined) { - return; - } - const reordered = this.computeReorder({ orderedIds, draggedTask, target }); if (reordered === undefined) { return; @@ -160,18 +156,9 @@ export class QuickTasksProvider /** * Fetches ordered command IDs for the quick tag from the DB. */ - private fetchOrderedQuickIds(): string[] | undefined { - const dbResult = getDb(); - /* istanbul ignore if -- DB is always initialised before tree views render */ - if (!dbResult.ok) { - return undefined; - } - const orderedIdsResult = getCommandIdsByTag({ - handle: dbResult.value, - tagName: QUICK_TAG, - }); - /* istanbul ignore next -- getCommandIdsByTag cannot fail with valid DB handle */ - return orderedIdsResult.ok ? orderedIdsResult.value : undefined; + private fetchOrderedQuickIds(): string[] { + const handle = getDb(); + return getCommandIdsByTag({ handle, tagName: QUICK_TAG }); } /** @@ -208,15 +195,11 @@ export class QuickTasksProvider * Persists display_order for each command in the reordered list. */ private persistDisplayOrder(reordered: string[]): void { - const dbResult = getDb(); - /* istanbul ignore if -- DB is always initialised before tree views render */ - if (!dbResult.ok) { - return; - } + const handle = getDb(); for (let i = 0; i < reordered.length; i++) { const commandId = reordered[i]; if (commandId !== undefined) { - dbResult.value.db.run( + handle.db.run( `UPDATE command_tags SET display_order = ? WHERE command_id = ? diff --git a/src/discovery/make.ts b/src/discovery/make.ts index 3aa66d3..40ac868 100644 --- a/src/discovery/make.ts +++ b/src/discovery/make.ts @@ -37,21 +37,22 @@ export async function discoverMakeTargets(workspaceRoot: string, excludePatterns const makeDir = path.dirname(file.fsPath); const category = simplifyPath(file.fsPath, workspaceRoot); - for (const target of targets) { + for (const { name, line } of targets) { // Skip internal targets (start with .) - if (target.startsWith(".")) { + if (name.startsWith(".")) { continue; } commands.push({ - id: generateCommandId("make", file.fsPath, target), - label: target, + id: generateCommandId("make", file.fsPath, name), + label: name, type: "make", category, - command: `make ${target}`, + command: `make ${name}`, cwd: makeDir, filePath: file.fsPath, tags: [], + line, }); } } @@ -59,25 +60,30 @@ export async function discoverMakeTargets(workspaceRoot: string, excludePatterns return commands; } +interface MakeTarget { + readonly name: string; + readonly line: number; +} + /** - * Parses Makefile to extract target names. + * Parses Makefile to extract target names and their line numbers. */ -function parseMakeTargets(content: string): string[] { - const targets: string[] = []; +function parseMakeTargets(content: string): MakeTarget[] { + const targets: MakeTarget[] = []; + const seen = new Set(); // Match lines like "target:" or "target: dependencies" // But not variable assignments like "VAR = value" or "VAR := value" const targetRegex = /^([a-zA-Z_][a-zA-Z0-9_-]*)\s*:/gm; let match; while ((match = targetRegex.exec(content)) !== null) { - const target = match[1]; - if (target === undefined || target === "") { + const name = match[1]; + if (name === undefined || name === "" || seen.has(name)) { continue; } - // Add target if not already present - if (!targets.includes(target)) { - targets.push(target); - } + seen.add(name); + const line = content.substring(0, match.index).split("\n").length; + targets.push({ name, line }); } return targets; diff --git a/src/models/TaskItem.ts b/src/models/TaskItem.ts index cb54ad9..a529e56 100644 --- a/src/models/TaskItem.ts +++ b/src/models/TaskItem.ts @@ -96,6 +96,7 @@ export interface CommandItem { readonly description?: string; readonly summary?: string; readonly securityWarning?: string; + readonly line?: number; } /** @@ -114,6 +115,7 @@ export interface MutableCommandItem { description?: string; summary?: string; securityWarning?: string; + line?: number; } /** diff --git a/src/runners/TaskRunner.ts b/src/runners/TaskRunner.ts index fc0df39..8b5c40b 100644 --- a/src/runners/TaskRunner.ts +++ b/src/runners/TaskRunner.ts @@ -6,7 +6,6 @@ import type { CommandItem, ParamDef } from "../models/TaskItem"; * * Shows error message without blocking (fire and forget). */ -/* istanbul ignore next -- fire-and-forget error display, VS Code API never rejects in practice */ function showError(message: string): void { vscode.window.showErrorMessage(message).then( () => { @@ -96,8 +95,7 @@ export class TaskRunner { */ private async runLaunch(task: CommandItem): Promise { const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; - /* istanbul ignore if -- e2e tests always have a workspace open */ - if (workspaceFolder === undefined) { + if (workspaceFolder === undefined) { showError("No workspace folder found"); return; } @@ -118,7 +116,7 @@ export class TaskRunner { if (matchingTask !== undefined) { await vscode.tasks.executeTask(matchingTask); - } /* istanbul ignore next -- task always exists at execution time since it was just discovered */ else { + } else { showError(`Command not found: ${task.label}`); } } @@ -192,8 +190,7 @@ export class TaskRunner { this.safeSendText(terminal, command, shellIntegration); } }); - /* istanbul ignore next -- 50ms timeout race: shell integration always wins in test environment */ - setTimeout(() => { + setTimeout(() => { if (!resolved) { resolved = true; listener.dispose(); @@ -217,7 +214,7 @@ export class TaskRunner { } else { terminal.sendText(command); } - } /* istanbul ignore next -- terminal.sendText never throws in practice, guards xterm edge case */ catch { + } catch { showError(`Failed to send command to terminal: ${command}`); } } diff --git a/src/test/e2e/db.e2e.test.ts b/src/test/e2e/db.e2e.test.ts index 3b227a2..7f32bfb 100644 --- a/src/test/e2e/db.e2e.test.ts +++ b/src/test/e2e/db.e2e.test.ts @@ -97,37 +97,32 @@ suite("DB Unit Tests", () => { test("addTagToCommand is idempotent", () => { registerCommand({ handle, commandId: "cmd-tag-2", contentHash: "h2" }); addTagToCommand({ handle, commandId: "cmd-tag-2", tagName: "deploy" }); - const result = addTagToCommand({ handle, commandId: "cmd-tag-2", tagName: "deploy" }); - assert.ok(result.ok); + addTagToCommand({ handle, commandId: "cmd-tag-2", tagName: "deploy" }); const ids = getCommandIdsByTag({ handle, tagName: "deploy" }); - assert.ok(ids.ok); - assert.strictEqual(ids.value.filter((id) => id === "cmd-tag-2").length, 1); + assert.strictEqual(ids.filter((id) => id === "cmd-tag-2").length, 1); }); test("removeTagFromCommand removes junction record", () => { registerCommand({ handle, commandId: "cmd-tag-3", contentHash: "h3" }); addTagToCommand({ handle, commandId: "cmd-tag-3", tagName: "test" }); - const removeResult = removeTagFromCommand({ + removeTagFromCommand({ handle, commandId: "cmd-tag-3", tagName: "test", }); - assert.ok(removeResult.ok); const ids = getCommandIdsByTag({ handle, tagName: "test" }); - assert.ok(ids.ok); - assert.ok(!ids.value.includes("cmd-tag-3")); + assert.ok(!ids.includes("cmd-tag-3")); }); test("removeTagFromCommand succeeds for non-existent tag", () => { registerCommand({ handle, commandId: "cmd-tag-4", contentHash: "h4" }); - const result = removeTagFromCommand({ + removeTagFromCommand({ handle, commandId: "cmd-tag-4", tagName: "nonexistent", }); - assert.ok(result.ok); }); test("getAllTagNames returns all distinct tags", () => { @@ -136,9 +131,8 @@ suite("DB Unit Tests", () => { addTagToCommand({ handle, commandId: "cmd-tags-5", tagName: "beta" }); const result = getAllTagNames(handle); - assert.ok(result.ok); - assert.ok(result.value.includes("alpha")); - assert.ok(result.value.includes("beta")); + assert.ok(result.includes("alpha")); + assert.ok(result.includes("beta")); }); }); diff --git a/src/test/e2e/quicktasks.e2e.test.ts b/src/test/e2e/quicktasks.e2e.test.ts index d422034..c5f5b6c 100644 --- a/src/test/e2e/quicktasks.e2e.test.ts +++ b/src/test/e2e/quicktasks.e2e.test.ts @@ -74,15 +74,13 @@ suite("Quick Launch E2E Tests (SQLite Junction Table)", () => { await sleep(1000); // Verify stored in database with 'quick' tag - const dbResult = getDb(); - assert.ok(dbResult.ok, "Database must be available"); + const handle = getDb(); - const tagsResult = getTagsForCommand({ - handle: dbResult.value, + const tags = getTagsForCommand({ + handle, commandId: task.id, }); - assert.ok(tagsResult.ok, "Should get tags for command"); - assert.ok(tagsResult.value.includes(QUICK_TAG), `Task ${task.id} should have 'quick' tag in database`); + assert.ok(tags.includes(QUICK_TAG), `Task ${task.id} should have 'quick' tag in database`); // Verify the Quick Launch tree view shows the task const quickItems = quickProvider.getChildren(); @@ -112,15 +110,14 @@ suite("Quick Launch E2E Tests (SQLite Junction Table)", () => { await vscode.commands.executeCommand("commandtree.addToQuick", addItem); await sleep(1000); - const dbResult = getDb(); - assert.ok(dbResult.ok, "Database must be available"); + const handle = getDb(); // Verify quick tag exists - let tagsResult = getTagsForCommand({ - handle: dbResult.value, + let tags = getTagsForCommand({ + handle, commandId: task.id, }); - assert.ok(tagsResult.ok && tagsResult.value.includes(QUICK_TAG), "Quick tag should exist before removal"); + assert.ok(tags.includes(QUICK_TAG), "Quick tag should exist before removal"); // Remove from quick via UI const removeItem = createCommandNode(task); @@ -128,12 +125,11 @@ suite("Quick Launch E2E Tests (SQLite Junction Table)", () => { await sleep(1000); // Verify junction record removed - tagsResult = getTagsForCommand({ - handle: dbResult.value, + tags = getTagsForCommand({ + handle, commandId: task.id, }); - assert.ok(tagsResult.ok, "Should get tags for command"); - assert.ok(!tagsResult.value.includes(QUICK_TAG), `Task ${task.id} should NOT have 'quick' tag after removal`); + assert.ok(!tags.includes(QUICK_TAG), `Task ${task.id} should NOT have 'quick' tag after removal`); // Verify tree view no longer shows the task const quickItemsAfterRemoval = quickProvider.getChildren(); @@ -166,16 +162,12 @@ suite("Quick Launch E2E Tests (SQLite Junction Table)", () => { await sleep(1000); // Verify order in database - const dbResult = getDb(); - assert.ok(dbResult.ok, "Database must be available"); + const handle = getDb(); - const orderedIdsResult = getCommandIdsByTag({ - handle: dbResult.value, + const orderedIds = getCommandIdsByTag({ + handle, tagName: QUICK_TAG, }); - assert.ok(orderedIdsResult.ok, "Should get ordered command IDs"); - - const orderedIds = orderedIdsResult.value; const index1 = orderedIds.indexOf(task1.id); const index2 = orderedIds.indexOf(task2.id); const index3 = orderedIds.indexOf(task3.id); @@ -222,15 +214,13 @@ suite("Quick Launch E2E Tests (SQLite Junction Table)", () => { await vscode.commands.executeCommand("commandtree.addToQuick", item); await sleep(1000); - const dbResult = getDb(); - assert.ok(dbResult.ok, "Database must be available"); + const handle = getDb(); - const initialIdsResult = getCommandIdsByTag({ - handle: dbResult.value, + const initialIds = getCommandIdsByTag({ + handle, tagName: QUICK_TAG, }); - assert.ok(initialIdsResult.ok, "Should get command IDs"); - const initialCount = initialIdsResult.value.filter((id) => id === task.id).length; + const initialCount = initialIds.filter((id) => id === task.id).length; assert.strictEqual(initialCount, 1, "Should have exactly one instance of task"); // Try to add again (should be ignored by INSERT OR IGNORE) @@ -238,12 +228,11 @@ suite("Quick Launch E2E Tests (SQLite Junction Table)", () => { await vscode.commands.executeCommand("commandtree.addToQuick", item2); await sleep(1000); - const afterIdsResult = getCommandIdsByTag({ - handle: dbResult.value, + const afterIds = getCommandIdsByTag({ + handle, tagName: QUICK_TAG, }); - assert.ok(afterIdsResult.ok, "Should get command IDs"); - const afterCount = afterIdsResult.value.filter((id) => id === task.id).length; + const afterCount = afterIds.filter((id) => id === task.id).length; assert.strictEqual(afterCount, 1, "Should still have exactly one instance (no duplicates)"); // Clean up @@ -276,17 +265,14 @@ suite("Quick Launch E2E Tests (SQLite Junction Table)", () => { await sleep(1000); // Check database directly for display_order values - const dbResult = getDb(); - assert.ok(dbResult.ok, "Database must be available"); + const handle = getDb(); - const orderedIdsResult = getCommandIdsByTag({ - handle: dbResult.value, + const orderedIds = getCommandIdsByTag({ + handle, tagName: QUICK_TAG, }); - assert.ok(orderedIdsResult.ok, "Should get ordered IDs"); // Verify tasks appear in insertion order - const orderedIds = orderedIdsResult.value; for (let i = 0; i < tasks.length; i++) { const task = tasks[i]; if (task !== undefined) { diff --git a/src/test/e2e/tagconfig.e2e.test.ts b/src/test/e2e/tagconfig.e2e.test.ts index 58d3254..3db4671 100644 --- a/src/test/e2e/tagconfig.e2e.test.ts +++ b/src/test/e2e/tagconfig.e2e.test.ts @@ -40,16 +40,14 @@ suite("Junction Table Tagging E2E Tests", () => { await sleep(500); // Verify tag stored in database with exact command ID - const dbResult = getDb(); - assert.ok(dbResult.ok, "Database must be available"); + const handle = getDb(); - const tagsResult = getTagsForCommand({ - handle: dbResult.value, + const tags = getTagsForCommand({ + handle, commandId: task.id, }); - assert.ok(tagsResult.ok, "Should get tags for command"); - assert.ok(tagsResult.value.length > 0, "Task should have at least one tag"); - assert.ok(tagsResult.value.includes(testTag), `Task should have tag "${testTag}"`); + assert.ok(tags.length > 0, "Task should have at least one tag"); + assert.ok(tags.includes(testTag), `Task should have tag "${testTag}"`); // Verify getAllTags includes the new tag (exercises CommandTreeProvider.getAllTags + TagConfig.getTagNames) const allTags = treeProvider.getAllTags(); @@ -74,28 +72,26 @@ suite("Junction Table Tagging E2E Tests", () => { await vscode.commands.executeCommand("commandtree.addTag", task, testTag); await sleep(500); - const dbResult = getDb(); - assert.ok(dbResult.ok, "Database must be available"); + const handle = getDb(); // Verify tag exists - let tagsResult = getTagsForCommand({ - handle: dbResult.value, + let tags = getTagsForCommand({ + handle, commandId: task.id, }); - assert.ok(tagsResult.ok && tagsResult.value.length > 0, "Tag should exist before removal"); - assert.ok(tagsResult.value.includes(testTag), `Task should have tag "${testTag}"`); + assert.ok(tags.length > 0, "Tag should exist before removal"); + assert.ok(tags.includes(testTag), `Task should have tag "${testTag}"`); // Remove tag via UI await vscode.commands.executeCommand("commandtree.removeTag", task, testTag); await sleep(500); // Verify tag removed from database - tagsResult = getTagsForCommand({ - handle: dbResult.value, + tags = getTagsForCommand({ + handle, commandId: task.id, }); - assert.ok(tagsResult.ok, "Should get tags for command"); - assert.ok(!tagsResult.value.includes(testTag), `Tag "${testTag}" should be removed from command ${task.id}`); + assert.ok(!tags.includes(testTag), `Tag "${testTag}" should be removed from command ${task.id}`); }); // SPEC: database-schema/command-tags-junction @@ -112,26 +108,24 @@ suite("Junction Table Tagging E2E Tests", () => { await vscode.commands.executeCommand("commandtree.addTag", task, testTag); await sleep(500); - const dbResult = getDb(); - assert.ok(dbResult.ok, "Database must be available"); + const handle = getDb(); - const tagsResult1 = getTagsForCommand({ - handle: dbResult.value, + const tags1 = getTagsForCommand({ + handle, commandId: task.id, }); - assert.ok(tagsResult1.ok && tagsResult1.value.length > 0, "Should have one tag"); - const initialCount = tagsResult1.value.length; + assert.ok(tags1.length > 0, "Should have one tag"); + const initialCount = tags1.length; // Try to add same tag again (should be ignored by INSERT OR IGNORE) await vscode.commands.executeCommand("commandtree.addTag", task, testTag); await sleep(500); - const tagsResult2 = getTagsForCommand({ - handle: dbResult.value, + const tags2 = getTagsForCommand({ + handle, commandId: task.id, }); - assert.ok(tagsResult2.ok, "Should get tags for command"); - assert.strictEqual(tagsResult2.value.length, initialCount, "Tag count should not increase when adding duplicate"); + assert.strictEqual(tags2.length, initialCount, "Tag count should not increase when adding duplicate"); // Clean up await vscode.commands.executeCommand("commandtree.removeTag", task, testTag); @@ -156,17 +150,14 @@ suite("Junction Table Tagging E2E Tests", () => { await sleep(500); // Verify database has exact ID for task1 only - const dbResult = getDb(); - assert.ok(dbResult.ok, "Database must be available"); + const handle = getDb(); - const commandIdsResult = getCommandIdsByTag({ - handle: dbResult.value, + const taggedIds = getCommandIdsByTag({ + handle, tagName: testTag, }); - assert.ok(commandIdsResult.ok, "Should get command IDs for tag"); - assert.ok(commandIdsResult.value.length > 0, "Should have at least one tagged command"); - const taggedIds = commandIdsResult.value; + assert.ok(taggedIds.length > 0, "Should have at least one tagged command"); assert.ok(taggedIds.includes(task1.id), `Tagged IDs should include task1 (${task1.id})`); assert.ok(!taggedIds.includes(task2.id), `Tagged IDs should NOT include task2 (${task2.id})`); @@ -192,21 +183,18 @@ suite("Junction Table Tagging E2E Tests", () => { } // Verify pattern matching: "scripts" tag applies to shell tasks (type: "shell" pattern) - const dbResult = getDb(); - assert.ok(dbResult.ok, "Database must be available"); - const scriptsResult = getCommandIdsByTag({ - handle: dbResult.value, + const handle = getDb(); + const scriptsIds = getCommandIdsByTag({ + handle, tagName: "scripts", }); - assert.ok(scriptsResult.ok, "Should get command IDs for scripts tag"); - assert.ok(scriptsResult.value.length > 0, "scripts tag should match shell commands"); + assert.ok(scriptsIds.length > 0, "scripts tag should match shell commands"); // Verify "debug" tag applies to launch configs (type: "launch" pattern) - const debugResult = getCommandIdsByTag({ - handle: dbResult.value, + const debugIds = getCommandIdsByTag({ + handle, tagName: "debug", }); - assert.ok(debugResult.ok, "Should get command IDs for debug tag"); - assert.ok(debugResult.value.length > 0, "debug tag should match launch configs"); + assert.ok(debugIds.length > 0, "debug tag should match launch configs"); }); }); diff --git a/src/test/e2e/treeview.e2e.test.ts b/src/test/e2e/treeview.e2e.test.ts index fa6c114..979b22f 100644 --- a/src/test/e2e/treeview.e2e.test.ts +++ b/src/test/e2e/treeview.e2e.test.ts @@ -7,7 +7,15 @@ */ import * as assert from "assert"; -import { activateExtension, sleep, getCommandTreeProvider, getLabelString, collectLeafTasks } from "../helpers/helpers"; +import * as vscode from "vscode"; +import { + activateExtension, + sleep, + getCommandTreeProvider, + getLabelString, + collectLeafTasks, + collectLeafItems, +} from "../helpers/helpers"; import { type CommandTreeItem, isCommandItem } from "../../models/TaskItem"; // TODO: No corresponding section in spec @@ -142,6 +150,62 @@ suite("TreeView E2E Tests", () => { }); }); + suite("Make Target Line Navigation", () => { + test("clicking a make target opens the Makefile at the target's line", async function () { + this.timeout(15000); + const provider = getCommandTreeProvider(); + const allItems = await collectLeafItems(provider); + const makeItems = allItems.filter((i) => isCommandItem(i.data) && i.data.type === "make"); + assert.ok(makeItems.length > 0, "Should discover at least one make target"); + + for (const item of makeItems) { + assert.ok(isCommandItem(item.data), "Item data must be a CommandItem"); + assert.ok(item.data.line !== undefined, `Make target "${item.data.label}" must have a line number`); + assert.ok(item.data.line > 0, `Make target "${item.data.label}" line must be positive`); + + assert.ok(item.command !== undefined, "Make target must have a click command"); + assert.strictEqual(item.command.command, "vscode.open", "Click must use vscode.open"); + const args = item.command.arguments; + assert.ok(args !== undefined && args.length === 2, "Click command must have URI and options arguments"); + + const uri = args[0] as vscode.Uri; + assert.ok(uri.fsPath.endsWith("Makefile"), "URI must point to a Makefile"); + + const options = args[1] as { selection: vscode.Range }; + assert.ok(options.selection !== undefined, "Options must include a selection range"); + assert.strictEqual( + options.selection.start.line, + item.data.line - 1, + `Selection must start at line ${item.data.line - 1} (0-indexed) for target "${item.data.label}"` + ); + } + }); + + test("make targets have correct line numbers matching the Makefile", async function () { + this.timeout(15000); + const provider = getCommandTreeProvider(); + const allTasks = await collectLeafTasks(provider); + const makeTasks = allTasks.filter((t) => t.type === "make"); + + // Verify specific targets from the fixture Makefile + const allTarget = makeTasks.find((t) => t.label === "all"); + assert.ok(allTarget !== undefined, "Should find 'all' target"); + assert.strictEqual(allTarget.line, 3, "'all' target is on line 3 of the fixture Makefile"); + + const buildTarget = makeTasks.find((t) => t.label === "build"); + assert.ok(buildTarget !== undefined, "Should find 'build' target"); + assert.strictEqual(buildTarget.line, 5, "'build' target is on line 5 of the fixture Makefile"); + + const testTarget = makeTasks.find((t) => t.label === "test"); + assert.ok(testTarget !== undefined, "Should find 'test' target"); + assert.strictEqual(testTarget.line, 8, "'test' target is on line 8 of the fixture Makefile"); + + const cleanTarget = makeTasks.find((t) => t.label === "clean"); + assert.ok(cleanTarget !== undefined, "Should find 'clean' target"); + assert.strictEqual(cleanTarget.line, 11, "'clean' target is on line 11 of the fixture Makefile"); + }); + }); + suite("AI Summaries", () => { test("@exclude-ci Copilot summarisation produces summaries for discovered tasks", async function () { this.timeout(15000); diff --git a/src/tree/nodeFactory.ts b/src/tree/nodeFactory.ts index d2c6193..0fdd319 100644 --- a/src/tree/nodeFactory.ts +++ b/src/tree/nodeFactory.ts @@ -67,7 +67,10 @@ export function createCommandNode(task: CommandItem): CommandTreeItem { command: { command: "vscode.open", title: "Open File", - arguments: [vscode.Uri.file(task.filePath)], + arguments: + task.line !== undefined + ? [vscode.Uri.file(task.filePath), { selection: new vscode.Range(task.line - 1, 0, task.line - 1, 0) }] + : [vscode.Uri.file(task.filePath)], }, }); } diff --git a/src/utils/fileUtils.ts b/src/utils/fileUtils.ts index 1efb7e8..bc065e0 100644 --- a/src/utils/fileUtils.ts +++ b/src/utils/fileUtils.ts @@ -10,9 +10,8 @@ export async function readFile(uri: vscode.Uri): Promise> try { const bytes = await vscode.workspace.fs.readFile(uri); return ok(new TextDecoder().decode(bytes)); - } /* istanbul ignore next -- VS Code FS API does not throw in test environment */ catch (e) { - const message = e instanceof Error ? e.message : "Unknown error reading file"; - return err(message); + } catch (e) { + return err((e as Error).message); } } diff --git a/src/utils/logger.ts b/src/utils/logger.ts index 36bf430..e40d54d 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -6,19 +6,11 @@ import * as vscode from "vscode"; */ class Logger { private readonly channel: vscode.OutputChannel; - private enabled = true; public constructor() { this.channel = vscode.window.createOutputChannel("CommandTree Debug"); } - /** - * Enables or disables logging - */ - public setEnabled(enabled: boolean): void { - this.enabled = enabled; - } - /** * Shows the output channel */ @@ -30,10 +22,6 @@ class Logger { * Logs an info message */ public info(message: string, data?: unknown): void { - /* istanbul ignore if -- logger is always enabled during tests */ - if (!this.enabled) { - return; - } const timestamp = new Date().toISOString(); const logLine = data !== undefined @@ -46,10 +34,6 @@ class Logger { * Logs a warning message */ public warn(message: string, data?: unknown): void { - /* istanbul ignore if -- logger is always enabled during tests */ - if (!this.enabled) { - return; - } const timestamp = new Date().toISOString(); const logLine = data !== undefined @@ -62,10 +46,6 @@ class Logger { * Logs an error message */ public error(message: string, data?: unknown): void { - /* istanbul ignore if -- logger is always enabled during tests */ - if (!this.enabled) { - return; - } const timestamp = new Date().toISOString(); const logLine = data !== undefined @@ -78,15 +58,10 @@ class Logger { * Logs filter operations */ public filter(operation: string, details: Record): void { - /* istanbul ignore if -- logger is always enabled during tests */ - if (!this.enabled) { - return; - } const timestamp = new Date().toISOString(); const detailsStr = JSON.stringify(details); this.channel.appendLine(`[${timestamp}] FILTER: ${operation} | ${detailsStr}`); } } -// Singleton instance export const logger = new Logger(); From 25a1c97ee7ab6b9fa111a63aa5a2629ed37bebda Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Mon, 6 Apr 2026 10:26:06 +1000 Subject: [PATCH 16/25] tests etc. --- src/db/db.ts | 8 +-- src/discovery/python.ts | 10 +-- src/test/e2e/treeview.e2e.test.ts | 110 ++++++++++++++++++------------ src/utils/fileUtils.ts | 6 +- 4 files changed, 75 insertions(+), 59 deletions(-) diff --git a/src/db/db.ts b/src/db/db.ts index b4ed562..e3ed1dc 100644 --- a/src/db/db.ts +++ b/src/db/db.ts @@ -209,10 +209,10 @@ export function getAllRows(handle: DbHandle): CommandRow[] { function rawToCommandRow(row: RawRow): CommandRow { return { commandId: row["command_id"] as string, - contentHash: (row["content_hash"] as string) ?? "", - summary: (row["summary"] as string) ?? "", - securityWarning: (row["security_warning"] as string | null) ?? null, - lastUpdated: (row["last_updated"] as string) ?? "", + contentHash: row["content_hash"] as string, + summary: row["summary"] as string, + securityWarning: row["security_warning"] as string | null, + lastUpdated: row["last_updated"] as string, }; } diff --git a/src/discovery/python.ts b/src/discovery/python.ts index 6220e21..18e953c 100644 --- a/src/discovery/python.ts +++ b/src/discovery/python.ts @@ -192,10 +192,7 @@ function extractArgName(argsStr: string): string | undefined { if (firstQuote < 0) { return undefined; } - const quote = argsStr[firstQuote]; - if (quote === undefined) { - return undefined; - } + const quote = argsStr[firstQuote]!; const endQuote = argsStr.indexOf(quote, firstQuote + 1); if (endQuote < 0) { return undefined; @@ -234,10 +231,7 @@ function extractHelpText(argsStr: string): string | undefined { if (quoteStart < 0) { return undefined; } - const quote = afterHelp[quoteStart]; - if (quote === undefined) { - return undefined; - } + const quote = afterHelp[quoteStart]!; const endQuote = afterHelp.indexOf(quote, quoteStart + 1); if (endQuote < 0) { return undefined; diff --git a/src/test/e2e/treeview.e2e.test.ts b/src/test/e2e/treeview.e2e.test.ts index 979b22f..0377b30 100644 --- a/src/test/e2e/treeview.e2e.test.ts +++ b/src/test/e2e/treeview.e2e.test.ts @@ -13,8 +13,8 @@ import { sleep, getCommandTreeProvider, getLabelString, - collectLeafTasks, collectLeafItems, + collectLeafTasks, } from "../helpers/helpers"; import { type CommandTreeItem, isCommandItem } from "../../models/TaskItem"; @@ -150,59 +150,81 @@ suite("TreeView E2E Tests", () => { }); }); + /** + * Executes a tree item's click command (simulates what VS Code does on click). + */ + async function executeItemClick(item: CommandTreeItem): Promise { + assert.ok(item.command !== undefined, "Item must have a click command"); + const args = (item.command.arguments ?? []) as [vscode.Uri, ...unknown[]]; + await vscode.commands.executeCommand(item.command.command, ...args); + } + suite("Make Target Line Navigation", () => { - test("clicking a make target opens the Makefile at the target's line", async function () { - this.timeout(15000); + test("clicking 'build' make target opens Makefile at the build target line, not the top", async function () { + this.timeout(20000); const provider = getCommandTreeProvider(); const allItems = await collectLeafItems(provider); - const makeItems = allItems.filter((i) => isCommandItem(i.data) && i.data.type === "make"); - assert.ok(makeItems.length > 0, "Should discover at least one make target"); - - for (const item of makeItems) { - assert.ok(isCommandItem(item.data), "Item data must be a CommandItem"); - assert.ok(item.data.line !== undefined, `Make target "${item.data.label}" must have a line number`); - assert.ok(item.data.line > 0, `Make target "${item.data.label}" line must be positive`); - - assert.ok(item.command !== undefined, "Make target must have a click command"); - assert.strictEqual(item.command.command, "vscode.open", "Click must use vscode.open"); - const args = item.command.arguments; - assert.ok(args !== undefined && args.length === 2, "Click command must have URI and options arguments"); - - const uri = args[0] as vscode.Uri; - assert.ok(uri.fsPath.endsWith("Makefile"), "URI must point to a Makefile"); - - const options = args[1] as { selection: vscode.Range }; - assert.ok(options.selection !== undefined, "Options must include a selection range"); - assert.strictEqual( - options.selection.start.line, - item.data.line - 1, - `Selection must start at line ${item.data.line - 1} (0-indexed) for target "${item.data.label}"` - ); - } + const buildItem = allItems.find((i) => isCommandItem(i.data) && i.data.type === "make" && i.data.label === "build"); + assert.ok(buildItem !== undefined, "Should find 'build' make target in tree"); + // Execute the click command — this is what happens when the user taps the item + await executeItemClick(buildItem); + await sleep(1000); + + // The editor must now be open on the Makefile + const editor = vscode.window.activeTextEditor; + assert.ok(editor !== undefined, "An editor must be open after clicking the make target"); + assert.ok(editor.document.uri.fsPath.endsWith("Makefile"), "The open file must be the Makefile"); + + // The cursor must be on the build target line (line 5 in fixture, 0-indexed = 4) + const cursorLine = editor.selection.active.line; + assert.strictEqual(cursorLine, 4, "Cursor must be on line 4 (0-indexed) where 'build:' is defined — not line 0"); }); - test("make targets have correct line numbers matching the Makefile", async function () { - this.timeout(15000); + test("clicking 'clean' make target navigates to a different line than 'build'", async function () { + this.timeout(20000); const provider = getCommandTreeProvider(); - const allTasks = await collectLeafTasks(provider); - const makeTasks = allTasks.filter((t) => t.type === "make"); + const allItems = await collectLeafItems(provider); + const cleanItem = allItems.find((i) => isCommandItem(i.data) && i.data.type === "make" && i.data.label === "clean"); + assert.ok(cleanItem !== undefined, "Should find 'clean' make target in tree"); + await executeItemClick(cleanItem); + await sleep(1000); + + const editor = vscode.window.activeTextEditor; + assert.ok(editor !== undefined, "An editor must be open after clicking the make target"); + assert.ok(editor.document.uri.fsPath.endsWith("Makefile"), "The open file must be the Makefile"); + + // clean: is on line 11 in the fixture (0-indexed = 10) + const cursorLine = editor.selection.active.line; + assert.strictEqual(cursorLine, 10, "Cursor must be on line 10 (0-indexed) where 'clean:' is defined"); + }); - // Verify specific targets from the fixture Makefile - const allTarget = makeTasks.find((t) => t.label === "all"); - assert.ok(allTarget !== undefined, "Should find 'all' target"); - assert.strictEqual(allTarget.line, 3, "'all' target is on line 3 of the fixture Makefile"); + test("each make target click navigates to its own line — not all the same line", async function () { + this.timeout(30000); + const provider = getCommandTreeProvider(); + const allItems = await collectLeafItems(provider); + const makeItems = allItems.filter((i) => isCommandItem(i.data) && i.data.type === "make"); + assert.ok(makeItems.length >= 3, "Should have at least 3 make targets to compare"); - const buildTarget = makeTasks.find((t) => t.label === "build"); - assert.ok(buildTarget !== undefined, "Should find 'build' target"); - assert.strictEqual(buildTarget.line, 5, "'build' target is on line 5 of the fixture Makefile"); + const lines: number[] = []; + for (const item of makeItems) { + await executeItemClick(item); + await sleep(500); - const testTarget = makeTasks.find((t) => t.label === "test"); - assert.ok(testTarget !== undefined, "Should find 'test' target"); - assert.strictEqual(testTarget.line, 8, "'test' target is on line 8 of the fixture Makefile"); + const editor = vscode.window.activeTextEditor; + assert.ok(editor !== undefined, "Editor must be open"); + lines.push(editor.selection.active.line); + } - const cleanTarget = makeTasks.find((t) => t.label === "clean"); - assert.ok(cleanTarget !== undefined, "Should find 'clean' target"); - assert.strictEqual(cleanTarget.line, 11, "'clean' target is on line 11 of the fixture Makefile"); + // If all targets opened at the top of the file, all lines would be 0 + const uniqueLines = new Set(lines); + assert.ok( + uniqueLines.size > 1, + `Each make target must navigate to its own line — got ${JSON.stringify(lines)} (all same = broken)` + ); + assert.ok( + !lines.every((l) => l === 0), + "Make targets must NOT all open at line 0 — line navigation is broken" + ); }); }); diff --git a/src/utils/fileUtils.ts b/src/utils/fileUtils.ts index bc065e0..8122c02 100644 --- a/src/utils/fileUtils.ts +++ b/src/utils/fileUtils.ts @@ -42,10 +42,10 @@ function handleStringChar(state: ParserState): boolean { if (!state.inString) { return false; } - const ch = state.content[state.pos] ?? ""; + const ch = state.content[state.pos]!; state.out.push(ch); if (ch === "\\") { - state.out.push(state.content[state.pos + 1] ?? ""); + state.out.push(state.content[state.pos + 1]!); state.pos += 2; return true; } @@ -77,7 +77,7 @@ function handleNonStringChar(state: ParserState): void { state.pos = skipUntilBlockEnd(state.content, state.pos); return; } - state.out.push(ch ?? ""); + state.out.push(ch as string); state.pos++; } From a9f5b95036dd035a936a783045f3712fd64a625e Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Mon, 6 Apr 2026 10:46:27 +1000 Subject: [PATCH 17/25] Repo standardisation --- .claude/settings.local.json | 4 +- .claude/skills/ci-prep/SKILL.md | 118 ++++--- .claude/skills/code-dedup/SKILL.md | 104 +++++++ .claude/skills/spec-check/SKILL.md | 205 +++++++++++++ .claude/skills/submit-pr/SKILL.md | 82 ++--- .claude/skills/upgrade-packages/SKILL.md | 80 +++++ .clinerules/00-read-instructions.md | 2 + .cursorrules | 2 + .github/copilot-instructions.md | 2 + .../{deploy-website.yml => deploy-pages.yml} | 24 +- .github/workflows/deploy.yml | 29 -- .github/workflows/release.yml | 42 +++ .gitignore | 92 ++++-- .prettierrc | 7 - .prettierrc.json | 13 + .vscode/settings.json | 8 +- .windsurfrules | 2 + Agents.md | 1 + Claude.md | 288 +++++++++--------- Makefile | 144 ++++++++- docs/{ => plans}/RUST-LSP-PLAN.md | 0 docs/{ => specs}/RUST-LSP-SPEC.md | 0 docs/{ => specs}/SPEC.md | 0 docs/{ => specs}/ai-summaries.md | 0 docs/{ => specs}/database.md | 0 docs/{ => specs}/discovery.md | 17 ++ docs/{ => specs}/execution.md | 0 docs/{ => specs}/extension.md | 0 docs/{ => specs}/parameters.md | 0 docs/{ => specs}/quick-launch.md | 0 docs/{ => specs}/settings.md | 0 docs/{ => specs}/skills.md | 0 docs/{ => specs}/tagging.md | 0 docs/{ => specs}/tree-view.md | 0 docs/{ => specs}/utilities.md | 0 opencode.json | 4 + src/CommandTreeProvider.ts | 15 +- src/QuickTasksProvider.ts | 24 +- src/config/TagConfig.ts | 19 +- src/db/lifecycle.ts | 145 ++++++++- src/discovery/index.ts | 64 ++-- src/discovery/launch.ts | 43 +-- src/discovery/python.ts | 4 +- src/discovery/tasks.ts | 160 ++-------- src/extension.ts | 37 ++- src/semantic/summaryPipeline.ts | 10 +- src/tags/tagSync.ts | 7 +- src/test/e2e/quicktasks.e2e.test.ts | 10 +- src/test/e2e/tagconfig.e2e.test.ts | 12 +- .../fixtures/workspace/.vscode/launch.json | 4 + .../fixtures/workspace/.vscode/tasks.json | 11 + src/test/fixtures/workspace/build.xml | 8 + src/test/fixtures/workspace/composer.json | 5 +- src/test/fixtures/workspace/deno.json | 5 +- .../fixtures/workspace/docs/api-reference.md | 7 + src/test/fixtures/workspace/justfile | 8 + .../fixtures/workspace/scripts/analyze.py | 20 ++ src/test/fixtures/workspace/scripts/clean.sh | 6 + .../fixtures/workspace/scripts/migrate.py | 9 + src/test/fixtures/workspace/scripts/setup.ps1 | 5 + src/utils/fileUtils.ts | 10 +- 61 files changed, 1355 insertions(+), 563 deletions(-) create mode 100644 .claude/skills/code-dedup/SKILL.md create mode 100644 .claude/skills/spec-check/SKILL.md create mode 100644 .claude/skills/upgrade-packages/SKILL.md create mode 100644 .clinerules/00-read-instructions.md create mode 100644 .cursorrules create mode 100644 .github/copilot-instructions.md rename .github/workflows/{deploy-website.yml => deploy-pages.yml} (76%) delete mode 100644 .github/workflows/deploy.yml delete mode 100644 .prettierrc create mode 100644 .prettierrc.json create mode 100644 .windsurfrules rename docs/{ => plans}/RUST-LSP-PLAN.md (100%) rename docs/{ => specs}/RUST-LSP-SPEC.md (100%) rename docs/{ => specs}/SPEC.md (100%) rename docs/{ => specs}/ai-summaries.md (100%) rename docs/{ => specs}/database.md (100%) rename docs/{ => specs}/discovery.md (83%) rename docs/{ => specs}/execution.md (100%) rename docs/{ => specs}/extension.md (100%) rename docs/{ => specs}/parameters.md (100%) rename docs/{ => specs}/quick-launch.md (100%) rename docs/{ => specs}/settings.md (100%) rename docs/{ => specs}/skills.md (100%) rename docs/{ => specs}/tagging.md (100%) rename docs/{ => specs}/tree-view.md (100%) rename docs/{ => specs}/utilities.md (100%) create mode 100644 opencode.json create mode 100644 src/test/fixtures/workspace/docs/api-reference.md create mode 100644 src/test/fixtures/workspace/scripts/analyze.py create mode 100644 src/test/fixtures/workspace/scripts/clean.sh create mode 100644 src/test/fixtures/workspace/scripts/migrate.py create mode 100644 src/test/fixtures/workspace/scripts/setup.ps1 diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 92bf543..ded823d 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -18,7 +18,9 @@ "Bash(npm run:*)", "Bash(npx cspell:*)", "Bash(gh pr:*)", - "Bash(gh run:*)" + "Bash(gh run:*)", + "Bash(npm test:*)", + "Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); t=d['total']; print\\(f\\\\\"Lines: {t['lines']['pct']}%\\\\nBranches: {t['branches']['pct']}%\\\\nFunctions: {t['functions']['pct']}%\\\\nStatements: {t['statements']['pct']}%\\\\\"\\)\")" ] }, "autoMemoryEnabled": false diff --git a/.claude/skills/ci-prep/SKILL.md b/.claude/skills/ci-prep/SKILL.md index ae50d50..dd3b646 100644 --- a/.claude/skills/ci-prep/SKILL.md +++ b/.claude/skills/ci-prep/SKILL.md @@ -1,62 +1,106 @@ --- name: ci-prep -description: Prepare the codebase for CI. Reads the CI workflow, builds a checklist, then loops through format/lint/build/test/coverage until every single check passes. Use before submitting a PR or when the user wants to ensure CI will pass. -argument-hint: "[optional focus area]" -allowed-tools: Read, Grep, Glob, Edit, Write, Bash +description: Prepares the current branch for CI by running the exact same steps locally and fixing issues. If CI is already failing, fetches the GH Actions logs first to diagnose. Use before pushing, when CI is red, or when the user says "fix ci". +argument-hint: "[--failing] [optional job name to focus on]" --- + -# CI Prep — Get the Codebase PR-Ready +# CI Prep -You MUST NOT STOP until every check passes and coverage threshold is met. +Prepare the current state for CI. If CI is already failing, fetch and analyze the logs first. -## Step 1: Read the CI Pipeline and Build Your Checklist +## Arguments -Read the CI workflow file: +- `--failing` — Indicates a GitHub Actions run is already failing. When present, you MUST execute **Step 1** before doing anything else. +- Any other argument is treated as a job name to focus on (but all failures are still reported). + +If `--failing` is NOT passed, skip directly to **Step 2**. + +## Step 1 — Fetch failed CI logs (only when `--failing`) + +You MUST do this before any other work. + +```bash +BRANCH=$(git branch --show-current) +PR_JSON=$(gh pr list --head "$BRANCH" --state open --json number,title,url --limit 1) +``` + +If the JSON array is empty, **stop immediately**: +> No open PR found for branch `$BRANCH`. Create a PR first. + +Otherwise fetch the logs: ```bash -cat .github/workflows/ci.yml +PR_NUMBER=$(echo "$PR_JSON" | jq -r '.[0].number') +gh pr checks "$PR_NUMBER" +RUN_ID=$(gh run list --branch "$BRANCH" --limit 1 --json databaseId --jq '.[0].databaseId') +gh run view "$RUN_ID" +gh run view "$RUN_ID" --log-failed ``` -Parse EVERY step in the workflow. Extract the exact commands CI runs. Build yourself a numbered checklist of every check you need to pass. This is YOUR checklist — derived from the actual CI config, not from assumptions. The CI pipeline changes over time so you MUST read it fresh and build your list from what you find. +Read **every line** of `--log-failed` output. For each failure note the exact file, line, and error message. If a job name argument was provided, prioritize that job but still report all failures. -## Step 2: Coordinate with Other Agents +## Step 2 — Analyze the CI workflow -You are likely working alongside other agents who are editing files concurrently. Before making changes: +1. Find the CI workflow file. Look in `.github/workflows/` for `ci.yml`. +2. Read the workflow file completely. Parse every job and every step. +3. Extract the ordered list of commands the CI actually runs (e.g., `make fmt-check`, `make lint`, `make spellcheck`, `make test EXCLUDE_CI=true`, `make build`, `make package`). +4. Note any environment variables, matrix strategies, or conditional steps that affect execution. -1. Check TMC status and messages for active agents and locked files -2. Do NOT edit files that are locked by other agents -3. Lock files before editing them yourself -4. Communicate what you are doing via TMC broadcasts -5. After each fix cycle, check TMC again — another agent may have broken something +**Do NOT assume the steps.** Extract what the CI *actually does*. -## Step 3: The Loop +## Step 3 — Run each CI step locally, in order -Run through your checklist from Step 1 in order. For each check: +Work through failures in this priority order: -1. Run the exact command from CI -2. If it passes, move to the next check -3. If it fails, FIX IT. Do NOT suppress warnings, ignore errors, remove assertions, or lower thresholds. Fix the actual code. -4. Re-run that check to confirm the fix works -5. Move to the next check +1. **Formatting** — run `make fmt` first to clear noise +2. **Compilation errors** — must compile before lint/test +3. **Lint violations** — fix the code pattern +4. **Runtime / test failures** — fix source code to satisfy the test -When you reach the end of the checklist, GO BACK TO THE START AND RUN THE ENTIRE CHECKLIST AGAIN. Other agents are working concurrently and may have broken something you already fixed. A fix for one check may have broken an earlier check. +For each command extracted from the CI workflow: -**Keep looping through the full checklist until you get a COMPLETE CLEAN RUN with ZERO failures from start to finish.** One clean pass is not enough if you fixed anything during that pass — you need a clean pass where NOTHING needed fixing. +1. Run the command exactly as CI would run it. +2. If the step fails, **stop and fix the issues** before continuing to the next step. +3. After fixing, re-run the same step to confirm it passes. +4. Move to the next step only after the current one succeeds. -Do NOT stop after one loop. Do NOT stop after two loops. Keep going until a full pass completes with every single check green on the first try. +### Hard constraints -## Step 4: Final Coordination +- **NEVER modify test files** — fix the source code, not the tests +- **NEVER add suppressions** (`// eslint-disable`, `// @ts-ignore`) +- **NEVER use `any` in TypeScript** to silence type errors +- **NEVER delete or ignore failing tests** +- **NEVER remove assertions** -1. Broadcast on TMC that CI prep is complete and all checks pass -2. Release any locks you hold -3. Report the final status to the user with the output of each passing check +If stuck on the same failure after 5 attempts, ask the user for help. + +## Step 4 — Report + +- List every step that was run and its result (pass/fail/fixed). +- If any step could not be fixed, report what failed and why. +- Confirm whether the branch is ready to push. + +## Step 5 — Commit/Push (only when `--failing`) + +Once all CI steps pass locally: + +1. Commit, but DO NOT MARK THE COMMIT WITH YOU AS AN AUTHOR!!! Only the user authors the commit! +2. Push +3. Monitor until completion or failure +4. Upon failure, go back to Step 1 ## Rules -- NEVER stop with failing checks. Loop until everything is green. -- NEVER suppress lint warnings, skip tests, or lower coverage thresholds. -- NEVER remove assertions to make tests pass. -- Fix the CODE, not the checks. -- If you are stuck on a failure after 3 attempts on the same issue, ask the user for help. Do NOT silently give up. -- Always coordinate with other agents via TMC. Check for messages regularly. -- Leave the codebase in a state that will pass CI on the first try. +- **Always read the CI workflow first.** Never assume what commands CI runs. +- Do not push if any step fails (unless `--failing` and all steps now pass) +- Fix issues found in each step before moving to the next +- Never skip steps or suppress errors +- If the CI workflow has multiple jobs, run all of them (respecting dependency order) +- Skip steps that are CI-infrastructure-only (checkout, setup-node, cache steps, artifact uploads) — focus on the actual build/test/lint commands + +## Success criteria + +- Every command that CI runs has been executed locally and passed +- All fixes are applied to the working tree +- The CI passes successfully (if you are correcting an existing failure) diff --git a/.claude/skills/code-dedup/SKILL.md b/.claude/skills/code-dedup/SKILL.md new file mode 100644 index 0000000..4d5ddab --- /dev/null +++ b/.claude/skills/code-dedup/SKILL.md @@ -0,0 +1,104 @@ +--- +name: code-dedup +description: Searches for duplicate code, duplicate tests, and dead code, then safely merges or removes them. Use when the user says "deduplicate", "find duplicates", "remove dead code", "DRY up", or "code dedup". Requires test coverage — refuses to touch untested code. +--- + + +# Code Dedup + +Carefully search for duplicate code, duplicate tests, and dead code across the repo. Merge duplicates and delete dead code — but only when test coverage proves the change is safe. + +## Prerequisites — hard gate + +Before touching ANY code, verify these conditions. If any fail, stop and report why. + +1. Run `make test` — all tests must pass. If tests fail, stop. Do not dedup a broken codebase. +2. Run `make coverage-check` — coverage must meet the repo's threshold. If it doesn't, stop. +3. Verify the project uses **static typing**. Check `tsconfig.json` has `"strict": true` — proceed. + +## Steps + +Copy this checklist and track progress: + +``` +Dedup Progress: +- [ ] Step 1: Prerequisites passed (tests green, coverage met, typed) +- [ ] Step 2: Dead code scan complete +- [ ] Step 3: Duplicate code scan complete +- [ ] Step 4: Duplicate test scan complete +- [ ] Step 5: Changes applied +- [ ] Step 6: Verification passed (tests green, coverage stable) +``` + +### Step 1 — Inventory test coverage + +Before deciding what to touch, understand what is tested. + +1. Run `make test` and `make coverage-check` to confirm green baseline +2. Note the current coverage percentage — this is the floor. It must not drop. +3. Identify which files/modules have coverage and which do not. Only files WITH coverage are candidates for dedup. + +### Step 2 — Scan for dead code + +Search for code that is never called, never imported, never referenced. + +1. Look for unused exports, unused functions, unused classes, unused variables +2. Check for `noUnusedLocals`/`noUnusedParameters` in tsconfig, look for unexported functions with zero references +3. For each candidate: **grep the entire codebase** for references (including tests, scripts, configs). Only mark as dead if truly zero references. +4. List all dead code found with file paths and line numbers. Do NOT delete yet. + +### Step 3 — Scan for duplicate code + +Search for code blocks that do the same thing in multiple places. + +1. Look for functions/methods with identical or near-identical logic +2. Look for copy-pasted blocks (same structure, maybe different variable names) +3. Look for multiple implementations of the same algorithm or pattern +4. Check across module boundaries — duplicates often hide in different packages +5. For each duplicate pair: note both locations, what they do, and how they differ (if at all) +6. List all duplicates found. Do NOT merge yet. + +### Step 4 — Scan for duplicate tests + +Search for tests that verify the same behavior. + +1. Look for test functions with identical assertions against the same code paths +2. Look for test fixtures/helpers that are duplicated across test files +3. Look for integration tests that fully cover what a unit test also covers (keep the integration test, mark the unit test as redundant) +4. List all duplicate tests found. Do NOT delete yet. + +### Step 5 — Apply changes (one at a time) + +For each change, follow this cycle: **change → test → verify coverage → continue or revert**. + +#### 5a. Remove dead code +- Delete dead code identified in Step 2 +- After each deletion: run `make test` and `make coverage-check` +- If tests fail or coverage drops: **revert immediately** and investigate + +#### 5b. Merge duplicate code +- For each duplicate pair: extract the shared logic into a single function/module +- Update all call sites to use the shared version +- After each merge: run `make test` and `make coverage-check` +- If tests fail: **revert immediately** + +#### 5c. Remove duplicate tests +- Delete the redundant test (keep the more thorough one) +- After each deletion: run `make coverage-check` +- If coverage drops: **revert immediately** + +### Step 6 — Final verification + +1. Run `make test` — all tests must still pass +2. Run `make coverage-check` — coverage must be >= the baseline from Step 1 +3. Run `make lint` and `make fmt-check` — code must be clean +4. Report: what was removed, what was merged, final coverage vs baseline + +## Rules + +- **No test coverage = do not touch.** If a file has no tests covering it, leave it alone entirely. +- **Coverage must not drop.** The coverage floor from Step 1 is sacred. +- **One change at a time.** Make one dedup change, run tests, verify coverage. Never batch. +- **When in doubt, leave it.** If two code blocks look similar but you're not 100% sure they're functionally identical, leave both. +- **Preserve public API surface.** Do not change function signatures, class names, or module exports that external code depends on. +- **Three similar lines is fine.** Only dedup when the shared logic is substantial (>10 lines) or when there are 3+ copies. diff --git a/.claude/skills/spec-check/SKILL.md b/.claude/skills/spec-check/SKILL.md new file mode 100644 index 0000000..5d4aedc --- /dev/null +++ b/.claude/skills/spec-check/SKILL.md @@ -0,0 +1,205 @@ +--- +name: spec-check +description: Audit spec/plan documents against the codebase. Ensures every spec section has implementing code, tests, and matching logic. Use when the user says "check specs", "spec audit", or "verify specs". +argument-hint: "[optional spec ID or filename filter]" +--- + + +# spec-check + +> **Portable skill.** This skill adapts to the current repository. The agent MUST inspect the repo structure and use judgment to apply these instructions appropriately. + +Audit spec/plan documents against the codebase. Ensures every spec section has implementing code, tests, and that the code logic matches the spec. + +## Arguments + +- `$ARGUMENTS` — optional spec name or ID to check (e.g., `AUTH-TOKEN-VERIFY` or `repo-standards`). If empty, check ALL specs. Spec IDs are descriptive slugs, NEVER numbered (see Step 1). + +## Instructions + +Follow these steps exactly. Be strict and pedantic. Stop on the first failure. + +--- + +### Step 1: Validate spec ID structure + +Before checking code/test references, verify that the specs themselves are well-formed. + +1. Find all spec documents (see locations in Step 2). +2. Extract every section ID using the regex `\[([A-Z][A-Z0-9]*(-[A-Z0-9]+)+)\]`. +3. **Flag invalid IDs:** + - Numbered IDs (`[SPEC-001]`, `[REQ-003]`, `[CI-004]`) — must be renamed to descriptive hierarchical slugs. + - Single-word IDs (`[TIMEOUT]`) — must have a group prefix. + - IDs with trailing numbers (`[FEAT-AUTH-01]`) — the number is meaningless, remove it. +4. **Check group clustering:** The first word of each ID is its group. All sections in the same group MUST appear together (adjacent) in the document. If they're scattered, flag it. +5. **Check for missing IDs:** Any heading that defines a requirement or behavior should have an ID. Flag headings in spec files that look like they define behavior but lack an ID. + +If any ID violations are found, report them all and **STOP**: +``` +SPEC ID VIOLATIONS: + +- docs/specs/AUTH-SPEC.md line 12: [SPEC-001] → rename to descriptive ID (e.g., [AUTH-LOGIN]) +- docs/specs/AUTH-SPEC.md line 30: [AUTH-TOKEN-VERIFY] and [AUTH-LOGIN] are not adjacent (scattered group) +- docs/specs/CI-SPEC.md line 5: "## Coverage thresholds" has no spec ID + +Fix spec IDs first, then re-run spec-check. +``` + +If all IDs are valid, proceed to Step 2. + +--- + +### Step 2: Find all spec/plan documents + +Search for markdown files that contain spec sections with IDs. Look in these locations: + +- `docs/*.md` +- `docs/**/*.md` +- `SPEC.md` +- `PLAN.md` +- `specs/*.md` + +Use Glob to find candidate files, then use Grep to confirm they contain spec IDs. + +**Spec ID patterns** — IDs appear in square brackets, typically at the start of a heading or section line. Match this regex pattern: + +``` +\[([A-Z][A-Z0-9]*(-[A-Z0-9]+)+)\] +``` + +Spec IDs are **hierarchical descriptive slugs, NEVER numbered.** The format is `[GROUP-TOPIC]` or `[GROUP-TOPIC-DETAIL]`. The first word is the **group** — all sections sharing the same group MUST appear together in the spec's table of contents. IDs are uppercase, hyphen-separated, unique across the repo, and MUST NOT contain sequential numbers. + +For each file, extract every spec ID and its associated section title (the heading text after the ID) and the full section content (everything until the next heading of equal or higher level). + +--- + +### Step 3: Filter specs + +- If `$ARGUMENTS` is non-empty, filter the discovered specs: + - If it matches a spec ID exactly (e.g., `AUTH-TOKEN-VERIFY`), check only that spec. + - If it matches a partial name (e.g., `repo-standards`), check all specs in files whose path contains that string. +- If `$ARGUMENTS` is empty, process ALL discovered specs. + +If filtering produces zero specs, report an error: +``` +ERROR: No specs found matching "$ARGUMENTS". Discovered spec files: [list them] +``` + +--- + +### Step 4: Check each spec section + +For EACH spec section that has an ID, perform checks A, B, and C below. **Stop on the first failure.** + +#### Check A: Code references the spec ID + +Search the entire codebase for the spec ID string, **excluding** these directories: +- `docs/` +- `node_modules/` +- `.git/` +- `*.md` files (markdown is docs, not code) + +Use Grep with the literal spec ID (e.g., `[AUTH-TOKEN-VERIFY]`) to find references in code files. + +**If NO code files reference the spec ID:** + +``` +SPEC VIOLATION: [AUTH-TOKEN-VERIFY] "Section Title" has no implementing code. + +Every spec section must have at least one code file that references it via a comment +containing the spec ID (e.g., `// Implements [AUTH-TOKEN-VERIFY]`). + +ACTION REQUIRED: Add a comment referencing [AUTH-TOKEN-VERIFY] in the file(s) that implement +this spec section, then re-run spec-check. +``` + +**STOP HERE. Do not continue to other checks.** + +#### Check B: Tests reference the spec ID + +Search test files for the spec ID. Test files are found in: +- `src/test/` +- `**/*.test.*` +- `**/*.spec.*` + +Use Grep to search these locations for the literal spec ID string. + +**If NO test files reference the spec ID:** + +``` +SPEC VIOLATION: [AUTH-TOKEN-VERIFY] "Section Title" has no tests. + +Every spec section must have corresponding tests that reference the spec ID. + +ACTION REQUIRED: Add tests for [AUTH-TOKEN-VERIFY] with a comment or test name containing +the spec ID, then re-run spec-check. +``` + +**STOP HERE. Do not continue to other checks.** + +#### Check C: Code logic matches the spec + +This is the most critical check. You must: + +1. **Read the spec section content carefully.** Understand exactly what behavior, logic, ordering, conditions, and constraints the spec describes. + +2. **Read the implementing code.** Use the references found in Check A to locate the implementing files. Read the relevant functions/sections. + +3. **Compare spec vs. code.** Be SENSITIVE and PEDANTIC. Check for: + - **Ordering violations** — If the spec says A happens before B, the code must do A before B. + - **Missing conditions** — If the spec says "only when X", the code must have that condition. + - **Extra behavior** — If the code does something the spec doesn't mention, flag it only if it contradicts the spec. + - **Wrong logic** — If the spec says "greater than" but code uses "greater than or equal", that's a violation. + - **Missing steps** — If the spec describes 5 steps but code only implements 3, that's a violation. + - **Wrong defaults** — If the spec says "default to X" but code defaults to Y, that's a violation. + +4. **If the code deviates from the spec**, report a detailed error with spec quotes and code references. + +5. **If the code matches the spec**, this check passes. Move to the next spec. + +--- + +### Step 5: Report results + +#### On failure (any check fails): + +Output ONLY the first violation found. Use the exact error format shown above. Do not summarize other specs. Do not offer to fix the code. Just report the violation. + +End with: +``` +spec-check FAILED. Fix the violation above and re-run. +``` + +#### On success (all specs pass): + +Output a summary table: + +``` +spec-check PASSED. All specs verified. + +| Spec ID | Title | Code References | Test References | Logic Match | +|----------------|--------------------------|-----------------|-----------------|-------------| +| [AUTH-TOKEN-VERIFY] | Authentication flow | src/auth.ts | tests/auth.test.ts | PASS | + +Checked N spec sections across M files. All have implementing code, tests, and matching logic. +``` + +--- + +## Search strategy summary + +1. **Validate spec IDs:** Check all IDs are hierarchical, descriptive, grouped, and non-numbered +2. **Find spec files:** Glob for `docs/**/*.md`, `SPEC.md`, `PLAN.md`, `specs/**/*.md` +3. **Extract spec IDs:** Grep for `\[[A-Z][A-Z0-9]*(-[A-Z0-9]+)+\]` in those files +4. **Find code refs:** Grep for the literal spec ID in all files, excluding `docs/`, `node_modules/`, `.git/`, `*.md` +5. **Find test refs:** Grep for the literal spec ID in test directories and test file patterns +6. **Read and compare:** Read the spec section content and the implementing code, compare logic + +## Key principles + +- **Fail fast.** Stop on the first violation. One fix at a time. +- **Be pedantic.** If the spec says it, the code must do it. No "close enough". +- **Quote everything.** Always quote the spec text and the code in error messages. +- **Be actionable.** Every error must tell the developer what file to change and what to do. +- **Exclude docs from code search.** Markdown files are documentation, not implementation. +- **No numbered IDs.** Spec IDs are hierarchical descriptive slugs, NEVER sequential numbers. diff --git a/.claude/skills/submit-pr/SKILL.md b/.claude/skills/submit-pr/SKILL.md index c6cb432..cf7f14b 100644 --- a/.claude/skills/submit-pr/SKILL.md +++ b/.claude/skills/submit-pr/SKILL.md @@ -1,63 +1,39 @@ --- name: submit-pr -description: Create and submit a GitHub pull request using the diff against main +description: Creates a pull request with a well-structured description after verifying CI passes. Use when the user asks to submit, create, or open a pull request. disable-model-invocation: true allowed-tools: Bash(git *), Bash(gh *) --- + -# Submit Pull Request +# Submit PR -Create a GitHub pull request for the current branch. +Create a pull request for the current branch with a well-structured description. ## Steps -1. Get the diff against the latest LOCAL main branch commit: - -``` -git diff main...HEAD -``` - -2. Read the diff output carefully. Do NOT look at commit messages. The diff is the only source of truth for what changed. - -3. Check if there's a related GitHub issue. Look for issue references in the branch name (e.g. `42-fix-bug` or `issue-42`). If found, fetch the issue title: - -``` -gh issue view --json title -q .title -``` - -4. Write the PR content using the project's PR template - -You read the file at .github/PULL_REQUEST_TEMPLATE.md - -Keep content TIGHT. Don't add waffle. - -5. Construct the PR title: -- If an issue number was found: `#: ` -- Otherwise: `` -- Keep under 70 characters - -6. Commit changes and push the current branch if needed: - -``` -git push -u origin HEAD -``` - -DO NOT include yourself as a a coauthor! - -7. Create the PR using `gh`: - -``` -gh pr create --title "" --body "$(cat <<'EOF' -# TLDR; -<tldr content> - -# Details -<details content> - -# How do the tests prove the change works -<test description> -EOF -)" -``` - -8. Return the PR URL to the user. +1. Run `make ci` — must pass completely before creating PR +2. **Generate the diff against main.** Run `git diff main...HEAD > /tmp/pr-diff.txt` to capture the full diff between the current branch and the head of main. This is the ONLY source of truth for what the PR contains. **Warning:** the diff can be very large. If the diff file exceeds context limits, process it in chunks rather than trying to load it all at once. +3. **Derive the PR title and description SOLELY from the diff.** Read the diff output and summarize what changed. Ignore commit messages, branch names, and any other metadata — only the actual code/content diff matters. +4. Write PR body using the template in `.github/pull_request_template.md` +5. Fill in (based on the diff analysis from step 3): + - TLDR: one sentence + - What Was Added: new files, features, deps + - What Was Changed/Deleted: modified behaviour + - How Tests Prove It Works: specific test names or output + - Spec/Doc Changes: if any + - Breaking Changes: yes/no + description +6. Use `gh pr create` with the filled template + +## Rules + +- Never create a PR if `make ci` fails +- PR description must be specific and tight — no vague placeholders +- Link to the relevant GitHub issue if one exists +- DO NOT include yourself as a coauthor! + +## Success criteria + +- `make ci` passed +- PR created with `gh pr create` +- PR URL returned to user diff --git a/.claude/skills/upgrade-packages/SKILL.md b/.claude/skills/upgrade-packages/SKILL.md new file mode 100644 index 0000000..2fc3294 --- /dev/null +++ b/.claude/skills/upgrade-packages/SKILL.md @@ -0,0 +1,80 @@ +--- +name: upgrade-packages +description: Upgrade all dependencies/packages to their latest versions. Use when the user says "upgrade packages", "update dependencies", "bump versions", "update packages", or "upgrade deps". +argument-hint: "[--check-only] [--major] [package-name]" +--- +<!-- agent-pmo:5547fd2 --> + +# Upgrade Packages + +Upgrade all project dependencies to their latest compatible (or latest major, if `--major`) versions. + +## Arguments + +- `--check-only` — List outdated packages without upgrading. Stop after Step 2. +- `--major` — Include major version bumps (breaking changes). Without this flag, stay within semver-compatible ranges. +- Any other argument is treated as a specific package name to upgrade (instead of all packages). + +## Step 1 — Detect package manager + +This is a TypeScript/Node.js project using npm (`package-lock.json`). + +## Step 2 — List outdated packages + +```bash +npm outdated +``` + +**Read the docs:** https://docs.npmjs.com/cli/v10/commands/npm-update + +If `--check-only` was passed, **stop here** and report the outdated list. + +## Step 3 — Read the official upgrade docs + +**Before running any upgrade command, fetch and read the official documentation URL above.** Use WebFetch. Do not guess at flags. + +## Step 4 — Upgrade packages + +```bash +npm update # semver-compatible +# --major flag: +npx npm-check-updates -u && npm install # bump to latest majors +``` + +## Step 5 — Verify the upgrade + +```bash +make ci +``` + +If tests fail: +1. Read the failure output carefully +2. Check the changelog / migration guide for the upgraded packages +3. Fix breaking changes in the code +4. Re-run tests +5. If stuck after 3 attempts on the same failure, report it to the user + +## Step 6 — Report + +- Packages upgraded (old version -> new version) +- Packages skipped (and why) +- Build/test result after upgrade +- Any breaking changes that were fixed +- Any packages that could not be upgraded + +## Rules + +- **Always list outdated packages first** before upgrading anything +- **Always read the official docs** before running upgrade commands +- **Always run tests after upgrading** to catch breakage immediately +- **Never remove packages** unless explicitly deprecated and replaced +- **Never downgrade packages** unless rolling back a broken upgrade +- **Never modify lockfiles manually** — let npm regenerate them +- **Commit nothing** — leave changes in the working tree for the user to review + +## Success criteria + +- All outdated packages upgraded to latest compatible (or latest major if `--major`) +- Build passes +- Tests pass +- User has a clear summary of what changed diff --git a/.clinerules/00-read-instructions.md b/.clinerules/00-read-instructions.md new file mode 100644 index 0000000..d9358a3 --- /dev/null +++ b/.clinerules/00-read-instructions.md @@ -0,0 +1,2 @@ +<!-- agent-pmo:5547fd2 --> +Read and follow all instructions in [CLAUDE.md](../CLAUDE.md) before writing any code. diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 0000000..b40afd8 --- /dev/null +++ b/.cursorrules @@ -0,0 +1,2 @@ +<!-- agent-pmo:5547fd2 --> +Read and follow all instructions in CLAUDE.md before writing any code. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..d9358a3 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,2 @@ +<!-- agent-pmo:5547fd2 --> +Read and follow all instructions in [CLAUDE.md](../CLAUDE.md) before writing any code. diff --git a/.github/workflows/deploy-website.yml b/.github/workflows/deploy-pages.yml similarity index 76% rename from .github/workflows/deploy-website.yml rename to .github/workflows/deploy-pages.yml index af1a72d..09e4777 100644 --- a/.github/workflows/deploy-website.yml +++ b/.github/workflows/deploy-pages.yml @@ -1,9 +1,8 @@ -name: Deploy Website +# agent-pmo:5547fd2 +name: Deploy Pages on: - workflow_run: - workflows: [Deploy] - types: [completed] + workflow_dispatch: permissions: contents: read @@ -16,15 +15,16 @@ concurrency: jobs: build: + name: Build site runs-on: ubuntu-latest - if: github.event.workflow_run.conclusion == 'success' + timeout-minutes: 10 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 20 - cache: npm + node-version: '20' + cache: 'npm' cache-dependency-path: website/package-lock.json - name: Install dependencies @@ -36,19 +36,21 @@ jobs: working-directory: website - name: Setup Pages - uses: actions/configure-pages@v4 + uses: actions/configure-pages@v5 - name: Upload artifact uses: actions/upload-pages-artifact@v3 with: - path: website/_site + path: website/_site/ deploy: + name: Deploy + needs: build + runs-on: ubuntu-latest + timeout-minutes: 10 environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} - runs-on: ubuntu-latest - needs: build steps: - name: Deploy to GitHub Pages id: deployment diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml deleted file mode 100644 index 968f085..0000000 --- a/.github/workflows/deploy.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: Deploy - -on: - workflow_dispatch: - workflow_run: - workflows: [Release] - types: [completed] - -jobs: - publish: - runs-on: ubuntu-latest - if: github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: npm - - - run: npm ci - - - name: Build - run: npm run compile - - - name: Publish to Marketplace - run: npx vsce publish --skip-duplicate - env: - VSCE_PAT: ${{ secrets.VSCE_PAT }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d1c20a7..f5de7c7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,3 +1,4 @@ +# agent-pmo:5547fd2 name: Release on: @@ -8,6 +9,7 @@ on: jobs: release: runs-on: ubuntu-latest + timeout-minutes: 10 permissions: contents: write steps: @@ -34,3 +36,43 @@ jobs: with: files: "*.vsix" generate_release_notes: true + + publish: + name: Publish to Marketplace + runs-on: ubuntu-latest + timeout-minutes: 10 + needs: release + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - run: npm ci + + - name: Build + run: npm run compile + + - name: Publish to Marketplace + run: npx vsce publish --skip-duplicate + env: + VSCE_PAT: ${{ secrets.VSCE_PAT }} + + deploy-pages: + name: Trigger website deploy + needs: release + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Trigger deploy-pages workflow + uses: actions/github-script@v7 + with: + script: | + await github.rest.actions.createWorkflowDispatch({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id: 'deploy-pages.yml', + ref: 'main', + }); diff --git a/.gitignore b/.gitignore index c657191..f7afb92 100644 --- a/.gitignore +++ b/.gitignore @@ -1,36 +1,86 @@ -# Dependencies -node_modules/ - -# Build output -out/ -*.vsix - -# VS Code test artifacts -.vscode-test/ +# agent-pmo:5547fd2 +# ============================================================================= +# UNIVERSAL +# ============================================================================= -# Coverage reports -coverage/ - -# OS files +# OS .DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes Thumbs.db +ehthumbs.db +Desktop.ini -# Editor +# IDE / Editor +.idea/ *.swp *.swo +*~ +.project +.classpath +.settings/ +*.sublime-project +*.sublime-workspace -.venv/ +# Portfolio-wide tooling +.too_many_cooks/ +.commandtree/ +.playwright-mcp/ +coordination/ +logs/ +nohup.out -src/test/fixtures/workspace/.vscode/tasktree.json +# Coverage artifacts (all languages) +coverage/ +lcov.info +*.profraw +*.profdata +htmlcov/ +.coverage +coverage.xml +coverage.out +coverage-summary.json +TestResults/ +mutants.out/ -.vscode/tasktree.json +# Secrets / local overrides +.env +.env.local +.env.*.local +*.local +*.secret +*.pem +*.key +!*.pub.key -.too_many_cooks/ +# Temporary +tmp/ +temp/ +scratch/ +# ============================================================================= +# TYPESCRIPT / NODE +# ============================================================================= +node_modules/ +dist/ +out/ +build/ +*.vsix +*.tgz +.npm/ +.cache/ +.vscode-test/ +.vscode-test-web/ +.nyc_output/ -.playwright-mcp/ +# ============================================================================= +# PROJECT-SPECIFIC +# ============================================================================= +.venv/ website/_site/ +test-results/ -.commandtree/ -logs/ +.claude/skills/website-audit/SKILL.md \ No newline at end of file diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index 77eb51f..0000000 --- a/.prettierrc +++ /dev/null @@ -1,7 +0,0 @@ -{ - "semi": true, - "singleQuote": false, - "trailingComma": "es5", - "tabWidth": 2, - "printWidth": 120 -} diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..2fa9d49 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,13 @@ +{ + "_agent_pmo": "5547fd2", + "tabWidth": 2, + "useTabs": false, + "printWidth": 120, + "semi": true, + "singleQuote": false, + "trailingComma": "es5", + "bracketSpacing": true, + "arrowParens": "always", + "endOfLine": "lf", + "bracketSameLine": false +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 67a1e83..82b8727 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -20,5 +20,11 @@ "editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit", "commandtree.enableAiSummaries": "explicit" + }, + "workbench.colorCustomizations": { + "titleBar.activeBackground": "#2a8c7a", + "titleBar.activeForeground": "#ffffff", + "titleBar.inactiveBackground": "#1f6e5f", + "titleBar.inactiveForeground": "#ffffffcc" } -} \ No newline at end of file +} diff --git a/.windsurfrules b/.windsurfrules new file mode 100644 index 0000000..b40afd8 --- /dev/null +++ b/.windsurfrules @@ -0,0 +1,2 @@ +<!-- agent-pmo:5547fd2 --> +Read and follow all instructions in CLAUDE.md before writing any code. diff --git a/Agents.md b/Agents.md index 5cfb79a..2bc847e 100644 --- a/Agents.md +++ b/Agents.md @@ -1,3 +1,4 @@ +<!-- agent-pmo:5547fd2 --> # Agents See [CLAUDE.md](CLAUDE.md) for all project instructions, coding rules, testing guidelines, and command reference. diff --git a/Claude.md b/Claude.md index a2970cf..2266b17 100644 --- a/Claude.md +++ b/Claude.md @@ -1,168 +1,201 @@ -# CLAUDE.md - CommandTree Extension +<!-- agent-pmo:5547fd2 --> +# CommandTree — Agent Instructions ⚠️ CRITICAL: **Reduce token usage.** Check file size before loading. Write less. Delete fluff and dead code. Alert user when context is loaded with pointless files. ⚠️ -## Too Many Cooks +> Read this entire file before writing any code. +> These rules are NON-NEGOTIABLE. Violations will be rejected in review. -You are working with many other agents. Make sure there is effective cooperation -- Register on TMC immediately -- Don't edit files that are locked; lock files when editing -- COMMUNICATE REGULARLY AND COORDINATE WITH OTHERS THROUGH MESSAGES +## Project Overview -## Coding Rules +CommandTree is a VS Code extension that discovers and organizes runnable tasks (npm scripts, Makefiles, shell scripts, launch configs, etc.) into a unified tree view sidebar. It supports tagging, quick launch, AI-generated summaries, and 20+ task discovery providers. -- **Zero duplication - TOP PRIORITY** - Always search for existing code before adding. Move; don't copy files. Add assertions to tests rather than duplicating tests. AIM FOR LESS CODE! -- **No string literals** - Named constants only, and it ONE location -- DO NOT USE GIT -- **Functional style** - Prefer pure functions, avoid classes where possible -- **No suppressing warnings** - Fix them properly -- Text matching (including Regex) is illegal. Use a proper parser/treesitter. If text matching is absolutely necessary, prefer Regex -- **Expressions over assignments** - Prefer const and immutable patterns -- **Named parameters** - Use object params for functions with 3+ args -- **Keep files under 450 LOC and functions under 20 LOC** -- **No commented-out code** - Delete it -- **No placeholders** - If incomplete, leave LOUD compilation error with TODO +**Primary language(s):** TypeScript +**Build command:** `make ci` +**Test command:** `make test` +**Lint command:** `make lint` + +## Too Many Cooks (Multi-Agent Coordination) + +If the TMC server is available: +1. Register immediately: descriptive name, intent, files you will touch +2. Before editing any file: lock it via TMC +3. Broadcast your plan before starting work +4. Check messages every few minutes +5. Release locks immediately when done +6. Never edit a locked file — wait or find another approach + +## Hard Rules — Universal (no exceptions) + +- **DO NOT use git commands.** No `git add`, `git commit`, `git push`, `git checkout`, `git merge`, `git rebase`, or any other git command. CI and GitHub Actions handle git. +- **ZERO DUPLICATION.** Before writing any code, search the codebase for existing implementations. Move code, don't copy it. +- **NO THROWING EXCEPTIONS.** Return `Result<T,E>` or the language equivalent. Exceptions are only for unrecoverable bugs (panic-level). +- **NO REGEX on structured data.** Never parse JSON, YAML, TOML, code, or any structured format with regex. Use proper parsers, AST tools, or library functions. If text matching is absolutely necessary, prefer Regex. +- **NO PLACEHOLDERS.** If something isn't implemented, leave a loud compilation error with TODO. Never write code that silently does nothing. +- **Functions < 20 lines.** Refactor aggressively. If a function exceeds 20 lines, split it. +- **Files < 450 lines.** If a file exceeds 450 lines, extract modules. +- **100% test coverage is the goal.** Never delete or skip tests. Never remove assertions. +- **Prefer E2E/integration tests.** Unit tests are acceptable only for isolating problems. +- **No suppressing linter warnings.** Fix the code, not the linter. +- **Pure functions** over statements. Prefer const and immutable patterns. +- **No string literals** — Named constants only, in ONE location. +- **Named parameters** — Use object params for functions with 3+ args. +- **No commented-out code** — Delete it. +- **Every spec section MUST have a unique, hierarchical, non-numeric ID.** Format: `[GROUP-TOPIC]` or `[GROUP-TOPIC-DETAIL]` (e.g., `[AUTH-TOKEN-VERIFY]`, `[CI-TIMEOUT]`). The first word is the **group** — all sections in the same group MUST be adjacent in the spec's TOC. NEVER use sequential numbers like `[SPEC-001]`. All code, tests, and design docs that implement or relate to a spec section MUST reference its ID in a comment. + +## Logging Standards + +- **Use a structured logging library.** Never use `console.log` for diagnostics. Use a proper structured logging library. +- **Log at entry/exit of all significant operations.** Use appropriate levels: `error`, `warn`, `info`, `debug`, `trace`. +- **Structured fields over string interpolation.** Log `{ "userId": 42, "action": "checkout" }` not `"User 42 performed checkout"`. +- **VS Code extensions:** Write detailed logs to a file in the extension's state folder. Basic errors and diagnostics MUST also appear in the extension's VS Code Output Channel so users can see them without hunting for files. +- **NEVER log personal data.** No names, emails, addresses, phone numbers, IP addresses, or any PII. +- **NEVER log secrets.** No API keys, tokens, passwords, connection strings, or credentials. + +### Logging Library + +| Language | Library | Notes | +|----------|---------|-------| +| TypeScript/Node | `pino` | JSON structured logging; use `pino-pretty` for dev | + +## Hard Rules — TypeScript -### Typescript - **CENTRALIZE global state** Keep it in one type/file. -- **TypeScript strict mode** - No `any`, no implicit types, turn all lints up to error -- **Regularly run the linter** - Fix lint errors IMMEDIATELY -- **Decouple providers from the VSCODE SDK** - No vscode sdk use within the providers -- **Ignoring lints = ⛔️ illegal** - Fix violations immediately -- **No throwing** - Only return `Result<T,E>` +- **TypeScript strict mode** — No `any`, no implicit types, turn all lints up to error +- No `!` (non-null assertion) — use optional chaining or explicit guards +- No implicit `any` — all function parameters and return types must be annotated +- No `// @ts-ignore` or `// @ts-nocheck` +- No `as Type` casts without a comment explaining why it's safe +- Strict mode always on (`tsconfig.json` must have `"strict": true`) +- No throwing — return `Result<T, E>` using a discriminated union +- **Regularly run the linter** — Fix lint errors IMMEDIATELY +- **Decouple providers from the VSCODE SDK** — No vscode sdk use within the providers +- **Ignoring lints = illegal** — Fix violations immediately ### CSS -- **Minimize duplication** - fewer classes is better -- **Don't include section in class name** - name them after what they are - not the section they sit in -## Testing +- **Minimize duplication** — fewer classes is better +- **Don't include section in class name** — name them after what they are - not the section they sit in -⚠️ NEVER KILL VSCODE PROCESSES +## Testing Rules -#### Rules -- **Prefer e2e tests over unit tests** - only unit tests for isolating bugs -- Separate e2e tests from unit tests by file. They should not be in the same file together. +- **Never delete a failing test.** Fix the code or fix the test expectation — never delete. +- **Never skip a test** without a ticket number and expiry date in the skip reason. +- **Assertions must be specific.** `assert.ok(true)` without a condition is illegal. +- **No try/catch in tests** that swallows the exception and asserts success. +- **Tests must be deterministic.** No sleep(), no relying on timing, no random state. +- **E2E tests: black-box only.** Only interact via VS Code commands or UI. Never call provider methods directly. +- NEVER KILL VSCODE PROCESSES +- Separate e2e tests from unit tests by file - Tests must prove USER INTERACTIONS work - E2E tests should have multiple user interactions each and loads of assertions - Prefer adding assertions to existing tests rather than adding new tests - Test files in `src/test/suite/*.test.ts` - Run tests: `npm test` -- NEVER remove assertions -- FAILING TEST = ✅ OK. TEST THAT DOESN'T ENFORCE BEHAVIOR = ⛔️ ILLEGAL +- FAILING TEST = OK. TEST THAT DOESN'T ENFORCE BEHAVIOR = ILLEGAL - Unit test = No VSCODE instance needed = isolation only test ### Automated (E2E) Testing **AUTOMATED TESTING IS BLACK BOX TESTING ONLY** -Only test the UI **THROUGH the UI**. Do not run command etc. to coerce the state. You are testing the UI, not the code. +Only test the UI **THROUGH the UI**. Do not run commands etc. to coerce the state. - Tests run in actual VS Code window via `@vscode/test-electron` -- Automated tests must not modify internal state or call functions that do. They must only use the extension through the UI. - * - ❌ Calling internal methods like provider.updateTasks() - * - ❌ Calling provider.refresh() directly - * - ❌ Manipulating internal state directly - * - ❌ Using any method not exposed via VS Code commands - * - ❌ Using commands that should just happen as part of normal use. e.g.: `await vscode.commands.executeCommand('commandtree.refresh');` - * - ❌ `executeCommand('commandtree.addToQuick', item)` - TAP the item via the DOM!!! +- Automated tests must not modify internal state or call functions that do: + - No calling internal methods like provider.updateTasks() + - No calling provider.refresh() directly + - No manipulating internal state directly + - No using any method not exposed via VS Code commands + - No using commands that should just happen as part of normal use (e.g., commandtree.refresh) ### Test First Process - Write test that fails because of bug/missing feature - Run tests to verify that test fails because of this reason - Adjust test and repeat until you see failure for the reason above - Add missing feature or fix bug -- Run tests to verify test passes. +- Run tests to verify test passes - Repeat and fix until test passes WITHOUT changing the test -**Every test MUST:** -1. Assert on the ACTUAL OBSERVABLE BEHAVIOR (UI state, view contents, return values) -2. Fail if the feature is broken -3. Test the full flow, not just side effects like config files - -### ⛔️ FAKE TESTS ARE ILLEGAL - -**A "fake test" is any test that passes without actually verifying behavior. These are STRICTLY FORBIDDEN:** - -```typescript -// ❌ ILLEGAL - asserts true unconditionally -assert.ok(true, 'Should work'); - -// ❌ ILLEGAL - no assertion on actual behavior -try { await doSomething(); } catch { } -assert.ok(true, 'Did not crash'); - -// ❌ ILLEGAL - only checks config file, not actual UI/view behavior -writeConfig({ quick: ['task1'] }); -const config = readConfig(); -assert.ok(config.quick.includes('task1')); // This doesn't test the FEATURE - -// ❌ ILLEGAL - empty catch with success assertion -try { await command(); } catch { /* swallow */ } -assert.ok(true, 'Command ran'); +### Fake Tests Are Illegal + +A "fake test" is any test that passes without actually verifying behavior: +- `assert.ok(true, 'Should work')` — asserts true unconditionally +- `try { await doSomething(); } catch { } assert.ok(true)` — no assertion on actual behavior +- Only checking config file, not actual UI/view behavior +- Empty catch with success assertion + +## Website + +**Optimise for SEO and AI**: always pay attention to this when writing content + +[Top ways to ensure content performs well in Google's AI experiences](https://developers.google.com/search/blog/2025/05/succeeding-in-ai-search) +[SEO Starter Guide](https://developers.google.com/search/docs/fundamentals/seo-starter-guide) +[How to optimise AI overviews](https://studiohawk.com.au/blog/how-to-optimise-ai-overviews/) +[Optimizing content for AI search](https://about.ads.microsoft.com/en/blog/post/october-2025/optimizing-your-content-for-inclusion-in-ai-search-answers) +[Implementing Social Media Preview Cards](https://documentation.platformos.com/use-cases/implementing-social-media-preview-cards) + +## Build Commands (cross-platform via GNU Make) + +```bash +make build # compile everything +make test # run tests with coverage +make lint # run all linters +make fmt # format all code +make fmt-check # check formatting (CI uses this) +make clean # remove build artifacts +make check # lint + test (pre-commit) +make ci # fmt-check + lint + spellcheck + test + build + package +make coverage # generate and open coverage report +make coverage-check # assert coverage thresholds +make spellcheck # run cspell spell checker +make package # build VSIX package +make setup # post-create dev environment setup ``` ## Critical Docs -### Vscode SDK +### VS Code SDK [VSCode Extension API](https://code.visualstudio.com/api/) [VSCode Extension Testing API](https://code.visualstudio.com/api/extension-guides/testing) [VSCODE Language Model API](https://code.visualstudio.com/api/extension-guides/ai/language-model) [Language Model Tool API](https://code.visualstudio.com/api/extension-guides/ai/tools) -[AI extensibility in VS Cod](https://code.visualstudio.com/api/extension-guides/ai/ai-extensibility-overview) +[AI extensibility in VS Code](https://code.visualstudio.com/api/extension-guides/ai/ai-extensibility-overview) [AI language models in VS Code](https://code.visualstudio.com/docs/copilot/customization/language-models) -### Website - -https://developers.google.com/search/blog/2025/05/succeeding-in-ai-search -https://developers.google.com/search/docs/fundamentals/seo-starter-guide - -https://studiohawk.com.au/blog/how-to-optimise-ai-overviews/ -https://about.ads.microsoft.com/en/blog/post/october-2025/optimizing-your-content-for-inclusion-in-ai-search-answers - -https://documentation.platformos.com/use-cases/implementing-social-media-preview-cards - -## Project Structure +## Repo Structure ``` CommandTree/ +├── .claude/skills/ # Agent skills +├── .github/ +│ ├── workflows/ +│ │ ├── ci.yml +│ │ ├── release.yml +│ │ └── deploy-pages.yml +│ └── pull_request_template.md +├── docs/ +│ ├── specs/ # Behavior specifications +│ └── plans/ # Goal-oriented plans with TODO checklists ├── src/ -│ ├── extension.ts # Entry point, command registration -│ ├── CommandTreeProvider.ts # TreeDataProvider implementation +│ ├── extension.ts # Entry point, command registration +│ ├── CommandTreeProvider.ts # TreeDataProvider implementation │ ├── config/ -│ │ └── TagConfig.ts # Tag configuration from commandtree.json -│ ├── discovery/ -│ │ ├── index.ts # Discovery orchestration -│ │ ├── shell.ts # Shell scripts (.sh, .bash, .zsh) -│ │ ├── npm.ts # NPM scripts (package.json) -│ │ ├── make.ts # Makefile targets -│ │ ├── launch.ts # VS Code launch configs -│ │ ├── tasks.ts # VS Code tasks -│ │ ├── python.ts # Python scripts (.py) -│ │ ├── powershell.ts # PowerShell scripts (.ps1) -│ │ ├── gradle.ts # Gradle tasks -│ │ ├── cargo.ts # Cargo (Rust) tasks -│ │ ├── maven.ts # Maven goals (pom.xml) -│ │ ├── ant.ts # Ant targets (build.xml) -│ │ ├── just.ts # Just recipes (justfile) -│ │ ├── taskfile.ts # Taskfile tasks (Taskfile.yml) -│ │ ├── deno.ts # Deno tasks (deno.json) -│ │ ├── rake.ts # Rake tasks (Rakefile) -│ │ ├── composer.ts # Composer scripts (composer.json) -│ │ ├── docker.ts # Docker Compose services -│ │ ├── dotnet.ts # .NET projects (.csproj) -│ │ ├── csharp-script.ts # C# scripts (.csx) -│ │ ├── fsharp-script.ts # F# scripts (.fsx) -│ │ ├── mise.ts # Mise tasks -│ │ └── markdown.ts # Markdown files (.md) +│ │ └── TagConfig.ts # Tag configuration from commandtree.json +│ ├── discovery/ # 20+ task discovery providers │ ├── models/ -│ │ └── TaskItem.ts # Task data model and TreeItem +│ │ └── TaskItem.ts # Task data model and TreeItem │ ├── runners/ -│ │ └── TaskRunner.ts # Task execution logic +│ │ └── TaskRunner.ts # Task execution logic │ └── test/ -│ └── suite/ # E2E test files -├── test-fixtures/ # Test workspace files -├── package.json # Extension manifest -├── tsconfig.json # TypeScript config -└── .vscode-test.mjs # Test runner config +│ └── suite/ # E2E test files +├── test-fixtures/ # Test workspace files +├── website/ # 11ty static site +├── package.json # Extension manifest +├── tsconfig.json # TypeScript config +├── eslint.config.mjs # ESLint flat config +├── .prettierrc.json # Prettier config +├── Makefile # Build targets +└── .vscode-test.mjs # Test runner config ``` ## Commands @@ -183,10 +216,6 @@ CommandTree/ | `commandtree.selectModel` | Select AI model | | `commandtree.openPreview` | Open markdown preview | -## Build Commands - -See [text](package.json) - ## Adding New Task Types 1. Create discovery module in `src/discovery/` @@ -196,29 +225,6 @@ See [text](package.json) 5. Handle execution in `TaskRunner.run()` 6. Add E2E tests in `src/test/suite/discovery.test.ts` -## VS Code API Patterns - -```typescript -// Register command -context.subscriptions.push( - vscode.commands.registerCommand('commandtree.xxx', handler) -); - -// File watcher -const watcher = vscode.workspace.createFileSystemWatcher('**/pattern'); -watcher.onDidChange(() => refresh()); -context.subscriptions.push(watcher); - -// Tree view -const treeView = vscode.window.createTreeView('commandtree', { - treeDataProvider: provider, - showCollapseAll: true -}); - -// Context for when clauses -vscode.commands.executeCommand('setContext', 'commandtree.hasFilter', true); -``` - ## Configuration Settings defined in `package.json` under `contributes.configuration`: diff --git a/Makefile b/Makefile index e163863..622e204 100644 --- a/Makefile +++ b/Makefile @@ -1,21 +1,104 @@ -.PHONY: format lint spellcheck build package test ci +# agent-pmo:5547fd2 +# ============================================================================= +# Standard Makefile — CommandTree +# Cross-platform: Linux, macOS, Windows (via GNU Make) +# ============================================================================= -format: - npx prettier --write "src/**/*.ts" +.PHONY: build test lint fmt fmt-check format clean check ci coverage coverage-check setup spellcheck package + +# ----------------------------------------------------------------------------- +# OS Detection +# ----------------------------------------------------------------------------- +ifeq ($(OS),Windows_NT) + SHELL := powershell.exe + .SHELLFLAGS := -NoProfile -Command + RM = Remove-Item -Recurse -Force -ErrorAction SilentlyContinue + MKDIR = New-Item -ItemType Directory -Force + HOME ?= $(USERPROFILE) +else + RM = rm -rf + MKDIR = mkdir -p +endif + +# Coverage threshold (override in CI via env var) +COVERAGE_THRESHOLD ?= 80 + +# ============================================================================= +# PRIMARY TARGETS (uniform interface — do not rename) +# ============================================================================= + +## build: Compile/assemble all artifacts +build: + @echo "==> Building..." + $(MAKE) _build + +## test: Run full test suite with coverage +test: + @echo "==> Testing..." + $(MAKE) _test +## lint: Run all linters (fails on any warning) lint: - npx eslint src + @echo "==> Linting..." + $(MAKE) _lint + +## fmt: Format all code in-place +fmt: + @echo "==> Formatting..." + $(MAKE) _fmt + +## fmt-check: Check formatting without modifying +fmt-check: + @echo "==> Checking format..." + $(MAKE) _fmt_check + +## clean: Remove all build artifacts +clean: + @echo "==> Cleaning..." + $(MAKE) _clean + +## check: lint + test (pre-commit) +check: lint test + +## ci: lint + test + build (full CI simulation) +ci: fmt-check lint spellcheck test build package + +## coverage: Generate coverage report +coverage: + @echo "==> Coverage report..." + $(MAKE) _coverage + +## coverage-check: Assert thresholds (exits non-zero if below) +coverage-check: + @echo "==> Checking coverage thresholds..." + $(MAKE) _coverage_check + +## setup: Post-create dev environment setup +setup: + @echo "==> Setting up development environment..." + $(MAKE) _setup + @echo "==> Setup complete. Run 'make ci' to validate." +# ============================================================================= +# CUSTOM TARGETS (project-specific) +# ============================================================================= + +## format: Alias for fmt (backwards compatibility) +format: fmt + +## spellcheck: Run cspell spell checker spellcheck: npx cspell "src/**/*.ts" -build: - npx tsc -p ./ - +## package: Build VSIX package package: build npx vsce package -UNAME := $(shell uname) +# ============================================================================= +# TYPESCRIPT/NODE IMPLEMENTATION +# ============================================================================= + +UNAME := $(shell uname 2>/dev/null) EXCLUDE_CI ?= false VSCODE_TEST_CMD = npx vscode-test --coverage @@ -29,9 +112,50 @@ else VSCODE_TEST = $(VSCODE_TEST_CMD) endif -test: build +_build: + npx tsc -p ./ + +_test: _build npm run test:unit $(VSCODE_TEST) node tools/check-coverage.mjs -ci: format lint spellcheck build test package +_lint: + npx eslint src + +_fmt: + npx prettier --write "src/**/*.ts" + +_fmt_check: + npx prettier --check "src/**/*.ts" + +_clean: + $(RM) out coverage .vscode-test + +_coverage: + @echo "==> HTML report: coverage/index.html" + +_coverage_check: + node tools/check-coverage.mjs + +_setup: + npm ci + +# ============================================================================= +# HELP +# ============================================================================= +help: + @echo "Available targets:" + @echo " build - Compile/assemble all artifacts" + @echo " test - Run full test suite with coverage" + @echo " lint - Run all linters (errors mode)" + @echo " fmt - Format all code in-place" + @echo " fmt-check - Check formatting (no modification)" + @echo " clean - Remove build artifacts" + @echo " check - lint + test (pre-commit)" + @echo " ci - fmt-check + lint + spellcheck + test + build + package" + @echo " coverage - Generate and open coverage report" + @echo " coverage-check - Assert coverage thresholds" + @echo " spellcheck - Run cspell spell checker" + @echo " package - Build VSIX package" + @echo " setup - Post-create dev environment setup" diff --git a/docs/RUST-LSP-PLAN.md b/docs/plans/RUST-LSP-PLAN.md similarity index 100% rename from docs/RUST-LSP-PLAN.md rename to docs/plans/RUST-LSP-PLAN.md diff --git a/docs/RUST-LSP-SPEC.md b/docs/specs/RUST-LSP-SPEC.md similarity index 100% rename from docs/RUST-LSP-SPEC.md rename to docs/specs/RUST-LSP-SPEC.md diff --git a/docs/SPEC.md b/docs/specs/SPEC.md similarity index 100% rename from docs/SPEC.md rename to docs/specs/SPEC.md diff --git a/docs/ai-summaries.md b/docs/specs/ai-summaries.md similarity index 100% rename from docs/ai-summaries.md rename to docs/specs/ai-summaries.md diff --git a/docs/database.md b/docs/specs/database.md similarity index 100% rename from docs/database.md rename to docs/specs/database.md diff --git a/docs/discovery.md b/docs/specs/discovery.md similarity index 83% rename from docs/discovery.md rename to docs/specs/discovery.md index 285490c..9d082e5 100644 --- a/docs/discovery.md +++ b/docs/specs/discovery.md @@ -4,6 +4,23 @@ CommandTree recursively scans the workspace for runnable commands grouped by type. Discovery respects exclude patterns configured in settings. It does this in the background on low priority. +## Parsing Strategy + +**[DISC-PARSE-STRATEGY]** + +Discovery modules MUST follow this hierarchy for parsing file formats: + +1. **VS Code built-in APIs** — Use when VS Code already parses the format natively. These are 100% reliable, handle edge cases (JSONC comments, variable substitution, input prompts), and produce zero maintenance burden. Examples: + - `vscode.tasks.fetchTasks()` for tasks.json + - `vscode.workspace.getConfiguration("launch")` for launch.json + - `JSON.parse()` for standard JSON files (npm, composer, deno) + +2. **No parsing** — If the format is simple enough that line-by-line text scanning works (shell comments, Python shebangs, Makefile targets), use that directly. No AST, no state machine. + +3. **Never** — Do NOT write ad-hoc parsers for structured formats (YAML, XML, TOML). If VS Code doesn't provide a built-in API and a proper parser library is needed, add one as a dependency. + +**Rationale**: Ad-hoc parsing of structured formats creates exponential branch complexity, is impossible to fully test, and silently breaks on valid input the author didn't anticipate. VS Code's built-in parsers are maintained by Microsoft, handle all edge cases, and cost zero lines of code. + ## Shell Scripts **SPEC-DISC-010** diff --git a/docs/execution.md b/docs/specs/execution.md similarity index 100% rename from docs/execution.md rename to docs/specs/execution.md diff --git a/docs/extension.md b/docs/specs/extension.md similarity index 100% rename from docs/extension.md rename to docs/specs/extension.md diff --git a/docs/parameters.md b/docs/specs/parameters.md similarity index 100% rename from docs/parameters.md rename to docs/specs/parameters.md diff --git a/docs/quick-launch.md b/docs/specs/quick-launch.md similarity index 100% rename from docs/quick-launch.md rename to docs/specs/quick-launch.md diff --git a/docs/settings.md b/docs/specs/settings.md similarity index 100% rename from docs/settings.md rename to docs/specs/settings.md diff --git a/docs/skills.md b/docs/specs/skills.md similarity index 100% rename from docs/skills.md rename to docs/specs/skills.md diff --git a/docs/tagging.md b/docs/specs/tagging.md similarity index 100% rename from docs/tagging.md rename to docs/specs/tagging.md diff --git a/docs/tree-view.md b/docs/specs/tree-view.md similarity index 100% rename from docs/tree-view.md rename to docs/specs/tree-view.md diff --git a/docs/utilities.md b/docs/specs/utilities.md similarity index 100% rename from docs/utilities.md rename to docs/specs/utilities.md diff --git a/opencode.json b/opencode.json new file mode 100644 index 0000000..2381af7 --- /dev/null +++ b/opencode.json @@ -0,0 +1,4 @@ +{ + "_agent_pmo": "5547fd2", + "instructions": ["CLAUDE.md"] +} diff --git a/src/CommandTreeProvider.ts b/src/CommandTreeProvider.ts index fe3e260..3b4aa0a 100644 --- a/src/CommandTreeProvider.ts +++ b/src/CommandTreeProvider.ts @@ -9,7 +9,7 @@ import { buildNestedFolderItems } from "./tree/folderTree"; import { createCommandNode, createCategoryNode } from "./tree/nodeFactory"; import { getAllRows } from "./db/db"; import type { CommandRow } from "./db/db"; -import { getDb } from "./db/lifecycle"; +import { getDbOrThrow } from "./db/lifecycle"; type SortOrder = "folder" | "name" | "type"; @@ -33,17 +33,23 @@ export class CommandTreeProvider implements vscode.TreeDataProvider<CommandTreeI } public async refresh(): Promise<void> { + logger.info("CommandTreeProvider.refresh() starting"); this.tagConfig.load(); + logger.info("Tag config loaded, getting exclude patterns"); const excludePatterns = getExcludePatterns(); + logger.info("Exclude patterns", { excludePatterns }); this.discoveryResult = await discoverAllTasks(this.workspaceRoot, excludePatterns); + logger.info("Discovery result received, flattening tasks"); this.commands = this.tagConfig.applyTags(flattenTasks(this.discoveryResult)); + logger.info("Tasks flattened and tagged", { count: this.commands.length }); this.loadSummaries(); this.commands = this.attachSummaries(this.commands); + logger.info("Summaries attached, firing tree change event"); this._onDidChangeTreeData.fire(undefined); } private loadSummaries(): void { - const handle = getDb(); + const handle = getDbOrThrow(); const rows = getAllRows(handle); const map = new Map<string, CommandRow>(); for (const row of rows) { @@ -118,10 +124,13 @@ export class CommandTreeProvider implements vscode.TreeDataProvider<CommandTreeI public async getChildren(element?: CommandTreeItem): Promise<CommandTreeItem[]> { if (!this.discoveryResult) { + logger.info("getChildren: no discovery result yet, triggering refresh"); await this.refresh(); } if (!element) { - return this.buildRootCategories(); + const roots = this.buildRootCategories(); + logger.info("getChildren: root categories built", { categoryCount: roots.length }); + return roots; } return element.children; } diff --git a/src/QuickTasksProvider.ts b/src/QuickTasksProvider.ts index 31108d7..05a1c3b 100644 --- a/src/QuickTasksProvider.ts +++ b/src/QuickTasksProvider.ts @@ -11,6 +11,7 @@ import { TagConfig } from "./config/TagConfig"; import { getDb } from "./db/lifecycle"; import { getCommandIdsByTag } from "./db/db"; import { createCommandNode, createPlaceholderNode } from "./tree/nodeFactory"; +import { logger } from "./utils/logger"; const QUICK_TASK_MIME_TYPE = "application/vnd.commandtree.quicktask"; const QUICK_TAG = "quick"; @@ -104,8 +105,12 @@ export class QuickTasksProvider * Sorts tasks by display_order from junction table. */ private sortByDisplayOrder(tasks: CommandItem[]): CommandItem[] { - const handle = getDb(); - const orderedIds = getCommandIdsByTag({ handle, tagName: QUICK_TAG }); + const dbResult = getDb(); + if (!dbResult.ok) { + logger.warn("sortByDisplayOrder: DB unavailable", { error: dbResult.error }); + return [...tasks].sort((a, b) => a.label.localeCompare(b.label)); + } + const orderedIds = getCommandIdsByTag({ handle: dbResult.value, tagName: QUICK_TAG }); return [...tasks].sort((a, b) => { const indexA = orderedIds.indexOf(a.id); const indexB = orderedIds.indexOf(b.id); @@ -157,8 +162,12 @@ export class QuickTasksProvider * Fetches ordered command IDs for the quick tag from the DB. */ private fetchOrderedQuickIds(): string[] { - const handle = getDb(); - return getCommandIdsByTag({ handle, tagName: QUICK_TAG }); + const dbResult = getDb(); + if (!dbResult.ok) { + logger.warn("fetchOrderedQuickIds: DB unavailable", { error: dbResult.error }); + return []; + } + return getCommandIdsByTag({ handle: dbResult.value, tagName: QUICK_TAG }); } /** @@ -195,7 +204,12 @@ export class QuickTasksProvider * Persists display_order for each command in the reordered list. */ private persistDisplayOrder(reordered: string[]): void { - const handle = getDb(); + const dbResult = getDb(); + if (!dbResult.ok) { + logger.warn("persistDisplayOrder: DB unavailable", { error: dbResult.error }); + return; + } + const handle = dbResult.value; for (let i = 0; i < reordered.length; i++) { const commandId = reordered[i]; if (commandId !== undefined) { diff --git a/src/config/TagConfig.ts b/src/config/TagConfig.ts index af1f676..b603ae5 100644 --- a/src/config/TagConfig.ts +++ b/src/config/TagConfig.ts @@ -5,7 +5,7 @@ */ import type { CommandItem } from "../models/TaskItem"; -import { getDb } from "../db/lifecycle"; +import { getDbOrThrow } from "../db/lifecycle"; import { addTagToCommand, removeTagFromCommand, @@ -22,7 +22,7 @@ export class TagConfig { * Loads all tag assignments from SQLite junction table. */ public load(): void { - const handle = getDb(); + const handle = getDbOrThrow(); const tagNames = getAllTagNames(handle); const map = new Map<string, string[]>(); @@ -53,8 +53,7 @@ export class TagConfig { * Gets all tag names. */ public getTagNames(): string[] { - const handle = getDb(); - return getAllTagNames(handle); + return getAllTagNames(getDbOrThrow()); } /** @@ -62,8 +61,7 @@ export class TagConfig { * Adds a task to a tag by creating junction record with exact command ID. */ public addTaskToTag(task: CommandItem, tagName: string): void { - const handle = getDb(); - addTagToCommand({ handle, commandId: task.id, tagName }); + addTagToCommand({ handle: getDbOrThrow(), commandId: task.id, tagName }); this.load(); } @@ -72,8 +70,7 @@ export class TagConfig { * Removes a task from a tag by deleting junction record. */ public removeTaskFromTag(task: CommandItem, tagName: string): void { - const handle = getDb(); - removeTagFromCommand({ handle, commandId: task.id, tagName }); + removeTagFromCommand({ handle: getDbOrThrow(), commandId: task.id, tagName }); this.load(); } @@ -82,8 +79,7 @@ export class TagConfig { * Gets ordered command IDs for a tag (ordered by display_order). */ public getOrderedCommandIds(tagName: string): string[] { - const handle = getDb(); - return getCommandIdsByTag({ handle, tagName }); + return getCommandIdsByTag({ handle: getDbOrThrow(), tagName }); } /** @@ -91,8 +87,7 @@ export class TagConfig { * Reorders commands for a tag by updating display_order in junction table. */ public reorderCommands(tagName: string, orderedCommandIds: string[]): void { - const handle = getDb(); - reorderTagCommands({ handle, tagName, orderedCommandIds }); + reorderTagCommands({ handle: getDbOrThrow(), tagName, orderedCommandIds }); this.load(); } } diff --git a/src/db/lifecycle.ts b/src/db/lifecycle.ts index 73166af..3eaf4fe 100644 --- a/src/db/lifecycle.ts +++ b/src/db/lifecycle.ts @@ -1,5 +1,5 @@ /** - * SPEC: database-schema + * SPEC: database-schema, DB-LOCK-RECOVERY * Singleton lifecycle management for the database. */ @@ -8,19 +8,29 @@ import * as path from "path"; import { logger } from "../utils/logger"; import type { DbHandle } from "./db"; import { openDatabase, initSchema, closeDatabase } from "./db"; +import type { Result } from "../models/Result"; +import { ok, err } from "../models/Result"; const COMMANDTREE_DIR = ".commandtree"; const DB_FILENAME = "commandtree.sqlite3"; +const LOCK_RETRY_INTERVAL_MS = 1000; +const LOCK_RETRY_MAX_MS = 10000; +const JOURNAL_SUFFIX = "-journal"; +const WAL_SUFFIX = "-wal"; +const SHM_SUFFIX = "-shm"; +const LOCK_DIR_SUFFIX = ".lock"; let dbHandle: DbHandle | null = null; /** + * SPEC: DB-LOCK-RECOVERY * Initialises the SQLite database singleton. - * Re-creates if the DB file was deleted externally. + * If the database is locked, retries for 10 seconds then + * forcefully removes lock/journal files and retries. */ -export function initDb(workspaceRoot: string): DbHandle { +export async function initDb(workspaceRoot: string): Promise<Result<DbHandle, string>> { if (dbHandle !== null && fs.existsSync(dbHandle.path)) { - return dbHandle; + return ok(dbHandle); } resetStaleHandle(); @@ -28,27 +38,48 @@ export function initDb(workspaceRoot: string): DbHandle { fs.mkdirSync(dbDir, { recursive: true }); const dbPath = path.join(dbDir, DB_FILENAME); - const openResult = openDatabase(dbPath); - if (!openResult.ok) { - throw new Error(openResult.error); + const result = tryOpenAndInit(dbPath); + if (result.ok) { + return result; } - initSchema(openResult.value); - dbHandle = openResult.value; - logger.info("SQLite database initialised", { path: dbPath }); - return dbHandle; + if (!isLockError(result.error)) { + return result; + } + + logger.warn("Database locked, retrying", { dbPath }); + const retryResult = await retryWithBackoff(dbPath); + if (retryResult.ok) { + return retryResult; + } + + logger.warn("Retries exhausted, force-removing lock files", { dbPath }); + removeLockFiles(dbPath); + return tryOpenAndInit(dbPath); } /** * Returns the current database handle. - * Throws if the database has not been initialised. + * Returns error if the database has not been initialised. */ -export function getDb(): DbHandle { +export function getDb(): Result<DbHandle, string> { if (dbHandle !== null && fs.existsSync(dbHandle.path)) { - return dbHandle; + return ok(dbHandle); } resetStaleHandle(); - throw new Error("Database not initialised. Call initDb first."); + return err("Database not initialised. Call initDb first."); +} + +/** + * Returns the database handle, throwing if not initialised. + * Use this in code paths where the DB is guaranteed to be available. + */ +export function getDbOrThrow(): DbHandle { + const result = getDb(); + if (!result.ok) { + throw new Error(result.error); + } + return result.value; } function resetStaleHandle(): void { @@ -69,3 +100,87 @@ export function disposeDb(): void { } logger.info("Database disposed"); } + +function tryOpenAndInit(dbPath: string): Result<DbHandle, string> { + const openResult = openDatabase(dbPath); + if (!openResult.ok) { + return openResult; + } + try { + initSchema(openResult.value); + } catch (e: unknown) { + closeDatabase(openResult.value); + const msg = e instanceof Error ? e.message : String(e); + return err(msg); + } + dbHandle = openResult.value; + logger.info("SQLite database initialised", { path: dbPath }); + return ok(openResult.value); +} + +function isLockError(message: string): boolean { + return message.includes("locked") || message.includes("SQLITE_BUSY"); +} + +async function retryWithBackoff(dbPath: string): Promise<Result<DbHandle, string>> { + let elapsed = 0; + let lastError = "database is locked"; + while (elapsed < LOCK_RETRY_MAX_MS) { + await sleep(LOCK_RETRY_INTERVAL_MS); + elapsed += LOCK_RETRY_INTERVAL_MS; + logger.info("Lock retry attempt", { elapsedMs: elapsed }); + const result = tryOpenAndInit(dbPath); + if (result.ok) { + return result; + } + lastError = result.error; + if (!isLockError(lastError)) { + return result; + } + } + return err(lastError); +} + +/** + * SPEC: DB-LOCK-RECOVERY + * Forcefully removes SQLite lock artifacts: + * - .lock directory + * - -journal file + * - -wal file + * - -shm file + */ +export function removeLockFiles(dbPath: string): void { + const targets = [ + { path: dbPath + LOCK_DIR_SUFFIX, isDir: true }, + { path: dbPath + JOURNAL_SUFFIX, isDir: false }, + { path: dbPath + WAL_SUFFIX, isDir: false }, + { path: dbPath + SHM_SUFFIX, isDir: false }, + ]; + for (const target of targets) { + if (!fs.existsSync(target.path)) { + continue; + } + try { + if (target.isDir) { + fs.rmSync(target.path, { recursive: true }); + } else { + fs.unlinkSync(target.path); + } + logger.info("Removed lock artifact", { path: target.path }); + } catch (e: unknown) { + logger.error("Failed to remove lock artifact", { + path: target.path, + error: e instanceof Error ? e.message : String(e), + }); + } + } +} + +function sleep(ms: number): Promise<void> { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// Test-only: reset internal state +export function resetForTesting(): void { + dbHandle = null; +} diff --git a/src/discovery/index.ts b/src/discovery/index.ts index f7da810..5bde456 100644 --- a/src/discovery/index.ts +++ b/src/discovery/index.ts @@ -111,9 +111,25 @@ export interface DiscoveryResult { * Discovers all tasks from all sources. */ export async function discoverAllTasks(workspaceRoot: string, excludePatterns: string[]): Promise<DiscoveryResult> { - logger.info("Discovery started", { workspaceRoot }); + logger.info("Discovery started", { workspaceRoot, excludePatterns }); + + // Run all discoveries in parallel, wrapping each to log errors + const wrapDiscovery = async (name: string, fn: () => CommandItem[] | Promise<CommandItem[]>): Promise<CommandItem[]> => { + try { + const items = await fn(); + if (items.length > 0) { + logger.info(`Discovery [${name}]`, { count: items.length }); + } + return items; + } catch (e: unknown) { + logger.error(`Discovery [${name}] FAILED`, { + error: e instanceof Error ? e.message : String(e), + stack: e instanceof Error ? e.stack : undefined, + }); + return []; + } + }; - // Run all discoveries in parallel const [ shell, npm, @@ -138,28 +154,28 @@ export async function discoverAllTasks(workspaceRoot: string, excludePatterns: s fsharpScript, mise, ] = await Promise.all([ - discoverShellScripts(workspaceRoot, excludePatterns), - discoverNpmScripts(workspaceRoot, excludePatterns), - discoverMakeTargets(workspaceRoot, excludePatterns), - discoverLaunchConfigs(workspaceRoot, excludePatterns), - discoverVsCodeTasks(workspaceRoot, excludePatterns), - discoverPythonScripts(workspaceRoot, excludePatterns), - discoverPowerShellScripts(workspaceRoot, excludePatterns), - discoverGradleTasks(workspaceRoot, excludePatterns), - discoverCargoTasks(workspaceRoot, excludePatterns), - discoverMavenGoals(workspaceRoot, excludePatterns), - discoverAntTargets(workspaceRoot, excludePatterns), - discoverJustRecipes(workspaceRoot, excludePatterns), - discoverTaskfileTasks(workspaceRoot, excludePatterns), - discoverDenoTasks(workspaceRoot, excludePatterns), - discoverRakeTasks(workspaceRoot, excludePatterns), - discoverComposerScripts(workspaceRoot, excludePatterns), - discoverDockerComposeServices(workspaceRoot, excludePatterns), - discoverDotnetProjects(workspaceRoot, excludePatterns), - discoverMarkdownFiles(workspaceRoot, excludePatterns), - discoverCsharpScripts(workspaceRoot, excludePatterns), - discoverFsharpScripts(workspaceRoot, excludePatterns), - discoverMiseTasks(workspaceRoot, excludePatterns), + wrapDiscovery("shell", () => discoverShellScripts(workspaceRoot, excludePatterns)), + wrapDiscovery("npm", () => discoverNpmScripts(workspaceRoot, excludePatterns)), + wrapDiscovery("make", () => discoverMakeTargets(workspaceRoot, excludePatterns)), + wrapDiscovery("launch", () => discoverLaunchConfigs(workspaceRoot, excludePatterns)), + wrapDiscovery("vscode", () => discoverVsCodeTasks(workspaceRoot, excludePatterns)), + wrapDiscovery("python", () => discoverPythonScripts(workspaceRoot, excludePatterns)), + wrapDiscovery("powershell", () => discoverPowerShellScripts(workspaceRoot, excludePatterns)), + wrapDiscovery("gradle", () => discoverGradleTasks(workspaceRoot, excludePatterns)), + wrapDiscovery("cargo", () => discoverCargoTasks(workspaceRoot, excludePatterns)), + wrapDiscovery("maven", () => discoverMavenGoals(workspaceRoot, excludePatterns)), + wrapDiscovery("ant", () => discoverAntTargets(workspaceRoot, excludePatterns)), + wrapDiscovery("just", () => discoverJustRecipes(workspaceRoot, excludePatterns)), + wrapDiscovery("taskfile", () => discoverTaskfileTasks(workspaceRoot, excludePatterns)), + wrapDiscovery("deno", () => discoverDenoTasks(workspaceRoot, excludePatterns)), + wrapDiscovery("rake", () => discoverRakeTasks(workspaceRoot, excludePatterns)), + wrapDiscovery("composer", () => discoverComposerScripts(workspaceRoot, excludePatterns)), + wrapDiscovery("docker", () => discoverDockerComposeServices(workspaceRoot, excludePatterns)), + wrapDiscovery("dotnet", () => discoverDotnetProjects(workspaceRoot, excludePatterns)), + wrapDiscovery("markdown", () => discoverMarkdownFiles(workspaceRoot, excludePatterns)), + wrapDiscovery("csharp-script", () => discoverCsharpScripts(workspaceRoot, excludePatterns)), + wrapDiscovery("fsharp-script", () => discoverFsharpScripts(workspaceRoot, excludePatterns)), + wrapDiscovery("mise", () => discoverMiseTasks(workspaceRoot, excludePatterns)), ]); const result = { diff --git a/src/discovery/launch.ts b/src/discovery/launch.ts index a940408..5601047 100644 --- a/src/discovery/launch.ts +++ b/src/discovery/launch.ts @@ -1,7 +1,6 @@ import * as vscode from "vscode"; import type { CommandItem, MutableCommandItem, IconDef, CategoryDef } from "../models/TaskItem"; import { generateCommandId } from "../models/TaskItem"; -import { readJsonFile } from "../utils/fileUtils"; export const ICON_DEF: IconDef = { icon: "debug-alt", @@ -14,48 +13,38 @@ export const CATEGORY_DEF: CategoryDef = { }; interface LaunchConfig { - name?: string; - type?: string; -} - -interface LaunchJson { - configurations?: LaunchConfig[]; + readonly name?: string; + readonly type?: string; } /** - * SPEC: command-discovery/launch-configurations + * SPEC: [DISC-LAUNCH], [DISC-PARSE-STRATEGY] * - * Discovers VS Code launch configurations. + * Discovers VS Code launch configurations using the built-in configuration API. + * VS Code handles JSONC parsing and variable substitution. */ -export async function discoverLaunchConfigs(workspaceRoot: string, excludePatterns: string[]): Promise<CommandItem[]> { - const exclude = `{${excludePatterns.join(",")}}`; - const files = await vscode.workspace.findFiles("**/.vscode/launch.json", exclude); +export function discoverLaunchConfigs(workspaceRoot: string, _excludePatterns: string[]): CommandItem[] { + const folders = vscode.workspace.workspaceFolders ?? []; const commands: CommandItem[] = []; - for (const file of files) { - const result = await readJsonFile<LaunchJson>(file); - if (!result.ok) { - continue; // Skip malformed launch.json - } - - const launch = result.value; - if (launch.configurations === undefined || !Array.isArray(launch.configurations)) { - continue; - } + for (const folder of folders) { + const launchConfig = vscode.workspace.getConfiguration("launch", folder.uri); + const configurations = launchConfig.get<LaunchConfig[]>("configurations") ?? []; + const filePath = vscode.Uri.joinPath(folder.uri, ".vscode", "launch.json").fsPath; - for (const config of launch.configurations) { - if (config.name === undefined) { + for (const config of configurations) { + if (config.name === undefined || config.name === "") { continue; } const task: MutableCommandItem = { - id: generateCommandId("launch", file.fsPath, config.name), + id: generateCommandId("launch", filePath, config.name), label: config.name, type: "launch", category: "VS Code Launch", - command: config.name, // Used to identify the config + command: config.name, cwd: workspaceRoot, - filePath: file.fsPath, + filePath, tags: [], }; if (config.type !== undefined) { diff --git a/src/discovery/python.ts b/src/discovery/python.ts index 18e953c..40b45c1 100644 --- a/src/discovery/python.ts +++ b/src/discovery/python.ts @@ -192,7 +192,7 @@ function extractArgName(argsStr: string): string | undefined { if (firstQuote < 0) { return undefined; } - const quote = argsStr[firstQuote]!; + const quote = argsStr.charAt(firstQuote); const endQuote = argsStr.indexOf(quote, firstQuote + 1); if (endQuote < 0) { return undefined; @@ -231,7 +231,7 @@ function extractHelpText(argsStr: string): string | undefined { if (quoteStart < 0) { return undefined; } - const quote = afterHelp[quoteStart]!; + const quote = afterHelp.charAt(quoteStart); const endQuote = afterHelp.indexOf(quote, quoteStart + 1); if (endQuote < 0) { return undefined; diff --git a/src/discovery/tasks.ts b/src/discovery/tasks.ts index 4be1888..736ca2f 100644 --- a/src/discovery/tasks.ts +++ b/src/discovery/tasks.ts @@ -1,7 +1,6 @@ import * as vscode from "vscode"; -import type { CommandItem, ParamDef, MutableCommandItem, IconDef, CategoryDef } from "../models/TaskItem"; +import type { CommandItem, MutableCommandItem, IconDef, CategoryDef } from "../models/TaskItem"; import { generateCommandId } from "../models/TaskItem"; -import { readJsonFile } from "../utils/fileUtils"; export const ICON_DEF: IconDef = { icon: "gear", color: "terminal.ansiBlue" }; export const CATEGORY_DEF: CategoryDef = { @@ -10,159 +9,44 @@ export const CATEGORY_DEF: CategoryDef = { flat: true, }; -interface TaskInput { - id: string; - description?: string; - default?: string; - options?: string[]; -} - -interface VscodeTaskDef { - label?: string; - type?: string; - script?: string; - detail?: string; -} - -interface TasksJsonConfig { - tasks?: VscodeTaskDef[]; - inputs?: TaskInput[]; -} - /** - * SPEC: command-discovery/vscode-tasks + * SPEC: [DISC-TASKS], [DISC-PARSE-STRATEGY] * - * Discovers VS Code tasks from tasks.json. + * Discovers VS Code tasks using the built-in task provider API. + * VS Code handles JSONC parsing, variable substitution, and input prompts. */ -export async function discoverVsCodeTasks(workspaceRoot: string, excludePatterns: string[]): Promise<CommandItem[]> { - const exclude = `{${excludePatterns.join(",")}}`; - const files = await vscode.workspace.findFiles("**/.vscode/tasks.json", exclude); - const commands: CommandItem[] = []; - - for (const file of files) { - const result = await readJsonFile<TasksJsonConfig>(file); - if (!result.ok) { - continue; // Skip malformed tasks.json - } - - const tasksConfig = result.value; - if (tasksConfig.tasks === undefined || !Array.isArray(tasksConfig.tasks)) { - continue; - } - - const inputs = parseInputs(tasksConfig.inputs); - const fileCommands = tasksConfig.tasks.flatMap((task) => buildTaskCommand({ task, inputs, file, workspaceRoot })); - commands.push(...fileCommands); - } - - return commands; +export async function discoverVsCodeTasks(workspaceRoot: string, _excludePatterns: string[]): Promise<CommandItem[]> { + const allTasks = await vscode.tasks.fetchTasks(); + return allTasks.flatMap((task) => taskToCommandItem(task, workspaceRoot)); } -function buildTaskCommand({ - task, - inputs, - file, - workspaceRoot, -}: { - task: VscodeTaskDef; - inputs: Map<string, ParamDef>; - file: vscode.Uri; - workspaceRoot: string; -}): CommandItem[] { - const label = resolveTaskLabel(task); - if (label === undefined) { +function taskToCommandItem(task: vscode.Task, workspaceRoot: string): CommandItem[] { + const label = task.name; + if (label === "") { return []; } - const taskParams = findTaskInputs(task, inputs); - const taskItem: MutableCommandItem = { - id: generateCommandId("vscode", file.fsPath, label), + const filePath = resolveTaskFilePath(task); + const item: MutableCommandItem = { + id: generateCommandId("vscode", filePath, label), label, type: "vscode", category: "VS Code Tasks", command: label, cwd: workspaceRoot, - filePath: file.fsPath, + filePath, tags: [], }; - if (taskParams.length > 0) { - taskItem.params = taskParams; - } - if (task.detail !== undefined && typeof task.detail === "string" && task.detail !== "") { - taskItem.description = task.detail; - } - return [taskItem]; -} - -function resolveTaskLabel(task: VscodeTaskDef): string | undefined { - if (task.label !== undefined) { - return task.label; - } - if (task.type === "npm" && task.script !== undefined) { - return `npm: ${task.script}`; - } - return undefined; -} - -/** - * Parses input definitions from tasks.json. - */ -function parseInputs(inputs: TaskInput[] | undefined): Map<string, ParamDef> { - const map = new Map<string, ParamDef>(); - if (!Array.isArray(inputs)) { - return map; + if (task.detail !== undefined && task.detail !== "") { + item.description = task.detail; } - - for (const input of inputs) { - const param: ParamDef = { - name: input.id, - ...(input.description !== undefined ? { description: input.description } : {}), - ...(input.default !== undefined ? { default: input.default } : {}), - ...(input.options !== undefined ? { options: input.options } : {}), - }; - map.set(input.id, param); - } - - return map; + return [item]; } -/** - * Finds input references in a task definition. - */ -const INPUT_PREFIX = "${input:"; -const INPUT_SUFFIX = "}"; - -function findTaskInputs(task: VscodeTaskDef, inputs: Map<string, ParamDef>): ParamDef[] { - const params: ParamDef[] = []; - const taskStr = JSON.stringify(task); - - for (const inputId of extractInputIds(taskStr)) { - const param = inputs.get(inputId); - if (param !== undefined && !params.some((p) => p.name === param.name)) { - params.push(param); - } - } - - return params; -} - -function extractInputIds(text: string): string[] { - const ids: string[] = []; - let searchFrom = 0; - - for (;;) { - const start = text.indexOf(INPUT_PREFIX, searchFrom); - if (start === -1) { - break; - } - const idStart = start + INPUT_PREFIX.length; - const end = text.indexOf(INPUT_SUFFIX, idStart); - if (end === -1) { - break; - } - ids.push(text.slice(idStart, end)); - searchFrom = end + INPUT_SUFFIX.length; +function resolveTaskFilePath(task: vscode.Task): string { + const folder = task.scope; + if (folder !== undefined && typeof folder === "object" && "uri" in folder) { + return vscode.Uri.joinPath(folder.uri, ".vscode", "tasks.json").fsPath; } - - return ids; + return ""; } diff --git a/src/extension.ts b/src/extension.ts index 1739718..793ba1a 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -28,10 +28,20 @@ export async function activate(context: vscode.ExtensionContext): Promise<Extens logger.warn("No workspace root found, extension not activating"); return; } - initDatabase(workspaceRoot); + try { + logger.info("Initialising database"); + initDatabase(workspaceRoot); + logger.info("Database initialised, creating providers"); + } catch (e: unknown) { + logger.error("Database init FAILED", { + error: e instanceof Error ? e.message : String(e), + stack: e instanceof Error ? e.stack : undefined, + }); + } treeProvider = new CommandTreeProvider(workspaceRoot); quickTasksProvider = new QuickTasksProvider(); taskRunner = new TaskRunner(); + logger.info("Registering tree views and commands"); registerTreeViews(context); registerCommands(context); setupFileWatchers({ @@ -51,10 +61,29 @@ export async function activate(context: vscode.ExtensionContext): Promise<Extens }); }, }); - await syncQuickTasks(); - await registerDiscoveredCommands(workspaceRoot); - await syncTagsFromJson(workspaceRoot); + try { + logger.info("Starting syncQuickTasks (initial refresh)"); + await syncQuickTasks(); + logger.info("syncQuickTasks complete", { taskCount: treeProvider.getAllTasks().length }); + } catch (e: unknown) { + logger.error("syncQuickTasks FAILED", { + error: e instanceof Error ? e.message : String(e), + stack: e instanceof Error ? e.stack : undefined, + }); + } + try { + await registerDiscoveredCommands(workspaceRoot); + logger.info("Syncing tags from JSON"); + await syncTagsFromJson(workspaceRoot); + } catch (e: unknown) { + logger.error("Post-discovery steps FAILED", { + error: e instanceof Error ? e.message : String(e), + stack: e instanceof Error ? e.stack : undefined, + }); + } + logger.info("Initialising AI summaries"); initAiSummaries(workspaceRoot); + logger.info("Extension activation complete"); return { commandTreeProvider: treeProvider, quickTasksProvider }; } diff --git a/src/semantic/summaryPipeline.ts b/src/semantic/summaryPipeline.ts index e822793..23683e6 100644 --- a/src/semantic/summaryPipeline.ts +++ b/src/semantic/summaryPipeline.ts @@ -13,7 +13,7 @@ import { computeContentHash } from "../db/db"; import type { FileSystemAdapter } from "./adapters"; import type { SummaryResult } from "./summariser"; import { selectCopilotModel, summariseScript } from "./summariser"; -import { initDb } from "../db/lifecycle"; +import { initDb, getDbOrThrow } from "../db/lifecycle"; import { upsertSummary, getRow, registerCommand } from "../db/db"; import type { DbHandle } from "../db/db"; @@ -114,7 +114,11 @@ export async function registerAllCommands(params: { readonly workspaceRoot: string; readonly fs: FileSystemAdapter; }): Promise<Result<number, string>> { - const handle = initDb(params.workspaceRoot); + const initResult = await initDb(params.workspaceRoot); + if (!initResult.ok) { + return err(initResult.error); + } + const handle = initResult.value; let registered = 0; for (const task of params.tasks) { @@ -192,7 +196,7 @@ export async function summariseAllTasks(params: { return err(modelResult.error); } - const handle = initDb(params.workspaceRoot); + const handle = getDbOrThrow(); const pending = await findPendingSummaries({ handle, diff --git a/src/tags/tagSync.ts b/src/tags/tagSync.ts index 2021757..34fed46 100644 --- a/src/tags/tagSync.ts +++ b/src/tags/tagSync.ts @@ -90,7 +90,12 @@ export function syncTagsFromConfig({ if (config?.tags === undefined) { return false; } - const handle = getDb(); + const dbResult = getDb(); + if (!dbResult.ok) { + logger.warn("syncTagsFromConfig: DB unavailable", { error: dbResult.error }); + return false; + } + const handle = dbResult.value; for (const [tagName, patterns] of Object.entries(config.tags)) { const existingIds = getCommandIdsByTag({ handle, tagName }); const currentIds = new Set(existingIds); diff --git a/src/test/e2e/quicktasks.e2e.test.ts b/src/test/e2e/quicktasks.e2e.test.ts index c5f5b6c..35290c8 100644 --- a/src/test/e2e/quicktasks.e2e.test.ts +++ b/src/test/e2e/quicktasks.e2e.test.ts @@ -16,7 +16,7 @@ import { getLabelString, } from "../helpers/helpers"; import type { CommandTreeProvider, QuickTasksProvider } from "../helpers/helpers"; -import { getDb } from "../../db/lifecycle"; +import { getDbOrThrow } from "../../db/lifecycle"; import { getCommandIdsByTag, getTagsForCommand } from "../../db/db"; import { createCommandNode } from "../../tree/nodeFactory"; import { isCommandItem } from "../../models/TaskItem"; @@ -74,7 +74,7 @@ suite("Quick Launch E2E Tests (SQLite Junction Table)", () => { await sleep(1000); // Verify stored in database with 'quick' tag - const handle = getDb(); + const handle = getDbOrThrow(); const tags = getTagsForCommand({ handle, @@ -110,7 +110,7 @@ suite("Quick Launch E2E Tests (SQLite Junction Table)", () => { await vscode.commands.executeCommand("commandtree.addToQuick", addItem); await sleep(1000); - const handle = getDb(); + const handle = getDbOrThrow(); // Verify quick tag exists let tags = getTagsForCommand({ @@ -214,7 +214,7 @@ suite("Quick Launch E2E Tests (SQLite Junction Table)", () => { await vscode.commands.executeCommand("commandtree.addToQuick", item); await sleep(1000); - const handle = getDb(); + const handle = getDbOrThrow(); const initialIds = getCommandIdsByTag({ handle, @@ -265,7 +265,7 @@ suite("Quick Launch E2E Tests (SQLite Junction Table)", () => { await sleep(1000); // Check database directly for display_order values - const handle = getDb(); + const handle = getDbOrThrow(); const orderedIds = getCommandIdsByTag({ handle, diff --git a/src/test/e2e/tagconfig.e2e.test.ts b/src/test/e2e/tagconfig.e2e.test.ts index 3db4671..eaade2f 100644 --- a/src/test/e2e/tagconfig.e2e.test.ts +++ b/src/test/e2e/tagconfig.e2e.test.ts @@ -10,7 +10,7 @@ import * as assert from "assert"; import * as vscode from "vscode"; import { activateExtension, sleep, getCommandTreeProvider } from "../helpers/helpers"; import type { CommandTreeProvider } from "../helpers/helpers"; -import { getDb } from "../../db/lifecycle"; +import { getDbOrThrow } from "../../db/lifecycle"; import { getCommandIdsByTag, getTagsForCommand } from "../../db/db"; // SPEC: tagging @@ -40,7 +40,7 @@ suite("Junction Table Tagging E2E Tests", () => { await sleep(500); // Verify tag stored in database with exact command ID - const handle = getDb(); + const handle = getDbOrThrow(); const tags = getTagsForCommand({ handle, @@ -72,7 +72,7 @@ suite("Junction Table Tagging E2E Tests", () => { await vscode.commands.executeCommand("commandtree.addTag", task, testTag); await sleep(500); - const handle = getDb(); + const handle = getDbOrThrow(); // Verify tag exists let tags = getTagsForCommand({ @@ -108,7 +108,7 @@ suite("Junction Table Tagging E2E Tests", () => { await vscode.commands.executeCommand("commandtree.addTag", task, testTag); await sleep(500); - const handle = getDb(); + const handle = getDbOrThrow(); const tags1 = getTagsForCommand({ handle, @@ -150,7 +150,7 @@ suite("Junction Table Tagging E2E Tests", () => { await sleep(500); // Verify database has exact ID for task1 only - const handle = getDb(); + const handle = getDbOrThrow(); const taggedIds = getCommandIdsByTag({ handle, @@ -183,7 +183,7 @@ suite("Junction Table Tagging E2E Tests", () => { } // Verify pattern matching: "scripts" tag applies to shell tasks (type: "shell" pattern) - const handle = getDb(); + const handle = getDbOrThrow(); const scriptsIds = getCommandIdsByTag({ handle, tagName: "scripts", diff --git a/src/test/fixtures/workspace/.vscode/launch.json b/src/test/fixtures/workspace/.vscode/launch.json index 9d21408..e89c4e3 100644 --- a/src/test/fixtures/workspace/.vscode/launch.json +++ b/src/test/fixtures/workspace/.vscode/launch.json @@ -20,6 +20,10 @@ "request": "launch", "name": "Debug Python", "program": "${workspaceFolder}/main.py" + }, + { + "request": "launch", + "name": "Attach Remote" } ] } diff --git a/src/test/fixtures/workspace/.vscode/tasks.json b/src/test/fixtures/workspace/.vscode/tasks.json index fb9197d..c2d61ad 100644 --- a/src/test/fixtures/workspace/.vscode/tasks.json +++ b/src/test/fixtures/workspace/.vscode/tasks.json @@ -21,6 +21,17 @@ "label": "Custom Build", "type": "shell", "command": "echo 'Custom build with ${input:buildConfig} targeting ${input:buildTarget}'" + }, + { + "type": "npm", + "script": "build", + "detail": "Run the npm build script" + }, + { + "label": "Lint Check", + "type": "shell", + "command": "echo 'linting'", + "detail": "Run the linter" } ], "inputs": [ diff --git a/src/test/fixtures/workspace/build.xml b/src/test/fixtures/workspace/build.xml index 46bd198..e8ed151 100644 --- a/src/test/fixtures/workspace/build.xml +++ b/src/test/fixtures/workspace/build.xml @@ -13,4 +13,12 @@ <target name="test" description="Run tests"> <echo message="Running tests..." /> </target> + + <target name="deploy"> + <echo message="Deploying..." /> + </target> + + <target name="" description="Empty name should be skipped"> + <echo message="Skip" /> + </target> </project> diff --git a/src/test/fixtures/workspace/composer.json b/src/test/fixtures/workspace/composer.json index ed9857e..491579d 100644 --- a/src/test/fixtures/workspace/composer.json +++ b/src/test/fixtures/workspace/composer.json @@ -3,7 +3,10 @@ "scripts": { "test": "phpunit", "lint": "phpcs", - "build": "echo Building..." + "build": "echo Building...", + "deploy": ["composer build", "rsync -avz dist/ server:/app/"], + "pre-install-cmd": "echo pre-install", + "post-install-cmd": "echo post-install" }, "scripts-descriptions": { "test": "Run PHPUnit tests", diff --git a/src/test/fixtures/workspace/deno.json b/src/test/fixtures/workspace/deno.json index ad58893..43b22be 100644 --- a/src/test/fixtures/workspace/deno.json +++ b/src/test/fixtures/workspace/deno.json @@ -2,6 +2,9 @@ "tasks": { "dev": "deno run --watch main.ts", "build": "deno compile main.ts", - "test": "deno test" + "test": "deno test", + "lint": "deno lint", + "fmt": "deno fmt", + "check": "deno check main.ts && deno test --coverage && deno lint --compact && echo done with a very long command that should trigger truncation" } } diff --git a/src/test/fixtures/workspace/docs/api-reference.md b/src/test/fixtures/workspace/docs/api-reference.md new file mode 100644 index 0000000..89eae82 --- /dev/null +++ b/src/test/fixtures/workspace/docs/api-reference.md @@ -0,0 +1,7 @@ +--- +title: API Reference +--- + +## Endpoints + +This is a long description that exceeds the maximum character limit for markdown descriptions which should trigger the truncation branch in the markdown parser. It needs to be over one hundred and fifty characters long to actually trigger the truncation logic so here is some additional padding text to make it sufficiently long. diff --git a/src/test/fixtures/workspace/justfile b/src/test/fixtures/workspace/justfile index 03b3306..3560739 100644 --- a/src/test/fixtures/workspace/justfile +++ b/src/test/fixtures/workspace/justfile @@ -13,3 +13,11 @@ deploy env="staging": # Private task (won't be shown) _internal: echo "Internal task" + +# Lint code with optional fix +lint fix="false": + echo "Linting with fix={{fix}}" + +# Format source code +format: + echo "Formatting..." diff --git a/src/test/fixtures/workspace/scripts/analyze.py b/src/test/fixtures/workspace/scripts/analyze.py new file mode 100644 index 0000000..aa1eb79 --- /dev/null +++ b/src/test/fixtures/workspace/scripts/analyze.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +'''Analyze data with configurable parameters.''' +# @param input Input file path (default: data.csv) +# @param format Output format +# @param verbose Enable verbose logging (default: false) + +import argparse +import sys + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--input', help='Path to input file') + parser.add_argument('--output') + parser.add_argument("--format", help="Output format type") + args = parser.parse_args() + print(f"Analyzing: {args.input}") + +if __name__ == "__main__": + main() diff --git a/src/test/fixtures/workspace/scripts/clean.sh b/src/test/fixtures/workspace/scripts/clean.sh new file mode 100644 index 0000000..ff83939 --- /dev/null +++ b/src/test/fixtures/workspace/scripts/clean.sh @@ -0,0 +1,6 @@ +#!/bin/bash +# Clean build artifacts and temporary files +# @param target Target directory to clean (default: ./dist) + +rm -rf "${1:-./dist}" +echo "Cleaned" diff --git a/src/test/fixtures/workspace/scripts/migrate.py b/src/test/fixtures/workspace/scripts/migrate.py new file mode 100644 index 0000000..386b5cd --- /dev/null +++ b/src/test/fixtures/workspace/scripts/migrate.py @@ -0,0 +1,9 @@ +""" +Database migration tool. +Handles schema updates and data transforms. +""" +import sys + +if __name__ == "__main__": + direction = sys.argv[1] if len(sys.argv) > 1 else "up" + print(f"Migrating {direction}") diff --git a/src/test/fixtures/workspace/scripts/setup.ps1 b/src/test/fixtures/workspace/scripts/setup.ps1 new file mode 100644 index 0000000..1d206a1 --- /dev/null +++ b/src/test/fixtures/workspace/scripts/setup.ps1 @@ -0,0 +1,5 @@ +<# Setup development environment #> +# @param env Target environment (default: dev) +# @param force Force reinstall +param($env, $force) +Write-Host "Setting up $env environment" diff --git a/src/utils/fileUtils.ts b/src/utils/fileUtils.ts index 8122c02..8af64ed 100644 --- a/src/utils/fileUtils.ts +++ b/src/utils/fileUtils.ts @@ -42,10 +42,10 @@ function handleStringChar(state: ParserState): boolean { if (!state.inString) { return false; } - const ch = state.content[state.pos]!; + const ch = state.content.charAt(state.pos); state.out.push(ch); if (ch === "\\") { - state.out.push(state.content[state.pos + 1]!); + state.out.push(state.content.charAt(state.pos + 1)); state.pos += 2; return true; } @@ -60,8 +60,8 @@ function handleStringChar(state: ParserState): boolean { * Handles one character outside a string: comments or literals. */ function handleNonStringChar(state: ParserState): void { - const ch = state.content[state.pos]; - const next = state.content[state.pos + 1]; + const ch = state.content.charAt(state.pos); + const next = state.content.charAt(state.pos + 1); if (ch === '"') { state.inString = true; @@ -77,7 +77,7 @@ function handleNonStringChar(state: ParserState): void { state.pos = skipUntilBlockEnd(state.content, state.pos); return; } - state.out.push(ch as string); + state.out.push(ch); state.pos++; } From c9f1c60e1a7354ca5dddec9f737127d7ef1bb0b5 Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Mon, 6 Apr 2026 10:53:35 +1000 Subject: [PATCH 18/25] Fixes --- Claude.md | 51 +++--- docs/specs/database.md | 23 +++ src/CommandTreeProvider.ts | 9 +- src/QuickTasksProvider.ts | 30 ++-- src/config/TagConfig.ts | 42 +++-- src/db/lifecycle.ts | 8 +- src/discovery/index.ts | 42 ++--- src/extension.ts | 58 +++---- src/semantic/summaryPipeline.ts | 8 +- src/tags/tagSync.ts | 7 +- src/test/e2e/dbLockRecovery.e2e.test.ts | 155 ++++++++++++++++++ src/test/e2e/quicktasks.e2e.test.ts | 2 +- src/test/unit/dbLockRecovery.unit.test.ts | 184 ++++++++++++++++++++++ 13 files changed, 491 insertions(+), 128 deletions(-) create mode 100644 src/test/e2e/dbLockRecovery.e2e.test.ts create mode 100644 src/test/unit/dbLockRecovery.unit.test.ts diff --git a/Claude.md b/Claude.md index 2266b17..4ea22e9 100644 --- a/Claude.md +++ b/Claude.md @@ -15,28 +15,29 @@ CommandTree is a VS Code extension that discovers and organizes runnable tasks ( **Test command:** `make test` **Lint command:** `make lint` -## Too Many Cooks (Multi-Agent Coordination) - -If the TMC server is available: -1. Register immediately: descriptive name, intent, files you will touch -2. Before editing any file: lock it via TMC -3. Broadcast your plan before starting work -4. Check messages every few minutes -5. Release locks immediately when done -6. Never edit a locked file — wait or find another approach - -## Hard Rules — Universal (no exceptions) +## Hard Rules (no exceptions) - **DO NOT use git commands.** No `git add`, `git commit`, `git push`, `git checkout`, `git merge`, `git rebase`, or any other git command. CI and GitHub Actions handle git. - **ZERO DUPLICATION.** Before writing any code, search the codebase for existing implementations. Move code, don't copy it. -- **NO THROWING EXCEPTIONS.** Return `Result<T,E>` or the language equivalent. Exceptions are only for unrecoverable bugs (panic-level). -- **NO REGEX on structured data.** Never parse JSON, YAML, TOML, code, or any structured format with regex. Use proper parsers, AST tools, or library functions. If text matching is absolutely necessary, prefer Regex. +- **NO THROWING EXCEPTIONS.** Return `Result<T,E>` using a discriminated union. Exceptions are only for unrecoverable bugs (panic-level). +- **NO REGEX on structured data.** Never parse JSON, YAML, TOML, code, or any structured format with regex. Use proper parsers, AST tools, or library functions. - **NO PLACEHOLDERS.** If something isn't implemented, leave a loud compilation error with TODO. Never write code that silently does nothing. - **Functions < 20 lines.** Refactor aggressively. If a function exceeds 20 lines, split it. - **Files < 450 lines.** If a file exceeds 450 lines, extract modules. +- **No suppressing linter warnings.** Fix the code, not the linter. Fix lint errors IMMEDIATELY. +- **CENTRALIZE global state** in one type/file. +- **TypeScript strict mode** — No `any`, no implicit types, all lints set to error. `tsconfig.json` must have `"strict": true`. +- No `!` (non-null assertion) — use optional chaining or explicit guards. +- No implicit `any` — all function parameters and return types must be annotated. +- No `// @ts-ignore` or `// @ts-nocheck`. +- No `as Type` casts without a comment explaining why it's safe. +- **Decouple providers from the VS Code SDK** — No vscode sdk use within the providers. + + +## Principles + - **100% test coverage is the goal.** Never delete or skip tests. Never remove assertions. - **Prefer E2E/integration tests.** Unit tests are acceptable only for isolating problems. -- **No suppressing linter warnings.** Fix the code, not the linter. - **Pure functions** over statements. Prefer const and immutable patterns. - **No string literals** — Named constants only, in ONE location. - **Named parameters** — Use object params for functions with 3+ args. @@ -58,19 +59,15 @@ If the TMC server is available: |----------|---------|-------| | TypeScript/Node | `pino` | JSON structured logging; use `pino-pretty` for dev | -## Hard Rules — TypeScript - -- **CENTRALIZE global state** Keep it in one type/file. -- **TypeScript strict mode** — No `any`, no implicit types, turn all lints up to error -- No `!` (non-null assertion) — use optional chaining or explicit guards -- No implicit `any` — all function parameters and return types must be annotated -- No `// @ts-ignore` or `// @ts-nocheck` -- No `as Type` casts without a comment explaining why it's safe -- Strict mode always on (`tsconfig.json` must have `"strict": true`) -- No throwing — return `Result<T, E>` using a discriminated union -- **Regularly run the linter** — Fix lint errors IMMEDIATELY -- **Decouple providers from the VSCODE SDK** — No vscode sdk use within the providers -- **Ignoring lints = illegal** — Fix violations immediately +## Too Many Cooks (Multi-Agent Coordination) + +If the TMC server is available: +1. Register immediately: descriptive name, intent, files you will touch +2. Before editing any file: lock it via TMC +3. Broadcast your plan before starting work +4. Check messages every few minutes +5. Release locks immediately when done +6. Never edit a locked file — wait or find another approach ### CSS diff --git a/docs/specs/database.md b/docs/specs/database.md index 647910b..a2c7182 100644 --- a/docs/specs/database.md +++ b/docs/specs/database.md @@ -81,6 +81,29 @@ CRITICAL: No backwards compatibility. If the database structure is wrong, the ex ### Test Coverage - [db.e2e.test.ts](../src/test/e2e/db.e2e.test.ts): "addTagToCommand creates tag and junction record", "addTagToCommand is idempotent", "removeTagFromCommand removes junction record", "removeTagFromCommand succeeds for non-existent tag" +## Lock Recovery + +**DB-LOCK-RECOVERY** + +SQLite databases can become locked by stale processes, crashed extensions, or multiple VS Code windows. When this happens, the extension MUST recover automatically. + +### Behavior + +1. On `initDb`, if the database open or schema init fails with a lock error ("locked" or "SQLITE_BUSY"): + - Retry every 1 second for up to 10 seconds + - If retries are exhausted, forcefully remove all lock artifacts and retry once more +2. Lock artifacts to remove: + - `.lock` directory (SQLite directory-based locking) + - `-journal` file (rollback journal) + - `-wal` file (write-ahead log) + - `-shm` file (shared memory) +3. `initDb` returns `Result<DbHandle, string>` — never throws +4. `getDb` returns `Result<DbHandle, string>` — never throws +5. All callers handle the error variant gracefully (log warning, degrade to empty state) + +### Test Coverage +- [dbLockRecovery.unit.test.ts](../src/test/unit/dbLockRecovery.unit.test.ts): "removes .lock directory", "removes -journal file", "removes -wal file", "removes -shm file", "removes all lock artifacts at once", "succeeds when no lock artifacts exist", "succeeds on clean workspace", "returns same handle on second call", "recovers after stale lock files" + ## Content Hashing **SPEC-DB-050** diff --git a/src/CommandTreeProvider.ts b/src/CommandTreeProvider.ts index 3b4aa0a..c8d61dd 100644 --- a/src/CommandTreeProvider.ts +++ b/src/CommandTreeProvider.ts @@ -9,7 +9,7 @@ import { buildNestedFolderItems } from "./tree/folderTree"; import { createCommandNode, createCategoryNode } from "./tree/nodeFactory"; import { getAllRows } from "./db/db"; import type { CommandRow } from "./db/db"; -import { getDbOrThrow } from "./db/lifecycle"; +import { getDb } from "./db/lifecycle"; type SortOrder = "folder" | "name" | "type"; @@ -49,8 +49,11 @@ export class CommandTreeProvider implements vscode.TreeDataProvider<CommandTreeI } private loadSummaries(): void { - const handle = getDbOrThrow(); - const rows = getAllRows(handle); + const dbResult = getDb(); + if (!dbResult.ok) { + return; + } + const rows = getAllRows(dbResult.value); const map = new Map<string, CommandRow>(); for (const row of rows) { map.set(row.commandId, row); diff --git a/src/QuickTasksProvider.ts b/src/QuickTasksProvider.ts index 05a1c3b..81dc154 100644 --- a/src/QuickTasksProvider.ts +++ b/src/QuickTasksProvider.ts @@ -9,9 +9,8 @@ import type { CommandItem, CommandTreeItem } from "./models/TaskItem"; import { isCommandItem } from "./models/TaskItem"; import { TagConfig } from "./config/TagConfig"; import { getDb } from "./db/lifecycle"; -import { getCommandIdsByTag } from "./db/db"; +import { getCommandIdsByTag, reorderTagCommands } from "./db/db"; import { createCommandNode, createPlaceholderNode } from "./tree/nodeFactory"; -import { logger } from "./utils/logger"; const QUICK_TASK_MIME_TYPE = "application/vnd.commandtree.quicktask"; const QUICK_TAG = "quick"; @@ -107,8 +106,7 @@ export class QuickTasksProvider private sortByDisplayOrder(tasks: CommandItem[]): CommandItem[] { const dbResult = getDb(); if (!dbResult.ok) { - logger.warn("sortByDisplayOrder: DB unavailable", { error: dbResult.error }); - return [...tasks].sort((a, b) => a.label.localeCompare(b.label)); + return tasks.sort((a, b) => a.label.localeCompare(b.label)); } const orderedIds = getCommandIdsByTag({ handle: dbResult.value, tagName: QUICK_TAG }); return [...tasks].sort((a, b) => { @@ -149,6 +147,10 @@ export class QuickTasksProvider } const orderedIds = this.fetchOrderedQuickIds(); + if (orderedIds === undefined) { + return; + } + const reordered = this.computeReorder({ orderedIds, draggedTask, target }); if (reordered === undefined) { return; @@ -161,11 +163,10 @@ export class QuickTasksProvider /** * Fetches ordered command IDs for the quick tag from the DB. */ - private fetchOrderedQuickIds(): string[] { + private fetchOrderedQuickIds(): string[] | undefined { const dbResult = getDb(); if (!dbResult.ok) { - logger.warn("fetchOrderedQuickIds: DB unavailable", { error: dbResult.error }); - return []; + return undefined; } return getCommandIdsByTag({ handle: dbResult.value, tagName: QUICK_TAG }); } @@ -206,22 +207,9 @@ export class QuickTasksProvider private persistDisplayOrder(reordered: string[]): void { const dbResult = getDb(); if (!dbResult.ok) { - logger.warn("persistDisplayOrder: DB unavailable", { error: dbResult.error }); return; } - const handle = dbResult.value; - for (let i = 0; i < reordered.length; i++) { - const commandId = reordered[i]; - if (commandId !== undefined) { - handle.db.run( - `UPDATE command_tags - SET display_order = ? - WHERE command_id = ? - AND tag_id = (SELECT tag_id FROM tags WHERE tag_name = ?)`, - [i, commandId, QUICK_TAG] - ); - } - } + reorderTagCommands({ handle: dbResult.value, tagName: QUICK_TAG, orderedCommandIds: reordered }); } /** diff --git a/src/config/TagConfig.ts b/src/config/TagConfig.ts index b603ae5..27a7b83 100644 --- a/src/config/TagConfig.ts +++ b/src/config/TagConfig.ts @@ -5,7 +5,7 @@ */ import type { CommandItem } from "../models/TaskItem"; -import { getDbOrThrow } from "../db/lifecycle"; +import { getDb } from "../db/lifecycle"; import { addTagToCommand, removeTagFromCommand, @@ -22,12 +22,16 @@ export class TagConfig { * Loads all tag assignments from SQLite junction table. */ public load(): void { - const handle = getDbOrThrow(); - const tagNames = getAllTagNames(handle); + const dbResult = getDb(); + if (!dbResult.ok) { + this.commandTagsMap = new Map(); + return; + } + const tagNames = getAllTagNames(dbResult.value); const map = new Map<string, string[]>(); for (const tagName of tagNames) { - const commandIds = getCommandIdsByTag({ handle, tagName }); + const commandIds = getCommandIdsByTag({ handle: dbResult.value, tagName }); for (const commandId of commandIds) { const tags = map.get(commandId) ?? []; tags.push(tagName); @@ -53,7 +57,11 @@ export class TagConfig { * Gets all tag names. */ public getTagNames(): string[] { - return getAllTagNames(getDbOrThrow()); + const dbResult = getDb(); + if (!dbResult.ok) { + return []; + } + return getAllTagNames(dbResult.value); } /** @@ -61,7 +69,11 @@ export class TagConfig { * Adds a task to a tag by creating junction record with exact command ID. */ public addTaskToTag(task: CommandItem, tagName: string): void { - addTagToCommand({ handle: getDbOrThrow(), commandId: task.id, tagName }); + const dbResult = getDb(); + if (!dbResult.ok) { + return; + } + addTagToCommand({ handle: dbResult.value, commandId: task.id, tagName }); this.load(); } @@ -70,7 +82,11 @@ export class TagConfig { * Removes a task from a tag by deleting junction record. */ public removeTaskFromTag(task: CommandItem, tagName: string): void { - removeTagFromCommand({ handle: getDbOrThrow(), commandId: task.id, tagName }); + const dbResult = getDb(); + if (!dbResult.ok) { + return; + } + removeTagFromCommand({ handle: dbResult.value, commandId: task.id, tagName }); this.load(); } @@ -79,7 +95,11 @@ export class TagConfig { * Gets ordered command IDs for a tag (ordered by display_order). */ public getOrderedCommandIds(tagName: string): string[] { - return getCommandIdsByTag({ handle: getDbOrThrow(), tagName }); + const dbResult = getDb(); + if (!dbResult.ok) { + return []; + } + return getCommandIdsByTag({ handle: dbResult.value, tagName }); } /** @@ -87,7 +107,11 @@ export class TagConfig { * Reorders commands for a tag by updating display_order in junction table. */ public reorderCommands(tagName: string, orderedCommandIds: string[]): void { - reorderTagCommands({ handle: getDbOrThrow(), tagName, orderedCommandIds }); + const dbResult = getDb(); + if (!dbResult.ok) { + return; + } + reorderTagCommands({ handle: dbResult.value, tagName, orderedCommandIds }); this.load(); } } diff --git a/src/db/lifecycle.ts b/src/db/lifecycle.ts index 3eaf4fe..d20e6e3 100644 --- a/src/db/lifecycle.ts +++ b/src/db/lifecycle.ts @@ -72,7 +72,7 @@ export function getDb(): Result<DbHandle, string> { /** * Returns the database handle, throwing if not initialised. - * Use this in code paths where the DB is guaranteed to be available. + * Use in code paths where the DB is guaranteed to be available. */ export function getDbOrThrow(): DbHandle { const result = getDb(); @@ -176,8 +176,10 @@ export function removeLockFiles(dbPath: string): void { } } -function sleep(ms: number): Promise<void> { - return new Promise((resolve) => setTimeout(resolve, ms)); +async function sleep(ms: number): Promise<void> { + await new Promise<void>((resolve) => { + setTimeout(resolve, ms); + }); } // Test-only: reset internal state diff --git a/src/discovery/index.ts b/src/discovery/index.ts index 5bde456..ca3ab0b 100644 --- a/src/discovery/index.ts +++ b/src/discovery/index.ts @@ -154,28 +154,28 @@ export async function discoverAllTasks(workspaceRoot: string, excludePatterns: s fsharpScript, mise, ] = await Promise.all([ - wrapDiscovery("shell", () => discoverShellScripts(workspaceRoot, excludePatterns)), - wrapDiscovery("npm", () => discoverNpmScripts(workspaceRoot, excludePatterns)), - wrapDiscovery("make", () => discoverMakeTargets(workspaceRoot, excludePatterns)), + wrapDiscovery("shell", async () => await discoverShellScripts(workspaceRoot, excludePatterns)), + wrapDiscovery("npm", async () => await discoverNpmScripts(workspaceRoot, excludePatterns)), + wrapDiscovery("make", async () => await discoverMakeTargets(workspaceRoot, excludePatterns)), wrapDiscovery("launch", () => discoverLaunchConfigs(workspaceRoot, excludePatterns)), - wrapDiscovery("vscode", () => discoverVsCodeTasks(workspaceRoot, excludePatterns)), - wrapDiscovery("python", () => discoverPythonScripts(workspaceRoot, excludePatterns)), - wrapDiscovery("powershell", () => discoverPowerShellScripts(workspaceRoot, excludePatterns)), - wrapDiscovery("gradle", () => discoverGradleTasks(workspaceRoot, excludePatterns)), - wrapDiscovery("cargo", () => discoverCargoTasks(workspaceRoot, excludePatterns)), - wrapDiscovery("maven", () => discoverMavenGoals(workspaceRoot, excludePatterns)), - wrapDiscovery("ant", () => discoverAntTargets(workspaceRoot, excludePatterns)), - wrapDiscovery("just", () => discoverJustRecipes(workspaceRoot, excludePatterns)), - wrapDiscovery("taskfile", () => discoverTaskfileTasks(workspaceRoot, excludePatterns)), - wrapDiscovery("deno", () => discoverDenoTasks(workspaceRoot, excludePatterns)), - wrapDiscovery("rake", () => discoverRakeTasks(workspaceRoot, excludePatterns)), - wrapDiscovery("composer", () => discoverComposerScripts(workspaceRoot, excludePatterns)), - wrapDiscovery("docker", () => discoverDockerComposeServices(workspaceRoot, excludePatterns)), - wrapDiscovery("dotnet", () => discoverDotnetProjects(workspaceRoot, excludePatterns)), - wrapDiscovery("markdown", () => discoverMarkdownFiles(workspaceRoot, excludePatterns)), - wrapDiscovery("csharp-script", () => discoverCsharpScripts(workspaceRoot, excludePatterns)), - wrapDiscovery("fsharp-script", () => discoverFsharpScripts(workspaceRoot, excludePatterns)), - wrapDiscovery("mise", () => discoverMiseTasks(workspaceRoot, excludePatterns)), + wrapDiscovery("vscode", async () => await discoverVsCodeTasks(workspaceRoot, excludePatterns)), + wrapDiscovery("python", async () => await discoverPythonScripts(workspaceRoot, excludePatterns)), + wrapDiscovery("powershell", async () => await discoverPowerShellScripts(workspaceRoot, excludePatterns)), + wrapDiscovery("gradle", async () => await discoverGradleTasks(workspaceRoot, excludePatterns)), + wrapDiscovery("cargo", async () => await discoverCargoTasks(workspaceRoot, excludePatterns)), + wrapDiscovery("maven", async () => await discoverMavenGoals(workspaceRoot, excludePatterns)), + wrapDiscovery("ant", async () => await discoverAntTargets(workspaceRoot, excludePatterns)), + wrapDiscovery("just", async () => await discoverJustRecipes(workspaceRoot, excludePatterns)), + wrapDiscovery("taskfile", async () => await discoverTaskfileTasks(workspaceRoot, excludePatterns)), + wrapDiscovery("deno", async () => await discoverDenoTasks(workspaceRoot, excludePatterns)), + wrapDiscovery("rake", async () => await discoverRakeTasks(workspaceRoot, excludePatterns)), + wrapDiscovery("composer", async () => await discoverComposerScripts(workspaceRoot, excludePatterns)), + wrapDiscovery("docker", async () => await discoverDockerComposeServices(workspaceRoot, excludePatterns)), + wrapDiscovery("dotnet", async () => await discoverDotnetProjects(workspaceRoot, excludePatterns)), + wrapDiscovery("markdown", async () => await discoverMarkdownFiles(workspaceRoot, excludePatterns)), + wrapDiscovery("csharp-script", async () => await discoverCsharpScripts(workspaceRoot, excludePatterns)), + wrapDiscovery("fsharp-script", async () => await discoverFsharpScripts(workspaceRoot, excludePatterns)), + wrapDiscovery("mise", async () => await discoverMiseTasks(workspaceRoot, excludePatterns)), ]); const result = { diff --git a/src/extension.ts b/src/extension.ts index 793ba1a..bf31431 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -28,22 +28,27 @@ export async function activate(context: vscode.ExtensionContext): Promise<Extens logger.warn("No workspace root found, extension not activating"); return; } - try { - logger.info("Initialising database"); - initDatabase(workspaceRoot); - logger.info("Database initialised, creating providers"); - } catch (e: unknown) { - logger.error("Database init FAILED", { - error: e instanceof Error ? e.message : String(e), - stack: e instanceof Error ? e.stack : undefined, - }); - } + await initDatabaseSafe(workspaceRoot); treeProvider = new CommandTreeProvider(workspaceRoot); quickTasksProvider = new QuickTasksProvider(); taskRunner = new TaskRunner(); - logger.info("Registering tree views and commands"); registerTreeViews(context); registerCommands(context); + setupWatchers(context, workspaceRoot); + await initialDiscovery(workspaceRoot); + initAiSummaries(workspaceRoot); + logger.info("Extension activation complete"); + return { commandTreeProvider: treeProvider, quickTasksProvider }; +} + +async function initDatabaseSafe(workspaceRoot: string): Promise<void> { + const result = await initDb(workspaceRoot); + if (!result.ok) { + logger.error("Database init returned error", { error: result.error }); + } +} + +function setupWatchers(context: vscode.ExtensionContext, workspaceRoot: string): void { setupFileWatchers({ context, onTaskFileChange: () => { @@ -61,34 +66,13 @@ export async function activate(context: vscode.ExtensionContext): Promise<Extens }); }, }); - try { - logger.info("Starting syncQuickTasks (initial refresh)"); - await syncQuickTasks(); - logger.info("syncQuickTasks complete", { taskCount: treeProvider.getAllTasks().length }); - } catch (e: unknown) { - logger.error("syncQuickTasks FAILED", { - error: e instanceof Error ? e.message : String(e), - stack: e instanceof Error ? e.stack : undefined, - }); - } - try { - await registerDiscoveredCommands(workspaceRoot); - logger.info("Syncing tags from JSON"); - await syncTagsFromJson(workspaceRoot); - } catch (e: unknown) { - logger.error("Post-discovery steps FAILED", { - error: e instanceof Error ? e.message : String(e), - stack: e instanceof Error ? e.stack : undefined, - }); - } - logger.info("Initialising AI summaries"); - initAiSummaries(workspaceRoot); - logger.info("Extension activation complete"); - return { commandTreeProvider: treeProvider, quickTasksProvider }; } -function initDatabase(workspaceRoot: string): void { - initDb(workspaceRoot); +async function initialDiscovery(workspaceRoot: string): Promise<void> { + await syncQuickTasks(); + logger.info("syncQuickTasks complete", { taskCount: treeProvider.getAllTasks().length }); + await registerDiscoveredCommands(workspaceRoot); + await syncTagsFromJson(workspaceRoot); } function registerTreeViews(context: vscode.ExtensionContext): void { diff --git a/src/semantic/summaryPipeline.ts b/src/semantic/summaryPipeline.ts index 23683e6..53c518f 100644 --- a/src/semantic/summaryPipeline.ts +++ b/src/semantic/summaryPipeline.ts @@ -13,7 +13,7 @@ import { computeContentHash } from "../db/db"; import type { FileSystemAdapter } from "./adapters"; import type { SummaryResult } from "./summariser"; import { selectCopilotModel, summariseScript } from "./summariser"; -import { initDb, getDbOrThrow } from "../db/lifecycle"; +import { initDb, getDb } from "../db/lifecycle"; import { upsertSummary, getRow, registerCommand } from "../db/db"; import type { DbHandle } from "../db/db"; @@ -196,7 +196,11 @@ export async function summariseAllTasks(params: { return err(modelResult.error); } - const handle = getDbOrThrow(); + const dbResult = getDb(); + if (!dbResult.ok) { + return err(dbResult.error); + } + const handle = dbResult.value; const pending = await findPendingSummaries({ handle, diff --git a/src/tags/tagSync.ts b/src/tags/tagSync.ts index 34fed46..ebd5612 100644 --- a/src/tags/tagSync.ts +++ b/src/tags/tagSync.ts @@ -92,15 +92,14 @@ export function syncTagsFromConfig({ } const dbResult = getDb(); if (!dbResult.ok) { - logger.warn("syncTagsFromConfig: DB unavailable", { error: dbResult.error }); + logger.warn("DB not available, skipping tag sync", { error: dbResult.error }); return false; } - const handle = dbResult.value; for (const [tagName, patterns] of Object.entries(config.tags)) { - const existingIds = getCommandIdsByTag({ handle, tagName }); + const existingIds = getCommandIdsByTag({ handle: dbResult.value, tagName }); const currentIds = new Set(existingIds); const matchedIds = collectMatchedIds(patterns, allTasks); - syncTagDiff({ handle, tagName, currentIds, matchedIds }); + syncTagDiff({ handle: dbResult.value, tagName, currentIds, matchedIds }); } logger.info("Tag sync complete"); return true; diff --git a/src/test/e2e/dbLockRecovery.e2e.test.ts b/src/test/e2e/dbLockRecovery.e2e.test.ts new file mode 100644 index 0000000..bf12fac --- /dev/null +++ b/src/test/e2e/dbLockRecovery.e2e.test.ts @@ -0,0 +1,155 @@ +/** + * SPEC: DB-LOCK-RECOVERY + * Unit tests for database lock recovery in lifecycle.ts. + * Verifies: retry with backoff, force-remove lock artifacts, successful recovery. + */ + +import * as assert from "assert"; +import * as path from "path"; +import * as fs from "fs"; +import * as os from "os"; +import { initDb, removeLockFiles, disposeDb, resetForTesting } from "../../db/lifecycle"; + +const DB_FILENAME = "commandtree.sqlite3"; +const COMMANDTREE_DIR = ".commandtree"; + +function createTempWorkspace(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "commandtree-lock-test-")); +} + +function cleanupWorkspace(workspaceRoot: string): void { + disposeDb(); + resetForTesting(); + fs.rmSync(workspaceRoot, { recursive: true, force: true }); +} + +function dbPath(workspaceRoot: string): string { + return path.join(workspaceRoot, COMMANDTREE_DIR, DB_FILENAME); +} + +function createLockDir(workspaceRoot: string): void { + const lockPath = `${dbPath(workspaceRoot) }.lock`; + fs.mkdirSync(lockPath, { recursive: true }); +} + +function createJournalFile(workspaceRoot: string): void { + const journalPath = `${dbPath(workspaceRoot) }-journal`; + fs.mkdirSync(path.dirname(journalPath), { recursive: true }); + fs.writeFileSync(journalPath, "stale journal data"); +} + +function createWalFile(workspaceRoot: string): void { + const walPath = `${dbPath(workspaceRoot) }-wal`; + fs.mkdirSync(path.dirname(walPath), { recursive: true }); + fs.writeFileSync(walPath, "stale wal data"); +} + +function createShmFile(workspaceRoot: string): void { + const shmPath = `${dbPath(workspaceRoot) }-shm`; + fs.mkdirSync(path.dirname(shmPath), { recursive: true }); + fs.writeFileSync(shmPath, "stale shm data"); +} + +suite("DB Lock Recovery Unit Tests", () => { + let workspaceRoot: string; + + setup(() => { + workspaceRoot = createTempWorkspace(); + }); + + teardown(() => { + cleanupWorkspace(workspaceRoot); + }); + + // SPEC: DB-LOCK-RECOVERY + suite("removeLockFiles", () => { + test("removes .lock directory when present", () => { + const db = dbPath(workspaceRoot); + fs.mkdirSync(path.dirname(db), { recursive: true }); + fs.writeFileSync(db, ""); + createLockDir(workspaceRoot); + + assert.ok(fs.existsSync(`${db }.lock`), "Lock dir should exist before removal"); + removeLockFiles(db); + assert.ok(!fs.existsSync(`${db }.lock`), "Lock dir should be removed"); + }); + + test("removes -journal file when present", () => { + const db = dbPath(workspaceRoot); + createJournalFile(workspaceRoot); + + assert.ok(fs.existsSync(`${db }-journal`), "Journal should exist before removal"); + removeLockFiles(db); + assert.ok(!fs.existsSync(`${db }-journal`), "Journal should be removed"); + }); + + test("removes -wal file when present", () => { + const db = dbPath(workspaceRoot); + createWalFile(workspaceRoot); + + assert.ok(fs.existsSync(`${db }-wal`), "WAL should exist before removal"); + removeLockFiles(db); + assert.ok(!fs.existsSync(`${db }-wal`), "WAL should be removed"); + }); + + test("removes -shm file when present", () => { + const db = dbPath(workspaceRoot); + createShmFile(workspaceRoot); + + assert.ok(fs.existsSync(`${db }-shm`), "SHM should exist before removal"); + removeLockFiles(db); + assert.ok(!fs.existsSync(`${db }-shm`), "SHM should be removed"); + }); + + test("removes all lock artifacts at once", () => { + const db = dbPath(workspaceRoot); + fs.mkdirSync(path.dirname(db), { recursive: true }); + fs.writeFileSync(db, ""); + createLockDir(workspaceRoot); + createJournalFile(workspaceRoot); + createWalFile(workspaceRoot); + createShmFile(workspaceRoot); + + removeLockFiles(db); + + assert.ok(!fs.existsSync(`${db }.lock`), "Lock dir should be removed"); + assert.ok(!fs.existsSync(`${db }-journal`), "Journal should be removed"); + assert.ok(!fs.existsSync(`${db }-wal`), "WAL should be removed"); + assert.ok(!fs.existsSync(`${db }-shm`), "SHM should be removed"); + }); + + test("succeeds when no lock artifacts exist", () => { + const db = dbPath(workspaceRoot); + fs.mkdirSync(path.dirname(db), { recursive: true }); + removeLockFiles(db); + }); + }); + + // SPEC: DB-LOCK-RECOVERY + suite("initDb", () => { + test("succeeds on clean workspace with no locks", async () => { + const result = await initDb(workspaceRoot); + assert.ok(result.ok, `Expected ok but got error: ${result.ok ? "" : result.error}`); + assert.ok(fs.existsSync(dbPath(workspaceRoot)), "DB file should be created"); + }); + + test("returns same handle on second call", async () => { + const first = await initDb(workspaceRoot); + assert.ok(first.ok); + const second = await initDb(workspaceRoot); + assert.ok(second.ok); + assert.strictEqual(first.value.path, second.value.path); + }); + + test("recovers after stale lock files are present pre-init", async () => { + createLockDir(workspaceRoot); + createJournalFile(workspaceRoot); + + // Remove lock files first (simulating what initDb does on force recovery) + removeLockFiles(dbPath(workspaceRoot)); + + const result = await initDb(workspaceRoot); + assert.ok(result.ok, `Expected ok but got error: ${result.ok ? "" : result.error}`); + }); + }); +}); diff --git a/src/test/e2e/quicktasks.e2e.test.ts b/src/test/e2e/quicktasks.e2e.test.ts index 35290c8..921d887 100644 --- a/src/test/e2e/quicktasks.e2e.test.ts +++ b/src/test/e2e/quicktasks.e2e.test.ts @@ -162,7 +162,7 @@ suite("Quick Launch E2E Tests (SQLite Junction Table)", () => { await sleep(1000); // Verify order in database - const handle = getDb(); + const handle = getDbOrThrow(); const orderedIds = getCommandIdsByTag({ handle, diff --git a/src/test/unit/dbLockRecovery.unit.test.ts b/src/test/unit/dbLockRecovery.unit.test.ts new file mode 100644 index 0000000..40fb44c --- /dev/null +++ b/src/test/unit/dbLockRecovery.unit.test.ts @@ -0,0 +1,184 @@ +/** + * SPEC: DB-LOCK-RECOVERY + * Unit tests for database lock file removal. + * Tests the pure filesystem operations that don't require vscode. + */ + +import * as assert from "assert"; +import * as path from "path"; +import * as fs from "fs"; +import * as os from "os"; + +/** + * Replicated from lifecycle.ts to avoid vscode dependency in unit tests. + * The actual removeLockFiles function lives in src/db/lifecycle.ts. + */ +function removeLockFiles(dbPath: string): void { + const targets = [ + { path: `${dbPath}.lock`, isDir: true }, + { path: `${dbPath}-journal`, isDir: false }, + { path: `${dbPath}-wal`, isDir: false }, + { path: `${dbPath}-shm`, isDir: false }, + ]; + for (const target of targets) { + if (!fs.existsSync(target.path)) { + continue; + } + if (target.isDir) { + fs.rmSync(target.path, { recursive: true }); + } else { + fs.unlinkSync(target.path); + } + } +} + +function isLockError(message: string): boolean { + return message.includes("locked") || message.includes("SQLITE_BUSY"); +} + +const DB_FILENAME = "commandtree.sqlite3"; +const COMMANDTREE_DIR = ".commandtree"; + +function createTempWorkspace(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "commandtree-lock-test-")); +} + +function cleanupWorkspace(workspaceRoot: string): void { + fs.rmSync(workspaceRoot, { recursive: true, force: true }); +} + +function dbPath(workspaceRoot: string): string { + return path.join(workspaceRoot, COMMANDTREE_DIR, DB_FILENAME); +} + +function ensureDbDir(workspaceRoot: string): void { + fs.mkdirSync(path.dirname(dbPath(workspaceRoot)), { recursive: true }); +} + +function createLockDir(workspaceRoot: string): void { + ensureDbDir(workspaceRoot); + fs.mkdirSync(`${dbPath(workspaceRoot)}.lock`, { recursive: true }); +} + +function createJournalFile(workspaceRoot: string): void { + ensureDbDir(workspaceRoot); + fs.writeFileSync(`${dbPath(workspaceRoot)}-journal`, "stale journal data"); +} + +function createWalFile(workspaceRoot: string): void { + ensureDbDir(workspaceRoot); + fs.writeFileSync(`${dbPath(workspaceRoot)}-wal`, "stale wal data"); +} + +function createShmFile(workspaceRoot: string): void { + ensureDbDir(workspaceRoot); + fs.writeFileSync(`${dbPath(workspaceRoot)}-shm`, "stale shm data"); +} + +suite("DB Lock Recovery Unit Tests", () => { + let workspaceRoot: string; + + setup(() => { + workspaceRoot = createTempWorkspace(); + }); + + teardown(() => { + cleanupWorkspace(workspaceRoot); + }); + + // SPEC: DB-LOCK-RECOVERY + suite("isLockError", () => { + test("detects 'locked' in message", () => { + assert.ok(isLockError("database is locked")); + }); + + test("detects 'SQLITE_BUSY' in message", () => { + assert.ok(isLockError("SQLITE_BUSY: database table is locked")); + }); + + test("returns false for unrelated errors", () => { + assert.ok(!isLockError("file not found")); + }); + + test("returns false for empty string", () => { + assert.ok(!isLockError("")); + }); + }); + + // SPEC: DB-LOCK-RECOVERY + suite("removeLockFiles", () => { + test("removes .lock directory when present", () => { + const db = dbPath(workspaceRoot); + ensureDbDir(workspaceRoot); + fs.writeFileSync(db, ""); + createLockDir(workspaceRoot); + + assert.ok(fs.existsSync(`${db}.lock`), "Lock dir should exist before removal"); + removeLockFiles(db); + assert.ok(!fs.existsSync(`${db}.lock`), "Lock dir should be removed"); + }); + + test("removes -journal file when present", () => { + const db = dbPath(workspaceRoot); + createJournalFile(workspaceRoot); + + assert.ok(fs.existsSync(`${db}-journal`), "Journal should exist before removal"); + removeLockFiles(db); + assert.ok(!fs.existsSync(`${db}-journal`), "Journal should be removed"); + }); + + test("removes -wal file when present", () => { + const db = dbPath(workspaceRoot); + createWalFile(workspaceRoot); + + assert.ok(fs.existsSync(`${db}-wal`), "WAL should exist before removal"); + removeLockFiles(db); + assert.ok(!fs.existsSync(`${db}-wal`), "WAL should be removed"); + }); + + test("removes -shm file when present", () => { + const db = dbPath(workspaceRoot); + createShmFile(workspaceRoot); + + assert.ok(fs.existsSync(`${db}-shm`), "SHM should exist before removal"); + removeLockFiles(db); + assert.ok(!fs.existsSync(`${db}-shm`), "SHM should be removed"); + }); + + test("removes all lock artifacts at once", () => { + const db = dbPath(workspaceRoot); + ensureDbDir(workspaceRoot); + fs.writeFileSync(db, ""); + createLockDir(workspaceRoot); + createJournalFile(workspaceRoot); + createWalFile(workspaceRoot); + createShmFile(workspaceRoot); + + removeLockFiles(db); + + assert.ok(!fs.existsSync(`${db}.lock`), "Lock dir should be removed"); + assert.ok(!fs.existsSync(`${db}-journal`), "Journal should be removed"); + assert.ok(!fs.existsSync(`${db}-wal`), "WAL should be removed"); + assert.ok(!fs.existsSync(`${db}-shm`), "SHM should be removed"); + }); + + test("succeeds when no lock artifacts exist", () => { + const db = dbPath(workspaceRoot); + ensureDbDir(workspaceRoot); + removeLockFiles(db); + }); + + test("preserves the database file itself", () => { + const db = dbPath(workspaceRoot); + ensureDbDir(workspaceRoot); + fs.writeFileSync(db, "database content"); + createLockDir(workspaceRoot); + createJournalFile(workspaceRoot); + + removeLockFiles(db); + + assert.ok(fs.existsSync(db), "DB file should still exist"); + assert.strictEqual(fs.readFileSync(db, "utf8"), "database content"); + }); + }); +}); From db13f5ba4db1127f765ea245f77949e856e64637 Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Mon, 6 Apr 2026 10:58:35 +1000 Subject: [PATCH 19/25] fixes --- .github/workflows/release.yml | 45 +++++++++++++---------- coverage-thresholds.json | 6 +-- src/db/db.ts | 20 ++-------- src/discovery/index.ts | 5 ++- src/runners/TaskRunner.ts | 8 ++-- src/semantic/summaryPipeline.ts | 5 +-- src/test/e2e/dbLockRecovery.e2e.test.ts | 32 ++++++++-------- src/test/e2e/treeview.e2e.test.ts | 13 ++++--- src/test/unit/dbLockRecovery.unit.test.ts | 10 ++--- tools/check-coverage.mjs | 5 ++- 10 files changed, 73 insertions(+), 76 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f5de7c7..cc9d9ce 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,36 +37,43 @@ jobs: files: "*.vsix" generate_release_notes: true - publish: - name: Publish to Marketplace + - name: Publish to Marketplace + run: npx vsce publish --skip-duplicate + env: + VSCE_PAT: ${{ secrets.VSCE_PAT }} + + post-release: + name: Update version & deploy site + needs: release runs-on: ubuntu-latest timeout-minutes: 10 - needs: release + permissions: + contents: write steps: - uses: actions/checkout@v4 + with: + ref: main + + - name: Extract version from tag + id: version + run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" - uses: actions/setup-node@v4 with: node-version: 20 - cache: npm - - run: npm ci + - name: Update package.json version + run: npm version ${{ steps.version.outputs.version }} --no-git-tag-version - - name: Build - run: npm run compile + - name: Commit and push + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add package.json package-lock.json + git commit -m "chore: bump version to ${{ steps.version.outputs.version }}" + git push - - name: Publish to Marketplace - run: npx vsce publish --skip-duplicate - env: - VSCE_PAT: ${{ secrets.VSCE_PAT }} - - deploy-pages: - name: Trigger website deploy - needs: release - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - name: Trigger deploy-pages workflow + - name: Trigger website deploy uses: actions/github-script@v7 with: script: | diff --git a/coverage-thresholds.json b/coverage-thresholds.json index cc79c40..34ff3bb 100644 --- a/coverage-thresholds.json +++ b/coverage-thresholds.json @@ -1,6 +1,6 @@ { - "lines": 81.38, + "lines": 83.12, "functions": 81.41, - "branches": 68.17, - "statements": 81.38 + "branches": 76.02, + "statements": 83.12 } diff --git a/src/db/db.ts b/src/db/db.ts index e3ed1dc..8a32734 100644 --- a/src/db/db.ts +++ b/src/db/db.ts @@ -148,10 +148,7 @@ export function registerCommand(params: { /** * Ensures a command record exists for referential integrity. */ -export function ensureCommandExists(params: { - readonly handle: DbHandle; - readonly commandId: string; -}): void { +export function ensureCommandExists(params: { readonly handle: DbHandle; readonly commandId: string }): void { registerCommand({ handle: params.handle, commandId: params.commandId, @@ -187,10 +184,7 @@ export function upsertSummary(params: { /** * Gets a single command record by command ID. */ -export function getRow(params: { - readonly handle: DbHandle; - readonly commandId: string; -}): CommandRow | undefined { +export function getRow(params: { readonly handle: DbHandle; readonly commandId: string }): CommandRow | undefined { const row = params.handle.db.get(`SELECT * FROM ${COMMAND_TABLE} WHERE command_id = ?`, [params.commandId]); if (row === null) { return undefined; @@ -267,10 +261,7 @@ export function removeTagFromCommand(params: { * SPEC: database-schema/tag-operations, tagging/filter * Gets all command IDs for a given tag, ordered by display_order. */ -export function getCommandIdsByTag(params: { - readonly handle: DbHandle; - readonly tagName: string; -}): string[] { +export function getCommandIdsByTag(params: { readonly handle: DbHandle; readonly tagName: string }): string[] { const rows = params.handle.db.all( `SELECT ct.command_id FROM ${COMMAND_TAGS_TABLE} ct @@ -286,10 +277,7 @@ export function getCommandIdsByTag(params: { * SPEC: database-schema/tag-operations, tagging * Gets all tags for a given command. */ -export function getTagsForCommand(params: { - readonly handle: DbHandle; - readonly commandId: string; -}): string[] { +export function getTagsForCommand(params: { readonly handle: DbHandle; readonly commandId: string }): string[] { const rows = params.handle.db.all( `SELECT t.tag_name FROM ${TAG_TABLE} t diff --git a/src/discovery/index.ts b/src/discovery/index.ts index ca3ab0b..33ac55f 100644 --- a/src/discovery/index.ts +++ b/src/discovery/index.ts @@ -114,7 +114,10 @@ export async function discoverAllTasks(workspaceRoot: string, excludePatterns: s logger.info("Discovery started", { workspaceRoot, excludePatterns }); // Run all discoveries in parallel, wrapping each to log errors - const wrapDiscovery = async (name: string, fn: () => CommandItem[] | Promise<CommandItem[]>): Promise<CommandItem[]> => { + const wrapDiscovery = async ( + name: string, + fn: () => CommandItem[] | Promise<CommandItem[]> + ): Promise<CommandItem[]> => { try { const items = await fn(); if (items.length > 0) { diff --git a/src/runners/TaskRunner.ts b/src/runners/TaskRunner.ts index 8b5c40b..9ea8b6c 100644 --- a/src/runners/TaskRunner.ts +++ b/src/runners/TaskRunner.ts @@ -95,7 +95,7 @@ export class TaskRunner { */ private async runLaunch(task: CommandItem): Promise<void> { const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; - if (workspaceFolder === undefined) { + if (workspaceFolder === undefined) { showError("No workspace folder found"); return; } @@ -116,7 +116,7 @@ export class TaskRunner { if (matchingTask !== undefined) { await vscode.tasks.executeTask(matchingTask); - } else { + } else { showError(`Command not found: ${task.label}`); } } @@ -190,7 +190,7 @@ export class TaskRunner { this.safeSendText(terminal, command, shellIntegration); } }); - setTimeout(() => { + setTimeout(() => { if (!resolved) { resolved = true; listener.dispose(); @@ -214,7 +214,7 @@ export class TaskRunner { } else { terminal.sendText(command); } - } catch { + } catch { showError(`Failed to send command to terminal: ${command}`); } } diff --git a/src/semantic/summaryPipeline.ts b/src/semantic/summaryPipeline.ts index 53c518f..ebd40f6 100644 --- a/src/semantic/summaryPipeline.ts +++ b/src/semantic/summaryPipeline.ts @@ -49,10 +49,7 @@ async function findPendingSummaries(params: { const content = await readTaskContent({ task, fs: params.fs }); const hash = computeContentHash(content); const existing = getRow({ handle: params.handle, commandId: task.id }); - const needsSummary = - existing === undefined || - existing.summary === "" || - existing.contentHash !== hash; + const needsSummary = existing === undefined || existing.summary === "" || existing.contentHash !== hash; if (needsSummary) { pending.push({ task, content, hash }); } diff --git a/src/test/e2e/dbLockRecovery.e2e.test.ts b/src/test/e2e/dbLockRecovery.e2e.test.ts index bf12fac..f7e0611 100644 --- a/src/test/e2e/dbLockRecovery.e2e.test.ts +++ b/src/test/e2e/dbLockRecovery.e2e.test.ts @@ -28,24 +28,24 @@ function dbPath(workspaceRoot: string): string { } function createLockDir(workspaceRoot: string): void { - const lockPath = `${dbPath(workspaceRoot) }.lock`; + const lockPath = `${dbPath(workspaceRoot)}.lock`; fs.mkdirSync(lockPath, { recursive: true }); } function createJournalFile(workspaceRoot: string): void { - const journalPath = `${dbPath(workspaceRoot) }-journal`; + const journalPath = `${dbPath(workspaceRoot)}-journal`; fs.mkdirSync(path.dirname(journalPath), { recursive: true }); fs.writeFileSync(journalPath, "stale journal data"); } function createWalFile(workspaceRoot: string): void { - const walPath = `${dbPath(workspaceRoot) }-wal`; + const walPath = `${dbPath(workspaceRoot)}-wal`; fs.mkdirSync(path.dirname(walPath), { recursive: true }); fs.writeFileSync(walPath, "stale wal data"); } function createShmFile(workspaceRoot: string): void { - const shmPath = `${dbPath(workspaceRoot) }-shm`; + const shmPath = `${dbPath(workspaceRoot)}-shm`; fs.mkdirSync(path.dirname(shmPath), { recursive: true }); fs.writeFileSync(shmPath, "stale shm data"); } @@ -69,36 +69,36 @@ suite("DB Lock Recovery Unit Tests", () => { fs.writeFileSync(db, ""); createLockDir(workspaceRoot); - assert.ok(fs.existsSync(`${db }.lock`), "Lock dir should exist before removal"); + assert.ok(fs.existsSync(`${db}.lock`), "Lock dir should exist before removal"); removeLockFiles(db); - assert.ok(!fs.existsSync(`${db }.lock`), "Lock dir should be removed"); + assert.ok(!fs.existsSync(`${db}.lock`), "Lock dir should be removed"); }); test("removes -journal file when present", () => { const db = dbPath(workspaceRoot); createJournalFile(workspaceRoot); - assert.ok(fs.existsSync(`${db }-journal`), "Journal should exist before removal"); + assert.ok(fs.existsSync(`${db}-journal`), "Journal should exist before removal"); removeLockFiles(db); - assert.ok(!fs.existsSync(`${db }-journal`), "Journal should be removed"); + assert.ok(!fs.existsSync(`${db}-journal`), "Journal should be removed"); }); test("removes -wal file when present", () => { const db = dbPath(workspaceRoot); createWalFile(workspaceRoot); - assert.ok(fs.existsSync(`${db }-wal`), "WAL should exist before removal"); + assert.ok(fs.existsSync(`${db}-wal`), "WAL should exist before removal"); removeLockFiles(db); - assert.ok(!fs.existsSync(`${db }-wal`), "WAL should be removed"); + assert.ok(!fs.existsSync(`${db}-wal`), "WAL should be removed"); }); test("removes -shm file when present", () => { const db = dbPath(workspaceRoot); createShmFile(workspaceRoot); - assert.ok(fs.existsSync(`${db }-shm`), "SHM should exist before removal"); + assert.ok(fs.existsSync(`${db}-shm`), "SHM should exist before removal"); removeLockFiles(db); - assert.ok(!fs.existsSync(`${db }-shm`), "SHM should be removed"); + assert.ok(!fs.existsSync(`${db}-shm`), "SHM should be removed"); }); test("removes all lock artifacts at once", () => { @@ -112,10 +112,10 @@ suite("DB Lock Recovery Unit Tests", () => { removeLockFiles(db); - assert.ok(!fs.existsSync(`${db }.lock`), "Lock dir should be removed"); - assert.ok(!fs.existsSync(`${db }-journal`), "Journal should be removed"); - assert.ok(!fs.existsSync(`${db }-wal`), "WAL should be removed"); - assert.ok(!fs.existsSync(`${db }-shm`), "SHM should be removed"); + assert.ok(!fs.existsSync(`${db}.lock`), "Lock dir should be removed"); + assert.ok(!fs.existsSync(`${db}-journal`), "Journal should be removed"); + assert.ok(!fs.existsSync(`${db}-wal`), "WAL should be removed"); + assert.ok(!fs.existsSync(`${db}-shm`), "SHM should be removed"); }); test("succeeds when no lock artifacts exist", () => { diff --git a/src/test/e2e/treeview.e2e.test.ts b/src/test/e2e/treeview.e2e.test.ts index 0377b30..167e5d7 100644 --- a/src/test/e2e/treeview.e2e.test.ts +++ b/src/test/e2e/treeview.e2e.test.ts @@ -164,7 +164,9 @@ suite("TreeView E2E Tests", () => { this.timeout(20000); const provider = getCommandTreeProvider(); const allItems = await collectLeafItems(provider); - const buildItem = allItems.find((i) => isCommandItem(i.data) && i.data.type === "make" && i.data.label === "build"); + const buildItem = allItems.find( + (i) => isCommandItem(i.data) && i.data.type === "make" && i.data.label === "build" + ); assert.ok(buildItem !== undefined, "Should find 'build' make target in tree"); // Execute the click command — this is what happens when the user taps the item await executeItemClick(buildItem); @@ -184,7 +186,9 @@ suite("TreeView E2E Tests", () => { this.timeout(20000); const provider = getCommandTreeProvider(); const allItems = await collectLeafItems(provider); - const cleanItem = allItems.find((i) => isCommandItem(i.data) && i.data.type === "make" && i.data.label === "clean"); + const cleanItem = allItems.find( + (i) => isCommandItem(i.data) && i.data.type === "make" && i.data.label === "clean" + ); assert.ok(cleanItem !== undefined, "Should find 'clean' make target in tree"); await executeItemClick(cleanItem); await sleep(1000); @@ -221,10 +225,7 @@ suite("TreeView E2E Tests", () => { uniqueLines.size > 1, `Each make target must navigate to its own line — got ${JSON.stringify(lines)} (all same = broken)` ); - assert.ok( - !lines.every((l) => l === 0), - "Make targets must NOT all open at line 0 — line navigation is broken" - ); + assert.ok(!lines.every((l) => l === 0), "Make targets must NOT all open at line 0 — line navigation is broken"); }); }); diff --git a/src/test/unit/dbLockRecovery.unit.test.ts b/src/test/unit/dbLockRecovery.unit.test.ts index 40fb44c..0386c7f 100644 --- a/src/test/unit/dbLockRecovery.unit.test.ts +++ b/src/test/unit/dbLockRecovery.unit.test.ts @@ -13,12 +13,12 @@ import * as os from "os"; * Replicated from lifecycle.ts to avoid vscode dependency in unit tests. * The actual removeLockFiles function lives in src/db/lifecycle.ts. */ -function removeLockFiles(dbPath: string): void { +function removeLockFiles(targetDbPath: string): void { const targets = [ - { path: `${dbPath}.lock`, isDir: true }, - { path: `${dbPath}-journal`, isDir: false }, - { path: `${dbPath}-wal`, isDir: false }, - { path: `${dbPath}-shm`, isDir: false }, + { path: `${targetDbPath}.lock`, isDir: true }, + { path: `${targetDbPath}-journal`, isDir: false }, + { path: `${targetDbPath}-wal`, isDir: false }, + { path: `${targetDbPath}-shm`, isDir: false }, ]; for (const target of targets) { if (!fs.existsSync(target.path)) { diff --git a/tools/check-coverage.mjs b/tools/check-coverage.mjs index 2236428..4fe3ccf 100644 --- a/tools/check-coverage.mjs +++ b/tools/check-coverage.mjs @@ -43,8 +43,9 @@ for (const metric of METRICS) { console.error(`FAIL: ${metric} ${pct}% < ${threshold}% (short by ${diff}%)`); failed = true; } else if (pct > threshold) { - console.log(`BUMP: ${metric} ${threshold}% -> ${pct}%`); - thresholds[metric] = pct; + const buffered = parseFloat((pct - 1).toFixed(2)); + console.log(`BUMP: ${metric} ${threshold}% -> ${buffered}% (actual ${pct}%, 1% buffer)`); + thresholds[metric] = buffered; bumped = true; } else { console.log(`OK: ${metric} ${pct}% == ${threshold}%`); From 6a78491a4d014340cdf55f349e6901de9e74b85b Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Mon, 6 Apr 2026 11:01:51 +1000 Subject: [PATCH 20/25] stuff --- src/utils/fileUtils.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/utils/fileUtils.ts b/src/utils/fileUtils.ts index 8af64ed..98b3a03 100644 --- a/src/utils/fileUtils.ts +++ b/src/utils/fileUtils.ts @@ -15,6 +15,15 @@ export async function readFile(uri: vscode.Uri): Promise<Result<string, string>> } } +/** + * Reads a file and returns its content. Throws on failure. + * Use in discovery modules where errors are caught by the orchestrator. + */ +export async function readFileContent(uri: vscode.Uri): Promise<string> { + const bytes = await vscode.workspace.fs.readFile(uri); + return new TextDecoder().decode(bytes); +} + /** * Parses JSON safely, returning a Result instead of throwing. */ From e2cf5d0d868456e846b8584d1783ad16eb189526 Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Mon, 6 Apr 2026 11:14:28 +1000 Subject: [PATCH 21/25] Fixes --- coverage-thresholds.json | 8 +++--- src/CommandTreeProvider.ts | 9 +++---- src/QuickTasksProvider.ts | 29 ++++++--------------- src/config/TagConfig.ts | 47 ++++++++++------------------------ src/discovery/ant.ts | 9 ++----- src/discovery/cargo.ts | 9 ++----- src/discovery/composer.ts | 15 +++-------- src/discovery/csharp-script.ts | 10 +++----- src/discovery/deno.ts | 18 +++---------- src/discovery/docker.ts | 9 ++----- src/discovery/dotnet.ts | 9 ++----- src/discovery/fsharp-script.ts | 10 +++----- src/discovery/gradle.ts | 9 ++----- src/discovery/just.ts | 9 ++----- src/discovery/make.ts | 9 ++----- src/discovery/markdown.ts | 9 ++----- src/discovery/mise.ts | 9 ++----- src/discovery/npm.ts | 15 +++-------- src/discovery/powershell.ts | 9 ++----- src/discovery/python.ts | 9 ++----- src/discovery/rake.ts | 9 ++----- src/discovery/shell.ts | 9 ++----- src/discovery/taskfile.ts | 9 ++----- src/tags/tagSync.ts | 12 +++------ 24 files changed, 77 insertions(+), 222 deletions(-) diff --git a/coverage-thresholds.json b/coverage-thresholds.json index 34ff3bb..cfbb862 100644 --- a/coverage-thresholds.json +++ b/coverage-thresholds.json @@ -1,6 +1,6 @@ { - "lines": 83.12, - "functions": 81.41, - "branches": 76.02, - "statements": 83.12 + "lines": 83, + "functions": 80.81, + "branches": 76.9, + "statements": 83 } diff --git a/src/CommandTreeProvider.ts b/src/CommandTreeProvider.ts index c8d61dd..3b4aa0a 100644 --- a/src/CommandTreeProvider.ts +++ b/src/CommandTreeProvider.ts @@ -9,7 +9,7 @@ import { buildNestedFolderItems } from "./tree/folderTree"; import { createCommandNode, createCategoryNode } from "./tree/nodeFactory"; import { getAllRows } from "./db/db"; import type { CommandRow } from "./db/db"; -import { getDb } from "./db/lifecycle"; +import { getDbOrThrow } from "./db/lifecycle"; type SortOrder = "folder" | "name" | "type"; @@ -49,11 +49,8 @@ export class CommandTreeProvider implements vscode.TreeDataProvider<CommandTreeI } private loadSummaries(): void { - const dbResult = getDb(); - if (!dbResult.ok) { - return; - } - const rows = getAllRows(dbResult.value); + const handle = getDbOrThrow(); + const rows = getAllRows(handle); const map = new Map<string, CommandRow>(); for (const row of rows) { map.set(row.commandId, row); diff --git a/src/QuickTasksProvider.ts b/src/QuickTasksProvider.ts index 81dc154..164dad2 100644 --- a/src/QuickTasksProvider.ts +++ b/src/QuickTasksProvider.ts @@ -8,7 +8,7 @@ import * as vscode from "vscode"; import type { CommandItem, CommandTreeItem } from "./models/TaskItem"; import { isCommandItem } from "./models/TaskItem"; import { TagConfig } from "./config/TagConfig"; -import { getDb } from "./db/lifecycle"; +import { getDbOrThrow } from "./db/lifecycle"; import { getCommandIdsByTag, reorderTagCommands } from "./db/db"; import { createCommandNode, createPlaceholderNode } from "./tree/nodeFactory"; @@ -104,11 +104,8 @@ export class QuickTasksProvider * Sorts tasks by display_order from junction table. */ private sortByDisplayOrder(tasks: CommandItem[]): CommandItem[] { - const dbResult = getDb(); - if (!dbResult.ok) { - return tasks.sort((a, b) => a.label.localeCompare(b.label)); - } - const orderedIds = getCommandIdsByTag({ handle: dbResult.value, tagName: QUICK_TAG }); + const handle = getDbOrThrow(); + const orderedIds = getCommandIdsByTag({ handle, tagName: QUICK_TAG }); return [...tasks].sort((a, b) => { const indexA = orderedIds.indexOf(a.id); const indexB = orderedIds.indexOf(b.id); @@ -147,10 +144,6 @@ export class QuickTasksProvider } const orderedIds = this.fetchOrderedQuickIds(); - if (orderedIds === undefined) { - return; - } - const reordered = this.computeReorder({ orderedIds, draggedTask, target }); if (reordered === undefined) { return; @@ -163,12 +156,9 @@ export class QuickTasksProvider /** * Fetches ordered command IDs for the quick tag from the DB. */ - private fetchOrderedQuickIds(): string[] | undefined { - const dbResult = getDb(); - if (!dbResult.ok) { - return undefined; - } - return getCommandIdsByTag({ handle: dbResult.value, tagName: QUICK_TAG }); + private fetchOrderedQuickIds(): string[] { + const handle = getDbOrThrow(); + return getCommandIdsByTag({ handle, tagName: QUICK_TAG }); } /** @@ -205,11 +195,8 @@ export class QuickTasksProvider * Persists display_order for each command in the reordered list. */ private persistDisplayOrder(reordered: string[]): void { - const dbResult = getDb(); - if (!dbResult.ok) { - return; - } - reorderTagCommands({ handle: dbResult.value, tagName: QUICK_TAG, orderedCommandIds: reordered }); + const handle = getDbOrThrow(); + reorderTagCommands({ handle, tagName: QUICK_TAG, orderedCommandIds: reordered }); } /** diff --git a/src/config/TagConfig.ts b/src/config/TagConfig.ts index 27a7b83..d44ec9a 100644 --- a/src/config/TagConfig.ts +++ b/src/config/TagConfig.ts @@ -5,7 +5,7 @@ */ import type { CommandItem } from "../models/TaskItem"; -import { getDb } from "../db/lifecycle"; +import { getDbOrThrow } from "../db/lifecycle"; import { addTagToCommand, removeTagFromCommand, @@ -22,16 +22,12 @@ export class TagConfig { * Loads all tag assignments from SQLite junction table. */ public load(): void { - const dbResult = getDb(); - if (!dbResult.ok) { - this.commandTagsMap = new Map(); - return; - } + const handle = getDbOrThrow(); - const tagNames = getAllTagNames(dbResult.value); + const tagNames = getAllTagNames(handle); const map = new Map<string, string[]>(); for (const tagName of tagNames) { - const commandIds = getCommandIdsByTag({ handle: dbResult.value, tagName }); + const commandIds = getCommandIdsByTag({ handle, tagName }); for (const commandId of commandIds) { const tags = map.get(commandId) ?? []; tags.push(tagName); @@ -57,11 +53,8 @@ export class TagConfig { * Gets all tag names. */ public getTagNames(): string[] { - const dbResult = getDb(); - if (!dbResult.ok) { - return []; - } - return getAllTagNames(dbResult.value); + const handle = getDbOrThrow(); + return getAllTagNames(handle); } /** @@ -69,11 +62,8 @@ export class TagConfig { * Adds a task to a tag by creating junction record with exact command ID. */ public addTaskToTag(task: CommandItem, tagName: string): void { - const dbResult = getDb(); - if (!dbResult.ok) { - return; - } - addTagToCommand({ handle: dbResult.value, commandId: task.id, tagName }); + const handle = getDbOrThrow(); + addTagToCommand({ handle, commandId: task.id, tagName }); this.load(); } @@ -82,11 +72,8 @@ export class TagConfig { * Removes a task from a tag by deleting junction record. */ public removeTaskFromTag(task: CommandItem, tagName: string): void { - const dbResult = getDb(); - if (!dbResult.ok) { - return; - } - removeTagFromCommand({ handle: dbResult.value, commandId: task.id, tagName }); + const handle = getDbOrThrow(); + removeTagFromCommand({ handle, commandId: task.id, tagName }); this.load(); } @@ -95,11 +82,8 @@ export class TagConfig { * Gets ordered command IDs for a tag (ordered by display_order). */ public getOrderedCommandIds(tagName: string): string[] { - const dbResult = getDb(); - if (!dbResult.ok) { - return []; - } - return getCommandIdsByTag({ handle: dbResult.value, tagName }); + const handle = getDbOrThrow(); + return getCommandIdsByTag({ handle, tagName }); } /** @@ -107,11 +91,8 @@ export class TagConfig { * Reorders commands for a tag by updating display_order in junction table. */ public reorderCommands(tagName: string, orderedCommandIds: string[]): void { - const dbResult = getDb(); - if (!dbResult.ok) { - return; - } - reorderTagCommands({ handle: dbResult.value, tagName, orderedCommandIds }); + const handle = getDbOrThrow(); + reorderTagCommands({ handle, tagName, orderedCommandIds }); this.load(); } } diff --git a/src/discovery/ant.ts b/src/discovery/ant.ts index bdf6dd2..b48a08d 100644 --- a/src/discovery/ant.ts +++ b/src/discovery/ant.ts @@ -2,7 +2,7 @@ import * as vscode from "vscode"; import * as path from "path"; import type { CommandItem, IconDef, CategoryDef } from "../models/TaskItem"; import { generateCommandId, simplifyPath } from "../models/TaskItem"; -import { readFile } from "../utils/fileUtils"; +import { readFileContent } from "../utils/fileUtils"; export const ICON_DEF: IconDef = { icon: "symbol-constructor", @@ -27,12 +27,7 @@ export async function discoverAntTargets(workspaceRoot: string, excludePatterns: const commands: CommandItem[] = []; for (const file of files) { - const result = await readFile(file); - if (!result.ok) { - continue; // Skip files we can't read - } - - const content = result.value; + const content = await readFileContent(file); const antDir = path.dirname(file.fsPath); const category = simplifyPath(file.fsPath, workspaceRoot); const targets = parseAntTargets(content); diff --git a/src/discovery/cargo.ts b/src/discovery/cargo.ts index a470910..905b53f 100644 --- a/src/discovery/cargo.ts +++ b/src/discovery/cargo.ts @@ -2,7 +2,7 @@ import * as vscode from "vscode"; import * as path from "path"; import type { CommandItem, IconDef, CategoryDef } from "../models/TaskItem"; import { generateCommandId, simplifyPath } from "../models/TaskItem"; -import { readFile } from "../utils/fileUtils"; +import { readFileContent } from "../utils/fileUtils"; export const ICON_DEF: IconDef = { icon: "package", color: "terminal.ansiRed" }; export const CATEGORY_DEF: CategoryDef = { @@ -41,12 +41,7 @@ export async function discoverCargoTasks(workspaceRoot: string, excludePatterns: const commands: CommandItem[] = []; for (const file of files) { - const result = await readFile(file); - if (!result.ok) { - continue; // Skip files we can't read - } - - const content = result.value; + const content = await readFileContent(file); const cargoDir = path.dirname(file.fsPath); const category = simplifyPath(file.fsPath, workspaceRoot); diff --git a/src/discovery/composer.ts b/src/discovery/composer.ts index 31dab7b..329a28b 100644 --- a/src/discovery/composer.ts +++ b/src/discovery/composer.ts @@ -2,7 +2,7 @@ import * as vscode from "vscode"; import * as path from "path"; import type { CommandItem, MutableCommandItem, IconDef, CategoryDef } from "../models/TaskItem"; import { generateCommandId, simplifyPath } from "../models/TaskItem"; -import { readFile, parseJson } from "../utils/fileUtils"; +import { readFileContent } from "../utils/fileUtils"; export const ICON_DEF: IconDef = { icon: "symbol-interface", @@ -70,17 +70,8 @@ function buildCommandItem(params: BuildCommandItemParams): CommandItem { } async function extractScriptsFromFile(file: vscode.Uri, workspaceRoot: string): Promise<CommandItem[]> { - const contentResult = await readFile(file); - if (!contentResult.ok) { - return []; - } - - const composerResult = parseJson<ComposerJson>(contentResult.value); - if (!composerResult.ok) { - return []; - } - - const composer = composerResult.value; + const content = await readFileContent(file); + const composer = JSON.parse(content) as ComposerJson; if (composer.scripts === undefined || typeof composer.scripts !== "object") { return []; } diff --git a/src/discovery/csharp-script.ts b/src/discovery/csharp-script.ts index f20cdd1..fc6ab9a 100644 --- a/src/discovery/csharp-script.ts +++ b/src/discovery/csharp-script.ts @@ -2,7 +2,7 @@ import * as vscode from "vscode"; import * as path from "path"; import type { CommandItem, MutableCommandItem, IconDef, CategoryDef } from "../models/TaskItem"; import { generateCommandId, simplifyPath } from "../models/TaskItem"; -import { readFile, parseFirstLineComment } from "../utils/fileUtils"; +import { readFileContent, parseFirstLineComment } from "../utils/fileUtils"; export const ICON_DEF: IconDef = { icon: "file-code", @@ -28,13 +28,9 @@ export async function discoverCsharpScripts(workspaceRoot: string, excludePatter const commands: CommandItem[] = []; for (const file of files) { - const result = await readFile(file); - if (!result.ok) { - continue; - } - + const content = await readFileContent(file); const name = path.basename(file.fsPath); - const description = parseFirstLineComment(result.value, COMMENT_PREFIX); + const description = parseFirstLineComment(content, COMMENT_PREFIX); const task: MutableCommandItem = { id: generateCommandId("csharp-script", file.fsPath, name), diff --git a/src/discovery/deno.ts b/src/discovery/deno.ts index b719e9c..2a86932 100644 --- a/src/discovery/deno.ts +++ b/src/discovery/deno.ts @@ -2,7 +2,7 @@ import * as vscode from "vscode"; import * as path from "path"; import type { CommandItem, MutableCommandItem, IconDef, CategoryDef } from "../models/TaskItem"; import { generateCommandId, simplifyPath } from "../models/TaskItem"; -import { readFile, parseJson, removeJsonComments } from "../utils/fileUtils"; +import { readFileContent, removeJsonComments } from "../utils/fileUtils"; export const ICON_DEF: IconDef = { icon: "symbol-namespace", @@ -39,19 +39,9 @@ export async function discoverDenoTasks(workspaceRoot: string, excludePatterns: const commands: CommandItem[] = []; for (const file of allFiles) { - const contentResult = await readFile(file); - if (!contentResult.ok) { - continue; // Skip unreadable files - } - - // Remove JSONC comments - const cleanJson = removeJsonComments(contentResult.value); - const denoResult = parseJson<DenoJson>(cleanJson); - if (!denoResult.ok) { - continue; // Skip malformed deno.json - } - - const deno = denoResult.value; + const content = await readFileContent(file); + const cleanJson = removeJsonComments(content); + const deno = JSON.parse(cleanJson) as DenoJson; if (deno.tasks === undefined || typeof deno.tasks !== "object") { continue; } diff --git a/src/discovery/docker.ts b/src/discovery/docker.ts index ad48dfa..89e5ff4 100644 --- a/src/discovery/docker.ts +++ b/src/discovery/docker.ts @@ -2,7 +2,7 @@ import * as vscode from "vscode"; import * as path from "path"; import type { CommandItem, MutableCommandItem, IconDef, CategoryDef } from "../models/TaskItem"; import { generateCommandId, simplifyPath } from "../models/TaskItem"; -import { readFile } from "../utils/fileUtils"; +import { readFileContent } from "../utils/fileUtils"; export const ICON_DEF: IconDef = { icon: "server-environment", @@ -31,12 +31,7 @@ export async function discoverDockerComposeServices( const commands: CommandItem[] = []; for (const file of allFiles) { - const result = await readFile(file); - if (!result.ok) { - continue; // Skip files we can't read - } - - const content = result.value; + const content = await readFileContent(file); const dockerDir = path.dirname(file.fsPath); const category = simplifyPath(file.fsPath, workspaceRoot); const services = parseDockerComposeServices(content); diff --git a/src/discovery/dotnet.ts b/src/discovery/dotnet.ts index 6543915..6ad84b4 100644 --- a/src/discovery/dotnet.ts +++ b/src/discovery/dotnet.ts @@ -2,7 +2,7 @@ import * as vscode from "vscode"; import * as path from "path"; import type { CommandItem, ParamDef, MutableCommandItem, IconDef, CategoryDef } from "../models/TaskItem"; import { generateCommandId, simplifyPath } from "../models/TaskItem"; -import { readFile } from "../utils/fileUtils"; +import { readFileContent } from "../utils/fileUtils"; export const ICON_DEF: IconDef = { icon: "circuit-board", @@ -43,12 +43,7 @@ export async function discoverDotnetProjects(workspaceRoot: string, excludePatte const commands: CommandItem[] = []; for (const file of allFiles) { - const result = await readFile(file); - if (!result.ok) { - continue; - } - - const content = result.value; + const content = await readFileContent(file); const projectInfo = analyzeProject(content); const projectDir = path.dirname(file.fsPath); const category = simplifyPath(file.fsPath, workspaceRoot); diff --git a/src/discovery/fsharp-script.ts b/src/discovery/fsharp-script.ts index 7a10f3e..bc4e172 100644 --- a/src/discovery/fsharp-script.ts +++ b/src/discovery/fsharp-script.ts @@ -2,7 +2,7 @@ import * as vscode from "vscode"; import * as path from "path"; import type { CommandItem, MutableCommandItem, IconDef, CategoryDef } from "../models/TaskItem"; import { generateCommandId, simplifyPath } from "../models/TaskItem"; -import { readFile, parseFirstLineComment } from "../utils/fileUtils"; +import { readFileContent, parseFirstLineComment } from "../utils/fileUtils"; export const ICON_DEF: IconDef = { icon: "file-code", @@ -28,13 +28,9 @@ export async function discoverFsharpScripts(workspaceRoot: string, excludePatter const commands: CommandItem[] = []; for (const file of files) { - const result = await readFile(file); - if (!result.ok) { - continue; - } - + const content = await readFileContent(file); const name = path.basename(file.fsPath); - const description = parseFirstLineComment(result.value, COMMENT_PREFIX); + const description = parseFirstLineComment(content, COMMENT_PREFIX); const task: MutableCommandItem = { id: generateCommandId("fsharp-script", file.fsPath, name), diff --git a/src/discovery/gradle.ts b/src/discovery/gradle.ts index 8cfe6ce..c2ada68 100644 --- a/src/discovery/gradle.ts +++ b/src/discovery/gradle.ts @@ -2,7 +2,7 @@ import * as vscode from "vscode"; import * as path from "path"; import type { CommandItem, IconDef, CategoryDef } from "../models/TaskItem"; import { generateCommandId, simplifyPath } from "../models/TaskItem"; -import { readFile } from "../utils/fileUtils"; +import { readFileContent } from "../utils/fileUtils"; export const ICON_DEF: IconDef = { icon: "symbol-property", @@ -39,12 +39,7 @@ export async function discoverGradleTasks(workspaceRoot: string, excludePatterns const commands: CommandItem[] = []; for (const file of allFiles) { - const result = await readFile(file); - if (!result.ok) { - continue; // Skip files we can't read - } - - const content = result.value; + const content = await readFileContent(file); const gradleDir = path.dirname(file.fsPath); const category = simplifyPath(file.fsPath, workspaceRoot); const parsedTasks = parseGradleTasks(content); diff --git a/src/discovery/just.ts b/src/discovery/just.ts index 7779214..cebbc6a 100644 --- a/src/discovery/just.ts +++ b/src/discovery/just.ts @@ -2,7 +2,7 @@ import * as vscode from "vscode"; import * as path from "path"; import type { CommandItem, ParamDef, MutableCommandItem, IconDef, CategoryDef } from "../models/TaskItem"; import { generateCommandId, simplifyPath } from "../models/TaskItem"; -import { readFile } from "../utils/fileUtils"; +import { readFileContent } from "../utils/fileUtils"; export const ICON_DEF: IconDef = { icon: "checklist", @@ -28,12 +28,7 @@ export async function discoverJustRecipes(workspaceRoot: string, excludePatterns const commands: CommandItem[] = []; for (const file of allFiles) { - const result = await readFile(file); - if (!result.ok) { - continue; // Skip files we can't read - } - - const content = result.value; + const content = await readFileContent(file); const justDir = path.dirname(file.fsPath); const category = simplifyPath(file.fsPath, workspaceRoot); const recipes = parseJustRecipes(content); diff --git a/src/discovery/make.ts b/src/discovery/make.ts index 40ac868..9d2f23d 100644 --- a/src/discovery/make.ts +++ b/src/discovery/make.ts @@ -2,7 +2,7 @@ import * as vscode from "vscode"; import * as path from "path"; import type { CommandItem, IconDef, CategoryDef } from "../models/TaskItem"; import { generateCommandId, simplifyPath } from "../models/TaskItem"; -import { readFile } from "../utils/fileUtils"; +import { readFileContent } from "../utils/fileUtils"; export const ICON_DEF: IconDef = { icon: "tools", @@ -27,12 +27,7 @@ export async function discoverMakeTargets(workspaceRoot: string, excludePatterns const commands: CommandItem[] = []; for (const file of allFiles) { - const result = await readFile(file); - if (!result.ok) { - continue; // Skip files we can't read - } - - const content = result.value; + const content = await readFileContent(file); const targets = parseMakeTargets(content); const makeDir = path.dirname(file.fsPath); const category = simplifyPath(file.fsPath, workspaceRoot); diff --git a/src/discovery/markdown.ts b/src/discovery/markdown.ts index c52d4c0..f586da7 100644 --- a/src/discovery/markdown.ts +++ b/src/discovery/markdown.ts @@ -2,7 +2,7 @@ import * as vscode from "vscode"; import * as path from "path"; import type { CommandItem, MutableCommandItem, IconDef, CategoryDef } from "../models/TaskItem"; import { generateCommandId, simplifyPath } from "../models/TaskItem"; -import { readFile } from "../utils/fileUtils"; +import { readFileContent } from "../utils/fileUtils"; export const ICON_DEF: IconDef = { icon: "markdown", @@ -24,12 +24,7 @@ export async function discoverMarkdownFiles(workspaceRoot: string, excludePatter const commands: CommandItem[] = []; for (const file of files) { - const result = await readFile(file); - if (!result.ok) { - continue; - } - - const content = result.value; + const content = await readFileContent(file); const name = path.basename(file.fsPath); const description = extractDescription(content); diff --git a/src/discovery/mise.ts b/src/discovery/mise.ts index 79c0669..14601d1 100644 --- a/src/discovery/mise.ts +++ b/src/discovery/mise.ts @@ -2,7 +2,7 @@ import * as vscode from "vscode"; import * as path from "path"; import type { CommandItem, MutableCommandItem, IconDef, CategoryDef } from "../models/TaskItem"; import { generateCommandId, simplifyPath } from "../models/TaskItem"; -import { readFile } from "../utils/fileUtils"; +import { readFileContent } from "../utils/fileUtils"; import { parseMiseToml, parseMiseYaml } from "./parsers/miseParser"; export { parseMiseToml, parseMiseYaml } from "./parsers/miseParser"; @@ -36,12 +36,7 @@ export async function discoverMiseTasks(workspaceRoot: string, excludePatterns: const commands: CommandItem[] = []; for (const file of allFiles) { - const result = await readFile(file); - if (!result.ok) { - continue; - } - - const content = result.value; + const content = await readFileContent(file); const miseDir = path.dirname(file.fsPath); const category = simplifyPath(file.fsPath, workspaceRoot); diff --git a/src/discovery/npm.ts b/src/discovery/npm.ts index cbc10a5..3df9bde 100644 --- a/src/discovery/npm.ts +++ b/src/discovery/npm.ts @@ -2,7 +2,7 @@ import * as vscode from "vscode"; import * as path from "path"; import type { CommandItem, IconDef, CategoryDef } from "../models/TaskItem"; import { generateCommandId, simplifyPath } from "../models/TaskItem"; -import { readFile, parseJson } from "../utils/fileUtils"; +import { readFileContent } from "../utils/fileUtils"; export const ICON_DEF: IconDef = { icon: "package", @@ -25,17 +25,8 @@ export async function discoverNpmScripts(workspaceRoot: string, excludePatterns: const commands: CommandItem[] = []; for (const file of files) { - const contentResult = await readFile(file); - if (!contentResult.ok) { - continue; // Skip unreadable package.json - } - - const pkgResult = parseJson<PackageJson>(contentResult.value); - if (!pkgResult.ok) { - continue; // Skip malformed package.json - } - - const pkg = pkgResult.value; + const content = await readFileContent(file); + const pkg = JSON.parse(content) as PackageJson; if (pkg.scripts === undefined || typeof pkg.scripts !== "object") { continue; } diff --git a/src/discovery/powershell.ts b/src/discovery/powershell.ts index 2adbb82..bb7058d 100644 --- a/src/discovery/powershell.ts +++ b/src/discovery/powershell.ts @@ -2,7 +2,7 @@ import * as vscode from "vscode"; import * as path from "path"; import type { CommandItem, MutableCommandItem, IconDef, CategoryDef } from "../models/TaskItem"; import { generateCommandId, simplifyPath } from "../models/TaskItem"; -import { readFile } from "../utils/fileUtils"; +import { readFileContent } from "../utils/fileUtils"; import { parsePowerShellParams as parseParams, parsePowerShellDescription as parsePsDescription, @@ -35,12 +35,7 @@ export async function discoverPowerShellScripts( const commands: CommandItem[] = []; for (const file of allFiles) { - const result = await readFile(file); - if (!result.ok) { - continue; // Skip files we can't read - } - - const content = result.value; + const content = await readFileContent(file); const name = path.basename(file.fsPath); const ext = path.extname(file.fsPath).toLowerCase(); const isPowerShell = ext === ".ps1"; diff --git a/src/discovery/python.ts b/src/discovery/python.ts index 40b45c1..8e4ca15 100644 --- a/src/discovery/python.ts +++ b/src/discovery/python.ts @@ -2,7 +2,7 @@ import * as vscode from "vscode"; import * as path from "path"; import type { CommandItem, ParamDef, MutableCommandItem, IconDef, CategoryDef } from "../models/TaskItem"; import { generateCommandId, simplifyPath } from "../models/TaskItem"; -import { readFile } from "../utils/fileUtils"; +import { readFileContent } from "../utils/fileUtils"; export const ICON_DEF: IconDef = { icon: "symbol-misc", @@ -24,12 +24,7 @@ export async function discoverPythonScripts(workspaceRoot: string, excludePatter const commands: CommandItem[] = []; for (const file of files) { - const result = await readFile(file); - if (!result.ok) { - continue; // Skip files we can't read - } - - const content = result.value; + const content = await readFileContent(file); // Skip non-runnable Python files (no main block or shebang) if (!isRunnablePythonScript(content)) { diff --git a/src/discovery/rake.ts b/src/discovery/rake.ts index 354ec00..2e29f0e 100644 --- a/src/discovery/rake.ts +++ b/src/discovery/rake.ts @@ -2,7 +2,7 @@ import * as vscode from "vscode"; import * as path from "path"; import type { CommandItem, MutableCommandItem, IconDef, CategoryDef } from "../models/TaskItem"; import { generateCommandId, simplifyPath } from "../models/TaskItem"; -import { readFile } from "../utils/fileUtils"; +import { readFileContent } from "../utils/fileUtils"; export const ICON_DEF: IconDef = { icon: "ruby", color: "terminal.ansiRed" }; export const CATEGORY_DEF: CategoryDef = { type: "rake", label: "Rake Tasks" }; @@ -31,12 +31,7 @@ export async function discoverRakeTasks(workspaceRoot: string, excludePatterns: const commands: CommandItem[] = []; for (const file of allFiles) { - const result = await readFile(file); - if (!result.ok) { - continue; // Skip files we can't read - } - - const content = result.value; + const content = await readFileContent(file); const rakeDir = path.dirname(file.fsPath); const category = simplifyPath(file.fsPath, workspaceRoot); const rakeTasks = parseRakeTasks(content); diff --git a/src/discovery/shell.ts b/src/discovery/shell.ts index be3df1a..3606574 100644 --- a/src/discovery/shell.ts +++ b/src/discovery/shell.ts @@ -2,7 +2,7 @@ import * as vscode from "vscode"; import * as path from "path"; import type { CommandItem, ParamDef, MutableCommandItem, IconDef, CategoryDef } from "../models/TaskItem"; import { generateCommandId, simplifyPath } from "../models/TaskItem"; -import { readFile } from "../utils/fileUtils"; +import { readFileContent } from "../utils/fileUtils"; export const ICON_DEF: IconDef = { icon: "terminal", @@ -24,12 +24,7 @@ export async function discoverShellScripts(workspaceRoot: string, excludePattern const commands: CommandItem[] = []; for (const file of files) { - const result = await readFile(file); - if (!result.ok) { - continue; // Skip files we can't read - } - - const content = result.value; + const content = await readFileContent(file); const name = path.basename(file.fsPath); const params = parseShellParams(content); const description = parseShellDescription(content); diff --git a/src/discovery/taskfile.ts b/src/discovery/taskfile.ts index 36712f4..005f9e8 100644 --- a/src/discovery/taskfile.ts +++ b/src/discovery/taskfile.ts @@ -2,7 +2,7 @@ import * as vscode from "vscode"; import * as path from "path"; import type { CommandItem, MutableCommandItem, IconDef, CategoryDef } from "../models/TaskItem"; import { generateCommandId, simplifyPath } from "../models/TaskItem"; -import { readFile } from "../utils/fileUtils"; +import { readFileContent } from "../utils/fileUtils"; export const ICON_DEF: IconDef = { icon: "tasklist", @@ -29,12 +29,7 @@ export async function discoverTaskfileTasks(workspaceRoot: string, excludePatter const commands: CommandItem[] = []; for (const file of allFiles) { - const result = await readFile(file); - if (!result.ok) { - continue; // Skip files we can't read - } - - const content = result.value; + const content = await readFileContent(file); const taskfileDir = path.dirname(file.fsPath); const category = simplifyPath(file.fsPath, workspaceRoot); const parsedTasks = parseTaskfileTasks(content); diff --git a/src/tags/tagSync.ts b/src/tags/tagSync.ts index ebd5612..e31a4db 100644 --- a/src/tags/tagSync.ts +++ b/src/tags/tagSync.ts @@ -3,7 +3,7 @@ import * as path from "path"; import type { CommandItem } from "../models/TaskItem"; import type { DbHandle } from "../db/db"; import { addTagToCommand, removeTagFromCommand, getCommandIdsByTag } from "../db/db"; -import { getDb } from "../db/lifecycle"; +import { getDbOrThrow } from "../db/lifecycle"; import { logger } from "../utils/logger"; interface TagPattern { @@ -90,16 +90,12 @@ export function syncTagsFromConfig({ if (config?.tags === undefined) { return false; } - const dbResult = getDb(); - if (!dbResult.ok) { - logger.warn("DB not available, skipping tag sync", { error: dbResult.error }); - return false; - } + const handle = getDbOrThrow(); for (const [tagName, patterns] of Object.entries(config.tags)) { - const existingIds = getCommandIdsByTag({ handle: dbResult.value, tagName }); + const existingIds = getCommandIdsByTag({ handle, tagName }); const currentIds = new Set(existingIds); const matchedIds = collectMatchedIds(patterns, allTasks); - syncTagDiff({ handle: dbResult.value, tagName, currentIds, matchedIds }); + syncTagDiff({ handle, tagName, currentIds, matchedIds }); } logger.info("Tag sync complete"); return true; From 73a3389816c21dafa8941881724f628a4ec5d7de Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Mon, 6 Apr 2026 11:35:45 +1000 Subject: [PATCH 22/25] Stuff --- src/test/fixtures/workspace/Taskfile.yml | 32 +++++++++++++++++++ src/test/fixtures/workspace/build.xml | 12 +++++++ src/test/fixtures/workspace/deno.json | 3 +- .../fixtures/workspace/docker-compose.yml | 18 +++++++++++ src/test/fixtures/workspace/docs/empty.md | 11 +++++++ src/test/fixtures/workspace/justfile | 16 ++++++++++ src/test/fixtures/workspace/package.json | 6 +++- .../fixtures/workspace/scripts/deploy_all.sh | 8 +++++ src/test/fixtures/workspace/scripts/test.ps1 | 25 +++++++++++++++ 9 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 src/test/fixtures/workspace/docs/empty.md create mode 100644 src/test/fixtures/workspace/scripts/deploy_all.sh create mode 100644 src/test/fixtures/workspace/scripts/test.ps1 diff --git a/src/test/fixtures/workspace/Taskfile.yml b/src/test/fixtures/workspace/Taskfile.yml index 98c873c..c9c1560 100644 --- a/src/test/fixtures/workspace/Taskfile.yml +++ b/src/test/fixtures/workspace/Taskfile.yml @@ -1,5 +1,9 @@ version: '3' +# Global variables +vars: + GREETING: Hello + tasks: build: desc: Build the project @@ -8,6 +12,7 @@ tasks: test: desc: Run tests + deps: [build] cmds: - echo "Testing..." @@ -15,3 +20,30 @@ tasks: desc: Run linter cmds: - echo "Linting..." + + deploy: + description: 'Deploy to production' + deps: [build, test] + cmds: + - echo "Deploying..." + + clean: + desc: "Clean build artifacts" + vars: + OUTPUT_DIR: dist + cmds: + - echo "Cleaning {{.OUTPUT_DIR}}..." + + format: + cmds: + - echo "Formatting..." + + generate: + desc: + cmds: + - echo "Generating..." + + setup-env: + desc: Setup development environment + cmds: + - echo "Setting up..." diff --git a/src/test/fixtures/workspace/build.xml b/src/test/fixtures/workspace/build.xml index e8ed151..8a0d92a 100644 --- a/src/test/fixtures/workspace/build.xml +++ b/src/test/fixtures/workspace/build.xml @@ -21,4 +21,16 @@ <target name="" description="Empty name should be skipped"> <echo message="Skip" /> </target> + + <target name='package' description='Create distribution package'> + <echo message="Packaging..." /> + </target> + + <target name="validate" description=""> + <echo message="Validating..." /> + </target> + + <target name="init"> + <echo message="Initializing..." /> + </target> </project> diff --git a/src/test/fixtures/workspace/deno.json b/src/test/fixtures/workspace/deno.json index 43b22be..0ec1d10 100644 --- a/src/test/fixtures/workspace/deno.json +++ b/src/test/fixtures/workspace/deno.json @@ -5,6 +5,7 @@ "test": "deno test", "lint": "deno lint", "fmt": "deno fmt", - "check": "deno check main.ts && deno test --coverage && deno lint --compact && echo done with a very long command that should trigger truncation" + "check": "deno check main.ts && deno test --coverage && deno lint --compact && echo done with a very long command that should trigger truncation", + "bench": "deno bench --allow-read --allow-write --allow-net --allow-env --unstable-kv benchmarks/main_bench.ts" } } diff --git a/src/test/fixtures/workspace/docker-compose.yml b/src/test/fixtures/workspace/docker-compose.yml index acc7de1..c0ef532 100644 --- a/src/test/fixtures/workspace/docker-compose.yml +++ b/src/test/fixtures/workspace/docker-compose.yml @@ -11,5 +11,23 @@ services: environment: POSTGRES_PASSWORD: secret + # Cache service redis: image: redis:7 + + app-1: + build: ./app + depends_on: + - db + - redis + + cache_store: + image: memcached:latest + ports: + - "11211:11211" + + # Worker service for background jobs + worker_2: + build: ./worker + environment: + QUEUE: default diff --git a/src/test/fixtures/workspace/docs/empty.md b/src/test/fixtures/workspace/docs/empty.md new file mode 100644 index 0000000..3510097 --- /dev/null +++ b/src/test/fixtures/workspace/docs/empty.md @@ -0,0 +1,11 @@ +--- +title: Empty Document +--- + +```bash +echo "This is just a code block" +``` + +```python +print("Another code block") +``` diff --git a/src/test/fixtures/workspace/justfile b/src/test/fixtures/workspace/justfile index 3560739..6d6b2dd 100644 --- a/src/test/fixtures/workspace/justfile +++ b/src/test/fixtures/workspace/justfile @@ -21,3 +21,19 @@ lint fix="false": # Format source code format: echo "Formatting..." + +# Run benchmarks with options +bench iterations="100" output="json": + echo "Running {{iterations}} benchmarks as {{output}}" + +# Generate documentation +docs src dst="./output": + echo "Generating docs from {{src}} to {{dst}}" + +# Hidden helper +_setup-deps: + echo "Installing dependencies..." + +# Check all the things +check: + echo "Checking..." diff --git a/src/test/fixtures/workspace/package.json b/src/test/fixtures/workspace/package.json index 5703a1a..540d4d6 100644 --- a/src/test/fixtures/workspace/package.json +++ b/src/test/fixtures/workspace/package.json @@ -5,6 +5,10 @@ "build": "echo 'Building main project'", "test": "echo 'Running tests'", "lint": "echo 'Linting code'", - "start": "echo 'Starting application'" + "start": "echo 'Starting application'", + "clean": "rimraf dist coverage .cache", + "prebuild": "npm run clean", + "postbuild": "echo 'Build complete'", + "bench": "vitest bench --reporter=verbose --outputFile=benchmark-results.json --coverage --run --passWithNoTests && echo done" } } diff --git a/src/test/fixtures/workspace/scripts/deploy_all.sh b/src/test/fixtures/workspace/scripts/deploy_all.sh new file mode 100644 index 0000000..923fcb1 --- /dev/null +++ b/src/test/fixtures/workspace/scripts/deploy_all.sh @@ -0,0 +1,8 @@ +#!/bin/bash +# Deploy all services to the target cluster +# @param cluster Target cluster +# +# This script handles full deployment +# @param region AWS region (default: us-east-1) + +echo "Deploying to $1 in $2" diff --git a/src/test/fixtures/workspace/scripts/test.ps1 b/src/test/fixtures/workspace/scripts/test.ps1 new file mode 100644 index 0000000..c60bf32 --- /dev/null +++ b/src/test/fixtures/workspace/scripts/test.ps1 @@ -0,0 +1,25 @@ +<# +.SYNOPSIS + Run all test suites + +.PARAMETER TestSuite + The test suite to run + +.PARAMETER Verbose + Enable verbose logging +#> + +# @param TestSuite The suite to run (default: unit) +# @param +# @param Timeout Max seconds to wait +# Regular comment that is not a param +# Another regular comment + +param( + $TestSuite = "unit", + $Timeout, + $Verbose +) + +# This is a regular comment +Write-Host "Running $TestSuite tests with timeout $Timeout" From 502555c439afa3c0f1d01d46374870432ee31c1c Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Mon, 6 Apr 2026 11:39:56 +1000 Subject: [PATCH 23/25] Remove unused fixture files that broke markdown folder grouping The extra docs/api-reference.md and docs/empty.md fixture files caused the docs/ directory to have 3 markdown files, triggering folder node wrapping in the tree view. This made guide.md unreachable by the markdown E2E tests which only check immediate category children. --- src/test/fixtures/workspace/docs/api-reference.md | 7 ------- src/test/fixtures/workspace/docs/empty.md | 11 ----------- 2 files changed, 18 deletions(-) delete mode 100644 src/test/fixtures/workspace/docs/api-reference.md delete mode 100644 src/test/fixtures/workspace/docs/empty.md diff --git a/src/test/fixtures/workspace/docs/api-reference.md b/src/test/fixtures/workspace/docs/api-reference.md deleted file mode 100644 index 89eae82..0000000 --- a/src/test/fixtures/workspace/docs/api-reference.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -title: API Reference ---- - -## Endpoints - -This is a long description that exceeds the maximum character limit for markdown descriptions which should trigger the truncation branch in the markdown parser. It needs to be over one hundred and fifty characters long to actually trigger the truncation logic so here is some additional padding text to make it sufficiently long. diff --git a/src/test/fixtures/workspace/docs/empty.md b/src/test/fixtures/workspace/docs/empty.md deleted file mode 100644 index 3510097..0000000 --- a/src/test/fixtures/workspace/docs/empty.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -title: Empty Document ---- - -```bash -echo "This is just a code block" -``` - -```python -print("Another code block") -``` From 37e0e5835b4480af490056b4c00d27aa57814caf Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Mon, 6 Apr 2026 17:02:05 +1000 Subject: [PATCH 24/25] fix --- src/test/e2e/markdown.e2e.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/test/e2e/markdown.e2e.test.ts b/src/test/e2e/markdown.e2e.test.ts index 046c2ea..7c690a4 100644 --- a/src/test/e2e/markdown.e2e.test.ts +++ b/src/test/e2e/markdown.e2e.test.ts @@ -134,14 +134,14 @@ suite("Markdown Discovery and Preview E2E Tests", () => { assert.ok(readmeItem !== undefined && isCommandItem(readmeItem.data), "Should find README.md with task"); - const initialEditorCount = vscode.window.visibleTextEditors.length; + const initialTabCount = vscode.window.tabGroups.all.flatMap((g) => g.tabs).length; await vscode.commands.executeCommand("commandtree.openPreview", readmeItem); await sleep(2000); - const finalEditorCount = vscode.window.visibleTextEditors.length; - assert.ok(finalEditorCount >= initialEditorCount, "Preview should open a new editor or reuse existing"); + const finalTabCount = vscode.window.tabGroups.all.flatMap((g) => g.tabs).length; + assert.ok(finalTabCount > initialTabCount, "Preview should open a new tab"); }); test("run command on markdown item opens preview", async function () { @@ -159,14 +159,14 @@ suite("Markdown Discovery and Preview E2E Tests", () => { assert.ok(guideItem !== undefined && isCommandItem(guideItem.data), "Should find guide.md with task"); - const initialEditorCount = vscode.window.visibleTextEditors.length; + const initialTabCount = vscode.window.tabGroups.all.flatMap((g) => g.tabs).length; await vscode.commands.executeCommand("commandtree.run", guideItem); await sleep(2000); - const finalEditorCount = vscode.window.visibleTextEditors.length; - assert.ok(finalEditorCount >= initialEditorCount, "Running markdown item should open preview"); + const finalTabCount = vscode.window.tabGroups.all.flatMap((g) => g.tabs).length; + assert.ok(finalTabCount > initialTabCount, "Running markdown item should open preview"); // Verify markdown uses preview, not terminal (exercises TaskRunner.runMarkdownPreview routing) const markdownTerminals = vscode.window.terminals.filter((t) => t.name.includes("guide.md")); From 70b3cf9b9ce57b4146994e5fb61758dd6e9a6484 Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Mon, 6 Apr 2026 18:12:49 +1000 Subject: [PATCH 25/25] fix --- src/runners/TaskRunner.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/runners/TaskRunner.ts b/src/runners/TaskRunner.ts index 9ea8b6c..0ccb383 100644 --- a/src/runners/TaskRunner.ts +++ b/src/runners/TaskRunner.ts @@ -123,9 +123,11 @@ export class TaskRunner { /** * Opens a markdown file in preview mode. + * Uses showPreviewToSide so each run reliably opens a dedicated preview tab + * instead of reusing an unlocked preview already open for another file. */ private async runMarkdownPreview(task: CommandItem): Promise<void> { - await vscode.commands.executeCommand("markdown.showPreview", vscode.Uri.file(task.filePath)); + await vscode.commands.executeCommand("markdown.showPreviewToSide", vscode.Uri.file(task.filePath)); } /**